diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..ccdc66f
Binary files /dev/null and b/.DS_Store differ
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8787f7d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,72 @@
+# Contributing
+
+Thanks for your interest in contributing to this repository!
+
+## What This Is
+
+This repo archives the **leaked source code** of Anthropic's Claude Code CLI. Contributions here are about **documentation, tooling, and exploration aids** — not modifying the original Claude Code source.
+
+## What You Can Contribute
+
+- **Documentation** — Improve or expand the [docs/](docs/) directory
+- **MCP Server** — Enhance the exploration MCP server in [mcp-server/](mcp-server/)
+- **Analysis** — Write-ups, architecture diagrams, or annotated walkthroughs
+- **Tooling** — Scripts or tools that aid in studying the source code
+- **Bug fixes** — Fix issues in the MCP server or supporting infrastructure
+
+## What Not to Change
+
+- **`src/` directory** — This is the original leaked source, preserved as-is. Don't modify it.
+- The [`backup` branch](https://github.com/codeaashu/claude-code/tree/backup) contains the unmodified original.
+
+## Getting Started
+
+### Prerequisites
+
+- **Node.js** 18+ (for the MCP server)
+- **Git**
+
+### Setup
+
+```bash
+git clone https://github.com/codeaashu/claude-code.git
+cd claude-code
+```
+
+### MCP Server Development
+
+```bash
+cd mcp-server
+npm install
+npm run dev # Run with tsx (no build step)
+npm run build # Compile to dist/
+```
+
+### Linting & Type Checking
+
+```bash
+# From the repo root — checks the leaked src/
+npm run lint # Biome lint
+npm run typecheck # TypeScript type check
+```
+
+## Code Style
+
+For any new code (MCP server, tooling, scripts):
+
+- TypeScript with strict mode
+- ES modules
+- 2-space indentation (tabs for `src/` to match Biome config)
+- Descriptive variable names, minimal comments
+
+## Submitting Changes
+
+1. Fork the repository
+2. Create a feature branch (`git checkout -b my-feature`)
+3. Make your changes
+4. Commit with a clear message
+5. Push and open a pull request
+
+## Questions?
+
+Open an issue or reach out to [nichxbt](https://www.x.com/nichxbt).
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6f5a111
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,45 @@
+# ─────────────────────────────────────────────────────────────
+# Claude Code CLI — Production Container
+# ─────────────────────────────────────────────────────────────
+# Multi-stage build: builds a production bundle, then copies
+# only the output into a minimal runtime image.
+#
+# Usage:
+# docker build -t claude-code .
+# docker run --rm -e ANTHROPIC_API_KEY=sk-... claude-code -p "hello"
+# ─────────────────────────────────────────────────────────────
+
+# Stage 1: Build
+FROM oven/bun:1-alpine AS builder
+
+WORKDIR /app
+
+# Copy manifests first for layer caching
+COPY package.json bun.lockb* ./
+
+# Install all dependencies (including devDependencies for build)
+RUN bun install --frozen-lockfile || bun install
+
+# Copy source
+COPY . .
+
+# Build production bundle
+RUN bun run build:prod
+
+# Stage 2: Runtime
+FROM oven/bun:1-alpine
+
+WORKDIR /app
+
+# Install OS-level runtime dependencies
+RUN apk add --no-cache git ripgrep
+
+# Copy only the bundled output from the builder
+COPY --from=builder /app/dist/cli.mjs /app/cli.mjs
+
+# Make it executable
+RUN chmod +x /app/cli.mjs
+
+ENTRYPOINT ["bun", "/app/cli.mjs"]
+
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1958a9c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,11 @@
+UNLICENSED — NOT FOR REDISTRIBUTION
+
+This repository contains leaked proprietary source code belonging to Anthropic, PBC.
+It is published here strictly for educational and research purposes.
+
+The original software is NOT open-source. Anthropic has not released this code
+under any permissive or copyleft license. Use at your own legal risk.
+
+For the official Claude Code CLI, see: https://docs.anthropic.com/en/docs/claude-code
+
+
diff --git a/README.md b/README.md
index 984db55..14b45be 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,447 @@
-# claude-code
-Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
+
+
+# Claude Code — Leaked Source
+
+**The full source code of Anthropic's Claude Code CLI, leaked on March 31, 2026**
+
+[](#tech-stack)
+[](#tech-stack)
+[](#tech-stack)
+[](#directory-structure)
+[](#-explore-with-mcp-server)
+[](https://www.npmjs.com/package/claude-code-explorer-mcp)
+
+> The original unmodified leaked source is preserved in the [`backup` branch](https://github.com/codeaashu/claude-code/tree/backup).
+
+
+
+---
+
+## Table of Contents
+
+- [How It Leaked](#how-it-leaked)
+- [What Is Claude Code?](#what-is-claude-code)
+- [Documentation](#-documentation)
+- [Explore with MCP Server](#-explore-with-mcp-server)
+- [Directory Structure](#directory-structure)
+- [Architecture](#architecture)
+ - [Tool System](#1-tool-system)
+ - [Command System](#2-command-system)
+ - [Service Layer](#3-service-layer)
+ - [Bridge System](#4-bridge-system)
+ - [Permission System](#5-permission-system)
+ - [Feature Flags](#6-feature-flags)
+- [Key Files](#key-files)
+- [Tech Stack](#tech-stack)
+- [Design Patterns](#design-patterns)
+- [GitPretty Setup](#gitpretty-setup)
+- [Contributing](#contributing)
+- [Disclaimer](#disclaimer)
+
+---
+
+## How It Leaked
+
+[Chaofan Shou (@Fried_rice)](https://x.com/Fried_rice) discovered that the published npm package for Claude Code included a `.map` file referencing the full, unobfuscated TypeScript source — downloadable as a zip from Anthropic's R2 storage bucket.
+
+> **"Claude code source code has been leaked via a map file in their npm registry!"**
+>
+> — [@Fried_rice, March 31, 2026](https://x.com/Fried_rice/status/2038894956459290963)
+
+---
+
+## What Is Claude Code?
+
+Claude Code is Anthropic's official CLI tool for interacting with Claude directly from the terminal — editing files, running commands, searching codebases, managing git workflows, and more. This repository contains the leaked `src/` directory.
+
+| | |
+|---|---|
+| **Leaked** | 2026-03-31 |
+| **Language** | TypeScript (strict) |
+| **Runtime** | [Bun](https://bun.sh) |
+| **Terminal UI** | [React](https://react.dev) + [Ink](https://github.com/vadimdemedes/ink) |
+| **Scale** | ~1,900 files · 512,000+ lines of code |
+
+---
+
+## � Documentation
+
+For in-depth guides, see the [`docs/`](docs/) directory:
+
+| Guide | Description |
+|-------|-------------|
+| **[Architecture](docs/architecture.md)** | Core pipeline, startup sequence, state management, rendering, data flow |
+| **[Tools Reference](docs/tools.md)** | Complete catalog of all ~40 agent tools with categories and permission model |
+| **[Commands Reference](docs/commands.md)** | All ~85 slash commands organized by category |
+| **[Subsystems Guide](docs/subsystems.md)** | Deep dives into Bridge, MCP, Permissions, Plugins, Skills, Tasks, Memory, Voice |
+| **[Exploration Guide](docs/exploration-guide.md)** | How to navigate the codebase — study paths, grep patterns, key files |
+
+Also see: [CONTRIBUTING.md](CONTRIBUTING.md) · [MCP Server README](mcp-server/README.md)
+
+---
+
+## �🔍 Explore with MCP Server
+
+This repo ships an [MCP server](https://modelcontextprotocol.io/) that lets any MCP-compatible client (Claude Code, Claude Desktop, VS Code Copilot, Cursor) explore the full source interactively.
+
+### Install from npm
+
+The MCP server is published as [`claude-code-explorer-mcp`](https://www.npmjs.com/package/claude-code-explorer-mcp) on npm — no need to clone the repo:
+
+```bash
+# Claude Code
+claude mcp add claude-code-explorer -- npx -y claude-code-explorer-mcp
+```
+
+### One-liner setup (from source)
+
+```bash
+git clone https://github.com/codeaashu/claude-code.git ~/claude-code \
+ && cd ~/claude-code/mcp-server \
+ && npm install && npm run build \
+ && claude mcp add claude-code-explorer -- node ~/claude-code/mcp-server/dist/index.js
+```
+
+
+Step-by-step setup
+
+```bash
+# 1. Clone the repo
+git clone https://github.com/codeaashu/claude-code.git
+cd claude-code/mcp-server
+
+# 2. Install & build
+npm install && npm run build
+
+# 3. Register with Claude Code
+claude mcp add claude-code-explorer -- node /absolute/path/to/claude-code/mcp-server/dist/index.js
+```
+
+Replace `/absolute/path/to/claude-code` with your actual clone path.
+
+
+
+
+VS Code / Cursor / Claude Desktop config
+
+**VS Code** — add to `.vscode/mcp.json`:
+```json
+{
+ "servers": {
+ "claude-code-explorer": {
+ "type": "stdio",
+ "command": "node",
+ "args": ["${workspaceFolder}/mcp-server/dist/index.js"],
+ "env": { "CLAUDE_CODE_SRC_ROOT": "${workspaceFolder}/src" }
+ }
+ }
+}
+```
+
+**Claude Desktop** — add to your config file:
+```json
+{
+ "mcpServers": {
+ "claude-code-explorer": {
+ "command": "node",
+ "args": ["/absolute/path/to/claude-code/mcp-server/dist/index.js"],
+ "env": { "CLAUDE_CODE_SRC_ROOT": "/absolute/path/to/claude-code/src" }
+ }
+ }
+}
+```
+
+**Cursor** — add to `~/.cursor/mcp.json` (same format as Claude Desktop).
+
+
+
+### Available tools & prompts
+
+| Tool | Description |
+|------|-------------|
+| `list_tools` | List all ~40 agent tools with source files |
+| `list_commands` | List all ~50 slash commands with source files |
+| `get_tool_source` | Read full source of any tool (e.g. BashTool, FileEditTool) |
+| `get_command_source` | Read source of any slash command (e.g. review, mcp) |
+| `read_source_file` | Read any file from `src/` by path |
+| `search_source` | Grep across the entire source tree |
+| `list_directory` | Browse `src/` directories |
+| `get_architecture` | High-level architecture overview |
+
+| Prompt | Description |
+|--------|-------------|
+| `explain_tool` | Deep-dive into how a specific tool works |
+| `explain_command` | Understand a slash command's implementation |
+| `architecture_overview` | Guided tour of the full architecture |
+| `how_does_it_work` | Explain any subsystem (permissions, MCP, bridge, etc.) |
+| `compare_tools` | Side-by-side comparison of two tools |
+
+**Try asking:** *"How does the BashTool work?"* · *"Search for where permissions are checked"* · *"Show me the /review command source"*
+
+### Custom source path / Remove
+
+```bash
+# Custom source location
+claude mcp add claude-code-explorer -e CLAUDE_CODE_SRC_ROOT=/path/to/src -- node /path/to/mcp-server/dist/index.js
+
+# Remove
+claude mcp remove claude-code-explorer
+```
+
+---
+
+## Directory Structure
+
+```
+src/
+├── main.tsx # Entrypoint — Commander.js CLI parser + React/Ink renderer
+├── QueryEngine.ts # Core LLM API caller (~46K lines)
+├── Tool.ts # Tool type definitions (~29K lines)
+├── commands.ts # Command registry (~25K lines)
+├── tools.ts # Tool registry
+├── context.ts # System/user context collection
+├── cost-tracker.ts # Token cost tracking
+│
+├── tools/ # Agent tool implementations (~40)
+├── commands/ # Slash command implementations (~50)
+├── components/ # Ink UI components (~140)
+├── services/ # External service integrations
+├── hooks/ # React hooks (incl. permission checks)
+├── types/ # TypeScript type definitions
+├── utils/ # Utility functions
+├── screens/ # Full-screen UIs (Doctor, REPL, Resume)
+│
+├── bridge/ # IDE integration (VS Code, JetBrains)
+├── coordinator/ # Multi-agent orchestration
+├── plugins/ # Plugin system
+├── skills/ # Skill system
+├── server/ # Server mode
+├── remote/ # Remote sessions
+├── memdir/ # Persistent memory directory
+├── tasks/ # Task management
+├── state/ # State management
+│
+├── voice/ # Voice input
+├── vim/ # Vim mode
+├── keybindings/ # Keybinding configuration
+├── schemas/ # Config schemas (Zod)
+├── migrations/ # Config migrations
+├── entrypoints/ # Initialization logic
+├── query/ # Query pipeline
+├── ink/ # Ink renderer wrapper
+├── buddy/ # Companion sprite (Easter egg 🐣)
+├── native-ts/ # Native TypeScript utils
+├── outputStyles/ # Output styling
+└── upstreamproxy/ # Proxy configuration
+```
+
+---
+
+## Architecture
+
+### 1. Tool System
+
+> `src/tools/` — Every tool Claude can invoke is a self-contained module with its own input schema, permission model, and execution logic.
+
+| Tool | Description |
+|---|---|
+| **File I/O** | |
+| `FileReadTool` | Read files (images, PDFs, notebooks) |
+| `FileWriteTool` | Create / overwrite files |
+| `FileEditTool` | Partial modification (string replacement) |
+| `NotebookEditTool` | Jupyter notebook editing |
+| **Search** | |
+| `GlobTool` | File pattern matching |
+| `GrepTool` | ripgrep-based content search |
+| `WebSearchTool` | Web search |
+| `WebFetchTool` | Fetch URL content |
+| **Execution** | |
+| `BashTool` | Shell command execution |
+| `SkillTool` | Skill execution |
+| `MCPTool` | MCP server tool invocation |
+| `LSPTool` | Language Server Protocol integration |
+| **Agents & Teams** | |
+| `AgentTool` | Sub-agent spawning |
+| `SendMessageTool` | Inter-agent messaging |
+| `TeamCreateTool` / `TeamDeleteTool` | Team management |
+| `TaskCreateTool` / `TaskUpdateTool` | Task management |
+| **Mode & State** | |
+| `EnterPlanModeTool` / `ExitPlanModeTool` | Plan mode toggle |
+| `EnterWorktreeTool` / `ExitWorktreeTool` | Git worktree isolation |
+| `ToolSearchTool` | Deferred tool discovery |
+| `SleepTool` | Proactive mode wait |
+| `CronCreateTool` | Scheduled triggers |
+| `RemoteTriggerTool` | Remote trigger |
+| `SyntheticOutputTool` | Structured output generation |
+
+### 2. Command System
+
+> `src/commands/` — User-facing slash commands invoked with `/` in the REPL.
+
+| Command | Description | | Command | Description |
+|---|---|---|---|---|
+| `/commit` | Git commit | | `/memory` | Persistent memory |
+| `/review` | Code review | | `/skills` | Skill management |
+| `/compact` | Context compression | | `/tasks` | Task management |
+| `/mcp` | MCP server management | | `/vim` | Vim mode toggle |
+| `/config` | Settings | | `/diff` | View changes |
+| `/doctor` | Environment diagnostics | | `/cost` | Check usage cost |
+| `/login` / `/logout` | Auth | | `/theme` | Change theme |
+| `/context` | Context visualization | | `/share` | Share session |
+| `/pr_comments` | PR comments | | `/resume` | Restore session |
+| `/desktop` | Desktop handoff | | `/mobile` | Mobile handoff |
+
+### 3. Service Layer
+
+> `src/services/` — External integrations and core infrastructure.
+
+| Service | Description |
+|---|---|
+| `api/` | Anthropic API client, file API, bootstrap |
+| `mcp/` | Model Context Protocol connection & management |
+| `oauth/` | OAuth 2.0 authentication |
+| `lsp/` | Language Server Protocol manager |
+| `analytics/` | GrowthBook feature flags & analytics |
+| `plugins/` | Plugin loader |
+| `compact/` | Conversation context compression |
+| `extractMemories/` | Automatic memory extraction |
+| `teamMemorySync/` | Team memory synchronization |
+| `tokenEstimation.ts` | Token count estimation |
+| `policyLimits/` | Organization policy limits |
+| `remoteManagedSettings/` | Remote managed settings |
+
+### 4. Bridge System
+
+> `src/bridge/` — Bidirectional communication layer connecting IDE extensions (VS Code, JetBrains) with the CLI.
+
+Key files: `bridgeMain.ts` (main loop) · `bridgeMessaging.ts` (protocol) · `bridgePermissionCallbacks.ts` (permission callbacks) · `replBridge.ts` (REPL session) · `jwtUtils.ts` (JWT auth) · `sessionRunner.ts` (session execution)
+
+### 5. Permission System
+
+> `src/hooks/toolPermission/` — Checks permissions on every tool invocation.
+
+Prompts the user for approval/denial or auto-resolves based on the configured permission mode: `default`, `plan`, `bypassPermissions`, `auto`, etc.
+
+### 6. Feature Flags
+
+Dead code elimination at build time via Bun's `bun:bundle`:
+
+```typescript
+import { feature } from 'bun:bundle'
+
+const voiceCommand = feature('VOICE_MODE')
+ ? require('./commands/voice/index.js').default
+ : null
+```
+
+Notable flags: `PROACTIVE` · `KAIROS` · `BRIDGE_MODE` · `DAEMON` · `VOICE_MODE` · `AGENT_TRIGGERS` · `MONITOR_TOOL`
+
+---
+
+## Key Files
+
+| File | Lines | Purpose |
+|------|------:|---------|
+| `QueryEngine.ts` | ~46K | Core LLM API engine — streaming, tool loops, thinking mode, retries, token counting |
+| `Tool.ts` | ~29K | Base types/interfaces for all tools — input schemas, permissions, progress state |
+| `commands.ts` | ~25K | Command registration & execution with conditional per-environment imports |
+| `main.tsx` | — | CLI parser + React/Ink renderer; parallelizes MDM, keychain, and GrowthBook on startup |
+
+---
+
+## Tech Stack
+
+| Category | Technology |
+|---|---|
+| Runtime | [Bun](https://bun.sh) |
+| Language | TypeScript (strict) |
+| Terminal UI | [React](https://react.dev) + [Ink](https://github.com/vadimdemedes/ink) |
+| CLI Parsing | [Commander.js](https://github.com/tj/commander.js) (extra-typings) |
+| Schema Validation | [Zod v4](https://zod.dev) |
+| Code Search | [ripgrep](https://github.com/BurntSushi/ripgrep) (via GrepTool) |
+| Protocols | [MCP SDK](https://modelcontextprotocol.io) · LSP |
+| API | [Anthropic SDK](https://docs.anthropic.com) |
+| Telemetry | OpenTelemetry + gRPC |
+| Feature Flags | GrowthBook |
+| Auth | OAuth 2.0 · JWT · macOS Keychain |
+
+---
+
+## Design Patterns
+
+
+Parallel Prefetch — Startup optimization
+
+MDM settings, keychain reads, and API preconnect fire in parallel as side-effects before heavy module evaluation:
+
+```typescript
+// main.tsx
+startMdmRawRead()
+startKeychainPrefetch()
+```
+
+
+
+
+Lazy Loading — Deferred heavy modules
+
+OpenTelemetry (~400KB) and gRPC (~700KB) are loaded via dynamic `import()` only when needed.
+
+
+
+
+Agent Swarms — Multi-agent orchestration
+
+Sub-agents spawn via `AgentTool`, with `coordinator/` handling orchestration. `TeamCreateTool` enables team-level parallel work.
+
+
+
+
+Skill System — Reusable workflows
+
+Defined in `skills/` and executed through `SkillTool`. Users can add custom skills.
+
+
+
+
+Plugin Architecture — Extensibility
+
+Built-in and third-party plugins loaded through the `plugins/` subsystem.
+
+
+
+---
+
+## GitPretty Setup
+
+
+Show per-file emoji commit messages in GitHub's file UI
+
+```bash
+# Apply emoji commits
+bash ./gitpretty-apply.sh .
+
+# Optional: install hooks for future commits
+bash ./gitpretty-apply.sh . --hooks
+
+# Push as usual
+git push origin main
+```
+
+
+
+---
+
+## Contributing
+
+Contributions to documentation, the MCP server, and exploration tooling are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
+
+> **Note:** The `src/` directory is the original leaked source and should not be modified.
+
+---
+
+## Disclaimer
+
+This repository archives source code leaked from Anthropic's npm registry on **2026-03-31**. All original source code is the property of [Anthropic](https://www.anthropic.com). This is not an official release and is not licensed for redistribution. Contact [nichxbt](https://www.x.com/nichxbt) for any comments.
+
+
diff --git a/Skill.md b/Skill.md
new file mode 100644
index 0000000..7847a75
--- /dev/null
+++ b/Skill.md
@@ -0,0 +1,220 @@
+---
+name: claude-code-skill
+description: Development conventions and architecture guide for the Claude Code CLI repository.
+---
+
+# Claude Code — Repository Skill
+
+## Project Overview
+
+Claude Code is Anthropic's CLI tool for interacting with Claude from the terminal. It supports file editing, shell commands, git workflows, code review, multi-agent coordination, IDE integration (VS Code, JetBrains), and Model Context Protocol (MCP).
+
+**Codebase:** ~1,900 files, 512,000+ lines of TypeScript under `src/`.
+
+## Tech Stack
+
+| Component | Technology |
+|------------------|------------------------------------------------|
+| Language | TypeScript (strict mode, ES modules) |
+| Runtime | Bun (JSX support, `bun:bundle` feature flags) |
+| Terminal UI | React + Ink (React for CLI) |
+| CLI Parser | Commander.js (`@commander-js/extra-typings`) |
+| API Client | `@anthropic-ai/sdk` |
+| Validation | Zod v4 |
+| Linter/Formatter | Biome |
+| Analytics | GrowthBook (feature flags & A/B testing) |
+| Protocol | Model Context Protocol (MCP) |
+
+## Architecture
+
+### Directory Map (`src/`)
+
+| Directory | Purpose |
+|------------------|-----------------------------------------------------------------|
+| `commands/` | ~50 slash commands (`/commit`, `/review`, `/config`, etc.) |
+| `tools/` | ~40 agent tools (Bash, FileRead, FileWrite, Glob, Grep, etc.) |
+| `components/` | ~140 Ink/React UI components for terminal rendering |
+| `services/` | External integrations (API, OAuth, MCP, LSP, analytics, plugins)|
+| `bridge/` | Bidirectional IDE communication layer |
+| `state/` | React context + custom store (AppState) |
+| `hooks/` | React hooks (permissions, keybindings, commands, settings) |
+| `types/` | TypeScript type definitions |
+| `utils/` | Utilities (shell, file ops, permissions, config, git) |
+| `screens/` | Full-screen UIs (Doctor, REPL, Resume, Compact) |
+| `skills/` | Bundled skills + skill loader system |
+| `plugins/` | Plugin system (marketplace + bundled plugins) |
+| `coordinator/` | Multi-agent coordination & supervisor logic |
+| `tasks/` | Task management (shell tasks, agent tasks, teammates) |
+| `context/` | React context providers (notifications, stats, FPS) |
+| `memdir/` | Persistent memory system (CLAUDE.md, user/project memory) |
+| `entrypoints/` | Initialization logic, Agent SDK, MCP entry |
+| `voice/` | Voice input/output (STT, keyterms) |
+| `vim/` | Vim mode keybinding support |
+| `schemas/` | Zod configuration schemas |
+| `keybindings/` | Keybinding configuration & resolver |
+| `migrations/` | Config migrations between versions |
+| `outputStyles/` | Output formatting & theming |
+| `query/` | Query pipeline & processing |
+| `server/` | Server/daemon mode |
+| `remote/` | Remote session handling |
+
+### Key Files
+
+| File | Role |
+|---------------------|-----------------------------------------------------|
+| `src/main.tsx` | CLI entry point (Commander parser, startup profiling)|
+| `src/QueryEngine.ts`| Core LLM API caller (streaming, tool-call loops) |
+| `src/Tool.ts` | Tool type definitions & `buildTool` factory |
+| `src/tools.ts` | Tool registry & presets |
+| `src/commands.ts` | Command registry |
+| `src/context.ts` | System/user context collection (git status, memory) |
+| `src/cost-tracker.ts`| Token cost tracking |
+
+### Entry Points & Initialization Sequence
+
+1. `src/main.tsx` — Commander CLI parser, startup profiling
+2. `src/entrypoints/init.ts` — Config, telemetry, OAuth, MDM
+3. `src/entrypoints/cli.tsx` — CLI session orchestration
+4. `src/entrypoints/mcp.ts` — MCP server mode
+5. `src/entrypoints/sdk/` — Agent SDK (programmatic API)
+6. `src/replLauncher.tsx` — REPL session launcher
+
+Startup performs parallel initialization: MDM policy reads, Keychain prefetch, feature flag checks, then core init.
+
+## Patterns & Conventions
+
+### Tool Definition
+
+Each tool lives in `src/tools/{ToolName}/` and uses `buildTool`:
+
+```typescript
+export const MyTool = buildTool({
+ name: 'MyTool',
+ aliases: ['my_tool'],
+ description: 'What this tool does',
+ inputSchema: z.object({
+ param: z.string(),
+ }),
+ async call(args, context, canUseTool, parentMessage, onProgress) {
+ // Execute and return { data: result, newMessages?: [...] }
+ },
+ async checkPermissions(input, context) { /* Permission checks */ },
+ isConcurrencySafe(input) { /* Can run in parallel? */ },
+ isReadOnly(input) { /* Non-destructive? */ },
+ prompt(options) { /* System prompt injection */ },
+ renderToolUseMessage(input, options) { /* UI for invocation */ },
+ renderToolResultMessage(content, progressMessages, options) { /* UI for result */ },
+})
+```
+
+**Directory structure per tool:** `{ToolName}.ts` or `.tsx` (main), `UI.tsx` (rendering), `prompt.ts` (system prompt), plus utility files.
+
+### Command Definition
+
+Commands live in `src/commands/` and follow three types:
+
+- **PromptCommand** — Sends a formatted prompt with injected tools (most commands)
+- **LocalCommand** — Runs in-process, returns text
+- **LocalJSXCommand** — Runs in-process, returns React JSX
+
+```typescript
+const command = {
+ type: 'prompt',
+ name: 'my-command',
+ description: 'What this command does',
+ progressMessage: 'working...',
+ allowedTools: ['Bash(git *)', 'FileRead(*)'],
+ source: 'builtin',
+ async getPromptForCommand(args, context) {
+ return [{ type: 'text', text: '...' }]
+ },
+} satisfies Command
+```
+
+Commands are registered in `src/commands.ts` and invoked via `/command-name` in the REPL.
+
+### Component Structure
+
+- Functional React components with Ink primitives (`Box`, `Text`, `useInput()`)
+- Styled with Chalk for terminal colors
+- React Compiler for optimized re-renders
+- Design system primitives in `src/components/design-system/`
+
+### State Management
+
+- `AppState` via React context + custom store (`src/state/AppStateStore.ts`)
+- Mutable state object passed to tool contexts
+- Selector functions for derived state
+- Change observers in `src/state/onChangeAppState.ts`
+
+### Permission System
+
+- **Modes:** `default` (prompt per operation), `plan` (show plan, ask once), `bypassPermissions` (auto-approve), `auto` (ML classifier)
+- **Rules:** Wildcard patterns — `Bash(git *)`, `FileEdit(/src/*)`
+- Tools implement `checkPermissions()` returning `{ granted: boolean, reason?, prompt? }`
+
+### Feature Flags & Build
+
+Bun's `bun:bundle` feature flags enable dead-code elimination at build time:
+
+```typescript
+import { feature } from 'bun:bundle'
+if (feature('PROACTIVE')) { /* proactive agent tools */ }
+```
+
+Notable flags: `PROACTIVE`, `KAIROS`, `BRIDGE_MODE`, `VOICE_MODE`, `COORDINATOR_MODE`, `DAEMON`, `WORKFLOW_SCRIPTS`.
+
+Some features are also gated via `process.env.USER_TYPE === 'ant'`.
+
+## Naming Conventions
+
+| Element | Convention | Example |
+|-------------|---------------------|----------------------------------|
+| Files | PascalCase (exports) or kebab-case (commands) | `BashTool.tsx`, `commit-push-pr.ts` |
+| Components | PascalCase | `App.tsx`, `PromptInput.tsx` |
+| Types | PascalCase, suffix with Props/State/Context | `ToolUseContext` |
+| Hooks | `use` prefix | `useCanUseTool`, `useSettings` |
+| Constants | SCREAMING_SNAKE_CASE | `MAX_TOKENS`, `DEFAULT_TIMEOUT_MS`|
+
+## Import Practices
+
+- ES modules with `.js` extensions (Bun convention)
+- Lazy imports for circular dependency breaking: `const getModule = () => require('./heavy.js')`
+- Conditional imports via feature flags or `process.env`
+- `biome-ignore` markers for manual import ordering where needed
+
+## Services
+
+| Service | Path | Purpose |
+|--------------------|-------------------------------|-----------------------------------|
+| API | `services/api/` | Anthropic SDK client, file uploads|
+| MCP | `services/mcp/` | MCP client, tool/resource discovery|
+| OAuth | `services/oauth/` | OAuth 2.0 auth flow |
+| LSP | `services/lsp/` | Language Server Protocol manager |
+| Analytics | `services/analytics/` | GrowthBook, telemetry, events |
+| Plugins | `services/plugins/` | Plugin loader, marketplace |
+| Compact | `services/compact/` | Context compression |
+| Policy Limits | `services/policyLimits/` | Org rate limits, quota checking |
+| Remote Settings | `services/remoteManagedSettings/` | Managed settings sync (Enterprise) |
+| Token Estimation | `services/tokenEstimation.ts` | Token count estimation |
+
+## Configuration
+
+**Settings locations:**
+- **Global:** `~/.claude/config.json`, `~/.claude/settings.json`
+- **Project:** `.claude/config.json`, `.claude/settings.json`
+- **System:** macOS Keychain + MDM, Windows Registry + MDM
+- **Managed:** Remote sync for Enterprise users
+
+## Guidelines
+
+1. Read relevant source files before making changes — understand existing patterns first.
+2. Follow the tool/command/component patterns above when adding new ones.
+3. Keep edits minimal and focused — avoid unnecessary refactoring.
+4. Use Zod for all input validation at system boundaries.
+5. Gate experimental features behind `bun:bundle` feature flags or env checks.
+6. Respect the permission system — tools that modify state must implement `checkPermissions()`.
+7. Use lazy imports when adding dependencies that could create circular references.
+8. Update this file as project conventions evolve.
+
+
diff --git a/agent.md b/agent.md
new file mode 100644
index 0000000..d18b48e
--- /dev/null
+++ b/agent.md
@@ -0,0 +1,34 @@
+---
+name: repository-agent
+description: Agent operating guide for claude-code.
+---
+
+# Agent
+
+## Purpose
+Define how an automated coding agent should operate in this repository.
+
+## Core Rules
+- Keep changes small, targeted, and easy to review.
+- Preserve existing command behavior unless a task explicitly asks for a behavior change.
+- Favor existing patterns in `src/commands/`, `src/tools/`, and shared utility modules.
+- Avoid broad refactors while fixing localized issues.
+
+## Workflow
+1. Gather context from relevant files before editing.
+2. Implement the smallest viable change.
+3. Run focused validation (type checks/tests for changed areas).
+4. Summarize what changed and any remaining risks.
+
+## Code Style
+- Match existing TypeScript style and naming in nearby files.
+- Prefer explicit, readable logic over compact clever code.
+- Add brief comments only when logic is not obvious.
+
+## Validation
+- Prefer targeted checks first, then broader checks if needed.
+- If validation cannot run, clearly state what was skipped and why.
+
+## Notes
+- Repository conventions may evolve; update this file when team norms change.
+
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..5f0bc8e
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,49 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "organizeImports": {
+ "enabled": true
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "complexity": {
+ "noExcessiveCognitiveComplexity": "warn"
+ },
+ "correctness": {
+ "noUnusedImports": "warn",
+ "noUnusedVariables": "warn"
+ },
+ "style": {
+ "noNonNullAssertion": "off",
+ "useImportType": "warn"
+ },
+ "suspicious": {
+ "noExplicitAny": "off"
+ }
+ }
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "tab",
+ "indentWidth": 2,
+ "lineWidth": 100
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "single",
+ "semicolons": "asNeeded"
+ }
+ },
+ "json": {
+ "formatter": {
+ "indentStyle": "space",
+ "indentWidth": 2
+ }
+ },
+ "files": {
+ "ignore": ["node_modules", "dist", "*.d.ts"]
+ }
+}
+
+
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..747353e
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,635 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 0,
+ "workspaces": {
+ "": {
+ "name": "@anthropic-ai/claude-code",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "@commander-js/extra-typings": "^13.1.0",
+ "@growthbook/growthbook": "^1.4.0",
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/api-logs": "^0.57.0",
+ "@opentelemetry/core": "^1.30.0",
+ "@opentelemetry/sdk-logs": "^0.57.0",
+ "@opentelemetry/sdk-metrics": "^1.30.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-search": "^0.15.0",
+ "@xterm/addon-unicode11": "^0.8.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/addon-webgl": "^0.18.0",
+ "@xterm/xterm": "^5.5.0",
+ "auto-bind": "^5.0.1",
+ "axios": "^1.7.0",
+ "chalk": "^5.4.0",
+ "chokidar": "^4.0.0",
+ "cli-boxes": "^3.0.0",
+ "code-excerpt": "^4.0.0",
+ "diff": "^7.0.0",
+ "execa": "^9.5.0",
+ "figures": "^6.1.0",
+ "fuse.js": "^7.0.0",
+ "highlight.js": "^11.11.0",
+ "ignore": "^6.0.0",
+ "lodash-es": "^4.17.21",
+ "marked": "^15.0.0",
+ "node-pty": "^1.1.0",
+ "p-map": "^7.0.0",
+ "picomatch": "^4.0.0",
+ "proper-lockfile": "^4.1.2",
+ "qrcode": "^1.5.0",
+ "react": "^19.0.0",
+ "react-reconciler": "^0.31.0",
+ "semver": "^7.6.0",
+ "stack-utils": "^2.0.6",
+ "strip-ansi": "^7.1.0",
+ "supports-hyperlinks": "^3.1.0",
+ "tree-kill": "^1.2.2",
+ "type-fest": "^4.30.0",
+ "undici": "^7.3.0",
+ "usehooks-ts": "^3.1.0",
+ "wrap-ansi": "^9.0.0",
+ "ws": "^8.18.0",
+ "yaml": "^2.6.0",
+ "zod": "^3.24.0",
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.0",
+ "@types/diff": "^7.0.0",
+ "@types/lodash-es": "^4.17.12",
+ "@types/node": "^22.10.0",
+ "@types/picomatch": "^3.0.0",
+ "@types/proper-lockfile": "^4.1.4",
+ "@types/react": "^19.0.0",
+ "@types/semver": "^7.5.8",
+ "@types/stack-utils": "^2.0.3",
+ "@types/ws": "^8.5.0",
+ "esbuild": "^0.25.0",
+ "typescript": "^5.7.0",
+ },
+ },
+ },
+ "packages": {
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
+
+ "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
+
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
+
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
+
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
+
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
+
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
+
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
+
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
+
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
+
+ "@commander-js/extra-typings": ["@commander-js/extra-typings@13.1.0", "", { "peerDependencies": { "commander": "~13.1.0" } }, "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
+ "@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
+
+ "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
+
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+
+ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
+
+ "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
+
+ "@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
+
+ "@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
+
+ "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="],
+
+ "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
+
+ "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
+
+ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
+
+ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
+
+ "@types/diff": ["@types/diff@7.0.2", "", {}, "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q=="],
+
+ "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
+
+ "@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
+
+ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+
+ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
+
+ "@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
+
+ "@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
+
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+
+ "@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
+
+ "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
+
+ "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+ "@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
+
+ "@xterm/addon-search": ["@xterm/addon-search@0.15.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg=="],
+
+ "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.8.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q=="],
+
+ "@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
+
+ "@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="],
+
+ "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
+
+ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
+
+ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+
+ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
+
+ "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
+
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
+
+ "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
+
+ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
+
+ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
+ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
+
+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
+
+ "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
+
+ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
+
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
+ "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
+
+ "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
+
+ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+
+ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
+
+ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
+ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
+
+ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
+
+ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
+ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+
+ "diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
+
+ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
+
+ "dom-mutator": ["dom-mutator@0.6.0", "", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
+ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
+ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+
+ "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
+
+ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
+
+ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
+
+ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
+
+ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
+
+ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
+
+ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
+
+ "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+
+ "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
+
+ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+
+ "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+
+ "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
+
+ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
+ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
+
+ "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
+
+ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
+
+ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
+
+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
+ "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
+ "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
+
+ "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
+
+ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+
+ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
+
+ "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
+
+ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
+ "ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
+
+ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+
+ "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
+
+ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+
+ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+
+ "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
+
+ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
+
+ "marked": ["marked@15.0.12", "", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
+
+ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
+ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+
+ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
+
+ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
+
+ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
+ "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
+
+ "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+
+ "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+
+ "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
+
+ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
+
+ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
+
+ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
+
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+
+ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+
+ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
+
+ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
+
+ "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
+
+ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
+
+ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
+
+ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
+
+ "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
+
+ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
+
+ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
+
+ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
+
+ "react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],
+
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
+ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
+
+ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
+
+ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
+
+ "semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
+
+ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+
+ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
+
+ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+
+ "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
+
+ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+
+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
+ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
+ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="],
+
+ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+
+ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
+ "tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
+
+ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
+
+ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
+
+ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+
+ "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
+
+ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
+ "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
+
+ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
+
+ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
+
+ "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
+
+ "yaml": ["yaml@2.8.3", "", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
+ "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
+
+ "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
+
+ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
+
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
+
+ "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
+ "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
+
+ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
+
+ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+
+ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
+ "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ }
+}
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..74f1c3d
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,4 @@
+# bunfig.toml — Bun configuration for development mode
+# The plugin intercepts `bun:bundle` imports → src/shims/bun-bundle.ts
+
+preload = ["./scripts/bun-plugin-shims.ts"]
diff --git a/docker/.dockerignore b/docker/.dockerignore
new file mode 100644
index 0000000..e278c3f
--- /dev/null
+++ b/docker/.dockerignore
@@ -0,0 +1,35 @@
+# NOTE: Docker reads .dockerignore from the build context root.
+# The canonical copy lives at /.dockerignore — keep both in sync.
+
+# Dependencies (rebuilt in container)
+node_modules
+
+# Git metadata
+.git
+.github
+.gitignore
+
+# Build output (rebuilt in container)
+dist
+
+# Env files — never bake secrets into the image
+.env
+.env.*
+
+# Logs and debug
+*.log
+npm-debug.log*
+bun-debug.log*
+
+# Test artifacts
+coverage
+.nyc_output
+
+# Editor / OS noise
+.DS_Store
+Thumbs.db
+.vscode
+.idea
+
+# Docker context itself
+docker
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..9e72faf
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,83 @@
+# ─────────────────────────────────────────────────────────────
+# Claude Web Terminal — Production Container
+# ─────────────────────────────────────────────────────────────
+# Multi-stage build: compiles node-pty native module and bundles
+# the Claude CLI, then copies artifacts into a slim runtime image.
+#
+# Usage:
+# docker build -f docker/Dockerfile -t claude-web .
+# docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web
+# ─────────────────────────────────────────────────────────────
+
+# ── Stage 1: Build ────────────────────────────────────────────
+FROM oven/bun:1 AS builder
+
+WORKDIR /app
+
+# Build tools required to compile node-pty's native C++ addon
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 make g++ \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy manifests first for layer caching
+COPY package.json bun.lockb* ./
+
+# Install all deps (triggers node-pty native compilation)
+RUN bun install --frozen-lockfile 2>/dev/null || bun install
+
+# Copy source tree
+COPY . .
+
+# Bundle the Claude CLI (produces dist/cli.mjs)
+RUN bun run build:prod
+
+# ── Stage 2: Runtime ──────────────────────────────────────────
+FROM oven/bun:1 AS runtime
+
+WORKDIR /app
+
+# curl for health checks; no build tools needed at runtime
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Non-root user that PTY sessions will run under
+RUN groupadd -r claude && useradd -r -g claude -m -d /home/claude claude
+
+# Compiled node_modules (includes native node-pty binary)
+COPY --from=builder /app/node_modules ./node_modules
+
+# Bundled Claude CLI
+COPY --from=builder /app/dist ./dist
+
+# PTY server source (bun runs TypeScript natively)
+COPY --from=builder /app/src/server ./src/server
+
+# TypeScript config needed for bun's module resolution
+COPY --from=builder /app/tsconfig.json ./tsconfig.json
+
+# Thin wrapper so the PTY server can exec `claude` as a subprocess
+RUN printf '#!/bin/sh\nexec bun /app/dist/cli.mjs "$@"\n' \
+ > /usr/local/bin/claude && chmod +x /usr/local/bin/claude
+
+# Entrypoint script
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+# Allow the claude user to write its config into its home dir
+RUN chown -R claude:claude /home/claude
+
+# ── Defaults ──────────────────────────────────────────────────
+ENV NODE_ENV=production \
+ PORT=3000 \
+ MAX_SESSIONS=5 \
+ CLAUDE_BIN=claude
+
+EXPOSE 3000
+
+USER claude
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD curl -f http://localhost:${PORT:-3000}/health || exit 1
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 0000000..ff16d69
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,28 @@
+services:
+ claude-web:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ ports:
+ - "${PORT:-3000}:3000"
+ environment:
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
+ - AUTH_TOKEN=${AUTH_TOKEN:-}
+ - MAX_SESSIONS=${MAX_SESSIONS:-5}
+ - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
+ volumes:
+ # Persist Claude's config and session data across restarts
+ - claude-data:/home/claude/.claude
+ tmpfs:
+ # PTY processes write temp files here; no persistent storage needed
+ - /tmp:mode=1777
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+volumes:
+ claude-data:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..d95006d
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+set -e
+
+# ── Validate required env vars ────────────────────────────────
+if [ -z "$ANTHROPIC_API_KEY" ]; then
+ echo "ERROR: ANTHROPIC_API_KEY is not set." >&2
+ echo "" >&2
+ echo " docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web" >&2
+ echo "" >&2
+ echo " Or via docker-compose with a .env file:" >&2
+ echo " ANTHROPIC_API_KEY=sk-ant-... docker-compose up" >&2
+ exit 1
+fi
+
+# The API key is forwarded to child PTY processes via process.env,
+# so the claude CLI will pick it up automatically — no config file needed.
+
+echo "Claude Web Terminal starting on port ${PORT:-3000}..."
+if [ -n "$AUTH_TOKEN" ]; then
+ echo " Auth token protection: enabled"
+fi
+if [ -n "$ALLOWED_ORIGINS" ]; then
+ echo " Allowed origins: $ALLOWED_ORIGINS"
+fi
+echo " Max sessions: ${MAX_SESSIONS:-5}"
+
+# Hand off to the PTY WebSocket server
+exec bun /app/src/server/web/pty-server.ts
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..0294cd3
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,224 @@
+# Architecture
+
+> Deep-dive into how Claude Code is structured internally.
+
+---
+
+## High-Level Overview
+
+Claude Code is a terminal-native AI coding assistant built as a single-binary CLI. The architecture follows a pipeline model:
+
+```
+User Input → CLI Parser → Query Engine → LLM API → Tool Execution Loop → Terminal UI
+```
+
+The entire UI layer is built with **React + Ink** (React for the terminal), making it a fully reactive CLI application with components, hooks, state management, and all the patterns you'd expect in a React web app — just rendered to the terminal.
+
+---
+
+## Core Pipeline
+
+### 1. Entrypoint (`src/main.tsx`)
+
+The CLI parser is built with [Commander.js](https://github.com/tj/commander.js) (`@commander-js/extra-typings`). On startup, it:
+
+- Fires parallel prefetch side-effects (MDM settings, Keychain, API preconnect) before heavy module imports
+- Parses CLI arguments and flags
+- Initializes the React/Ink renderer
+- Hands off to the REPL launcher (`src/replLauncher.tsx`)
+
+### 2. Initialization (`src/entrypoints/`)
+
+| File | Role |
+|------|------|
+| `cli.tsx` | CLI session orchestration — the main path from launch to REPL |
+| `init.ts` | Config, telemetry, OAuth, MDM policy initialization |
+| `mcp.ts` | MCP server mode entrypoint (Claude Code as an MCP server) |
+| `sdk/` | Agent SDK — programmatic API for embedding Claude Code |
+
+Startup performs parallel initialization: MDM policy reads, Keychain prefetch, feature flag checks, then core init.
+
+### 3. Query Engine (`src/QueryEngine.ts`, ~46K lines)
+
+The heart of Claude Code. Handles:
+
+- **Streaming responses** from the Anthropic API
+- **Tool-call loops** — when the LLM requests a tool, execute it and feed the result back
+- **Thinking mode** — extended thinking with budget management
+- **Retry logic** — automatic retries with backoff for transient failures
+- **Token counting** — tracks input/output tokens and cost per turn
+- **Context management** — manages conversation history and context windows
+
+### 4. Tool System (`src/Tool.ts` + `src/tools/`)
+
+Every capability Claude can invoke is a **tool**. Each tool is self-contained with:
+
+- **Input schema** (Zod validation)
+- **Permission model** (what needs user approval)
+- **Execution logic** (the actual implementation)
+- **UI components** (how invocation/results render in the terminal)
+
+Tools are registered in `src/tools.ts` and discovered by the Query Engine during tool-call loops.
+
+See [Tools Reference](tools.md) for the complete catalog.
+
+### 5. Command System (`src/commands.ts` + `src/commands/`)
+
+User-facing slash commands (`/commit`, `/review`, `/mcp`, etc.) that can be typed in the REPL. Three types:
+
+| Type | Description | Example |
+|------|-------------|---------|
+| **PromptCommand** | Sends a formatted prompt to the LLM with injected tools | `/review`, `/commit` |
+| **LocalCommand** | Runs in-process, returns plain text | `/cost`, `/version` |
+| **LocalJSXCommand** | Runs in-process, returns React JSX | `/doctor`, `/install` |
+
+Commands are registered in `src/commands.ts` and invoked via `/command-name` in the REPL.
+
+See [Commands Reference](commands.md) for the complete catalog.
+
+---
+
+## State Management
+
+Claude Code uses a **React context + custom store** pattern:
+
+| Component | Location | Purpose |
+|-----------|----------|---------|
+| `AppState` | `src/state/AppStateStore.ts` | Global mutable state object |
+| Context Providers | `src/context/` | React context for notifications, stats, FPS |
+| Selectors | `src/state/` | Derived state functions |
+| Change Observers | `src/state/onChangeAppState.ts` | Side-effects on state changes |
+
+The `AppState` object is passed into tool contexts, giving tools access to conversation history, settings, and runtime state.
+
+---
+
+## UI Layer
+
+### Components (`src/components/`, ~140 components)
+
+- Functional React components using Ink primitives (`Box`, `Text`, `useInput()`)
+- Styled with [Chalk](https://github.com/chalk/chalk) for terminal colors
+- React Compiler enabled for optimized re-renders
+- Design system primitives in `src/components/design-system/`
+
+### Screens (`src/screens/`)
+
+Full-screen UI modes:
+
+| Screen | Purpose |
+|--------|---------|
+| `REPL.tsx` | Main interactive REPL (the default screen) |
+| `Doctor.tsx` | Environment diagnostics (`/doctor`) |
+| `ResumeConversation.tsx` | Session restore (`/resume`) |
+
+### Hooks (`src/hooks/`, ~80 hooks)
+
+Standard React hooks pattern. Notable categories:
+
+- **Permission hooks** — `useCanUseTool`, `src/hooks/toolPermission/`
+- **IDE integration** — `useIDEIntegration`, `useIdeConnectionStatus`, `useDiffInIDE`
+- **Input handling** — `useTextInput`, `useVimInput`, `usePasteHandler`, `useInputBuffer`
+- **Session management** — `useSessionBackgrounding`, `useRemoteSession`, `useAssistantHistory`
+- **Plugin/skill hooks** — `useManagePlugins`, `useSkillsChange`
+- **Notification hooks** — `src/hooks/notifs/` (rate limits, deprecation warnings, etc.)
+
+---
+
+## Configuration & Schemas
+
+### Config Schemas (`src/schemas/`)
+
+Zod v4-based schemas for all configuration:
+
+- User settings
+- Project-level settings
+- Organization/enterprise policies
+- Permission rules
+
+### Migrations (`src/migrations/`)
+
+Handles config format changes between versions — reads old configs and transforms them to the current schema.
+
+---
+
+## Build System
+
+### Bun Runtime
+
+Claude Code runs on [Bun](https://bun.sh) (not Node.js). Key implications:
+
+- Native JSX/TSX support without a transpilation step
+- `bun:bundle` feature flags for dead-code elimination
+- ES modules with `.js` extensions (Bun convention)
+
+### Feature Flags (Dead Code Elimination)
+
+```typescript
+import { feature } from 'bun:bundle'
+
+// Code inside inactive feature flags is completely stripped at build time
+if (feature('VOICE_MODE')) {
+ const voiceCommand = require('./commands/voice/index.js').default
+}
+```
+
+Notable flags:
+
+| Flag | Feature |
+|------|---------|
+| `PROACTIVE` | Proactive agent mode (autonomous actions) |
+| `KAIROS` | Kairos subsystem |
+| `BRIDGE_MODE` | IDE bridge integration |
+| `DAEMON` | Background daemon mode |
+| `VOICE_MODE` | Voice input/output |
+| `AGENT_TRIGGERS` | Triggered agent actions |
+| `MONITOR_TOOL` | Monitoring tool |
+| `COORDINATOR_MODE` | Multi-agent coordinator |
+| `WORKFLOW_SCRIPTS` | Workflow automation scripts |
+
+### Lazy Loading
+
+Heavy modules are deferred via dynamic `import()` until first use:
+
+- OpenTelemetry (~400KB)
+- gRPC (~700KB)
+- Other optional dependencies
+
+---
+
+## Error Handling & Telemetry
+
+### Telemetry (`src/services/analytics/`)
+
+- [GrowthBook](https://www.growthbook.io/) for feature flags and A/B testing
+- [OpenTelemetry](https://opentelemetry.io/) for distributed tracing and metrics
+- Custom event tracking for usage analytics
+
+### Cost Tracking (`src/cost-tracker.ts`)
+
+Tracks token usage and estimated cost per conversation turn. Accessible via the `/cost` command.
+
+### Diagnostics (`/doctor` command)
+
+The `Doctor.tsx` screen runs environment checks: API connectivity, authentication, tool availability, MCP server status, and more.
+
+---
+
+## Concurrency Model
+
+Claude Code uses a **single-threaded event loop** (Bun/Node.js model) with:
+
+- Async/await for I/O operations
+- React's concurrent rendering for UI updates
+- Web Workers or child processes for CPU-intensive tasks (gRPC, etc.)
+- Tool concurrency safety — each tool declares `isConcurrencySafe()` to indicate if it can run in parallel with other tools
+
+---
+
+## See Also
+
+- [Tools Reference](tools.md) — Complete catalog of all 40 agent tools
+- [Commands Reference](commands.md) — Complete catalog of all slash commands
+- [Subsystems Guide](subsystems.md) — Bridge, MCP, permissions, skills, plugins, and more
+- [Exploration Guide](exploration-guide.md) — How to navigate this codebase
diff --git a/docs/bridge.md b/docs/bridge.md
new file mode 100644
index 0000000..f909496
--- /dev/null
+++ b/docs/bridge.md
@@ -0,0 +1,239 @@
+# Bridge Layer (VS Code / JetBrains IDE Integration)
+
+## Architecture Overview
+
+The bridge (`src/bridge/`, ~31 files) connects Claude Code CLI sessions to
+remote IDE extensions (VS Code, JetBrains) and the claude.ai web UI. It is
+gated behind `feature('BRIDGE_MODE')` which defaults to `false`.
+
+### Protocols
+
+The bridge uses **two transport generations**:
+
+| Version | Read Path | Write Path | Negotiation |
+|---------|-----------|------------|-------------|
+| **v1 (env-based)** | WebSocket to Session-Ingress (`ws(s)://.../v1/session_ingress/ws/{sessionId}`) | HTTP POST to Session-Ingress | Environments API poll/ack/dispatch |
+| **v2 (env-less)** | SSE stream via `SSETransport` | `CCRClient` → `/worker/*` endpoints | Direct `POST /v1/code/sessions/{id}/bridge` → worker JWT |
+
+Both wrapped behind `ReplBridgeTransport` interface (`replBridgeTransport.ts`).
+
+The v1 path: register environment → poll for work → acknowledge → spawn session.
+The v2 path: create session → POST `/bridge` for JWT → SSE + CCRClient directly.
+
+### Authentication
+
+1. **OAuth tokens** — claude.ai subscription required (`isClaudeAISubscriber()`)
+2. **JWT** — Session-Ingress tokens (`sk-ant-si-` prefixed) with `exp` claims.
+ `jwtUtils.ts` decodes and schedules proactive refresh before expiry.
+3. **Trusted Device token** — `X-Trusted-Device-Token` header for elevated
+ security tier sessions. Enrolled via `trustedDevice.ts`.
+4. **Environment secret** — base64url-encoded `WorkSecret` containing
+ `session_ingress_token`, `api_base_url`, git sources, auth tokens.
+
+Dev override: `CLAUDE_BRIDGE_OAUTH_TOKEN` and `CLAUDE_BRIDGE_BASE_URL`
+(ant-only, `process.env.USER_TYPE === 'ant'`).
+
+### Message Flow (IDE ↔ CLI)
+
+```
+IDE / claude.ai ──WebSocket/SSE──→ Session-Ingress ──→ CLI (replBridge)
+ ←── POST / CCRClient writes ──── Session-Ingress ←── CLI
+```
+
+**Inbound** (server → CLI):
+- `user` messages (prompts from web UI) → `handleIngressMessage()` → enqueued to REPL
+- `control_request` (initialize, set_model, interrupt, set_permission_mode, set_max_thinking_tokens)
+- `control_response` (permission decisions from IDE)
+
+**Outbound** (CLI → server):
+- `assistant` messages (Claude's responses)
+- `user` messages (echoed for sync)
+- `result` messages (turn completion)
+- System events, tool starts, activities
+
+Dedup: `BoundedUUIDSet` tracks recent posted/inbound UUIDs to reject echoes
+and re-deliveries.
+
+### Lifecycle
+
+1. **Entitlement check**: `isBridgeEnabled()` / `isBridgeEnabledBlocking()` →
+ GrowthBook gate `tengu_ccr_bridge` + OAuth subscriber check
+2. **Session creation**: `createBridgeSession()` → POST to API
+3. **Transport init**: v1 `HybridTransport` or v2 `SSETransport` + `CCRClient`
+4. **Message pump**: Read inbound via transport, write outbound via batch
+5. **Token refresh**: Proactive JWT refresh via `createTokenRefreshScheduler()`
+6. **Teardown**: `teardown()` → flush pending → close transport → archive session
+
+Spawn modes for `claude remote-control`:
+- `single-session`: One session in cwd, bridge tears down when it ends
+- `worktree`: Persistent server, each session gets an isolated git worktree
+- `same-dir`: Persistent server, sessions share cwd
+
+### Key Types
+
+- `BridgeConfig` — Full bridge configuration (dir, auth, URLs, spawn mode, timeouts)
+- `WorkSecret` — Decoded work payload (token, API URL, git sources, MCP config)
+- `SessionHandle` — Running session (kill, activities, stdin, token update)
+- `ReplBridgeHandle` — REPL bridge API (write messages, control requests, teardown)
+- `BridgeState` — `'ready' | 'connected' | 'reconnecting' | 'failed'`
+- `SpawnMode` — `'single-session' | 'worktree' | 'same-dir'`
+
+---
+
+## Feature Gate Analysis
+
+### Must Work (currently works correctly)
+
+The `feature('BRIDGE_MODE')` gate in `src/shims/bun-bundle.ts` defaults to
+`false` (reads `CLAUDE_CODE_BRIDGE_MODE` env var). All critical code paths
+are properly guarded:
+
+| Location | Guard |
+|----------|-------|
+| `src/entrypoints/cli.tsx:112` | `feature('BRIDGE_MODE') && args[0] === 'remote-control'` |
+| `src/main.tsx:2246` | `feature('BRIDGE_MODE') && remoteControlOption !== undefined` |
+| `src/main.tsx:3866` | `if (feature('BRIDGE_MODE'))` (Commander subcommand) |
+| `src/hooks/useReplBridge.tsx:79-88` | All `useAppState` calls gated by `feature('BRIDGE_MODE')` ternary |
+| `src/hooks/useReplBridge.tsx:99` | `useEffect` body gated by `feature('BRIDGE_MODE')` |
+| `src/components/PromptInput/PromptInputFooter.tsx:160` | `if (!feature('BRIDGE_MODE')) return null` |
+| `src/components/Settings/Config.tsx:930` | `feature('BRIDGE_MODE') && isBridgeEnabled()` spread |
+| `src/tools/BriefTool/upload.ts:99` | `if (feature('BRIDGE_MODE'))` |
+| `src/tools/ConfigTool/supportedSettings.ts:153` | `feature('BRIDGE_MODE')` spread |
+
+### Can Defer (full bridge functionality)
+
+All of the following are behind the feature gate and inactive:
+- `runBridgeLoop()` — Full bridge orchestration in `bridgeMain.ts`
+- `initReplBridge()` — REPL bridge initialization
+- `initBridgeCore()` / `initEnvLessBridgeCore()` — Transport negotiation
+- `createBridgeApiClient()` — Environments API calls
+- `BridgeUI` — Bridge status display and QR codes
+- Token refresh scheduling
+- Multi-session management (worktree mode)
+- Permission delegation to IDE
+
+### Won't Break
+
+Static imports of bridge modules from outside `src/bridge/` do NOT crash because:
+
+1. **All bridge files exist** — they're in the repo, so imports resolve.
+2. **No side effects at import time** — bridge modules define functions/types
+ but don't execute bridge logic on import.
+3. **Runtime guards** — Functions like `isBridgeEnabled()` return `false`
+ when `feature('BRIDGE_MODE')` is false. `getReplBridgeHandle()` returns
+ `null`. `useReplBridge` short-circuits via ternary operators.
+
+Files with unguarded static imports (safe because files exist):
+- `src/hooks/useReplBridge.tsx` — imports types and utils from bridge
+- `src/components/Settings/Config.tsx` — imports `isBridgeEnabled` (returns false)
+- `src/components/PromptInput/PromptInputFooter.tsx` — early-returns null
+- `src/tools/SendMessageTool/SendMessageTool.ts` — `getReplBridgeHandle()` returns null
+- `src/tools/BriefTool/upload.ts` — guarded at call site
+- `src/commands/logout/logout.tsx` — `clearTrustedDeviceTokenCache` is a no-op
+
+---
+
+## Bridge Stub
+
+Created `src/bridge/stub.ts` with:
+- `isBridgeAvailable()` → always returns `false`
+- `noopBridgeHandle` — silent no-op `ReplBridgeHandle`
+- `noopBridgeLogger` — silent no-op `BridgeLogger`
+
+Available for any future code that needs a safe fallback when bridge is off.
+
+---
+
+## Bridge Activation (Future Work)
+
+To enable the bridge:
+
+### 1. Environment Variable
+```bash
+export CLAUDE_CODE_BRIDGE_MODE=true
+```
+
+### 2. Authentication Requirements
+- Must be logged in to claude.ai with an active subscription
+ (`isClaudeAISubscriber()` must return `true`)
+- OAuth tokens obtained via `claude auth login` (needs `user:profile` scope)
+- GrowthBook gate `tengu_ccr_bridge` must be enabled for the user's org
+
+### 3. IDE Extension
+- VS Code: Claude Code extension (connects via the bridge's Session-Ingress layer)
+- JetBrains: Similar integration (same protocol)
+- Web: `claude.ai/code?bridge={environmentId}` URL
+
+### 4. Network / Ports
+- **Session-Ingress**: WebSocket (`wss://`) or SSE for reads; HTTPS POST for writes
+- **API base**: Production `api.claude.ai` (configured via OAuth config)
+- Dev overrides: `CLAUDE_BRIDGE_BASE_URL`, localhost uses `ws://` and `/v2/` paths
+- QR code displayed in terminal links to `claude.ai/code?bridge={envId}`
+
+### 5. Running Remote Control
+```bash
+# Single session (tears down when session ends)
+claude remote-control
+
+# Named session
+claude remote-control "my-project"
+
+# With specific spawn mode (requires tengu_ccr_bridge_multi_session gate)
+claude remote-control --spawn worktree
+claude remote-control --spawn same-dir
+```
+
+### 6. Additional Flags
+- `--remote-control [name]` / `--rc [name]` — Start REPL with bridge pre-enabled
+- `--debug-file ` — Write debug log to file
+- `--session-id ` — Resume an existing session
+
+---
+
+## Chrome Extension Bridge
+
+### `--claude-in-chrome-mcp` (cli.tsx:72)
+
+Launches a **Claude-in-Chrome MCP server** via `runClaudeInChromeMcpServer()` from
+`src/utils/claudeInChrome/mcpServer.ts`. This:
+- Creates a `StdioServerTransport` (MCP over stdin/stdout)
+- Uses `@ant/claude-for-chrome-mcp` package to create an MCP server
+- Bridges between Claude Code and the Chrome extension
+- Supports both native socket (local) and WebSocket bridge (`wss://bridge.claudeusercontent.com`)
+- Gated by `tengu_copper_bridge` GrowthBook flag (or `USER_TYPE=ant`)
+
+**Not gated by `feature('BRIDGE_MODE')`** — this is a separate subsystem. It only
+runs when explicitly invoked with `--claude-in-chrome-mcp` flag.
+
+### `--chrome-native-host` (cli.tsx:79)
+
+Launches the **Chrome Native Messaging Host** via `runChromeNativeHost()` from
+`src/utils/claudeInChrome/chromeNativeHost.ts`. This:
+- Implements Chrome's native messaging protocol (4-byte length prefix + JSON over stdin/stdout)
+- Creates a Unix domain socket server at a secure path
+- Proxies MCP messages between Chrome extension and local Claude Code instances
+- Has its own debug logging to `~/.claude/debug/chrome-native-host.txt` (ant-only)
+
+**Not gated by `feature('BRIDGE_MODE')`** — separate entry point. Only activated
+when Chrome calls the registered native messaging host binary.
+
+### Safety
+
+Both Chrome paths:
+- Are **dynamic imports** — only loaded when the specific flag is passed
+- Return immediately after their own `await` — no side effects on normal CLI startup
+- Cannot crash normal operation because they're entirely separate code paths
+- Have no dependency on the bridge feature flag
+
+---
+
+## Verification Summary
+
+| Check | Status |
+|-------|--------|
+| `feature('BRIDGE_MODE')` returns `false` by default | ✅ Verified in `src/shims/bun-bundle.ts` |
+| Bridge code not executed when disabled | ✅ All call sites use `feature()` guard |
+| No bridge-related errors on startup | ✅ Imports resolve (files exist), no side effects |
+| CLI works in terminal-only mode | ✅ Bridge is purely additive |
+| Chrome paths don't crash | ✅ Separate dynamic imports, only on explicit flags |
+| Stub available for safety | ✅ Created `src/bridge/stub.ts` |
diff --git a/docs/commands.md b/docs/commands.md
new file mode 100644
index 0000000..e9cc4fa
--- /dev/null
+++ b/docs/commands.md
@@ -0,0 +1,211 @@
+# Commands Reference
+
+> Complete catalog of all slash commands in Claude Code.
+
+---
+
+## Overview
+
+Commands are user-facing actions invoked with a `/` prefix in the REPL (e.g., `/commit`, `/review`). They live in `src/commands/` and are registered in `src/commands.ts`.
+
+### Command Types
+
+| Type | Description | Example |
+|------|-------------|---------|
+| **PromptCommand** | Sends a formatted prompt to the LLM with injected tools | `/review`, `/commit` |
+| **LocalCommand** | Runs in-process, returns plain text | `/cost`, `/version` |
+| **LocalJSXCommand** | Runs in-process, returns React JSX | `/install`, `/doctor` |
+
+### Command Definition Pattern
+
+```typescript
+const command = {
+ type: 'prompt',
+ name: 'my-command',
+ description: 'What this command does',
+ progressMessage: 'working...',
+ allowedTools: ['Bash(git *)', 'FileRead(*)'],
+ source: 'builtin',
+ async getPromptForCommand(args, context) {
+ return [{ type: 'text', text: '...' }]
+ },
+} satisfies Command
+```
+
+---
+
+## Git & Version Control
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/commit` | `commit.ts` | Create a git commit with an AI-generated message |
+| `/commit-push-pr` | `commit-push-pr.ts` | Commit, push, and create a PR in one step |
+| `/branch` | `branch/` | Create or switch git branches |
+| `/diff` | `diff/` | View file changes (staged, unstaged, or against a ref) |
+| `/pr_comments` | `pr_comments/` | View and address PR review comments |
+| `/rewind` | `rewind/` | Revert to a previous state |
+
+## Code Quality
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/review` | `review.ts` | AI-powered code review of staged/unstaged changes |
+| `/security-review` | `security-review.ts` | Security-focused code review |
+| `/advisor` | `advisor.ts` | Get architectural or design advice |
+| `/bughunter` | `bughunter/` | Find potential bugs in the codebase |
+
+## Session & Context
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/compact` | `compact/` | Compress conversation context to fit more history |
+| `/context` | `context/` | Visualize current context (files, memory, etc.) |
+| `/resume` | `resume/` | Restore a previous conversation session |
+| `/session` | `session/` | Manage sessions (list, switch, delete) |
+| `/share` | `share/` | Share a session via link |
+| `/export` | `export/` | Export conversation to a file |
+| `/summary` | `summary/` | Generate a summary of the current session |
+| `/clear` | `clear/` | Clear the conversation history |
+
+## Configuration & Settings
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/config` | `config/` | View or modify Claude Code settings |
+| `/permissions` | `permissions/` | Manage tool permission rules |
+| `/theme` | `theme/` | Change the terminal color theme |
+| `/output-style` | `output-style/` | Change output formatting style |
+| `/color` | `color/` | Toggle color output |
+| `/keybindings` | `keybindings/` | View or customize keybindings |
+| `/vim` | `vim/` | Toggle vim mode for input |
+| `/effort` | `effort/` | Adjust response effort level |
+| `/model` | `model/` | Switch the active model |
+| `/privacy-settings` | `privacy-settings/` | Manage privacy/data settings |
+| `/fast` | `fast/` | Toggle fast mode (shorter responses) |
+| `/brief` | `brief.ts` | Toggle brief output mode |
+
+## Memory & Knowledge
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/memory` | `memory/` | Manage persistent memory (CLAUDE.md files) |
+| `/add-dir` | `add-dir/` | Add a directory to the project context |
+| `/files` | `files/` | List files in the current context |
+
+## MCP & Plugins
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/mcp` | `mcp/` | Manage MCP server connections |
+| `/plugin` | `plugin/` | Install, remove, or manage plugins |
+| `/reload-plugins` | `reload-plugins/` | Reload all installed plugins |
+| `/skills` | `skills/` | View and manage skills |
+
+## Authentication
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/login` | `login/` | Authenticate with Anthropic |
+| `/logout` | `logout/` | Sign out |
+| `/oauth-refresh` | `oauth-refresh/` | Refresh OAuth tokens |
+
+## Tasks & Agents
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/tasks` | `tasks/` | Manage background tasks |
+| `/agents` | `agents/` | Manage sub-agents |
+| `/ultraplan` | `ultraplan.tsx` | Generate a detailed execution plan |
+| `/plan` | `plan/` | Enter planning mode |
+
+## Diagnostics & Status
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/doctor` | `doctor/` | Run environment diagnostics |
+| `/status` | `status/` | Show system and session status |
+| `/stats` | `stats/` | Show session statistics |
+| `/cost` | `cost/` | Display token usage and estimated cost |
+| `/version` | `version.ts` | Show Claude Code version |
+| `/usage` | `usage/` | Show detailed API usage |
+| `/extra-usage` | `extra-usage/` | Show extended usage details |
+| `/rate-limit-options` | `rate-limit-options/` | View rate limit configuration |
+
+## Installation & Setup
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/install` | `install.tsx` | Install or update Claude Code |
+| `/upgrade` | `upgrade/` | Upgrade to the latest version |
+| `/init` | `init.ts` | Initialize a project (create CLAUDE.md) |
+| `/init-verifiers` | `init-verifiers.ts` | Set up verifier hooks |
+| `/onboarding` | `onboarding/` | Run the first-time setup wizard |
+| `/terminalSetup` | `terminalSetup/` | Configure terminal integration |
+
+## IDE & Desktop Integration
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/bridge` | `bridge/` | Manage IDE bridge connections |
+| `/bridge-kick` | `bridge-kick.ts` | Force-restart the IDE bridge |
+| `/ide` | `ide/` | Open in IDE |
+| `/desktop` | `desktop/` | Hand off to the desktop app |
+| `/mobile` | `mobile/` | Hand off to the mobile app |
+| `/teleport` | `teleport/` | Transfer session to another device |
+
+## Remote & Environment
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/remote-env` | `remote-env/` | Configure remote environment |
+| `/remote-setup` | `remote-setup/` | Set up remote session |
+| `/env` | `env/` | View environment variables |
+| `/sandbox-toggle` | `sandbox-toggle/` | Toggle sandbox mode |
+
+## Misc
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/help` | `help/` | Show help and available commands |
+| `/exit` | `exit/` | Exit Claude Code |
+| `/copy` | `copy/` | Copy content to clipboard |
+| `/feedback` | `feedback/` | Send feedback to Anthropic |
+| `/release-notes` | `release-notes/` | View release notes |
+| `/rename` | `rename/` | Rename the current session |
+| `/tag` | `tag/` | Tag the current session |
+| `/insights` | `insights.ts` | Show codebase insights |
+| `/stickers` | `stickers/` | Easter egg — stickers |
+| `/good-claude` | `good-claude/` | Easter egg — praise Claude |
+| `/voice` | `voice/` | Toggle voice input mode |
+| `/chrome` | `chrome/` | Chrome extension integration |
+| `/issue` | `issue/` | File a GitHub issue |
+| `/statusline` | `statusline.tsx` | Customize the status line |
+| `/thinkback` | `thinkback/` | Replay Claude's thinking process |
+| `/thinkback-play` | `thinkback-play/` | Animated thinking replay |
+| `/passes` | `passes/` | Multi-pass execution |
+| `/x402` | `x402/` | x402 payment protocol integration |
+
+## Internal / Debug Commands
+
+| Command | Source | Description |
+|---------|--------|-------------|
+| `/ant-trace` | `ant-trace/` | Anthropic-internal tracing |
+| `/autofix-pr` | `autofix-pr/` | Auto-fix PR issues |
+| `/backfill-sessions` | `backfill-sessions/` | Backfill session data |
+| `/break-cache` | `break-cache/` | Invalidate caches |
+| `/btw` | `btw/` | "By the way" interjection |
+| `/ctx_viz` | `ctx_viz/` | Context visualization (debug) |
+| `/debug-tool-call` | `debug-tool-call/` | Debug a specific tool call |
+| `/heapdump` | `heapdump/` | Dump heap for memory analysis |
+| `/hooks` | `hooks/` | Manage hook scripts |
+| `/mock-limits` | `mock-limits/` | Mock rate limits for testing |
+| `/perf-issue` | `perf-issue/` | Report performance issues |
+| `/reset-limits` | `reset-limits/` | Reset rate limit counters |
+
+---
+
+## See Also
+
+- [Architecture](architecture.md) — How the command system fits into the pipeline
+- [Tools Reference](tools.md) — Agent tools (different from slash commands)
+- [Exploration Guide](exploration-guide.md) — Finding command source code
diff --git a/docs/exploration-guide.md b/docs/exploration-guide.md
new file mode 100644
index 0000000..b34a077
--- /dev/null
+++ b/docs/exploration-guide.md
@@ -0,0 +1,246 @@
+# Exploration Guide
+
+> How to navigate and study the Claude Code source code.
+
+---
+
+## Quick Start
+
+This is a **read-only reference codebase** — there's no build system or test suite. The goal is to understand how a production AI coding assistant is built.
+
+### Orientation
+
+| What | Where |
+|------|-------|
+| CLI entrypoint | `src/main.tsx` |
+| Core LLM engine | `src/QueryEngine.ts` (~46K lines) |
+| Tool definitions | `src/Tool.ts` (~29K lines) |
+| Command registry | `src/commands.ts` (~25K lines) |
+| Tool registry | `src/tools.ts` |
+| Context collection | `src/context.ts` |
+| All tool implementations | `src/tools/` (40 subdirectories) |
+| All command implementations | `src/commands/` (~85 subdirectories + 15 files) |
+
+---
+
+## Finding Things
+
+### "How does tool X work?"
+
+1. Go to `src/tools/{ToolName}/`
+2. Main implementation is `{ToolName}.ts` or `.tsx`
+3. UI rendering is in `UI.tsx`
+4. System prompt contribution is in `prompt.ts`
+
+Example — understanding BashTool:
+```
+src/tools/BashTool/
+├── BashTool.ts ← Core execution logic
+├── UI.tsx ← How bash output renders in terminal
+├── prompt.ts ← What the system prompt says about bash
+└── ...
+```
+
+### "How does command X work?"
+
+1. Check `src/commands/{command-name}/` (directory) or `src/commands/{command-name}.ts` (file)
+2. Look for the `getPromptForCommand()` function (PromptCommands) or direct implementation (LocalCommands)
+
+### "How does feature X work?"
+
+| Feature | Start Here |
+|---------|-----------|
+| Permissions | `src/hooks/toolPermission/` |
+| IDE bridge | `src/bridge/bridgeMain.ts` |
+| MCP client | `src/services/mcp/` |
+| Plugin system | `src/plugins/` + `src/services/plugins/` |
+| Skills | `src/skills/` |
+| Voice input | `src/voice/` + `src/services/voice.ts` |
+| Multi-agent | `src/coordinator/` |
+| Memory | `src/memdir/` |
+| Authentication | `src/services/oauth/` |
+| Config schemas | `src/schemas/` |
+| State management | `src/state/` |
+
+### "How does an API call flow?"
+
+Trace from user input to API response:
+
+```
+src/main.tsx ← CLI parsing
+ → src/replLauncher.tsx ← REPL session start
+ → src/QueryEngine.ts ← Core engine
+ → src/services/api/ ← Anthropic SDK client
+ → (Anthropic API) ← HTTP/streaming
+ ← Tool use response
+ → src/tools/{ToolName}/ ← Tool execution
+ ← Tool result
+ → (feed back to API) ← Continue the loop
+```
+
+---
+
+## Code Patterns to Recognize
+
+### `buildTool()` — Tool Factory
+
+Every tool uses this pattern:
+
+```typescript
+export const MyTool = buildTool({
+ name: 'MyTool',
+ inputSchema: z.object({ ... }),
+ async call(args, context) { ... },
+ async checkPermissions(input, context) { ... },
+})
+```
+
+### Feature Flag Gates
+
+```typescript
+import { feature } from 'bun:bundle'
+
+if (feature('VOICE_MODE')) {
+ // This code is stripped at build time if VOICE_MODE is off
+}
+```
+
+### Anthropic-Internal Gates
+
+```typescript
+if (process.env.USER_TYPE === 'ant') {
+ // Anthropic employee-only features
+}
+```
+
+### Index Re-exports
+
+Most directories have an `index.ts` that re-exports the public API:
+
+```typescript
+// src/tools/BashTool/index.ts
+export { BashTool } from './BashTool.js'
+```
+
+### Lazy Dynamic Imports
+
+Heavy modules are loaded only when needed:
+
+```typescript
+const { OpenTelemetry } = await import('./heavy-module.js')
+```
+
+### ESM with `.js` Extensions
+
+Bun convention — all imports use `.js` extensions even for `.ts` files:
+
+```typescript
+import { something } from './utils.js' // Actually imports utils.ts
+```
+
+---
+
+## Key Files by Size
+
+The largest files contain the most logic and are worth studying:
+
+| File | Lines | What's Inside |
+|------|-------|---------------|
+| `QueryEngine.ts` | ~46K | Streaming, tool loops, retries, token counting |
+| `Tool.ts` | ~29K | Tool types, `buildTool`, permission models |
+| `commands.ts` | ~25K | Command registry, conditional loading |
+| `main.tsx` | — | CLI parser, startup optimization |
+| `context.ts` | — | OS, shell, git, user context assembly |
+
+---
+
+## Study Paths
+
+### Path 1: "How does a tool work end-to-end?"
+
+1. Read `src/Tool.ts` — understand the `buildTool` interface
+2. Pick a simple tool like `FileReadTool` in `src/tools/FileReadTool/`
+3. Trace how `QueryEngine.ts` calls tools during the tool loop
+4. See how permissions are checked in `src/hooks/toolPermission/`
+
+### Path 2: "How does the UI work?"
+
+1. Read `src/screens/REPL.tsx` — the main screen
+2. Explore `src/components/` — pick a few components
+3. See `src/hooks/useTextInput.ts` — how user input is captured
+4. Check `src/ink/` — the Ink renderer wrapper
+
+### Path 3: "How does the IDE integration work?"
+
+1. Start at `src/bridge/bridgeMain.ts`
+2. Follow `bridgeMessaging.ts` for the message protocol
+3. See `bridgePermissionCallbacks.ts` for how permissions route to the IDE
+4. Check `replBridge.ts` for REPL session bridging
+
+### Path 4: "How do plugins extend Claude Code?"
+
+1. Read `src/types/plugin.ts` — the plugin API surface
+2. See `src/services/plugins/` — how plugins are loaded
+3. Check `src/plugins/builtinPlugins.ts` — built-in examples
+4. Look at `src/plugins/bundled/` — bundled plugin code
+
+### Path 5: "How does MCP work?"
+
+1. Read `src/services/mcp/` — the MCP client
+2. See `src/tools/MCPTool/` — how MCP tools are invoked
+3. Check `src/entrypoints/mcp.ts` — Claude Code as an MCP server
+4. Look at `src/skills/mcpSkillBuilders.ts` — skills from MCP
+
+---
+
+## Using the MCP Server for Exploration
+
+This repo includes a standalone MCP server (`mcp-server/`) that lets any MCP-compatible client explore the source code. See the [MCP Server README](../mcp-server/README.md) for setup.
+
+Once connected, you can ask an AI assistant to explore the source:
+
+- "How does the BashTool work?"
+- "Search for where permissions are checked"
+- "List all files in the bridge directory"
+- "Read QueryEngine.ts lines 1-100"
+
+---
+
+## Grep Patterns
+
+Useful grep/ripgrep patterns for finding things:
+
+```bash
+# Find all tool definitions
+rg "buildTool\(" src/tools/
+
+# Find all command definitions
+rg "satisfies Command" src/commands/
+
+# Find feature flag usage
+rg "feature\(" src/
+
+# Find Anthropic-internal gates
+rg "USER_TYPE.*ant" src/
+
+# Find all React hooks
+rg "^export function use" src/hooks/
+
+# Find all Zod schemas
+rg "z\.object\(" src/schemas/
+
+# Find all system prompt contributions
+rg "prompt\(" src/tools/*/prompt.ts
+
+# Find permission rule patterns
+rg "checkPermissions" src/tools/
+```
+
+---
+
+## See Also
+
+- [Architecture](architecture.md) — Overall system design
+- [Tools Reference](tools.md) — Complete tool catalog
+- [Commands Reference](commands.md) — All slash commands
+- [Subsystems Guide](subsystems.md) — Deep dives into Bridge, MCP, Permissions, etc.
diff --git a/docs/subsystems.md b/docs/subsystems.md
new file mode 100644
index 0000000..4406654
--- /dev/null
+++ b/docs/subsystems.md
@@ -0,0 +1,346 @@
+# Subsystems Guide
+
+> Detailed documentation of Claude Code's major subsystems.
+
+---
+
+## Table of Contents
+
+- [Bridge (IDE Integration)](#bridge-ide-integration)
+- [MCP (Model Context Protocol)](#mcp-model-context-protocol)
+- [Permission System](#permission-system)
+- [Plugin System](#plugin-system)
+- [Skill System](#skill-system)
+- [Task System](#task-system)
+- [Memory System](#memory-system)
+- [Coordinator (Multi-Agent)](#coordinator-multi-agent)
+- [Voice System](#voice-system)
+- [Service Layer](#service-layer)
+
+---
+
+## Bridge (IDE Integration)
+
+**Location:** `src/bridge/`
+
+The bridge is a bidirectional communication layer connecting Claude Code's CLI with IDE extensions (VS Code, JetBrains). It allows the CLI to run as a backend for IDE-based interfaces.
+
+### Architecture
+
+```
+┌──────────────────┐ ┌──────────────────────┐
+│ IDE Extension │◄───────►│ Bridge Layer │
+│ (VS Code, JB) │ JWT │ (src/bridge/) │
+│ │ Auth │ │
+│ - UI rendering │ │ - Session mgmt │
+│ - File watching │ │ - Message routing │
+│ - Diff display │ │ - Permission proxy │
+└──────────────────┘ └──────────┬───────────┘
+ │
+ ▼
+ ┌──────────────────────┐
+ │ Claude Code Core │
+ │ (QueryEngine, Tools) │
+ └──────────────────────┘
+```
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `bridgeMain.ts` | Main bridge loop — starts the bidirectional channel |
+| `bridgeMessaging.ts` | Message protocol (serialize/deserialize) |
+| `bridgePermissionCallbacks.ts` | Routes permission prompts to the IDE |
+| `bridgeApi.ts` | API surface exposed to the IDE |
+| `bridgeConfig.ts` | Bridge configuration |
+| `replBridge.ts` | Connects the REPL session to the bridge |
+| `jwtUtils.ts` | JWT-based authentication between CLI and IDE |
+| `sessionRunner.ts` | Manages bridge session execution |
+| `createSession.ts` | Creates new bridge sessions |
+| `trustedDevice.ts` | Device trust verification |
+| `workSecret.ts` | Workspace-scoped secrets |
+| `inboundMessages.ts` | Handles messages coming from the IDE |
+| `inboundAttachments.ts` | Handles file attachments from the IDE |
+| `types.ts` | TypeScript types for the bridge protocol |
+
+### Feature Flag
+
+The bridge is gated behind the `BRIDGE_MODE` feature flag and is stripped from non-IDE builds.
+
+---
+
+## MCP (Model Context Protocol)
+
+**Location:** `src/services/mcp/`
+
+Claude Code acts as both an **MCP client** (consuming tools/resources from MCP servers) and can run as an **MCP server** (exposing its own tools via `src/entrypoints/mcp.ts`).
+
+### Client Features
+
+- **Tool discovery** — Enumerates tools from connected MCP servers
+- **Resource browsing** — Lists and reads MCP-exposed resources
+- **Dynamic tool loading** — `ToolSearchTool` discovers tools at runtime
+- **Authentication** — `McpAuthTool` handles MCP server auth flows
+- **Connectivity monitoring** — `useMcpConnectivityStatus` hook tracks connection health
+
+### Server Mode
+
+When launched via `src/entrypoints/mcp.ts`, Claude Code exposes its own tools and resources via the MCP protocol, allowing other AI agents to use Claude Code as a tool server.
+
+### Related Tools
+
+| Tool | Purpose |
+|------|---------|
+| `MCPTool` | Invoke tools on connected MCP servers |
+| `ListMcpResourcesTool` | List available MCP resources |
+| `ReadMcpResourceTool` | Read a specific MCP resource |
+| `McpAuthTool` | Authenticate with an MCP server |
+| `ToolSearchTool` | Discover deferred tools from MCP servers |
+
+### Configuration
+
+MCP servers are configured via `/mcp` command or settings files. The server approval flow lives in `src/services/mcpServerApproval.tsx`.
+
+---
+
+## Permission System
+
+**Location:** `src/hooks/toolPermission/`
+
+Every tool invocation passes through a centralized permission check before execution.
+
+### Permission Modes
+
+| Mode | Behavior |
+|------|----------|
+| `default` | Prompts the user for each potentially destructive operation |
+| `plan` | Shows the full execution plan, asks once for batch approval |
+| `bypassPermissions` | Auto-approves all operations (dangerous — for trusted environments) |
+| `auto` | ML-based classifier automatically decides (experimental) |
+
+### How It Works
+
+1. Tool is invoked by the Query Engine
+2. `checkPermissions(input, context)` is called on the tool
+3. Permission handler checks against configured rules
+4. If not auto-approved, user is prompted via terminal or IDE
+
+### Permission Rules
+
+Rules use wildcard patterns to match tool invocations:
+
+```
+Bash(git *) # Allow all git commands without prompt
+Bash(npm test) # Allow 'npm test' specifically
+FileEdit(/src/*) # Allow edits to anything under src/
+FileRead(*) # Allow reading any file
+```
+
+### Key Files
+
+| File | Path |
+|------|------|
+| Permission context | `src/hooks/toolPermission/PermissionContext.ts` |
+| Permission handlers | `src/hooks/toolPermission/handlers/` |
+| Permission logging | `src/hooks/toolPermission/permissionLogging.ts` |
+| Permission types | `src/types/permissions.ts` |
+
+---
+
+## Plugin System
+
+**Location:** `src/plugins/`, `src/services/plugins/`
+
+Claude Code supports installable plugins that can extend its capabilities.
+
+### Structure
+
+| Component | Location | Purpose |
+|-----------|----------|---------|
+| Plugin loader | `src/services/plugins/` | Discovers and loads plugins |
+| Built-in plugins | `src/plugins/builtinPlugins.ts` | Plugins that ship with Claude Code |
+| Bundled plugins | `src/plugins/bundled/` | Plugin code bundled into the binary |
+| Plugin types | `src/types/plugin.ts` | TypeScript types for plugin API |
+
+### Plugin Lifecycle
+
+1. **Discovery** — Scans plugin directories and marketplace
+2. **Installation** — Downloaded and registered (`/plugin` command)
+3. **Loading** — Initialized at startup or on-demand
+4. **Execution** — Plugins can contribute tools, commands, and prompts
+5. **Auto-update** — `usePluginAutoupdateNotification` handles updates
+
+### Related Commands
+
+| Command | Purpose |
+|---------|---------|
+| `/plugin` | Install, remove, or manage plugins |
+| `/reload-plugins` | Reload all installed plugins |
+
+---
+
+## Skill System
+
+**Location:** `src/skills/`
+
+Skills are reusable, named workflows that bundle prompts and tool configurations for specific tasks.
+
+### Structure
+
+| Component | Location | Purpose |
+|-----------|----------|---------|
+| Bundled skills | `src/skills/bundled/` | Skills that ship with Claude Code |
+| Skill loader | `src/skills/loadSkillsDir.ts` | Loads skills from disk |
+| MCP skill builders | `src/skills/mcpSkillBuilders.ts` | Creates skills from MCP resources |
+| Skill registry | `src/skills/bundledSkills.ts` | Registration of all bundled skills |
+
+### Bundled Skills (16)
+
+| Skill | Purpose |
+|-------|---------|
+| `batch` | Batch operations across multiple files |
+| `claudeApi` | Direct Anthropic API interaction |
+| `claudeInChrome` | Chrome extension integration |
+| `debug` | Debugging workflows |
+| `keybindings` | Keybinding configuration |
+| `loop` | Iterative refinement loops |
+| `loremIpsum` | Generate placeholder text |
+| `remember` | Persist information to memory |
+| `scheduleRemoteAgents` | Schedule agents for remote execution |
+| `simplify` | Simplify complex code |
+| `skillify` | Create new skills from workflows |
+| `stuck` | Get unstuck when blocked |
+| `updateConfig` | Modify configuration programmatically |
+| `verify` / `verifyContent` | Verify code correctness |
+
+### Execution
+
+Skills are invoked via the `SkillTool` or the `/skills` command. Users can also create custom skills.
+
+---
+
+## Task System
+
+**Location:** `src/tasks/`
+
+Manages background and parallel work items — shell tasks, agent tasks, and teammate agents.
+
+### Task Types
+
+| Type | Location | Purpose |
+|------|----------|---------|
+| `LocalShellTask` | `LocalShellTask/` | Background shell command execution |
+| `LocalAgentTask` | `LocalAgentTask/` | Sub-agent running locally |
+| `RemoteAgentTask` | `RemoteAgentTask/` | Agent running on a remote machine |
+| `InProcessTeammateTask` | `InProcessTeammateTask/` | Parallel teammate agent |
+| `DreamTask` | `DreamTask/` | Background "dreaming" process |
+| `LocalMainSessionTask` | `LocalMainSessionTask.ts` | Main session as a task |
+
+### Task Tools
+
+| Tool | Purpose |
+|------|---------|
+| `TaskCreateTool` | Create a new background task |
+| `TaskUpdateTool` | Update task status |
+| `TaskGetTool` | Retrieve task details |
+| `TaskListTool` | List all tasks |
+| `TaskOutputTool` | Get task output |
+| `TaskStopTool` | Stop a running task |
+
+---
+
+## Memory System
+
+**Location:** `src/memdir/`
+
+Claude Code's persistent memory system, based on `CLAUDE.md` files.
+
+### Memory Hierarchy
+
+| Scope | Location | Purpose |
+|-------|----------|---------|
+| Project memory | `CLAUDE.md` in project root | Project-specific facts, conventions |
+| User memory | `~/.claude/CLAUDE.md` | User preferences, cross-project |
+| Extracted memories | `src/services/extractMemories/` | Auto-extracted from conversations |
+| Team memory sync | `src/services/teamMemorySync/` | Shared team knowledge |
+
+### Related
+
+- `/memory` command for managing memories
+- `remember` skill for persisting information
+- `useMemoryUsage` hook for tracking memory size
+
+---
+
+## Coordinator (Multi-Agent)
+
+**Location:** `src/coordinator/`
+
+Orchestrates multiple agents working in parallel on different aspects of a task.
+
+### How It Works
+
+- `coordinatorMode.ts` manages the coordinator lifecycle
+- `TeamCreateTool` and `TeamDeleteTool` manage agent teams
+- `SendMessageTool` enables inter-agent communication
+- `AgentTool` spawns sub-agents
+
+Gated behind the `COORDINATOR_MODE` feature flag.
+
+---
+
+## Voice System
+
+**Location:** `src/voice/`
+
+Voice input/output support for hands-free interaction.
+
+### Components
+
+| File | Location | Purpose |
+|------|----------|---------|
+| Voice service | `src/services/voice.ts` | Core voice processing |
+| STT streaming | `src/services/voiceStreamSTT.ts` | Speech-to-text streaming |
+| Key terms | `src/services/voiceKeyterms.ts` | Domain-specific vocabulary |
+| Voice hooks | `src/hooks/useVoice.ts`, `useVoiceEnabled.ts`, `useVoiceIntegration.tsx` | React hooks |
+| Voice command | `src/commands/voice/` | `/voice` slash command |
+
+Gated behind the `VOICE_MODE` feature flag.
+
+---
+
+## Service Layer
+
+**Location:** `src/services/`
+
+External integrations and shared services.
+
+| Service | Path | Purpose |
+|---------|------|---------|
+| **API** | `api/` | Anthropic SDK client, file uploads, bootstrap |
+| **MCP** | `mcp/` | MCP client connections and tool discovery |
+| **OAuth** | `oauth/` | OAuth 2.0 authentication flow |
+| **LSP** | `lsp/` | Language Server Protocol manager |
+| **Analytics** | `analytics/` | GrowthBook feature flags, telemetry |
+| **Plugins** | `plugins/` | Plugin loader and marketplace |
+| **Compact** | `compact/` | Conversation context compression |
+| **Policy Limits** | `policyLimits/` | Organization rate limits/quota |
+| **Remote Settings** | `remoteManagedSettings/` | Enterprise managed settings sync |
+| **Token Estimation** | `tokenEstimation.ts` | Token count estimation |
+| **Team Memory** | `teamMemorySync/` | Team knowledge synchronization |
+| **Tips** | `tips/` | Contextual usage tips |
+| **Agent Summary** | `AgentSummary/` | Agent work summaries |
+| **Prompt Suggestion** | `PromptSuggestion/` | Suggested follow-up prompts |
+| **Session Memory** | `SessionMemory/` | Session-level memory |
+| **Magic Docs** | `MagicDocs/` | Documentation generation |
+| **Auto Dream** | `autoDream/` | Background ideation |
+| **x402** | `x402/` | x402 payment protocol |
+
+---
+
+## See Also
+
+- [Architecture](architecture.md) — How subsystems connect in the core pipeline
+- [Tools Reference](tools.md) — Tools related to each subsystem
+- [Commands Reference](commands.md) — Commands for managing subsystems
+- [Exploration Guide](exploration-guide.md) — Finding subsystem source code
diff --git a/docs/tools.md b/docs/tools.md
new file mode 100644
index 0000000..a7c6bc8
--- /dev/null
+++ b/docs/tools.md
@@ -0,0 +1,173 @@
+# Tools Reference
+
+> Complete catalog of all ~40 agent tools in Claude Code.
+
+---
+
+## Overview
+
+Every tool lives in `src/tools//` as a self-contained module. Each tool defines:
+
+- **Input schema** — Zod-validated parameters
+- **Permission model** — What requires user approval
+- **Execution logic** — The tool's implementation
+- **UI components** — Terminal rendering for invocation and results
+- **Concurrency safety** — Whether it can run in parallel
+
+Tools are registered in `src/tools.ts` and invoked by the Query Engine during LLM tool-call loops.
+
+### Tool Definition Pattern
+
+```typescript
+export const MyTool = buildTool({
+ name: 'MyTool',
+ aliases: ['my_tool'],
+ description: 'What this tool does',
+ inputSchema: z.object({
+ param: z.string(),
+ }),
+ async call(args, context, canUseTool, parentMessage, onProgress) {
+ // Execute and return { data: result, newMessages?: [...] }
+ },
+ async checkPermissions(input, context) { /* Permission checks */ },
+ isConcurrencySafe(input) { /* Can run in parallel? */ },
+ isReadOnly(input) { /* Non-destructive? */ },
+ prompt(options) { /* System prompt injection */ },
+ renderToolUseMessage(input, options) { /* UI for invocation */ },
+ renderToolResultMessage(content, progressMessages, options) { /* UI for result */ },
+})
+```
+
+**Directory structure per tool:**
+
+```
+src/tools/MyTool/
+├── MyTool.ts # Main implementation
+├── UI.tsx # Terminal rendering
+├── prompt.ts # System prompt contribution
+└── utils.ts # Tool-specific helpers
+```
+
+---
+
+## File System Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **FileReadTool** | Read file contents (text, images, PDFs, notebooks). Supports line ranges | Yes |
+| **FileWriteTool** | Create or overwrite files | No |
+| **FileEditTool** | Partial file modification via string replacement | No |
+| **GlobTool** | Find files matching glob patterns (e.g. `**/*.ts`) | Yes |
+| **GrepTool** | Content search using ripgrep (regex-capable) | Yes |
+| **NotebookEditTool** | Edit Jupyter notebook cells | No |
+| **TodoWriteTool** | Write to a structured todo/task file | No |
+
+## Shell & Execution Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **BashTool** | Execute shell commands in bash | No |
+| **PowerShellTool** | Execute PowerShell commands (Windows) | No |
+| **REPLTool** | Run code in a REPL session (Python, Node, etc.) | No |
+
+## Agent & Orchestration Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **AgentTool** | Spawn a sub-agent for complex tasks | No |
+| **SendMessageTool** | Send messages between agents | No |
+| **TeamCreateTool** | Create a team of parallel agents | No |
+| **TeamDeleteTool** | Remove a team agent | No |
+| **EnterPlanModeTool** | Switch to planning mode (no execution) | No |
+| **ExitPlanModeTool** | Exit planning mode, resume execution | No |
+| **EnterWorktreeTool** | Isolate work in a git worktree | No |
+| **ExitWorktreeTool** | Exit worktree isolation | No |
+| **SleepTool** | Pause execution (proactive mode) | Yes |
+| **SyntheticOutputTool** | Generate structured output | Yes |
+
+## Task Management Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **TaskCreateTool** | Create a new background task | No |
+| **TaskUpdateTool** | Update a task's status or details | No |
+| **TaskGetTool** | Get details of a specific task | Yes |
+| **TaskListTool** | List all tasks | Yes |
+| **TaskOutputTool** | Get output from a completed task | Yes |
+| **TaskStopTool** | Stop a running task | No |
+
+## Web Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **WebFetchTool** | Fetch content from a URL | Yes |
+| **WebSearchTool** | Search the web | Yes |
+
+## MCP (Model Context Protocol) Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **MCPTool** | Invoke tools on connected MCP servers | Varies |
+| **ListMcpResourcesTool** | List resources exposed by MCP servers | Yes |
+| **ReadMcpResourceTool** | Read a specific MCP resource | Yes |
+| **McpAuthTool** | Handle MCP server authentication | No |
+| **ToolSearchTool** | Discover deferred/dynamic tools from MCP servers | Yes |
+
+## Integration Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **LSPTool** | Language Server Protocol operations (go-to-definition, find references, etc.) | Yes |
+| **SkillTool** | Execute a registered skill | Varies |
+
+## Scheduling & Triggers
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **ScheduleCronTool** | Create a scheduled cron trigger | No |
+| **RemoteTriggerTool** | Fire a remote trigger | No |
+
+## Utility Tools
+
+| Tool | Description | Read-Only |
+|------|-------------|-----------|
+| **AskUserQuestionTool** | Prompt the user for input during execution | Yes |
+| **BriefTool** | Generate a brief/summary | Yes |
+| **ConfigTool** | Read or modify Claude Code configuration | No |
+
+---
+
+## Permission Model
+
+Every tool invocation passes through the permission system (`src/hooks/toolPermission/`). Permission modes:
+
+| Mode | Behavior |
+|------|----------|
+| `default` | Prompt the user for each potentially destructive operation |
+| `plan` | Show the full plan, ask once |
+| `bypassPermissions` | Auto-approve everything (dangerous) |
+| `auto` | ML-based classifier decides |
+
+Permission rules use wildcard patterns:
+
+```
+Bash(git *) # Allow all git commands
+FileEdit(/src/*) # Allow edits to anything in src/
+FileRead(*) # Allow reading any file
+```
+
+Each tool implements `checkPermissions()` returning `{ granted: boolean, reason?, prompt? }`.
+
+---
+
+## Tool Presets
+
+Tools are grouped into presets in `src/tools.ts` for different contexts (e.g. read-only tools for code review, full toolset for development).
+
+---
+
+## See Also
+
+- [Architecture](architecture.md) — How tools fit into the overall pipeline
+- [Subsystems Guide](subsystems.md) — MCP, permissions, and other tool-related subsystems
+- [Exploration Guide](exploration-guide.md) — How to read tool source code
diff --git a/gitpretty-apply.sh b/gitpretty-apply.sh
new file mode 100644
index 0000000..a6a5205
--- /dev/null
+++ b/gitpretty-apply.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# Apply gitpretty's per-file beautification so GitHub file history shows
+# readable, themed commit messages for each file.
+
+REPO_PATH="${1:-.}"
+INSTALL_HOOKS="${2:-}"
+GITPRETTY_HOME="${HOME}/.gitpretty"
+
+if ! command -v git >/dev/null 2>&1; then
+ echo "git is required but was not found on PATH"
+ exit 1
+fi
+
+if [ ! -d "${REPO_PATH}/.git" ]; then
+ echo "Target is not a git repository: ${REPO_PATH}"
+ echo "Usage: $0 [repo-path] [--hooks]"
+ exit 1
+fi
+
+if [ ! -d "${GITPRETTY_HOME}" ]; then
+ echo "Installing gitpretty into ${GITPRETTY_HOME} ..."
+ git clone https://github.com/codeaashu/gitpretty.git "${GITPRETTY_HOME}"
+fi
+
+chmod +x "${GITPRETTY_HOME}"/*.sh "${GITPRETTY_HOME}"/scripts/*.sh
+
+if [ "${INSTALL_HOOKS}" = "--hooks" ]; then
+ echo "Installing gitpretty hooks in ${REPO_PATH} ..."
+ (
+ cd "${REPO_PATH}"
+ "${GITPRETTY_HOME}"/scripts/emoji-hooks.sh install
+ )
+fi
+
+echo "Running per-file beautify commits in ${REPO_PATH} ..."
+"${GITPRETTY_HOME}"/emoji-file-commits.sh "${REPO_PATH}"
+
+echo "Done. Review with: git -C ${REPO_PATH} log --oneline -n 20"
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b52c01d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3545 @@
+{
+ "name": "@anthropic-ai/claude-code",
+ "version": "0.0.0-leaked",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@anthropic-ai/claude-code",
+ "version": "0.0.0-leaked",
+ "license": "UNLICENSED",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "@commander-js/extra-typings": "^13.1.0",
+ "@growthbook/growthbook": "^1.4.0",
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/api-logs": "^0.57.0",
+ "@opentelemetry/core": "^1.30.0",
+ "@opentelemetry/sdk-logs": "^0.57.0",
+ "@opentelemetry/sdk-metrics": "^1.30.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-search": "^0.15.0",
+ "@xterm/addon-unicode11": "^0.8.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/addon-webgl": "^0.18.0",
+ "@xterm/xterm": "^5.5.0",
+ "auto-bind": "^5.0.1",
+ "axios": "^1.7.0",
+ "chalk": "^5.4.0",
+ "chokidar": "^4.0.0",
+ "cli-boxes": "^3.0.0",
+ "code-excerpt": "^4.0.0",
+ "diff": "^7.0.0",
+ "execa": "^9.5.0",
+ "figures": "^6.1.0",
+ "fuse.js": "^7.0.0",
+ "highlight.js": "^11.11.0",
+ "ignore": "^6.0.0",
+ "lodash-es": "^4.17.21",
+ "marked": "^15.0.0",
+ "node-pty": "^1.1.0",
+ "p-map": "^7.0.0",
+ "picomatch": "^4.0.0",
+ "proper-lockfile": "^4.1.2",
+ "qrcode": "^1.5.0",
+ "react": "^19.0.0",
+ "react-reconciler": "^0.31.0",
+ "semver": "^7.6.0",
+ "stack-utils": "^2.0.6",
+ "strip-ansi": "^7.1.0",
+ "supports-hyperlinks": "^3.1.0",
+ "tree-kill": "^1.2.2",
+ "type-fest": "^4.30.0",
+ "undici": "^7.3.0",
+ "usehooks-ts": "^3.1.0",
+ "wrap-ansi": "^9.0.0",
+ "ws": "^8.18.0",
+ "yaml": "^2.6.0",
+ "zod": "^3.24.0"
+ },
+ "bin": {
+ "claude": "src/entrypoints/cli.tsx"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.0",
+ "@types/diff": "^7.0.0",
+ "@types/lodash-es": "^4.17.12",
+ "@types/node": "^22.10.0",
+ "@types/picomatch": "^3.0.0",
+ "@types/proper-lockfile": "^4.1.4",
+ "@types/react": "^19.0.0",
+ "@types/semver": "^7.5.8",
+ "@types/stack-utils": "^2.0.3",
+ "@types/ws": "^8.5.0",
+ "esbuild": "^0.25.0",
+ "typescript": "^5.7.0"
+ },
+ "engines": {
+ "bun": ">=1.1.0"
+ }
+ },
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.39.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
+ "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7"
+ }
+ },
+ "node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
+ "version": "18.19.130",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "license": "MIT"
+ },
+ "node_modules/@biomejs/biome": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
+ "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "1.9.4",
+ "@biomejs/cli-darwin-x64": "1.9.4",
+ "@biomejs/cli-linux-arm64": "1.9.4",
+ "@biomejs/cli-linux-arm64-musl": "1.9.4",
+ "@biomejs/cli-linux-x64": "1.9.4",
+ "@biomejs/cli-linux-x64-musl": "1.9.4",
+ "@biomejs/cli-win32-arm64": "1.9.4",
+ "@biomejs/cli-win32-x64": "1.9.4"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
+ "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
+ "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
+ "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
+ "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
+ "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
+ "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
+ "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
+ "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@commander-js/extra-typings": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz",
+ "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "commander": "~13.1.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@growthbook/growthbook": {
+ "version": "1.6.5",
+ "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-1.6.5.tgz",
+ "integrity": "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A==",
+ "license": "MIT",
+ "dependencies": {
+ "dom-mutator": "^0.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "1.19.12",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
+ "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.57.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz",
+ "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/core": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz",
+ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "1.28.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz",
+ "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/semantic-conventions": "1.28.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-logs": {
+ "version": "0.57.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.2.tgz",
+ "integrity": "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.57.2",
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/resources": "1.30.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.4.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-metrics": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz",
+ "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/resources": "1.30.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz",
+ "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/resources": "1.30.1",
+ "@opentelemetry/semantic-conventions": "1.28.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",
+ "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@sec-ant/readable-stream": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/merge-streams": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@types/diff": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
+ "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.15",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
+ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.13",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+ "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^4.0.4"
+ }
+ },
+ "node_modules/@types/picomatch": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.2.tgz",
+ "integrity": "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/proper-lockfile": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
+ "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/retry": "*"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/retry": {
+ "version": "0.12.5",
+ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
+ "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/semver": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/stack-utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
+ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
+ "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-search": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
+ "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-unicode11": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.8.0.tgz",
+ "integrity": "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-web-links": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
+ "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-webgl": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
+ "integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
+ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/auto-bind": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
+ "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
+ "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+ "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/code-excerpt": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
+ "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
+ "license": "MIT",
+ "dependencies": {
+ "convert-to-spaces": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-to-spaces": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
+ "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/diff": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
+ "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
+ "node_modules/dom-mutator": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/dom-mutator/-/dom-mutator-0.6.0.tgz",
+ "integrity": "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+ "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "figures": "^6.1.0",
+ "get-stream": "^9.0.0",
+ "human-signals": "^8.0.1",
+ "is-plain-obj": "^4.1.0",
+ "is-stream": "^4.0.1",
+ "npm-run-path": "^6.0.0",
+ "pretty-ms": "^9.2.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^4.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.5.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
+ "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/figures": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
+ "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data-encoder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+ "license": "MIT"
+ },
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/formdata-node": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "1.0.0",
+ "web-streams-polyfill": "4.0.0-beta.3"
+ },
+ "engines": {
+ "node": ">= 12.20"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fuse.js": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
+ "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
+ "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+ "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sec-ant/readable-stream": "^0.4.1",
+ "is-stream": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.9",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
+ "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
+ "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz",
+ "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-stream": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
+ "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
+ "node_modules/marked": {
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-pty": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
+ "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^7.1.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
+ "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0",
+ "unicorn-magic": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
+ "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-ms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
+ "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz",
+ "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/pretty-ms": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
+ "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parse-ms": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/proper-lockfile": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+ "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "retry": "^0.12.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "node_modules/proper-lockfile/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-reconciler": {
+ "version": "0.31.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
+ "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.25.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
+ "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/stack-utils": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
+ "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-hyperlinks": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
+ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
+ "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/unicorn-magic": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+ "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/usehooks-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
+ "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.debounce": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=16.15.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "4.0.0-beta.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yoctocolors": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
+ "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..048f770
--- /dev/null
+++ b/package.json
@@ -0,0 +1,94 @@
+{
+ "name": "@anthropic-ai/claude-code",
+ "version": "0.0.0-leaked",
+ "description": "Anthropic Claude Code CLI — leaked source (2026-03-31). Not an official release.",
+ "license": "UNLICENSED",
+ "private": true,
+ "type": "module",
+ "main": "src/entrypoints/cli.tsx",
+ "bin": {
+ "claude": "src/entrypoints/cli.tsx"
+ },
+ "scripts": {
+ "build": "bun scripts/build-bundle.ts",
+ "build:watch": "bun scripts/build-bundle.ts --watch",
+ "build:prod": "bun scripts/build-bundle.ts --minify",
+ "build:web": "bun scripts/build-web.ts",
+ "build:web:watch": "bun scripts/build-web.ts --watch",
+ "build:web:prod": "bun scripts/build-web.ts --minify",
+ "typecheck": "tsc --noEmit",
+ "lint": "biome check src/",
+ "lint:fix": "biome check --write src/",
+ "format": "biome format --write src/",
+ "check": "biome check src/ && tsc --noEmit"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "@commander-js/extra-typings": "^13.1.0",
+ "@growthbook/growthbook": "^1.4.0",
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/api-logs": "^0.57.0",
+ "@opentelemetry/core": "^1.30.0",
+ "@opentelemetry/sdk-logs": "^0.57.0",
+ "@opentelemetry/sdk-metrics": "^1.30.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-search": "^0.15.0",
+ "@xterm/addon-unicode11": "^0.8.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/addon-webgl": "^0.18.0",
+ "@xterm/xterm": "^5.5.0",
+ "auto-bind": "^5.0.1",
+ "axios": "^1.7.0",
+ "chalk": "^5.4.0",
+ "chokidar": "^4.0.0",
+ "cli-boxes": "^3.0.0",
+ "code-excerpt": "^4.0.0",
+ "diff": "^7.0.0",
+ "execa": "^9.5.0",
+ "figures": "^6.1.0",
+ "fuse.js": "^7.0.0",
+ "highlight.js": "^11.11.0",
+ "ignore": "^6.0.0",
+ "lodash-es": "^4.17.21",
+ "marked": "^15.0.0",
+ "node-pty": "^1.1.0",
+ "p-map": "^7.0.0",
+ "picomatch": "^4.0.0",
+ "proper-lockfile": "^4.1.2",
+ "qrcode": "^1.5.0",
+ "react": "^19.0.0",
+ "react-reconciler": "^0.31.0",
+ "semver": "^7.6.0",
+ "stack-utils": "^2.0.6",
+ "strip-ansi": "^7.1.0",
+ "supports-hyperlinks": "^3.1.0",
+ "tree-kill": "^1.2.2",
+ "type-fest": "^4.30.0",
+ "undici": "^7.3.0",
+ "usehooks-ts": "^3.1.0",
+ "wrap-ansi": "^9.0.0",
+ "ws": "^8.18.0",
+ "yaml": "^2.6.0",
+ "zod": "^3.24.0"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.0",
+ "@types/diff": "^7.0.0",
+ "@types/lodash-es": "^4.17.12",
+ "@types/node": "^22.10.0",
+ "@types/picomatch": "^3.0.0",
+ "@types/proper-lockfile": "^4.1.4",
+ "@types/react": "^19.0.0",
+ "@types/semver": "^7.5.8",
+ "@types/stack-utils": "^2.0.3",
+ "@types/ws": "^8.5.0",
+ "esbuild": "^0.25.0",
+ "typescript": "^5.7.0"
+ },
+ "engines": {
+ "bun": ">=1.1.0"
+ },
+ "packageManager": "bun@1.1.0"
+}
diff --git a/prompts/00-overview.md b/prompts/00-overview.md
new file mode 100644
index 0000000..79841c1
--- /dev/null
+++ b/prompts/00-overview.md
@@ -0,0 +1,35 @@
+# Build-Out Prompt Index
+
+Run these prompts **in order** in separate chat sessions. Each one is self-contained.
+
+| # | File | What It Does | Depends On |
+|---|------|-------------|------------|
+| 01 | `01-install-bun-and-deps.md` | Install Bun runtime, install all dependencies | — |
+| 02 | `02-runtime-shims.md` | Create `bun:bundle` runtime shim + `MACRO` globals so code runs without Bun's bundler | 01 |
+| 03 | `03-build-config.md` | Create esbuild-based build system that bundles the CLI to a single runnable file | 01, 02 |
+| 04 | `04-fix-mcp-server.md` | Fix TypeScript errors in `mcp-server/` and make it build | 01 |
+| 05 | `05-env-and-auth.md` | Set up `.env` file, API key config, OAuth stubs | 01 |
+| 06 | `06-ink-react-terminal-ui.md` | Verify and fix the Ink/React terminal rendering pipeline | 01, 02, 03 |
+| 07 | `07-tool-system.md` | Audit and wire up the 40+ tool implementations (BashTool, FileEditTool, etc.) | 01–03 |
+| 08 | `08-command-system.md` | Audit and wire up the 50+ slash commands (/commit, /review, etc.) | 01–03, 07 |
+| 09 | `09-query-engine.md` | Get the core LLM call loop (QueryEngine) functional — streaming, tool calls, retries | 01–03, 05, 07 |
+| 10 | `10-context-and-prompts.md` | Wire up system prompt construction, context gathering, memory system | 01–03 |
+| 11 | `11-mcp-integration.md` | Get MCP client/server integration working — registry, tool discovery | 01–04 |
+| 12 | `12-services-layer.md` | Wire up analytics, policy limits, remote settings, session memory | 01–03, 05 |
+| 13 | `13-bridge-ide.md` | Stub out or implement the VS Code / JetBrains bridge layer | 01–03, 09 |
+| 14 | `14-dev-runner.md` | Create `npm run dev` / `bun run dev` script that launches the CLI in dev mode | 01–03 |
+| 15 | `15-production-bundle.md` | Create production build: minified bundle, platform-specific packaging | 03 |
+| 16 | `16-testing.md` | Add test infrastructure (vitest), write smoke tests for core subsystems | All |
+
+## Quick Start
+
+1. Open a new Copilot chat
+2. Paste the contents of `01-install-bun-and-deps.md`
+3. Follow the instructions / let the agent run
+4. Repeat for `02`, `03`, etc.
+
+## Notes
+
+- Prompts 07–13 can be run somewhat in **parallel** (they touch different subsystems)
+- If a prompt fails, fix the issue before moving to the next one
+- Each prompt is designed to be **independently verifiable** — it tells you how to confirm it worked
diff --git a/prompts/01-install-bun-and-deps.md b/prompts/01-install-bun-and-deps.md
new file mode 100644
index 0000000..78ff245
--- /dev/null
+++ b/prompts/01-install-bun-and-deps.md
@@ -0,0 +1,38 @@
+# Prompt 01: Install Bun Runtime & Dependencies
+
+## Context
+
+You are working in `/workspaces/claude-code`, which contains the leaked source code of Anthropic's Claude Code CLI. It's a TypeScript/TSX project that uses **Bun** as its runtime (not Node.js). The `package.json` specifies `"engines": { "bun": ">=1.1.0" }`.
+
+There is no `bun.lockb` lockfile — it was not included in the leak.
+
+## Task
+
+1. **Install Bun** (if not already installed):
+ ```
+ curl -fsSL https://bun.sh/install | bash
+ ```
+ Then ensure `bun` is on the PATH.
+
+2. **Run `bun install`** in the project root (`/workspaces/claude-code`) to install all dependencies. This will generate a `bun.lockb` lockfile.
+
+3. **Verify the install** — confirm that:
+ - `node_modules/` exists and has the major packages: `@anthropic-ai/sdk`, `react`, `chalk`, `@commander-js/extra-typings`, `ink` (may not exist separately — check `@anthropic-ai/sdk`, `zod`, `@modelcontextprotocol/sdk`)
+ - `bun --version` returns 1.1.0+
+
+4. **Run the typecheck** to see current state:
+ ```
+ bun run typecheck
+ ```
+ Report any errors — don't fix them yet, just capture the output.
+
+5. **Also install deps for the mcp-server sub-project**:
+ ```
+ cd mcp-server && npm install && cd ..
+ ```
+
+## Verification
+
+- `bun --version` outputs >= 1.1.0
+- `ls node_modules/@anthropic-ai/sdk` succeeds
+- `bun run typecheck` runs (errors are expected at this stage, just report them)
diff --git a/prompts/02-runtime-shims.md b/prompts/02-runtime-shims.md
new file mode 100644
index 0000000..00f4517
--- /dev/null
+++ b/prompts/02-runtime-shims.md
@@ -0,0 +1,137 @@
+# Prompt 02: Runtime Shims for `bun:bundle` Feature Flags & `MACRO` Globals
+
+## Context
+
+You are working in `/workspaces/claude-code`. This is the Claude Code CLI source. It was built to run under **Bun's bundler** which provides two build-time features that don't exist at runtime:
+
+### 1. `bun:bundle` feature flags
+Throughout the code you'll find:
+```ts
+import { feature } from 'bun:bundle'
+if (feature('BRIDGE_MODE')) { ... }
+```
+Bun's bundler replaces `feature('X')` with `true`/`false` at build time for dead-code elimination. Without the bundler, this import fails at runtime.
+
+**Current state**: There's a type stub at `src/types/bun-bundle.d.ts` that satisfies TypeScript, but there's no runtime module. We need a real module.
+
+### 2. `MACRO` global object
+The code references a global `MACRO` object with these properties:
+- `MACRO.VERSION` — package version string (e.g., `"1.0.53"`)
+- `MACRO.PACKAGE_URL` — npm package name (e.g., `"@anthropic-ai/claude-code"`)
+- `MACRO.ISSUES_EXPLAINER` — feedback URL/instructions string
+
+These are normally inlined by the bundler. Some files already guard with `typeof MACRO !== 'undefined'`, but most don't.
+
+## Task
+
+### Part A: Create `bun:bundle` runtime module
+
+Create a file at `src/shims/bun-bundle.ts` that exports a `feature()` function. Feature flags should be configurable via environment variables so we can toggle them:
+
+```ts
+// src/shims/bun-bundle.ts
+
+// Map of feature flags to their enabled state.
+// In production Bun builds, these are compile-time constants.
+// For our dev build, we read from env vars with sensible defaults.
+const FEATURE_FLAGS: Record = {
+ PROACTIVE: envBool('CLAUDE_CODE_PROACTIVE', false),
+ KAIROS: envBool('CLAUDE_CODE_KAIROS', false),
+ BRIDGE_MODE: envBool('CLAUDE_CODE_BRIDGE_MODE', false),
+ DAEMON: envBool('CLAUDE_CODE_DAEMON', false),
+ VOICE_MODE: envBool('CLAUDE_CODE_VOICE_MODE', false),
+ AGENT_TRIGGERS: envBool('CLAUDE_CODE_AGENT_TRIGGERS', false),
+ MONITOR_TOOL: envBool('CLAUDE_CODE_MONITOR_TOOL', false),
+ COORDINATOR_MODE: envBool('CLAUDE_CODE_COORDINATOR_MODE', false),
+ ABLATION_BASELINE: false, // always off for external builds
+ DUMP_SYSTEM_PROMPT: envBool('CLAUDE_CODE_DUMP_SYSTEM_PROMPT', false),
+ BG_SESSIONS: envBool('CLAUDE_CODE_BG_SESSIONS', false),
+}
+
+function envBool(key: string, fallback: boolean): boolean {
+ const v = process.env[key]
+ if (v === undefined) return fallback
+ return v === '1' || v === 'true'
+}
+
+export function feature(name: string): boolean {
+ return FEATURE_FLAGS[name] ?? false
+}
+```
+
+### Part B: Create `MACRO` global definition
+
+Create a file at `src/shims/macro.ts` that defines and installs the global `MACRO` object:
+
+```ts
+// src/shims/macro.ts
+
+// Read version from package.json at startup
+import { readFileSync } from 'fs'
+import { resolve, dirname } from 'path'
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url)
+const pkgPath = resolve(dirname(__filename), '..', '..', 'package.json')
+let version = '0.0.0-dev'
+try {
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
+ version = pkg.version || version
+} catch {}
+
+const MACRO_OBJ = {
+ VERSION: version,
+ PACKAGE_URL: '@anthropic-ai/claude-code',
+ ISSUES_EXPLAINER: 'report issues at https://github.com/anthropics/claude-code/issues',
+}
+
+// Install as global
+;(globalThis as any).MACRO = MACRO_OBJ
+
+export default MACRO_OBJ
+```
+
+### Part C: Create a preload/bootstrap file
+
+Create `src/shims/preload.ts` that imports both shims so they're available before any app code runs:
+
+```ts
+// src/shims/preload.ts
+// Must be loaded before any application code.
+// Provides runtime equivalents of Bun bundler build-time features.
+
+import './macro.js'
+// bun:bundle is resolved via the build alias, not imported here
+```
+
+### Part D: Update tsconfig.json `paths`
+
+The current tsconfig.json has:
+```json
+"paths": {
+ "bun:bundle": ["./src/types/bun-bundle.d.ts"]
+}
+```
+
+This handles type-checking. For runtime, we'll need the build system (Prompt 03) to alias `bun:bundle` → `src/shims/bun-bundle.ts`. **Don't change tsconfig.json** — the type stub is correct for `tsc`. Just note this for the next prompt.
+
+### Part E: Add global MACRO type declaration
+
+Check if there's already a global type declaration for `MACRO`. If not, add one to `src/types/bun-bundle.d.ts` or a new `src/types/macro.d.ts`:
+
+```ts
+declare const MACRO: {
+ VERSION: string
+ PACKAGE_URL: string
+ ISSUES_EXPLAINER: string
+}
+```
+
+Make sure `tsc --noEmit` still passes after your changes.
+
+## Verification
+
+1. `bun run typecheck` should pass (or have the same errors as before — no new errors)
+2. The files `src/shims/bun-bundle.ts`, `src/shims/macro.ts`, `src/shims/preload.ts` exist
+3. Running `bun -e "import { feature } from './src/shims/bun-bundle.ts'; console.log(feature('BRIDGE_MODE'))"` should print `false`
+4. Running `bun -e "import './src/shims/macro.ts'; console.log(MACRO.VERSION)"` should print the version
diff --git a/prompts/03-build-config.md b/prompts/03-build-config.md
new file mode 100644
index 0000000..f1a4ac3
--- /dev/null
+++ b/prompts/03-build-config.md
@@ -0,0 +1,165 @@
+# Prompt 03: Create esbuild-Based Build System
+
+## Context
+
+You are working in `/workspaces/claude-code`. This is the Claude Code CLI — a TypeScript/TSX terminal app using React + Ink. It was originally built using **Bun's bundler** with feature flags, but that build config wasn't included in the leak.
+
+We need to create a build system that:
+1. Bundles the entire `src/` tree into a runnable output
+2. Aliases `bun:bundle` → our shim at `src/shims/bun-bundle.ts`
+3. Injects the `MACRO` global (via `src/shims/macro.ts` preload)
+4. Handles TSX/JSX (React)
+5. Handles ESM `.js` extension imports (the code uses `import from './foo.js'` which maps to `./foo.ts`)
+6. Produces output that can run under **Bun** (primary) or **Node.js 20+** (secondary)
+
+## Existing Files
+
+- `src/shims/bun-bundle.ts` — runtime `feature()` function (created in Prompt 02)
+- `src/shims/macro.ts` — global `MACRO` object (created in Prompt 02)
+- `src/shims/preload.ts` — preload bootstrap (created in Prompt 02)
+- `src/entrypoints/cli.tsx` — main entrypoint
+- `tsconfig.json` — has `"jsx": "react-jsx"`, `"module": "ESNext"`, `"moduleResolution": "bundler"`
+
+## Task
+
+### Part A: Install esbuild
+
+```bash
+bun add -d esbuild
+```
+
+### Part B: Create build script
+
+Create `scripts/build-bundle.ts` (a Bun-runnable build script):
+
+```ts
+// scripts/build-bundle.ts
+// Usage: bun scripts/build-bundle.ts [--watch] [--minify]
+
+import * as esbuild from 'esbuild'
+import { resolve } from 'path'
+
+const ROOT = resolve(import.meta.dir, '..')
+const watch = process.argv.includes('--watch')
+const minify = process.argv.includes('--minify')
+
+const buildOptions: esbuild.BuildOptions = {
+ entryPoints: [resolve(ROOT, 'src/entrypoints/cli.tsx')],
+ bundle: true,
+ platform: 'node',
+ target: 'node20',
+ format: 'esm',
+ outdir: resolve(ROOT, 'dist'),
+ outExtension: { '.js': '.mjs' },
+
+ // Inject the MACRO global before all other code
+ inject: [resolve(ROOT, 'src/shims/macro.ts')],
+
+ // Alias bun:bundle to our runtime shim
+ alias: {
+ 'bun:bundle': resolve(ROOT, 'src/shims/bun-bundle.ts'),
+ },
+
+ // Don't bundle node built-ins or native packages
+ external: [
+ // Node built-ins
+ 'fs', 'path', 'os', 'crypto', 'child_process', 'http', 'https',
+ 'net', 'tls', 'url', 'util', 'stream', 'events', 'buffer',
+ 'querystring', 'readline', 'zlib', 'assert', 'tty', 'worker_threads',
+ 'perf_hooks', 'async_hooks', 'dns', 'dgram', 'cluster',
+ 'node:*',
+ // Native addons that can't be bundled
+ 'fsevents',
+ ],
+
+ jsx: 'automatic',
+
+ // Source maps for debugging
+ sourcemap: true,
+
+ minify,
+
+ // Banner: shebang for CLI + preload the MACRO global
+ banner: {
+ js: '#!/usr/bin/env node\n',
+ },
+
+ // Handle the .js → .ts resolution that the codebase uses
+ resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
+
+ logLevel: 'info',
+}
+
+async function main() {
+ if (watch) {
+ const ctx = await esbuild.context(buildOptions)
+ await ctx.watch()
+ console.log('Watching for changes...')
+ } else {
+ const result = await esbuild.build(buildOptions)
+ if (result.errors.length > 0) {
+ console.error('Build failed')
+ process.exit(1)
+ }
+ console.log('Build complete → dist/')
+ }
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})
+```
+
+**Important**: This is a starting point. You will likely need to iterate on the externals list and alias configuration. The codebase has ~1,900 files — some imports may need special handling. When you run the build:
+
+1. Run it: `bun scripts/build-bundle.ts`
+2. Look at the errors
+3. Fix them (add externals, fix aliases, etc.)
+4. Repeat until it bundles successfully
+
+Common issues you'll hit:
+- **npm packages that use native modules** → add to `external`
+- **Dynamic `require()` calls** behind `process.env.USER_TYPE === 'ant'` → these are Anthropic-internal, wrap them or stub them
+- **Circular dependencies** → esbuild handles these but may warn
+- **Re-exports from barrel files** → should work but watch for issues
+
+### Part C: Add npm scripts
+
+Add these to `package.json` `"scripts"`:
+
+```json
+{
+ "build": "bun scripts/build-bundle.ts",
+ "build:watch": "bun scripts/build-bundle.ts --watch",
+ "build:prod": "bun scripts/build-bundle.ts --minify"
+}
+```
+
+### Part D: Create dist output directory
+
+Add `dist/` to `.gitignore` (create one if it doesn't exist).
+
+### Part E: Iterate on build errors
+
+Run the build and fix whatever comes up. The goal is a clean `bun scripts/build-bundle.ts` that produces `dist/cli.mjs`.
+
+**Strategy for unresolvable modules**: If modules reference Anthropic-internal packages or Bun-specific APIs (like `Bun.hash`, `Bun.file`), create minimal stubs in `src/shims/` that provide compatible fallbacks.
+
+### Part F: Test the output
+
+After a successful build:
+```bash
+node dist/cli.mjs --version
+# or
+bun dist/cli.mjs --version
+```
+
+This should print the version. It will likely crash after that because no API key is configured — that's fine for now.
+
+## Verification
+
+1. `bun scripts/build-bundle.ts` completes without errors
+2. `dist/cli.mjs` exists
+3. `bun dist/cli.mjs --version` or `node dist/cli.mjs --version` prints a version string
+4. `package.json` has `build`, `build:watch`, `build:prod` scripts
diff --git a/prompts/04-fix-mcp-server.md b/prompts/04-fix-mcp-server.md
new file mode 100644
index 0000000..ed2f478
--- /dev/null
+++ b/prompts/04-fix-mcp-server.md
@@ -0,0 +1,85 @@
+# Prompt 04: Fix MCP Server Build
+
+## Context
+
+You are working in `/workspaces/claude-code/mcp-server/`. This is a separate sub-project that provides an MCP (Model Context Protocol) server for exploring the Claude Code source. It's a simpler, self-contained TypeScript project.
+
+Currently `npm run build` (which runs `tsc`) fails with TypeScript errors.
+
+## Task
+
+1. **Run the build and capture errors**:
+ ```bash
+ cd /workspaces/claude-code/mcp-server
+ npm run build 2>&1
+ ```
+
+2. **Fix all TypeScript errors** in `mcp-server/src/server.ts` and `mcp-server/src/index.ts`. Common issues include:
+ - Duplicate function implementations
+ - Missing imports
+ - Type mismatches with the MCP SDK types
+
+3. **Verify the fix**:
+ ```bash
+ npm run build
+ ```
+ Should complete with zero errors and produce output in `mcp-server/dist/`.
+
+4. **Test the MCP server runs**:
+ ```bash
+ node dist/index.js --help 2>&1 || node dist/index.js 2>&1 | head -5
+ ```
+ It may hang waiting for stdio input (that's normal for an MCP server) — just verify it starts without crashing.
+
+## Key Files
+
+- `mcp-server/package.json` — build script and dependencies
+- `mcp-server/tsconfig.json` — TypeScript config
+- `mcp-server/src/server.ts` — Main server logic (tools, resources, prompts)
+- `mcp-server/src/index.ts` — Entrypoint (stdio transport)
+
+## Verification
+
+1. `cd mcp-server && npm run build` succeeds with zero errors
+2. `ls mcp-server/dist/` shows compiled `.js` files
+3. `node mcp-server/dist/index.js` starts without immediate crash
+
+---
+
+## Fix: GitHub Push Protection Blocked Push
+
+`git push origin main` is rejected because commits `5e99ad6` and `7adaa5d` contain a **GitHub OAuth token** in `.mcpregistry_github_token`. The file was deleted from disk but still exists in git history. You must rewrite history to remove it.
+
+### Step 1: Scrub secrets from history
+
+```bash
+git filter-branch --force --index-filter \
+ 'git rm --cached --ignore-unmatch .mcpregistry_github_token .mcpregistry_registry_token mcp-server/.mcpregistry_github_token mcp-server/.mcpregistry_registry_token' \
+ --prune-empty HEAD~5..HEAD
+```
+
+### Step 2: Push
+
+```bash
+git push origin main
+```
+
+### Alternative: Interactive rebase
+
+```bash
+git rebase -i HEAD~5
+# Change "pick" to "edit" for commits 5e99ad6 and 7adaa5d
+# At each stop, run:
+git rm --cached .mcpregistry_github_token .mcpregistry_registry_token 2>/dev/null
+git rm --cached mcp-server/.mcpregistry_github_token mcp-server/.mcpregistry_registry_token 2>/dev/null
+git commit --amend --no-edit
+git rebase --continue
+```
+
+### Step 3: Prevent future leaks
+
+```bash
+echo ".mcpregistry_github_token" >> .gitignore
+echo ".mcpregistry_registry_token" >> .gitignore
+git add .gitignore && git commit -m "chore: gitignore token files"
+```
diff --git a/prompts/05-env-and-auth.md b/prompts/05-env-and-auth.md
new file mode 100644
index 0000000..a1412c1
--- /dev/null
+++ b/prompts/05-env-and-auth.md
@@ -0,0 +1,100 @@
+# Prompt 05: Environment Configuration & API Authentication
+
+## Context
+
+You are working in `/workspaces/claude-code`. The CLI needs an Anthropic API key to function. The auth system supports multiple backends:
+- **Direct API** (`ANTHROPIC_API_KEY`) — simplest
+- **OAuth** (Claude.ai subscription) — complex browser flow
+- **AWS Bedrock** — `AWS_*` env vars
+- **Google Vertex AI** — GCP credentials
+- **Azure Foundry** — `ANTHROPIC_FOUNDRY_API_KEY`
+
+## Task
+
+### Part A: Create `.env` file from the existing code
+
+Search the codebase for all environment variables used. Key files to check:
+- `src/entrypoints/cli.tsx` (reads env vars at top level)
+- `src/services/api/client.ts` (API client construction)
+- `src/utils/auth.ts` (authentication)
+- `src/utils/config.ts` (config loading)
+- `src/constants/` (any hardcoded config)
+- `src/entrypoints/init.ts` (initialization reads)
+
+Create a `.env.example` file (or update the existing one if it exists) with ALL discoverable env vars, organized by category, with documentation comments. At minimum include:
+
+```env
+# ─── Authentication ───
+ANTHROPIC_API_KEY= # Required: Your Anthropic API key (sk-ant-...)
+
+# ─── API Configuration ───
+ANTHROPIC_BASE_URL= # Custom API endpoint (default: https://api.anthropic.com)
+ANTHROPIC_MODEL= # Override default model (e.g., claude-sonnet-4-20250514)
+ANTHROPIC_SMALL_FAST_MODEL= # Model for fast/cheap operations (e.g., claude-haiku)
+
+# ─── Feature Flags (used by bun:bundle shim) ───
+CLAUDE_CODE_PROACTIVE=false
+CLAUDE_CODE_BRIDGE_MODE=false
+CLAUDE_CODE_COORDINATOR_MODE=false
+CLAUDE_CODE_VOICE_MODE=false
+
+# ─── Debug ───
+CLAUDE_CODE_DEBUG_LOG_LEVEL= # debug, info, warn, error
+DEBUG=false
+```
+
+### Part B: Trace the API client setup
+
+Read `src/services/api/client.ts` to understand how the Anthropic SDK is initialized. Document:
+1. What env vars it reads
+2. How it selects between API backends (direct, Bedrock, Vertex, etc.)
+3. Where the API key comes from (env var? keychain? OAuth token?)
+
+Create a comment block at the top of `.env.example` explaining how auth works.
+
+### Part C: Create a minimal auth test
+
+Create `scripts/test-auth.ts`:
+```ts
+// scripts/test-auth.ts
+// Quick test that the API key is configured and can reach Anthropic
+// Usage: bun scripts/test-auth.ts
+
+import Anthropic from '@anthropic-ai/sdk'
+
+const client = new Anthropic({
+ apiKey: process.env.ANTHROPIC_API_KEY,
+})
+
+async function main() {
+ try {
+ const msg = await client.messages.create({
+ model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514',
+ max_tokens: 50,
+ messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }],
+ })
+ console.log('✅ API connection successful!')
+ console.log('Response:', msg.content[0].type === 'text' ? msg.content[0].text : msg.content[0])
+ } catch (err: any) {
+ console.error('❌ API connection failed:', err.message)
+ process.exit(1)
+ }
+}
+
+main()
+```
+
+### Part D: Stub OAuth for development
+
+The OAuth flow (`src/services/oauth/`) requires browser interaction and Anthropic's OAuth endpoints. For development, we want to bypass it.
+
+Search for where the auth decision is made (likely in `src/utils/auth.ts` or `src/entrypoints/init.ts`). Document what would need to be stubbed to skip OAuth and use only `ANTHROPIC_API_KEY`.
+
+Don't modify source files yet — just document findings in a comment at the bottom of `.env.example`.
+
+## Verification
+
+1. `.env.example` exists with comprehensive env var documentation
+2. `scripts/test-auth.ts` exists
+3. With a valid `ANTHROPIC_API_KEY` set: `bun scripts/test-auth.ts` prints success
+4. Without an API key: `bun scripts/test-auth.ts` prints a clear error
diff --git a/prompts/06-ink-react-terminal-ui.md b/prompts/06-ink-react-terminal-ui.md
new file mode 100644
index 0000000..24d2ccb
--- /dev/null
+++ b/prompts/06-ink-react-terminal-ui.md
@@ -0,0 +1,110 @@
+# Prompt 06: Verify and Fix the Ink/React Terminal UI Pipeline
+
+## Context
+
+You are working in `/workspaces/claude-code`. The CLI renders its UI using **React + Ink** — a framework that renders React components to the terminal (not a browser). This project includes a **custom fork of Ink** embedded directly in `src/ink/`.
+
+Key files:
+- `src/ink.ts` — Public API (re-exports `render()` and `createRoot()`, wraps with `ThemeProvider`)
+- `src/ink/root.ts` — Ink's root renderer
+- `src/ink/ink.tsx` — Core Ink component
+- `src/ink/reconciler.ts` — React reconciler for terminal output
+- `src/ink/dom.ts` — Terminal DOM implementation
+- `src/ink/renderer.ts` — Renders virtual DOM to terminal strings
+- `src/ink/components/` — Built-in Ink components (Box, Text, etc.)
+- `src/components/` — Claude Code's ~140 custom components
+
+## Task
+
+### Part A: Trace the render pipeline
+
+Read these files in order and document the rendering flow:
+
+1. `src/ink.ts` → how `render()` and `createRoot()` work
+2. `src/ink/root.ts` → how Ink creates a root and mounts React
+3. `src/ink/reconciler.ts` → what React reconciler is used
+4. `src/ink/renderer.ts` → how the virtual DOM becomes terminal output
+5. `src/ink/dom.ts` → what the "DOM nodes" look like
+
+Create a brief architecture doc in a comment block or README section.
+
+### Part B: Verify Ink components compile
+
+Check that the core Ink components are self-contained:
+```
+src/ink/components/
+```
+List them all and verify they don't have missing imports.
+
+### Part C: Check the ThemeProvider
+
+Read `src/components/design-system/ThemeProvider.tsx` (or wherever it lives). Verify it:
+1. Exists
+2. Exports a `ThemeProvider` component
+3. The theme system doesn't depend on external resources
+
+### Part D: Create a minimal render test
+
+Create `scripts/test-ink.tsx`:
+```tsx
+// scripts/test-ink.tsx
+// Minimal test that the Ink terminal UI renders
+// Usage: bun scripts/test-ink.tsx
+
+import React from 'react'
+
+// We need the shims loaded first
+import './src/shims/preload.js'
+
+// Now try to use Ink
+import { render } from './src/ink.js'
+
+// Minimal component
+function Hello() {
+ return Hello from Claude Code Ink UI!
+}
+
+// Need to import Text from Ink
+import { Text } from './src/ink/components/Text.js'
+
+async function main() {
+ const instance = await render()
+ // Give it a moment to render
+ setTimeout(() => {
+ instance.unmount()
+ process.exit(0)
+ }, 500)
+}
+
+main().catch(err => {
+ console.error('Ink render test failed:', err)
+ process.exit(1)
+})
+```
+
+Adjust the imports based on what you find — the Text component path may differ.
+
+### Part E: Fix any issues
+
+If Ink rendering fails, the common issues are:
+1. **Missing `yoga-wasm-web` or `yoga-layout`** — Ink uses Yoga for flexbox layout. Check if there's a Yoga dependency or if it's embedded.
+2. **React version mismatch** — The code uses React 19. Verify the reconciler is compatible.
+3. **Terminal detection** — Ink checks if stdout is a TTY. In some environments this may need to be forced.
+4. **Missing chalk/ansi dependency** — Terminal colors.
+
+Fix whatever you find to make the test render successfully.
+
+### Part F: Verify component imports
+
+Check that `src/components/` components can import from the Ink system without errors. Pick 3-5 key components:
+- `src/components/MessageResponse.tsx` (or similar — the main chat message renderer)
+- `src/components/ToolUseResult.tsx` (or similar — tool output display)
+- `src/components/PermissionRequest.tsx` (or similar — permission modal)
+
+Read their imports and verify nothing is missing.
+
+## Verification
+
+1. `scripts/test-ink.tsx` renders "Hello from Claude Code Ink UI!" to the terminal
+2. No new TypeScript errors introduced
+3. You've documented the render pipeline flow
diff --git a/prompts/07-tool-system.md b/prompts/07-tool-system.md
new file mode 100644
index 0000000..e93e15f
--- /dev/null
+++ b/prompts/07-tool-system.md
@@ -0,0 +1,104 @@
+# Prompt 07: Audit and Wire Up the Tool System
+
+## Context
+
+You are working in `/workspaces/claude-code`. The Claude Code CLI has ~40 tools that the LLM can invoke during conversations. Each tool is in `src/tools//` and follows a consistent pattern.
+
+Key files:
+- `src/Tool.ts` (~29K lines) — Tool type definitions, `ToolUseContext`, `PermissionResult`, etc.
+- `src/tools.ts` — Tool registry (`getTools()` function that returns all available tools)
+- `src/tools/` — Individual tool directories
+
+## Task
+
+### Part A: Understand the Tool interface
+
+Read `src/Tool.ts` and document the `Tool` interface. Key questions:
+1. What fields does a `Tool` have? (name, description, inputSchema, execute, etc.)
+2. What is `ToolUseContext`? What does it provide to tool execution?
+3. How do tool permissions work? (`PermissionResult`, `needsPermission`)
+4. How do tools declare their input schema? (JSON Schema / Zod)
+
+### Part B: Audit the tool registry
+
+Read `src/tools.ts` fully. It dynamically imports tools behind feature flags and env checks:
+```ts
+const REPLTool = process.env.USER_TYPE === 'ant' ? ... : null
+const SleepTool = feature('PROACTIVE') || feature('KAIROS') ? ... : null
+```
+
+Create a complete inventory of:
+1. **Always-available tools** — imported unconditionally
+2. **Feature-gated tools** — which feature flag enables them
+3. **Ant-only tools** — gated behind `USER_TYPE === 'ant'` (Anthropic internal)
+4. **Broken/missing tools** — any tools referenced but not found
+
+### Part C: Verify each tool compiles
+
+For each tool directory in `src/tools/`, check:
+1. Does it have an `index.ts` or main file?
+2. Does it export a tool definition matching the `Tool` interface?
+3. Are its imports resolvable?
+
+Focus on the **core 10 tools** that are essential for basic operation:
+- `BashTool` — shell command execution
+- `FileReadTool` — read files
+- `FileWriteTool` — write files
+- `FileEditTool` — edit files (search & replace)
+- `GlobTool` — find files by pattern
+- `GrepTool` — search file contents
+- `AgentTool` — spawn sub-agent
+- `WebFetchTool` — HTTP requests
+- `AskUserQuestionTool` — ask the user for input
+- `TodoWriteTool` — todo list management
+
+### Part D: Fix import issues
+
+The tool registry (`src/tools.ts`) uses dynamic imports with `bun:bundle` feature flags. With our runtime shim, these should work — but verify:
+
+1. Feature-gated imports resolve when the flag is `false` (should be skipped)
+2. Feature-gated imports resolve when the flag is `true` (should load)
+3. Ant-only tools gracefully handle `process.env.USER_TYPE !== 'ant'`
+
+Fix any import resolution errors.
+
+### Part E: Create a tool smoke test
+
+Create `scripts/test-tools.ts`:
+```ts
+// scripts/test-tools.ts
+// Verify all tools load without errors
+// Usage: bun scripts/test-tools.ts
+
+import './src/shims/preload.js'
+
+async function main() {
+ const { getTools } = await import('./src/tools.js')
+
+ // getTools() may need arguments — check its signature
+ const tools = getTools(/* ... */)
+
+ console.log(`Loaded ${tools.length} tools:\n`)
+ for (const tool of tools) {
+ console.log(` ✓ ${tool.name}`)
+ }
+}
+
+main().catch(err => {
+ console.error('Tool loading failed:', err)
+ process.exit(1)
+})
+```
+
+Adapt the script to match the actual `getTools()` signature.
+
+### Part F: Stub Anthropic-internal tools
+
+Any tools gated behind `USER_TYPE === 'ant'` should be cleanly excluded. Verify the null checks work and don't cause runtime errors when these tools are missing from the registry.
+
+## Verification
+
+1. `scripts/test-tools.ts` runs and lists all available tools without errors
+2. The core 10 tools listed above are all present
+3. No TypeScript errors in `src/tools/` or `src/tools.ts`
+4. Ant-only tools are cleanly excluded (no crashes)
diff --git a/prompts/08-command-system.md b/prompts/08-command-system.md
new file mode 100644
index 0000000..69e3a6c
--- /dev/null
+++ b/prompts/08-command-system.md
@@ -0,0 +1,90 @@
+# Prompt 08: Audit and Wire Up the Command System
+
+## Context
+
+You are working in `/workspaces/claude-code`. The CLI has ~50 slash commands (e.g., `/commit`, `/review`, `/init`, `/config`). These are registered in `src/commands.ts` and implemented in `src/commands/`.
+
+Key files:
+- `src/commands.ts` (~25K lines) — Command registry (`getCommands()`)
+- `src/commands/` — Individual command implementations
+- `src/types/command.ts` — Command type definition
+
+## Task
+
+### Part A: Understand the Command interface
+
+Read `src/types/command.ts` and the top of `src/commands.ts`. Document:
+1. The `Command` type (name, description, execute, args, etc.)
+2. How commands are registered
+3. How command execution is triggered (from the REPL? from CLI args?)
+
+### Part B: Audit the command registry
+
+Read `src/commands.ts` fully. Create a complete inventory of all commands, organized by category:
+
+**Essential commands** (needed for basic operation):
+- `/help` — show help
+- `/config` — view/edit configuration
+- `/init` — initialize a project
+- `/commit` — git commit
+- `/review` — code review
+
+**Feature-gated commands** (behind feature flags or USER_TYPE):
+- List which flag enables each
+
+**Potentially broken commands** (reference missing imports or services):
+- List any that can't resolve their imports
+
+### Part C: Verify core commands compile
+
+For the essential commands listed above, read their implementations and check:
+1. All imports resolve
+2. They don't depend on unavailable services
+3. The function signatures match the Command type
+
+### Part D: Fix import issues
+
+Similar to the tool system, commands may have:
+- Feature-gated imports that need the `bun:bundle` shim
+- Ant-only code paths
+- Dynamic imports that need correct paths
+
+Fix whatever is broken.
+
+### Part E: Handle "moved to plugin" commands
+
+There's a file `src/commands/createMovedToPluginCommand.ts`. Read it — some commands have been migrated to the plugin system. These should gracefully tell the user the command has moved, not crash.
+
+### Part F: Create a command smoke test
+
+Create `scripts/test-commands.ts`:
+```ts
+// scripts/test-commands.ts
+// Verify all commands load without errors
+// Usage: bun scripts/test-commands.ts
+
+import './src/shims/preload.js'
+
+async function main() {
+ const { getCommands } = await import('./src/commands.js')
+
+ const commands = getCommands(/* check signature */)
+
+ console.log(`Loaded ${commands.length} commands:\n`)
+ for (const cmd of commands) {
+ console.log(` /${cmd.name} — ${cmd.description || '(no description)'}`)
+ }
+}
+
+main().catch(err => {
+ console.error('Command loading failed:', err)
+ process.exit(1)
+})
+```
+
+## Verification
+
+1. `scripts/test-commands.ts` lists all available commands
+2. Core commands (`/help`, `/config`, `/init`, `/commit`) are present
+3. No runtime crashes from missing imports
+4. Moved-to-plugin commands show a friendly message instead of crashing
diff --git a/prompts/09-query-engine.md b/prompts/09-query-engine.md
new file mode 100644
index 0000000..55f44aa
--- /dev/null
+++ b/prompts/09-query-engine.md
@@ -0,0 +1,118 @@
+# Prompt 09: Get the QueryEngine (Core LLM Loop) Functional
+
+## Context
+
+You are working in `/workspaces/claude-code`. The `QueryEngine` (`src/QueryEngine.ts`, ~46K lines) is the heart of the CLI — it:
+1. Sends messages to the Anthropic API (streaming)
+2. Processes streaming responses (text, thinking, tool_use blocks)
+3. Executes tools when the LLM requests them (tool loop)
+4. Handles retries, rate limits, and errors
+5. Tracks token usage and costs
+6. Manages conversation context (message history)
+
+This is the most complex single file. The goal is to get it functional enough for a basic conversation loop.
+
+## Key Dependencies
+
+The QueryEngine depends on:
+- `src/services/api/client.ts` — Anthropic SDK client
+- `src/services/api/claude.ts` — Message API wrapper
+- `src/Tool.ts` — Tool definitions
+- `src/tools.ts` — Tool registry
+- `src/context.ts` — System context
+- `src/constants/prompts.ts` — System prompt
+- Token counting utilities
+- Streaming event handlers
+
+## Task
+
+### Part A: Map the QueryEngine architecture
+
+Read `src/QueryEngine.ts` and create a structural map:
+1. **Class structure** — What classes/interfaces are defined?
+2. **Public API** — What method starts a query? What does it return?
+3. **Message flow** — How does a user message become an API call?
+4. **Tool loop** — How are tool calls detected, executed, and fed back?
+5. **Streaming** — How are streaming events processed?
+6. **Retry logic** — How are API errors handled?
+
+### Part B: Trace the API call path
+
+Follow the chain from QueryEngine → API client:
+1. Read `src/services/api/client.ts` — how is the Anthropic SDK client created?
+2. Read `src/services/api/claude.ts` — what's the message creation wrapper?
+3. What parameters are passed? (model, max_tokens, system prompt, tools, messages)
+4. How is streaming handled? (SSE? SDK streaming?)
+
+### Part C: Identify and fix blockers
+
+The QueryEngine will have dependencies on many subsystems. For each dependency:
+- **If it's essential** (API client, tool execution) → make sure it works
+- **If it's optional** (analytics, telemetry, policy limits) → stub or skip it
+
+Common blockers:
+1. **Missing API configuration** → needs `ANTHROPIC_API_KEY` (Prompt 05)
+2. **Policy limits service** → may block execution, needs stubbing
+3. **GrowthBook/analytics** → needs stubbing or graceful failure
+4. **Remote managed settings** → needs stubbing
+5. **Bootstrap data fetch** → may need to be optional
+
+### Part D: Create a minimal conversation test
+
+Create `scripts/test-query.ts` that exercises the QueryEngine directly:
+
+```ts
+// scripts/test-query.ts
+// Minimal test of the QueryEngine — single query, no REPL
+// Usage: ANTHROPIC_API_KEY=sk-ant-... bun scripts/test-query.ts "What is 2+2?"
+
+import './src/shims/preload.js'
+
+async function main() {
+ const query = process.argv[2] || 'What is 2+2?'
+
+ // Import and set up minimal dependencies
+ // You'll need to figure out the exact imports and initialization
+ // by reading src/QueryEngine.ts, src/query.ts, and src/replLauncher.tsx
+
+ // The basic flow should be:
+ // 1. Create API client
+ // 2. Build system prompt
+ // 3. Create QueryEngine instance
+ // 4. Send a query
+ // 5. Print the response
+
+ console.log(`Query: ${query}`)
+ console.log('---')
+
+ // TODO: Wire up the actual QueryEngine call
+ // This is the hardest part — document what you need to do
+}
+
+main().catch(err => {
+ console.error('Query test failed:', err)
+ process.exit(1)
+})
+```
+
+### Part E: Handle the streaming response
+
+The QueryEngine likely uses the Anthropic SDK's streaming interface. Make sure:
+1. Text content is printed to stdout as it streams
+2. Thinking blocks are handled (displayed or hidden based on config)
+3. Tool use blocks trigger tool execution
+4. The tool loop feeds results back and continues
+
+### Part F: Document what's still broken
+
+After getting a basic query working, document:
+1. Which features work
+2. Which features are stubbed
+3. What would need to happen for full functionality
+
+## Verification
+
+1. `ANTHROPIC_API_KEY=sk-ant-... bun scripts/test-query.ts "What is 2+2?"` gets a response
+2. Streaming output appears in real-time
+3. No unhandled crashes (graceful error messages are fine)
+4. Architecture is documented
diff --git a/prompts/10-context-and-prompts.md b/prompts/10-context-and-prompts.md
new file mode 100644
index 0000000..f43ee55
--- /dev/null
+++ b/prompts/10-context-and-prompts.md
@@ -0,0 +1,100 @@
+# Prompt 10: Wire Up System Prompt, Context Gathering & Memory System
+
+## Context
+
+You are working in `/workspaces/claude-code`. The CLI constructs a detailed system prompt before each conversation. This prompt includes:
+1. **Static instructions** — core behavior rules (from `src/constants/prompts.ts`)
+2. **Dynamic context** — OS, shell, git status, working directory (from `src/context.ts`)
+3. **Tool descriptions** — auto-generated from tool schemas
+4. **Memory** — persistent `.claude.md` files (from `src/memdir/`)
+5. **User context** — config, preferences, project settings
+
+## Key Files
+
+- `src/constants/prompts.ts` — System prompt construction
+- `src/constants/system.ts` — System identity strings
+- `src/context.ts` — OS/shell/git context collection
+- `src/context/` — Additional context modules
+- `src/memdir/` — Memory directory system (reads `.claude.md`, `CLAUDE.md` files)
+- `src/utils/messages.ts` — Message construction helpers
+
+## Task
+
+### Part A: Trace the system prompt construction
+
+Read `src/constants/prompts.ts` and map:
+1. What is `getSystemPrompt()`'s signature and return type?
+2. What sections does the system prompt contain?
+3. How are tools described in the prompt?
+4. What model-specific variations exist?
+5. Where does the `MACRO.ISSUES_EXPLAINER` reference resolve to?
+
+### Part B: Fix the context gathering
+
+Read `src/context.ts` and:
+1. Understand `getSystemContext()` and `getUserContext()`
+2. These collect OS info, shell version, git status, etc.
+3. Verify they work on Linux (this codebase was likely developed on macOS, so some paths may be macOS-specific)
+4. Fix any platform-specific issues
+
+### Part C: Wire up the memory system
+
+Read `src/memdir/` directory:
+1. How does it find `.claude.md` / `CLAUDE.md` files?
+2. How is memory content injected into the system prompt?
+3. Does it support project-level, user-level, and session-level memory?
+
+Verify it works by:
+1. Creating a test `CLAUDE.md` in the project root
+2. Running the system prompt builder
+3. Checking the memory appears in the output
+
+### Part D: Create a prompt inspection script
+
+Create `scripts/test-prompt.ts`:
+```ts
+// scripts/test-prompt.ts
+// Dump the full system prompt that would be sent to the API
+// Usage: bun scripts/test-prompt.ts
+
+import './src/shims/preload.js'
+
+async function main() {
+ // Import the prompt builder
+ const { getSystemPrompt } = await import('./src/constants/prompts.js')
+
+ // May need to pass tools list and model name
+ // Check the function signature
+ const prompt = await getSystemPrompt([], 'claude-sonnet-4-20250514')
+
+ console.log('=== SYSTEM PROMPT ===')
+ console.log(prompt.join('\n'))
+ console.log('=== END ===')
+ console.log(`\nTotal length: ${prompt.join('\n').length} characters`)
+}
+
+main().catch(err => {
+ console.error('Prompt test failed:', err)
+ process.exit(1)
+})
+```
+
+### Part E: Fix MACRO references in prompts
+
+The prompt system references `MACRO.ISSUES_EXPLAINER`. Make sure our `MACRO` global (from `src/shims/macro.ts`) provides this value. If the prompt references other `MACRO` fields, add them too.
+
+### Part F: Context module audit
+
+Check `src/context/` for additional context modules:
+- Project detection (language, framework)
+- Git integration (branch, status, recent commits)
+- Environment detection (CI, container, SSH)
+
+Verify these work in our dev environment.
+
+## Verification
+
+1. `bun scripts/test-prompt.ts` dumps a complete system prompt
+2. The prompt includes: tool descriptions, OS context, memory content
+3. No `undefined` or `MACRO.` references in the output
+4. Memory system reads `.claude.md` from the project root
diff --git a/prompts/11-mcp-integration.md b/prompts/11-mcp-integration.md
new file mode 100644
index 0000000..857e57d
--- /dev/null
+++ b/prompts/11-mcp-integration.md
@@ -0,0 +1,113 @@
+# Prompt 11: MCP Client/Server Integration
+
+## Context
+
+You are working in `/workspaces/claude-code`. The CLI has built-in MCP (Model Context Protocol) support:
+- **MCP Client** — connects to external MCP servers (tools, resources)
+- **MCP Server** — exposes Claude Code itself as an MCP server
+
+MCP lets the CLI use tools provided by external servers and lets other clients use Claude Code as a tool provider.
+
+## Key Files
+
+- `src/services/mcp/` — MCP client implementation
+- `src/services/mcp/types.ts` — MCP config types
+- `src/entrypoints/mcp.ts` — MCP server mode entrypoint
+- `src/tools/MCPTool/` — Tool that calls MCP servers
+- `src/tools/ListMcpResourcesTool/` — Lists MCP resources
+- `src/tools/ReadMcpResourceTool/` — Reads MCP resources
+- `src/tools/McpAuthTool/` — MCP server authentication
+- `mcp-server/` — Standalone MCP server sub-project (from Prompt 04)
+
+## Task
+
+### Part A: Understand MCP client architecture
+
+Read `src/services/mcp/` directory:
+1. How are MCP servers discovered? (`.mcp.json` config file?)
+2. How are MCP server connections established? (stdio, HTTP, SSE?)
+3. How are MCP tools registered and made available?
+4. What is the `ScopedMcpServerConfig` type?
+
+### Part B: Understand MCP config format
+
+Search for `.mcp.json` or MCP config loading code. Document:
+1. Where does the config file live? (`~/.claude/.mcp.json`? project root?)
+2. What's the config schema? (server name, command, args, env?)
+3. How are multiple servers configured?
+
+Example config you might find:
+```json
+{
+ "mcpServers": {
+ "my-server": {
+ "command": "node",
+ "args": ["path/to/server.js"],
+ "env": {}
+ }
+ }
+}
+```
+
+### Part C: Verify MCP SDK integration
+
+The project uses `@modelcontextprotocol/sdk` (^1.12.1). Check:
+1. Is it installed in `node_modules/`?
+2. Does the import work: `import { Client } from '@modelcontextprotocol/sdk/client/index.js'`
+3. Are there version compatibility issues?
+
+### Part D: Test MCP client with our own server
+
+Create a test that:
+1. Starts the `mcp-server/` we fixed in Prompt 04 as a child process
+2. Connects to it via stdio using the MCP client from `src/services/mcp/`
+3. Lists available tools
+4. Calls one tool (e.g., `list_files` or `search_code`)
+
+Create `scripts/test-mcp.ts`:
+```ts
+// scripts/test-mcp.ts
+// Test MCP client/server roundtrip
+// Usage: bun scripts/test-mcp.ts
+
+import './src/shims/preload.js'
+
+// TODO:
+// 1. Spawn mcp-server as a child process (stdio transport)
+// 2. Create MCP client from src/services/mcp/
+// 3. Connect client to server
+// 4. List tools
+// 5. Call a tool
+// 6. Print results
+```
+
+### Part E: Test MCP server mode
+
+The CLI can run as an MCP server itself (`src/entrypoints/mcp.ts`). Read this file and verify:
+1. What tools does it expose?
+2. What resources does it provide?
+3. Can it be started with `bun src/entrypoints/mcp.ts`?
+
+### Part F: Create sample MCP config
+
+Create a `.mcp.json` in the project root (or wherever the app looks for it) that configures the local MCP server:
+```json
+{
+ "mcpServers": {
+ "claude-code-explorer": {
+ "command": "node",
+ "args": ["mcp-server/dist/index.js"],
+ "env": {
+ "CLAUDE_CODE_SRC_ROOT": "./src"
+ }
+ }
+ }
+}
+```
+
+## Verification
+
+1. MCP client code in `src/services/mcp/` loads without errors
+2. MCP server mode (`src/entrypoints/mcp.ts`) starts without crashing
+3. A roundtrip test (client → server → response) works
+4. `.mcp.json` config file is created and parseable
diff --git a/prompts/12-services-layer.md b/prompts/12-services-layer.md
new file mode 100644
index 0000000..afdbdca
--- /dev/null
+++ b/prompts/12-services-layer.md
@@ -0,0 +1,123 @@
+# Prompt 12: Wire Up Services Layer (Analytics, Policy, Settings, Sessions)
+
+## Context
+
+You are working in `/workspaces/claude-code`. The CLI has several background services that run during operation:
+- **Analytics/Telemetry** — GrowthBook feature flags, OpenTelemetry traces
+- **Policy Limits** — rate limiting, quota enforcement from Anthropic backend
+- **Remote Managed Settings** — server-pushed configuration
+- **Session Memory** — persistent conversation history across invocations
+- **Bootstrap Data** — initial config fetched from API on startup
+
+Most of these talk to Anthropic's backend servers and will fail in our dev build. The goal is to make them fail gracefully (not crash the app) or provide stubs.
+
+## Key Files
+
+- `src/services/analytics/growthbook.ts` — GrowthBook feature flag client
+- `src/services/analytics/` — Telemetry, event logging
+- `src/services/policyLimits/` — Rate limit enforcement
+- `src/services/remoteManagedSettings/` — Server-pushed settings
+- `src/services/SessionMemory/` — Conversation persistence
+- `src/services/api/bootstrap.ts` — Initial data fetch
+- `src/entrypoints/init.ts` — Where most services are initialized
+- `src/cost-tracker.ts` — Token usage and cost tracking
+
+## Task
+
+### Part A: Map the initialization sequence
+
+Read `src/entrypoints/init.ts` carefully. Document:
+1. What services are initialized, in what order?
+2. Which are blocking (must complete before app starts)?
+3. Which are fire-and-forget (async, can fail silently)?
+4. What happens if each one fails?
+
+### Part B: Make GrowthBook optional
+
+Read `src/services/analytics/growthbook.ts`:
+1. How is GrowthBook initialized?
+2. Where is it called from? (feature flag checks throughout the codebase)
+3. What happens if initialization fails?
+
+**Goal**: Make GrowthBook fail silently — all feature flag checks should return `false` (default) if GrowthBook is unavailable. This may already be handled, but verify it.
+
+### Part C: Stub policy limits
+
+Read `src/services/policyLimits/`:
+1. What limits does it enforce? (messages per minute, tokens per day, etc.)
+2. What happens when a limit is hit?
+3. Where is `loadPolicyLimits()` called?
+
+**Goal**: Make the app work without policy limits. Either:
+- Stub the service to return "no limits" (allow everything)
+- Or catch and ignore errors from the API call
+
+### Part D: Make remote settings optional
+
+Read `src/services/remoteManagedSettings/`:
+1. What settings does it manage?
+2. What's the fallback when the server is unreachable?
+
+**Goal**: Ensure the app works with default settings when the remote endpoint fails.
+
+### Part E: Handle bootstrap data
+
+Read `src/services/api/bootstrap.ts`:
+1. What data does it fetch?
+2. What uses this data?
+3. What happens if the fetch fails?
+
+**Goal**: Provide sensible defaults when bootstrap fails (no API key = no bootstrap).
+
+### Part F: Verify session memory
+
+Read `src/services/SessionMemory/`:
+1. Where is session data stored? (filesystem path)
+2. How are sessions identified?
+3. Does it work with the local filesystem?
+
+**Goal**: Session memory should work out of the box since it's local filesystem.
+
+### Part G: Wire up cost tracking
+
+Read `src/cost-tracker.ts`:
+1. How are costs calculated?
+2. Where is usage reported?
+3. Does it persist across sessions?
+
+**Goal**: Cost tracking should work locally (just display, no remote reporting needed).
+
+### Part H: Create a services smoke test
+
+Create `scripts/test-services.ts`:
+```ts
+// scripts/test-services.ts
+// Test that all services initialize without crashing
+// Usage: bun scripts/test-services.ts
+
+import './src/shims/preload.js'
+
+async function main() {
+ console.log('Testing service initialization...')
+
+ // Try to run the init sequence
+ try {
+ const { init } = await import('./src/entrypoints/init.js')
+ await init()
+ console.log('✅ Services initialized')
+ } catch (err: any) {
+ console.error('❌ Init failed:', err.message)
+ // Document which service failed and why
+ }
+}
+
+main()
+```
+
+## Verification
+
+1. `bun scripts/test-services.ts` completes without crashing (warnings are fine)
+2. Missing remote services log warnings, not crashes
+3. Session memory reads/writes to the local filesystem
+4. Cost tracking displays locally
+5. The app can start even when Anthropic's backend is unreachable (with just an API key)
diff --git a/prompts/13-bridge-ide.md b/prompts/13-bridge-ide.md
new file mode 100644
index 0000000..9b585ab
--- /dev/null
+++ b/prompts/13-bridge-ide.md
@@ -0,0 +1,75 @@
+# Prompt 13: Bridge Layer (VS Code / JetBrains IDE Integration)
+
+## Context
+
+You are working in `/workspaces/claude-code`. The "Bridge" is the subsystem that connects Claude Code to IDE extensions (VS Code, JetBrains). It enables:
+- Remote control of Claude Code from an IDE
+- Sharing file context between IDE and CLI
+- Permission approvals from the IDE UI
+- Session management across IDE and terminal
+
+The Bridge is **gated behind `feature('BRIDGE_MODE')`** and is the most complex optional subsystem (~30 files in `src/bridge/`).
+
+## Key Files
+
+- `src/bridge/bridgeMain.ts` — Main bridge orchestration
+- `src/bridge/bridgeApi.ts` — Bridge API endpoints
+- `src/bridge/bridgeMessaging.ts` — WebSocket/HTTP messaging
+- `src/bridge/bridgeConfig.ts` — Bridge configuration
+- `src/bridge/bridgeUI.ts` — Bridge UI rendering
+- `src/bridge/jwtUtils.ts` — JWT authentication for bridge connections
+- `src/bridge/types.ts` — Bridge types
+- `src/bridge/initReplBridge.ts` — REPL integration
+- `src/bridge/replBridge.ts` — REPL bridge handle
+
+## Task
+
+### Part A: Understand the bridge architecture
+
+Read `src/bridge/types.ts` and `src/bridge/bridgeMain.ts` (first 100 lines). Document:
+1. What protocols does the bridge use? (WebSocket, HTTP polling, etc.)
+2. How does authentication work? (JWT)
+3. What messages flow between IDE and CLI?
+4. How is the bridge lifecycle managed?
+
+### Part B: Assess what's needed vs. what can be deferred
+
+The bridge is a **nice-to-have** for initial build-out. Categorize:
+1. **Must work**: Feature flag gate (`feature('BRIDGE_MODE')` returns `false` → bridge code is skipped)
+2. **Can defer**: Full bridge functionality
+3. **Might break**: Code paths that assume bridge is available even when disabled
+
+### Part C: Verify the feature gate works
+
+Ensure that when `CLAUDE_CODE_BRIDGE_MODE=false` (or unset):
+1. Bridge code is not imported
+2. Bridge initialization is skipped
+3. No bridge-related errors appear
+4. The CLI works normally in terminal-only mode
+
+### Part D: Stub the bridge for safety
+
+If any code paths reference bridge functionality outside the feature gate:
+1. Create `src/bridge/stub.ts` with no-op implementations
+2. Make sure imports from `src/bridge/` resolve without crashing
+3. Ensure the REPL works without bridge
+
+### Part E: Document bridge activation
+
+For future work, document what would be needed to enable the bridge:
+1. Set `CLAUDE_CODE_BRIDGE_MODE=true`
+2. What IDE extension is needed?
+3. What authentication setup is required?
+4. What ports/sockets does it use?
+
+### Part F: Check the Chrome extension bridge
+
+There's a `--claude-in-chrome-mcp` and `--chrome-native-host` mode referenced in `src/entrypoints/cli.tsx`. Read these paths and document what they do. These can be deferred — just make sure they don't crash when not in use.
+
+## Verification
+
+1. CLI works normally with bridge disabled (default)
+2. No bridge-related errors in stdout/stderr
+3. `feature('BRIDGE_MODE')` correctly returns `false`
+4. Bridge architecture is documented for future enablement
+5. No dangling imports that crash when bridge is off
diff --git a/prompts/14-dev-runner.md b/prompts/14-dev-runner.md
new file mode 100644
index 0000000..6b32816
--- /dev/null
+++ b/prompts/14-dev-runner.md
@@ -0,0 +1,137 @@
+# Prompt 14: Create Development Runner
+
+## Context
+
+You are working in `/workspaces/claude-code`. By now you should have:
+- Bun installed (Prompt 01)
+- Runtime shims for `bun:bundle` and `MACRO` (Prompt 02)
+- A build system (Prompt 03)
+- Environment config (Prompt 05)
+
+Now we need a way to **run the CLI in development mode** — quickly launching it without a full production build.
+
+## Task
+
+### Part A: Create `bun run dev` script
+
+Bun can run TypeScript directly without compilation. Create a development launcher.
+
+**Option 1: Direct Bun execution** (preferred)
+
+Create `scripts/dev.ts`:
+```ts
+// scripts/dev.ts
+// Development launcher — runs the CLI directly via Bun
+// Usage: bun scripts/dev.ts [args...]
+// Or: bun run dev [args...]
+
+// Load shims first
+import '../src/shims/preload.js'
+
+// Register bun:bundle module resolver
+// Since Bun natively supports the module, we may need to
+// register our shim. Check if this is needed.
+
+// Launch the CLI
+await import('../src/entrypoints/cli.js')
+```
+
+**Option 2: Bun with preload**
+
+Use Bun's `--preload` flag:
+```bash
+bun --preload ./src/shims/preload.ts src/entrypoints/cli.tsx
+```
+
+**Investigate which approach works** with the `bun:bundle` import. The tricky part is that `bun:bundle` is a special Bun module name — at runtime (without the bundler), Bun may not recognize it. You'll need to either:
+1. Use Bun's `bunfig.toml` to create a module alias
+2. Use a loader/plugin to intercept the import
+3. Use a pre-transform step to rewrite imports
+
+### Part B: Handle the `bun:bundle` import at runtime
+
+This is the critical challenge. Options to investigate:
+
+**Option A: `bunfig.toml` alias**
+```toml
+[resolve]
+alias = { "bun:bundle" = "./src/shims/bun-bundle.ts" }
+```
+
+**Option B: Bun plugin**
+Create a Bun plugin that intercepts `bun:bundle`:
+```ts
+// scripts/bun-plugin-shims.ts
+import { plugin } from 'bun'
+
+plugin({
+ name: 'bun-bundle-shim',
+ setup(build) {
+ build.onResolve({ filter: /^bun:bundle$/ }, () => ({
+ path: resolve(import.meta.dir, '../src/shims/bun-bundle.ts'),
+ }))
+ },
+})
+```
+Then reference it in `bunfig.toml`:
+```toml
+preload = ["./scripts/bun-plugin-shims.ts"]
+```
+
+**Option C: Patch at build time**
+If runtime aliasing doesn't work, use a quick pre-build transform that replaces `from 'bun:bundle'` with `from '../shims/bun-bundle.js'` across all files, outputting to a temp directory.
+
+**Try the options in order** and go with whichever works.
+
+### Part C: Add npm scripts
+
+Add to `package.json`:
+```json
+{
+ "scripts": {
+ "dev": "bun scripts/dev.ts",
+ "dev:repl": "bun scripts/dev.ts --repl",
+ "start": "bun scripts/dev.ts"
+ }
+}
+```
+
+### Part D: Create a `.env` loader
+
+If the dev script doesn't automatically load `.env`, add dotenv support:
+```bash
+bun add -d dotenv-cli
+```
+Then wrap the dev command:
+```json
+"dev": "dotenv -e .env -- bun scripts/dev.ts"
+```
+
+Or use Bun's built-in `.env` loading (Bun automatically reads `.env` files).
+
+### Part E: Test the development runner
+
+1. Set `ANTHROPIC_API_KEY` in `.env`
+2. Run `bun run dev --version` → should print version
+3. Run `bun run dev --help` → should print help text
+4. Run `bun run dev` → should start the interactive REPL (will need working Ink UI)
+5. Run `ANTHROPIC_API_KEY=sk-ant-... bun run dev -p "say hello"` → should make one API call and print response
+
+### Part F: Add debug mode
+
+Add a debug script that enables verbose logging:
+```json
+{
+ "scripts": {
+ "dev:debug": "CLAUDE_CODE_DEBUG_LOG_LEVEL=debug bun scripts/dev.ts"
+ }
+}
+```
+
+## Verification
+
+1. `bun run dev --version` prints the version
+2. `bun run dev --help` prints help without errors
+3. The `bun:bundle` import resolves correctly at runtime
+4. `.env` variables are loaded
+5. No module resolution errors on startup
diff --git a/prompts/15-production-bundle.md b/prompts/15-production-bundle.md
new file mode 100644
index 0000000..10f4487
--- /dev/null
+++ b/prompts/15-production-bundle.md
@@ -0,0 +1,123 @@
+# Prompt 15: Production Bundle & Packaging
+
+## Context
+
+You are working in `/workspaces/claude-code`. By now you should have a working development runner (Prompt 14) and build system (Prompt 03). This prompt focuses on creating a production-quality bundle.
+
+## Task
+
+### Part A: Optimize the esbuild configuration
+
+Update `scripts/build-bundle.ts` for production:
+
+1. **Tree shaking** — esbuild does this by default, but verify:
+ - Feature-gated code with `if (feature('X'))` where X is `false` should be eliminated
+ - `process.env.USER_TYPE === 'ant'` branches should be eliminated (set `define` to replace with `false`)
+
+2. **Define replacements** — Inline constants at build time:
+ ```ts
+ define: {
+ 'process.env.USER_TYPE': '"external"', // Not 'ant' (Anthropic internal)
+ 'process.env.NODE_ENV': '"production"',
+ }
+ ```
+
+3. **Minification** — Enable for production (`--minify` flag)
+
+4. **Source maps** — External source maps for production debugging
+
+5. **Target** — Ensure compatibility with both Bun 1.1+ and Node.js 20+
+
+### Part B: Handle chunking/splitting
+
+The full bundle will be large (~2-5 MB minified). Consider:
+1. **Single file** — Simplest, works everywhere (recommended for CLI tools)
+2. **Code splitting** — Multiple chunks, only useful if we want lazy loading
+
+Go with single file unless it causes issues.
+
+### Part C: Create the executable
+
+After bundling to `dist/cli.mjs`:
+
+1. **Add shebang** — `#!/usr/bin/env node` (already in banner)
+2. **Make executable** — `chmod +x dist/cli.mjs`
+3. **Test it runs** — `./dist/cli.mjs --version`
+
+### Part D: Platform packaging
+
+Create packaging scripts for distribution:
+
+**npm package** (`scripts/package-npm.ts`):
+```ts
+// Generate a publishable npm package in dist/npm/
+// - package.json with bin, main, version
+// - The bundled CLI file
+// - README.md
+```
+
+**Standalone binary** (optional, via Bun):
+```bash
+bun build --compile src/entrypoints/cli.tsx --outfile dist/claude
+```
+This creates a single binary with Bun runtime embedded. Not all features will work, but it's worth testing.
+
+### Part E: Docker build
+
+Update the existing `Dockerfile` to produce a runnable container:
+
+```dockerfile
+FROM oven/bun:1-alpine AS builder
+WORKDIR /app
+COPY package.json bun.lockb* ./
+RUN bun install --frozen-lockfile || bun install
+COPY . .
+RUN bun run build:prod
+
+FROM oven/bun:1-alpine
+WORKDIR /app
+COPY --from=builder /app/dist/cli.mjs /app/
+RUN apk add --no-cache git ripgrep
+ENTRYPOINT ["bun", "/app/cli.mjs"]
+```
+
+### Part F: Verify production build
+
+1. `bun run build:prod` succeeds
+2. `ls -lh dist/cli.mjs` — check file size
+3. `node dist/cli.mjs --version` — works with Node.js
+4. `bun dist/cli.mjs --version` — works with Bun
+5. `ANTHROPIC_API_KEY=... node dist/cli.mjs -p "hello"` — end-to-end works
+
+### Part G: CI build script
+
+Create `scripts/ci-build.sh`:
+```bash
+#!/bin/bash
+set -euo pipefail
+
+echo "=== Installing dependencies ==="
+bun install
+
+echo "=== Type checking ==="
+bun run typecheck
+
+echo "=== Linting ==="
+bun run lint
+
+echo "=== Building ==="
+bun run build:prod
+
+echo "=== Verifying build ==="
+node dist/cli.mjs --version
+
+echo "=== Done ==="
+```
+
+## Verification
+
+1. `bun run build:prod` produces `dist/cli.mjs`
+2. The bundle is < 10 MB (ideally < 5 MB)
+3. `node dist/cli.mjs --version` works
+4. `docker build .` succeeds (if Docker is available)
+5. CI script runs end-to-end without errors
diff --git a/prompts/16-testing.md b/prompts/16-testing.md
new file mode 100644
index 0000000..fbc02f0
--- /dev/null
+++ b/prompts/16-testing.md
@@ -0,0 +1,125 @@
+# Prompt 16: Add Test Infrastructure & Smoke Tests
+
+## Context
+
+You are working in `/workspaces/claude-code`. The leaked source does not include any test files or test configuration (they were presumably in a separate directory or repo). We need to add a test framework and write smoke tests for core subsystems.
+
+## Task
+
+### Part A: Set up Vitest
+
+```bash
+bun add -d vitest @types/node
+```
+
+Create `vitest.config.ts`:
+```ts
+import { defineConfig } from 'vitest/config'
+import { resolve } from 'path'
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ setupFiles: ['tests/setup.ts'],
+ testTimeout: 30000,
+ },
+ resolve: {
+ alias: {
+ 'bun:bundle': resolve(__dirname, 'src/shims/bun-bundle.ts'),
+ },
+ },
+})
+```
+
+Create `tests/setup.ts`:
+```ts
+// Global test setup
+import '../src/shims/preload.js'
+```
+
+Add to `package.json`:
+```json
+{
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest"
+ }
+}
+```
+
+### Part B: Write unit tests for shims
+
+`tests/shims/bun-bundle.test.ts`:
+- Test `feature()` returns `false` for unknown flags
+- Test `feature()` returns `false` for disabled flags
+- Test `feature()` returns `true` when env var is set
+- Test `feature('ABLATION_BASELINE')` always returns `false`
+
+`tests/shims/macro.test.ts`:
+- Test `MACRO.VERSION` is a string
+- Test `MACRO.PACKAGE_URL` is set
+- Test `MACRO.ISSUES_EXPLAINER` is set
+
+### Part C: Write smoke tests for core modules
+
+`tests/smoke/tools.test.ts`:
+- Test that `getTools()` returns an array
+- Test that each tool has: name, description, inputSchema
+- Test that BashTool, FileReadTool, FileWriteTool are present
+
+`tests/smoke/commands.test.ts`:
+- Test that `getCommands()` returns an array
+- Test that each command has: name, execute function
+- Test that /help and /config commands exist
+
+`tests/smoke/context.test.ts`:
+- Test that `getSystemContext()` returns OS info
+- Test that git status can be collected
+- Test that platform detection works on Linux
+
+`tests/smoke/prompt.test.ts`:
+- Test that `getSystemPrompt()` returns a non-empty array
+- Test that the prompt includes tool descriptions
+- Test that MACRO references are resolved (no `undefined`)
+
+### Part D: Write integration tests (if API key available)
+
+`tests/integration/api.test.ts`:
+- Skip if `ANTHROPIC_API_KEY` is not set
+- Test API client creation
+- Test a simple message (hello world)
+- Test streaming works
+- Test tool use (calculator-style tool call)
+
+`tests/integration/mcp.test.ts`:
+- Test MCP server starts
+- Test MCP client connects
+- Test tool listing
+- Test tool execution roundtrip
+
+### Part E: Write build tests
+
+`tests/build/bundle.test.ts`:
+- Test that `dist/cli.mjs` exists after build
+- Test that it has a shebang
+- Test that it's not empty
+- Test that `node dist/cli.mjs --version` exits cleanly
+
+### Part F: Add pre-commit hook (optional)
+
+If the project uses git hooks, add:
+```bash
+# In package.json or a git hook
+bun run typecheck && bun run test
+```
+
+## Verification
+
+1. `bun run test` runs all tests
+2. Shim tests pass
+3. Smoke tests pass (tools, commands, context, prompts load)
+4. Integration tests are skipped when no API key is set
+5. Integration tests pass when API key is available
+6. Test output is clear and readable
diff --git a/scripts/build-bundle.ts b/scripts/build-bundle.ts
new file mode 100644
index 0000000..79c5890
--- /dev/null
+++ b/scripts/build-bundle.ts
@@ -0,0 +1,197 @@
+// scripts/build-bundle.ts
+// Usage: bun scripts/build-bundle.ts [--watch] [--minify] [--no-sourcemap]
+//
+// Production build: bun scripts/build-bundle.ts --minify
+// Dev build: bun scripts/build-bundle.ts
+// Watch mode: bun scripts/build-bundle.ts --watch
+
+import * as esbuild from 'esbuild'
+import { resolve, dirname } from 'path'
+import { chmodSync, readFileSync, existsSync } from 'fs'
+import { fileURLToPath } from 'url'
+
+// Bun: import.meta.dir — Node 21+: import.meta.dirname — fallback
+const __dir: string =
+ (import.meta as any).dir ??
+ (import.meta as any).dirname ??
+ dirname(fileURLToPath(import.meta.url))
+
+const ROOT = resolve(__dir, '..')
+const watch = process.argv.includes('--watch')
+const minify = process.argv.includes('--minify')
+const noSourcemap = process.argv.includes('--no-sourcemap')
+
+// Read version from package.json for MACRO injection
+const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'))
+const version = pkg.version || '0.0.0-dev'
+
+// ── Plugin: resolve bare 'src/' imports (tsconfig baseUrl: ".") ──
+// The codebase uses `import ... from 'src/foo/bar.js'` which relies on
+// TypeScript's baseUrl resolution. This plugin maps those to real TS files.
+const srcResolverPlugin: esbuild.Plugin = {
+ name: 'src-resolver',
+ setup(build) {
+ build.onResolve({ filter: /^src\// }, (args) => {
+ const basePath = resolve(ROOT, args.path)
+
+ // Already exists as-is
+ if (existsSync(basePath)) {
+ return { path: basePath }
+ }
+
+ // Strip .js/.jsx and try TypeScript extensions
+ const withoutExt = basePath.replace(/\.(js|jsx)$/, '')
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
+ const candidate = withoutExt + ext
+ if (existsSync(candidate)) {
+ return { path: candidate }
+ }
+ }
+
+ // Try as directory with index file
+ const dirPath = basePath.replace(/\.(js|jsx)$/, '')
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
+ const candidate = resolve(dirPath, 'index' + ext)
+ if (existsSync(candidate)) {
+ return { path: candidate }
+ }
+ }
+
+ // Let esbuild handle it (will error if truly missing)
+ return undefined
+ })
+ },
+}
+
+const buildOptions: esbuild.BuildOptions = {
+ entryPoints: [resolve(ROOT, 'src/entrypoints/cli.tsx')],
+ bundle: true,
+ platform: 'node',
+ target: ['node20', 'es2022'],
+ format: 'esm',
+ outdir: resolve(ROOT, 'dist'),
+ outExtension: { '.js': '.mjs' },
+
+ // Single-file output — no code splitting for CLI tools
+ splitting: false,
+
+ plugins: [srcResolverPlugin],
+
+ // Use tsconfig for baseUrl / paths resolution (complements plugin above)
+ tsconfig: resolve(ROOT, 'tsconfig.json'),
+
+ // Alias bun:bundle to our runtime shim
+ alias: {
+ 'bun:bundle': resolve(ROOT, 'src/shims/bun-bundle.ts'),
+ },
+
+ // Don't bundle node built-ins or problematic native packages
+ external: [
+ // Node built-ins (with and without node: prefix)
+ 'fs', 'path', 'os', 'crypto', 'child_process', 'http', 'https',
+ 'net', 'tls', 'url', 'util', 'stream', 'events', 'buffer',
+ 'querystring', 'readline', 'zlib', 'assert', 'tty', 'worker_threads',
+ 'perf_hooks', 'async_hooks', 'dns', 'dgram', 'cluster',
+ 'string_decoder', 'module', 'vm', 'constants', 'domain',
+ 'console', 'process', 'v8', 'inspector',
+ 'node:*',
+ // Native addons that can't be bundled
+ 'fsevents',
+ 'sharp',
+ 'image-processor-napi',
+ // Anthropic-internal packages (not published externally)
+ '@anthropic-ai/sandbox-runtime',
+ '@anthropic-ai/claude-agent-sdk',
+ // Anthropic-internal (@ant/) packages — gated behind USER_TYPE === 'ant'
+ '@ant/*',
+ ],
+
+ jsx: 'automatic',
+
+ // Source maps for production debugging (external .map files)
+ sourcemap: noSourcemap ? false : 'external',
+
+ // Minification for production
+ minify,
+
+ // Tree shaking (on by default, explicit for clarity)
+ treeShaking: true,
+
+ // Define replacements — inline constants at build time
+ // MACRO.* — originally inlined by Bun's bundler at compile time
+ // process.env.USER_TYPE — eliminates 'ant' (Anthropic-internal) code branches
+ define: {
+ 'MACRO.VERSION': JSON.stringify(version),
+ 'MACRO.PACKAGE_URL': JSON.stringify('@anthropic-ai/claude-code'),
+ 'MACRO.ISSUES_EXPLAINER': JSON.stringify(
+ 'report issues at https://github.com/anthropics/claude-code/issues'
+ ),
+ 'process.env.USER_TYPE': '"external"',
+ 'process.env.NODE_ENV': minify ? '"production"' : '"development"',
+ },
+
+ // Banner: shebang for direct CLI execution
+ banner: {
+ js: '#!/usr/bin/env node\n',
+ },
+
+ // Handle the .js → .ts resolution that the codebase uses
+ resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
+
+ logLevel: 'info',
+
+ // Metafile for bundle analysis
+ metafile: true,
+}
+
+async function main() {
+ if (watch) {
+ const ctx = await esbuild.context(buildOptions)
+ await ctx.watch()
+ console.log('Watching for changes...')
+ } else {
+ const startTime = Date.now()
+ const result = await esbuild.build(buildOptions)
+
+ if (result.errors.length > 0) {
+ console.error('Build failed')
+ process.exit(1)
+ }
+
+ // Make the output executable
+ const outPath = resolve(ROOT, 'dist/cli.mjs')
+ try {
+ chmodSync(outPath, 0o755)
+ } catch {
+ // chmod may fail on some platforms, non-fatal
+ }
+
+ const elapsed = Date.now() - startTime
+
+ // Print bundle size info
+ if (result.metafile) {
+ const text = await esbuild.analyzeMetafile(result.metafile, { verbose: false })
+ const outFiles = Object.entries(result.metafile.outputs)
+ for (const [file, info] of outFiles) {
+ if (file.endsWith('.mjs')) {
+ const sizeMB = ((info as { bytes: number }).bytes / 1024 / 1024).toFixed(2)
+ console.log(`\n ${file}: ${sizeMB} MB`)
+ }
+ }
+ console.log(`\nBuild complete in ${elapsed}ms → dist/`)
+
+ // Write metafile for further analysis
+ const { writeFileSync } = await import('fs')
+ writeFileSync(
+ resolve(ROOT, 'dist/meta.json'),
+ JSON.stringify(result.metafile),
+ )
+ console.log(' Metafile written to dist/meta.json')
+ }
+ }
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/scripts/build-web.ts b/scripts/build-web.ts
new file mode 100644
index 0000000..da758ce
--- /dev/null
+++ b/scripts/build-web.ts
@@ -0,0 +1,58 @@
+// scripts/build-web.ts
+// Bundles the browser-side terminal frontend.
+//
+// Usage:
+// bun scripts/build-web.ts # dev build
+// bun scripts/build-web.ts --watch # watch mode
+// bun scripts/build-web.ts --minify # production (minified)
+
+import * as esbuild from 'esbuild'
+import { resolve, dirname } from 'path'
+import { fileURLToPath } from 'url'
+
+const __dir: string =
+ (import.meta as any).dir ??
+ (import.meta as any).dirname ??
+ dirname(fileURLToPath(import.meta.url))
+
+const ROOT = resolve(__dir, '..')
+const ENTRY = resolve(ROOT, 'src/server/web/terminal.ts')
+const OUT_DIR = resolve(ROOT, 'src/server/web/public')
+
+const watch = process.argv.includes('--watch')
+const minify = process.argv.includes('--minify')
+
+const buildOptions: esbuild.BuildOptions = {
+ entryPoints: [ENTRY],
+ bundle: true,
+ platform: 'browser',
+ target: ['es2020', 'chrome90', 'firefox90', 'safari14'],
+ format: 'esm',
+ outdir: OUT_DIR,
+ // CSS imported from JS is auto-emitted alongside the JS output
+ loader: { '.css': 'css' },
+ minify,
+ sourcemap: minify ? false : 'inline',
+ tsconfig: resolve(ROOT, 'src/server/web/tsconfig.json'),
+ logLevel: 'info',
+}
+
+async function main() {
+ if (watch) {
+ const ctx = await esbuild.context(buildOptions)
+ await ctx.watch()
+ console.log('Watching src/server/web/terminal.ts...')
+ } else {
+ const start = Date.now()
+ const result = await esbuild.build(buildOptions)
+ if (result.errors.length > 0) {
+ process.exit(1)
+ }
+ console.log(`Web build complete in ${Date.now() - start}ms → ${OUT_DIR}`)
+ }
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/scripts/build.sh b/scripts/build.sh
new file mode 100644
index 0000000..d2c0cc3
--- /dev/null
+++ b/scripts/build.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────
+# build.sh — Minimal build / check script for the leaked source
+# ─────────────────────────────────────────────────────────────
+# Usage:
+# ./scripts/build.sh # install + typecheck + lint
+# ./scripts/build.sh install # install deps only
+# ./scripts/build.sh check # typecheck + lint only
+# ─────────────────────────────────────────────────────────────
+set -euo pipefail
+
+STEP="${1:-all}"
+
+install_deps() {
+ echo "── Installing dependencies ──"
+ if command -v bun &>/dev/null; then
+ bun install
+ elif command -v npm &>/dev/null; then
+ npm install
+ else
+ echo "Error: neither bun nor npm found on PATH" >&2
+ exit 1
+ fi
+}
+
+typecheck() {
+ echo "── Running TypeScript type-check ──"
+ npx tsc --noEmit
+}
+
+lint() {
+ echo "── Running Biome lint ──"
+ npx @biomejs/biome check src/
+}
+
+case "$STEP" in
+ install)
+ install_deps
+ ;;
+ check)
+ typecheck
+ lint
+ ;;
+ all)
+ install_deps
+ typecheck
+ lint
+ ;;
+ *)
+ echo "Unknown step: $STEP"
+ echo "Usage: $0 [install|check|all]"
+ exit 1
+ ;;
+esac
+
+echo "── Done ──"
+
+
diff --git a/scripts/bun-plugin-shims.ts b/scripts/bun-plugin-shims.ts
new file mode 100644
index 0000000..dbc682f
--- /dev/null
+++ b/scripts/bun-plugin-shims.ts
@@ -0,0 +1,18 @@
+// scripts/bun-plugin-shims.ts
+// Bun preload plugin — intercepts `bun:bundle` imports at runtime
+// and resolves them to our local shim so the CLI can run without
+// the production Bun bundler pass.
+
+import { plugin } from 'bun'
+import { resolve } from 'path'
+
+plugin({
+ name: 'bun-bundle-shim',
+ setup(build) {
+ const shimPath = resolve(import.meta.dir, '../src/shims/bun-bundle.ts')
+
+ build.onResolve({ filter: /^bun:bundle$/ }, () => ({
+ path: shimPath,
+ }))
+ },
+})
diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh
new file mode 100644
index 0000000..61a9d95
--- /dev/null
+++ b/scripts/ci-build.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# ─────────────────────────────────────────────────────────────
+# ci-build.sh — CI/CD build pipeline
+# ─────────────────────────────────────────────────────────────
+# Runs the full build pipeline: install, typecheck, lint, build,
+# and verify the output. Intended for CI environments.
+#
+# Usage:
+# ./scripts/ci-build.sh
+# ─────────────────────────────────────────────────────────────
+set -euo pipefail
+
+echo "=== Installing dependencies ==="
+bun install
+
+echo "=== Type checking ==="
+bun run typecheck
+
+echo "=== Linting ==="
+bun run lint
+
+echo "=== Building production bundle ==="
+bun run build:prod
+
+echo "=== Verifying build output ==="
+
+# Check that the bundle was produced
+if [ ! -f dist/cli.mjs ]; then
+ echo "ERROR: dist/cli.mjs not found"
+ exit 1
+fi
+
+# Print bundle size
+SIZE=$(ls -lh dist/cli.mjs | awk '{print $5}')
+echo " Bundle size: $SIZE"
+
+# Verify the bundle runs with Node.js
+if command -v node &>/dev/null; then
+ VERSION=$(node dist/cli.mjs --version 2>&1 || true)
+ echo " node dist/cli.mjs --version → $VERSION"
+fi
+
+# Verify the bundle runs with Bun
+if command -v bun &>/dev/null; then
+ VERSION=$(bun dist/cli.mjs --version 2>&1 || true)
+ echo " bun dist/cli.mjs --version → $VERSION"
+fi
+
+echo "=== Done ==="
diff --git a/scripts/dev.ts b/scripts/dev.ts
new file mode 100644
index 0000000..0d4a63b
--- /dev/null
+++ b/scripts/dev.ts
@@ -0,0 +1,15 @@
+// scripts/dev.ts
+// Development launcher — runs the CLI directly via Bun's TS runtime.
+//
+// Usage:
+// bun scripts/dev.ts [args...]
+// bun run dev [args...]
+//
+// The bun:bundle shim is loaded automatically via bunfig.toml preload.
+// Bun automatically reads .env files from the project root.
+
+// Load MACRO global (version, package url, etc.) before any app code
+import '../src/shims/macro.js'
+
+// Launch the CLI entrypoint
+await import('../src/entrypoints/cli.js')
diff --git a/scripts/package-npm.ts b/scripts/package-npm.ts
new file mode 100644
index 0000000..f5d39e3
--- /dev/null
+++ b/scripts/package-npm.ts
@@ -0,0 +1,91 @@
+// scripts/package-npm.ts
+// Generate a publishable npm package in dist/npm/
+//
+// Usage: bun scripts/package-npm.ts
+//
+// Prerequisites: run `bun run build:prod` first to generate dist/cli.mjs
+
+import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, chmodSync } from 'fs'
+import { resolve } from 'path'
+
+// Bun: import.meta.dir — Node 21+: import.meta.dirname — fallback
+const __dir: string =
+ (import.meta as ImportMeta & { dir?: string; dirname?: string }).dir ??
+ (import.meta as ImportMeta & { dir?: string; dirname?: string }).dirname ??
+ new URL('.', import.meta.url).pathname
+
+const ROOT = resolve(__dir, '..')
+const DIST = resolve(ROOT, 'dist')
+const NPM_DIR = resolve(DIST, 'npm')
+const CLI_BUNDLE = resolve(DIST, 'cli.mjs')
+
+function main() {
+ // Verify the bundle exists
+ if (!existsSync(CLI_BUNDLE)) {
+ console.error('Error: dist/cli.mjs not found. Run `bun run build:prod` first.')
+ process.exit(1)
+ }
+
+ // Read source package.json
+ const srcPkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'))
+
+ // Create npm output directory
+ mkdirSync(NPM_DIR, { recursive: true })
+
+ // Copy the bundled CLI
+ copyFileSync(CLI_BUNDLE, resolve(NPM_DIR, 'cli.mjs'))
+ chmodSync(resolve(NPM_DIR, 'cli.mjs'), 0o755)
+
+ // Copy source map if it exists
+ const sourceMap = resolve(DIST, 'cli.mjs.map')
+ if (existsSync(sourceMap)) {
+ copyFileSync(sourceMap, resolve(NPM_DIR, 'cli.mjs.map'))
+ }
+
+ // Generate a publishable package.json
+ const npmPkg = {
+ name: srcPkg.name || '@anthropic-ai/claude-code',
+ version: srcPkg.version || '0.0.0',
+ description: srcPkg.description || 'Anthropic Claude Code CLI',
+ license: 'MIT',
+ type: 'module',
+ main: './cli.mjs',
+ bin: {
+ claude: './cli.mjs',
+ },
+ engines: {
+ node: '>=20.0.0',
+ },
+ os: ['darwin', 'linux', 'win32'],
+ files: [
+ 'cli.mjs',
+ 'cli.mjs.map',
+ 'README.md',
+ ],
+ }
+
+ writeFileSync(
+ resolve(NPM_DIR, 'package.json'),
+ JSON.stringify(npmPkg, null, 2) + '\n',
+ )
+
+ // Copy README if it exists
+ const readme = resolve(ROOT, 'README.md')
+ if (existsSync(readme)) {
+ copyFileSync(readme, resolve(NPM_DIR, 'README.md'))
+ }
+
+ // Summary
+ const bundleSize = readFileSync(CLI_BUNDLE).byteLength
+ const sizeMB = (bundleSize / 1024 / 1024).toFixed(2)
+
+ console.log('npm package generated in dist/npm/')
+ console.log(` package: ${npmPkg.name}@${npmPkg.version}`)
+ console.log(` bundle: cli.mjs (${sizeMB} MB)`)
+ console.log(` bin: claude → ./cli.mjs`)
+ console.log('')
+ console.log('To publish:')
+ console.log(' cd dist/npm && npm publish')
+}
+
+main()
diff --git a/scripts/test-auth.ts b/scripts/test-auth.ts
new file mode 100644
index 0000000..cd17fc0
--- /dev/null
+++ b/scripts/test-auth.ts
@@ -0,0 +1,26 @@
+// scripts/test-auth.ts
+// Quick test that the API key is configured and can reach Anthropic
+// Usage: bun scripts/test-auth.ts
+
+import Anthropic from '@anthropic-ai/sdk'
+
+const client = new Anthropic({
+ apiKey: process.env.ANTHROPIC_API_KEY,
+})
+
+async function main() {
+ try {
+ const msg = await client.messages.create({
+ model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514',
+ max_tokens: 50,
+ messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }],
+ })
+ console.log('✅ API connection successful!')
+ console.log('Response:', msg.content[0].type === 'text' ? msg.content[0].text : msg.content[0])
+ } catch (err: any) {
+ console.error('❌ API connection failed:', err.message)
+ process.exit(1)
+ }
+}
+
+main()
diff --git a/scripts/test-commands.ts b/scripts/test-commands.ts
new file mode 100644
index 0000000..b32e431
--- /dev/null
+++ b/scripts/test-commands.ts
@@ -0,0 +1,64 @@
+// scripts/test-commands.ts
+// Verify all commands load without errors
+// Usage: bun scripts/test-commands.ts
+//
+// The bun:bundle shim is loaded automatically via bunfig.toml preload.
+
+// Load MACRO global before any app code
+import '../src/shims/macro.js'
+
+async function main() {
+ const { getCommands } = await import('../src/commands.js')
+
+ const cwd = process.cwd()
+ const commands = await getCommands(cwd)
+
+ console.log(`Loaded ${commands.length} commands:\n`)
+
+ // Group commands by type for readability
+ const byType: Record = {}
+ for (const cmd of commands) {
+ const t = cmd.type
+ if (!byType[t]) byType[t] = []
+ byType[t]!.push(cmd)
+ }
+
+ for (const [type, cmds] of Object.entries(byType)) {
+ console.log(` [${type}] (${cmds.length} commands)`)
+ for (const cmd of cmds) {
+ const aliases = cmd.aliases?.length ? ` (aliases: ${cmd.aliases.join(', ')})` : ''
+ const hidden = cmd.isHidden ? ' [hidden]' : ''
+ const source = cmd.type === 'prompt' ? ` (source: ${cmd.source})` : ''
+ console.log(` /${cmd.name} — ${cmd.description || '(no description)'}${aliases}${hidden}${source}`)
+ }
+ console.log()
+ }
+
+ // Verify essential commands are present
+ const essential = ['help', 'config', 'init', 'commit', 'review']
+ const commandNames = new Set(commands.map(c => c.name))
+ const missing = essential.filter(n => !commandNames.has(n))
+
+ if (missing.length > 0) {
+ console.error(`❌ Missing essential commands: ${missing.join(', ')}`)
+ process.exit(1)
+ }
+
+ console.log(`✅ All ${essential.length} essential commands present: ${essential.join(', ')}`)
+
+ // Check moved-to-plugin commands
+ const movedToPlugin = commands.filter(
+ c => c.type === 'prompt' && c.description && c.name
+ ).filter(c => ['security-review', 'pr-comments'].includes(c.name))
+
+ if (movedToPlugin.length > 0) {
+ console.log(`✅ Moved-to-plugin commands present and loadable: ${movedToPlugin.map(c => c.name).join(', ')}`)
+ }
+
+ console.log(`\n✅ Command system loaded successfully (${commands.length} commands)`)
+}
+
+main().catch(err => {
+ console.error('❌ Command loading failed:', err)
+ process.exit(1)
+})
diff --git a/scripts/test-mcp.ts b/scripts/test-mcp.ts
new file mode 100644
index 0000000..7326fe2
--- /dev/null
+++ b/scripts/test-mcp.ts
@@ -0,0 +1,180 @@
+#!/usr/bin/env npx tsx
+/**
+ * scripts/test-mcp.ts
+ * Test MCP client/server roundtrip using the standalone mcp-server sub-project.
+ *
+ * Usage:
+ * cd mcp-server && npm install && npm run build && cd ..
+ * npx tsx scripts/test-mcp.ts
+ *
+ * What it does:
+ * 1. Spawns mcp-server/dist/index.js as a child process (stdio transport)
+ * 2. Creates an MCP client using @modelcontextprotocol/sdk
+ * 3. Connects client to server
+ * 4. Lists available tools
+ * 5. Calls list_tools and read_source_file tools
+ * 6. Lists resources and reads one
+ * 7. Prints results and exits
+ */
+
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const PROJECT_ROOT = resolve(__dirname, "..");
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+function section(title: string) {
+ console.log(`\n${"─".repeat(60)}`);
+ console.log(` ${title}`);
+ console.log(`${"─".repeat(60)}`);
+}
+
+function jsonPretty(obj: unknown): string {
+ return JSON.stringify(obj, null, 2);
+}
+
+// ── Main ──────────────────────────────────────────────────────────────────
+
+async function main() {
+ const serverScript = resolve(PROJECT_ROOT, "mcp-server", "dist", "index.js");
+ const srcRoot = resolve(PROJECT_ROOT, "src");
+
+ section("1. Spawning MCP server (stdio transport)");
+ console.log(` Server: ${serverScript}`);
+ console.log(` SRC_ROOT: ${srcRoot}`);
+
+ const transport = new StdioClientTransport({
+ command: "node",
+ args: [serverScript],
+ env: {
+ ...process.env,
+ CLAUDE_CODE_SRC_ROOT: srcRoot,
+ } as Record,
+ stderr: "pipe",
+ });
+
+ // Log stderr from the server process
+ if (transport.stderr) {
+ transport.stderr.on("data", (data: Buffer) => {
+ const msg = data.toString().trim();
+ if (msg) console.log(` [server stderr] ${msg}`);
+ });
+ }
+
+ section("2. Creating MCP client");
+ const client = new Client(
+ {
+ name: "test-mcp-client",
+ version: "1.0.0",
+ },
+ {
+ capabilities: {},
+ }
+ );
+
+ section("3. Connecting client → server");
+ await client.connect(transport);
+ console.log(" ✓ Connected successfully");
+
+ // ── List Tools ──────────────────────────────────────────────────────────
+ section("4. Listing available tools");
+ const toolsResult = await client.listTools();
+ console.log(` Found ${toolsResult.tools.length} tool(s):`);
+ for (const tool of toolsResult.tools) {
+ console.log(` • ${tool.name} — ${tool.description?.slice(0, 80)}`);
+ }
+
+ // ── Call list_tools ─────────────────────────────────────────────────────
+ section("5. Calling tool: list_tools");
+ const listToolsResult = await client.callTool({
+ name: "list_tools",
+ arguments: {},
+ });
+ const listToolsContent = listToolsResult.content as Array<{
+ type: string;
+ text: string;
+ }>;
+ const listToolsText = listToolsContent
+ .filter((c) => c.type === "text")
+ .map((c) => c.text)
+ .join("\n");
+ // Show first 500 chars
+ console.log(
+ ` Result (first 500 chars):\n${listToolsText.slice(0, 500)}${listToolsText.length > 500 ? "\n …(truncated)" : ""}`
+ );
+
+ // ── Call read_source_file ───────────────────────────────────────────────
+ section("6. Calling tool: read_source_file (path: 'main.tsx', lines 1-20)");
+ const readResult = await client.callTool({
+ name: "read_source_file",
+ arguments: { path: "main.tsx", startLine: 1, endLine: 20 },
+ });
+ const readContent = readResult.content as Array<{
+ type: string;
+ text: string;
+ }>;
+ const readText = readContent
+ .filter((c) => c.type === "text")
+ .map((c) => c.text)
+ .join("\n");
+ console.log(` Result:\n${readText.slice(0, 600)}`);
+
+ // ── List Resources ──────────────────────────────────────────────────────
+ section("7. Listing resources");
+ try {
+ const resourcesResult = await client.listResources();
+ console.log(` Found ${resourcesResult.resources.length} resource(s):`);
+ for (const res of resourcesResult.resources.slice(0, 10)) {
+ console.log(` • ${res.name} (${res.uri})`);
+ }
+ if (resourcesResult.resources.length > 10) {
+ console.log(
+ ` …and ${resourcesResult.resources.length - 10} more`
+ );
+ }
+
+ // Read the first resource
+ if (resourcesResult.resources.length > 0) {
+ const firstRes = resourcesResult.resources[0]!;
+ section(`8. Reading resource: ${firstRes.name}`);
+ const resContent = await client.readResource({ uri: firstRes.uri });
+ const resText = resContent.contents
+ .filter((c): c is { uri: string; text: string; mimeType?: string } => "text" in c)
+ .map((c) => c.text)
+ .join("\n");
+ console.log(
+ ` Content (first 400 chars):\n${resText.slice(0, 400)}${resText.length > 400 ? "\n …(truncated)" : ""}`
+ );
+ }
+ } catch (err) {
+ console.log(` Resources not supported or error: ${err}`);
+ }
+
+ // ── List Prompts ────────────────────────────────────────────────────────
+ section("9. Listing prompts");
+ try {
+ const promptsResult = await client.listPrompts();
+ console.log(` Found ${promptsResult.prompts.length} prompt(s):`);
+ for (const p of promptsResult.prompts) {
+ console.log(` • ${p.name} — ${p.description?.slice(0, 80)}`);
+ }
+ } catch (err) {
+ console.log(` Prompts not supported or error: ${err}`);
+ }
+
+ // ── Cleanup ─────────────────────────────────────────────────────────────
+ section("Done ✓");
+ console.log(" All tests passed. Closing connection.");
+ await client.close();
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error("\n✗ Test failed:", err);
+ process.exit(1);
+});
diff --git a/scripts/test-services.ts b/scripts/test-services.ts
new file mode 100644
index 0000000..576ff9f
--- /dev/null
+++ b/scripts/test-services.ts
@@ -0,0 +1,233 @@
+// scripts/test-services.ts
+// Test that all services initialize without crashing
+// Usage: bun scripts/test-services.ts
+
+import '../src/shims/preload.js'
+
+// Ensure we don't accidentally talk to real servers
+process.env.NODE_ENV = process.env.NODE_ENV || 'test'
+
+type TestResult = { name: string; status: 'pass' | 'fail' | 'skip'; detail?: string }
+const results: TestResult[] = []
+
+function pass(name: string, detail?: string) {
+ results.push({ name, status: 'pass', detail })
+ console.log(` ✅ ${name}${detail ? ` — ${detail}` : ''}`)
+}
+
+function fail(name: string, detail: string) {
+ results.push({ name, status: 'fail', detail })
+ console.log(` ❌ ${name} — ${detail}`)
+}
+
+function skip(name: string, detail: string) {
+ results.push({ name, status: 'skip', detail })
+ console.log(` ⏭️ ${name} — ${detail}`)
+}
+
+async function testGrowthBook() {
+ console.log('\n--- GrowthBook (Feature Flags) ---')
+ try {
+ const gb = await import('../src/services/analytics/growthbook.js')
+
+ // Test cached feature value returns default when GrowthBook is unavailable
+ const boolResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_feature', false)
+ if (boolResult === false) {
+ pass('getFeatureValue_CACHED_MAY_BE_STALE (bool)', 'returns default false')
+ } else {
+ fail('getFeatureValue_CACHED_MAY_BE_STALE (bool)', `expected false, got ${boolResult}`)
+ }
+
+ const strResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_str', 'default_val')
+ if (strResult === 'default_val') {
+ pass('getFeatureValue_CACHED_MAY_BE_STALE (str)', 'returns default string')
+ } else {
+ fail('getFeatureValue_CACHED_MAY_BE_STALE (str)', `expected "default_val", got "${strResult}"`)
+ }
+
+ // Test Statsig gate check returns false
+ const gateResult = gb.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('nonexistent_gate')
+ if (gateResult === false) {
+ pass('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', 'returns false for unknown gate')
+ } else {
+ fail('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', `expected false, got ${gateResult}`)
+ }
+ } catch (err: any) {
+ fail('GrowthBook import', err.message)
+ }
+}
+
+async function testAnalyticsSink() {
+ console.log('\n--- Analytics Sink ---')
+ try {
+ const analytics = await import('../src/services/analytics/index.js')
+
+ // logEvent should queue without crashing when no sink is attached
+ analytics.logEvent('test_event', { test_key: 1 })
+ pass('logEvent (no sink)', 'queues without crash')
+
+ await analytics.logEventAsync('test_async_event', { test_key: 2 })
+ pass('logEventAsync (no sink)', 'queues without crash')
+ } catch (err: any) {
+ fail('Analytics sink', err.message)
+ }
+}
+
+async function testPolicyLimits() {
+ console.log('\n--- Policy Limits ---')
+ try {
+ const pl = await import('../src/services/policyLimits/index.js')
+
+ // isPolicyAllowed should return true (fail open) when no restrictions loaded
+ const result = pl.isPolicyAllowed('allow_remote_sessions')
+ if (result === true) {
+ pass('isPolicyAllowed (no cache)', 'fails open — returns true')
+ } else {
+ fail('isPolicyAllowed (no cache)', `expected true (fail open), got ${result}`)
+ }
+
+ // isPolicyLimitsEligible should return false without valid auth
+ const eligible = pl.isPolicyLimitsEligible()
+ pass('isPolicyLimitsEligible', `returns ${eligible} (expected false in test env)`)
+ } catch (err: any) {
+ fail('Policy limits', err.message)
+ }
+}
+
+async function testRemoteManagedSettings() {
+ console.log('\n--- Remote Managed Settings ---')
+ try {
+ const rms = await import('../src/services/remoteManagedSettings/index.js')
+
+ // isEligibleForRemoteManagedSettings should return false without auth
+ const eligible = rms.isEligibleForRemoteManagedSettings()
+ pass('isEligibleForRemoteManagedSettings', `returns ${eligible} (expected false in test env)`)
+
+ // waitForRemoteManagedSettingsToLoad should resolve immediately if not eligible
+ await rms.waitForRemoteManagedSettingsToLoad()
+ pass('waitForRemoteManagedSettingsToLoad', 'resolves immediately when not eligible')
+ } catch (err: any) {
+ fail('Remote managed settings', err.message)
+ }
+}
+
+async function testBootstrapData() {
+ console.log('\n--- Bootstrap Data ---')
+ try {
+ const bootstrap = await import('../src/services/api/bootstrap.js')
+
+ // fetchBootstrapData should not crash — just skip when no auth
+ await bootstrap.fetchBootstrapData()
+ pass('fetchBootstrapData', 'completes without crash (skips when no auth)')
+ } catch (err: any) {
+ // fetchBootstrapData catches its own errors, so this means an import-level issue
+ fail('Bootstrap data', err.message)
+ }
+}
+
+async function testSessionMemoryUtils() {
+ console.log('\n--- Session Memory ---')
+ try {
+ const smUtils = await import('../src/services/SessionMemory/sessionMemoryUtils.js')
+
+ // Default config should be sensible
+ const config = smUtils.DEFAULT_SESSION_MEMORY_CONFIG
+ if (config.minimumMessageTokensToInit > 0 && config.minimumTokensBetweenUpdate > 0) {
+ pass('DEFAULT_SESSION_MEMORY_CONFIG', `init=${config.minimumMessageTokensToInit} tokens, update=${config.minimumTokensBetweenUpdate} tokens`)
+ } else {
+ fail('DEFAULT_SESSION_MEMORY_CONFIG', 'unexpected config values')
+ }
+
+ // getLastSummarizedMessageId should return undefined initially
+ const lastId = smUtils.getLastSummarizedMessageId()
+ if (lastId === undefined) {
+ pass('getLastSummarizedMessageId', 'returns undefined initially')
+ } else {
+ fail('getLastSummarizedMessageId', `expected undefined, got ${lastId}`)
+ }
+ } catch (err: any) {
+ fail('Session memory utils', err.message)
+ }
+}
+
+async function testCostTracker() {
+ console.log('\n--- Cost Tracking ---')
+ try {
+ const ct = await import('../src/cost-tracker.js')
+
+ // Total cost should start at 0
+ const cost = ct.getTotalCost()
+ if (cost === 0) {
+ pass('getTotalCost', 'starts at $0.00')
+ } else {
+ pass('getTotalCost', `current: $${cost.toFixed(4)} (non-zero means restored session)`)
+ }
+
+ // Duration should be available
+ const duration = ct.getTotalDuration()
+ pass('getTotalDuration', `${duration}ms`)
+
+ // Token counters should be available
+ const inputTokens = ct.getTotalInputTokens()
+ const outputTokens = ct.getTotalOutputTokens()
+ pass('Token counters', `input=${inputTokens}, output=${outputTokens}`)
+
+ // Lines changed
+ const added = ct.getTotalLinesAdded()
+ const removed = ct.getTotalLinesRemoved()
+ pass('Lines changed', `+${added} -${removed}`)
+ } catch (err: any) {
+ fail('Cost tracker', err.message)
+ }
+}
+
+async function testInit() {
+ console.log('\n--- Init (entrypoint) ---')
+ try {
+ const { init } = await import('../src/entrypoints/init.js')
+ await init()
+ pass('init()', 'completed successfully')
+ } catch (err: any) {
+ fail('init()', err.message)
+ }
+}
+
+async function main() {
+ console.log('=== Services Layer Smoke Test ===')
+ console.log(`Environment: NODE_ENV=${process.env.NODE_ENV}`)
+ console.log(`Auth: ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY ? '(set)' : '(not set)'}`)
+
+ // Test individual services first (order: least-dependent → most-dependent)
+ await testAnalyticsSink()
+ await testGrowthBook()
+ await testPolicyLimits()
+ await testRemoteManagedSettings()
+ await testBootstrapData()
+ await testSessionMemoryUtils()
+ await testCostTracker()
+
+ // Then test the full init sequence
+ await testInit()
+
+ // Summary
+ console.log('\n=== Summary ===')
+ const passed = results.filter(r => r.status === 'pass').length
+ const failed = results.filter(r => r.status === 'fail').length
+ const skipped = results.filter(r => r.status === 'skip').length
+ console.log(` ${passed} passed, ${failed} failed, ${skipped} skipped`)
+
+ if (failed > 0) {
+ console.log('\nFailed tests:')
+ for (const r of results.filter(r => r.status === 'fail')) {
+ console.log(` ❌ ${r.name}: ${r.detail}`)
+ }
+ process.exit(1)
+ }
+
+ console.log('\n✅ All services handle graceful degradation correctly')
+}
+
+main().catch(err => {
+ console.error('Fatal error in smoke test:', err)
+ process.exit(1)
+})
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
new file mode 100644
index 0000000..d0e97b6
--- /dev/null
+++ b/scripts/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "types": ["node"]
+ },
+ "include": ["./**/*.ts", "./types.d.ts"]
+}
diff --git a/scripts/types.d.ts b/scripts/types.d.ts
new file mode 100644
index 0000000..80ccad7
--- /dev/null
+++ b/scripts/types.d.ts
@@ -0,0 +1,86 @@
+// Local type declarations for scripts/ — avoids depending on installed packages
+// for type checking in build scripts.
+
+// ── esbuild (minimal surface used by build-bundle.ts) ──
+declare module 'esbuild' {
+ export interface Plugin {
+ name: string
+ setup(build: PluginBuild): void
+ }
+
+ export interface PluginBuild {
+ onResolve(
+ options: { filter: RegExp },
+ callback: (args: OnResolveArgs) => OnResolveResult | undefined | null,
+ ): void
+ }
+
+ export interface OnResolveArgs {
+ path: string
+ importer: string
+ namespace: string
+ resolveDir: string
+ kind: string
+ pluginData: unknown
+ }
+
+ export interface OnResolveResult {
+ path?: string
+ external?: boolean
+ namespace?: string
+ pluginData?: unknown
+ }
+
+ export interface BuildOptions {
+ entryPoints?: string[]
+ bundle?: boolean
+ platform?: string
+ target?: string[]
+ format?: string
+ outdir?: string
+ outExtension?: Record
+ splitting?: boolean
+ plugins?: Plugin[]
+ tsconfig?: string
+ alias?: Record
+ external?: string[]
+ jsx?: string
+ sourcemap?: boolean | string
+ minify?: boolean
+ treeShaking?: boolean
+ define?: Record
+ banner?: Record
+ resolveExtensions?: string[]
+ logLevel?: string
+ metafile?: boolean
+ [key: string]: unknown
+ }
+
+ export interface Metafile {
+ inputs: Record
+ outputs: Record
+ }
+
+ export interface BuildResult {
+ errors: { text: string }[]
+ warnings: { text: string }[]
+ metafile?: Metafile
+ }
+
+ export interface BuildContext {
+ watch(): Promise
+ serve(options?: unknown): Promise
+ rebuild(): Promise
+ dispose(): Promise
+ }
+
+ export function build(options: BuildOptions): Promise
+ export function context(options: BuildOptions): Promise
+ export function analyzeMetafile(metafile: Metafile, options?: { verbose?: boolean }): Promise
+}
+
+// ── Bun's ImportMeta extensions ──
+interface ImportMeta {
+ dir: string
+ dirname: string
+}
diff --git a/server.json b/server.json
new file mode 100644
index 0000000..c999baf
--- /dev/null
+++ b/server.json
@@ -0,0 +1,24 @@
+cl{
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
+ "name": "io.github.codeaashu/claude-code-explorer-mcp",
+ "title": "Claude Code Explorer MCP",
+ "description": "Explore the Claude Code CLI source — browse tools, commands, search the 512K-line codebase.",
+ "repository": {
+ "url": "https://github.com/codeaashu/claude-code",
+ "source": "github",
+ "subfolder": "mcp-server"
+ },
+ "version": "1.1.0",
+ "packages": [
+ {
+ "registryType": "npm",
+ "registryBaseUrl": "https://registry.npmjs.org",
+ "identifier": "claude-code-explorer-mcp",
+ "version": "1.1.0",
+ "transport": {
+ "type": "stdio"
+ },
+ "runtimeHint": "node"
+ }
+ ]
+}
diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts
new file mode 100644
index 0000000..6c6fd76
--- /dev/null
+++ b/src/QueryEngine.ts
@@ -0,0 +1,1297 @@
+import { feature } from 'bun:bundle'
+import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
+import { randomUUID } from 'crypto'
+import last from 'lodash-es/last.js'
+import {
+ getSessionId,
+ isSessionPersistenceDisabled,
+} from 'src/bootstrap/state.js'
+import type {
+ PermissionMode,
+ SDKCompactBoundaryMessage,
+ SDKMessage,
+ SDKPermissionDenial,
+ SDKStatus,
+ SDKUserMessageReplay,
+} from 'src/entrypoints/agentSdkTypes.js'
+import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
+import type { NonNullableUsage } from 'src/services/api/logging.js'
+import { EMPTY_USAGE } from 'src/services/api/logging.js'
+import stripAnsi from 'strip-ansi'
+import type { Command } from './commands.js'
+import { getSlashCommandToolSkills } from './commands.js'
+import {
+ LOCAL_COMMAND_STDERR_TAG,
+ LOCAL_COMMAND_STDOUT_TAG,
+} from './constants/xml.js'
+import {
+ getModelUsage,
+ getTotalAPIDuration,
+ getTotalCost,
+} from './cost-tracker.js'
+import type { CanUseToolFn } from './hooks/useCanUseTool.js'
+import { loadMemoryPrompt } from './memdir/memdir.js'
+import { hasAutoMemPathOverride } from './memdir/paths.js'
+import { query } from './query.js'
+import { categorizeRetryableAPIError } from './services/api/errors.js'
+import type { MCPServerConnection } from './services/mcp/types.js'
+import type { AppState } from './state/AppState.js'
+import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
+import type { AgentDefinition } from './tools/AgentTool/loadAgentsDir.js'
+import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'
+import type { Message } from './types/message.js'
+import type { OrphanedPermission } from './types/textInputTypes.js'
+import { createAbortController } from './utils/abortController.js'
+import type { AttributionState } from './utils/commitAttribution.js'
+import { getGlobalConfig } from './utils/config.js'
+import { getCwd } from './utils/cwd.js'
+import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
+import { getFastModeState } from './utils/fastMode.js'
+import {
+ type FileHistoryState,
+ fileHistoryEnabled,
+ fileHistoryMakeSnapshot,
+} from './utils/fileHistory.js'
+import {
+ cloneFileStateCache,
+ type FileStateCache,
+} from './utils/fileStateCache.js'
+import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
+import { registerStructuredOutputEnforcement } from './utils/hooks/hookHelpers.js'
+import { getInMemoryErrors } from './utils/log.js'
+import { countToolCalls, SYNTHETIC_MESSAGES } from './utils/messages.js'
+import {
+ getMainLoopModel,
+ parseUserSpecifiedModel,
+} from './utils/model/model.js'
+import { loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'
+import {
+ type ProcessUserInputContext,
+ processUserInput,
+} from './utils/processUserInput/processUserInput.js'
+import { fetchSystemPromptParts } from './utils/queryContext.js'
+import { setCwd } from './utils/Shell.js'
+import {
+ flushSessionStorage,
+ recordTranscript,
+} from './utils/sessionStorage.js'
+import { asSystemPrompt } from './utils/systemPromptType.js'
+import { resolveThemeSetting } from './utils/systemTheme.js'
+import {
+ shouldEnableThinkingByDefault,
+ type ThinkingConfig,
+} from './utils/thinking.js'
+
+// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
+/* eslint-disable @typescript-eslint/no-require-imports */
+const messageSelector =
+ (): typeof import('src/components/MessageSelector.js') =>
+ require('src/components/MessageSelector.js')
+
+import {
+ localCommandOutputToSDKAssistantMessage,
+ toSDKCompactMetadata,
+} from './utils/messages/mappers.js'
+import {
+ buildSystemInitMessage,
+ sdkCompatToolName,
+} from './utils/messages/systemInit.js'
+import {
+ getScratchpadDir,
+ isScratchpadEnabled,
+} from './utils/permissions/filesystem.js'
+/* eslint-enable @typescript-eslint/no-require-imports */
+import {
+ handleOrphanedPermission,
+ isResultSuccessful,
+ normalizeMessage,
+} from './utils/queryHelpers.js'
+
+// Dead code elimination: conditional import for coordinator mode
+/* eslint-disable @typescript-eslint/no-require-imports */
+const getCoordinatorUserContext: (
+ mcpClients: ReadonlyArray<{ name: string }>,
+ scratchpadDir?: string,
+) => { [k: string]: string } = feature('COORDINATOR_MODE')
+ ? require('./coordinator/coordinatorMode.js').getCoordinatorUserContext
+ : () => ({})
+/* eslint-enable @typescript-eslint/no-require-imports */
+
+// Dead code elimination: conditional import for snip compaction
+/* eslint-disable @typescript-eslint/no-require-imports */
+const snipModule = feature('HISTORY_SNIP')
+ ? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
+ : null
+const snipProjection = feature('HISTORY_SNIP')
+ ? (require('./services/compact/snipProjection.js') as typeof import('./services/compact/snipProjection.js'))
+ : null
+/* eslint-enable @typescript-eslint/no-require-imports */
+
+export type QueryEngineConfig = {
+ cwd: string
+ tools: Tools
+ commands: Command[]
+ mcpClients: MCPServerConnection[]
+ agents: AgentDefinition[]
+ canUseTool: CanUseToolFn
+ getAppState: () => AppState
+ setAppState: (f: (prev: AppState) => AppState) => void
+ initialMessages?: Message[]
+ readFileCache: FileStateCache
+ customSystemPrompt?: string
+ appendSystemPrompt?: string
+ userSpecifiedModel?: string
+ fallbackModel?: string
+ thinkingConfig?: ThinkingConfig
+ maxTurns?: number
+ maxBudgetUsd?: number
+ taskBudget?: { total: number }
+ jsonSchema?: Record
+ verbose?: boolean
+ replayUserMessages?: boolean
+ /** Handler for URL elicitations triggered by MCP tool -32042 errors. */
+ handleElicitation?: ToolUseContext['handleElicitation']
+ includePartialMessages?: boolean
+ setSDKStatus?: (status: SDKStatus) => void
+ abortController?: AbortController
+ orphanedPermission?: OrphanedPermission
+ /**
+ * Snip-boundary handler: receives each yielded system message plus the
+ * current mutableMessages store. Returns undefined if the message is not a
+ * snip boundary; otherwise returns the replayed snip result. Injected by
+ * ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside
+ * the gated module (keeps QueryEngine free of excluded strings and testable
+ * despite feature() returning false under bun test). SDK-only: the REPL
+ * keeps full history for UI scrollback and projects on demand via
+ * projectSnippedView; QueryEngine truncates here to bound memory in long
+ * headless sessions (no UI to preserve).
+ */
+ snipReplay?: (
+ yieldedSystemMsg: Message,
+ store: Message[],
+ ) => { messages: Message[]; executed: boolean } | undefined
+}
+
+/**
+ * QueryEngine owns the query lifecycle and session state for a conversation.
+ * It extracts the core logic from ask() into a standalone class that can be
+ * used by both the headless/SDK path and (in a future phase) the REPL.
+ *
+ * One QueryEngine per conversation. Each submitMessage() call starts a new
+ * turn within the same conversation. State (messages, file cache, usage, etc.)
+ * persists across turns.
+ */
+export class QueryEngine {
+ private config: QueryEngineConfig
+ private mutableMessages: Message[]
+ private abortController: AbortController
+ private permissionDenials: SDKPermissionDenial[]
+ private totalUsage: NonNullableUsage
+ private hasHandledOrphanedPermission = false
+ private readFileState: FileStateCache
+ // Turn-scoped skill discovery tracking (feeds was_discovered on
+ // tengu_skill_tool_invocation). Must persist across the two
+ // processUserInputContext rebuilds inside submitMessage, but is cleared
+ // at the start of each submitMessage to avoid unbounded growth across
+ // many turns in SDK mode.
+ private discoveredSkillNames = new Set()
+ private loadedNestedMemoryPaths = new Set()
+
+ constructor(config: QueryEngineConfig) {
+ this.config = config
+ this.mutableMessages = config.initialMessages ?? []
+ this.abortController = config.abortController ?? createAbortController()
+ this.permissionDenials = []
+ this.readFileState = config.readFileCache
+ this.totalUsage = EMPTY_USAGE
+ }
+
+ async *submitMessage(
+ prompt: string | ContentBlockParam[],
+ options?: { uuid?: string; isMeta?: boolean },
+ ): AsyncGenerator {
+ const {
+ cwd,
+ commands,
+ tools,
+ mcpClients,
+ verbose = false,
+ thinkingConfig,
+ maxTurns,
+ maxBudgetUsd,
+ taskBudget,
+ canUseTool,
+ customSystemPrompt,
+ appendSystemPrompt,
+ userSpecifiedModel,
+ fallbackModel,
+ jsonSchema,
+ getAppState,
+ setAppState,
+ replayUserMessages = false,
+ includePartialMessages = false,
+ agents = [],
+ setSDKStatus,
+ orphanedPermission,
+ } = this.config
+
+ this.discoveredSkillNames.clear()
+ setCwd(cwd)
+ const persistSession = !isSessionPersistenceDisabled()
+ const startTime = Date.now()
+
+ // Wrap canUseTool to track permission denials
+ const wrappedCanUseTool: CanUseToolFn = async (
+ tool,
+ input,
+ toolUseContext,
+ assistantMessage,
+ toolUseID,
+ forceDecision,
+ ) => {
+ const result = await canUseTool(
+ tool,
+ input,
+ toolUseContext,
+ assistantMessage,
+ toolUseID,
+ forceDecision,
+ )
+
+ // Track denials for SDK reporting
+ if (result.behavior !== 'allow') {
+ this.permissionDenials.push({
+ tool_name: sdkCompatToolName(tool.name),
+ tool_use_id: toolUseID,
+ tool_input: input,
+ })
+ }
+
+ return result
+ }
+
+ const initialAppState = getAppState()
+ const initialMainLoopModel = userSpecifiedModel
+ ? parseUserSpecifiedModel(userSpecifiedModel)
+ : getMainLoopModel()
+
+ const initialThinkingConfig: ThinkingConfig = thinkingConfig
+ ? thinkingConfig
+ : shouldEnableThinkingByDefault() !== false
+ ? { type: 'adaptive' }
+ : { type: 'disabled' }
+
+ headlessProfilerCheckpoint('before_getSystemPrompt')
+ // Narrow once so TS tracks the type through the conditionals below.
+ const customPrompt =
+ typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined
+ const {
+ defaultSystemPrompt,
+ userContext: baseUserContext,
+ systemContext,
+ } = await fetchSystemPromptParts({
+ tools,
+ mainLoopModel: initialMainLoopModel,
+ additionalWorkingDirectories: Array.from(
+ initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(),
+ ),
+ mcpClients,
+ customSystemPrompt: customPrompt,
+ })
+ headlessProfilerCheckpoint('after_getSystemPrompt')
+ const userContext = {
+ ...baseUserContext,
+ ...getCoordinatorUserContext(
+ mcpClients,
+ isScratchpadEnabled() ? getScratchpadDir() : undefined,
+ ),
+ }
+
+ // When an SDK caller provides a custom system prompt AND has set
+ // CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt.
+ // The env var is an explicit opt-in signal — the caller has wired up
+ // a memory directory and needs Claude to know how to use it (which
+ // Write/Edit tools to call, MEMORY.md filename, loading semantics).
+ // The caller can layer their own policy text via appendSystemPrompt.
+ const memoryMechanicsPrompt =
+ customPrompt !== undefined && hasAutoMemPathOverride()
+ ? await loadMemoryPrompt()
+ : null
+
+ const systemPrompt = asSystemPrompt([
+ ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
+ ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
+ ...(appendSystemPrompt ? [appendSystemPrompt] : []),
+ ])
+
+ // Register function hook for structured output enforcement
+ const hasStructuredOutputTool = tools.some(t =>
+ toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME),
+ )
+ if (jsonSchema && hasStructuredOutputTool) {
+ registerStructuredOutputEnforcement(setAppState, getSessionId())
+ }
+
+ let processUserInputContext: ProcessUserInputContext = {
+ messages: this.mutableMessages,
+ // Slash commands that mutate the message array (e.g. /force-snip)
+ // call setMessages(fn). In interactive mode this writes back to
+ // AppState; in print mode we write back to mutableMessages so the
+ // rest of the query loop (push at :389, snapshot at :392) sees
+ // the result. The second processUserInputContext below (after
+ // slash-command processing) keeps the no-op — nothing else calls
+ // setMessages past that point.
+ setMessages: fn => {
+ this.mutableMessages = fn(this.mutableMessages)
+ },
+ onChangeAPIKey: () => {},
+ handleElicitation: this.config.handleElicitation,
+ options: {
+ commands,
+ debug: false, // we use stdout, so don't want to clobber it
+ tools,
+ verbose,
+ mainLoopModel: initialMainLoopModel,
+ thinkingConfig: initialThinkingConfig,
+ mcpClients,
+ mcpResources: {},
+ ideInstallationStatus: null,
+ isNonInteractiveSession: true,
+ customSystemPrompt,
+ appendSystemPrompt,
+ agentDefinitions: { activeAgents: agents, allAgents: [] },
+ theme: resolveThemeSetting(getGlobalConfig().theme),
+ maxBudgetUsd,
+ },
+ getAppState,
+ setAppState,
+ abortController: this.abortController,
+ readFileState: this.readFileState,
+ nestedMemoryAttachmentTriggers: new Set(),
+ loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
+ dynamicSkillDirTriggers: new Set(),
+ discoveredSkillNames: this.discoveredSkillNames,
+ setInProgressToolUseIDs: () => {},
+ setResponseLength: () => {},
+ updateFileHistoryState: (
+ updater: (prev: FileHistoryState) => FileHistoryState,
+ ) => {
+ setAppState(prev => {
+ const updated = updater(prev.fileHistory)
+ if (updated === prev.fileHistory) return prev
+ return { ...prev, fileHistory: updated }
+ })
+ },
+ updateAttributionState: (
+ updater: (prev: AttributionState) => AttributionState,
+ ) => {
+ setAppState(prev => {
+ const updated = updater(prev.attribution)
+ if (updated === prev.attribution) return prev
+ return { ...prev, attribution: updated }
+ })
+ },
+ setSDKStatus,
+ }
+
+ // Handle orphaned permission (only once per engine lifetime)
+ if (orphanedPermission && !this.hasHandledOrphanedPermission) {
+ this.hasHandledOrphanedPermission = true
+ for await (const message of handleOrphanedPermission(
+ orphanedPermission,
+ tools,
+ this.mutableMessages,
+ processUserInputContext,
+ )) {
+ yield message
+ }
+ }
+
+ const {
+ messages: messagesFromUserInput,
+ shouldQuery,
+ allowedTools,
+ model: modelFromUserInput,
+ resultText,
+ } = await processUserInput({
+ input: prompt,
+ mode: 'prompt',
+ setToolJSX: () => {},
+ context: {
+ ...processUserInputContext,
+ messages: this.mutableMessages,
+ },
+ messages: this.mutableMessages,
+ uuid: options?.uuid,
+ isMeta: options?.isMeta,
+ querySource: 'sdk',
+ })
+
+ // Push new messages, including user input and any attachments
+ this.mutableMessages.push(...messagesFromUserInput)
+
+ // Update params to reflect updates from processing /slash commands
+ const messages = [...this.mutableMessages]
+
+ // Persist the user's message(s) to transcript BEFORE entering the query
+ // loop. The for-await below only calls recordTranscript when ask() yields
+ // an assistant/user/compact_boundary message — which doesn't happen until
+ // the API responds. If the process is killed before that (e.g. user clicks
+ // Stop in cowork seconds after send), the transcript is left with only
+ // queue-operation entries; getLastSessionLog filters those out, returns
+ // null, and --resume fails with "No conversation found". Writing now makes
+ // the transcript resumable from the point the user message was accepted,
+ // even if no API response ever arrives.
+ //
+ // --bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after
+ // kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention
+ // — the single largest controllable critical-path cost after module eval.
+ // Transcript is still written (for post-hoc debugging); just not blocking.
+ if (persistSession && messagesFromUserInput.length > 0) {
+ const transcriptPromise = recordTranscript(messages)
+ if (isBareMode()) {
+ void transcriptPromise
+ } else {
+ await transcriptPromise
+ if (
+ isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
+ isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
+ ) {
+ await flushSessionStorage()
+ }
+ }
+ }
+
+ // Filter messages that should be acknowledged after transcript
+ const replayableMessages = messagesFromUserInput.filter(
+ msg =>
+ (msg.type === 'user' &&
+ !msg.isMeta && // Skip synthetic caveat messages
+ !msg.toolUseResult && // Skip tool results (they'll be acked from query)
+ messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
+ (msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries
+ )
+ const messagesToAck = replayUserMessages ? replayableMessages : []
+
+ // Update the ToolPermissionContext based on user input processing (as necessary)
+ setAppState(prev => ({
+ ...prev,
+ toolPermissionContext: {
+ ...prev.toolPermissionContext,
+ alwaysAllowRules: {
+ ...prev.toolPermissionContext.alwaysAllowRules,
+ command: allowedTools,
+ },
+ },
+ }))
+
+ const mainLoopModel = modelFromUserInput ?? initialMainLoopModel
+
+ // Recreate after processing the prompt to pick up updated messages and
+ // model (from slash commands).
+ processUserInputContext = {
+ messages,
+ setMessages: () => {},
+ onChangeAPIKey: () => {},
+ handleElicitation: this.config.handleElicitation,
+ options: {
+ commands,
+ debug: false,
+ tools,
+ verbose,
+ mainLoopModel,
+ thinkingConfig: initialThinkingConfig,
+ mcpClients,
+ mcpResources: {},
+ ideInstallationStatus: null,
+ isNonInteractiveSession: true,
+ customSystemPrompt,
+ appendSystemPrompt,
+ theme: resolveThemeSetting(getGlobalConfig().theme),
+ agentDefinitions: { activeAgents: agents, allAgents: [] },
+ maxBudgetUsd,
+ },
+ getAppState,
+ setAppState,
+ abortController: this.abortController,
+ readFileState: this.readFileState,
+ nestedMemoryAttachmentTriggers: new Set(),
+ loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
+ dynamicSkillDirTriggers: new Set(),
+ discoveredSkillNames: this.discoveredSkillNames,
+ setInProgressToolUseIDs: () => {},
+ setResponseLength: () => {},
+ updateFileHistoryState: processUserInputContext.updateFileHistoryState,
+ updateAttributionState: processUserInputContext.updateAttributionState,
+ setSDKStatus,
+ }
+
+ headlessProfilerCheckpoint('before_skills_plugins')
+ // Cache-only: headless/SDK/CCR startup must not block on network for
+ // ref-tracked plugins. CCR populates the cache via CLAUDE_CODE_SYNC_PLUGIN_INSTALL
+ // (headlessPluginInstall) or CLAUDE_CODE_PLUGIN_SEED_DIR before this runs;
+ // SDK callers that need fresh source can call /reload-plugins.
+ const [skills, { enabled: enabledPlugins }] = await Promise.all([
+ getSlashCommandToolSkills(getCwd()),
+ loadAllPluginsCacheOnly(),
+ ])
+ headlessProfilerCheckpoint('after_skills_plugins')
+
+ yield buildSystemInitMessage({
+ tools,
+ mcpClients,
+ model: mainLoopModel,
+ permissionMode: initialAppState.toolPermissionContext
+ .mode as PermissionMode, // TODO: avoid the cast
+ commands,
+ agents,
+ skills,
+ plugins: enabledPlugins,
+ fastMode: initialAppState.fastMode,
+ })
+
+ // Record when system message is yielded for headless latency tracking
+ headlessProfilerCheckpoint('system_message_yielded')
+
+ if (!shouldQuery) {
+ // Return the results of local slash commands.
+ // Use messagesFromUserInput (not replayableMessages) for command output
+ // because selectableUserMessagesFilter excludes local-command-stdout tags.
+ for (const msg of messagesFromUserInput) {
+ if (
+ msg.type === 'user' &&
+ typeof msg.message.content === 'string' &&
+ (msg.message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
+ msg.message.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`) ||
+ msg.isCompactSummary)
+ ) {
+ yield {
+ type: 'user',
+ message: {
+ ...msg.message,
+ content: stripAnsi(msg.message.content),
+ },
+ session_id: getSessionId(),
+ parent_tool_use_id: null,
+ uuid: msg.uuid,
+ timestamp: msg.timestamp,
+ isReplay: !msg.isCompactSummary,
+ isSynthetic: msg.isMeta || msg.isVisibleInTranscriptOnly,
+ } as SDKUserMessageReplay
+ }
+
+ // Local command output — yield as a synthetic assistant message so
+ // RC renders it as assistant-style text rather than a user bubble.
+ // Emitted as assistant (not the dedicated SDKLocalCommandOutputMessage
+ // system subtype) so mobile clients + session-ingress can parse it.
+ if (
+ msg.type === 'system' &&
+ msg.subtype === 'local_command' &&
+ typeof msg.content === 'string' &&
+ (msg.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
+ msg.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`))
+ ) {
+ yield localCommandOutputToSDKAssistantMessage(msg.content, msg.uuid)
+ }
+
+ if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
+ yield {
+ type: 'system',
+ subtype: 'compact_boundary' as const,
+ session_id: getSessionId(),
+ uuid: msg.uuid,
+ compact_metadata: toSDKCompactMetadata(msg.compactMetadata),
+ } as SDKCompactBoundaryMessage
+ }
+ }
+
+ if (persistSession) {
+ await recordTranscript(messages)
+ if (
+ isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
+ isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
+ ) {
+ await flushSessionStorage()
+ }
+ }
+
+ yield {
+ type: 'result',
+ subtype: 'success',
+ is_error: false,
+ duration_ms: Date.now() - startTime,
+ duration_api_ms: getTotalAPIDuration(),
+ num_turns: messages.length - 1,
+ result: resultText ?? '',
+ stop_reason: null,
+ session_id: getSessionId(),
+ total_cost_usd: getTotalCost(),
+ usage: this.totalUsage,
+ modelUsage: getModelUsage(),
+ permission_denials: this.permissionDenials,
+ fast_mode_state: getFastModeState(
+ mainLoopModel,
+ initialAppState.fastMode,
+ ),
+ uuid: randomUUID(),
+ }
+ return
+ }
+
+ if (fileHistoryEnabled() && persistSession) {
+ messagesFromUserInput
+ .filter(messageSelector().selectableUserMessagesFilter)
+ .forEach(message => {
+ void fileHistoryMakeSnapshot(
+ (updater: (prev: FileHistoryState) => FileHistoryState) => {
+ setAppState(prev => ({
+ ...prev,
+ fileHistory: updater(prev.fileHistory),
+ }))
+ },
+ message.uuid,
+ )
+ })
+ }
+
+ // Track current message usage (reset on each message_start)
+ let currentMessageUsage: NonNullableUsage = EMPTY_USAGE
+ let turnCount = 1
+ let hasAcknowledgedInitialMessages = false
+ // Track structured output from StructuredOutput tool calls
+ let structuredOutputFromTool: unknown
+ // Track the last stop_reason from assistant messages
+ let lastStopReason: string | null = null
+ // Reference-based watermark so error_during_execution's errors[] is
+ // turn-scoped. A length-based index breaks when the 100-entry ring buffer
+ // shift()s during the turn — the index slides. If this entry is rotated
+ // out, lastIndexOf returns -1 and we include everything (safe fallback).
+ const errorLogWatermark = getInMemoryErrors().at(-1)
+ // Snapshot count before this query for delta-based retry limiting
+ const initialStructuredOutputCalls = jsonSchema
+ ? countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME)
+ : 0
+
+ for await (const message of query({
+ messages,
+ systemPrompt,
+ userContext,
+ systemContext,
+ canUseTool: wrappedCanUseTool,
+ toolUseContext: processUserInputContext,
+ fallbackModel,
+ querySource: 'sdk',
+ maxTurns,
+ taskBudget,
+ })) {
+ // Record assistant, user, and compact boundary messages
+ if (
+ message.type === 'assistant' ||
+ message.type === 'user' ||
+ (message.type === 'system' && message.subtype === 'compact_boundary')
+ ) {
+ // Before writing a compact boundary, flush any in-memory-only
+ // messages up through the preservedSegment tail. Attachments and
+ // progress are now recorded inline (their switch cases below), but
+ // this flush still matters for the preservedSegment tail walk.
+ // If the SDK subprocess restarts before then (claude-desktop kills
+ // between turns), tailUuid points to a never-written message →
+ // applyPreservedSegmentRelinks fails its tail→head walk → returns
+ // without pruning → resume loads full pre-compact history.
+ if (
+ persistSession &&
+ message.type === 'system' &&
+ message.subtype === 'compact_boundary'
+ ) {
+ const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
+ if (tailUuid) {
+ const tailIdx = this.mutableMessages.findLastIndex(
+ m => m.uuid === tailUuid,
+ )
+ if (tailIdx !== -1) {
+ await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
+ }
+ }
+ }
+ messages.push(message)
+ if (persistSession) {
+ // Fire-and-forget for assistant messages. claude.ts yields one
+ // assistant message per content block, then mutates the last
+ // one's message.usage/stop_reason on message_delta — relying on
+ // the write queue's 100ms lazy jsonStringify. Awaiting here
+ // blocks ask()'s generator, so message_delta can't run until
+ // every block is consumed; the drain timer (started at block 1)
+ // elapses first. Interactive CC doesn't hit this because
+ // useLogMessages.ts fire-and-forgets. enqueueWrite is
+ // order-preserving so fire-and-forget here is safe.
+ if (message.type === 'assistant') {
+ void recordTranscript(messages)
+ } else {
+ await recordTranscript(messages)
+ }
+ }
+
+ // Acknowledge initial user messages after first transcript recording
+ if (!hasAcknowledgedInitialMessages && messagesToAck.length > 0) {
+ hasAcknowledgedInitialMessages = true
+ for (const msgToAck of messagesToAck) {
+ if (msgToAck.type === 'user') {
+ yield {
+ type: 'user',
+ message: msgToAck.message,
+ session_id: getSessionId(),
+ parent_tool_use_id: null,
+ uuid: msgToAck.uuid,
+ timestamp: msgToAck.timestamp,
+ isReplay: true,
+ } as SDKUserMessageReplay
+ }
+ }
+ }
+ }
+
+ if (message.type === 'user') {
+ turnCount++
+ }
+
+ switch (message.type) {
+ case 'tombstone':
+ // Tombstone messages are control signals for removing messages, skip them
+ break
+ case 'assistant':
+ // Capture stop_reason if already set (synthetic messages). For
+ // streamed responses, this is null at content_block_stop time;
+ // the real value arrives via message_delta (handled below).
+ if (message.message.stop_reason != null) {
+ lastStopReason = message.message.stop_reason
+ }
+ this.mutableMessages.push(message)
+ yield* normalizeMessage(message)
+ break
+ case 'progress':
+ this.mutableMessages.push(message)
+ // Record inline so the dedup loop in the next ask() call sees it
+ // as already-recorded. Without this, deferred progress interleaves
+ // with already-recorded tool_results in mutableMessages, and the
+ // dedup walk freezes startingParentUuid at the wrong message —
+ // forking the chain and orphaning the conversation on resume.
+ if (persistSession) {
+ messages.push(message)
+ void recordTranscript(messages)
+ }
+ yield* normalizeMessage(message)
+ break
+ case 'user':
+ this.mutableMessages.push(message)
+ yield* normalizeMessage(message)
+ break
+ case 'stream_event':
+ if (message.event.type === 'message_start') {
+ // Reset current message usage for new message
+ currentMessageUsage = EMPTY_USAGE
+ currentMessageUsage = updateUsage(
+ currentMessageUsage,
+ message.event.message.usage,
+ )
+ }
+ if (message.event.type === 'message_delta') {
+ currentMessageUsage = updateUsage(
+ currentMessageUsage,
+ message.event.usage,
+ )
+ // Capture stop_reason from message_delta. The assistant message
+ // is yielded at content_block_stop with stop_reason=null; the
+ // real value only arrives here (see claude.ts message_delta
+ // handler). Without this, result.stop_reason is always null.
+ if (message.event.delta.stop_reason != null) {
+ lastStopReason = message.event.delta.stop_reason
+ }
+ }
+ if (message.event.type === 'message_stop') {
+ // Accumulate current message usage into total
+ this.totalUsage = accumulateUsage(
+ this.totalUsage,
+ currentMessageUsage,
+ )
+ }
+
+ if (includePartialMessages) {
+ yield {
+ type: 'stream_event' as const,
+ event: message.event,
+ session_id: getSessionId(),
+ parent_tool_use_id: null,
+ uuid: randomUUID(),
+ }
+ }
+
+ break
+ case 'attachment':
+ this.mutableMessages.push(message)
+ // Record inline (same reason as progress above).
+ if (persistSession) {
+ messages.push(message)
+ void recordTranscript(messages)
+ }
+
+ // Extract structured output from StructuredOutput tool calls
+ if (message.attachment.type === 'structured_output') {
+ structuredOutputFromTool = message.attachment.data
+ }
+ // Handle max turns reached signal from query.ts
+ else if (message.attachment.type === 'max_turns_reached') {
+ if (persistSession) {
+ if (
+ isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
+ isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
+ ) {
+ await flushSessionStorage()
+ }
+ }
+ yield {
+ type: 'result',
+ subtype: 'error_max_turns',
+ duration_ms: Date.now() - startTime,
+ duration_api_ms: getTotalAPIDuration(),
+ is_error: true,
+ num_turns: message.attachment.turnCount,
+ stop_reason: lastStopReason,
+ session_id: getSessionId(),
+ total_cost_usd: getTotalCost(),
+ usage: this.totalUsage,
+ modelUsage: getModelUsage(),
+ permission_denials: this.permissionDenials,
+ fast_mode_state: getFastModeState(
+ mainLoopModel,
+ initialAppState.fastMode,
+ ),
+ uuid: randomUUID(),
+ errors: [
+ `Reached maximum number of turns (${message.attachment.maxTurns})`,
+ ],
+ }
+ return
+ }
+ // Yield queued_command attachments as SDK user message replays
+ else if (
+ replayUserMessages &&
+ message.attachment.type === 'queued_command'
+ ) {
+ yield {
+ type: 'user',
+ message: {
+ role: 'user' as const,
+ content: message.attachment.prompt,
+ },
+ session_id: getSessionId(),
+ parent_tool_use_id: null,
+ uuid: message.attachment.source_uuid || message.uuid,
+ timestamp: message.timestamp,
+ isReplay: true,
+ } as SDKUserMessageReplay
+ }
+ break
+ case 'stream_request_start':
+ // Don't yield stream request start messages
+ break
+ case 'system': {
+ // Snip boundary: replay on our store to remove zombie messages and
+ // stale markers. The yielded boundary is a signal, not data to push —
+ // the replay produces its own equivalent boundary. Without this,
+ // markers persist and re-trigger on every turn, and mutableMessages
+ // never shrinks (memory leak in long SDK sessions). The subtype
+ // check lives inside the injected callback so feature-gated strings
+ // stay out of this file (excluded-strings check).
+ const snipResult = this.config.snipReplay?.(
+ message,
+ this.mutableMessages,
+ )
+ if (snipResult !== undefined) {
+ if (snipResult.executed) {
+ this.mutableMessages.length = 0
+ this.mutableMessages.push(...snipResult.messages)
+ }
+ break
+ }
+ this.mutableMessages.push(message)
+ // Yield compact boundary messages to SDK
+ if (
+ message.subtype === 'compact_boundary' &&
+ message.compactMetadata
+ ) {
+ // Release pre-compaction messages for GC. The boundary was just
+ // pushed so it's the last element. query.ts already uses
+ // getMessagesAfterCompactBoundary() internally, so only
+ // post-boundary messages are needed going forward.
+ const mutableBoundaryIdx = this.mutableMessages.length - 1
+ if (mutableBoundaryIdx > 0) {
+ this.mutableMessages.splice(0, mutableBoundaryIdx)
+ }
+ const localBoundaryIdx = messages.length - 1
+ if (localBoundaryIdx > 0) {
+ messages.splice(0, localBoundaryIdx)
+ }
+
+ yield {
+ type: 'system',
+ subtype: 'compact_boundary' as const,
+ session_id: getSessionId(),
+ uuid: message.uuid,
+ compact_metadata: toSDKCompactMetadata(message.compactMetadata),
+ }
+ }
+ if (message.subtype === 'api_error') {
+ yield {
+ type: 'system',
+ subtype: 'api_retry' as const,
+ attempt: message.retryAttempt,
+ max_retries: message.maxRetries,
+ retry_delay_ms: message.retryInMs,
+ error_status: message.error.status ?? null,
+ error: categorizeRetryableAPIError(message.error),
+ session_id: getSessionId(),
+ uuid: message.uuid,
+ }
+ }
+ // Don't yield other system messages in headless mode
+ break
+ }
+ case 'tool_use_summary':
+ // Yield tool use summary messages to SDK
+ yield {
+ type: 'tool_use_summary' as const,
+ summary: message.summary,
+ preceding_tool_use_ids: message.precedingToolUseIds,
+ session_id: getSessionId(),
+ uuid: message.uuid,
+ }
+ break
+ }
+
+ // Check if USD budget has been exceeded
+ if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
+ if (persistSession) {
+ if (
+ isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
+ isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
+ ) {
+ await flushSessionStorage()
+ }
+ }
+ yield {
+ type: 'result',
+ subtype: 'error_max_budget_usd',
+ duration_ms: Date.now() - startTime,
+ duration_api_ms: getTotalAPIDuration(),
+ is_error: true,
+ num_turns: turnCount,
+ stop_reason: lastStopReason,
+ session_id: getSessionId(),
+ total_cost_usd: getTotalCost(),
+ usage: this.totalUsage,
+ modelUsage: getModelUsage(),
+ permission_denials: this.permissionDenials,
+ fast_mode_state: getFastModeState(
+ mainLoopModel,
+ initialAppState.fastMode,
+ ),
+ uuid: randomUUID(),
+ errors: [`Reached maximum budget ($${maxBudgetUsd})`],
+ }
+ return
+ }
+
+ // Check if structured output retry limit exceeded (only on user messages)
+ if (message.type === 'user' && jsonSchema) {
+ const currentCalls = countToolCalls(
+ this.mutableMessages,
+ SYNTHETIC_OUTPUT_TOOL_NAME,
+ )
+ const callsThisQuery = currentCalls - initialStructuredOutputCalls
+ const maxRetries = parseInt(
+ process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5',
+ 10,
+ )
+ if (callsThisQuery >= maxRetries) {
+ if (persistSession) {
+ if (
+ isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
+ isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
+ ) {
+ await flushSessionStorage()
+ }
+ }
+ yield {
+ type: 'result',
+ subtype: 'error_max_structured_output_retries',
+ duration_ms: Date.now() - startTime,
+ duration_api_ms: getTotalAPIDuration(),
+ is_error: true,
+ num_turns: turnCount,
+ stop_reason: lastStopReason,
+ session_id: getSessionId(),
+ total_cost_usd: getTotalCost(),
+ usage: this.totalUsage,
+ modelUsage: getModelUsage(),
+ permission_denials: this.permissionDenials,
+ fast_mode_state: getFastModeState(
+ mainLoopModel,
+ initialAppState.fastMode,
+ ),
+ uuid: randomUUID(),
+ errors: [
+ `Failed to provide valid structured output after ${maxRetries} attempts`,
+ ],
+ }
+ return
+ }
+ }
+ }
+
+ // Stop hooks yield progress/attachment messages AFTER the assistant
+ // response (via yield* handleStopHooks in query.ts). Since #23537 pushes
+ // those to `messages` inline, last(messages) can be a progress/attachment
+ // instead of the assistant — which makes textResult extraction below
+ // return '' and -p mode emit a blank line. Allowlist to assistant|user:
+ // isResultSuccessful handles both (user with all tool_result blocks is a
+ // valid successful terminal state).
+ const result = messages.findLast(
+ m => m.type === 'assistant' || m.type === 'user',
+ )
+ // Capture for the error_during_execution diagnostic — isResultSuccessful
+ // is a type predicate (message is Message), so inside the false branch
+ // `result` narrows to never and these accesses don't typecheck.
+ const edeResultType = result?.type ?? 'undefined'
+ const edeLastContentType =
+ result?.type === 'assistant'
+ ? (last(result.message.content)?.type ?? 'none')
+ : 'n/a'
+
+ // Flush buffered transcript writes before yielding result.
+ // The desktop app kills the CLI process immediately after receiving the
+ // result message, so any unflushed writes would be lost.
+ if (persistSession) {
+ if (
+ isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
+ isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
+ ) {
+ await flushSessionStorage()
+ }
+ }
+
+ if (!isResultSuccessful(result, lastStopReason)) {
+ yield {
+ type: 'result',
+ subtype: 'error_during_execution',
+ duration_ms: Date.now() - startTime,
+ duration_api_ms: getTotalAPIDuration(),
+ is_error: true,
+ num_turns: turnCount,
+ stop_reason: lastStopReason,
+ session_id: getSessionId(),
+ total_cost_usd: getTotalCost(),
+ usage: this.totalUsage,
+ modelUsage: getModelUsage(),
+ permission_denials: this.permissionDenials,
+ fast_mode_state: getFastModeState(
+ mainLoopModel,
+ initialAppState.fastMode,
+ ),
+ uuid: randomUUID(),
+ // Diagnostic prefix: these are what isResultSuccessful() checks — if
+ // the result type isn't assistant-with-text/thinking or user-with-
+ // tool_result, and stop_reason isn't end_turn, that's why this fired.
+ // errors[] is turn-scoped via the watermark; previously it dumped the
+ // entire process's logError buffer (ripgrep timeouts, ENOENT, etc).
+ errors: (() => {
+ const all = getInMemoryErrors()
+ const start = errorLogWatermark
+ ? all.lastIndexOf(errorLogWatermark) + 1
+ : 0
+ return [
+ `[ede_diagnostic] result_type=${edeResultType} last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
+ ...all.slice(start).map(_ => _.error),
+ ]
+ })(),
+ }
+ return
+ }
+
+ // Extract the text result based on message type
+ let textResult = ''
+ let isApiError = false
+
+ if (result.type === 'assistant') {
+ const lastContent = last(result.message.content)
+ if (
+ lastContent?.type === 'text' &&
+ !SYNTHETIC_MESSAGES.has(lastContent.text)
+ ) {
+ textResult = lastContent.text
+ }
+ isApiError = Boolean(result.isApiErrorMessage)
+ }
+
+ yield {
+ type: 'result',
+ subtype: 'success',
+ is_error: isApiError,
+ duration_ms: Date.now() - startTime,
+ duration_api_ms: getTotalAPIDuration(),
+ num_turns: turnCount,
+ result: textResult,
+ stop_reason: lastStopReason,
+ session_id: getSessionId(),
+ total_cost_usd: getTotalCost(),
+ usage: this.totalUsage,
+ modelUsage: getModelUsage(),
+ permission_denials: this.permissionDenials,
+ structured_output: structuredOutputFromTool,
+ fast_mode_state: getFastModeState(
+ mainLoopModel,
+ initialAppState.fastMode,
+ ),
+ uuid: randomUUID(),
+ }
+ }
+
+ interrupt(): void {
+ this.abortController.abort()
+ }
+
+ getMessages(): readonly Message[] {
+ return this.mutableMessages
+ }
+
+ getReadFileState(): FileStateCache {
+ return this.readFileState
+ }
+
+ getSessionId(): string {
+ return getSessionId()
+ }
+
+ setModel(model: string): void {
+ this.config.userSpecifiedModel = model
+ }
+}
+
+/**
+ * Sends a single prompt to the Claude API and returns the response.
+ * Assumes that claude is being used non-interactively -- will not
+ * ask the user for permissions or further input.
+ *
+ * Convenience wrapper around QueryEngine for one-shot usage.
+ */
+export async function* ask({
+ commands,
+ prompt,
+ promptUuid,
+ isMeta,
+ cwd,
+ tools,
+ mcpClients,
+ verbose = false,
+ thinkingConfig,
+ maxTurns,
+ maxBudgetUsd,
+ taskBudget,
+ canUseTool,
+ mutableMessages = [],
+ getReadFileCache,
+ setReadFileCache,
+ customSystemPrompt,
+ appendSystemPrompt,
+ userSpecifiedModel,
+ fallbackModel,
+ jsonSchema,
+ getAppState,
+ setAppState,
+ abortController,
+ replayUserMessages = false,
+ includePartialMessages = false,
+ handleElicitation,
+ agents = [],
+ setSDKStatus,
+ orphanedPermission,
+}: {
+ commands: Command[]
+ prompt: string | Array
+ promptUuid?: string
+ isMeta?: boolean
+ cwd: string
+ tools: Tools
+ verbose?: boolean
+ mcpClients: MCPServerConnection[]
+ thinkingConfig?: ThinkingConfig
+ maxTurns?: number
+ maxBudgetUsd?: number
+ taskBudget?: { total: number }
+ canUseTool: CanUseToolFn
+ mutableMessages?: Message[]
+ customSystemPrompt?: string
+ appendSystemPrompt?: string
+ userSpecifiedModel?: string
+ fallbackModel?: string
+ jsonSchema?: Record
+ getAppState: () => AppState
+ setAppState: (f: (prev: AppState) => AppState) => void
+ getReadFileCache: () => FileStateCache
+ setReadFileCache: (cache: FileStateCache) => void
+ abortController?: AbortController
+ replayUserMessages?: boolean
+ includePartialMessages?: boolean
+ handleElicitation?: ToolUseContext['handleElicitation']
+ agents?: AgentDefinition[]
+ setSDKStatus?: (status: SDKStatus) => void
+ orphanedPermission?: OrphanedPermission
+}): AsyncGenerator {
+ const engine = new QueryEngine({
+ cwd,
+ tools,
+ commands,
+ mcpClients,
+ agents,
+ canUseTool,
+ getAppState,
+ setAppState,
+ initialMessages: mutableMessages,
+ readFileCache: cloneFileStateCache(getReadFileCache()),
+ customSystemPrompt,
+ appendSystemPrompt,
+ userSpecifiedModel,
+ fallbackModel,
+ thinkingConfig,
+ maxTurns,
+ maxBudgetUsd,
+ taskBudget,
+ jsonSchema,
+ verbose,
+ handleElicitation,
+ replayUserMessages,
+ includePartialMessages,
+ setSDKStatus,
+ abortController,
+ orphanedPermission,
+ ...(feature('HISTORY_SNIP')
+ ? {
+ snipReplay: (yielded: Message, store: Message[]) => {
+ if (!snipProjection!.isSnipBoundaryMessage(yielded))
+ return undefined
+ return snipModule!.snipCompactIfNeeded(store, { force: true })
+ },
+ }
+ : {}),
+ })
+
+ try {
+ yield* engine.submitMessage(prompt, {
+ uuid: promptUuid,
+ isMeta,
+ })
+ } finally {
+ setReadFileCache(engine.getReadFileState())
+ }
+}
+
+
diff --git a/src/Task.ts b/src/Task.ts
new file mode 100644
index 0000000..da197c4
--- /dev/null
+++ b/src/Task.ts
@@ -0,0 +1,127 @@
+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
+}
+
+// Task ID prefixes
+const TASK_ID_PREFIXES: Record = {
+ 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,
+ }
+}
+
+
diff --git a/src/Tool.ts b/src/Tool.ts
new file mode 100644
index 0000000..285ed10
--- /dev/null
+++ b/src/Tool.ts
@@ -0,0 +1,794 @@
+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
+ 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
+ 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
+ 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,
+ ) => void
+ /** Send an OS-level notification (iTerm2, Kitty, Ghostty, bell, etc.) */
+ sendOSNotification?: (opts: {
+ message: string
+ notificationType: string
+ }) => void
+ nestedMemoryAttachmentTriggers?: Set
+ /**
+ * 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
+ dynamicSkillDirTriggers?: Set
+ /** Skill names surfaced via skill_discovery this session. Telemetry only (feeds was_discovered). */
+ discoveredSkillNames?: Set
+ userModified?: boolean
+ setInProgressToolUseIDs: (f: (prev: Set) => Set) => 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
+ 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
= {
+ toolUseID: string
+ data: P
+}
+
+export function filterToolProgressMessages(
+ progressMessagesForMessage: ProgressMessage[],
+): ProgressMessage[] {
+ return progressMessagesForMessage.filter(
+ (msg): msg is ProgressMessage =>
+ msg.data?.type !== 'hook_progress',
+ )
+}
+
+export type ToolResult = {
+ 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
+ structuredContent?: Record
+ }
+}
+
+export type ToolCallProgress
= (
+ progress: ToolProgress
,
+) => 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,
+ context: ToolUseContext,
+ canUseTool: CanUseToolFn,
+ parentMessage: AssistantMessage,
+ onProgress?: ToolCallProgress
,
+ ): Promise>
+ description(
+ input: z.infer,
+ options: {
+ isNonInteractiveSession: boolean
+ toolPermissionContext: ToolPermissionContext
+ tools: Tools
+ },
+ ): Promise
+ 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
+ inputsEquivalent?(a: z.infer, b: z.infer): boolean
+ isConcurrencySafe(input: z.infer): boolean
+ isEnabled(): boolean
+ isReadOnly(input: z.infer): boolean
+ /** Defaults to false. Only set when the tool performs irreversible operations (delete, overwrite, send). */
+ isDestructive?(input: z.infer): 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): {
+ isSearch: boolean
+ isRead: boolean
+ isList?: boolean
+ }
+ isOpenWorld?(input: z.infer): 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): 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,
+ context: ToolUseContext,
+ ): Promise
+
+ /**
+ * 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,
+ context: ToolUseContext,
+ ): Promise
+
+ // Optional method for tools that operate on a file path
+ getPath?(input: z.infer): 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,
+ ): Promise<(pattern: string) => boolean>
+
+ prompt(options: {
+ getToolPermissionContext: () => Promise
+ tools: Tools
+ agents: AgentDefinition[]
+ allowedAgentTypes?: string[]
+ }): Promise
+ userFacingName(input: Partial> | undefined): string
+ userFacingNameBackgroundColor?(
+ input: Partial> | 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> | 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> | 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): 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
[],
+ 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>,
+ 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>): React.ReactNode
+ /**
+ * Optional. When omitted, no progress UI is shown while the tool runs.
+ */
+ renderToolUseProgressMessage?(
+ progressMessagesForMessage: ProgressMessage
[],
+ 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 .
+ * Only define this for tools that need custom rejection UI (e.g., file edits
+ * that show the rejected diff).
+ */
+ renderToolUseRejectedMessage?(
+ input: z.infer,
+ options: {
+ columns: number
+ messages: Message[]
+ style?: 'condensed'
+ theme: ThemeName
+ tools: Tools
+ verbose: boolean
+ progressMessagesForMessage: ProgressMessage
[]
+ isTranscriptMode?: boolean
+ },
+ ): React.ReactNode
+ /**
+ * Optional. When omitted, falls back to .
+ * 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
[]
+ 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
[]
+ 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, DefaultableToolKeys> &
+ Partial, 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 = Omit & {
+ [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 =>
+ 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 mirrors runtime `{...TOOL_DEFAULTS, ...def}` at the type level.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyToolDef = ToolDef
+
+export function buildTool(def: D): BuiltTool {
+ // The runtime spread is straightforward; the `as` bridges the gap between
+ // the structural-any constraint and the precise BuiltTool return. The
+ // type semantics are proven by the 0-error typecheck across all 60+ tools.
+ return {
+ ...TOOL_DEFAULTS,
+ userFacingName: () => def.name,
+ ...def,
+ } as BuiltTool
+}
+
+
diff --git a/src/assistant/sessionHistory.ts b/src/assistant/sessionHistory.ts
new file mode 100644
index 0000000..8253d46
--- /dev/null
+++ b/src/assistant/sessionHistory.ts
@@ -0,0 +1,89 @@
+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
+}
+
+/** Prepare auth + headers + base URL once, reuse across pages. */
+export async function createHistoryAuthCtx(
+ sessionId: string,
+): Promise {
+ 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,
+ label: string,
+): Promise {
+ const resp = await axios
+ .get(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 {
+ 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 {
+ return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
+}
+
+
diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts
new file mode 100644
index 0000000..2250d74
--- /dev/null
+++ b/src/bootstrap/state.ts
@@ -0,0 +1,1760 @@
+import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
+import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api'
+import type { logs } from '@opentelemetry/api-logs'
+import type { LoggerProvider } from '@opentelemetry/sdk-logs'
+import type { MeterProvider } from '@opentelemetry/sdk-metrics'
+import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
+import { realpathSync } from 'fs'
+import sumBy from 'lodash-es/sumBy.js'
+import { cwd } from 'process'
+import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js'
+import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'
+import type { HookCallbackMatcher } from 'src/types/hooks.js'
+// Indirection for browser-sdk build (package.json "browser" field swaps
+// crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto —
+// zero circular-dep risk. Path-alias import bypasses bootstrap-isolation
+// (rule only checks ./ and / prefixes); explicit disable documents intent.
+// eslint-disable-next-line custom-rules/bootstrap-isolation
+import { randomUUID } from 'src/utils/crypto.js'
+import type { ModelSetting } from 'src/utils/model/model.js'
+import type { ModelStrings } from 'src/utils/model/modelStrings.js'
+import type { SettingSource } from 'src/utils/settings/constants.js'
+import { resetSettingsCache } from 'src/utils/settings/settingsCache.js'
+import type { PluginHookMatcher } from 'src/utils/settings/types.js'
+import { createSignal } from 'src/utils/signal.js'
+
+// Union type for registered hooks - can be SDK callbacks or native plugin hooks
+type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher
+
+import type { SessionId } from 'src/types/ids.js'
+
+// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
+
+// dev: true on entries that came via --dangerously-load-development-channels.
+// The allowlist gate checks this per-entry (not the session-wide
+// hasDevChannels bit) so passing both flags doesn't let the dev dialog's
+// acceptance leak allowlist-bypass to the --channels entries.
+export type ChannelEntry =
+ | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean }
+ | { kind: 'server'; name: string; dev?: boolean }
+
+export type AttributedCounter = {
+ add(value: number, additionalAttributes?: Attributes): void
+}
+
+type State = {
+ originalCwd: string
+ // Stable project root - set once at startup (including by --worktree flag),
+ // never updated by mid-session EnterWorktreeTool.
+ // Use for project identity (history, skills, sessions) not file operations.
+ projectRoot: string
+ totalCostUSD: number
+ totalAPIDuration: number
+ totalAPIDurationWithoutRetries: number
+ totalToolDuration: number
+ turnHookDurationMs: number
+ turnToolDurationMs: number
+ turnClassifierDurationMs: number
+ turnToolCount: number
+ turnHookCount: number
+ turnClassifierCount: number
+ startTime: number
+ lastInteractionTime: number
+ totalLinesAdded: number
+ totalLinesRemoved: number
+ hasUnknownModelCost: boolean
+ cwd: string
+ modelUsage: { [modelName: string]: ModelUsage }
+ mainLoopModelOverride: ModelSetting | undefined
+ initialMainLoopModel: ModelSetting
+ modelStrings: ModelStrings | null
+ isInteractive: boolean
+ kairosActive: boolean
+ // When true, ensureToolResultPairing throws on mismatch instead of
+ // repairing with synthetic placeholders. HFI opts in at startup so
+ // trajectories fail fast rather than conditioning the model on fake
+ // tool_results.
+ strictToolResultPairing: boolean
+ sdkAgentProgressSummariesEnabled: boolean
+ userMsgOptIn: boolean
+ clientType: string
+ sessionSource: string | undefined
+ questionPreviewFormat: 'markdown' | 'html' | undefined
+ flagSettingsPath: string | undefined
+ flagSettingsInline: Record | null
+ allowedSettingSources: SettingSource[]
+ sessionIngressToken: string | null | undefined
+ oauthTokenFromFd: string | null | undefined
+ apiKeyFromFd: string | null | undefined
+ // Telemetry state
+ meter: Meter | null
+ sessionCounter: AttributedCounter | null
+ locCounter: AttributedCounter | null
+ prCounter: AttributedCounter | null
+ commitCounter: AttributedCounter | null
+ costCounter: AttributedCounter | null
+ tokenCounter: AttributedCounter | null
+ codeEditToolDecisionCounter: AttributedCounter | null
+ activeTimeCounter: AttributedCounter | null
+ statsStore: { observe(name: string, value: number): void } | null
+ sessionId: SessionId
+ // Parent session ID for tracking session lineage (e.g., plan mode -> implementation)
+ parentSessionId: SessionId | undefined
+ // Logger state
+ loggerProvider: LoggerProvider | null
+ eventLogger: ReturnType | null
+ // Meter provider state
+ meterProvider: MeterProvider | null
+ // Tracer provider state
+ tracerProvider: BasicTracerProvider | null
+ // Agent color state
+ agentColorMap: Map
+ agentColorIndex: number
+ // Last API request for bug reports
+ lastAPIRequest: Omit | null
+ // Messages from the last API request (ant-only; reference, not clone).
+ // Captures the exact post-compaction, CLAUDE.md-injected message set sent
+ // to the API so /share's serialized_conversation.json reflects reality.
+ lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
+ // Last auto-mode classifier request(s) for /share transcript
+ lastClassifierRequests: unknown[] | null
+ // CLAUDE.md content cached by context.ts for the auto-mode classifier.
+ // Breaks the yoloClassifier → claudemd → filesystem → permissions cycle.
+ cachedClaudeMdContent: string | null
+ // In-memory error log for recent errors
+ inMemoryErrorLog: Array<{ error: string; timestamp: string }>
+ // Session-only plugins from --plugin-dir flag
+ inlinePlugins: Array
+ // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
+ chromeFlagOverride: boolean | undefined
+ // Use cowork_plugins directory instead of plugins (--cowork flag or env var)
+ useCoworkPlugins: boolean
+ // Session-only bypass permissions mode flag (not persisted)
+ sessionBypassPermissionsMode: boolean
+ // Session-only flag gating the .claude/scheduled_tasks.json watcher
+ // (useScheduledTasks). Set by cronScheduler.start() when the JSON has
+ // entries, or by CronCreateTool. Not persisted.
+ scheduledTasksEnabled: boolean
+ // Session-only cron tasks created via CronCreate with durable: false.
+ // Fire on schedule like file-backed tasks but are never written to
+ // .claude/scheduled_tasks.json — they die with the process. Typed via
+ // SessionCronTask below (not importing from cronTasks.ts keeps
+ // bootstrap a leaf of the import DAG).
+ sessionCronTasks: SessionCronTask[]
+ // Teams created this session via TeamCreate. cleanupSessionTeams()
+ // removes these on gracefulShutdown so subagent-created teams don't
+ // persist on disk forever (gh-32730). TeamDelete removes entries to
+ // avoid double-cleanup. Lives here (not teamHelpers.ts) so
+ // resetStateForTests() clears it between tests.
+ sessionCreatedTeams: Set
+ // Session-only trust flag for home directory (not persisted to disk)
+ // When running from home dir, trust dialog is shown but not saved to disk.
+ // This flag allows features requiring trust to work during the session.
+ sessionTrustAccepted: boolean
+ // Session-only flag to disable session persistence to disk
+ sessionPersistenceDisabled: boolean
+ // Track if user has exited plan mode in this session (for re-entry guidance)
+ hasExitedPlanMode: boolean
+ // Track if we need to show the plan mode exit attachment (one-time notification)
+ needsPlanModeExitAttachment: boolean
+ // Track if we need to show the auto mode exit attachment (one-time notification)
+ needsAutoModeExitAttachment: boolean
+ // Track if LSP plugin recommendation has been shown this session (only show once)
+ lspRecommendationShownThisSession: boolean
+ // SDK init event state - jsonSchema for structured output
+ initJsonSchema: Record | null
+ // Registered hooks - SDK callbacks and plugin native hooks
+ registeredHooks: Partial> | null
+ // Cache for plan slugs: sessionId -> wordSlug
+ planSlugCache: Map
+ // Track teleported session for reliability logging
+ teleportedSessionInfo: {
+ isTeleported: boolean
+ hasLoggedFirstMessage: boolean
+ sessionId: string | null
+ } | null
+ // Track invoked skills for preservation across compaction
+ // Keys are composite: `${agentId ?? ''}:${skillName}` to prevent cross-agent overwrites
+ invokedSkills: Map<
+ string,
+ {
+ skillName: string
+ skillPath: string
+ content: string
+ invokedAt: number
+ agentId: string | null
+ }
+ >
+ // Track slow operations for dev bar display (ant-only)
+ slowOperations: Array<{
+ operation: string
+ durationMs: number
+ timestamp: number
+ }>
+ // SDK-provided betas (e.g., context-1m-2025-08-07)
+ sdkBetas: string[] | undefined
+ // Main thread agent type (from --agent flag or settings)
+ mainThreadAgentType: string | undefined
+ // Remote mode (--remote flag)
+ isRemoteMode: boolean
+ // Direct connect server URL (for display in header)
+ directConnectServerUrl: string | undefined
+ // System prompt section cache state
+ systemPromptSectionCache: Map
+ // Last date emitted to the model (for detecting midnight date changes)
+ lastEmittedDate: string | null
+ // Additional directories from --add-dir flag (for CLAUDE.md loading)
+ additionalDirectoriesForClaudeMd: string[]
+ // Channel server allowlist from --channels flag (servers whose channel
+ // notifications should register this session). Parsed once in main.tsx —
+ // the tag decides trust model: 'plugin' → marketplace verification +
+ // allowlist, 'server' → allowlist always fails (schema is plugin-only).
+ // Either kind needs entry.dev to bypass allowlist.
+ allowedChannels: ChannelEntry[]
+ // True if any entry in allowedChannels came from
+ // --dangerously-load-development-channels (so ChannelsNotice can name the
+ // right flag in policy-blocked messages)
+ hasDevChannels: boolean
+ // Dir containing the session's `.jsonl`; null = derive from originalCwd.
+ sessionProjectDir: string | null
+ // Cached prompt cache 1h TTL allowlist from GrowthBook (session-stable)
+ promptCache1hAllowlist: string[] | null
+ // Cached 1h TTL user eligibility (session-stable). Latched on first
+ // evaluation so mid-session overage flips don't change the cache_control
+ // TTL, which would bust the server-side prompt cache.
+ promptCache1hEligible: boolean | null
+ // Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first
+ // activated, keep sending the header for the rest of the session so
+ // Shift+Tab toggles don't bust the ~50-70K token prompt cache.
+ afkModeHeaderLatched: boolean | null
+ // Sticky-on latch for FAST_MODE_BETA_HEADER. Once fast mode is first
+ // enabled, keep sending the header so cooldown enter/exit doesn't
+ // double-bust the prompt cache. The `speed` body param stays dynamic.
+ fastModeHeaderLatched: boolean | null
+ // Sticky-on latch for the cache-editing beta header. Once cached
+ // microcompact is first enabled, keep sending the header so mid-session
+ // GrowthBook/settings toggles don't bust the prompt cache.
+ cacheEditingHeaderLatched: boolean | null
+ // Sticky-on latch for clearing thinking from prior tool loops. Triggered
+ // when >1h since last API call (confirmed cache miss — no cache-hit
+ // benefit to keeping thinking). Once latched, stays on so the newly-warmed
+ // thinking-cleared cache isn't busted by flipping back to keep:'all'.
+ thinkingClearLatched: boolean | null
+ // Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
+ promptId: string | null
+ // Last API requestId for the main conversation chain (not subagents).
+ // Updated after each successful API response for main-session queries.
+ // Read at shutdown to send cache eviction hints to inference.
+ lastMainRequestId: string | undefined
+ // Timestamp (Date.now()) of the last successful API call completion.
+ // Used to compute timeSinceLastApiCallMs in tengu_api_success for
+ // correlating cache misses with idle time (cache TTL is ~5min).
+ lastApiCompletionTimestamp: number | null
+ // Set to true after compaction (auto or manual /compact). Consumed by
+ // logAPISuccess to tag the first post-compaction API call so we can
+ // distinguish compaction-induced cache misses from TTL expiry.
+ pendingPostCompaction: boolean
+}
+
+// ALSO HERE - THINK THRICE BEFORE MODIFYING
+function getInitialState(): State {
+ // Resolve symlinks in cwd to match behavior of shell.ts setCwd
+ // This ensures consistency with how paths are sanitized for session storage
+ let resolvedCwd = ''
+ if (
+ typeof process !== 'undefined' &&
+ typeof process.cwd === 'function' &&
+ typeof realpathSync === 'function'
+ ) {
+ const rawCwd = cwd()
+ try {
+ resolvedCwd = realpathSync(rawCwd).normalize('NFC')
+ } catch {
+ // File Provider EPERM on CloudStorage mounts (lstat per path component).
+ resolvedCwd = rawCwd.normalize('NFC')
+ }
+ }
+ const state: State = {
+ originalCwd: resolvedCwd,
+ projectRoot: resolvedCwd,
+ totalCostUSD: 0,
+ totalAPIDuration: 0,
+ totalAPIDurationWithoutRetries: 0,
+ totalToolDuration: 0,
+ turnHookDurationMs: 0,
+ turnToolDurationMs: 0,
+ turnClassifierDurationMs: 0,
+ turnToolCount: 0,
+ turnHookCount: 0,
+ turnClassifierCount: 0,
+ startTime: Date.now(),
+ lastInteractionTime: Date.now(),
+ totalLinesAdded: 0,
+ totalLinesRemoved: 0,
+ hasUnknownModelCost: false,
+ cwd: resolvedCwd,
+ modelUsage: {},
+ mainLoopModelOverride: undefined,
+ initialMainLoopModel: null,
+ modelStrings: null,
+ isInteractive: false,
+ kairosActive: false,
+ strictToolResultPairing: false,
+ sdkAgentProgressSummariesEnabled: false,
+ userMsgOptIn: false,
+ clientType: 'cli',
+ sessionSource: undefined,
+ questionPreviewFormat: undefined,
+ sessionIngressToken: undefined,
+ oauthTokenFromFd: undefined,
+ apiKeyFromFd: undefined,
+ flagSettingsPath: undefined,
+ flagSettingsInline: null,
+ allowedSettingSources: [
+ 'userSettings',
+ 'projectSettings',
+ 'localSettings',
+ 'flagSettings',
+ 'policySettings',
+ ],
+ // Telemetry state
+ meter: null,
+ sessionCounter: null,
+ locCounter: null,
+ prCounter: null,
+ commitCounter: null,
+ costCounter: null,
+ tokenCounter: null,
+ codeEditToolDecisionCounter: null,
+ activeTimeCounter: null,
+ statsStore: null,
+ sessionId: randomUUID() as SessionId,
+ parentSessionId: undefined,
+ // Logger state
+ loggerProvider: null,
+ eventLogger: null,
+ // Meter provider state
+ meterProvider: null,
+ tracerProvider: null,
+ // Agent color state
+ agentColorMap: new Map(),
+ agentColorIndex: 0,
+ // Last API request for bug reports
+ lastAPIRequest: null,
+ lastAPIRequestMessages: null,
+ // Last auto-mode classifier request(s) for /share transcript
+ lastClassifierRequests: null,
+ cachedClaudeMdContent: null,
+ // In-memory error log for recent errors
+ inMemoryErrorLog: [],
+ // Session-only plugins from --plugin-dir flag
+ inlinePlugins: [],
+ // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
+ chromeFlagOverride: undefined,
+ // Use cowork_plugins directory instead of plugins
+ useCoworkPlugins: false,
+ // Session-only bypass permissions mode flag (not persisted)
+ sessionBypassPermissionsMode: false,
+ // Scheduled tasks disabled until flag or dialog enables them
+ scheduledTasksEnabled: false,
+ sessionCronTasks: [],
+ sessionCreatedTeams: new Set(),
+ // Session-only trust flag (not persisted to disk)
+ sessionTrustAccepted: false,
+ // Session-only flag to disable session persistence to disk
+ sessionPersistenceDisabled: false,
+ // Track if user has exited plan mode in this session
+ hasExitedPlanMode: false,
+ // Track if we need to show the plan mode exit attachment
+ needsPlanModeExitAttachment: false,
+ // Track if we need to show the auto mode exit attachment
+ needsAutoModeExitAttachment: false,
+ // Track if LSP plugin recommendation has been shown this session
+ lspRecommendationShownThisSession: false,
+ // SDK init event state
+ initJsonSchema: null,
+ registeredHooks: null,
+ // Cache for plan slugs
+ planSlugCache: new Map(),
+ // Track teleported session for reliability logging
+ teleportedSessionInfo: null,
+ // Track invoked skills for preservation across compaction
+ invokedSkills: new Map(),
+ // Track slow operations for dev bar display
+ slowOperations: [],
+ // SDK-provided betas
+ sdkBetas: undefined,
+ // Main thread agent type
+ mainThreadAgentType: undefined,
+ // Remote mode
+ isRemoteMode: false,
+ ...(process.env.USER_TYPE === 'ant'
+ ? {
+ replBridgeActive: false,
+ }
+ : {}),
+ // Direct connect server URL
+ directConnectServerUrl: undefined,
+ // System prompt section cache state
+ systemPromptSectionCache: new Map(),
+ // Last date emitted to the model
+ lastEmittedDate: null,
+ // Additional directories from --add-dir flag (for CLAUDE.md loading)
+ additionalDirectoriesForClaudeMd: [],
+ // Channel server allowlist from --channels flag
+ allowedChannels: [],
+ hasDevChannels: false,
+ // Session project dir (null = derive from originalCwd)
+ sessionProjectDir: null,
+ // Prompt cache 1h allowlist (null = not yet fetched from GrowthBook)
+ promptCache1hAllowlist: null,
+ // Prompt cache 1h eligibility (null = not yet evaluated)
+ promptCache1hEligible: null,
+ // Beta header latches (null = not yet triggered)
+ afkModeHeaderLatched: null,
+ fastModeHeaderLatched: null,
+ cacheEditingHeaderLatched: null,
+ thinkingClearLatched: null,
+ // Current prompt ID
+ promptId: null,
+ lastMainRequestId: undefined,
+ lastApiCompletionTimestamp: null,
+ pendingPostCompaction: false,
+ }
+
+ return state
+}
+
+// AND ESPECIALLY HERE
+const STATE: State = getInitialState()
+
+export function getSessionId(): SessionId {
+ return STATE.sessionId
+}
+
+export function regenerateSessionId(
+ options: { setCurrentAsParent?: boolean } = {},
+): SessionId {
+ if (options.setCurrentAsParent) {
+ STATE.parentSessionId = STATE.sessionId
+ }
+ // Drop the outgoing session's plan-slug entry so the Map doesn't
+ // accumulate stale keys. Callers that need to carry the slug across
+ // (REPL.tsx clearContext) read it before calling clearConversation.
+ STATE.planSlugCache.delete(STATE.sessionId)
+ // Regenerated sessions live in the current project: reset projectDir to
+ // null so getTranscriptPath() derives from originalCwd.
+ STATE.sessionId = randomUUID() as SessionId
+ STATE.sessionProjectDir = null
+ return STATE.sessionId
+}
+
+export function getParentSessionId(): SessionId | undefined {
+ return STATE.parentSessionId
+}
+
+/**
+ * Atomically switch the active session. `sessionId` and `sessionProjectDir`
+ * always change together — there is no separate setter for either, so they
+ * cannot drift out of sync (CC-34).
+ *
+ * @param projectDir — directory containing `.jsonl`. Omit (or
+ * pass `null`) for sessions in the current project — the path will derive
+ * from originalCwd at read time. Pass `dirname(transcriptPath)` when the
+ * session lives in a different project directory (git worktrees,
+ * cross-project resume). Every call resets the project dir; it never
+ * carries over from the previous session.
+ */
+export function switchSession(
+ sessionId: SessionId,
+ projectDir: string | null = null,
+): void {
+ // Drop the outgoing session's plan-slug entry so the Map stays bounded
+ // across repeated /resume. Only the current session's slug is ever read
+ // (plans.ts getPlanSlug defaults to getSessionId()).
+ STATE.planSlugCache.delete(STATE.sessionId)
+ STATE.sessionId = sessionId
+ STATE.sessionProjectDir = projectDir
+ sessionSwitched.emit(sessionId)
+}
+
+const sessionSwitched = createSignal<[id: SessionId]>()
+
+/**
+ * Register a callback that fires when switchSession changes the active
+ * sessionId. bootstrap can't import listeners directly (DAG leaf), so
+ * callers register themselves. concurrentSessions.ts uses this to keep the
+ * PID file's sessionId in sync with --resume.
+ */
+export const onSessionSwitch = sessionSwitched.subscribe
+
+/**
+ * Project directory the current session's transcript lives in, or `null` if
+ * the session was created in the current project (common case — derive from
+ * originalCwd). See `switchSession()`.
+ */
+export function getSessionProjectDir(): string | null {
+ return STATE.sessionProjectDir
+}
+
+export function getOriginalCwd(): string {
+ return STATE.originalCwd
+}
+
+/**
+ * Get the stable project root directory.
+ * Unlike getOriginalCwd(), this is never updated by mid-session EnterWorktreeTool
+ * (so skills/history stay stable when entering a throwaway worktree).
+ * It IS set at startup by --worktree, since that worktree is the session's project.
+ * Use for project identity (history, skills, sessions) not file operations.
+ */
+export function getProjectRoot(): string {
+ return STATE.projectRoot
+}
+
+export function setOriginalCwd(cwd: string): void {
+ STATE.originalCwd = cwd.normalize('NFC')
+}
+
+/**
+ * Only for --worktree startup flag. Mid-session EnterWorktreeTool must NOT
+ * call this — skills/history should stay anchored to where the session started.
+ */
+export function setProjectRoot(cwd: string): void {
+ STATE.projectRoot = cwd.normalize('NFC')
+}
+
+export function getCwdState(): string {
+ return STATE.cwd
+}
+
+export function setCwdState(cwd: string): void {
+ STATE.cwd = cwd.normalize('NFC')
+}
+
+export function getDirectConnectServerUrl(): string | undefined {
+ return STATE.directConnectServerUrl
+}
+
+export function setDirectConnectServerUrl(url: string): void {
+ STATE.directConnectServerUrl = url
+}
+
+export function addToTotalDurationState(
+ duration: number,
+ durationWithoutRetries: number,
+): void {
+ STATE.totalAPIDuration += duration
+ STATE.totalAPIDurationWithoutRetries += durationWithoutRetries
+}
+
+export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void {
+ STATE.totalAPIDuration = 0
+ STATE.totalAPIDurationWithoutRetries = 0
+ STATE.totalCostUSD = 0
+}
+
+export function addToTotalCostState(
+ cost: number,
+ modelUsage: ModelUsage,
+ model: string,
+): void {
+ STATE.modelUsage[model] = modelUsage
+ STATE.totalCostUSD += cost
+}
+
+export function getTotalCostUSD(): number {
+ return STATE.totalCostUSD
+}
+
+export function getTotalAPIDuration(): number {
+ return STATE.totalAPIDuration
+}
+
+export function getTotalDuration(): number {
+ return Date.now() - STATE.startTime
+}
+
+export function getTotalAPIDurationWithoutRetries(): number {
+ return STATE.totalAPIDurationWithoutRetries
+}
+
+export function getTotalToolDuration(): number {
+ return STATE.totalToolDuration
+}
+
+export function addToToolDuration(duration: number): void {
+ STATE.totalToolDuration += duration
+ STATE.turnToolDurationMs += duration
+ STATE.turnToolCount++
+}
+
+export function getTurnHookDurationMs(): number {
+ return STATE.turnHookDurationMs
+}
+
+export function addToTurnHookDuration(duration: number): void {
+ STATE.turnHookDurationMs += duration
+ STATE.turnHookCount++
+}
+
+export function resetTurnHookDuration(): void {
+ STATE.turnHookDurationMs = 0
+ STATE.turnHookCount = 0
+}
+
+export function getTurnHookCount(): number {
+ return STATE.turnHookCount
+}
+
+export function getTurnToolDurationMs(): number {
+ return STATE.turnToolDurationMs
+}
+
+export function resetTurnToolDuration(): void {
+ STATE.turnToolDurationMs = 0
+ STATE.turnToolCount = 0
+}
+
+export function getTurnToolCount(): number {
+ return STATE.turnToolCount
+}
+
+export function getTurnClassifierDurationMs(): number {
+ return STATE.turnClassifierDurationMs
+}
+
+export function addToTurnClassifierDuration(duration: number): void {
+ STATE.turnClassifierDurationMs += duration
+ STATE.turnClassifierCount++
+}
+
+export function resetTurnClassifierDuration(): void {
+ STATE.turnClassifierDurationMs = 0
+ STATE.turnClassifierCount = 0
+}
+
+export function getTurnClassifierCount(): number {
+ return STATE.turnClassifierCount
+}
+
+export function getStatsStore(): {
+ observe(name: string, value: number): void
+} | null {
+ return STATE.statsStore
+}
+
+export function setStatsStore(
+ store: { observe(name: string, value: number): void } | null,
+): void {
+ STATE.statsStore = store
+}
+
+/**
+ * Marks that an interaction occurred.
+ *
+ * By default the actual Date.now() call is deferred until the next Ink render
+ * frame (via flushInteractionTime()) so we avoid calling Date.now() on every
+ * single keypress.
+ *
+ * Pass `immediate = true` when calling from React useEffect callbacks or
+ * other code that runs *after* the Ink render cycle has already flushed.
+ * Without it the timestamp stays stale until the next render, which may never
+ * come if the user is idle (e.g. permission dialog waiting for input).
+ */
+let interactionTimeDirty = false
+
+export function updateLastInteractionTime(immediate?: boolean): void {
+ if (immediate) {
+ flushInteractionTime_inner()
+ } else {
+ interactionTimeDirty = true
+ }
+}
+
+/**
+ * If an interaction was recorded since the last flush, update the timestamp
+ * now. Called by Ink before each render cycle so we batch many keypresses into
+ * a single Date.now() call.
+ */
+export function flushInteractionTime(): void {
+ if (interactionTimeDirty) {
+ flushInteractionTime_inner()
+ }
+}
+
+function flushInteractionTime_inner(): void {
+ STATE.lastInteractionTime = Date.now()
+ interactionTimeDirty = false
+}
+
+export function addToTotalLinesChanged(added: number, removed: number): void {
+ STATE.totalLinesAdded += added
+ STATE.totalLinesRemoved += removed
+}
+
+export function getTotalLinesAdded(): number {
+ return STATE.totalLinesAdded
+}
+
+export function getTotalLinesRemoved(): number {
+ return STATE.totalLinesRemoved
+}
+
+export function getTotalInputTokens(): number {
+ return sumBy(Object.values(STATE.modelUsage), 'inputTokens')
+}
+
+export function getTotalOutputTokens(): number {
+ return sumBy(Object.values(STATE.modelUsage), 'outputTokens')
+}
+
+export function getTotalCacheReadInputTokens(): number {
+ return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens')
+}
+
+export function getTotalCacheCreationInputTokens(): number {
+ return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens')
+}
+
+export function getTotalWebSearchRequests(): number {
+ return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests')
+}
+
+let outputTokensAtTurnStart = 0
+let currentTurnTokenBudget: number | null = null
+export function getTurnOutputTokens(): number {
+ return getTotalOutputTokens() - outputTokensAtTurnStart
+}
+export function getCurrentTurnTokenBudget(): number | null {
+ return currentTurnTokenBudget
+}
+let budgetContinuationCount = 0
+export function snapshotOutputTokensForTurn(budget: number | null): void {
+ outputTokensAtTurnStart = getTotalOutputTokens()
+ currentTurnTokenBudget = budget
+ budgetContinuationCount = 0
+}
+export function getBudgetContinuationCount(): number {
+ return budgetContinuationCount
+}
+export function incrementBudgetContinuationCount(): void {
+ budgetContinuationCount++
+}
+
+export function setHasUnknownModelCost(): void {
+ STATE.hasUnknownModelCost = true
+}
+
+export function hasUnknownModelCost(): boolean {
+ return STATE.hasUnknownModelCost
+}
+
+export function getLastMainRequestId(): string | undefined {
+ return STATE.lastMainRequestId
+}
+
+export function setLastMainRequestId(requestId: string): void {
+ STATE.lastMainRequestId = requestId
+}
+
+export function getLastApiCompletionTimestamp(): number | null {
+ return STATE.lastApiCompletionTimestamp
+}
+
+export function setLastApiCompletionTimestamp(timestamp: number): void {
+ STATE.lastApiCompletionTimestamp = timestamp
+}
+
+/** Mark that a compaction just occurred. The next API success event will
+ * include isPostCompaction=true, then the flag auto-resets. */
+export function markPostCompaction(): void {
+ STATE.pendingPostCompaction = true
+}
+
+/** Consume the post-compaction flag. Returns true once after compaction,
+ * then returns false until the next compaction. */
+export function consumePostCompaction(): boolean {
+ const was = STATE.pendingPostCompaction
+ STATE.pendingPostCompaction = false
+ return was
+}
+
+export function getLastInteractionTime(): number {
+ return STATE.lastInteractionTime
+}
+
+// Scroll drain suspension — background intervals check this before doing work
+// so they don't compete with scroll frames for the event loop. Set by
+// ScrollBox scrollBy/scrollTo, cleared SCROLL_DRAIN_IDLE_MS after the last
+// scroll event. Module-scope (not in STATE) — ephemeral hot-path flag, no
+// test-reset needed since the debounce timer self-clears.
+let scrollDraining = false
+let scrollDrainTimer: ReturnType | undefined
+const SCROLL_DRAIN_IDLE_MS = 150
+
+/** Mark that a scroll event just happened. Background intervals gate on
+ * getIsScrollDraining() and skip their work until the debounce clears. */
+export function markScrollActivity(): void {
+ scrollDraining = true
+ if (scrollDrainTimer) clearTimeout(scrollDrainTimer)
+ scrollDrainTimer = setTimeout(() => {
+ scrollDraining = false
+ scrollDrainTimer = undefined
+ }, SCROLL_DRAIN_IDLE_MS)
+ scrollDrainTimer.unref?.()
+}
+
+/** True while scroll is actively draining (within 150ms of last event).
+ * Intervals should early-return when this is set — the work picks up next
+ * tick after scroll settles. */
+export function getIsScrollDraining(): boolean {
+ return scrollDraining
+}
+
+/** Await this before expensive one-shot work (network, subprocess) that could
+ * coincide with scroll. Resolves immediately if not scrolling; otherwise
+ * polls at the idle interval until the flag clears. */
+export async function waitForScrollIdle(): Promise {
+ while (scrollDraining) {
+ // bootstrap-isolation forbids importing sleep() from src/utils/
+ // eslint-disable-next-line no-restricted-syntax
+ await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.())
+ }
+}
+
+export function getModelUsage(): { [modelName: string]: ModelUsage } {
+ return STATE.modelUsage
+}
+
+export function getUsageForModel(model: string): ModelUsage | undefined {
+ return STATE.modelUsage[model]
+}
+
+/**
+ * Gets the model override set from the --model CLI flag or after the user
+ * updates their configured model.
+ */
+export function getMainLoopModelOverride(): ModelSetting | undefined {
+ return STATE.mainLoopModelOverride
+}
+
+export function getInitialMainLoopModel(): ModelSetting {
+ return STATE.initialMainLoopModel
+}
+
+export function setMainLoopModelOverride(
+ model: ModelSetting | undefined,
+): void {
+ STATE.mainLoopModelOverride = model
+}
+
+export function setInitialMainLoopModel(model: ModelSetting): void {
+ STATE.initialMainLoopModel = model
+}
+
+export function getSdkBetas(): string[] | undefined {
+ return STATE.sdkBetas
+}
+
+export function setSdkBetas(betas: string[] | undefined): void {
+ STATE.sdkBetas = betas
+}
+
+export function resetCostState(): void {
+ STATE.totalCostUSD = 0
+ STATE.totalAPIDuration = 0
+ STATE.totalAPIDurationWithoutRetries = 0
+ STATE.totalToolDuration = 0
+ STATE.startTime = Date.now()
+ STATE.totalLinesAdded = 0
+ STATE.totalLinesRemoved = 0
+ STATE.hasUnknownModelCost = false
+ STATE.modelUsage = {}
+ STATE.promptId = null
+}
+
+/**
+ * Sets cost state values for session restore.
+ * Called by restoreCostStateForSession in cost-tracker.ts.
+ */
+export function setCostStateForRestore({
+ totalCostUSD,
+ totalAPIDuration,
+ totalAPIDurationWithoutRetries,
+ totalToolDuration,
+ totalLinesAdded,
+ totalLinesRemoved,
+ lastDuration,
+ modelUsage,
+}: {
+ totalCostUSD: number
+ totalAPIDuration: number
+ totalAPIDurationWithoutRetries: number
+ totalToolDuration: number
+ totalLinesAdded: number
+ totalLinesRemoved: number
+ lastDuration: number | undefined
+ modelUsage: { [modelName: string]: ModelUsage } | undefined
+}): void {
+ STATE.totalCostUSD = totalCostUSD
+ STATE.totalAPIDuration = totalAPIDuration
+ STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries
+ STATE.totalToolDuration = totalToolDuration
+ STATE.totalLinesAdded = totalLinesAdded
+ STATE.totalLinesRemoved = totalLinesRemoved
+
+ // Restore per-model usage breakdown
+ if (modelUsage) {
+ STATE.modelUsage = modelUsage
+ }
+
+ // Adjust startTime to make wall duration accumulate
+ if (lastDuration) {
+ STATE.startTime = Date.now() - lastDuration
+ }
+}
+
+// Only used in tests
+export function resetStateForTests(): void {
+ if (process.env.NODE_ENV !== 'test') {
+ throw new Error('resetStateForTests can only be called in tests')
+ }
+ Object.entries(getInitialState()).forEach(([key, value]) => {
+ STATE[key as keyof State] = value as never
+ })
+ outputTokensAtTurnStart = 0
+ currentTurnTokenBudget = null
+ budgetContinuationCount = 0
+ sessionSwitched.clear()
+}
+
+// You shouldn't use this directly. See src/utils/model/modelStrings.ts::getModelStrings()
+export function getModelStrings(): ModelStrings | null {
+ return STATE.modelStrings
+}
+
+// You shouldn't use this directly. See src/utils/model/modelStrings.ts
+export function setModelStrings(modelStrings: ModelStrings): void {
+ STATE.modelStrings = modelStrings
+}
+
+// Test utility function to reset model strings for re-initialization.
+// Separate from setModelStrings because we only want to accept 'null' in tests.
+export function resetModelStringsForTestingOnly() {
+ STATE.modelStrings = null
+}
+
+export function setMeter(
+ meter: Meter,
+ createCounter: (name: string, options: MetricOptions) => AttributedCounter,
+): void {
+ STATE.meter = meter
+
+ // Initialize all counters using the provided factory
+ STATE.sessionCounter = createCounter('claude_code.session.count', {
+ description: 'Count of CLI sessions started',
+ })
+ STATE.locCounter = createCounter('claude_code.lines_of_code.count', {
+ description:
+ "Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed",
+ })
+ STATE.prCounter = createCounter('claude_code.pull_request.count', {
+ description: 'Number of pull requests created',
+ })
+ STATE.commitCounter = createCounter('claude_code.commit.count', {
+ description: 'Number of git commits created',
+ })
+ STATE.costCounter = createCounter('claude_code.cost.usage', {
+ description: 'Cost of the Claude Code session',
+ unit: 'USD',
+ })
+ STATE.tokenCounter = createCounter('claude_code.token.usage', {
+ description: 'Number of tokens used',
+ unit: 'tokens',
+ })
+ STATE.codeEditToolDecisionCounter = createCounter(
+ 'claude_code.code_edit_tool.decision',
+ {
+ description:
+ 'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools',
+ },
+ )
+ STATE.activeTimeCounter = createCounter('claude_code.active_time.total', {
+ description: 'Total active time in seconds',
+ unit: 's',
+ })
+}
+
+export function getMeter(): Meter | null {
+ return STATE.meter
+}
+
+export function getSessionCounter(): AttributedCounter | null {
+ return STATE.sessionCounter
+}
+
+export function getLocCounter(): AttributedCounter | null {
+ return STATE.locCounter
+}
+
+export function getPrCounter(): AttributedCounter | null {
+ return STATE.prCounter
+}
+
+export function getCommitCounter(): AttributedCounter | null {
+ return STATE.commitCounter
+}
+
+export function getCostCounter(): AttributedCounter | null {
+ return STATE.costCounter
+}
+
+export function getTokenCounter(): AttributedCounter | null {
+ return STATE.tokenCounter
+}
+
+export function getCodeEditToolDecisionCounter(): AttributedCounter | null {
+ return STATE.codeEditToolDecisionCounter
+}
+
+export function getActiveTimeCounter(): AttributedCounter | null {
+ return STATE.activeTimeCounter
+}
+
+export function getLoggerProvider(): LoggerProvider | null {
+ return STATE.loggerProvider
+}
+
+export function setLoggerProvider(provider: LoggerProvider | null): void {
+ STATE.loggerProvider = provider
+}
+
+export function getEventLogger(): ReturnType | null {
+ return STATE.eventLogger
+}
+
+export function setEventLogger(
+ logger: ReturnType | null,
+): void {
+ STATE.eventLogger = logger
+}
+
+export function getMeterProvider(): MeterProvider | null {
+ return STATE.meterProvider
+}
+
+export function setMeterProvider(provider: MeterProvider | null): void {
+ STATE.meterProvider = provider
+}
+export function getTracerProvider(): BasicTracerProvider | null {
+ return STATE.tracerProvider
+}
+export function setTracerProvider(provider: BasicTracerProvider | null): void {
+ STATE.tracerProvider = provider
+}
+
+export function getIsNonInteractiveSession(): boolean {
+ return !STATE.isInteractive
+}
+
+export function getIsInteractive(): boolean {
+ return STATE.isInteractive
+}
+
+export function setIsInteractive(value: boolean): void {
+ STATE.isInteractive = value
+}
+
+export function getClientType(): string {
+ return STATE.clientType
+}
+
+export function setClientType(type: string): void {
+ STATE.clientType = type
+}
+
+export function getSdkAgentProgressSummariesEnabled(): boolean {
+ return STATE.sdkAgentProgressSummariesEnabled
+}
+
+export function setSdkAgentProgressSummariesEnabled(value: boolean): void {
+ STATE.sdkAgentProgressSummariesEnabled = value
+}
+
+export function getKairosActive(): boolean {
+ return STATE.kairosActive
+}
+
+export function setKairosActive(value: boolean): void {
+ STATE.kairosActive = value
+}
+
+export function getStrictToolResultPairing(): boolean {
+ return STATE.strictToolResultPairing
+}
+
+export function setStrictToolResultPairing(value: boolean): void {
+ STATE.strictToolResultPairing = value
+}
+
+// Field name 'userMsgOptIn' avoids excluded-string substrings ('BriefTool',
+// 'SendUserMessage' — case-insensitive). All callers are inside feature()
+// guards so these accessors don't need their own (matches getKairosActive).
+export function getUserMsgOptIn(): boolean {
+ return STATE.userMsgOptIn
+}
+
+export function setUserMsgOptIn(value: boolean): void {
+ STATE.userMsgOptIn = value
+}
+
+export function getSessionSource(): string | undefined {
+ return STATE.sessionSource
+}
+
+export function setSessionSource(source: string): void {
+ STATE.sessionSource = source
+}
+
+export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined {
+ return STATE.questionPreviewFormat
+}
+
+export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void {
+ STATE.questionPreviewFormat = format
+}
+
+export function getAgentColorMap(): Map {
+ return STATE.agentColorMap
+}
+
+export function getFlagSettingsPath(): string | undefined {
+ return STATE.flagSettingsPath
+}
+
+export function setFlagSettingsPath(path: string | undefined): void {
+ STATE.flagSettingsPath = path
+}
+
+export function getFlagSettingsInline(): Record | null {
+ return STATE.flagSettingsInline
+}
+
+export function setFlagSettingsInline(
+ settings: Record | null,
+): void {
+ STATE.flagSettingsInline = settings
+}
+
+export function getSessionIngressToken(): string | null | undefined {
+ return STATE.sessionIngressToken
+}
+
+export function setSessionIngressToken(token: string | null): void {
+ STATE.sessionIngressToken = token
+}
+
+export function getOauthTokenFromFd(): string | null | undefined {
+ return STATE.oauthTokenFromFd
+}
+
+export function setOauthTokenFromFd(token: string | null): void {
+ STATE.oauthTokenFromFd = token
+}
+
+export function getApiKeyFromFd(): string | null | undefined {
+ return STATE.apiKeyFromFd
+}
+
+export function setApiKeyFromFd(key: string | null): void {
+ STATE.apiKeyFromFd = key
+}
+
+export function setLastAPIRequest(
+ params: Omit | null,
+): void {
+ STATE.lastAPIRequest = params
+}
+
+export function getLastAPIRequest(): Omit<
+ BetaMessageStreamParams,
+ 'messages'
+> | null {
+ return STATE.lastAPIRequest
+}
+
+export function setLastAPIRequestMessages(
+ messages: BetaMessageStreamParams['messages'] | null,
+): void {
+ STATE.lastAPIRequestMessages = messages
+}
+
+export function getLastAPIRequestMessages():
+ | BetaMessageStreamParams['messages']
+ | null {
+ return STATE.lastAPIRequestMessages
+}
+
+export function setLastClassifierRequests(requests: unknown[] | null): void {
+ STATE.lastClassifierRequests = requests
+}
+
+export function getLastClassifierRequests(): unknown[] | null {
+ return STATE.lastClassifierRequests
+}
+
+export function setCachedClaudeMdContent(content: string | null): void {
+ STATE.cachedClaudeMdContent = content
+}
+
+export function getCachedClaudeMdContent(): string | null {
+ return STATE.cachedClaudeMdContent
+}
+
+export function addToInMemoryErrorLog(errorInfo: {
+ error: string
+ timestamp: string
+}): void {
+ const MAX_IN_MEMORY_ERRORS = 100
+ if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) {
+ STATE.inMemoryErrorLog.shift() // Remove oldest error
+ }
+ STATE.inMemoryErrorLog.push(errorInfo)
+}
+
+export function getAllowedSettingSources(): SettingSource[] {
+ return STATE.allowedSettingSources
+}
+
+export function setAllowedSettingSources(sources: SettingSource[]): void {
+ STATE.allowedSettingSources = sources
+}
+
+export function preferThirdPartyAuthentication(): boolean {
+ // IDE extension should behave as 1P for authentication reasons.
+ return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode'
+}
+
+export function setInlinePlugins(plugins: Array): void {
+ STATE.inlinePlugins = plugins
+}
+
+export function getInlinePlugins(): Array {
+ return STATE.inlinePlugins
+}
+
+export function setChromeFlagOverride(value: boolean | undefined): void {
+ STATE.chromeFlagOverride = value
+}
+
+export function getChromeFlagOverride(): boolean | undefined {
+ return STATE.chromeFlagOverride
+}
+
+export function setUseCoworkPlugins(value: boolean): void {
+ STATE.useCoworkPlugins = value
+ resetSettingsCache()
+}
+
+export function getUseCoworkPlugins(): boolean {
+ return STATE.useCoworkPlugins
+}
+
+export function setSessionBypassPermissionsMode(enabled: boolean): void {
+ STATE.sessionBypassPermissionsMode = enabled
+}
+
+export function getSessionBypassPermissionsMode(): boolean {
+ return STATE.sessionBypassPermissionsMode
+}
+
+export function setScheduledTasksEnabled(enabled: boolean): void {
+ STATE.scheduledTasksEnabled = enabled
+}
+
+export function getScheduledTasksEnabled(): boolean {
+ return STATE.scheduledTasksEnabled
+}
+
+export type SessionCronTask = {
+ id: string
+ cron: string
+ prompt: string
+ createdAt: number
+ recurring?: boolean
+ /**
+ * When set, the task was created by an in-process teammate (not the team lead).
+ * The scheduler routes fires to that teammate's pendingUserMessages queue
+ * instead of the main REPL command queue. Session-only — never written to disk.
+ */
+ agentId?: string
+}
+
+export function getSessionCronTasks(): SessionCronTask[] {
+ return STATE.sessionCronTasks
+}
+
+export function addSessionCronTask(task: SessionCronTask): void {
+ STATE.sessionCronTasks.push(task)
+}
+
+/**
+ * Returns the number of tasks actually removed. Callers use this to skip
+ * downstream work (e.g. the disk read in removeCronTasks) when all ids
+ * were accounted for here.
+ */
+export function removeSessionCronTasks(ids: readonly string[]): number {
+ if (ids.length === 0) return 0
+ const idSet = new Set(ids)
+ const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id))
+ const removed = STATE.sessionCronTasks.length - remaining.length
+ if (removed === 0) return 0
+ STATE.sessionCronTasks = remaining
+ return removed
+}
+
+export function setSessionTrustAccepted(accepted: boolean): void {
+ STATE.sessionTrustAccepted = accepted
+}
+
+export function getSessionTrustAccepted(): boolean {
+ return STATE.sessionTrustAccepted
+}
+
+export function setSessionPersistenceDisabled(disabled: boolean): void {
+ STATE.sessionPersistenceDisabled = disabled
+}
+
+export function isSessionPersistenceDisabled(): boolean {
+ return STATE.sessionPersistenceDisabled
+}
+
+export function hasExitedPlanModeInSession(): boolean {
+ return STATE.hasExitedPlanMode
+}
+
+export function setHasExitedPlanMode(value: boolean): void {
+ STATE.hasExitedPlanMode = value
+}
+
+export function needsPlanModeExitAttachment(): boolean {
+ return STATE.needsPlanModeExitAttachment
+}
+
+export function setNeedsPlanModeExitAttachment(value: boolean): void {
+ STATE.needsPlanModeExitAttachment = value
+}
+
+export function handlePlanModeTransition(
+ fromMode: string,
+ toMode: string,
+): void {
+ // If switching TO plan mode, clear any pending exit attachment
+ // This prevents sending both plan_mode and plan_mode_exit when user toggles quickly
+ if (toMode === 'plan' && fromMode !== 'plan') {
+ STATE.needsPlanModeExitAttachment = false
+ }
+
+ // If switching out of plan mode, trigger the plan_mode_exit attachment
+ if (fromMode === 'plan' && toMode !== 'plan') {
+ STATE.needsPlanModeExitAttachment = true
+ }
+}
+
+export function needsAutoModeExitAttachment(): boolean {
+ return STATE.needsAutoModeExitAttachment
+}
+
+export function setNeedsAutoModeExitAttachment(value: boolean): void {
+ STATE.needsAutoModeExitAttachment = value
+}
+
+export function handleAutoModeTransition(
+ fromMode: string,
+ toMode: string,
+): void {
+ // Auto↔plan transitions are handled by prepareContextForPlanMode (auto may
+ // stay active through plan if opted in) and ExitPlanMode (restores mode).
+ // Skip both directions so this function only handles direct auto transitions.
+ if (
+ (fromMode === 'auto' && toMode === 'plan') ||
+ (fromMode === 'plan' && toMode === 'auto')
+ ) {
+ return
+ }
+ const fromIsAuto = fromMode === 'auto'
+ const toIsAuto = toMode === 'auto'
+
+ // If switching TO auto mode, clear any pending exit attachment
+ // This prevents sending both auto_mode and auto_mode_exit when user toggles quickly
+ if (toIsAuto && !fromIsAuto) {
+ STATE.needsAutoModeExitAttachment = false
+ }
+
+ // If switching out of auto mode, trigger the auto_mode_exit attachment
+ if (fromIsAuto && !toIsAuto) {
+ STATE.needsAutoModeExitAttachment = true
+ }
+}
+
+// LSP plugin recommendation session tracking
+export function hasShownLspRecommendationThisSession(): boolean {
+ return STATE.lspRecommendationShownThisSession
+}
+
+export function setLspRecommendationShownThisSession(value: boolean): void {
+ STATE.lspRecommendationShownThisSession = value
+}
+
+// SDK init event state
+export function setInitJsonSchema(schema: Record): void {
+ STATE.initJsonSchema = schema
+}
+
+export function getInitJsonSchema(): Record | null {
+ return STATE.initJsonSchema
+}
+
+export function registerHookCallbacks(
+ hooks: Partial>,
+): void {
+ if (!STATE.registeredHooks) {
+ STATE.registeredHooks = {}
+ }
+
+ // `registerHookCallbacks` may be called multiple times, so we need to merge (not overwrite)
+ for (const [event, matchers] of Object.entries(hooks)) {
+ const eventKey = event as HookEvent
+ if (!STATE.registeredHooks[eventKey]) {
+ STATE.registeredHooks[eventKey] = []
+ }
+ STATE.registeredHooks[eventKey]!.push(...matchers)
+ }
+}
+
+export function getRegisteredHooks(): Partial<
+ Record
+> | null {
+ return STATE.registeredHooks
+}
+
+export function clearRegisteredHooks(): void {
+ STATE.registeredHooks = null
+}
+
+export function clearRegisteredPluginHooks(): void {
+ if (!STATE.registeredHooks) {
+ return
+ }
+
+ const filtered: Partial> = {}
+ for (const [event, matchers] of Object.entries(STATE.registeredHooks)) {
+ // Keep only callback hooks (those without pluginRoot)
+ const callbackHooks = matchers.filter(m => !('pluginRoot' in m))
+ if (callbackHooks.length > 0) {
+ filtered[event as HookEvent] = callbackHooks
+ }
+ }
+
+ STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null
+}
+
+export function resetSdkInitState(): void {
+ STATE.initJsonSchema = null
+ STATE.registeredHooks = null
+}
+
+export function getPlanSlugCache(): Map {
+ return STATE.planSlugCache
+}
+
+export function getSessionCreatedTeams(): Set {
+ return STATE.sessionCreatedTeams
+}
+
+// Teleported session tracking for reliability logging
+export function setTeleportedSessionInfo(info: {
+ sessionId: string | null
+}): void {
+ STATE.teleportedSessionInfo = {
+ isTeleported: true,
+ hasLoggedFirstMessage: false,
+ sessionId: info.sessionId,
+ }
+}
+
+export function getTeleportedSessionInfo(): {
+ isTeleported: boolean
+ hasLoggedFirstMessage: boolean
+ sessionId: string | null
+} | null {
+ return STATE.teleportedSessionInfo
+}
+
+export function markFirstTeleportMessageLogged(): void {
+ if (STATE.teleportedSessionInfo) {
+ STATE.teleportedSessionInfo.hasLoggedFirstMessage = true
+ }
+}
+
+// Invoked skills tracking for preservation across compaction
+export type InvokedSkillInfo = {
+ skillName: string
+ skillPath: string
+ content: string
+ invokedAt: number
+ agentId: string | null
+}
+
+export function addInvokedSkill(
+ skillName: string,
+ skillPath: string,
+ content: string,
+ agentId: string | null = null,
+): void {
+ const key = `${agentId ?? ''}:${skillName}`
+ STATE.invokedSkills.set(key, {
+ skillName,
+ skillPath,
+ content,
+ invokedAt: Date.now(),
+ agentId,
+ })
+}
+
+export function getInvokedSkills(): Map {
+ return STATE.invokedSkills
+}
+
+export function getInvokedSkillsForAgent(
+ agentId: string | undefined | null,
+): Map {
+ const normalizedId = agentId ?? null
+ const filtered = new Map()
+ for (const [key, skill] of STATE.invokedSkills) {
+ if (skill.agentId === normalizedId) {
+ filtered.set(key, skill)
+ }
+ }
+ return filtered
+}
+
+export function clearInvokedSkills(
+ preservedAgentIds?: ReadonlySet,
+): void {
+ if (!preservedAgentIds || preservedAgentIds.size === 0) {
+ STATE.invokedSkills.clear()
+ return
+ }
+ for (const [key, skill] of STATE.invokedSkills) {
+ if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) {
+ STATE.invokedSkills.delete(key)
+ }
+ }
+}
+
+export function clearInvokedSkillsForAgent(agentId: string): void {
+ for (const [key, skill] of STATE.invokedSkills) {
+ if (skill.agentId === agentId) {
+ STATE.invokedSkills.delete(key)
+ }
+ }
+}
+
+// Slow operations tracking for dev bar
+const MAX_SLOW_OPERATIONS = 10
+const SLOW_OPERATION_TTL_MS = 10000
+
+export function addSlowOperation(operation: string, durationMs: number): void {
+ if (process.env.USER_TYPE !== 'ant') return
+ // Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
+ // These are intentionally slow since the user is drafting text
+ if (operation.includes('exec') && operation.includes('claude-prompt-')) {
+ return
+ }
+ const now = Date.now()
+ // Remove stale operations
+ STATE.slowOperations = STATE.slowOperations.filter(
+ op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
+ )
+ // Add new operation
+ STATE.slowOperations.push({ operation, durationMs, timestamp: now })
+ // Keep only the most recent operations
+ if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
+ STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
+ }
+}
+
+const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
+ operation: string
+ durationMs: number
+ timestamp: number
+}> = []
+
+export function getSlowOperations(): ReadonlyArray<{
+ operation: string
+ durationMs: number
+ timestamp: number
+}> {
+ // Most common case: nothing tracked. Return a stable reference so the
+ // caller's setState() can bail via Object.is instead of re-rendering at 2fps.
+ if (STATE.slowOperations.length === 0) {
+ return EMPTY_SLOW_OPERATIONS
+ }
+ const now = Date.now()
+ // Only allocate a new array when something actually expired; otherwise keep
+ // the reference stable across polls while ops are still fresh.
+ if (
+ STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
+ ) {
+ STATE.slowOperations = STATE.slowOperations.filter(
+ op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
+ )
+ if (STATE.slowOperations.length === 0) {
+ return EMPTY_SLOW_OPERATIONS
+ }
+ }
+ // Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
+ // before pushing, so the array held in React state is never mutated.
+ return STATE.slowOperations
+}
+
+export function getMainThreadAgentType(): string | undefined {
+ return STATE.mainThreadAgentType
+}
+
+export function setMainThreadAgentType(agentType: string | undefined): void {
+ STATE.mainThreadAgentType = agentType
+}
+
+export function getIsRemoteMode(): boolean {
+ return STATE.isRemoteMode
+}
+
+export function setIsRemoteMode(value: boolean): void {
+ STATE.isRemoteMode = value
+}
+
+// System prompt section accessors
+
+export function getSystemPromptSectionCache(): Map {
+ return STATE.systemPromptSectionCache
+}
+
+export function setSystemPromptSectionCacheEntry(
+ name: string,
+ value: string | null,
+): void {
+ STATE.systemPromptSectionCache.set(name, value)
+}
+
+export function clearSystemPromptSectionState(): void {
+ STATE.systemPromptSectionCache.clear()
+}
+
+// Last emitted date accessors (for detecting midnight date changes)
+
+export function getLastEmittedDate(): string | null {
+ return STATE.lastEmittedDate
+}
+
+export function setLastEmittedDate(date: string | null): void {
+ STATE.lastEmittedDate = date
+}
+
+export function getAdditionalDirectoriesForClaudeMd(): string[] {
+ return STATE.additionalDirectoriesForClaudeMd
+}
+
+export function setAdditionalDirectoriesForClaudeMd(
+ directories: string[],
+): void {
+ STATE.additionalDirectoriesForClaudeMd = directories
+}
+
+export function getAllowedChannels(): ChannelEntry[] {
+ return STATE.allowedChannels
+}
+
+export function setAllowedChannels(entries: ChannelEntry[]): void {
+ STATE.allowedChannels = entries
+}
+
+export function getHasDevChannels(): boolean {
+ return STATE.hasDevChannels
+}
+
+export function setHasDevChannels(value: boolean): void {
+ STATE.hasDevChannels = value
+}
+
+export function getPromptCache1hAllowlist(): string[] | null {
+ return STATE.promptCache1hAllowlist
+}
+
+export function setPromptCache1hAllowlist(allowlist: string[] | null): void {
+ STATE.promptCache1hAllowlist = allowlist
+}
+
+export function getPromptCache1hEligible(): boolean | null {
+ return STATE.promptCache1hEligible
+}
+
+export function setPromptCache1hEligible(eligible: boolean | null): void {
+ STATE.promptCache1hEligible = eligible
+}
+
+export function getAfkModeHeaderLatched(): boolean | null {
+ return STATE.afkModeHeaderLatched
+}
+
+export function setAfkModeHeaderLatched(v: boolean): void {
+ STATE.afkModeHeaderLatched = v
+}
+
+export function getFastModeHeaderLatched(): boolean | null {
+ return STATE.fastModeHeaderLatched
+}
+
+export function setFastModeHeaderLatched(v: boolean): void {
+ STATE.fastModeHeaderLatched = v
+}
+
+export function getCacheEditingHeaderLatched(): boolean | null {
+ return STATE.cacheEditingHeaderLatched
+}
+
+export function setCacheEditingHeaderLatched(v: boolean): void {
+ STATE.cacheEditingHeaderLatched = v
+}
+
+export function getThinkingClearLatched(): boolean | null {
+ return STATE.thinkingClearLatched
+}
+
+export function setThinkingClearLatched(v: boolean): void {
+ STATE.thinkingClearLatched = v
+}
+
+/**
+ * Reset beta header latches to null. Called on /clear and /compact so a
+ * fresh conversation gets fresh header evaluation.
+ */
+export function clearBetaHeaderLatches(): void {
+ STATE.afkModeHeaderLatched = null
+ STATE.fastModeHeaderLatched = null
+ STATE.cacheEditingHeaderLatched = null
+ STATE.thinkingClearLatched = null
+}
+
+export function getPromptId(): string | null {
+ return STATE.promptId
+}
+
+export function setPromptId(id: string | null): void {
+ STATE.promptId = id
+}
+
+
+
diff --git a/src/bridge/bridgeApi.ts b/src/bridge/bridgeApi.ts
new file mode 100644
index 0000000..1d6cc4c
--- /dev/null
+++ b/src/bridge/bridgeApi.ts
@@ -0,0 +1,541 @@
+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
+ /**
+ * 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 {
+ const headers: Record = {
+ 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(
+ 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 {
+ 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(
+ `${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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+}
+
+
diff --git a/src/bridge/bridgeConfig.ts b/src/bridge/bridgeConfig.ts
new file mode 100644
index 0000000..ed68b74
--- /dev/null
+++ b/src/bridge/bridgeConfig.ts
@@ -0,0 +1,50 @@
+/**
+ * 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
+}
+
+
diff --git a/src/bridge/bridgeDebug.ts b/src/bridge/bridgeDebug.ts
new file mode 100644
index 0000000..28a493e
--- /dev/null
+++ b/src/bridge/bridgeDebug.ts
@@ -0,0 +1,137 @@
+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 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)
+ },
+ }
+}
+
+
diff --git a/src/bridge/bridgeEnabled.ts b/src/bridge/bridgeEnabled.ts
new file mode 100644
index 0000000..235a00c
--- /dev/null
+++ b/src/bridge/bridgeEnabled.ts
@@ -0,0 +1,204 @@
+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 {
+ 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 {
+ 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
+}
+
+
diff --git a/src/bridge/bridgeMain.ts b/src/bridge/bridgeMain.ts
new file mode 100644
index 0000000..6e3ec15
--- /dev/null
+++ b/src/bridge/bridgeMain.ts
@@ -0,0 +1,3001 @@
+import { feature } from 'bun:bundle'
+import { randomUUID } from 'crypto'
+import { hostname, tmpdir } from 'os'
+import { basename, join, resolve } from 'path'
+import { getRemoteSessionUrl } from '../constants/product.js'
+import { shutdownDatadog } from '../services/analytics/datadog.js'
+import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js'
+import { checkGate_CACHED_OR_BLOCKING } from '../services/analytics/growthbook.js'
+import {
+ type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ logEvent,
+ logEventAsync,
+} from '../services/analytics/index.js'
+import { isInBundledMode } from '../utils/bundledMode.js'
+import { logForDebugging } from '../utils/debug.js'
+import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
+import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
+import { errorMessage } from '../utils/errors.js'
+import { truncateToWidth } from '../utils/format.js'
+import { logError } from '../utils/log.js'
+import { sleep } from '../utils/sleep.js'
+import { createAgentWorktree, removeAgentWorktree } from '../utils/worktree.js'
+import {
+ BridgeFatalError,
+ createBridgeApiClient,
+ isExpiredErrorType,
+ isSuppressible403,
+ validateBridgeId,
+} from './bridgeApi.js'
+import { formatDuration } from './bridgeStatusUtil.js'
+import { createBridgeLogger } from './bridgeUI.js'
+import { createCapacityWake } from './capacityWake.js'
+import { describeAxiosError } from './debugUtils.js'
+import { createTokenRefreshScheduler } from './jwtUtils.js'
+import { getPollIntervalConfig } from './pollConfig.js'
+import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
+import { createSessionSpawner, safeFilenameId } from './sessionRunner.js'
+import { getTrustedDeviceToken } from './trustedDevice.js'
+import {
+ BRIDGE_LOGIN_ERROR,
+ type BridgeApiClient,
+ type BridgeConfig,
+ type BridgeLogger,
+ DEFAULT_SESSION_TIMEOUT_MS,
+ type SessionDoneStatus,
+ type SessionHandle,
+ type SessionSpawner,
+ type SessionSpawnOpts,
+ type SpawnMode,
+} from './types.js'
+import {
+ buildCCRv2SdkUrl,
+ buildSdkUrl,
+ decodeWorkSecret,
+ registerWorker,
+ sameSessionId,
+} from './workSecret.js'
+
+export type BackoffConfig = {
+ connInitialMs: number
+ connCapMs: number
+ connGiveUpMs: number
+ generalInitialMs: number
+ generalCapMs: number
+ generalGiveUpMs: number
+ /** SIGTERM→SIGKILL grace period on shutdown. Default 30s. */
+ shutdownGraceMs?: number
+ /** stopWorkWithRetry base delay (1s/2s/4s backoff). Default 1000ms. */
+ stopWorkBaseDelayMs?: number
+}
+
+const DEFAULT_BACKOFF: BackoffConfig = {
+ connInitialMs: 2_000,
+ connCapMs: 120_000, // 2 minutes
+ connGiveUpMs: 600_000, // 10 minutes
+ generalInitialMs: 500,
+ generalCapMs: 30_000,
+ generalGiveUpMs: 600_000, // 10 minutes
+}
+
+/** Status update interval for the live display (ms). */
+const STATUS_UPDATE_INTERVAL_MS = 1_000
+const SPAWN_SESSIONS_DEFAULT = 32
+
+/**
+ * GrowthBook gate for multi-session spawn modes (--spawn / --capacity / --create-session-in-dir).
+ * Sibling of tengu_ccr_bridge_multi_environment (multiple envs per host:dir) —
+ * this one enables multiple sessions per environment.
+ * Rollout staged via targeting rules: ants first, then gradual external.
+ *
+ * Uses the blocking gate check so a stale disk-cache miss doesn't unfairly
+ * deny access. The fast path (cache has true) is still instant; only the
+ * cold-start path awaits the server fetch, and that fetch also seeds the
+ * disk cache for next time.
+ */
+async function isMultiSessionSpawnEnabled(): Promise {
+ return checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge_multi_session')
+}
+
+/**
+ * Returns the threshold for detecting system sleep/wake in the poll loop.
+ * Must exceed the max backoff cap — otherwise normal backoff delays trigger
+ * false sleep detection (resetting the error budget indefinitely). Using
+ * 2× the connection backoff cap, matching the pattern in WebSocketTransport
+ * and replBridge.
+ */
+function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
+ return backoff.connCapMs * 2
+}
+
+/**
+ * Returns the args that must precede CLI flags when spawning a child claude
+ * process. In compiled binaries, process.execPath is the claude binary itself
+ * and args go directly to it. In npm installs (node running cli.js),
+ * process.execPath is the node runtime — the child spawn must pass the script
+ * path as the first arg, otherwise node interprets --sdk-url as a node option
+ * and exits with "bad option: --sdk-url". See anthropics/claude-code#28334.
+ */
+function spawnScriptArgs(): string[] {
+ if (isInBundledMode() || !process.argv[1]) {
+ return []
+ }
+ return [process.argv[1]]
+}
+
+/** Attempt to spawn a session; returns error string if spawn throws. */
+function safeSpawn(
+ spawner: SessionSpawner,
+ opts: SessionSpawnOpts,
+ dir: string,
+): SessionHandle | string {
+ try {
+ return spawner.spawn(opts, dir)
+ } catch (err) {
+ const errMsg = errorMessage(err)
+ logError(new Error(`Session spawn failed: ${errMsg}`))
+ return errMsg
+ }
+}
+
+export async function runBridgeLoop(
+ config: BridgeConfig,
+ environmentId: string,
+ environmentSecret: string,
+ api: BridgeApiClient,
+ spawner: SessionSpawner,
+ logger: BridgeLogger,
+ signal: AbortSignal,
+ backoffConfig: BackoffConfig = DEFAULT_BACKOFF,
+ initialSessionId?: string,
+ getAccessToken?: () => string | undefined | Promise,
+): Promise {
+ // Local abort controller so that onSessionDone can stop the poll loop.
+ // Linked to the incoming signal so external aborts also work.
+ const controller = new AbortController()
+ if (signal.aborted) {
+ controller.abort()
+ } else {
+ signal.addEventListener('abort', () => controller.abort(), { once: true })
+ }
+ const loopSignal = controller.signal
+
+ const activeSessions = new Map()
+ const sessionStartTimes = new Map()
+ const sessionWorkIds = new Map()
+ // Compat-surface ID (session_*) computed once at spawn and cached so
+ // cleanup and status-update ticks use the same key regardless of whether
+ // the tengu_bridge_repl_v2_cse_shim_enabled gate flips mid-session.
+ const sessionCompatIds = new Map()
+ // Session ingress JWTs for heartbeat auth, keyed by sessionId.
+ // Stored separately from handle.accessToken because the token refresh
+ // scheduler overwrites that field with the OAuth token (~3h55m in).
+ const sessionIngressTokens = new Map()
+ const sessionTimers = new Map>()
+ const completedWorkIds = new Set()
+ const sessionWorktrees = new Map<
+ string,
+ {
+ worktreePath: string
+ worktreeBranch?: string
+ gitRoot?: string
+ hookBased?: boolean
+ }
+ >()
+ // Track sessions killed by the timeout watchdog so onSessionDone can
+ // distinguish them from server-initiated or shutdown interrupts.
+ const timedOutSessions = new Set()
+ // Sessions that already have a title (server-set or bridge-derived) so
+ // onFirstUserMessage doesn't clobber a user-assigned --name / web rename.
+ // Keyed by compatSessionId to match logger.setSessionTitle's key.
+ const titledSessions = new Set()
+ // Signal to wake the at-capacity sleep early when a session completes,
+ // so the bridge can immediately accept new work.
+ const capacityWake = createCapacityWake(loopSignal)
+
+ /**
+ * Heartbeat all active work items.
+ * Returns 'ok' if at least one heartbeat succeeded, 'auth_failed' if any
+ * got a 401/403 (JWT expired — re-queued via reconnectSession so the next
+ * poll delivers fresh work), or 'failed' if all failed for other reasons.
+ */
+ async function heartbeatActiveWorkItems(): Promise<
+ 'ok' | 'auth_failed' | 'fatal' | 'failed'
+ > {
+ let anySuccess = false
+ let anyFatal = false
+ const authFailedSessions: string[] = []
+ for (const [sessionId] of activeSessions) {
+ const workId = sessionWorkIds.get(sessionId)
+ const ingressToken = sessionIngressTokens.get(sessionId)
+ if (!workId || !ingressToken) {
+ continue
+ }
+ try {
+ await api.heartbeatWork(environmentId, workId, ingressToken)
+ anySuccess = true
+ } catch (err) {
+ logForDebugging(
+ `[bridge:heartbeat] Failed for sessionId=${sessionId} workId=${workId}: ${errorMessage(err)}`,
+ )
+ if (err instanceof BridgeFatalError) {
+ logEvent('tengu_bridge_heartbeat_error', {
+ status:
+ err.status as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ error_type: (err.status === 401 || err.status === 403
+ ? 'auth_failed'
+ : 'fatal') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ if (err.status === 401 || err.status === 403) {
+ authFailedSessions.push(sessionId)
+ } else {
+ // 404/410 = environment expired or deleted — no point retrying
+ anyFatal = true
+ }
+ }
+ }
+ }
+ // JWT expired → trigger server-side re-dispatch. Without this, work stays
+ // ACK'd out of the Redis PEL and poll returns empty forever (CC-1263).
+ // The existingHandle path below delivers the fresh token to the child.
+ // sessionId is already in the format /bridge/reconnect expects: it comes
+ // from work.data.id, which matches the server's EnvironmentInstance store
+ // (cse_* under the compat gate, session_* otherwise).
+ for (const sessionId of authFailedSessions) {
+ logger.logVerbose(
+ `Session ${sessionId} token expired — re-queuing via bridge/reconnect`,
+ )
+ try {
+ await api.reconnectSession(environmentId, sessionId)
+ logForDebugging(
+ `[bridge:heartbeat] Re-queued sessionId=${sessionId} via bridge/reconnect`,
+ )
+ } catch (err) {
+ logger.logError(
+ `Failed to refresh session ${sessionId} token: ${errorMessage(err)}`,
+ )
+ logForDebugging(
+ `[bridge:heartbeat] reconnectSession(${sessionId}) failed: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ }
+ }
+ if (anyFatal) {
+ return 'fatal'
+ }
+ if (authFailedSessions.length > 0) {
+ return 'auth_failed'
+ }
+ return anySuccess ? 'ok' : 'failed'
+ }
+
+ // Sessions spawned with CCR v2 env vars. v2 children cannot use OAuth
+ // tokens (CCR worker endpoints validate the JWT's session_id claim,
+ // register_worker.go:32), so onRefresh triggers server re-dispatch
+ // instead — the next poll delivers fresh work with a new JWT via the
+ // existingHandle path below.
+ const v2Sessions = new Set()
+
+ // Proactive token refresh: schedules a timer 5min before the session
+ // ingress JWT expires. v1 delivers OAuth directly; v2 calls
+ // reconnectSession to trigger server re-dispatch (CC-1263: without
+ // this, v2 daemon sessions silently die at ~5h since the server does
+ // not auto-re-dispatch ACK'd work on lease expiry).
+ const tokenRefresh = getAccessToken
+ ? createTokenRefreshScheduler({
+ getAccessToken,
+ onRefresh: (sessionId, oauthToken) => {
+ const handle = activeSessions.get(sessionId)
+ if (!handle) {
+ return
+ }
+ if (v2Sessions.has(sessionId)) {
+ logger.logVerbose(
+ `Refreshing session ${sessionId} token via bridge/reconnect`,
+ )
+ void api
+ .reconnectSession(environmentId, sessionId)
+ .catch((err: unknown) => {
+ logger.logError(
+ `Failed to refresh session ${sessionId} token: ${errorMessage(err)}`,
+ )
+ logForDebugging(
+ `[bridge:token] reconnectSession(${sessionId}) failed: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ })
+ } else {
+ handle.updateAccessToken(oauthToken)
+ }
+ },
+ label: 'bridge',
+ })
+ : null
+ const loopStartTime = Date.now()
+ // Track all in-flight cleanup promises (stopWork, worktree removal) so
+ // the shutdown sequence can await them before process.exit().
+ const pendingCleanups = new Set>()
+ function trackCleanup(p: Promise): void {
+ pendingCleanups.add(p)
+ void p.finally(() => pendingCleanups.delete(p))
+ }
+ let connBackoff = 0
+ let generalBackoff = 0
+ let connErrorStart: number | null = null
+ let generalErrorStart: number | null = null
+ let lastPollErrorTime: number | null = null
+ let statusUpdateTimer: ReturnType | null = null
+ // Set by BridgeFatalError and give-up paths so the shutdown block can
+ // skip the resume message (resume is impossible after env expiry/auth
+ // failure/sustained connection errors).
+ let fatalExit = false
+
+ logForDebugging(
+ `[bridge:work] Starting poll loop spawnMode=${config.spawnMode} maxSessions=${config.maxSessions} environmentId=${environmentId}`,
+ )
+ logForDiagnosticsNoPII('info', 'bridge_loop_started', {
+ max_sessions: config.maxSessions,
+ spawn_mode: config.spawnMode,
+ })
+
+ // For ant users, show where session debug logs will land so they can tail them.
+ // sessionRunner.ts uses the same base path. File appears once a session spawns.
+ if (process.env.USER_TYPE === 'ant') {
+ let debugGlob: string
+ if (config.debugFile) {
+ const ext = config.debugFile.lastIndexOf('.')
+ debugGlob =
+ ext > 0
+ ? `${config.debugFile.slice(0, ext)}-*${config.debugFile.slice(ext)}`
+ : `${config.debugFile}-*`
+ } else {
+ debugGlob = join(tmpdir(), 'claude', 'bridge-session-*.log')
+ }
+ logger.setDebugLogPath(debugGlob)
+ }
+
+ logger.printBanner(config, environmentId)
+
+ // Seed the logger's session count + spawn mode before any render. Without
+ // this, setAttached() below renders with the logger's default sessionMax=1,
+ // showing "Capacity: 0/1" until the status ticker kicks in (which is gated
+ // by !initialSessionId and only starts after the poll loop picks up work).
+ logger.updateSessionCount(0, config.maxSessions, config.spawnMode)
+
+ // If an initial session was pre-created, show its URL from the start so
+ // the user can click through immediately (matching /remote-control behavior).
+ if (initialSessionId) {
+ logger.setAttached(initialSessionId)
+ }
+
+ /** Refresh the inline status display. Shows idle or active depending on state. */
+ function updateStatusDisplay(): void {
+ // Push the session count (no-op when maxSessions === 1) so the
+ // next renderStatusLine tick shows the current count.
+ logger.updateSessionCount(
+ activeSessions.size,
+ config.maxSessions,
+ config.spawnMode,
+ )
+
+ // Push per-session activity into the multi-session display.
+ for (const [sid, handle] of activeSessions) {
+ const act = handle.currentActivity
+ if (act) {
+ logger.updateSessionActivity(sessionCompatIds.get(sid) ?? sid, act)
+ }
+ }
+
+ if (activeSessions.size === 0) {
+ logger.updateIdleStatus()
+ return
+ }
+
+ // Show the most recently started session that is still actively working.
+ // Sessions whose current activity is 'result' or 'error' are between
+ // turns — the CLI emitted its result but the process stays alive waiting
+ // for the next user message. Skip updating so the status line keeps
+ // whatever state it had (Attached / session title).
+ const [sessionId, handle] = [...activeSessions.entries()].pop()!
+ const startTime = sessionStartTimes.get(sessionId)
+ if (!startTime) return
+
+ const activity = handle.currentActivity
+ if (!activity || activity.type === 'result' || activity.type === 'error') {
+ // Session is between turns — keep current status (Attached/titled).
+ // In multi-session mode, still refresh so bullet-list activities stay current.
+ if (config.maxSessions > 1) logger.refreshDisplay()
+ return
+ }
+
+ const elapsed = formatDuration(Date.now() - startTime)
+
+ // Build trail from recent tool activities (last 5)
+ const trail = handle.activities
+ .filter(a => a.type === 'tool_start')
+ .slice(-5)
+ .map(a => a.summary)
+
+ logger.updateSessionStatus(sessionId, elapsed, activity, trail)
+ }
+
+ /** Start the status display update ticker. */
+ function startStatusUpdates(): void {
+ stopStatusUpdates()
+ // Call immediately so the first transition (e.g. Connecting → Ready)
+ // happens without delay, avoiding concurrent timer races.
+ updateStatusDisplay()
+ statusUpdateTimer = setInterval(
+ updateStatusDisplay,
+ STATUS_UPDATE_INTERVAL_MS,
+ )
+ }
+
+ /** Stop the status display update ticker. */
+ function stopStatusUpdates(): void {
+ if (statusUpdateTimer) {
+ clearInterval(statusUpdateTimer)
+ statusUpdateTimer = null
+ }
+ }
+
+ function onSessionDone(
+ sessionId: string,
+ startTime: number,
+ handle: SessionHandle,
+ ): (status: SessionDoneStatus) => void {
+ return (rawStatus: SessionDoneStatus): void => {
+ const workId = sessionWorkIds.get(sessionId)
+ activeSessions.delete(sessionId)
+ sessionStartTimes.delete(sessionId)
+ sessionWorkIds.delete(sessionId)
+ sessionIngressTokens.delete(sessionId)
+ const compatId = sessionCompatIds.get(sessionId) ?? sessionId
+ sessionCompatIds.delete(sessionId)
+ logger.removeSession(compatId)
+ titledSessions.delete(compatId)
+ v2Sessions.delete(sessionId)
+ // Clear per-session timeout timer
+ const timer = sessionTimers.get(sessionId)
+ if (timer) {
+ clearTimeout(timer)
+ sessionTimers.delete(sessionId)
+ }
+ // Clear token refresh timer
+ tokenRefresh?.cancel(sessionId)
+ // Wake the at-capacity sleep so the bridge can accept new work immediately
+ capacityWake.wake()
+
+ // If the session was killed by the timeout watchdog, treat it as a
+ // failed session (not a server/shutdown interrupt) so we still call
+ // stopWork and archiveSession below.
+ const wasTimedOut = timedOutSessions.delete(sessionId)
+ const status: SessionDoneStatus =
+ wasTimedOut && rawStatus === 'interrupted' ? 'failed' : rawStatus
+ const durationMs = Date.now() - startTime
+
+ logForDebugging(
+ `[bridge:session] sessionId=${sessionId} workId=${workId ?? 'unknown'} exited status=${status} duration=${formatDuration(durationMs)}`,
+ )
+ logEvent('tengu_bridge_session_done', {
+ status:
+ status as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ duration_ms: durationMs,
+ })
+ logForDiagnosticsNoPII('info', 'bridge_session_done', {
+ status,
+ duration_ms: durationMs,
+ })
+
+ // Clear the status display before printing final log
+ logger.clearStatus()
+ stopStatusUpdates()
+
+ // Build error message from stderr if available
+ const stderrSummary =
+ handle.lastStderr.length > 0 ? handle.lastStderr.join('\n') : undefined
+ let failureMessage: string | undefined
+
+ switch (status) {
+ case 'completed':
+ logger.logSessionComplete(sessionId, durationMs)
+ break
+ case 'failed':
+ // Skip failure log during shutdown — the child exits non-zero when
+ // killed, which is expected and not a real failure.
+ // Also skip for timeout-killed sessions — the timeout watchdog
+ // already logged a clear timeout message.
+ if (!wasTimedOut && !loopSignal.aborted) {
+ failureMessage = stderrSummary ?? 'Process exited with error'
+ logger.logSessionFailed(sessionId, failureMessage)
+ logError(new Error(`Bridge session failed: ${failureMessage}`))
+ }
+ break
+ case 'interrupted':
+ logger.logVerbose(`Session ${sessionId} interrupted`)
+ break
+ }
+
+ // Notify the server that this work item is done. Skip for interrupted
+ // sessions — interrupts are either server-initiated (the server already
+ // knows) or caused by bridge shutdown (which calls stopWork() separately).
+ if (status !== 'interrupted' && workId) {
+ trackCleanup(
+ stopWorkWithRetry(
+ api,
+ environmentId,
+ workId,
+ logger,
+ backoffConfig.stopWorkBaseDelayMs,
+ ),
+ )
+ completedWorkIds.add(workId)
+ }
+
+ // Clean up worktree if one was created for this session
+ const wt = sessionWorktrees.get(sessionId)
+ if (wt) {
+ sessionWorktrees.delete(sessionId)
+ trackCleanup(
+ removeAgentWorktree(
+ wt.worktreePath,
+ wt.worktreeBranch,
+ wt.gitRoot,
+ wt.hookBased,
+ ).catch((err: unknown) =>
+ logger.logVerbose(
+ `Failed to remove worktree ${wt.worktreePath}: ${errorMessage(err)}`,
+ ),
+ ),
+ )
+ }
+
+ // Lifecycle decision: in multi-session mode, keep the bridge running
+ // after a session completes. In single-session mode, abort the poll
+ // loop so the bridge exits cleanly.
+ if (status !== 'interrupted' && !loopSignal.aborted) {
+ if (config.spawnMode !== 'single-session') {
+ // Multi-session: archive the completed session so it doesn't linger
+ // as stale in the web UI. archiveSession is idempotent (409 if already
+ // archived), so double-archiving at shutdown is safe.
+ // sessionId arrived as cse_* from the work poll (infrastructure-layer
+ // tag). archiveSession hits /v1/sessions/{id}/archive which is the
+ // compat surface and validates TagSession (session_*). Re-tag — same
+ // UUID underneath.
+ trackCleanup(
+ api
+ .archiveSession(compatId)
+ .catch((err: unknown) =>
+ logger.logVerbose(
+ `Failed to archive session ${sessionId}: ${errorMessage(err)}`,
+ ),
+ ),
+ )
+ logForDebugging(
+ `[bridge:session] Session ${status}, returning to idle (multi-session mode)`,
+ )
+ } else {
+ // Single-session: coupled lifecycle — tear down environment
+ logForDebugging(
+ `[bridge:session] Session ${status}, aborting poll loop to tear down environment`,
+ )
+ controller.abort()
+ return
+ }
+ }
+
+ if (!loopSignal.aborted) {
+ startStatusUpdates()
+ }
+ }
+ }
+
+ // Start the idle status display immediately — unless we have a pre-created
+ // session, in which case setAttached() already set up the display and the
+ // poll loop will start status updates when it picks up the session.
+ if (!initialSessionId) {
+ startStatusUpdates()
+ }
+
+ while (!loopSignal.aborted) {
+ // Fetched once per iteration — the GrowthBook cache refreshes every
+ // 5 min, so a loop running at the at-capacity rate picks up config
+ // changes within one sleep cycle.
+ const pollConfig = getPollIntervalConfig()
+
+ try {
+ const work = await api.pollForWork(
+ environmentId,
+ environmentSecret,
+ loopSignal,
+ pollConfig.reclaim_older_than_ms,
+ )
+
+ // Log reconnection if we were previously disconnected
+ const wasDisconnected =
+ connErrorStart !== null || generalErrorStart !== null
+ if (wasDisconnected) {
+ const disconnectedMs =
+ Date.now() - (connErrorStart ?? generalErrorStart ?? Date.now())
+ logger.logReconnected(disconnectedMs)
+ logForDebugging(
+ `[bridge:poll] Reconnected after ${formatDuration(disconnectedMs)}`,
+ )
+ logEvent('tengu_bridge_reconnected', {
+ disconnected_ms: disconnectedMs,
+ })
+ }
+
+ connBackoff = 0
+ generalBackoff = 0
+ connErrorStart = null
+ generalErrorStart = null
+ lastPollErrorTime = null
+
+ // Null response = no work available in the queue.
+ // Add a minimum delay to avoid hammering the server.
+ if (!work) {
+ // Use live check (not a snapshot) since sessions can end during poll.
+ const atCap = activeSessions.size >= config.maxSessions
+ if (atCap) {
+ const atCapMs = pollConfig.multisession_poll_interval_ms_at_capacity
+ // Heartbeat loops WITHOUT polling. When at-capacity polling is also
+ // enabled (atCapMs > 0), the loop tracks a deadline and breaks out
+ // to poll at that interval — heartbeat and poll compose instead of
+ // one suppressing the other. We break out to poll when:
+ // - Poll deadline reached (atCapMs > 0 only)
+ // - Auth fails (JWT expired → poll refreshes tokens)
+ // - Capacity wake fires (session ended → poll for new work)
+ // - Loop aborted (shutdown)
+ if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) {
+ logEvent('tengu_bridge_heartbeat_mode_entered', {
+ active_sessions: activeSessions.size,
+ heartbeat_interval_ms:
+ pollConfig.non_exclusive_heartbeat_interval_ms,
+ })
+ // Deadline computed once at entry — GB updates to atCapMs don't
+ // shift an in-flight deadline (next entry picks up the new value).
+ const pollDeadline = atCapMs > 0 ? Date.now() + atCapMs : null
+ let hbResult: 'ok' | 'auth_failed' | 'fatal' | 'failed' = 'ok'
+ let hbCycles = 0
+ while (
+ !loopSignal.aborted &&
+ activeSessions.size >= config.maxSessions &&
+ (pollDeadline === null || Date.now() < pollDeadline)
+ ) {
+ // Re-read config each cycle so GrowthBook updates take effect
+ const hbConfig = getPollIntervalConfig()
+ if (hbConfig.non_exclusive_heartbeat_interval_ms <= 0) break
+
+ // Capture capacity signal BEFORE the async heartbeat call so
+ // a session ending during the HTTP request is caught by the
+ // subsequent sleep (instead of being lost to a replaced controller).
+ const cap = capacityWake.signal()
+
+ hbResult = await heartbeatActiveWorkItems()
+ if (hbResult === 'auth_failed' || hbResult === 'fatal') {
+ cap.cleanup()
+ break
+ }
+
+ hbCycles++
+ await sleep(
+ hbConfig.non_exclusive_heartbeat_interval_ms,
+ cap.signal,
+ )
+ cap.cleanup()
+ }
+
+ // Determine exit reason for telemetry
+ const exitReason =
+ hbResult === 'auth_failed' || hbResult === 'fatal'
+ ? hbResult
+ : loopSignal.aborted
+ ? 'shutdown'
+ : activeSessions.size < config.maxSessions
+ ? 'capacity_changed'
+ : pollDeadline !== null && Date.now() >= pollDeadline
+ ? 'poll_due'
+ : 'config_disabled'
+ logEvent('tengu_bridge_heartbeat_mode_exited', {
+ reason:
+ exitReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ heartbeat_cycles: hbCycles,
+ active_sessions: activeSessions.size,
+ })
+ if (exitReason === 'poll_due') {
+ // bridgeApi throttles empty-poll logs (EMPTY_POLL_LOG_INTERVAL=100)
+ // so the once-per-10min poll_due poll is invisible at counter=2.
+ // Log it here so verification runs see both endpoints in the debug log.
+ logForDebugging(
+ `[bridge:poll] Heartbeat poll_due after ${hbCycles} cycles — falling through to pollForWork`,
+ )
+ }
+
+ // On auth_failed or fatal, sleep before polling to avoid a tight
+ // poll+heartbeat loop. Auth_failed: heartbeatActiveWorkItems
+ // already called reconnectSession — the sleep gives the server
+ // time to propagate the re-queue. Fatal (404/410): may be a
+ // single work item GCd while the environment is still valid.
+ // Use atCapMs if enabled, else the heartbeat interval as a floor
+ // (guaranteed > 0 here) so heartbeat-only configs don't tight-loop.
+ if (hbResult === 'auth_failed' || hbResult === 'fatal') {
+ const cap = capacityWake.signal()
+ await sleep(
+ atCapMs > 0
+ ? atCapMs
+ : pollConfig.non_exclusive_heartbeat_interval_ms,
+ cap.signal,
+ )
+ cap.cleanup()
+ }
+ } else if (atCapMs > 0) {
+ // Heartbeat disabled: slow poll as liveness signal.
+ const cap = capacityWake.signal()
+ await sleep(atCapMs, cap.signal)
+ cap.cleanup()
+ }
+ } else {
+ const interval =
+ activeSessions.size > 0
+ ? pollConfig.multisession_poll_interval_ms_partial_capacity
+ : pollConfig.multisession_poll_interval_ms_not_at_capacity
+ await sleep(interval, loopSignal)
+ }
+ continue
+ }
+
+ // At capacity — we polled to keep the heartbeat alive, but cannot
+ // accept new work right now. We still enter the switch below so that
+ // token refreshes for existing sessions are processed (the case
+ // 'session' handler checks for existing sessions before the inner
+ // capacity guard).
+ const atCapacityBeforeSwitch = activeSessions.size >= config.maxSessions
+
+ // Skip work items that have already been completed and stopped.
+ // The server may re-deliver stale work before processing our stop
+ // request, which would otherwise cause a duplicate session spawn.
+ if (completedWorkIds.has(work.id)) {
+ logForDebugging(
+ `[bridge:work] Skipping already-completed workId=${work.id}`,
+ )
+ // Respect capacity throttle — without a sleep here, persistent stale
+ // redeliveries would tight-loop at poll-request speed (the !work
+ // branch above is the only sleep, and work != null skips it).
+ if (atCapacityBeforeSwitch) {
+ const cap = capacityWake.signal()
+ if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) {
+ await heartbeatActiveWorkItems()
+ await sleep(
+ pollConfig.non_exclusive_heartbeat_interval_ms,
+ cap.signal,
+ )
+ } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) {
+ await sleep(
+ pollConfig.multisession_poll_interval_ms_at_capacity,
+ cap.signal,
+ )
+ }
+ cap.cleanup()
+ } else {
+ await sleep(1000, loopSignal)
+ }
+ continue
+ }
+
+ // Decode the work secret for session spawning and to extract the JWT
+ // used for the ack call below.
+ let secret
+ try {
+ secret = decodeWorkSecret(work.secret)
+ } catch (err) {
+ const errMsg = errorMessage(err)
+ logger.logError(
+ `Failed to decode work secret for workId=${work.id}: ${errMsg}`,
+ )
+ logEvent('tengu_bridge_work_secret_failed', {})
+ // Can't ack (needs the JWT we failed to decode). stopWork uses OAuth,
+ // so it's callable here — prevents XAUTOCLAIM from re-delivering this
+ // poisoned item every reclaim_older_than_ms cycle.
+ completedWorkIds.add(work.id)
+ trackCleanup(
+ stopWorkWithRetry(
+ api,
+ environmentId,
+ work.id,
+ logger,
+ backoffConfig.stopWorkBaseDelayMs,
+ ),
+ )
+ // Respect capacity throttle before retrying — without a sleep here,
+ // repeated decode failures at capacity would tight-loop at
+ // poll-request speed (work != null skips the !work sleep above).
+ if (atCapacityBeforeSwitch) {
+ const cap = capacityWake.signal()
+ if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) {
+ await heartbeatActiveWorkItems()
+ await sleep(
+ pollConfig.non_exclusive_heartbeat_interval_ms,
+ cap.signal,
+ )
+ } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) {
+ await sleep(
+ pollConfig.multisession_poll_interval_ms_at_capacity,
+ cap.signal,
+ )
+ }
+ cap.cleanup()
+ }
+ continue
+ }
+
+ // Explicitly acknowledge after committing to handle the work — NOT
+ // before. The at-capacity guard inside case 'session' can break
+ // without spawning; acking there would permanently lose the work.
+ // Ack failures are non-fatal: server re-delivers, and existingHandle
+ // / completedWorkIds paths handle the dedup.
+ const ackWork = async (): Promise => {
+ logForDebugging(`[bridge:work] Acknowledging workId=${work.id}`)
+ try {
+ await api.acknowledgeWork(
+ environmentId,
+ work.id,
+ secret.session_ingress_token,
+ )
+ } catch (err) {
+ logForDebugging(
+ `[bridge:work] Acknowledge failed workId=${work.id}: ${errorMessage(err)}`,
+ )
+ }
+ }
+
+ const workType: string = work.data.type
+ switch (work.data.type) {
+ case 'healthcheck':
+ await ackWork()
+ logForDebugging('[bridge:work] Healthcheck received')
+ logger.logVerbose('Healthcheck received')
+ break
+ case 'session': {
+ const sessionId = work.data.id
+ try {
+ validateBridgeId(sessionId, 'session_id')
+ } catch {
+ await ackWork()
+ logger.logError(`Invalid session_id received: ${sessionId}`)
+ break
+ }
+
+ // If the session is already running, deliver the fresh token so
+ // the child process can reconnect its WebSocket with the new
+ // session ingress token. This handles the case where the server
+ // re-dispatches work for an existing session after the WS drops.
+ const existingHandle = activeSessions.get(sessionId)
+ if (existingHandle) {
+ existingHandle.updateAccessToken(secret.session_ingress_token)
+ sessionIngressTokens.set(sessionId, secret.session_ingress_token)
+ sessionWorkIds.set(sessionId, work.id)
+ // Re-schedule next refresh from the fresh JWT's expiry. onRefresh
+ // branches on v2Sessions so both v1 and v2 are safe here.
+ tokenRefresh?.schedule(sessionId, secret.session_ingress_token)
+ logForDebugging(
+ `[bridge:work] Updated access token for existing sessionId=${sessionId} workId=${work.id}`,
+ )
+ await ackWork()
+ break
+ }
+
+ // At capacity — token refresh for existing sessions is handled
+ // above, but we cannot spawn new ones. The post-switch capacity
+ // sleep will throttle the loop; just break here.
+ if (activeSessions.size >= config.maxSessions) {
+ logForDebugging(
+ `[bridge:work] At capacity (${activeSessions.size}/${config.maxSessions}), cannot spawn new session for workId=${work.id}`,
+ )
+ break
+ }
+
+ await ackWork()
+ const spawnStartTime = Date.now()
+
+ // CCR v2 path: register this bridge as the session worker, get the
+ // epoch, and point the child at /v1/code/sessions/{id}. The child
+ // already has the full v2 client (SSETransport + CCRClient) — same
+ // code path environment-manager launches in containers.
+ //
+ // v1 path: Session-Ingress WebSocket. Uses config.sessionIngressUrl
+ // (not secret.api_base_url, which may point to a remote proxy tunnel
+ // that doesn't know about locally-created sessions).
+ let sdkUrl: string
+ let useCcrV2 = false
+ let workerEpoch: number | undefined
+ // Server decides per-session via the work secret; env var is the
+ // ant-dev override (e.g. forcing v2 before the server flag is on).
+ if (
+ secret.use_code_sessions === true ||
+ isEnvTruthy(process.env.CLAUDE_BRIDGE_USE_CCR_V2)
+ ) {
+ sdkUrl = buildCCRv2SdkUrl(config.apiBaseUrl, sessionId)
+ // Retry once on transient failure (network blip, 500) before
+ // permanently giving up and killing the session.
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ try {
+ workerEpoch = await registerWorker(
+ sdkUrl,
+ secret.session_ingress_token,
+ )
+ useCcrV2 = true
+ logForDebugging(
+ `[bridge:session] CCR v2: registered worker sessionId=${sessionId} epoch=${workerEpoch} attempt=${attempt}`,
+ )
+ break
+ } catch (err) {
+ const errMsg = errorMessage(err)
+ if (attempt < 2) {
+ logForDebugging(
+ `[bridge:session] CCR v2: registerWorker attempt ${attempt} failed, retrying: ${errMsg}`,
+ )
+ await sleep(2_000, loopSignal)
+ if (loopSignal.aborted) break
+ continue
+ }
+ logger.logError(
+ `CCR v2 worker registration failed for session ${sessionId}: ${errMsg}`,
+ )
+ logError(new Error(`registerWorker failed: ${errMsg}`))
+ completedWorkIds.add(work.id)
+ trackCleanup(
+ stopWorkWithRetry(
+ api,
+ environmentId,
+ work.id,
+ logger,
+ backoffConfig.stopWorkBaseDelayMs,
+ ),
+ )
+ }
+ }
+ if (!useCcrV2) break
+ } else {
+ sdkUrl = buildSdkUrl(config.sessionIngressUrl, sessionId)
+ }
+
+ // In worktree mode, on-demand sessions get an isolated git worktree
+ // so concurrent sessions don't interfere with each other's file
+ // changes. The pre-created initial session (if any) runs in
+ // config.dir so the user's first session lands in the directory they
+ // invoked `rc` from — matching the old single-session UX.
+ // In same-dir and single-session modes, all sessions share config.dir.
+ // Capture spawnMode before the await below — the `w` key handler
+ // mutates config.spawnMode directly, and createAgentWorktree can
+ // take 1-2s, so reading config.spawnMode after the await can
+ // produce contradictory analytics (spawn_mode:'same-dir', in_worktree:true).
+ const spawnModeAtDecision = config.spawnMode
+ let sessionDir = config.dir
+ let worktreeCreateMs = 0
+ if (
+ spawnModeAtDecision === 'worktree' &&
+ (initialSessionId === undefined ||
+ !sameSessionId(sessionId, initialSessionId))
+ ) {
+ const wtStart = Date.now()
+ try {
+ const wt = await createAgentWorktree(
+ `bridge-${safeFilenameId(sessionId)}`,
+ )
+ worktreeCreateMs = Date.now() - wtStart
+ sessionWorktrees.set(sessionId, {
+ worktreePath: wt.worktreePath,
+ worktreeBranch: wt.worktreeBranch,
+ gitRoot: wt.gitRoot,
+ hookBased: wt.hookBased,
+ })
+ sessionDir = wt.worktreePath
+ logForDebugging(
+ `[bridge:session] Created worktree for sessionId=${sessionId} at ${wt.worktreePath}`,
+ )
+ } catch (err) {
+ const errMsg = errorMessage(err)
+ logger.logError(
+ `Failed to create worktree for session ${sessionId}: ${errMsg}`,
+ )
+ logError(new Error(`Worktree creation failed: ${errMsg}`))
+ completedWorkIds.add(work.id)
+ trackCleanup(
+ stopWorkWithRetry(
+ api,
+ environmentId,
+ work.id,
+ logger,
+ backoffConfig.stopWorkBaseDelayMs,
+ ),
+ )
+ break
+ }
+ }
+
+ logForDebugging(
+ `[bridge:session] Spawning sessionId=${sessionId} sdkUrl=${sdkUrl}`,
+ )
+
+ // compat-surface session_* form for logger/Sessions-API calls.
+ // Work poll returns cse_* under v2 compat; convert before spawn so
+ // the onFirstUserMessage callback can close over it.
+ const compatSessionId = toCompatSessionId(sessionId)
+
+ const spawnResult = safeSpawn(
+ spawner,
+ {
+ sessionId,
+ sdkUrl,
+ accessToken: secret.session_ingress_token,
+ useCcrV2,
+ workerEpoch,
+ onFirstUserMessage: text => {
+ // Server-set titles (--name, web rename) win. fetchSessionTitle
+ // runs concurrently; if it already populated titledSessions,
+ // skip. If it hasn't resolved yet, the derived title sticks —
+ // acceptable since the server had no title at spawn time.
+ if (titledSessions.has(compatSessionId)) return
+ titledSessions.add(compatSessionId)
+ const title = deriveSessionTitle(text)
+ logger.setSessionTitle(compatSessionId, title)
+ logForDebugging(
+ `[bridge:title] derived title for ${compatSessionId}: ${title}`,
+ )
+ void import('./createSession.js')
+ .then(({ updateBridgeSessionTitle }) =>
+ updateBridgeSessionTitle(compatSessionId, title, {
+ baseUrl: config.apiBaseUrl,
+ }),
+ )
+ .catch(err =>
+ logForDebugging(
+ `[bridge:title] failed to update title for ${compatSessionId}: ${err}`,
+ { level: 'error' },
+ ),
+ )
+ },
+ },
+ sessionDir,
+ )
+ if (typeof spawnResult === 'string') {
+ logger.logError(
+ `Failed to spawn session ${sessionId}: ${spawnResult}`,
+ )
+ // Clean up worktree if one was created for this session
+ const wt = sessionWorktrees.get(sessionId)
+ if (wt) {
+ sessionWorktrees.delete(sessionId)
+ trackCleanup(
+ removeAgentWorktree(
+ wt.worktreePath,
+ wt.worktreeBranch,
+ wt.gitRoot,
+ wt.hookBased,
+ ).catch((err: unknown) =>
+ logger.logVerbose(
+ `Failed to remove worktree ${wt.worktreePath}: ${errorMessage(err)}`,
+ ),
+ ),
+ )
+ }
+ completedWorkIds.add(work.id)
+ trackCleanup(
+ stopWorkWithRetry(
+ api,
+ environmentId,
+ work.id,
+ logger,
+ backoffConfig.stopWorkBaseDelayMs,
+ ),
+ )
+ break
+ }
+ const handle = spawnResult
+
+ const spawnDurationMs = Date.now() - spawnStartTime
+ logEvent('tengu_bridge_session_started', {
+ active_sessions: activeSessions.size,
+ spawn_mode:
+ spawnModeAtDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ in_worktree: sessionWorktrees.has(sessionId),
+ spawn_duration_ms: spawnDurationMs,
+ worktree_create_ms: worktreeCreateMs,
+ inProtectedNamespace: isInProtectedNamespace(),
+ })
+ logForDiagnosticsNoPII('info', 'bridge_session_started', {
+ spawn_mode: spawnModeAtDecision,
+ in_worktree: sessionWorktrees.has(sessionId),
+ spawn_duration_ms: spawnDurationMs,
+ worktree_create_ms: worktreeCreateMs,
+ })
+
+ activeSessions.set(sessionId, handle)
+ sessionWorkIds.set(sessionId, work.id)
+ sessionIngressTokens.set(sessionId, secret.session_ingress_token)
+ sessionCompatIds.set(sessionId, compatSessionId)
+
+ const startTime = Date.now()
+ sessionStartTimes.set(sessionId, startTime)
+
+ // Use a generic prompt description since we no longer get startup_context
+ logger.logSessionStart(sessionId, `Session ${sessionId}`)
+
+ // Compute the actual debug file path (mirrors sessionRunner.ts logic)
+ const safeId = safeFilenameId(sessionId)
+ let sessionDebugFile: string | undefined
+ if (config.debugFile) {
+ const ext = config.debugFile.lastIndexOf('.')
+ if (ext > 0) {
+ sessionDebugFile = `${config.debugFile.slice(0, ext)}-${safeId}${config.debugFile.slice(ext)}`
+ } else {
+ sessionDebugFile = `${config.debugFile}-${safeId}`
+ }
+ } else if (config.verbose || process.env.USER_TYPE === 'ant') {
+ sessionDebugFile = join(
+ tmpdir(),
+ 'claude',
+ `bridge-session-${safeId}.log`,
+ )
+ }
+
+ if (sessionDebugFile) {
+ logger.logVerbose(`Debug log: ${sessionDebugFile}`)
+ }
+
+ // Register in the sessions Map before starting status updates so the
+ // first render tick shows the correct count and bullet list in sync.
+ logger.addSession(
+ compatSessionId,
+ getRemoteSessionUrl(compatSessionId, config.sessionIngressUrl),
+ )
+
+ // Start live status updates and transition to "Attached" state.
+ startStatusUpdates()
+ logger.setAttached(compatSessionId)
+
+ // One-shot title fetch. If the session already has a title (set via
+ // --name, web rename, or /remote-control), display it and mark as
+ // titled so the first-user-message fallback doesn't overwrite it.
+ // Otherwise onFirstUserMessage derives one from the first prompt.
+ void fetchSessionTitle(compatSessionId, config.apiBaseUrl)
+ .then(title => {
+ if (title && activeSessions.has(sessionId)) {
+ titledSessions.add(compatSessionId)
+ logger.setSessionTitle(compatSessionId, title)
+ logForDebugging(
+ `[bridge:title] server title for ${compatSessionId}: ${title}`,
+ )
+ }
+ })
+ .catch(err =>
+ logForDebugging(
+ `[bridge:title] failed to fetch title for ${compatSessionId}: ${err}`,
+ { level: 'error' },
+ ),
+ )
+
+ // Start per-session timeout watchdog
+ const timeoutMs =
+ config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS
+ if (timeoutMs > 0) {
+ const timer = setTimeout(
+ onSessionTimeout,
+ timeoutMs,
+ sessionId,
+ timeoutMs,
+ logger,
+ timedOutSessions,
+ handle,
+ )
+ sessionTimers.set(sessionId, timer)
+ }
+
+ // Schedule proactive token refresh before the JWT expires.
+ // onRefresh branches on v2Sessions: v1 delivers OAuth to the
+ // child, v2 triggers server re-dispatch via reconnectSession.
+ if (useCcrV2) {
+ v2Sessions.add(sessionId)
+ }
+ tokenRefresh?.schedule(sessionId, secret.session_ingress_token)
+
+ void handle.done.then(onSessionDone(sessionId, startTime, handle))
+ break
+ }
+ default:
+ await ackWork()
+ // Gracefully ignore unknown work types. The backend may send new
+ // types before the bridge client is updated.
+ logForDebugging(
+ `[bridge:work] Unknown work type: ${workType}, skipping`,
+ )
+ break
+ }
+
+ // When at capacity, throttle the loop. The switch above still runs so
+ // existing-session token refreshes are processed, but we sleep here
+ // to avoid busy-looping. Include the capacity wake signal so the
+ // sleep is interrupted immediately when a session completes.
+ if (atCapacityBeforeSwitch) {
+ const cap = capacityWake.signal()
+ if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) {
+ await heartbeatActiveWorkItems()
+ await sleep(
+ pollConfig.non_exclusive_heartbeat_interval_ms,
+ cap.signal,
+ )
+ } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) {
+ await sleep(
+ pollConfig.multisession_poll_interval_ms_at_capacity,
+ cap.signal,
+ )
+ }
+ cap.cleanup()
+ }
+ } catch (err) {
+ if (loopSignal.aborted) {
+ break
+ }
+
+ // Fatal errors (401/403) — no point retrying, auth won't fix itself
+ if (err instanceof BridgeFatalError) {
+ fatalExit = true
+ // Server-enforced expiry gets a clean status message, not an error
+ if (isExpiredErrorType(err.errorType)) {
+ logger.logStatus(err.message)
+ } else if (isSuppressible403(err)) {
+ // Cosmetic 403 errors (e.g., external_poll_sessions scope,
+ // environments:manage permission) — don't show to user
+ logForDebugging(`[bridge:work] Suppressed 403 error: ${err.message}`)
+ } else {
+ logger.logError(err.message)
+ logError(err)
+ }
+ logEvent('tengu_bridge_fatal_error', {
+ status: err.status,
+ error_type:
+ err.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ logForDiagnosticsNoPII(
+ isExpiredErrorType(err.errorType) ? 'info' : 'error',
+ 'bridge_fatal_error',
+ { status: err.status, error_type: err.errorType },
+ )
+ break
+ }
+
+ const errMsg = describeAxiosError(err)
+
+ if (isConnectionError(err) || isServerError(err)) {
+ const now = Date.now()
+
+ // Detect system sleep/wake: if the gap since the last poll error
+ // greatly exceeds the expected backoff, the machine likely slept.
+ // Reset error tracking so the bridge retries with a fresh budget.
+ if (
+ lastPollErrorTime !== null &&
+ now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig)
+ ) {
+ logForDebugging(
+ `[bridge:work] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting error budget`,
+ )
+ logForDiagnosticsNoPII('info', 'bridge_poll_sleep_detected', {
+ gapMs: now - lastPollErrorTime,
+ })
+ connErrorStart = null
+ connBackoff = 0
+ generalErrorStart = null
+ generalBackoff = 0
+ }
+ lastPollErrorTime = now
+
+ if (!connErrorStart) {
+ connErrorStart = now
+ }
+ const elapsed = now - connErrorStart
+ if (elapsed >= backoffConfig.connGiveUpMs) {
+ logger.logError(
+ `Server unreachable for ${Math.round(elapsed / 60_000)} minutes, giving up.`,
+ )
+ logEvent('tengu_bridge_poll_give_up', {
+ error_type:
+ 'connection' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ elapsed_ms: elapsed,
+ })
+ logForDiagnosticsNoPII('error', 'bridge_poll_give_up', {
+ error_type: 'connection',
+ elapsed_ms: elapsed,
+ })
+ fatalExit = true
+ break
+ }
+
+ // Reset the other track when switching error types
+ generalErrorStart = null
+ generalBackoff = 0
+
+ connBackoff = connBackoff
+ ? Math.min(connBackoff * 2, backoffConfig.connCapMs)
+ : backoffConfig.connInitialMs
+ const delay = addJitter(connBackoff)
+ logger.logVerbose(
+ `Connection error, retrying in ${formatDelay(delay)} (${Math.round(elapsed / 1000)}s elapsed): ${errMsg}`,
+ )
+ logger.updateReconnectingStatus(
+ formatDelay(delay),
+ formatDuration(elapsed),
+ )
+ // The poll_due heartbeat-loop exit leaves a healthy lease exposed to
+ // this backoff path. Heartbeat before each sleep so /poll outages
+ // (the VerifyEnvironmentSecretAuth DB path heartbeat was introduced
+ // to avoid) don't kill the 300s lease TTL. No-op when activeSessions
+ // is empty or heartbeat is disabled.
+ if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) {
+ await heartbeatActiveWorkItems()
+ }
+ await sleep(delay, loopSignal)
+ } else {
+ const now = Date.now()
+
+ // Sleep detection for general errors (same logic as connection errors)
+ if (
+ lastPollErrorTime !== null &&
+ now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig)
+ ) {
+ logForDebugging(
+ `[bridge:work] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting error budget`,
+ )
+ logForDiagnosticsNoPII('info', 'bridge_poll_sleep_detected', {
+ gapMs: now - lastPollErrorTime,
+ })
+ connErrorStart = null
+ connBackoff = 0
+ generalErrorStart = null
+ generalBackoff = 0
+ }
+ lastPollErrorTime = now
+
+ if (!generalErrorStart) {
+ generalErrorStart = now
+ }
+ const elapsed = now - generalErrorStart
+ if (elapsed >= backoffConfig.generalGiveUpMs) {
+ logger.logError(
+ `Persistent errors for ${Math.round(elapsed / 60_000)} minutes, giving up.`,
+ )
+ logEvent('tengu_bridge_poll_give_up', {
+ error_type:
+ 'general' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ elapsed_ms: elapsed,
+ })
+ logForDiagnosticsNoPII('error', 'bridge_poll_give_up', {
+ error_type: 'general',
+ elapsed_ms: elapsed,
+ })
+ fatalExit = true
+ break
+ }
+
+ // Reset the other track when switching error types
+ connErrorStart = null
+ connBackoff = 0
+
+ generalBackoff = generalBackoff
+ ? Math.min(generalBackoff * 2, backoffConfig.generalCapMs)
+ : backoffConfig.generalInitialMs
+ const delay = addJitter(generalBackoff)
+ logger.logVerbose(
+ `Poll failed, retrying in ${formatDelay(delay)} (${Math.round(elapsed / 1000)}s elapsed): ${errMsg}`,
+ )
+ logger.updateReconnectingStatus(
+ formatDelay(delay),
+ formatDuration(elapsed),
+ )
+ if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) {
+ await heartbeatActiveWorkItems()
+ }
+ await sleep(delay, loopSignal)
+ }
+ }
+ }
+
+ // Clean up
+ stopStatusUpdates()
+ logger.clearStatus()
+
+ const loopDurationMs = Date.now() - loopStartTime
+ logEvent('tengu_bridge_shutdown', {
+ active_sessions: activeSessions.size,
+ loop_duration_ms: loopDurationMs,
+ })
+ logForDiagnosticsNoPII('info', 'bridge_shutdown', {
+ active_sessions: activeSessions.size,
+ loop_duration_ms: loopDurationMs,
+ })
+
+ // Graceful shutdown: kill active sessions, report them as interrupted,
+ // archive sessions, then deregister the environment so the web UI shows
+ // the bridge as offline.
+
+ // Collect all session IDs to archive on exit. This includes:
+ // 1. Active sessions (snapshot before killing — onSessionDone clears maps)
+ // 2. The initial auto-created session (may never have had work dispatched)
+ // api.archiveSession is idempotent (409 if already archived), so
+ // double-archiving is safe.
+ const sessionsToArchive = new Set(activeSessions.keys())
+ if (initialSessionId) {
+ sessionsToArchive.add(initialSessionId)
+ }
+ // Snapshot before killing — onSessionDone clears sessionCompatIds.
+ const compatIdSnapshot = new Map(sessionCompatIds)
+
+ if (activeSessions.size > 0) {
+ logForDebugging(
+ `[bridge:shutdown] Shutting down ${activeSessions.size} active session(s)`,
+ )
+ logger.logStatus(
+ `Shutting down ${activeSessions.size} active session(s)\u2026`,
+ )
+
+ // Snapshot work IDs before killing — onSessionDone clears the maps when
+ // each child exits, so we need a copy for the stopWork calls below.
+ const shutdownWorkIds = new Map(sessionWorkIds)
+
+ for (const [sessionId, handle] of activeSessions.entries()) {
+ logForDebugging(
+ `[bridge:shutdown] Sending SIGTERM to sessionId=${sessionId}`,
+ )
+ handle.kill()
+ }
+
+ const timeout = new AbortController()
+ await Promise.race([
+ Promise.allSettled([...activeSessions.values()].map(h => h.done)),
+ sleep(backoffConfig.shutdownGraceMs ?? 30_000, timeout.signal),
+ ])
+ timeout.abort()
+
+ // SIGKILL any processes that didn't respond to SIGTERM within the grace window
+ for (const [sid, handle] of activeSessions.entries()) {
+ logForDebugging(`[bridge:shutdown] Force-killing stuck sessionId=${sid}`)
+ handle.forceKill()
+ }
+
+ // Clear any remaining session timeout and refresh timers
+ for (const timer of sessionTimers.values()) {
+ clearTimeout(timer)
+ }
+ sessionTimers.clear()
+ tokenRefresh?.cancelAll()
+
+ // Clean up any remaining worktrees from active sessions.
+ // Snapshot and clear the map first so onSessionDone (which may fire
+ // during the await below when handle.done resolves) won't try to
+ // remove the same worktrees again.
+ if (sessionWorktrees.size > 0) {
+ const remainingWorktrees = [...sessionWorktrees.values()]
+ sessionWorktrees.clear()
+ logForDebugging(
+ `[bridge:shutdown] Cleaning up ${remainingWorktrees.length} worktree(s)`,
+ )
+ await Promise.allSettled(
+ remainingWorktrees.map(wt =>
+ removeAgentWorktree(
+ wt.worktreePath,
+ wt.worktreeBranch,
+ wt.gitRoot,
+ wt.hookBased,
+ ),
+ ),
+ )
+ }
+
+ // Stop all active work items so the server knows they're done
+ await Promise.allSettled(
+ [...shutdownWorkIds.entries()].map(([sessionId, workId]) => {
+ return api
+ .stopWork(environmentId, workId, true)
+ .catch(err =>
+ logger.logVerbose(
+ `Failed to stop work ${workId} for session ${sessionId}: ${errorMessage(err)}`,
+ ),
+ )
+ }),
+ )
+ }
+
+ // Ensure all in-flight cleanup (stopWork, worktree removal) from
+ // onSessionDone completes before deregistering — otherwise
+ // process.exit() can kill them mid-flight.
+ if (pendingCleanups.size > 0) {
+ await Promise.allSettled([...pendingCleanups])
+ }
+
+ // In single-session mode with a known session, leave the session and
+ // environment alive so `claude remote-control --session-id=` can resume.
+ // The backend GCs stale environments via a 4h TTL (BRIDGE_LAST_POLL_TTL).
+ // Archiving the session or deregistering the environment would make the
+ // printed resume command a lie — deregister deletes Firestore + Redis stream.
+ // Skip when the loop exited fatally (env expired, auth failed, give-up) —
+ // resume is impossible in those cases and the message would contradict the
+ // error already printed.
+ // feature('KAIROS') gate: --session-id is ant-only; without the gate,
+ // revert to the pre-PR behavior (archive + deregister on every shutdown).
+ if (
+ feature('KAIROS') &&
+ config.spawnMode === 'single-session' &&
+ initialSessionId &&
+ !fatalExit
+ ) {
+ logger.logStatus(
+ `Resume this session by running \`claude remote-control --continue\``,
+ )
+ logForDebugging(
+ `[bridge:shutdown] Skipping archive+deregister to allow resume of session ${initialSessionId}`,
+ )
+ return
+ }
+
+ // Archive all known sessions so they don't linger as idle/running on the
+ // server after the bridge goes offline.
+ if (sessionsToArchive.size > 0) {
+ logForDebugging(
+ `[bridge:shutdown] Archiving ${sessionsToArchive.size} session(s)`,
+ )
+ await Promise.allSettled(
+ [...sessionsToArchive].map(sessionId =>
+ api
+ .archiveSession(
+ compatIdSnapshot.get(sessionId) ?? toCompatSessionId(sessionId),
+ )
+ .catch(err =>
+ logger.logVerbose(
+ `Failed to archive session ${sessionId}: ${errorMessage(err)}`,
+ ),
+ ),
+ ),
+ )
+ }
+
+ // Deregister the environment so the web UI shows the bridge as offline
+ // and the Redis stream is cleaned up.
+ try {
+ await api.deregisterEnvironment(environmentId)
+ logForDebugging(
+ `[bridge:shutdown] Environment deregistered, bridge offline`,
+ )
+ logger.logVerbose('Environment deregistered.')
+ } catch (err) {
+ logger.logVerbose(`Failed to deregister environment: ${errorMessage(err)}`)
+ }
+
+ // Clear the crash-recovery pointer — the env is gone, pointer would be
+ // stale. The early return above (resumable SIGINT shutdown) skips this,
+ // leaving the pointer as a backup for the printed --session-id hint.
+ const { clearBridgePointer } = await import('./bridgePointer.js')
+ await clearBridgePointer(config.dir)
+
+ logger.logVerbose('Environment offline.')
+}
+
+const CONNECTION_ERROR_CODES = new Set([
+ 'ECONNREFUSED',
+ 'ECONNRESET',
+ 'ETIMEDOUT',
+ 'ENETUNREACH',
+ 'EHOSTUNREACH',
+])
+
+export function isConnectionError(err: unknown): boolean {
+ if (
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ typeof err.code === 'string' &&
+ CONNECTION_ERROR_CODES.has(err.code)
+ ) {
+ return true
+ }
+ return false
+}
+
+/** Detect HTTP 5xx errors from axios (code: 'ERR_BAD_RESPONSE'). */
+export function isServerError(err: unknown): boolean {
+ return (
+ !!err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ typeof err.code === 'string' &&
+ err.code === 'ERR_BAD_RESPONSE'
+ )
+}
+
+/** Add ±25% jitter to a delay value. */
+function addJitter(ms: number): number {
+ return Math.max(0, ms + ms * 0.25 * (2 * Math.random() - 1))
+}
+
+function formatDelay(ms: number): string {
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`
+}
+
+/**
+ * Retry stopWork with exponential backoff (3 attempts, 1s/2s/4s).
+ * Ensures the server learns the work item ended, preventing server-side zombies.
+ */
+async function stopWorkWithRetry(
+ api: BridgeApiClient,
+ environmentId: string,
+ workId: string,
+ logger: BridgeLogger,
+ baseDelayMs = 1000,
+): Promise {
+ const MAX_ATTEMPTS = 3
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ await api.stopWork(environmentId, workId, false)
+ logForDebugging(
+ `[bridge:work] stopWork succeeded for workId=${workId} on attempt ${attempt}/${MAX_ATTEMPTS}`,
+ )
+ return
+ } catch (err) {
+ // Auth/permission errors won't be fixed by retrying
+ if (err instanceof BridgeFatalError) {
+ if (isSuppressible403(err)) {
+ logForDebugging(
+ `[bridge:work] Suppressed stopWork 403 for ${workId}: ${err.message}`,
+ )
+ } else {
+ logger.logError(`Failed to stop work ${workId}: ${err.message}`)
+ }
+ logForDiagnosticsNoPII('error', 'bridge_stop_work_failed', {
+ attempts: attempt,
+ fatal: true,
+ })
+ return
+ }
+ const errMsg = errorMessage(err)
+ if (attempt < MAX_ATTEMPTS) {
+ const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
+ logger.logVerbose(
+ `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
+ )
+ await sleep(delay)
+ } else {
+ logger.logError(
+ `Failed to stop work ${workId} after ${MAX_ATTEMPTS} attempts: ${errMsg}`,
+ )
+ logForDiagnosticsNoPII('error', 'bridge_stop_work_failed', {
+ attempts: MAX_ATTEMPTS,
+ })
+ }
+ }
+ }
+}
+
+function onSessionTimeout(
+ sessionId: string,
+ timeoutMs: number,
+ logger: BridgeLogger,
+ timedOutSessions: Set,
+ handle: SessionHandle,
+): void {
+ logForDebugging(
+ `[bridge:session] sessionId=${sessionId} timed out after ${formatDuration(timeoutMs)}`,
+ )
+ logEvent('tengu_bridge_session_timeout', {
+ timeout_ms: timeoutMs,
+ })
+ logger.logSessionFailed(
+ sessionId,
+ `Session timed out after ${formatDuration(timeoutMs)}`,
+ )
+ timedOutSessions.add(sessionId)
+ handle.kill()
+}
+
+export type ParsedArgs = {
+ verbose: boolean
+ sandbox: boolean
+ debugFile?: string
+ sessionTimeoutMs?: number
+ permissionMode?: string
+ name?: string
+ /** Value passed to --spawn (if any); undefined if no --spawn flag was given. */
+ spawnMode: SpawnMode | undefined
+ /** Value passed to --capacity (if any); undefined if no --capacity flag was given. */
+ capacity: number | undefined
+ /** --[no-]create-session-in-dir override; undefined = use default (on). */
+ createSessionInDir: boolean | undefined
+ /** Resume an existing session instead of creating a new one. */
+ sessionId?: string
+ /** Resume the last session in this directory (reads bridge-pointer.json). */
+ continueSession: boolean
+ help: boolean
+ error?: string
+}
+
+const SPAWN_FLAG_VALUES = ['session', 'same-dir', 'worktree'] as const
+
+function parseSpawnValue(raw: string | undefined): SpawnMode | string {
+ if (raw === 'session') return 'single-session'
+ if (raw === 'same-dir') return 'same-dir'
+ if (raw === 'worktree') return 'worktree'
+ return `--spawn requires one of: ${SPAWN_FLAG_VALUES.join(', ')} (got: ${raw ?? ''})`
+}
+
+function parseCapacityValue(raw: string | undefined): number | string {
+ const n = raw === undefined ? NaN : parseInt(raw, 10)
+ if (isNaN(n) || n < 1) {
+ return `--capacity requires a positive integer (got: ${raw ?? ''})`
+ }
+ return n
+}
+
+export function parseArgs(args: string[]): ParsedArgs {
+ let verbose = false
+ let sandbox = false
+ let debugFile: string | undefined
+ let sessionTimeoutMs: number | undefined
+ let permissionMode: string | undefined
+ let name: string | undefined
+ let help = false
+ let spawnMode: SpawnMode | undefined
+ let capacity: number | undefined
+ let createSessionInDir: boolean | undefined
+ let sessionId: string | undefined
+ let continueSession = false
+
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i]!
+ if (arg === '--help' || arg === '-h') {
+ help = true
+ } else if (arg === '--verbose' || arg === '-v') {
+ verbose = true
+ } else if (arg === '--sandbox') {
+ sandbox = true
+ } else if (arg === '--no-sandbox') {
+ sandbox = false
+ } else if (arg === '--debug-file' && i + 1 < args.length) {
+ debugFile = resolve(args[++i]!)
+ } else if (arg.startsWith('--debug-file=')) {
+ debugFile = resolve(arg.slice('--debug-file='.length))
+ } else if (arg === '--session-timeout' && i + 1 < args.length) {
+ sessionTimeoutMs = parseInt(args[++i]!, 10) * 1000
+ } else if (arg.startsWith('--session-timeout=')) {
+ sessionTimeoutMs =
+ parseInt(arg.slice('--session-timeout='.length), 10) * 1000
+ } else if (arg === '--permission-mode' && i + 1 < args.length) {
+ permissionMode = args[++i]!
+ } else if (arg.startsWith('--permission-mode=')) {
+ permissionMode = arg.slice('--permission-mode='.length)
+ } else if (arg === '--name' && i + 1 < args.length) {
+ name = args[++i]!
+ } else if (arg.startsWith('--name=')) {
+ name = arg.slice('--name='.length)
+ } else if (
+ feature('KAIROS') &&
+ arg === '--session-id' &&
+ i + 1 < args.length
+ ) {
+ sessionId = args[++i]!
+ if (!sessionId) {
+ return makeError('--session-id requires a value')
+ }
+ } else if (feature('KAIROS') && arg.startsWith('--session-id=')) {
+ sessionId = arg.slice('--session-id='.length)
+ if (!sessionId) {
+ return makeError('--session-id requires a value')
+ }
+ } else if (feature('KAIROS') && (arg === '--continue' || arg === '-c')) {
+ continueSession = true
+ } else if (arg === '--spawn' || arg.startsWith('--spawn=')) {
+ if (spawnMode !== undefined) {
+ return makeError('--spawn may only be specified once')
+ }
+ const raw = arg.startsWith('--spawn=')
+ ? arg.slice('--spawn='.length)
+ : args[++i]
+ const v = parseSpawnValue(raw)
+ if (v === 'single-session' || v === 'same-dir' || v === 'worktree') {
+ spawnMode = v
+ } else {
+ return makeError(v)
+ }
+ } else if (arg === '--capacity' || arg.startsWith('--capacity=')) {
+ if (capacity !== undefined) {
+ return makeError('--capacity may only be specified once')
+ }
+ const raw = arg.startsWith('--capacity=')
+ ? arg.slice('--capacity='.length)
+ : args[++i]
+ const v = parseCapacityValue(raw)
+ if (typeof v === 'number') capacity = v
+ else return makeError(v)
+ } else if (arg === '--create-session-in-dir') {
+ createSessionInDir = true
+ } else if (arg === '--no-create-session-in-dir') {
+ createSessionInDir = false
+ } else {
+ return makeError(
+ `Unknown argument: ${arg}\nRun 'claude remote-control --help' for usage.`,
+ )
+ }
+ }
+
+ // Note: gate check for --spawn/--capacity/--create-session-in-dir is in bridgeMain
+ // (gate-aware error). Flag cross-validation happens here.
+
+ // --capacity only makes sense for multi-session modes.
+ if (spawnMode === 'single-session' && capacity !== undefined) {
+ return makeError(
+ `--capacity cannot be used with --spawn=session (single-session mode has fixed capacity 1).`,
+ )
+ }
+
+ // --session-id / --continue resume a specific session on its original
+ // environment; incompatible with spawn-related flags (which configure
+ // fresh session creation), and mutually exclusive with each other.
+ if (
+ (sessionId || continueSession) &&
+ (spawnMode !== undefined ||
+ capacity !== undefined ||
+ createSessionInDir !== undefined)
+ ) {
+ return makeError(
+ `--session-id and --continue cannot be used with --spawn, --capacity, or --create-session-in-dir.`,
+ )
+ }
+ if (sessionId && continueSession) {
+ return makeError(`--session-id and --continue cannot be used together.`)
+ }
+
+ return {
+ verbose,
+ sandbox,
+ debugFile,
+ sessionTimeoutMs,
+ permissionMode,
+ name,
+ spawnMode,
+ capacity,
+ createSessionInDir,
+ sessionId,
+ continueSession,
+ help,
+ }
+
+ function makeError(error: string): ParsedArgs {
+ return {
+ verbose,
+ sandbox,
+ debugFile,
+ sessionTimeoutMs,
+ permissionMode,
+ name,
+ spawnMode,
+ capacity,
+ createSessionInDir,
+ sessionId,
+ continueSession,
+ help,
+ error,
+ }
+ }
+}
+
+async function printHelp(): Promise {
+ // Use EXTERNAL_PERMISSION_MODES for help text — internal modes (bubble)
+ // are ant-only and auto is feature-gated; they're still accepted by validation.
+ const { EXTERNAL_PERMISSION_MODES } = await import('../types/permissions.js')
+ const modes = EXTERNAL_PERMISSION_MODES.join(', ')
+ const showServer = await isMultiSessionSpawnEnabled()
+ const serverOptions = showServer
+ ? ` --spawn Spawn mode: same-dir, worktree, session
+ (default: same-dir)
+ --capacity Max concurrent sessions in worktree or
+ same-dir mode (default: ${SPAWN_SESSIONS_DEFAULT})
+ --[no-]create-session-in-dir Pre-create a session in the current
+ directory; in worktree mode this session
+ stays in cwd while on-demand sessions get
+ isolated worktrees (default: on)
+`
+ : ''
+ const serverDescription = showServer
+ ? `
+ Remote Control runs as a persistent server that accepts multiple concurrent
+ sessions in the current directory. One session is pre-created on start so
+ you have somewhere to type immediately. Use --spawn=worktree to isolate
+ each on-demand session in its own git worktree, or --spawn=session for
+ the classic single-session mode (exits when that session ends). Press 'w'
+ during runtime to toggle between same-dir and worktree.
+`
+ : ''
+ const serverNote = showServer
+ ? ` - Worktree mode requires a git repository or WorktreeCreate/WorktreeRemove hooks
+`
+ : ''
+ const help = `
+Remote Control - Connect your local environment to claude.ai/code
+
+USAGE
+ claude remote-control [options]
+OPTIONS
+ --name Name for the session (shown in claude.ai/code)
+${
+ feature('KAIROS')
+ ? ` -c, --continue Resume the last session in this directory
+ --session-id Resume a specific session by ID (cannot be
+ used with spawn flags or --continue)
+`
+ : ''
+} --permission-mode Permission mode for spawned sessions
+ (${modes})
+ --debug-file Write debug logs to file
+ -v, --verbose Enable verbose output
+ -h, --help Show this help
+${serverOptions}
+DESCRIPTION
+ Remote Control allows you to control sessions on your local device from
+ claude.ai/code (https://claude.ai/code). Run this command in the
+ directory you want to work in, then connect from the Claude app or web.
+${serverDescription}
+NOTES
+ - You must be logged in with a Claude account that has a subscription
+ - Run \`claude\` first in the directory to accept the workspace trust dialog
+${serverNote}`
+ // biome-ignore lint/suspicious/noConsole: intentional help output
+ console.log(help)
+}
+
+const TITLE_MAX_LEN = 80
+
+/** Derive a session title from a user message: first line, truncated. */
+function deriveSessionTitle(text: string): string {
+ // Collapse whitespace — newlines/tabs would break the single-line status display.
+ const flat = text.replace(/\s+/g, ' ').trim()
+ return truncateToWidth(flat, TITLE_MAX_LEN)
+}
+
+/**
+ * One-shot fetch of a session's title via GET /v1/sessions/{id}.
+ *
+ * Uses `getBridgeSession` from createSession.ts (ccr-byoc headers + org UUID)
+ * rather than the environments-level bridgeApi client, whose headers make the
+ * Sessions API return 404. Returns undefined if the session has no title yet
+ * or the fetch fails — the caller falls back to deriving a title from the
+ * first user message.
+ */
+async function fetchSessionTitle(
+ compatSessionId: string,
+ baseUrl: string,
+): Promise {
+ const { getBridgeSession } = await import('./createSession.js')
+ const session = await getBridgeSession(compatSessionId, { baseUrl })
+ return session?.title || undefined
+}
+
+export async function bridgeMain(args: string[]): Promise {
+ const parsed = parseArgs(args)
+
+ if (parsed.help) {
+ await printHelp()
+ return
+ }
+ if (parsed.error) {
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(`Error: ${parsed.error}`)
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ const {
+ verbose,
+ sandbox,
+ debugFile,
+ sessionTimeoutMs,
+ permissionMode,
+ name,
+ spawnMode: parsedSpawnMode,
+ capacity: parsedCapacity,
+ createSessionInDir: parsedCreateSessionInDir,
+ sessionId: parsedSessionId,
+ continueSession,
+ } = parsed
+ // Mutable so --continue can set it from the pointer file. The #20460
+ // resume flow below then treats it the same as an explicit --session-id.
+ let resumeSessionId = parsedSessionId
+ // When --continue found a pointer, this is the directory it came from
+ // (may be a worktree sibling, not `dir`). On resume-flow deterministic
+ // failure, clear THIS file so --continue doesn't keep hitting the same
+ // dead session. Undefined for explicit --session-id (leaves pointer alone).
+ let resumePointerDir: string | undefined
+
+ const usedMultiSessionFeature =
+ parsedSpawnMode !== undefined ||
+ parsedCapacity !== undefined ||
+ parsedCreateSessionInDir !== undefined
+
+ // Validate permission mode early so the user gets an error before
+ // the bridge starts polling for work.
+ if (permissionMode !== undefined) {
+ const { PERMISSION_MODES } = await import('../types/permissions.js')
+ const valid: readonly string[] = PERMISSION_MODES
+ if (!valid.includes(permissionMode)) {
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+ }
+
+ const dir = resolve('.')
+
+ // The bridge fast-path bypasses init.ts, so we must enable config reading
+ // before any code that transitively calls getGlobalConfig()
+ const { enableConfigs, checkHasTrustDialogAccepted } = await import(
+ '../utils/config.js'
+ )
+ enableConfigs()
+
+ // Initialize analytics and error reporting sinks. The bridge bypasses the
+ // setup() init flow, so we call initSinks() directly to attach sinks here.
+ const { initSinks } = await import('../utils/sinks.js')
+ initSinks()
+
+ // Gate-aware validation: --spawn / --capacity / --create-session-in-dir require
+ // the multi-session gate. parseArgs has already validated flag combinations;
+ // here we only check the gate since that requires an async GrowthBook call.
+ // Runs after enableConfigs() (GrowthBook cache reads global config) and after
+ // initSinks() so the denial event can be enqueued.
+ const multiSessionEnabled = await isMultiSessionSpawnEnabled()
+ if (usedMultiSessionFeature && !multiSessionEnabled) {
+ await logEventAsync('tengu_bridge_multi_session_denied', {
+ used_spawn: parsedSpawnMode !== undefined,
+ used_capacity: parsedCapacity !== undefined,
+ used_create_session_in_dir: parsedCreateSessionInDir !== undefined,
+ })
+ // logEventAsync only enqueues — process.exit() discards buffered events.
+ // Flush explicitly, capped at 500ms to match gracefulShutdown.ts.
+ // (sleep() doesn't unref its timer, but process.exit() follows immediately
+ // so the ref'd timer can't delay shutdown.)
+ await Promise.race([
+ Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
+ sleep(500, undefined, { unref: true }),
+ ]).catch(() => {})
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ 'Error: Multi-session Remote Control is not enabled for your account yet.',
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ // Set the bootstrap CWD so that trust checks, project config lookups, and
+ // git utilities (getBranch, getRemoteUrl) resolve against the correct path.
+ const { setOriginalCwd, setCwdState } = await import('../bootstrap/state.js')
+ setOriginalCwd(dir)
+ setCwdState(dir)
+
+ // The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens),
+ // so we must verify trust was previously established by a normal `claude` session.
+ if (!checkHasTrustDialogAccepted()) {
+ // biome-ignore lint/suspicious/noConsole:: intentional console output
+ console.error(
+ `Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ // Resolve auth
+ const { clearOAuthTokenCache, checkAndRefreshOAuthTokenIfNeeded } =
+ await import('../utils/auth.js')
+ const { getBridgeAccessToken, getBridgeBaseUrl } = await import(
+ './bridgeConfig.js'
+ )
+
+ const bridgeToken = getBridgeAccessToken()
+ if (!bridgeToken) {
+ // biome-ignore lint/suspicious/noConsole:: intentional console output
+ console.error(BRIDGE_LOGIN_ERROR)
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ // First-time remote dialog — explain what bridge does and get consent
+ const {
+ getGlobalConfig,
+ saveGlobalConfig,
+ getCurrentProjectConfig,
+ saveCurrentProjectConfig,
+ } = await import('../utils/config.js')
+ if (!getGlobalConfig().remoteDialogSeen) {
+ const readline = await import('readline')
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ })
+ // biome-ignore lint/suspicious/noConsole:: intentional console output
+ console.log(
+ '\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n',
+ )
+ const answer = await new Promise(resolve => {
+ rl.question('Enable Remote Control? (y/n) ', resolve)
+ })
+ rl.close()
+ saveGlobalConfig(current => {
+ if (current.remoteDialogSeen) return current
+ return { ...current, remoteDialogSeen: true }
+ })
+ if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(0)
+ }
+ }
+
+ // --continue: resolve the most recent session from the crash-recovery
+ // pointer and chain into the #20460 --session-id flow. Worktree-aware:
+ // checks current dir first (fast path, zero exec), then fans out to git
+ // worktree siblings if that misses — the REPL bridge writes to
+ // getOriginalCwd() which EnterWorktreeTool/activeWorktreeSession can
+ // point at a worktree while the user's shell is at the repo root.
+ // KAIROS-gated at parseArgs — continueSession is always false in external
+ // builds, so this block tree-shakes.
+ if (feature('KAIROS') && continueSession) {
+ const { readBridgePointerAcrossWorktrees } = await import(
+ './bridgePointer.js'
+ )
+ const found = await readBridgePointerAcrossWorktrees(dir)
+ if (!found) {
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ `Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+ const { pointer, dir: pointerDir } = found
+ const ageMin = Math.round(pointer.ageMs / 60_000)
+ const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`
+ const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : ''
+ // biome-ignore lint/suspicious/noConsole: intentional info output
+ console.error(
+ `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`,
+ )
+ resumeSessionId = pointer.sessionId
+ // Track where the pointer came from so the #20460 exit(1) paths below
+ // clear the RIGHT file on deterministic failure — otherwise --continue
+ // would keep hitting the same dead session. May be a worktree sibling.
+ resumePointerDir = pointerDir
+ }
+
+ // In production, baseUrl is the Anthropic API (from OAuth config).
+ // CLAUDE_BRIDGE_BASE_URL overrides this for ant local dev only.
+ const baseUrl = getBridgeBaseUrl()
+
+ // For non-localhost targets, require HTTPS to protect credentials.
+ if (
+ baseUrl.startsWith('http://') &&
+ !baseUrl.includes('localhost') &&
+ !baseUrl.includes('127.0.0.1')
+ ) {
+ // biome-ignore lint/suspicious/noConsole:: intentional console output
+ console.error(
+ 'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.',
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ // Session ingress URL for WebSocket connections. In production this is the
+ // same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress).
+ // Locally, session-ingress runs on a different port (9413) than the
+ // contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
+ // set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
+ const sessionIngressUrl =
+ process.env.USER_TYPE === 'ant' &&
+ process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
+ ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
+ : baseUrl
+
+ const { getBranch, getRemoteUrl, findGitRoot } = await import(
+ '../utils/git.js'
+ )
+
+ // Precheck worktree availability for the first-run dialog and the `w`
+ // toggle. Unconditional so we know upfront whether worktree is an option.
+ const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
+ const worktreeAvailable = hasWorktreeCreateHook() || findGitRoot(dir) !== null
+
+ // Load saved per-project spawn-mode preference. Gated by multiSessionEnabled
+ // so a GrowthBook rollback cleanly reverts users to single-session —
+ // otherwise a saved pref would silently re-enable multi-session behavior
+ // (worktree isolation, 32 max sessions, w toggle) despite the gate being off.
+ // Also guard against a stale worktree pref left over from when this dir WAS
+ // a git repo (or the user copied config) — clear it on disk so the warning
+ // doesn't repeat on every launch.
+ let savedSpawnMode = multiSessionEnabled
+ ? getCurrentProjectConfig().remoteControlSpawnMode
+ : undefined
+ if (savedSpawnMode === 'worktree' && !worktreeAvailable) {
+ // biome-ignore lint/suspicious/noConsole: intentional warning output
+ console.error(
+ 'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.',
+ )
+ savedSpawnMode = undefined
+ saveCurrentProjectConfig(current => {
+ if (current.remoteControlSpawnMode === undefined) return current
+ return { ...current, remoteControlSpawnMode: undefined }
+ })
+ }
+
+ // First-run spawn-mode choice: ask once per project when the choice is
+ // meaningful (gate on, both modes available, no explicit override, not
+ // resuming). Saves to ProjectConfig so subsequent runs skip this.
+ if (
+ multiSessionEnabled &&
+ !savedSpawnMode &&
+ worktreeAvailable &&
+ parsedSpawnMode === undefined &&
+ !resumeSessionId &&
+ process.stdin.isTTY
+ ) {
+ const readline = await import('readline')
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ })
+ // biome-ignore lint/suspicious/noConsole: intentional dialog output
+ console.log(
+ `\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
+ `Spawn mode for this project:\n` +
+ ` [1] same-dir \u2014 sessions share the current directory (default)\n` +
+ ` [2] worktree \u2014 each session gets an isolated git worktree\n\n` +
+ `This can be changed later or explicitly set with --spawn=same-dir or --spawn=worktree.\n`,
+ )
+ const answer = await new Promise(resolve => {
+ rl.question('Choose [1/2] (default: 1): ', resolve)
+ })
+ rl.close()
+ const chosen: 'same-dir' | 'worktree' =
+ answer.trim() === '2' ? 'worktree' : 'same-dir'
+ savedSpawnMode = chosen
+ logEvent('tengu_bridge_spawn_mode_chosen', {
+ spawn_mode:
+ chosen as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ saveCurrentProjectConfig(current => {
+ if (current.remoteControlSpawnMode === chosen) return current
+ return { ...current, remoteControlSpawnMode: chosen }
+ })
+ }
+
+ // Determine effective spawn mode.
+ // Precedence: resume > explicit --spawn > saved project pref > gate default
+ // - resuming via --continue / --session-id: always single-session (resume
+ // targets one specific session in its original directory)
+ // - explicit --spawn flag: use that value directly (does not persist)
+ // - saved ProjectConfig.remoteControlSpawnMode: set by first-run dialog or `w`
+ // - default with gate on: same-dir (persistent multi-session, shared cwd)
+ // - default with gate off: single-session (unchanged legacy behavior)
+ // Track how spawn mode was determined, for rollout analytics.
+ type SpawnModeSource = 'resume' | 'flag' | 'saved' | 'gate_default'
+ let spawnModeSource: SpawnModeSource
+ let spawnMode: SpawnMode
+ if (resumeSessionId) {
+ spawnMode = 'single-session'
+ spawnModeSource = 'resume'
+ } else if (parsedSpawnMode !== undefined) {
+ spawnMode = parsedSpawnMode
+ spawnModeSource = 'flag'
+ } else if (savedSpawnMode !== undefined) {
+ spawnMode = savedSpawnMode
+ spawnModeSource = 'saved'
+ } else {
+ spawnMode = multiSessionEnabled ? 'same-dir' : 'single-session'
+ spawnModeSource = 'gate_default'
+ }
+ const maxSessions =
+ spawnMode === 'single-session'
+ ? 1
+ : (parsedCapacity ?? SPAWN_SESSIONS_DEFAULT)
+ // Pre-create an empty session on start so the user has somewhere to type
+ // immediately, running in the current directory (exempted from worktree
+ // creation in the spawn loop). On by default; --no-create-session-in-dir
+ // opts out for a pure on-demand server where every session is isolated.
+ // The effectiveResumeSessionId guard at the creation site handles the
+ // resume case (skip creation when resume succeeded; fall through to
+ // fresh creation on env-mismatch fallback).
+ const preCreateSession = parsedCreateSessionInDir ?? true
+
+ // Without --continue: a leftover pointer means the previous run didn't
+ // shut down cleanly (crash, kill -9, terminal closed). Clear it so the
+ // stale env doesn't linger past its relevance. Runs in all modes
+ // (clearBridgePointer is a no-op when no file exists) — covers the
+ // gate-transition case where a user crashed in single-session mode then
+ // starts fresh in worktree mode. Only single-session mode writes new
+ // pointers.
+ if (!resumeSessionId) {
+ const { clearBridgePointer } = await import('./bridgePointer.js')
+ await clearBridgePointer(dir)
+ }
+
+ // Worktree mode requires either git or WorktreeCreate/WorktreeRemove hooks.
+ // Only reachable via explicit --spawn=worktree (default is same-dir);
+ // saved worktree pref was already guarded above.
+ if (spawnMode === 'worktree' && !worktreeAvailable) {
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ `Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ const branch = await getBranch()
+ const gitRepoUrl = await getRemoteUrl()
+ const machineName = hostname()
+ const bridgeId = randomUUID()
+
+ const { handleOAuth401Error } = await import('../utils/auth.js')
+ const api = createBridgeApiClient({
+ baseUrl,
+ getAccessToken: getBridgeAccessToken,
+ runnerVersion: MACRO.VERSION,
+ onDebug: logForDebugging,
+ onAuth401: handleOAuth401Error,
+ getTrustedDeviceToken,
+ })
+
+ // When resuming a session via --session-id, fetch it to learn its
+ // environment_id and reuse that for registration (idempotent on the
+ // backend). Left undefined otherwise — the backend rejects
+ // client-generated UUIDs and will allocate a fresh environment.
+ // feature('KAIROS') gate: --session-id is ant-only; parseArgs already
+ // rejects the flag when the gate is off, so resumeSessionId is always
+ // undefined here in external builds — this guard is for tree-shaking.
+ let reuseEnvironmentId: string | undefined
+ if (feature('KAIROS') && resumeSessionId) {
+ try {
+ validateBridgeId(resumeSessionId, 'sessionId')
+ } catch {
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+ // Proactively refresh the OAuth token — getBridgeSession uses raw axios
+ // without the withOAuthRetry 401-refresh logic. An expired-but-present
+ // token would otherwise produce a misleading "not found" error.
+ await checkAndRefreshOAuthTokenIfNeeded()
+ clearOAuthTokenCache()
+ const { getBridgeSession } = await import('./createSession.js')
+ const session = await getBridgeSession(resumeSessionId, {
+ baseUrl,
+ getAccessToken: getBridgeAccessToken,
+ })
+ if (!session) {
+ // Session gone on server → pointer is stale. Clear it so the user
+ // isn't re-prompted next launch. (Explicit --session-id leaves the
+ // pointer alone — it's an independent file they may not even have.)
+ // resumePointerDir may be a worktree sibling — clear THAT file.
+ if (resumePointerDir) {
+ const { clearBridgePointer } = await import('./bridgePointer.js')
+ await clearBridgePointer(resumePointerDir)
+ }
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ `Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+ if (!session.environment_id) {
+ if (resumePointerDir) {
+ const { clearBridgePointer } = await import('./bridgePointer.js')
+ await clearBridgePointer(resumePointerDir)
+ }
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ `Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+ reuseEnvironmentId = session.environment_id
+ logForDebugging(
+ `[bridge:init] Resuming session ${resumeSessionId} on environment ${reuseEnvironmentId}`,
+ )
+ }
+
+ const config: BridgeConfig = {
+ dir,
+ machineName,
+ branch,
+ gitRepoUrl,
+ maxSessions,
+ spawnMode,
+ verbose,
+ sandbox,
+ bridgeId,
+ workerType: 'claude_code',
+ environmentId: randomUUID(),
+ reuseEnvironmentId,
+ apiBaseUrl: baseUrl,
+ sessionIngressUrl,
+ debugFile,
+ sessionTimeoutMs,
+ }
+
+ logForDebugging(
+ `[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir} branch=${branch} gitRepoUrl=${gitRepoUrl} machine=${machineName}`,
+ )
+ logForDebugging(
+ `[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`,
+ )
+ logForDebugging(
+ `[bridge:init] sandbox=${sandbox}${debugFile ? ` debugFile=${debugFile}` : ''}`,
+ )
+
+ // Register the bridge environment before entering the poll loop.
+ let environmentId: string
+ let environmentSecret: string
+ try {
+ const reg = await api.registerBridgeEnvironment(config)
+ environmentId = reg.environment_id
+ environmentSecret = reg.environment_secret
+ } catch (err) {
+ logEvent('tengu_bridge_registration_failed', {
+ status: err instanceof BridgeFatalError ? err.status : undefined,
+ })
+ // Registration failures are fatal — print a clean message instead of a stack trace.
+ // biome-ignore lint/suspicious/noConsole:: intentional console output
+ console.error(
+ err instanceof BridgeFatalError && err.status === 404
+ ? 'Remote Control environments are not available for your account.'
+ : `Error: ${errorMessage(err)}`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+
+ // Tracks whether the --session-id resume flow completed successfully.
+ // Used below to skip fresh session creation and seed initialSessionId.
+ // Cleared on env mismatch so we gracefully fall back to a new session.
+ let effectiveResumeSessionId: string | undefined
+ if (feature('KAIROS') && resumeSessionId) {
+ if (reuseEnvironmentId && environmentId !== reuseEnvironmentId) {
+ // Backend returned a different environment_id — the original env
+ // expired or was reaped. Reconnect won't work against the new env
+ // (session is bound to the old one). Log to sentry for visibility
+ // and fall through to fresh session creation on the new env.
+ logError(
+ new Error(
+ `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`,
+ ),
+ )
+ // biome-ignore lint/suspicious/noConsole: intentional warning output
+ console.warn(
+ `Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`,
+ )
+ // Don't deregister — we're going to use this new environment.
+ // effectiveResumeSessionId stays undefined → fresh session path below.
+ } else {
+ // Force-stop any stale worker instances for this session and re-queue
+ // it so our poll loop picks it up. Must happen after registration so
+ // the backend knows a live worker exists for the environment.
+ //
+ // The pointer stores a session_* ID but /bridge/reconnect looks
+ // sessions up by their infra tag (cse_*) when ccr_v2_compat_enabled
+ // is on. Try both; the conversion is a no-op if already cse_*.
+ const infraResumeId = toInfraSessionId(resumeSessionId)
+ const reconnectCandidates =
+ infraResumeId === resumeSessionId
+ ? [resumeSessionId]
+ : [resumeSessionId, infraResumeId]
+ let reconnected = false
+ let lastReconnectErr: unknown
+ for (const candidateId of reconnectCandidates) {
+ try {
+ await api.reconnectSession(environmentId, candidateId)
+ logForDebugging(
+ `[bridge:init] Session ${candidateId} re-queued via bridge/reconnect`,
+ )
+ effectiveResumeSessionId = resumeSessionId
+ reconnected = true
+ break
+ } catch (err) {
+ lastReconnectErr = err
+ logForDebugging(
+ `[bridge:init] reconnectSession(${candidateId}) failed: ${errorMessage(err)}`,
+ )
+ }
+ }
+ if (!reconnected) {
+ const err = lastReconnectErr
+
+ // Do NOT deregister on transient reconnect failure — at this point
+ // environmentId IS the session's own environment. Deregistering
+ // would make retry impossible. The backend's 4h TTL cleans up.
+ const isFatal = err instanceof BridgeFatalError
+ // Clear pointer only on fatal reconnect failure. Transient failures
+ // ("try running the same command again") should keep the pointer so
+ // next launch re-prompts — that IS the retry mechanism.
+ if (resumePointerDir && isFatal) {
+ const { clearBridgePointer } = await import('./bridgePointer.js')
+ await clearBridgePointer(resumePointerDir)
+ }
+ // biome-ignore lint/suspicious/noConsole: intentional error output
+ console.error(
+ isFatal
+ ? `Error: ${errorMessage(err)}`
+ : `Error: Failed to reconnect session ${resumeSessionId}: ${errorMessage(err)}\nThe session may still be resumable — try running the same command again.`,
+ )
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(1)
+ }
+ }
+ }
+
+ logForDebugging(
+ `[bridge:init] Registered, server environmentId=${environmentId}`,
+ )
+ const startupPollConfig = getPollIntervalConfig()
+ logEvent('tengu_bridge_started', {
+ max_sessions: config.maxSessions,
+ has_debug_file: !!config.debugFile,
+ sandbox: config.sandbox,
+ verbose: config.verbose,
+ heartbeat_interval_ms:
+ startupPollConfig.non_exclusive_heartbeat_interval_ms,
+ spawn_mode:
+ config.spawnMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ spawn_mode_source:
+ spawnModeSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ multi_session_gate: multiSessionEnabled,
+ pre_create_session: preCreateSession,
+ worktree_available: worktreeAvailable,
+ })
+ logForDiagnosticsNoPII('info', 'bridge_started', {
+ max_sessions: config.maxSessions,
+ sandbox: config.sandbox,
+ spawn_mode: config.spawnMode,
+ })
+
+ const spawner = createSessionSpawner({
+ execPath: process.execPath,
+ scriptArgs: spawnScriptArgs(),
+ env: process.env,
+ verbose,
+ sandbox,
+ debugFile,
+ permissionMode,
+ onDebug: logForDebugging,
+ onActivity: (sessionId, activity) => {
+ logForDebugging(
+ `[bridge:activity] sessionId=${sessionId} ${activity.type} ${activity.summary}`,
+ )
+ },
+ onPermissionRequest: (sessionId, request, _accessToken) => {
+ logForDebugging(
+ `[bridge:perm] sessionId=${sessionId} tool=${request.request.tool_name} request_id=${request.request_id} (not auto-approving)`,
+ )
+ },
+ })
+
+ const logger = createBridgeLogger({ verbose })
+ const { parseGitHubRepository } = await import('../utils/detectRepository.js')
+ const ownerRepo = gitRepoUrl ? parseGitHubRepository(gitRepoUrl) : null
+ // Use the repo name from the parsed owner/repo, or fall back to the dir basename
+ const repoName = ownerRepo ? ownerRepo.split('/').pop()! : basename(dir)
+ logger.setRepoInfo(repoName, branch)
+
+ // `w` toggle is available iff we're in a multi-session mode AND worktree
+ // is a valid option. When unavailable, the mode suffix and hint are hidden.
+ const toggleAvailable = spawnMode !== 'single-session' && worktreeAvailable
+ if (toggleAvailable) {
+ // Safe cast: spawnMode is not single-session (checked above), and the
+ // saved-worktree-in-non-git guard + exit check above ensure worktree
+ // is only reached when available.
+ logger.setSpawnModeDisplay(spawnMode as 'same-dir' | 'worktree')
+ }
+
+ // Listen for keys: space toggles QR code, w toggles spawn mode
+ const onStdinData = (data: Buffer): void => {
+ if (data[0] === 0x03 || data[0] === 0x04) {
+ // Ctrl+C / Ctrl+D — trigger graceful shutdown
+ process.emit('SIGINT')
+ return
+ }
+ if (data[0] === 0x20 /* space */) {
+ logger.toggleQr()
+ return
+ }
+ if (data[0] === 0x77 /* 'w' */) {
+ if (!toggleAvailable) return
+ const newMode: 'same-dir' | 'worktree' =
+ config.spawnMode === 'same-dir' ? 'worktree' : 'same-dir'
+ config.spawnMode = newMode
+ logEvent('tengu_bridge_spawn_mode_toggled', {
+ spawn_mode:
+ newMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ logger.logStatus(
+ newMode === 'worktree'
+ ? 'Spawn mode: worktree (new sessions get isolated git worktrees)'
+ : 'Spawn mode: same-dir (new sessions share the current directory)',
+ )
+ logger.setSpawnModeDisplay(newMode)
+ logger.refreshDisplay()
+ saveCurrentProjectConfig(current => {
+ if (current.remoteControlSpawnMode === newMode) return current
+ return { ...current, remoteControlSpawnMode: newMode }
+ })
+ return
+ }
+ }
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(true)
+ process.stdin.resume()
+ process.stdin.on('data', onStdinData)
+ }
+
+ const controller = new AbortController()
+ const onSigint = (): void => {
+ logForDebugging('[bridge:shutdown] SIGINT received, shutting down')
+ controller.abort()
+ }
+ const onSigterm = (): void => {
+ logForDebugging('[bridge:shutdown] SIGTERM received, shutting down')
+ controller.abort()
+ }
+ process.on('SIGINT', onSigint)
+ process.on('SIGTERM', onSigterm)
+
+ // Auto-create an empty session so the user has somewhere to type
+ // immediately (matching /remote-control behavior). Controlled by
+ // preCreateSession: on by default; --no-create-session-in-dir opts out.
+ // When a --session-id resume succeeded, skip creation entirely — the
+ // session already exists and bridge/reconnect has re-queued it.
+ // When resume was requested but failed on env mismatch, effectiveResumeSessionId
+ // is undefined, so we fall through to fresh session creation (honoring the
+ // "Creating a fresh session instead" warning printed above).
+ let initialSessionId: string | null =
+ feature('KAIROS') && effectiveResumeSessionId
+ ? effectiveResumeSessionId
+ : null
+ if (preCreateSession && !(feature('KAIROS') && effectiveResumeSessionId)) {
+ const { createBridgeSession } = await import('./createSession.js')
+ try {
+ initialSessionId = await createBridgeSession({
+ environmentId,
+ title: name,
+ events: [],
+ gitRepoUrl,
+ branch,
+ signal: controller.signal,
+ baseUrl,
+ getAccessToken: getBridgeAccessToken,
+ permissionMode,
+ })
+ if (initialSessionId) {
+ logForDebugging(
+ `[bridge:init] Created initial session ${initialSessionId}`,
+ )
+ }
+ } catch (err) {
+ logForDebugging(
+ `[bridge:init] Session creation failed (non-fatal): ${errorMessage(err)}`,
+ )
+ }
+ }
+
+ // Crash-recovery pointer: write immediately so kill -9 at any point
+ // after this leaves a recoverable trail. Covers both fresh sessions and
+ // resumed ones (so a second crash after resume is still recoverable).
+ // Cleared when runBridgeLoop falls through to archive+deregister; left in
+ // place on the SIGINT resumable-shutdown return (backup for when the user
+ // closes the terminal before copying the printed --session-id hint).
+ // Refreshed hourly so a 5h+ session that crashes still has a fresh
+ // pointer (staleness checks file mtime, backend TTL is rolling-from-poll).
+ let pointerRefreshTimer: ReturnType | null = null
+ // Single-session only: --continue forces single-session mode on resume,
+ // so a pointer written in multi-session mode would contradict the user's
+ // config when they try to resume. The resumable-shutdown path is also
+ // gated to single-session (line ~1254) so the pointer would be orphaned.
+ if (initialSessionId && spawnMode === 'single-session') {
+ const { writeBridgePointer } = await import('./bridgePointer.js')
+ const pointerPayload = {
+ sessionId: initialSessionId,
+ environmentId,
+ source: 'standalone' as const,
+ }
+ await writeBridgePointer(config.dir, pointerPayload)
+ pointerRefreshTimer = setInterval(
+ writeBridgePointer,
+ 60 * 60 * 1000,
+ config.dir,
+ pointerPayload,
+ )
+ // Don't let the interval keep the process alive on its own.
+ pointerRefreshTimer.unref?.()
+ }
+
+ try {
+ await runBridgeLoop(
+ config,
+ environmentId,
+ environmentSecret,
+ api,
+ spawner,
+ logger,
+ controller.signal,
+ undefined,
+ initialSessionId ?? undefined,
+ async () => {
+ // Clear the memoized OAuth token cache so we re-read from secure
+ // storage, picking up tokens refreshed by child processes.
+ clearOAuthTokenCache()
+ // Proactively refresh the token if it's expired on disk too.
+ await checkAndRefreshOAuthTokenIfNeeded()
+ return getBridgeAccessToken()
+ },
+ )
+ } finally {
+ if (pointerRefreshTimer !== null) {
+ clearInterval(pointerRefreshTimer)
+ }
+ process.off('SIGINT', onSigint)
+ process.off('SIGTERM', onSigterm)
+ process.stdin.off('data', onStdinData)
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(false)
+ }
+ process.stdin.pause()
+ }
+
+ // The bridge bypasses init.ts (and its graceful shutdown handler), so we
+ // must exit explicitly.
+ // eslint-disable-next-line custom-rules/no-process-exit
+ process.exit(0)
+}
+
+// ─── Headless bridge (daemon worker) ────────────────────────────────────────
+
+/**
+ * Thrown by runBridgeHeadless for configuration issues the supervisor should
+ * NOT retry (trust not accepted, worktree unavailable, http-not-https). The
+ * daemon worker catches this and exits with EXIT_CODE_PERMANENT so the
+ * supervisor parks the worker instead of respawning it on backoff.
+ */
+export class BridgeHeadlessPermanentError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'BridgeHeadlessPermanentError'
+ }
+}
+
+export type HeadlessBridgeOpts = {
+ dir: string
+ name?: string
+ spawnMode: 'same-dir' | 'worktree'
+ capacity: number
+ permissionMode?: string
+ sandbox: boolean
+ sessionTimeoutMs?: number
+ createSessionOnStart: boolean
+ getAccessToken: () => string | undefined
+ onAuth401: (failedToken: string) => Promise
+ log: (s: string) => void
+}
+
+/**
+ * Non-interactive bridge entrypoint for the `remoteControl` daemon worker.
+ *
+ * Linear subset of bridgeMain(): no readline dialogs, no stdin key handlers,
+ * no TUI, no process.exit(). Config comes from the caller (daemon.json), auth
+ * comes via IPC (supervisor's AuthManager), logs go to the worker's stdout
+ * pipe. Throws on fatal errors — the worker catches and maps permanent vs
+ * transient to the right exit code.
+ *
+ * Resolves cleanly when `signal` aborts and the poll loop tears down.
+ */
+export async function runBridgeHeadless(
+ opts: HeadlessBridgeOpts,
+ signal: AbortSignal,
+): Promise {
+ const { dir, log } = opts
+
+ // Worker inherits the supervisor's CWD. chdir first so git utilities
+ // (getBranch/getRemoteUrl) — which read from bootstrap CWD state set
+ // below — resolve against the right repo.
+ process.chdir(dir)
+ const { setOriginalCwd, setCwdState } = await import('../bootstrap/state.js')
+ setOriginalCwd(dir)
+ setCwdState(dir)
+
+ const { enableConfigs, checkHasTrustDialogAccepted } = await import(
+ '../utils/config.js'
+ )
+ enableConfigs()
+ const { initSinks } = await import('../utils/sinks.js')
+ initSinks()
+
+ if (!checkHasTrustDialogAccepted()) {
+ throw new BridgeHeadlessPermanentError(
+ `Workspace not trusted: ${dir}. Run \`claude\` in that directory first to accept the trust dialog.`,
+ )
+ }
+
+ if (!opts.getAccessToken()) {
+ // Transient — supervisor's AuthManager may pick up a token on next cycle.
+ throw new Error(BRIDGE_LOGIN_ERROR)
+ }
+
+ const { getBridgeBaseUrl } = await import('./bridgeConfig.js')
+ const baseUrl = getBridgeBaseUrl()
+ if (
+ baseUrl.startsWith('http://') &&
+ !baseUrl.includes('localhost') &&
+ !baseUrl.includes('127.0.0.1')
+ ) {
+ throw new BridgeHeadlessPermanentError(
+ 'Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.',
+ )
+ }
+ const sessionIngressUrl =
+ process.env.USER_TYPE === 'ant' &&
+ process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
+ ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
+ : baseUrl
+
+ const { getBranch, getRemoteUrl, findGitRoot } = await import(
+ '../utils/git.js'
+ )
+ const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
+
+ if (opts.spawnMode === 'worktree') {
+ const worktreeAvailable =
+ hasWorktreeCreateHook() || findGitRoot(dir) !== null
+ if (!worktreeAvailable) {
+ throw new BridgeHeadlessPermanentError(
+ `Worktree mode requires a git repository or WorktreeCreate hooks. Directory ${dir} has neither.`,
+ )
+ }
+ }
+
+ const branch = await getBranch()
+ const gitRepoUrl = await getRemoteUrl()
+ const machineName = hostname()
+ const bridgeId = randomUUID()
+
+ const config: BridgeConfig = {
+ dir,
+ machineName,
+ branch,
+ gitRepoUrl,
+ maxSessions: opts.capacity,
+ spawnMode: opts.spawnMode,
+ verbose: false,
+ sandbox: opts.sandbox,
+ bridgeId,
+ workerType: 'claude_code',
+ environmentId: randomUUID(),
+ apiBaseUrl: baseUrl,
+ sessionIngressUrl,
+ sessionTimeoutMs: opts.sessionTimeoutMs,
+ }
+
+ const api = createBridgeApiClient({
+ baseUrl,
+ getAccessToken: opts.getAccessToken,
+ runnerVersion: MACRO.VERSION,
+ onDebug: log,
+ onAuth401: opts.onAuth401,
+ getTrustedDeviceToken,
+ })
+
+ let environmentId: string
+ let environmentSecret: string
+ try {
+ const reg = await api.registerBridgeEnvironment(config)
+ environmentId = reg.environment_id
+ environmentSecret = reg.environment_secret
+ } catch (err) {
+ // Transient — let supervisor backoff-retry.
+ throw new Error(`Bridge registration failed: ${errorMessage(err)}`)
+ }
+
+ const spawner = createSessionSpawner({
+ execPath: process.execPath,
+ scriptArgs: spawnScriptArgs(),
+ env: process.env,
+ verbose: false,
+ sandbox: opts.sandbox,
+ permissionMode: opts.permissionMode,
+ onDebug: log,
+ })
+
+ const logger = createHeadlessBridgeLogger(log)
+ logger.printBanner(config, environmentId)
+
+ let initialSessionId: string | undefined
+ if (opts.createSessionOnStart) {
+ const { createBridgeSession } = await import('./createSession.js')
+ try {
+ const sid = await createBridgeSession({
+ environmentId,
+ title: opts.name,
+ events: [],
+ gitRepoUrl,
+ branch,
+ signal,
+ baseUrl,
+ getAccessToken: opts.getAccessToken,
+ permissionMode: opts.permissionMode,
+ })
+ if (sid) {
+ initialSessionId = sid
+ log(`created initial session ${sid}`)
+ }
+ } catch (err) {
+ log(`session pre-creation failed (non-fatal): ${errorMessage(err)}`)
+ }
+ }
+
+ await runBridgeLoop(
+ config,
+ environmentId,
+ environmentSecret,
+ api,
+ spawner,
+ logger,
+ signal,
+ undefined,
+ initialSessionId,
+ async () => opts.getAccessToken(),
+ )
+}
+
+/** BridgeLogger adapter that routes everything to a single line-log fn. */
+function createHeadlessBridgeLogger(log: (s: string) => void): BridgeLogger {
+ const noop = (): void => {}
+ return {
+ printBanner: (cfg, envId) =>
+ log(
+ `registered environmentId=${envId} dir=${cfg.dir} spawnMode=${cfg.spawnMode} capacity=${cfg.maxSessions}`,
+ ),
+ logSessionStart: (id, _prompt) => log(`session start ${id}`),
+ logSessionComplete: (id, ms) => log(`session complete ${id} (${ms}ms)`),
+ logSessionFailed: (id, err) => log(`session failed ${id}: ${err}`),
+ logStatus: log,
+ logVerbose: log,
+ logError: s => log(`error: ${s}`),
+ logReconnected: ms => log(`reconnected after ${ms}ms`),
+ addSession: (id, _url) => log(`session attached ${id}`),
+ removeSession: id => log(`session detached ${id}`),
+ updateIdleStatus: noop,
+ updateReconnectingStatus: noop,
+ updateSessionStatus: noop,
+ updateSessionActivity: noop,
+ updateSessionCount: noop,
+ updateFailedStatus: noop,
+ setSpawnModeDisplay: noop,
+ setRepoInfo: noop,
+ setDebugLogPath: noop,
+ setAttached: noop,
+ setSessionTitle: noop,
+ clearStatus: noop,
+ toggleQr: noop,
+ refreshDisplay: noop,
+ }
+}
+
+
diff --git a/src/bridge/bridgeMessaging.ts b/src/bridge/bridgeMessaging.ts
new file mode 100644
index 0000000..3360002
--- /dev/null
+++ b/src/bridge/bridgeMessaging.ts
@@ -0,0 +1,463 @@
+/**
+ * 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
+ * (, , 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) | 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()
+ private writeIdx = 0
+
+ constructor(capacity: number) {
+ this.capacity = capacity
+ this.ring = new Array(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
+ }
+}
+
+
diff --git a/src/bridge/bridgePermissionCallbacks.ts b/src/bridge/bridgePermissionCallbacks.ts
new file mode 100644
index 0000000..28e8ed1
--- /dev/null
+++ b/src/bridge/bridgePermissionCallbacks.ts
@@ -0,0 +1,45 @@
+import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
+
+type BridgePermissionResponse = {
+ behavior: 'allow' | 'deny'
+ updatedInput?: Record
+ updatedPermissions?: PermissionUpdate[]
+ message?: string
+}
+
+type BridgePermissionCallbacks = {
+ sendRequest(
+ requestId: string,
+ toolName: string,
+ input: Record,
+ 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 }
+
+
diff --git a/src/bridge/bridgePointer.ts b/src/bridge/bridgePointer.ts
new file mode 100644
index 0000000..62d7a70
--- /dev/null
+++ b/src/bridge/bridgePointer.ts
@@ -0,0 +1,212 @@
+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>
+
+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 {
+ 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 {
+ 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
+ }
+}
+
+
diff --git a/src/bridge/bridgeStatusUtil.ts b/src/bridge/bridgeStatusUtil.ts
new file mode 100644
index 0000000..39cedd9
--- /dev/null
+++ b/src/bridge/bridgeStatusUtil.ts
@@ -0,0 +1,165 @@
+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`
+}
+
+
diff --git a/src/bridge/bridgeUI.ts b/src/bridge/bridgeUI.ts
new file mode 100644
index 0000000..43d0cca
--- /dev/null
+++ b/src/bridge/bridgeUI.ts
@@ -0,0 +1,532 @@
+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 {
+ 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 | 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()
+ },
+ }
+}
+
+
diff --git a/src/bridge/capacityWake.ts b/src/bridge/capacityWake.ts
new file mode 100644
index 0000000..140cecd
--- /dev/null
+++ b/src/bridge/capacityWake.ts
@@ -0,0 +1,58 @@
+/**
+ * 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 }
+}
+
+
diff --git a/src/bridge/codeSessionApi.ts b/src/bridge/codeSessionApi.ts
new file mode 100644
index 0000000..b52f56d
--- /dev/null
+++ b/src/bridge/codeSessionApi.ts
@@ -0,0 +1,170 @@
+/**
+ * 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 {
+ 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 {
+ 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 {
+ 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,
+ }
+}
+
+
diff --git a/src/bridge/createSession.ts b/src/bridge/createSession.ts
new file mode 100644
index 0000000..1ebb3ec
--- /dev/null
+++ b/src/bridge/createSession.ts
@@ -0,0 +1,386 @@
+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: } 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 {
+ 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 {
+ 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 {
+ 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)}`,
+ )
+ }
+}
+
+
diff --git a/src/bridge/debugUtils.ts b/src/bridge/debugUtils.ts
new file mode 100644
index 0000000..88b1048
--- /dev/null
+++ b/src/bridge/debugUtils.ts
@@ -0,0 +1,143 @@
+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
+ const detail =
+ typeof data.message === 'string'
+ ? data.message
+ : typeof data.error === 'object' &&
+ data.error &&
+ 'message' in data.error &&
+ typeof (data.error as Record).message ===
+ 'string'
+ ? (data.error as Record).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 }),
+ })
+}
+
+
diff --git a/src/bridge/envLessBridgeConfig.ts b/src/bridge/envLessBridgeConfig.ts
new file mode 100644
index 0000000..c08c4c9
--- /dev/null
+++ b/src/bridge/envLessBridgeConfig.ts
@@ -0,0 +1,167 @@
+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 {
+ const raw = await getFeatureValue_DEPRECATED(
+ '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 {
+ 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 {
+ if (!isEnvLessBridgeEnabled()) return false
+ const cfg = await getEnvLessBridgeConfig()
+ return cfg.should_show_app_upgrade_message
+}
+
+
diff --git a/src/bridge/flushGate.ts b/src/bridge/flushGate.ts
new file mode 100644
index 0000000..8369427
--- /dev/null
+++ b/src/bridge/flushGate.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 {
+ 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
+ }
+}
+
+
diff --git a/src/bridge/inboundAttachments.ts b/src/bridge/inboundAttachments.ts
new file mode 100644
index 0000000..37c5d34
--- /dev/null
+++ b/src/bridge/inboundAttachments.ts
@@ -0,0 +1,177 @@
+/**
+ * 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>
+
+/** 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 {
+ 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 {
+ 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,
+ prefix: string,
+): string | Array {
+ 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,
+): Promise> {
+ const attachments = extractInboundAttachments(msg)
+ if (attachments.length === 0) return content
+ const prefix = await resolveInboundAttachments(attachments)
+ return prependPathRefs(content, prefix)
+}
+
+
diff --git a/src/bridge/inboundMessages.ts b/src/bridge/inboundMessages.ts
new file mode 100644
index 0000000..1e36ffb
--- /dev/null
+++ b/src/bridge/inboundMessages.ts
@@ -0,0 +1,82 @@
+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; 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,
+): Array {
+ if (!blocks.some(isMalformedBase64Image)) return blocks
+
+ return blocks.map(block => {
+ if (!isMalformedBase64Image(block)) return block
+ const src = block.source as unknown as Record
+ 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).media_type
+}
+
+
diff --git a/src/bridge/initReplBridge.ts b/src/bridge/initReplBridge.ts
new file mode 100644
index 0000000..b617590
--- /dev/null
+++ b/src/bridge/initReplBridge.ts
@@ -0,0 +1,571 @@
+/**
+ * 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
+ 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 `. 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
+ /** 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 {
+ 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 ({
+ ...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 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 ). Replaced by
+ * generateSessionTitle once Haiku resolves (~1-15s).
+ */
+function deriveTitle(raw: string): string | undefined {
+ // Strip , , 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
+}
+
+
diff --git a/src/bridge/jwtUtils.ts b/src/bridge/jwtUtils.ts
new file mode 100644
index 0000000..d485b40
--- /dev/null
+++ b/src/bridge/jwtUtils.ts
@@ -0,0 +1,258 @@
+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
+ 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>()
+ const failureCounts = new Map()
+ // 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()
+
+ 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 {
+ 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 }
+}
+
+
diff --git a/src/bridge/pollConfig.ts b/src/bridge/pollConfig.ts
new file mode 100644
index 0000000..2e63a8f
--- /dev/null
+++ b/src/bridge/pollConfig.ts
@@ -0,0 +1,112 @@
+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(
+ 'tengu_bridge_poll_interval_config',
+ DEFAULT_POLL_CONFIG,
+ 5 * 60 * 1000,
+ )
+ const parsed = pollIntervalConfigSchema().safeParse(raw)
+ return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG
+}
+
+
diff --git a/src/bridge/pollConfigDefaults.ts b/src/bridge/pollConfigDefaults.ts
new file mode 100644
index 0000000..c1a561a
--- /dev/null
+++ b/src/bridge/pollConfigDefaults.ts
@@ -0,0 +1,84 @@
+/**
+ * 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,
+}
+
+
diff --git a/src/bridge/remoteBridgeCore.ts b/src/bridge/remoteBridgeCore.ts
new file mode 100644
index 0000000..7dc7dc5
--- /dev/null
+++ b/src/bridge/remoteBridgeCore.ts
@@ -0,0 +1,1010 @@
+// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
+/**
+ * Env-less Remote Control bridge core.
+ *
+ * "Env-less" = no Environments API layer. Distinct from "CCR v2" (the
+ * /worker/* transport protocol) — the env-based path (replBridge.ts) can also
+ * use CCR v2 transport via CLAUDE_CODE_USE_CCR_V2. This file is about removing
+ * the poll/dispatch layer, not about which transport protocol is underneath.
+ *
+ * Unlike initBridgeCore (env-based, ~2400 lines), this connects directly
+ * to the session-ingress layer without the Environments API work-dispatch
+ * layer:
+ *
+ * 1. POST /v1/code/sessions (OAuth, no env_id) → session.id
+ * 2. POST /v1/code/sessions/{id}/bridge (OAuth) → {worker_jwt, expires_in, api_base_url, worker_epoch}
+ * Each /bridge call bumps epoch — it IS the register. No separate /worker/register.
+ * 3. createV2ReplTransport(worker_jwt, worker_epoch) → SSE + CCRClient
+ * 4. createTokenRefreshScheduler → proactive /bridge re-call (new JWT + new epoch)
+ * 5. 401 on SSE → rebuild transport with fresh /bridge credentials (same seq-num)
+ *
+ * No register/poll/ack/stop/heartbeat/deregister environment lifecycle.
+ * The Environments API historically existed because CCR's /worker/*
+ * endpoints required a session_id+role=worker JWT that only the work-dispatch
+ * layer could mint. Server PR #292605 (renamed in #293280) adds the /bridge endpoint as a direct
+ * OAuth→worker_jwt exchange, making the env layer optional for REPL sessions.
+ *
+ * Gated by `tengu_bridge_repl_v2` GrowthBook flag in initReplBridge.ts.
+ * REPL-only — daemon/print stay on env-based.
+ */
+
+import { feature } from 'bun:bundle'
+import axios from 'axios'
+import {
+ createV2ReplTransport,
+ type ReplBridgeTransport,
+} from './replBridgeTransport.js'
+import { buildCCRv2SdkUrl } from './workSecret.js'
+import { toCompatSessionId } from './sessionIdCompat.js'
+import { FlushGate } from './flushGate.js'
+import { createTokenRefreshScheduler } from './jwtUtils.js'
+import { getTrustedDeviceToken } from './trustedDevice.js'
+import {
+ getEnvLessBridgeConfig,
+ type EnvLessBridgeConfig,
+} from './envLessBridgeConfig.js'
+import {
+ handleIngressMessage,
+ handleServerControlRequest,
+ makeResultMessage,
+ isEligibleBridgeMessage,
+ extractTitleText,
+ BoundedUUIDSet,
+} from './bridgeMessaging.js'
+import { logBridgeSkip } from './debugUtils.js'
+import { logForDebugging } from '../utils/debug.js'
+import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
+import { isInProtectedNamespace } from '../utils/envUtils.js'
+import { errorMessage } from '../utils/errors.js'
+import { sleep } from '../utils/sleep.js'
+import { registerCleanup } from '../utils/cleanupRegistry.js'
+import {
+ type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ logEvent,
+} from '../services/analytics/index.js'
+import type { ReplBridgeHandle, BridgeState } from './replBridge.js'
+import type { Message } from '../types/message.js'
+import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
+import type {
+ SDKControlRequest,
+ SDKControlResponse,
+} from '../entrypoints/sdk/controlTypes.js'
+import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
+
+const ANTHROPIC_VERSION = '2023-06-01'
+
+// Telemetry discriminator for ws_connected. 'initial' is the default and
+// never passed to rebuildTransport (which can only be called post-init);
+// Exclude<> makes that constraint explicit at both signatures.
+type ConnectCause = 'initial' | 'proactive_refresh' | 'auth_401_recovery'
+
+function oauthHeaders(accessToken: string): Record {
+ return {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ 'anthropic-version': ANTHROPIC_VERSION,
+ }
+}
+
+export type EnvLessBridgeParams = {
+ baseUrl: string
+ orgUUID: string
+ title: string
+ getAccessToken: () => string | undefined
+ onAuth401?: (staleAccessToken: string) => Promise
+ /**
+ * Converts internal Message[] → SDKMessage[] for writeMessages() and the
+ * initial-flush/drain paths. Injected rather than imported — mappers.ts
+ * transitively pulls in src/commands.ts (entire command registry + React
+ * tree) which would bloat bundles that don't already have it.
+ */
+ toSDKMessages: (messages: Message[]) => SDKMessage[]
+ initialHistoryCap: number
+ initialMessages?: Message[]
+ onInboundMessage?: (msg: SDKMessage) => void | Promise
+ /**
+ * Fired on each title-worthy user message seen in writeMessages() until
+ * the callback returns true (done). Mirrors replBridge.ts's onUserMessage —
+ * caller derives a title and PATCHes /v1/sessions/{id} so auto-started
+ * sessions don't stay at the generic fallback. The caller owns the
+ * derive-at-count-1-and-3 policy; the transport just keeps calling until
+ * told to stop. sessionId is the raw cse_* — updateBridgeSessionTitle
+ * retags internally.
+ */
+ onUserMessage?: (text: string, sessionId: string) => boolean
+ 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
+ /**
+ * When true, skip opening the SSE read stream — only the CCRClient write
+ * path is activated. Threaded to createV2ReplTransport and
+ * handleServerControlRequest.
+ */
+ outboundOnly?: boolean
+ /** Free-form tags for session categorization (e.g. ['ccr-mirror']). */
+ tags?: string[]
+}
+
+/**
+ * Create a session, fetch a worker JWT, connect the v2 transport.
+ *
+ * Returns null on any pre-flight failure (session create failed, /bridge
+ * failed, transport setup failed). Caller (initReplBridge) surfaces this
+ * as a generic "initialization failed" state.
+ */
+export async function initEnvLessBridgeCore(
+ params: EnvLessBridgeParams,
+): Promise {
+ const {
+ baseUrl,
+ orgUUID,
+ title,
+ getAccessToken,
+ onAuth401,
+ toSDKMessages,
+ initialHistoryCap,
+ initialMessages,
+ onInboundMessage,
+ onUserMessage,
+ onPermissionResponse,
+ onInterrupt,
+ onSetModel,
+ onSetMaxThinkingTokens,
+ onSetPermissionMode,
+ onStateChange,
+ outboundOnly,
+ tags,
+ } = params
+
+ const cfg = await getEnvLessBridgeConfig()
+
+ // ── 1. Create session (POST /v1/code/sessions, no env_id) ───────────────
+ const accessToken = getAccessToken()
+ if (!accessToken) {
+ logForDebugging('[remote-bridge] No OAuth token')
+ return null
+ }
+
+ const createdSessionId = await withRetry(
+ () =>
+ createCodeSession(baseUrl, accessToken, title, cfg.http_timeout_ms, tags),
+ 'createCodeSession',
+ cfg,
+ )
+ if (!createdSessionId) {
+ onStateChange?.('failed', 'Session creation failed — see debug log')
+ logBridgeSkip('v2_session_create_failed', undefined, true)
+ return null
+ }
+ const sessionId: string = createdSessionId
+ logForDebugging(`[remote-bridge] Created session ${sessionId}`)
+ logForDiagnosticsNoPII('info', 'bridge_repl_v2_session_created')
+
+ // ── 2. Fetch bridge credentials (POST /bridge → worker_jwt, expires_in, api_base_url) ──
+ const credentials = await withRetry(
+ () =>
+ fetchRemoteCredentials(
+ sessionId,
+ baseUrl,
+ accessToken,
+ cfg.http_timeout_ms,
+ ),
+ 'fetchRemoteCredentials',
+ cfg,
+ )
+ if (!credentials) {
+ onStateChange?.('failed', 'Remote credentials fetch failed — see debug log')
+ logBridgeSkip('v2_remote_creds_failed', undefined, true)
+ void archiveSession(
+ sessionId,
+ baseUrl,
+ accessToken,
+ orgUUID,
+ cfg.http_timeout_ms,
+ )
+ return null
+ }
+ logForDebugging(
+ `[remote-bridge] Fetched bridge credentials (expires_in=${credentials.expires_in}s)`,
+ )
+
+ // ── 3. Build v2 transport (SSETransport + CCRClient) ────────────────────
+ const sessionUrl = buildCCRv2SdkUrl(credentials.api_base_url, sessionId)
+ logForDebugging(`[remote-bridge] v2 session URL: ${sessionUrl}`)
+
+ let transport: ReplBridgeTransport
+ try {
+ transport = await createV2ReplTransport({
+ sessionUrl,
+ ingressToken: credentials.worker_jwt,
+ sessionId,
+ epoch: credentials.worker_epoch,
+ heartbeatIntervalMs: cfg.heartbeat_interval_ms,
+ heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
+ // Per-instance closure — keeps the worker JWT out of
+ // process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN, which mcp/client.ts
+ // reads ungatedly and would otherwise send to user-configured ws/http
+ // MCP servers. Frozen-at-construction is correct: transport is fully
+ // rebuilt on refresh (rebuildTransport below).
+ getAuthToken: () => credentials.worker_jwt,
+ outboundOnly,
+ })
+ } catch (err) {
+ logForDebugging(
+ `[remote-bridge] v2 transport setup failed: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ onStateChange?.('failed', `Transport setup failed: ${errorMessage(err)}`)
+ logBridgeSkip('v2_transport_setup_failed', undefined, true)
+ void archiveSession(
+ sessionId,
+ baseUrl,
+ accessToken,
+ orgUUID,
+ cfg.http_timeout_ms,
+ )
+ return null
+ }
+ logForDebugging(
+ `[remote-bridge] v2 transport created (epoch=${credentials.worker_epoch})`,
+ )
+ onStateChange?.('ready')
+
+ // ── 4. State ────────────────────────────────────────────────────────────
+
+ // Echo dedup: messages we POST come back on the read stream. Seeded with
+ // initial message UUIDs so server echoes of flushed history are recognized.
+ // Both sets cover initial UUIDs — recentPostedUUIDs is a 2000-cap ring buffer
+ // and could evict them after enough live writes; initialMessageUUIDs is the
+ // unbounded fallback. Defense-in-depth; mirrors replBridge.ts.
+ const recentPostedUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)
+ const initialMessageUUIDs = new Set()
+ if (initialMessages) {
+ for (const msg of initialMessages) {
+ initialMessageUUIDs.add(msg.uuid)
+ recentPostedUUIDs.add(msg.uuid)
+ }
+ }
+
+ // Defensive dedup for re-delivered inbound prompts (seq-num negotiation
+ // edge cases, server history replay after transport swap).
+ const recentInboundUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)
+
+ // FlushGate: queue live writes while the history flush POST is in flight,
+ // so the server receives [history..., live...] in order.
+ const flushGate = new FlushGate()
+
+ let initialFlushDone = false
+ let tornDown = false
+ let authRecoveryInFlight = false
+ // Latch for onUserMessage — flips true when the callback returns true
+ // (policy says "done deriving"). sessionId is const (no re-create path —
+ // rebuildTransport swaps JWT/epoch, same session), so no reset needed.
+ let userMessageCallbackDone = !onUserMessage
+
+ // Telemetry: why did onConnect fire? Set by rebuildTransport before
+ // wireTransportCallbacks; read asynchronously by onConnect. Race-safe
+ // because authRecoveryInFlight serializes rebuild callers, and a fresh
+ // initEnvLessBridgeCore() call gets a fresh closure defaulting to 'initial'.
+ let connectCause: ConnectCause = 'initial'
+
+ // Deadline for onConnect after transport.connect(). Cleared by onConnect
+ // (connected) and onClose (got a close — not silent). If neither fires
+ // before cfg.connect_timeout_ms, onConnectTimeout emits — the only
+ // signal for the `started → (silence)` gap.
+ let connectDeadline: ReturnType | undefined
+ function onConnectTimeout(cause: ConnectCause): void {
+ if (tornDown) return
+ logEvent('tengu_bridge_repl_connect_timeout', {
+ v2: true,
+ elapsed_ms: cfg.connect_timeout_ms,
+ cause:
+ cause as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ }
+
+ // ── 5. JWT refresh scheduler ────────────────────────────────────────────
+ // Schedule a callback 5min before expiry (per response.expires_in). On fire,
+ // re-fetch /bridge with OAuth → rebuild transport with fresh credentials.
+ // Each /bridge call bumps epoch server-side, so a JWT-only swap would leave
+ // the old CCRClient heartbeating with a stale epoch → 409 within 20s.
+ // JWT is opaque — do not decode.
+ const refresh = createTokenRefreshScheduler({
+ refreshBufferMs: cfg.token_refresh_buffer_ms,
+ getAccessToken: async () => {
+ // Unconditionally refresh OAuth before calling /bridge — getAccessToken()
+ // returns expired tokens as non-null strings (doesn't check expiresAt),
+ // so truthiness doesn't mean valid. Pass the stale token to onAuth401
+ // so handleOAuth401Error's keychain-comparison can detect parallel refresh.
+ const stale = getAccessToken()
+ if (onAuth401) await onAuth401(stale ?? '')
+ return getAccessToken() ?? stale
+ },
+ onRefresh: (sid, oauthToken) => {
+ void (async () => {
+ // Laptop wake: overdue proactive timer + SSE 401 fire ~simultaneously.
+ // Claim the flag BEFORE the /bridge fetch so the other path skips
+ // entirely — prevents double epoch bump (each /bridge call bumps; if
+ // both fetch, the first rebuild gets a stale epoch and 409s).
+ if (authRecoveryInFlight || tornDown) {
+ logForDebugging(
+ '[remote-bridge] Recovery already in flight, skipping proactive refresh',
+ )
+ return
+ }
+ authRecoveryInFlight = true
+ try {
+ const fresh = await withRetry(
+ () =>
+ fetchRemoteCredentials(
+ sid,
+ baseUrl,
+ oauthToken,
+ cfg.http_timeout_ms,
+ ),
+ 'fetchRemoteCredentials (proactive)',
+ cfg,
+ )
+ if (!fresh || tornDown) return
+ await rebuildTransport(fresh, 'proactive_refresh')
+ logForDebugging(
+ '[remote-bridge] Transport rebuilt (proactive refresh)',
+ )
+ } catch (err) {
+ logForDebugging(
+ `[remote-bridge] Proactive refresh rebuild failed: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ logForDiagnosticsNoPII(
+ 'error',
+ 'bridge_repl_v2_proactive_refresh_failed',
+ )
+ if (!tornDown) {
+ onStateChange?.('failed', `Refresh failed: ${errorMessage(err)}`)
+ }
+ } finally {
+ authRecoveryInFlight = false
+ }
+ })()
+ },
+ label: 'remote',
+ })
+ refresh.scheduleFromExpiresIn(sessionId, credentials.expires_in)
+
+ // ── 6. Wire callbacks (extracted so transport-rebuild can re-wire) ──────
+ function wireTransportCallbacks(): void {
+ transport.setOnConnect(() => {
+ clearTimeout(connectDeadline)
+ logForDebugging('[remote-bridge] v2 transport connected')
+ logForDiagnosticsNoPII('info', 'bridge_repl_v2_transport_connected')
+ logEvent('tengu_bridge_repl_ws_connected', {
+ v2: true,
+ cause:
+ connectCause as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+
+ if (!initialFlushDone && initialMessages && initialMessages.length > 0) {
+ initialFlushDone = true
+ // Capture current transport — if 401/teardown happens mid-flush,
+ // the stale .finally() must not drain the gate or signal connected.
+ // (Same guard pattern as replBridge.ts:1119.)
+ const flushTransport = transport
+ void flushHistory(initialMessages)
+ .catch(e =>
+ logForDebugging(`[remote-bridge] flushHistory failed: ${e}`),
+ )
+ .finally(() => {
+ // authRecoveryInFlight catches the v1-vs-v2 asymmetry: v1 nulls
+ // transport synchronously in setOnClose (replBridge.ts:1175), so
+ // transport !== flushTransport trips immediately. v2 doesn't null —
+ // transport reassigned only at rebuildTransport:346, 3 awaits deep.
+ // authRecoveryInFlight is set synchronously at rebuildTransport entry.
+ if (
+ transport !== flushTransport ||
+ tornDown ||
+ authRecoveryInFlight
+ ) {
+ return
+ }
+ drainFlushGate()
+ onStateChange?.('connected')
+ })
+ } else if (!flushGate.active) {
+ onStateChange?.('connected')
+ }
+ })
+
+ transport.setOnData((data: string) => {
+ handleIngressMessage(
+ data,
+ recentPostedUUIDs,
+ recentInboundUUIDs,
+ onInboundMessage,
+ // Remote client answered the permission prompt — the turn resumes.
+ // Without this the server stays on requires_action until the next
+ // user message or turn-end result.
+ onPermissionResponse
+ ? res => {
+ transport.reportState('running')
+ onPermissionResponse(res)
+ }
+ : undefined,
+ req =>
+ handleServerControlRequest(req, {
+ transport,
+ sessionId,
+ onInterrupt,
+ onSetModel,
+ onSetMaxThinkingTokens,
+ onSetPermissionMode,
+ outboundOnly,
+ }),
+ )
+ })
+
+ transport.setOnClose((code?: number) => {
+ clearTimeout(connectDeadline)
+ if (tornDown) return
+ logForDebugging(`[remote-bridge] v2 transport closed (code=${code})`)
+ logEvent('tengu_bridge_repl_ws_closed', { code, v2: true })
+ // onClose fires only for TERMINAL failures: 401 (JWT invalid),
+ // 4090 (CCR epoch mismatch), 4091 (CCR init failed), or SSE 10-min
+ // reconnect budget exhausted. Transient disconnects are handled
+ // transparently inside SSETransport. 401 we can recover from (fetch
+ // fresh JWT, rebuild transport); all other codes are dead-ends.
+ if (code === 401 && !authRecoveryInFlight) {
+ void recoverFromAuthFailure()
+ return
+ }
+ onStateChange?.('failed', `Transport closed (code ${code})`)
+ })
+ }
+
+ // ── 7. Transport rebuild (shared by proactive refresh + 401 recovery) ──
+ // Every /bridge call bumps epoch server-side. Both refresh paths must
+ // rebuild the transport with the new epoch — a JWT-only swap leaves the
+ // old CCRClient heartbeating stale epoch → 409. SSE resumes from the old
+ // transport's high-water-mark seq-num so no server-side replay.
+ // Caller MUST set authRecoveryInFlight = true before calling (synchronously,
+ // before any await) and clear it in a finally. This function doesn't manage
+ // the flag — moving it here would be too late to prevent a double /bridge
+ // fetch, and each fetch bumps epoch.
+ async function rebuildTransport(
+ fresh: RemoteCredentials,
+ cause: Exclude,
+ ): Promise {
+ connectCause = cause
+ // Queue writes during rebuild — once /bridge returns, the old transport's
+ // epoch is stale and its next write/heartbeat 409s. Without this gate,
+ // writeMessages adds UUIDs to recentPostedUUIDs then writeBatch silently
+ // no-ops (closed uploader after 409) → permanent silent message loss.
+ flushGate.start()
+ try {
+ const seq = transport.getLastSequenceNum()
+ transport.close()
+ transport = await createV2ReplTransport({
+ sessionUrl: buildCCRv2SdkUrl(fresh.api_base_url, sessionId),
+ ingressToken: fresh.worker_jwt,
+ sessionId,
+ epoch: fresh.worker_epoch,
+ heartbeatIntervalMs: cfg.heartbeat_interval_ms,
+ heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
+ initialSequenceNum: seq,
+ getAuthToken: () => fresh.worker_jwt,
+ outboundOnly,
+ })
+ if (tornDown) {
+ // Teardown fired during the async createV2ReplTransport window.
+ // Don't wire/connect/schedule — we'd re-arm timers after cancelAll()
+ // and fire onInboundMessage into a torn-down bridge.
+ transport.close()
+ return
+ }
+ wireTransportCallbacks()
+ transport.connect()
+ connectDeadline = setTimeout(
+ onConnectTimeout,
+ cfg.connect_timeout_ms,
+ connectCause,
+ )
+ refresh.scheduleFromExpiresIn(sessionId, fresh.expires_in)
+ // Drain queued writes into the new uploader. Runs before
+ // ccr.initialize() resolves (transport.connect() is fire-and-forget),
+ // but the uploader serializes behind the initial PUT /worker. If
+ // init fails (4091), events drop — but only recentPostedUUIDs
+ // (per-instance) is populated, so re-enabling the bridge re-flushes.
+ drainFlushGate()
+ } finally {
+ // End the gate on failure paths too — drainFlushGate already ended
+ // it on success. Queued messages are dropped (transport still dead).
+ flushGate.drop()
+ }
+ }
+
+ // ── 8. 401 recovery (OAuth refresh + rebuild) ───────────────────────────
+ async function recoverFromAuthFailure(): Promise {
+ // setOnClose already guards `!authRecoveryInFlight` but that check and
+ // this set must be atomic against onRefresh — claim synchronously before
+ // any await. Laptop wake fires both paths ~simultaneously.
+ if (authRecoveryInFlight) return
+ authRecoveryInFlight = true
+ onStateChange?.('reconnecting', 'JWT expired — refreshing')
+ logForDebugging('[remote-bridge] 401 on SSE — attempting JWT refresh')
+ try {
+ // Unconditionally try OAuth refresh — getAccessToken() returns expired
+ // tokens as non-null strings, so !oauthToken doesn't catch expiry.
+ // Pass the stale token so handleOAuth401Error's keychain-comparison
+ // can detect if another tab already refreshed.
+ const stale = getAccessToken()
+ if (onAuth401) await onAuth401(stale ?? '')
+ const oauthToken = getAccessToken() ?? stale
+ if (!oauthToken || tornDown) {
+ if (!tornDown) {
+ onStateChange?.('failed', 'JWT refresh failed: no OAuth token')
+ }
+ return
+ }
+
+ const fresh = await withRetry(
+ () =>
+ fetchRemoteCredentials(
+ sessionId,
+ baseUrl,
+ oauthToken,
+ cfg.http_timeout_ms,
+ ),
+ 'fetchRemoteCredentials (recovery)',
+ cfg,
+ )
+ if (!fresh || tornDown) {
+ if (!tornDown) {
+ onStateChange?.('failed', 'JWT refresh failed after 401')
+ }
+ return
+ }
+ // If 401 interrupted the initial flush, writeBatch may have silently
+ // no-op'd on the closed uploader (ccr.close() ran in the SSE wrapper
+ // before our setOnClose callback). Reset so the new onConnect re-flushes.
+ // (v1 scopes initialFlushDone inside the per-transport closure at
+ // replBridge.ts:1027 so it resets naturally; v2 has it at outer scope.)
+ initialFlushDone = false
+ await rebuildTransport(fresh, 'auth_401_recovery')
+ logForDebugging('[remote-bridge] Transport rebuilt after 401')
+ } catch (err) {
+ logForDebugging(
+ `[remote-bridge] 401 recovery failed: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed')
+ if (!tornDown) {
+ onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`)
+ }
+ } finally {
+ authRecoveryInFlight = false
+ }
+ }
+
+ wireTransportCallbacks()
+
+ // Start flushGate BEFORE connect so writeMessages() during handshake
+ // queues instead of racing the history POST.
+ if (initialMessages && initialMessages.length > 0) {
+ flushGate.start()
+ }
+ transport.connect()
+ connectDeadline = setTimeout(
+ onConnectTimeout,
+ cfg.connect_timeout_ms,
+ connectCause,
+ )
+
+ // ── 8. History flush + drain helpers ────────────────────────────────────
+ function drainFlushGate(): void {
+ const msgs = flushGate.end()
+ if (msgs.length === 0) return
+ for (const msg of msgs) recentPostedUUIDs.add(msg.uuid)
+ const events = toSDKMessages(msgs).map(m => ({
+ ...m,
+ session_id: sessionId,
+ }))
+ if (msgs.some(m => m.type === 'user')) {
+ transport.reportState('running')
+ }
+ logForDebugging(
+ `[remote-bridge] Drained ${msgs.length} queued message(s) after flush`,
+ )
+ void transport.writeBatch(events)
+ }
+
+ async function flushHistory(msgs: Message[]): Promise {
+ // v2 always creates a fresh server session (unconditional createCodeSession
+ // above) — no session reuse, no double-post risk. Unlike v1, we do NOT
+ // filter by previouslyFlushedUUIDs: that set persists across REPL enable/
+ // disable cycles (useRef), so it would wrongly suppress history on re-enable.
+ const eligible = msgs.filter(isEligibleBridgeMessage)
+ const capped =
+ initialHistoryCap > 0 && eligible.length > initialHistoryCap
+ ? eligible.slice(-initialHistoryCap)
+ : eligible
+ if (capped.length < eligible.length) {
+ logForDebugging(
+ `[remote-bridge] Capped initial flush: ${eligible.length} -> ${capped.length} (cap=${initialHistoryCap})`,
+ )
+ }
+ const events = toSDKMessages(capped).map(m => ({
+ ...m,
+ session_id: sessionId,
+ }))
+ if (events.length === 0) return
+ // Mid-turn init: if Remote Control is enabled while a query is running,
+ // the last eligible message is a user prompt or tool_result (both 'user'
+ // type). Without this the init PUT's 'idle' sticks until the next user-
+ // type message forwards via writeMessages — which for a pure-text turn
+ // is never (only assistant chunks stream post-init). Check eligible (pre-
+ // cap), not capped: the cap may truncate to a user message even when the
+ // actual trailing message is assistant.
+ if (eligible.at(-1)?.type === 'user') {
+ transport.reportState('running')
+ }
+ logForDebugging(`[remote-bridge] Flushing ${events.length} history events`)
+ await transport.writeBatch(events)
+ }
+
+ // ── 9. Teardown ───────────────────────────────────────────────────────────
+ // On SIGINT/SIGTERM//exit, gracefulShutdown races runCleanupFunctions()
+ // against a 2s cap before forceExit kills the process. Budget accordingly:
+ // - archive: teardown_archive_timeout_ms (default 1500, cap 2000)
+ // - result write: fire-and-forget, archive latency covers the drain
+ // - 401 retry: only if first archive 401s, shares the same budget
+ async function teardown(): Promise {
+ if (tornDown) return
+ tornDown = true
+ refresh.cancelAll()
+ clearTimeout(connectDeadline)
+ flushGate.drop()
+
+ // Fire the result message before archive — transport.write() only awaits
+ // enqueue (SerialBatchEventUploader resolves once buffered, drain is
+ // async). Archiving before close() gives the uploader's drain loop a
+ // window (typical archive ≈ 100-500ms) to POST the result without an
+ // explicit sleep. close() sets closed=true which interrupts drain at the
+ // next while-check, so close-before-archive drops the result.
+ transport.reportState('idle')
+ void transport.write(makeResultMessage(sessionId))
+
+ let token = getAccessToken()
+ let status = await archiveSession(
+ sessionId,
+ baseUrl,
+ token,
+ orgUUID,
+ cfg.teardown_archive_timeout_ms,
+ )
+
+ // Token is usually fresh (refresh scheduler runs 5min before expiry) but
+ // laptop-wake past the refresh window leaves getAccessToken() returning a
+ // stale string. Retry once on 401 — onAuth401 (= handleOAuth401Error)
+ // clears keychain cache + force-refreshes. No proactive refresh on the
+ // happy path: handleOAuth401Error force-refreshes even valid tokens,
+ // which would waste budget 99% of the time. try/catch mirrors
+ // recoverFromAuthFailure: keychain reads can throw (macOS locked after
+ // wake); an uncaught throw here would skip transport.close + telemetry.
+ if (status === 401 && onAuth401) {
+ try {
+ await onAuth401(token ?? '')
+ token = getAccessToken()
+ status = await archiveSession(
+ sessionId,
+ baseUrl,
+ token,
+ orgUUID,
+ cfg.teardown_archive_timeout_ms,
+ )
+ } catch (err) {
+ logForDebugging(
+ `[remote-bridge] Teardown 401 retry threw: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ }
+ }
+
+ transport.close()
+
+ const archiveStatus: ArchiveTelemetryStatus =
+ status === 'no_token'
+ ? 'skipped_no_token'
+ : status === 'timeout' || status === 'error'
+ ? 'network_error'
+ : status >= 500
+ ? 'server_5xx'
+ : status >= 400
+ ? 'server_4xx'
+ : 'ok'
+
+ logForDebugging(`[remote-bridge] Torn down (archive=${status})`)
+ logForDiagnosticsNoPII('info', 'bridge_repl_v2_teardown')
+ logEvent(
+ feature('CCR_MIRROR') && outboundOnly
+ ? 'tengu_ccr_mirror_teardown'
+ : 'tengu_bridge_repl_teardown',
+ {
+ v2: true,
+ archive_status:
+ archiveStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ archive_ok: typeof status === 'number' && status < 400,
+ archive_http_status: typeof status === 'number' ? status : undefined,
+ archive_timeout: status === 'timeout',
+ archive_no_token: status === 'no_token',
+ },
+ )
+ }
+ const unregister = registerCleanup(teardown)
+
+ if (feature('CCR_MIRROR') && outboundOnly) {
+ logEvent('tengu_ccr_mirror_started', {
+ v2: true,
+ expires_in_s: credentials.expires_in,
+ })
+ } else {
+ logEvent('tengu_bridge_repl_started', {
+ has_initial_messages: !!(initialMessages && initialMessages.length > 0),
+ v2: true,
+ expires_in_s: credentials.expires_in,
+ inProtectedNamespace: isInProtectedNamespace(),
+ })
+ }
+
+ // ── 10. Handle ──────────────────────────────────────────────────────────
+ return {
+ bridgeSessionId: sessionId,
+ environmentId: '',
+ sessionIngressUrl: credentials.api_base_url,
+ writeMessages(messages) {
+ const filtered = messages.filter(
+ m =>
+ isEligibleBridgeMessage(m) &&
+ !initialMessageUUIDs.has(m.uuid) &&
+ !recentPostedUUIDs.has(m.uuid),
+ )
+ if (filtered.length === 0) return
+
+ // Fire onUserMessage for title derivation. Scan before the flushGate
+ // check — prompts are title-worthy even if they queue. Keeps calling
+ // on every title-worthy message until the callback returns true; the
+ // caller owns the policy (derive at 1st and 3rd, skip if explicit).
+ if (!userMessageCallbackDone) {
+ for (const m of filtered) {
+ const text = extractTitleText(m)
+ if (text !== undefined && onUserMessage?.(text, sessionId)) {
+ userMessageCallbackDone = true
+ break
+ }
+ }
+ }
+
+ if (flushGate.enqueue(...filtered)) {
+ logForDebugging(
+ `[remote-bridge] Queued ${filtered.length} message(s) during flush`,
+ )
+ return
+ }
+
+ for (const msg of filtered) recentPostedUUIDs.add(msg.uuid)
+ const events = toSDKMessages(filtered).map(m => ({
+ ...m,
+ session_id: sessionId,
+ }))
+ // v2 does not derive worker_status from events server-side (unlike v1
+ // session-ingress session_status_updater.go). Push it from here so the
+ // CCR web session list shows Running instead of stuck on Idle. A user
+ // message in the batch marks turn start. CCRClient.reportState dedupes
+ // consecutive same-state pushes.
+ if (filtered.some(m => m.type === 'user')) {
+ transport.reportState('running')
+ }
+ logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`)
+ void transport.writeBatch(events)
+ },
+ writeSdkMessages(messages: SDKMessage[]) {
+ const filtered = messages.filter(
+ m => !m.uuid || !recentPostedUUIDs.has(m.uuid),
+ )
+ if (filtered.length === 0) return
+ for (const msg of filtered) {
+ if (msg.uuid) recentPostedUUIDs.add(msg.uuid)
+ }
+ const events = filtered.map(m => ({ ...m, session_id: sessionId }))
+ void transport.writeBatch(events)
+ },
+ sendControlRequest(request: SDKControlRequest) {
+ if (authRecoveryInFlight) {
+ logForDebugging(
+ `[remote-bridge] Dropping control_request during 401 recovery: ${request.request_id}`,
+ )
+ return
+ }
+ const event = { ...request, session_id: sessionId }
+ if (request.request.subtype === 'can_use_tool') {
+ transport.reportState('requires_action')
+ }
+ void transport.write(event)
+ logForDebugging(
+ `[remote-bridge] Sent control_request request_id=${request.request_id}`,
+ )
+ },
+ sendControlResponse(response: SDKControlResponse) {
+ if (authRecoveryInFlight) {
+ logForDebugging(
+ '[remote-bridge] Dropping control_response during 401 recovery',
+ )
+ return
+ }
+ const event = { ...response, session_id: sessionId }
+ transport.reportState('running')
+ void transport.write(event)
+ logForDebugging('[remote-bridge] Sent control_response')
+ },
+ sendControlCancelRequest(requestId: string) {
+ if (authRecoveryInFlight) {
+ logForDebugging(
+ `[remote-bridge] Dropping control_cancel_request during 401 recovery: ${requestId}`,
+ )
+ return
+ }
+ const event = {
+ type: 'control_cancel_request' as const,
+ request_id: requestId,
+ session_id: sessionId,
+ }
+ // Hook/classifier/channel/recheck resolved the permission locally —
+ // interactiveHandler calls only cancelRequest (no sendResponse) on
+ // those paths, so without this the server stays on requires_action.
+ transport.reportState('running')
+ void transport.write(event)
+ logForDebugging(
+ `[remote-bridge] Sent control_cancel_request request_id=${requestId}`,
+ )
+ },
+ sendResult() {
+ if (authRecoveryInFlight) {
+ logForDebugging('[remote-bridge] Dropping result during 401 recovery')
+ return
+ }
+ transport.reportState('idle')
+ void transport.write(makeResultMessage(sessionId))
+ logForDebugging(`[remote-bridge] Sent result`)
+ },
+ async teardown() {
+ unregister()
+ await teardown()
+ },
+ }
+}
+
+// ─── Session API (v2 /code/sessions, no env) ─────────────────────────────────
+
+/** Retry an async init call with exponential backoff + jitter. */
+async function withRetry(
+ fn: () => Promise,
+ label: string,
+ cfg: EnvLessBridgeConfig,
+): Promise {
+ const max = cfg.init_retry_max_attempts
+ for (let attempt = 1; attempt <= max; attempt++) {
+ const result = await fn()
+ if (result !== null) return result
+ if (attempt < max) {
+ const base = cfg.init_retry_base_delay_ms * 2 ** (attempt - 1)
+ const jitter =
+ base * cfg.init_retry_jitter_fraction * (2 * Math.random() - 1)
+ const delay = Math.min(base + jitter, cfg.init_retry_max_delay_ms)
+ logForDebugging(
+ `[remote-bridge] ${label} failed (attempt ${attempt}/${max}), retrying in ${Math.round(delay)}ms`,
+ )
+ await sleep(delay)
+ }
+ }
+ return null
+}
+
+// Moved to codeSessionApi.ts so the SDK /bridge subpath can bundle them
+// without pulling in this file's heavy CLI tree (analytics, transport).
+export {
+ createCodeSession,
+ type RemoteCredentials,
+} from './codeSessionApi.js'
+import {
+ createCodeSession,
+ fetchRemoteCredentials as fetchRemoteCredentialsRaw,
+ type RemoteCredentials,
+} from './codeSessionApi.js'
+import { getBridgeBaseUrlOverride } from './bridgeConfig.js'
+
+// CLI-side wrapper that applies the CLAUDE_BRIDGE_BASE_URL dev override and
+// injects the trusted-device token (both are env/GrowthBook reads that the
+// SDK-facing codeSessionApi.ts export must stay free of).
+export async function fetchRemoteCredentials(
+ sessionId: string,
+ baseUrl: string,
+ accessToken: string,
+ timeoutMs: number,
+): Promise {
+ const creds = await fetchRemoteCredentialsRaw(
+ sessionId,
+ baseUrl,
+ accessToken,
+ timeoutMs,
+ getTrustedDeviceToken(),
+ )
+ if (!creds) return null
+ return getBridgeBaseUrlOverride()
+ ? { ...creds, api_base_url: baseUrl }
+ : creds
+}
+
+type ArchiveStatus = number | 'timeout' | 'error' | 'no_token'
+
+// Single categorical for BQ `GROUP BY archive_status`. The booleans on
+// _teardown predate this and are redundant with it (except archive_timeout,
+// which distinguishes ECONNABORTED from other network errors — both map to
+// 'network_error' here since the dominant cause in a 1.5s window is timeout).
+type ArchiveTelemetryStatus =
+ | 'ok'
+ | 'skipped_no_token'
+ | 'network_error'
+ | 'server_4xx'
+ | 'server_5xx'
+
+async function archiveSession(
+ sessionId: string,
+ baseUrl: string,
+ accessToken: string | undefined,
+ orgUUID: string,
+ timeoutMs: number,
+): Promise {
+ if (!accessToken) return 'no_token'
+ // Archive lives at the compat layer (/v1/sessions/*, not /v1/code/sessions).
+ // compat.parseSessionID only accepts TagSession (session_*), so retag cse_*.
+ // anthropic-beta + x-organization-uuid are required — without them the
+ // compat gateway 404s before reaching the handler.
+ //
+ // Unlike bridgeMain.ts (which caches compatId in sessionCompatIds to keep
+ // in-memory titledSessions/logger keys consistent across a mid-session
+ // gate flip), this compatId is only a server URL path segment — no
+ // in-memory state. Fresh compute matches whatever the server currently
+ // validates: if the gate is OFF, the server has been updated to accept
+ // cse_* and we correctly send it.
+ const compatId = toCompatSessionId(sessionId)
+ try {
+ const response = await axios.post(
+ `${baseUrl}/v1/sessions/${compatId}/archive`,
+ {},
+ {
+ headers: {
+ ...oauthHeaders(accessToken),
+ 'anthropic-beta': 'ccr-byoc-2025-07-29',
+ 'x-organization-uuid': orgUUID,
+ },
+ timeout: timeoutMs,
+ validateStatus: () => true,
+ },
+ )
+ logForDebugging(
+ `[remote-bridge] Archive ${compatId} status=${response.status}`,
+ )
+ return response.status
+ } catch (err) {
+ const msg = errorMessage(err)
+ logForDebugging(`[remote-bridge] Archive failed: ${msg}`)
+ return axios.isAxiosError(err) && err.code === 'ECONNABORTED'
+ ? 'timeout'
+ : 'error'
+ }
+}
+
+
diff --git a/src/bridge/replBridge.ts b/src/bridge/replBridge.ts
new file mode 100644
index 0000000..65b9a04
--- /dev/null
+++ b/src/bridge/replBridge.ts
@@ -0,0 +1,2408 @@
+// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
+import { randomUUID } from 'crypto'
+import {
+ createBridgeApiClient,
+ BridgeFatalError,
+ isExpiredErrorType,
+ isSuppressible403,
+} from './bridgeApi.js'
+import type { BridgeConfig, BridgeApiClient } from './types.js'
+import { logForDebugging } from '../utils/debug.js'
+import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
+import {
+ type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ logEvent,
+} from '../services/analytics/index.js'
+import { registerCleanup } from '../utils/cleanupRegistry.js'
+import {
+ handleIngressMessage,
+ handleServerControlRequest,
+ makeResultMessage,
+ isEligibleBridgeMessage,
+ extractTitleText,
+ BoundedUUIDSet,
+} from './bridgeMessaging.js'
+import {
+ decodeWorkSecret,
+ buildSdkUrl,
+ buildCCRv2SdkUrl,
+ sameSessionId,
+} from './workSecret.js'
+import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
+import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
+import { getTrustedDeviceToken } from './trustedDevice.js'
+import { HybridTransport } from '../cli/transports/HybridTransport.js'
+import {
+ type ReplBridgeTransport,
+ createV1ReplTransport,
+ createV2ReplTransport,
+} from './replBridgeTransport.js'
+import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
+import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
+import { validateBridgeId } from './bridgeApi.js'
+import {
+ describeAxiosError,
+ extractHttpStatus,
+ logBridgeSkip,
+} from './debugUtils.js'
+import type { Message } from '../types/message.js'
+import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
+import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
+import type {
+ SDKControlRequest,
+ SDKControlResponse,
+} from '../entrypoints/sdk/controlTypes.js'
+import { createCapacityWake, type CapacitySignal } from './capacityWake.js'
+import { FlushGate } from './flushGate.js'
+import {
+ DEFAULT_POLL_CONFIG,
+ type PollIntervalConfig,
+} from './pollConfigDefaults.js'
+import { errorMessage } from '../utils/errors.js'
+import { sleep } from '../utils/sleep.js'
+import {
+ wrapApiForFaultInjection,
+ registerBridgeDebugHandle,
+ clearBridgeDebugHandle,
+ injectBridgeFault,
+} from './bridgeDebug.js'
+
+export type ReplBridgeHandle = {
+ bridgeSessionId: string
+ environmentId: string
+ sessionIngressUrl: string
+ writeMessages(messages: Message[]): void
+ writeSdkMessages(messages: SDKMessage[]): void
+ sendControlRequest(request: SDKControlRequest): void
+ sendControlResponse(response: SDKControlResponse): void
+ sendControlCancelRequest(requestId: string): void
+ sendResult(): void
+ teardown(): Promise
+}
+
+export type BridgeState = 'ready' | 'connected' | 'reconnecting' | 'failed'
+
+/**
+ * Explicit-param input to initBridgeCore. Everything initReplBridge reads
+ * from bootstrap state (cwd, session ID, git, OAuth) becomes a field here.
+ * A daemon caller (Agent SDK, PR 4) that never runs main.tsx fills these
+ * in itself.
+ */
+export type BridgeCoreParams = {
+ dir: string
+ machineName: string
+ branch: string
+ gitRepoUrl: string | null
+ title: string
+ baseUrl: string
+ sessionIngressUrl: string
+ /**
+ * Opaque string sent as metadata.worker_type. Use BridgeWorkerType for
+ * the two CLI-originated values; daemon callers may send any string the
+ * backend recognizes (it's just a filter key on the web side).
+ */
+ workerType: string
+ getAccessToken: () => string | undefined
+ /**
+ * POST /v1/sessions. Injected because `createSession.ts` lazy-loads
+ * `auth.ts`/`model.ts`/`oauth/client.ts` and `bun --outfile` inlines
+ * dynamic imports — the lazy-load doesn't help, the whole REPL tree ends
+ * up in the Agent SDK bundle.
+ *
+ * REPL wrapper passes `createBridgeSession` from `createSession.ts`.
+ * Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
+ * (HTTP-only, orgUUID+model supplied by the daemon caller).
+ *
+ * Receives `gitRepoUrl`+`branch` so the REPL wrapper can build the git
+ * source/outcome for claude.ai's session card. Daemon ignores them.
+ */
+ createSession: (opts: {
+ environmentId: string
+ title: string
+ gitRepoUrl: string | null
+ branch: string
+ signal: AbortSignal
+ }) => Promise
+ /**
+ * POST /v1/sessions/{id}/archive. Same injection rationale. Best-effort;
+ * the callback MUST NOT throw.
+ */
+ archiveSession: (sessionId: string) => Promise
+ /**
+ * Invoked on reconnect-after-env-lost to refresh the title. REPL wrapper
+ * reads session storage (picks up /rename); daemon returns the static
+ * title. Defaults to () => title.
+ */
+ getCurrentTitle?: () => string
+ /**
+ * Converts internal Message[] → SDKMessage[] for writeMessages() and the
+ * initial-flush/drain paths. REPL wrapper passes the real toSDKMessages
+ * from utils/messages/mappers.ts. Daemon callers that only use
+ * writeSdkMessages() and pass no initialMessages can omit this — those
+ * code paths are unreachable.
+ *
+ * Injected rather than imported because mappers.ts transitively pulls in
+ * src/commands.ts via messages.ts → api.ts → prompts.ts, dragging the
+ * entire command registry + React tree into the Agent SDK bundle.
+ */
+ toSDKMessages?: (messages: Message[]) => SDKMessage[]
+ /**
+ * OAuth 401 refresh handler passed to createBridgeApiClient. REPL wrapper
+ * passes handleOAuth401Error; daemon passes its AuthManager's handler.
+ * Injected because utils/auth.ts transitively pulls in the command
+ * registry via config.ts → file.ts → permissions/filesystem.ts →
+ * sessionStorage.ts → commands.ts.
+ */
+ onAuth401?: (staleAccessToken: string) => Promise
+ /**
+ * Poll interval config getter for the work-poll heartbeat loop. REPL
+ * wrapper passes the GrowthBook-backed getPollIntervalConfig (allows ops
+ * to live-tune poll rates fleet-wide). Daemon passes a static config
+ * with a 60s heartbeat (5× headroom under the 300s work-lease TTL).
+ * Injected because growthbook.ts transitively pulls in the command
+ * registry via the same config.ts chain.
+ */
+ getPollIntervalConfig?: () => PollIntervalConfig
+ /**
+ * Max initial messages to replay on connect. REPL wrapper reads from the
+ * tengu_bridge_initial_history_cap GrowthBook flag. Daemon passes no
+ * initialMessages so this is never read. Default 200 matches the flag
+ * default.
+ */
+ initialHistoryCap?: number
+ // Same REPL-flush machinery as InitBridgeOptions — daemon omits these.
+ initialMessages?: Message[]
+ previouslyFlushedUUIDs?: Set
+ onInboundMessage?: (msg: SDKMessage) => void
+ onPermissionResponse?: (response: SDKControlResponse) => void
+ onInterrupt?: () => void
+ onSetModel?: (model: string | undefined) => void
+ onSetMaxThinkingTokens?: (maxTokens: number | null) => void
+ /**
+ * Returns a policy verdict so this module can emit an error control_response
+ * without importing the policy checks itself (bootstrap-isolation constraint).
+ * The callback must guard `auto` (isAutoModeGateEnabled) and
+ * `bypassPermissions` (isBypassPermissionsModeDisabled AND
+ * isBypassPermissionsModeAvailable) BEFORE calling transitionPermissionMode —
+ * that function's internal auto-gate check is a defensive throw, not a
+ * graceful guard, and its side-effect order is setAutoModeActive(true) then
+ * throw, which corrupts the 3-way invariant documented in src/CLAUDE.md if
+ * the callback lets the throw escape here.
+ */
+ onSetPermissionMode?: (
+ mode: PermissionMode,
+ ) => { ok: true } | { ok: false; error: string }
+ onStateChange?: (state: BridgeState, detail?: string) => void
+ /**
+ * Fires on each real user message to flow through writeMessages() until
+ * the callback returns true (done). Mirrors remoteBridgeCore.ts's
+ * onUserMessage so the REPL bridge can derive a session title from early
+ * prompts when none was set at init time (e.g. user runs /remote-control
+ * on an empty conversation, then types). Tool-result wrappers, meta
+ * messages, and display-tag-only messages are skipped. Receives
+ * currentSessionId so the wrapper can PATCH the title without a closure
+ * dance to reach the not-yet-returned handle. The caller owns the
+ * derive-at-count-1-and-3 policy; the transport just keeps calling until
+ * told to stop. Not fired for the writeSdkMessages daemon path (daemon
+ * sets its own title at init). Distinct from SessionSpawnOpts's
+ * onFirstUserMessage (spawn-bridge, PR #21250), which stays fire-once.
+ */
+ onUserMessage?: (text: string, sessionId: string) => boolean
+ /** See InitBridgeOptions.perpetual. */
+ perpetual?: boolean
+ /**
+ * Seeds lastTransportSequenceNum — the SSE event-stream high-water mark
+ * that's carried across transport swaps within one process. Daemon callers
+ * pass the value they persisted at shutdown so the FIRST SSE connect of a
+ * fresh process sends from_sequence_num and the server doesn't replay full
+ * history. REPL callers omit (fresh session each run → 0 is correct).
+ */
+ initialSSESequenceNum?: number
+}
+
+/**
+ * Superset of ReplBridgeHandle. Adds getSSESequenceNum for daemon callers
+ * that persist the SSE seq-num across process restarts and pass it back as
+ * initialSSESequenceNum on the next start.
+ */
+export type BridgeCoreHandle = ReplBridgeHandle & {
+ /**
+ * Current SSE sequence-number high-water mark. Updates as transports
+ * swap. Daemon callers persist this on shutdown and pass it back as
+ * initialSSESequenceNum on next start.
+ */
+ getSSESequenceNum(): number
+}
+
+/**
+ * Poll error recovery constants. When the work poll starts failing (e.g.
+ * server 500s), we use exponential backoff and give up after this timeout.
+ * This is deliberately long — the server is the authority on when a session
+ * is truly dead. As long as the server accepts our poll, we keep waiting
+ * for it to re-dispatch the work item.
+ */
+const POLL_ERROR_INITIAL_DELAY_MS = 2_000
+const POLL_ERROR_MAX_DELAY_MS = 60_000
+const POLL_ERROR_GIVE_UP_MS = 15 * 60 * 1000
+
+// Monotonically increasing counter for distinguishing init calls in logs
+let initSequence = 0
+
+/**
+ * Bootstrap-free core: env registration → session creation → poll loop →
+ * ingress WS → teardown. Reads nothing from bootstrap/state or
+ * sessionStorage — all context comes from params. Caller (initReplBridge
+ * below, or a daemon in PR 4) has already passed entitlement gates and
+ * gathered git/auth/title.
+ *
+ * Returns null on registration or session-creation failure.
+ */
+export async function initBridgeCore(
+ params: BridgeCoreParams,
+): Promise {
+ const {
+ dir,
+ machineName,
+ branch,
+ gitRepoUrl,
+ title,
+ baseUrl,
+ sessionIngressUrl,
+ workerType,
+ getAccessToken,
+ createSession,
+ archiveSession,
+ getCurrentTitle = () => title,
+ toSDKMessages = () => {
+ throw new Error(
+ 'BridgeCoreParams.toSDKMessages not provided. Pass it if you use writeMessages() or initialMessages — daemon callers that only use writeSdkMessages() never hit this path.',
+ )
+ },
+ onAuth401,
+ getPollIntervalConfig = () => DEFAULT_POLL_CONFIG,
+ initialHistoryCap = 200,
+ initialMessages,
+ previouslyFlushedUUIDs,
+ onInboundMessage,
+ onPermissionResponse,
+ onInterrupt,
+ onSetModel,
+ onSetMaxThinkingTokens,
+ onSetPermissionMode,
+ onStateChange,
+ onUserMessage,
+ perpetual,
+ initialSSESequenceNum = 0,
+ } = params
+
+ const seq = ++initSequence
+
+ // bridgePointer import hoisted: perpetual mode reads it before register;
+ // non-perpetual writes it after session create; both use clear at teardown.
+ const { writeBridgePointer, clearBridgePointer, readBridgePointer } =
+ await import('./bridgePointer.js')
+
+ // Perpetual mode: read the crash-recovery pointer and treat it as prior
+ // state. The pointer is written unconditionally after session create
+ // (crash-recovery for all sessions); perpetual mode just skips the
+ // teardown clear so it survives clean exits too. Only reuse 'repl'
+ // pointers — a crashed standalone bridge (`claude remote-control`)
+ // writes source:'standalone' with a different workerType.
+ const rawPrior = perpetual ? await readBridgePointer(dir) : null
+ const prior = rawPrior?.source === 'repl' ? rawPrior : null
+
+ logForDebugging(
+ `[bridge:repl] initBridgeCore #${seq} starting (initialMessages=${initialMessages?.length ?? 0}${prior ? ` perpetual prior=env:${prior.environmentId}` : ''})`,
+ )
+
+ // 5. Register bridge environment
+ const rawApi = createBridgeApiClient({
+ baseUrl,
+ getAccessToken,
+ runnerVersion: MACRO.VERSION,
+ onDebug: logForDebugging,
+ onAuth401,
+ getTrustedDeviceToken,
+ })
+ // Ant-only: interpose so /bridge-kick can inject poll/register/heartbeat
+ // failures. Zero cost in external builds (rawApi passes through unchanged).
+ const api =
+ process.env.USER_TYPE === 'ant' ? wrapApiForFaultInjection(rawApi) : rawApi
+
+ const bridgeConfig: BridgeConfig = {
+ dir,
+ machineName,
+ branch,
+ gitRepoUrl,
+ maxSessions: 1,
+ spawnMode: 'single-session',
+ verbose: false,
+ sandbox: false,
+ bridgeId: randomUUID(),
+ workerType,
+ environmentId: randomUUID(),
+ reuseEnvironmentId: prior?.environmentId,
+ apiBaseUrl: baseUrl,
+ sessionIngressUrl,
+ }
+
+ let environmentId: string
+ let environmentSecret: string
+ try {
+ const reg = await api.registerBridgeEnvironment(bridgeConfig)
+ environmentId = reg.environment_id
+ environmentSecret = reg.environment_secret
+ } catch (err) {
+ logBridgeSkip(
+ 'registration_failed',
+ `[bridge:repl] Environment registration failed: ${errorMessage(err)}`,
+ )
+ // Stale pointer may be the cause (expired/deleted env) — clear it so
+ // the next start doesn't retry the same dead ID.
+ if (prior) {
+ await clearBridgePointer(dir)
+ }
+ onStateChange?.('failed', errorMessage(err))
+ return null
+ }
+
+ logForDebugging(`[bridge:repl] Environment registered: ${environmentId}`)
+ logForDiagnosticsNoPII('info', 'bridge_repl_env_registered')
+ logEvent('tengu_bridge_repl_env_registered', {})
+
+ /**
+ * Reconnect-in-place: if the just-registered environmentId matches what
+ * was requested, call reconnectSession to force-stop stale workers and
+ * re-queue the session. Used at init (perpetual mode — env is alive but
+ * idle after clean teardown) and in doReconnect() Strategy 1 (env lost
+ * then resurrected). Returns true on success; caller falls back to
+ * fresh session creation on false.
+ */
+ async function tryReconnectInPlace(
+ requestedEnvId: string,
+ sessionId: string,
+ ): Promise {
+ if (environmentId !== requestedEnvId) {
+ logForDebugging(
+ `[bridge:repl] Env mismatch (requested ${requestedEnvId}, got ${environmentId}) — cannot reconnect in place`,
+ )
+ return false
+ }
+ // The pointer stores what createBridgeSession returned (session_*,
+ // compat/convert.go:41). /bridge/reconnect is an environments-layer
+ // endpoint — once the server's ccr_v2_compat_enabled gate is on it
+ // looks sessions up by their infra tag (cse_*) and returns "Session
+ // not found" for the session_* costume. We don't know the gate state
+ // pre-poll, so try both; the re-tag is a no-op if the ID is already
+ // cse_* (doReconnect Strategy 1 path — currentSessionId never mutates
+ // to cse_* but future-proof the check).
+ const infraId = toInfraSessionId(sessionId)
+ const candidates =
+ infraId === sessionId ? [sessionId] : [sessionId, infraId]
+ for (const id of candidates) {
+ try {
+ await api.reconnectSession(environmentId, id)
+ logForDebugging(
+ `[bridge:repl] Reconnected session ${id} in place on env ${environmentId}`,
+ )
+ return true
+ } catch (err) {
+ logForDebugging(
+ `[bridge:repl] reconnectSession(${id}) failed: ${errorMessage(err)}`,
+ )
+ }
+ }
+ logForDebugging(
+ '[bridge:repl] reconnectSession exhausted — falling through to fresh session',
+ )
+ return false
+ }
+
+ // Perpetual init: env is alive but has no queued work after clean
+ // teardown. reconnectSession re-queues it. doReconnect() has the same
+ // call but only fires on poll 404 (env dead);
+ // here the env is alive but idle.
+ const reusedPriorSession = prior
+ ? await tryReconnectInPlace(prior.environmentId, prior.sessionId)
+ : false
+ if (prior && !reusedPriorSession) {
+ await clearBridgePointer(dir)
+ }
+
+ // 6. Create session on the bridge. Initial messages are NOT included as
+ // session creation events because those use STREAM_ONLY persistence and
+ // are published before the CCR UI subscribes, so they get lost. Instead,
+ // initial messages are flushed via the ingress WebSocket once it connects.
+
+ // Mutable session ID — updated when the environment+session pair is
+ // re-created after a connection loss.
+ let currentSessionId: string
+
+
+ if (reusedPriorSession && prior) {
+ currentSessionId = prior.sessionId
+ logForDebugging(
+ `[bridge:repl] Perpetual session reused: ${currentSessionId}`,
+ )
+ // Server already has all initialMessages from the prior CLI run. Mark
+ // them as previously-flushed so the initial flush filter excludes them
+ // (previouslyFlushedUUIDs is a fresh Set on every CLI start). Duplicate
+ // UUIDs cause the server to kill the WebSocket.
+ if (initialMessages && previouslyFlushedUUIDs) {
+ for (const msg of initialMessages) {
+ previouslyFlushedUUIDs.add(msg.uuid)
+ }
+ }
+ } else {
+ const createdSessionId = await createSession({
+ environmentId,
+ title,
+ gitRepoUrl,
+ branch,
+ signal: AbortSignal.timeout(15_000),
+ })
+
+ if (!createdSessionId) {
+ logForDebugging(
+ '[bridge:repl] Session creation failed, deregistering environment',
+ )
+ logEvent('tengu_bridge_repl_session_failed', {})
+ await api.deregisterEnvironment(environmentId).catch(() => {})
+ onStateChange?.('failed', 'Session creation failed')
+ return null
+ }
+
+ currentSessionId = createdSessionId
+ logForDebugging(`[bridge:repl] Session created: ${currentSessionId}`)
+ }
+
+ // Crash-recovery pointer: written now so a kill -9 at any point after
+ // this leaves a recoverable trail. Cleared in teardown (non-perpetual)
+ // or left alone (perpetual mode — pointer survives clean exit too).
+ // `claude remote-control --continue` from the same directory will detect
+ // it and offer to resume.
+ await writeBridgePointer(dir, {
+ sessionId: currentSessionId,
+ environmentId,
+ source: 'repl',
+ })
+ logForDiagnosticsNoPII('info', 'bridge_repl_session_created')
+ logEvent('tengu_bridge_repl_started', {
+ has_initial_messages: !!(initialMessages && initialMessages.length > 0),
+ inProtectedNamespace: isInProtectedNamespace(),
+ })
+
+ // UUIDs of initial messages. Used for dedup in writeMessages to avoid
+ // re-sending messages that were already flushed on WebSocket open.
+ const initialMessageUUIDs = new Set()
+ if (initialMessages) {
+ for (const msg of initialMessages) {
+ initialMessageUUIDs.add(msg.uuid)
+ }
+ }
+
+ // Bounded ring buffer of UUIDs for messages we've already sent to the
+ // server via the ingress WebSocket. Serves two purposes:
+ // 1. Echo filtering — ignore our own messages bouncing back on the WS.
+ // 2. Secondary dedup in writeMessages — catch race conditions where
+ // the hook's index-based tracking isn't sufficient.
+ //
+ // Seeded with initialMessageUUIDs so that when the server echoes back
+ // the initial conversation context over the ingress WebSocket, those
+ // messages are recognized as echoes and not re-injected into the REPL.
+ //
+ // Capacity of 2000 covers well over any realistic echo window (echoes
+ // arrive within milliseconds) and any messages that might be re-encountered
+ // after compaction. The hook's lastWrittenIndexRef is the primary dedup;
+ // this is a safety net.
+ const recentPostedUUIDs = new BoundedUUIDSet(2000)
+ for (const uuid of initialMessageUUIDs) {
+ recentPostedUUIDs.add(uuid)
+ }
+
+ // Bounded set of INBOUND prompt UUIDs we've already forwarded to the REPL.
+ // Defensive dedup for when the server re-delivers prompts (seq-num
+ // negotiation failure, server edge cases, transport swap races). The
+ // seq-num carryover below is the primary fix; this is the safety net.
+ const recentInboundUUIDs = new BoundedUUIDSet(2000)
+
+ // 7. Start poll loop for work items — this is what makes the session
+ // "live" on claude.ai. When a user types there, the backend dispatches
+ // a work item to our environment. We poll for it, get the ingress token,
+ // and connect the ingress WebSocket.
+ //
+ // The poll loop keeps running: when work arrives it connects the ingress
+ // WebSocket, and if the WebSocket drops unexpectedly (code != 1000) it
+ // resumes polling to get a fresh ingress token and reconnect.
+ const pollController = new AbortController()
+ // Adapter over either HybridTransport (v1: WS reads + POST writes to
+ // Session-Ingress) or SSETransport+CCRClient (v2: SSE reads + POST
+ // writes to CCR /worker/*). The v1/v2 choice is made in onWorkReceived:
+ // server-driven via secret.use_code_sessions, with CLAUDE_BRIDGE_USE_CCR_V2
+ // as an ant-dev override.
+ let transport: ReplBridgeTransport | null = null
+ // Bumped on every onWorkReceived. Captured in createV2ReplTransport's .then()
+ // closure to detect stale resolutions: if two calls race while transport is
+ // null, both registerWorker() (bumping server epoch), and whichever resolves
+ // SECOND is the correct one — but the transport !== null check gets this
+ // backwards (first-to-resolve installs, second discards). The generation
+ // counter catches it independent of transport state.
+ let v2Generation = 0
+ // SSE sequence-number high-water mark carried across transport swaps.
+ // Without this, each new SSETransport starts at 0, sends no
+ // from_sequence_num / Last-Event-ID on its first connect, and the server
+ // replays the entire session event history — every prompt ever sent
+ // re-delivered as fresh inbound messages on every onWorkReceived.
+ //
+ // Seed only when we actually reconnected the prior session. If
+ // `reusedPriorSession` is false we fell through to `createSession()` —
+ // the caller's persisted seq-num belongs to a dead session and applying
+ // it to the fresh stream (starting at 1) silently drops events. Same
+ // hazard as doReconnect Strategy 2; same fix as the reset there.
+ let lastTransportSequenceNum = reusedPriorSession ? initialSSESequenceNum : 0
+ // Track the current work ID so teardown can call stopWork
+ let currentWorkId: string | null = null
+ // Session ingress JWT for the current work item — used for heartbeat auth.
+ let currentIngressToken: string | null = null
+ // Signal to wake the at-capacity sleep early when the transport is lost,
+ // so the poll loop immediately switches back to fast polling for new work.
+ const capacityWake = createCapacityWake(pollController.signal)
+ const wakePollLoop = capacityWake.wake
+ const capacitySignal = capacityWake.signal
+ // Gates message writes during the initial flush to prevent ordering
+ // races where new messages arrive at the server interleaved with history.
+ const flushGate = new FlushGate()
+
+ // Latch for onUserMessage — flips true when the callback returns true
+ // (policy says "done deriving"). If no callback, skip scanning entirely
+ // (daemon path — no title derivation needed).
+ let userMessageCallbackDone = !onUserMessage
+
+ // Shared counter for environment re-creations, used by both
+ // onEnvironmentLost and the abnormal-close handler.
+ const MAX_ENVIRONMENT_RECREATIONS = 3
+ let environmentRecreations = 0
+ let reconnectPromise: Promise | null = null
+
+ /**
+ * Recover from onEnvironmentLost (poll returned 404 — env was reaped
+ * server-side). Tries two strategies in order:
+ *
+ * 1. Reconnect-in-place: idempotent re-register with reuseEnvironmentId
+ * → if the backend returns the same env ID, call reconnectSession()
+ * to re-queue the existing session. currentSessionId stays the same;
+ * the URL on the user's phone stays valid; previouslyFlushedUUIDs is
+ * preserved so history isn't re-sent.
+ *
+ * 2. Fresh session fallback: if the backend returns a different env ID
+ * (original TTL-expired, e.g. laptop slept >4h) or reconnectSession()
+ * throws, archive the old session and create a new one on the
+ * now-registered env. Old behavior before #20460 primitives landed.
+ *
+ * Uses a promise-based reentrancy guard so concurrent callers share the
+ * same reconnection attempt.
+ */
+ async function reconnectEnvironmentWithSession(): Promise {
+ if (reconnectPromise) {
+ return reconnectPromise
+ }
+ reconnectPromise = doReconnect()
+ try {
+ return await reconnectPromise
+ } finally {
+ reconnectPromise = null
+ }
+ }
+
+ async function doReconnect(): Promise {
+ environmentRecreations++
+ // Invalidate any in-flight v2 handshake — the environment is being
+ // recreated, so a stale transport arriving post-reconnect would be
+ // pointed at a dead session.
+ v2Generation++
+ logForDebugging(
+ `[bridge:repl] Reconnecting after env lost (attempt ${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS})`,
+ )
+
+ if (environmentRecreations > MAX_ENVIRONMENT_RECREATIONS) {
+ logForDebugging(
+ `[bridge:repl] Environment reconnect limit reached (${MAX_ENVIRONMENT_RECREATIONS}), giving up`,
+ )
+ return false
+ }
+
+ // Close the stale transport. Capture seq BEFORE close — if Strategy 1
+ // (tryReconnectInPlace) succeeds we keep the SAME session, and the
+ // next transport must resume where this one left off, not replay from
+ // the last transport-swap checkpoint.
+ if (transport) {
+ const seq = transport.getLastSequenceNum()
+ if (seq > lastTransportSequenceNum) {
+ lastTransportSequenceNum = seq
+ }
+ transport.close()
+ transport = null
+ }
+ // Transport is gone — wake the poll loop out of its at-capacity
+ // heartbeat sleep so it can fast-poll for re-dispatched work.
+ wakePollLoop()
+ // Reset flush gate so writeMessages() hits the !transport guard
+ // instead of silently queuing into a dead buffer.
+ flushGate.drop()
+
+ // Release the current work item (force=false — we may want the session
+ // back). Best-effort: the env is probably gone, so this likely 404s.
+ if (currentWorkId) {
+ const workIdBeingCleared = currentWorkId
+ await api
+ .stopWork(environmentId, workIdBeingCleared, false)
+ .catch(() => {})
+ // When doReconnect runs concurrently with the poll loop (ws_closed
+ // handler case — void-called, unlike the awaited onEnvironmentLost
+ // path), onWorkReceived can fire during the stopWork await and set
+ // a fresh currentWorkId. If it did, the poll loop has already
+ // recovered on its own — defer to it rather than proceeding to
+ // archiveSession, which would destroy the session its new
+ // transport is connected to.
+ if (currentWorkId !== workIdBeingCleared) {
+ logForDebugging(
+ '[bridge:repl] Poll loop recovered during stopWork await — deferring to it',
+ )
+ environmentRecreations = 0
+ return true
+ }
+ currentWorkId = null
+ currentIngressToken = null
+ }
+
+ // Bail out if teardown started while we were awaiting
+ if (pollController.signal.aborted) {
+ logForDebugging('[bridge:repl] Reconnect aborted by teardown')
+ return false
+ }
+
+ // Strategy 1: idempotent re-register with the server-issued env ID.
+ // If the backend resurrects the same env (fresh secret), we can
+ // reconnect the existing session. If it hands back a different ID, the
+ // original env is truly gone and we fall through to a fresh session.
+ const requestedEnvId = environmentId
+ bridgeConfig.reuseEnvironmentId = requestedEnvId
+ try {
+ const reg = await api.registerBridgeEnvironment(bridgeConfig)
+ environmentId = reg.environment_id
+ environmentSecret = reg.environment_secret
+ } catch (err) {
+ bridgeConfig.reuseEnvironmentId = undefined
+ logForDebugging(
+ `[bridge:repl] Environment re-registration failed: ${errorMessage(err)}`,
+ )
+ return false
+ }
+ // Clear before any await — a stale value would poison the next fresh
+ // registration if doReconnect runs again.
+ bridgeConfig.reuseEnvironmentId = undefined
+
+ logForDebugging(
+ `[bridge:repl] Re-registered: requested=${requestedEnvId} got=${environmentId}`,
+ )
+
+ // Bail out if teardown started while we were registering
+ if (pollController.signal.aborted) {
+ logForDebugging(
+ '[bridge:repl] Reconnect aborted after env registration, cleaning up',
+ )
+ await api.deregisterEnvironment(environmentId).catch(() => {})
+ return false
+ }
+
+ // Same race as above, narrower window: poll loop may have set up a
+ // transport during the registerBridgeEnvironment await. Bail before
+ // tryReconnectInPlace/archiveSession kill it server-side.
+ if (transport !== null) {
+ logForDebugging(
+ '[bridge:repl] Poll loop recovered during registerBridgeEnvironment await — deferring to it',
+ )
+ environmentRecreations = 0
+ return true
+ }
+
+ // Strategy 1: same helper as perpetual init. currentSessionId stays
+ // the same on success; URL on mobile/web stays valid;
+ // previouslyFlushedUUIDs preserved (no re-flush).
+ if (await tryReconnectInPlace(requestedEnvId, currentSessionId)) {
+ logEvent('tengu_bridge_repl_reconnected_in_place', {})
+ environmentRecreations = 0
+ return true
+ }
+ // Env differs → TTL-expired/reaped; or reconnect failed.
+ // Don't deregister — we have a fresh secret for this env either way.
+ if (environmentId !== requestedEnvId) {
+ logEvent('tengu_bridge_repl_env_expired_fresh_session', {})
+ }
+
+ // Strategy 2: fresh session on the now-registered environment.
+ // Archive the old session first — it's orphaned (bound to a dead env,
+ // or reconnectSession rejected it). Don't deregister the env — we just
+ // got a fresh secret for it and are about to use it.
+ await archiveSession(currentSessionId)
+
+ // Bail out if teardown started while we were archiving
+ if (pollController.signal.aborted) {
+ logForDebugging(
+ '[bridge:repl] Reconnect aborted after archive, cleaning up',
+ )
+ await api.deregisterEnvironment(environmentId).catch(() => {})
+ return false
+ }
+
+ // Re-read the current title in case the user renamed the session.
+ // REPL wrapper reads session storage; daemon wrapper returns the
+ // original title (nothing to refresh).
+ const currentTitle = getCurrentTitle()
+
+ // Create a new session on the now-registered environment
+ const newSessionId = await createSession({
+ environmentId,
+ title: currentTitle,
+ gitRepoUrl,
+ branch,
+ signal: AbortSignal.timeout(15_000),
+ })
+
+ if (!newSessionId) {
+ logForDebugging(
+ '[bridge:repl] Session creation failed during reconnection',
+ )
+ return false
+ }
+
+ // Bail out if teardown started during session creation (up to 15s)
+ if (pollController.signal.aborted) {
+ logForDebugging(
+ '[bridge:repl] Reconnect aborted after session creation, cleaning up',
+ )
+ await archiveSession(newSessionId)
+ return false
+ }
+
+ currentSessionId = newSessionId
+ // Re-publish to the PID file so peer dedup (peerRegistry.ts) picks up the
+ // new ID — setReplBridgeHandle only fires at init/teardown, not reconnect.
+ void updateSessionBridgeId(toCompatSessionId(newSessionId)).catch(() => {})
+ // Reset per-session transport state IMMEDIATELY after the session swap,
+ // before any await. If this runs after `await writeBridgePointer` below,
+ // there's a window where handle.bridgeSessionId already returns session B
+ // but getSSESequenceNum() still returns session A's seq — a daemon
+ // persistState() in that window writes {bridgeSessionId: B, seq: OLD_A},
+ // which PASSES the session-ID validation check and defeats it entirely.
+ //
+ // The SSE seq-num is scoped to the session's event stream — carrying it
+ // over leaves the transport's lastSequenceNum stuck high (seq only
+ // advances when received > last), and its next internal reconnect would
+ // send from_sequence_num=OLD_SEQ against a stream starting at 1 → all
+ // events in the gap silently dropped. Inbound UUID dedup is also
+ // session-scoped.
+ lastTransportSequenceNum = 0
+ recentInboundUUIDs.clear()
+ // Title derivation is session-scoped too: if the user typed during the
+ // createSession await above, the callback fired against the OLD archived
+ // session ID (PATCH lost) and the new session got `currentTitle` captured
+ // BEFORE they typed. Reset so the next prompt can re-derive. Self-
+ // correcting: if the caller's policy is already done (explicit title or
+ // count ≥ 3), it returns true on the first post-reset call and re-latches.
+ userMessageCallbackDone = !onUserMessage
+ logForDebugging(`[bridge:repl] Re-created session: ${currentSessionId}`)
+
+ // Rewrite the crash-recovery pointer with the new IDs so a crash after
+ // this point resumes the right session. (The reconnect-in-place path
+ // above doesn't touch the pointer — same session, same env.)
+ await writeBridgePointer(dir, {
+ sessionId: currentSessionId,
+ environmentId,
+ source: 'repl',
+ })
+
+ // Clear flushed UUIDs so initial messages are re-sent to the new session.
+ // UUIDs are scoped per-session on the server, so re-flushing is safe.
+ previouslyFlushedUUIDs?.clear()
+
+
+ // Reset the counter so independent reconnections hours apart don't
+ // exhaust the limit — it guards against rapid consecutive failures,
+ // not lifetime total.
+ environmentRecreations = 0
+
+ return true
+ }
+
+ // Helper: get the current OAuth access token for session ingress auth.
+ // Unlike the JWT path, OAuth tokens are refreshed by the standard OAuth
+ // flow — no proactive scheduler needed.
+ function getOAuthToken(): string | undefined {
+ return getAccessToken()
+ }
+
+ // Drain any messages that were queued during the initial flush.
+ // Called after writeBatch completes (or fails) so queued messages
+ // are sent in order after the historical messages.
+ function drainFlushGate(): void {
+ const msgs = flushGate.end()
+ if (msgs.length === 0) return
+ if (!transport) {
+ logForDebugging(
+ `[bridge:repl] Cannot drain ${msgs.length} pending message(s): no transport`,
+ )
+ return
+ }
+ for (const msg of msgs) {
+ recentPostedUUIDs.add(msg.uuid)
+ }
+ const sdkMessages = toSDKMessages(msgs)
+ const events = sdkMessages.map(sdkMsg => ({
+ ...sdkMsg,
+ session_id: currentSessionId,
+ }))
+ logForDebugging(
+ `[bridge:repl] Drained ${msgs.length} pending message(s) after flush`,
+ )
+ void transport.writeBatch(events)
+ }
+
+ // Teardown reference — set after definition below. All callers are async
+ // callbacks that run after assignment, so the reference is always valid.
+ let doTeardownImpl: (() => Promise) | null = null
+ function triggerTeardown(): void {
+ void doTeardownImpl?.()
+ }
+
+ /**
+ * Body of the transport's setOnClose callback, hoisted to initBridgeCore
+ * scope so /bridge-kick can fire it directly. setOnClose wraps this with
+ * a stale-transport guard; debugFireClose calls it bare.
+ *
+ * With autoReconnect:true, this only fires on: clean close (1000),
+ * permanent server rejection (4001/1002/4003), or 10-min budget
+ * exhaustion. Transient drops are retried internally by the transport.
+ */
+ function handleTransportPermanentClose(closeCode: number | undefined): void {
+ logForDebugging(
+ `[bridge:repl] Transport permanently closed: code=${closeCode}`,
+ )
+ logEvent('tengu_bridge_repl_ws_closed', {
+ code: closeCode,
+ })
+ // Capture SSE seq high-water mark before nulling. When called from
+ // setOnClose the guard guarantees transport !== null; when fired from
+ // /bridge-kick it may already be null (e.g. fired twice) — skip.
+ if (transport) {
+ const closedSeq = transport.getLastSequenceNum()
+ if (closedSeq > lastTransportSequenceNum) {
+ lastTransportSequenceNum = closedSeq
+ }
+ transport = null
+ }
+ // Transport is gone — wake the poll loop out of its at-capacity
+ // heartbeat sleep so it's fast-polling by the time the reconnect
+ // below completes and the server re-queues work.
+ wakePollLoop()
+ // Reset flush state so writeMessages() hits the !transport guard
+ // (with a warning log) instead of silently queuing into a buffer
+ // that will never be drained. Unlike onWorkReceived (which
+ // preserves pending messages for the new transport), onClose is
+ // a permanent close — no new transport will drain these.
+ const dropped = flushGate.drop()
+ if (dropped > 0) {
+ logForDebugging(
+ `[bridge:repl] Dropping ${dropped} pending message(s) on transport close (code=${closeCode})`,
+ { level: 'warn' },
+ )
+ }
+
+ if (closeCode === 1000) {
+ // Clean close — session ended normally. Tear down the bridge.
+ onStateChange?.('failed', 'session ended')
+ pollController.abort()
+ triggerTeardown()
+ return
+ }
+
+ // Transport reconnect budget exhausted or permanent server
+ // rejection. By this point the env has usually been reaped
+ // server-side (BQ 2026-03-12: ~98% of ws_closed never recover
+ // via poll alone). stopWork(force=false) can't re-dispatch work
+ // from an archived env; reconnectEnvironmentWithSession can
+ // re-activate it via POST /bridge/reconnect, or fall through
+ // to a fresh session if the env is truly gone. The poll loop
+ // (already woken above) picks up the re-queued work once
+ // doReconnect completes.
+ onStateChange?.(
+ 'reconnecting',
+ `Remote Control connection lost (code ${closeCode})`,
+ )
+ logForDebugging(
+ `[bridge:repl] Transport reconnect budget exhausted (code=${closeCode}), attempting env reconnect`,
+ )
+ void reconnectEnvironmentWithSession().then(success => {
+ if (success) return
+ // doReconnect has four abort-check return-false sites for
+ // teardown-in-progress. Don't pollute the BQ failure signal
+ // or double-teardown when the user just quit.
+ if (pollController.signal.aborted) return
+ // doReconnect returns false (never throws) on genuine failure.
+ // The dangerous case: registerBridgeEnvironment succeeded (so
+ // environmentId now points at a fresh valid env) but
+ // createSession failed — poll loop would poll a sessionless
+ // env getting null work with no errors, never hitting any
+ // give-up path. Tear down explicitly.
+ logForDebugging(
+ '[bridge:repl] reconnectEnvironmentWithSession resolved false — tearing down',
+ )
+ logEvent('tengu_bridge_repl_reconnect_failed', {
+ close_code: closeCode,
+ })
+ onStateChange?.('failed', 'reconnection failed')
+ triggerTeardown()
+ })
+ }
+
+ // Ant-only: SIGUSR2 → force doReconnect() for manual testing. Skips the
+ // ~30s poll wait — fire-and-observe in the debug log immediately.
+ // Windows has no USR signals; `process.on` would throw there.
+ let sigusr2Handler: (() => void) | undefined
+ if (process.env.USER_TYPE === 'ant' && process.platform !== 'win32') {
+ sigusr2Handler = () => {
+ logForDebugging(
+ '[bridge:repl] SIGUSR2 received — forcing doReconnect() for testing',
+ )
+ void reconnectEnvironmentWithSession()
+ }
+ process.on('SIGUSR2', sigusr2Handler)
+ }
+
+ // Ant-only: /bridge-kick fault injection. handleTransportPermanentClose
+ // is defined below and assigned into this slot so the slash command can
+ // invoke it directly — the real setOnClose callback is buried inside
+ // wireTransport which is itself inside onWorkReceived.
+ let debugFireClose: ((code: number) => void) | null = null
+ if (process.env.USER_TYPE === 'ant') {
+ registerBridgeDebugHandle({
+ fireClose: code => {
+ if (!debugFireClose) {
+ logForDebugging('[bridge:debug] fireClose: no transport wired yet')
+ return
+ }
+ logForDebugging(`[bridge:debug] fireClose(${code}) — injecting`)
+ debugFireClose(code)
+ },
+ forceReconnect: () => {
+ logForDebugging('[bridge:debug] forceReconnect — injecting')
+ void reconnectEnvironmentWithSession()
+ },
+ injectFault: injectBridgeFault,
+ wakePollLoop,
+ describe: () =>
+ `env=${environmentId} session=${currentSessionId} transport=${transport?.getStateLabel() ?? 'null'} workId=${currentWorkId ?? 'null'}`,
+ })
+ }
+
+ const pollOpts = {
+ api,
+ getCredentials: () => ({ environmentId, environmentSecret }),
+ signal: pollController.signal,
+ getPollIntervalConfig,
+ onStateChange,
+ getWsState: () => transport?.getStateLabel() ?? 'null',
+ // REPL bridge is single-session: having any transport == at capacity.
+ // No need to check isConnectedStatus() — even while the transport is
+ // auto-reconnecting internally (up to 10 min), poll is heartbeat-only.
+ isAtCapacity: () => transport !== null,
+ capacitySignal,
+ onFatalError: triggerTeardown,
+ getHeartbeatInfo: () => {
+ if (!currentWorkId || !currentIngressToken) {
+ return null
+ }
+ return {
+ environmentId,
+ workId: currentWorkId,
+ sessionToken: currentIngressToken,
+ }
+ },
+ // Work-item JWT expired (or work gone). The transport is useless —
+ // SSE reconnects and CCR writes use the same stale token. Without
+ // this callback the poll loop would do a 10-min at-capacity backoff,
+ // during which the work lease (300s TTL) expires and the server stops
+ // forwarding prompts → ~25-min dead window observed in daemon logs.
+ // Kill the transport + work state so isAtCapacity()=false; the loop
+ // fast-polls and picks up the server's re-dispatched work in seconds.
+ onHeartbeatFatal: (err: BridgeFatalError) => {
+ logForDebugging(
+ `[bridge:repl] heartbeatWork fatal (status=${err.status}) — tearing down work item for fast re-dispatch`,
+ )
+ if (transport) {
+ const seq = transport.getLastSequenceNum()
+ if (seq > lastTransportSequenceNum) {
+ lastTransportSequenceNum = seq
+ }
+ transport.close()
+ transport = null
+ }
+ flushGate.drop()
+ // force=false → server re-queues. Likely already expired, but
+ // idempotent and makes re-dispatch immediate if not.
+ if (currentWorkId) {
+ void api
+ .stopWork(environmentId, currentWorkId, false)
+ .catch((e: unknown) => {
+ logForDebugging(
+ `[bridge:repl] stopWork after heartbeat fatal: ${errorMessage(e)}`,
+ )
+ })
+ }
+ currentWorkId = null
+ currentIngressToken = null
+ wakePollLoop()
+ onStateChange?.(
+ 'reconnecting',
+ 'Work item lease expired, fetching fresh token',
+ )
+ },
+ async onEnvironmentLost() {
+ const success = await reconnectEnvironmentWithSession()
+ if (!success) {
+ return null
+ }
+ return { environmentId, environmentSecret }
+ },
+ onWorkReceived: (
+ workSessionId: string,
+ ingressToken: string,
+ workId: string,
+ serverUseCcrV2: boolean,
+ ) => {
+ // When new work arrives while a transport is already open, the
+ // server has decided to re-dispatch (e.g. token rotation, server
+ // restart). Close the existing transport and reconnect — discarding
+ // the work causes a stuck 'reconnecting' state if the old WS dies
+ // shortly after (the server won't re-dispatch a work item it
+ // already delivered).
+ // ingressToken (JWT) is stored for heartbeat auth (both v1 and v2).
+ // Transport auth diverges — see the v1/v2 split below.
+ if (transport?.isConnectedStatus()) {
+ logForDebugging(
+ `[bridge:repl] Work received while transport connected, replacing with fresh token (workId=${workId})`,
+ )
+ }
+
+ logForDebugging(
+ `[bridge:repl] Work received: workId=${workId} workSessionId=${workSessionId} currentSessionId=${currentSessionId} match=${sameSessionId(workSessionId, currentSessionId)}`,
+ )
+
+ // Refresh the crash-recovery pointer's mtime. Staleness checks file
+ // mtime (not embedded timestamp) so this re-write bumps the clock —
+ // a 5h+ session that crashes still has a fresh pointer. Fires once
+ // per work dispatch (infrequent — bounded by user message rate).
+ void writeBridgePointer(dir, {
+ sessionId: currentSessionId,
+ environmentId,
+ source: 'repl',
+ })
+
+ // Reject foreign session IDs — the server shouldn't assign sessions
+ // from other environments. Since we create env+session as a pair,
+ // a mismatch indicates an unexpected server-side reassignment.
+ //
+ // Compare by underlying UUID, not by tagged-ID prefix. When CCR
+ // v2's compat layer serves the session, createBridgeSession gets
+ // session_* from the v1-facing API (compat/convert.go:41) but the
+ // infrastructure layer delivers cse_* in the work queue
+ // (container_manager.go:129). Same UUID, different tag.
+ if (!sameSessionId(workSessionId, currentSessionId)) {
+ logForDebugging(
+ `[bridge:repl] Rejecting foreign session: expected=${currentSessionId} got=${workSessionId}`,
+ )
+ return
+ }
+
+ currentWorkId = workId
+ currentIngressToken = ingressToken
+
+ // Server decides per-session (secret.use_code_sessions from the work
+ // secret, threaded through runWorkPollLoop). The env var is an ant-dev
+ // override for forcing v2 before the server flag is on for your user —
+ // requires ccr_v2_compat_enabled server-side or registerWorker 404s.
+ //
+ // Kept separate from CLAUDE_CODE_USE_CCR_V2 (the child-SDK transport
+ // selector set by sessionRunner/environment-manager) to avoid the
+ // inheritance hazard in spawn mode where the parent's orchestrator
+ // var would leak into a v1 child.
+ const useCcrV2 =
+ serverUseCcrV2 || isEnvTruthy(process.env.CLAUDE_BRIDGE_USE_CCR_V2)
+
+ // Auth is the one place v1 and v2 diverge hard:
+ //
+ // - v1 (Session-Ingress): accepts OAuth OR JWT. We prefer OAuth
+ // because the standard OAuth refresh flow handles expiry — no
+ // separate JWT refresh scheduler needed.
+ //
+ // - v2 (CCR /worker/*): REQUIRES the JWT. register_worker.go:32
+ // validates the session_id claim, which OAuth tokens don't carry.
+ // The JWT from the work secret has both that claim and the worker
+ // role (environment_auth.py:856). JWT refresh: when it expires the
+ // server re-dispatches work with a fresh one, and onWorkReceived
+ // fires again. createV2ReplTransport stores it via
+ // updateSessionIngressAuthToken() before touching the network.
+ let v1OauthToken: string | undefined
+ if (!useCcrV2) {
+ v1OauthToken = getOAuthToken()
+ if (!v1OauthToken) {
+ logForDebugging(
+ '[bridge:repl] No OAuth token available for session ingress, skipping work',
+ )
+ return
+ }
+ updateSessionIngressAuthToken(v1OauthToken)
+ }
+ logEvent('tengu_bridge_repl_work_received', {})
+
+ // Close the previous transport. Nullify BEFORE calling close() so
+ // the close callback doesn't treat the programmatic close as
+ // "session ended normally" and trigger a full teardown.
+ if (transport) {
+ const oldTransport = transport
+ transport = null
+ // Capture the SSE sequence high-water mark so the next transport
+ // resumes the stream instead of replaying from seq 0. Use max() —
+ // a transport that died early (never received any frames) would
+ // otherwise reset a non-zero mark back to 0.
+ const oldSeq = oldTransport.getLastSequenceNum()
+ if (oldSeq > lastTransportSequenceNum) {
+ lastTransportSequenceNum = oldSeq
+ }
+ oldTransport.close()
+ }
+ // Reset flush state — the old flush (if any) is no longer relevant.
+ // Preserve pending messages so they're drained after the new
+ // transport's flush completes (the hook has already advanced its
+ // lastWrittenIndex and won't re-send them).
+ flushGate.deactivate()
+
+ // Closure adapter over the shared handleServerControlRequest —
+ // captures transport/currentSessionId so the transport.setOnData
+ // callback below doesn't need to thread them through.
+ const onServerControlRequest = (request: SDKControlRequest): void =>
+ handleServerControlRequest(request, {
+ transport,
+ sessionId: currentSessionId,
+ onInterrupt,
+ onSetModel,
+ onSetMaxThinkingTokens,
+ onSetPermissionMode,
+ })
+
+ let initialFlushDone = false
+
+ // Wire callbacks onto a freshly constructed transport and connect.
+ // Extracted so the (sync) v1 and (async) v2 construction paths can
+ // share the identical callback + flush machinery.
+ const wireTransport = (newTransport: ReplBridgeTransport): void => {
+ transport = newTransport
+
+ newTransport.setOnConnect(() => {
+ // Guard: if transport was replaced by a newer onWorkReceived call
+ // while the WS was connecting, ignore this stale callback.
+ if (transport !== newTransport) return
+
+ logForDebugging('[bridge:repl] Ingress transport connected')
+ logEvent('tengu_bridge_repl_ws_connected', {})
+
+ // Update the env var with the latest OAuth token so POST writes
+ // (which read via getSessionIngressAuthToken()) use a fresh token.
+ // v2 skips this — createV2ReplTransport already stored the JWT,
+ // and overwriting it with OAuth would break subsequent /worker/*
+ // requests (session_id claim check).
+ if (!useCcrV2) {
+ const freshToken = getOAuthToken()
+ if (freshToken) {
+ updateSessionIngressAuthToken(freshToken)
+ }
+ }
+
+ // Reset teardownStarted so future teardowns are not blocked.
+ teardownStarted = false
+
+ // Flush initial messages only on first connect, not on every
+ // WS reconnection. Re-flushing would cause duplicate messages.
+ // IMPORTANT: onStateChange('connected') is deferred until the
+ // flush completes. This prevents writeMessages() from sending
+ // new messages that could arrive at the server interleaved with
+ // the historical messages, and delays the web UI from showing
+ // the session as active until history is persisted.
+ if (
+ !initialFlushDone &&
+ initialMessages &&
+ initialMessages.length > 0
+ ) {
+ initialFlushDone = true
+
+ // Cap the initial flush to the most recent N messages. The full
+ // history is UI-only (model doesn't see it) and large replays cause
+ // slow session-ingress persistence (each event is a threadstore write)
+ // plus elevated Firestore pressure. A 0 or negative cap disables it.
+ const historyCap = initialHistoryCap
+ const eligibleMessages = initialMessages.filter(
+ m =>
+ isEligibleBridgeMessage(m) &&
+ !previouslyFlushedUUIDs?.has(m.uuid),
+ )
+ const cappedMessages =
+ historyCap > 0 && eligibleMessages.length > historyCap
+ ? eligibleMessages.slice(-historyCap)
+ : eligibleMessages
+ if (cappedMessages.length < eligibleMessages.length) {
+ logForDebugging(
+ `[bridge:repl] Capped initial flush: ${eligibleMessages.length} -> ${cappedMessages.length} (cap=${historyCap})`,
+ )
+ logEvent('tengu_bridge_repl_history_capped', {
+ eligible_count: eligibleMessages.length,
+ capped_count: cappedMessages.length,
+ })
+ }
+ const sdkMessages = toSDKMessages(cappedMessages)
+ if (sdkMessages.length > 0) {
+ logForDebugging(
+ `[bridge:repl] Flushing ${sdkMessages.length} initial message(s) via transport`,
+ )
+ const events = sdkMessages.map(sdkMsg => ({
+ ...sdkMsg,
+ session_id: currentSessionId,
+ }))
+ const dropsBefore = newTransport.droppedBatchCount
+ void newTransport
+ .writeBatch(events)
+ .then(() => {
+ // If any batch was dropped during this flush (SI down for
+ // maxConsecutiveFailures attempts), flush() still resolved
+ // normally but the events were NOT delivered. Don't mark
+ // UUIDs as flushed — keep them eligible for re-send on the
+ // next onWorkReceived (JWT refresh re-dispatch, line ~1144).
+ if (newTransport.droppedBatchCount > dropsBefore) {
+ logForDebugging(
+ `[bridge:repl] Initial flush dropped ${newTransport.droppedBatchCount - dropsBefore} batch(es) — not marking ${sdkMessages.length} UUID(s) as flushed`,
+ )
+ return
+ }
+ if (previouslyFlushedUUIDs) {
+ for (const sdkMsg of sdkMessages) {
+ if (sdkMsg.uuid) {
+ previouslyFlushedUUIDs.add(sdkMsg.uuid)
+ }
+ }
+ }
+ })
+ .catch(e =>
+ logForDebugging(`[bridge:repl] Initial flush failed: ${e}`),
+ )
+ .finally(() => {
+ // Guard: if transport was replaced during the flush,
+ // don't signal connected or drain — the new transport
+ // owns the lifecycle now.
+ if (transport !== newTransport) return
+ drainFlushGate()
+ onStateChange?.('connected')
+ })
+ } else {
+ // All initial messages were already flushed (filtered by
+ // previouslyFlushedUUIDs). No flush POST needed — clear
+ // the flag and signal connected immediately. This is the
+ // first connect for this transport (inside !initialFlushDone),
+ // so no flush POST is in-flight — the flag was set before
+ // connect() and must be cleared here.
+ drainFlushGate()
+ onStateChange?.('connected')
+ }
+ } else if (!flushGate.active) {
+ // No initial messages or already flushed on first connect.
+ // WS auto-reconnect path — only signal connected if no flush
+ // POST is in-flight. If one is, .finally() owns the lifecycle.
+ onStateChange?.('connected')
+ }
+ })
+
+ newTransport.setOnData(data => {
+ handleIngressMessage(
+ data,
+ recentPostedUUIDs,
+ recentInboundUUIDs,
+ onInboundMessage,
+ onPermissionResponse,
+ onServerControlRequest,
+ )
+ })
+
+ // Body lives at initBridgeCore scope so /bridge-kick can call it
+ // directly via debugFireClose. All referenced closures (transport,
+ // wakePollLoop, flushGate, reconnectEnvironmentWithSession, etc.)
+ // are already at that scope. The only lexical dependency on
+ // wireTransport was `newTransport.getLastSequenceNum()` — but after
+ // the guard below passes we know transport === newTransport.
+ debugFireClose = handleTransportPermanentClose
+ newTransport.setOnClose(closeCode => {
+ // Guard: if transport was replaced, ignore stale close.
+ if (transport !== newTransport) return
+ handleTransportPermanentClose(closeCode)
+ })
+
+ // Start the flush gate before connect() to cover the WS handshake
+ // window. Between transport assignment and setOnConnect firing,
+ // writeMessages() could send messages via HTTP POST before the
+ // initial flush starts. Starting the gate here ensures those
+ // calls are queued. If there are no initial messages, the gate
+ // stays inactive.
+ if (
+ !initialFlushDone &&
+ initialMessages &&
+ initialMessages.length > 0
+ ) {
+ flushGate.start()
+ }
+
+ newTransport.connect()
+ } // end wireTransport
+
+ // Bump unconditionally — ANY new transport (v1 or v2) invalidates an
+ // in-flight v2 handshake. Also bumped in doReconnect().
+ v2Generation++
+
+ if (useCcrV2) {
+ // workSessionId is the cse_* form (infrastructure-layer ID from the
+ // work queue), which is what /v1/code/sessions/{id}/worker/* wants.
+ // The session_* form (currentSessionId) is NOT usable here —
+ // handler/convert.go:30 validates TagCodeSession.
+ const sessionUrl = buildCCRv2SdkUrl(baseUrl, workSessionId)
+ const thisGen = v2Generation
+ logForDebugging(
+ `[bridge:repl] CCR v2: sessionUrl=${sessionUrl} session=${workSessionId} gen=${thisGen}`,
+ )
+ void createV2ReplTransport({
+ sessionUrl,
+ ingressToken,
+ sessionId: workSessionId,
+ initialSequenceNum: lastTransportSequenceNum,
+ }).then(
+ t => {
+ // Teardown started while registerWorker was in flight. Teardown
+ // saw transport === null and skipped close(); installing now
+ // would leak CCRClient heartbeat timers and reset
+ // teardownStarted via wireTransport's side effects.
+ if (pollController.signal.aborted) {
+ t.close()
+ return
+ }
+ // onWorkReceived may have fired again while registerWorker()
+ // was in flight (server re-dispatch with a fresh JWT). The
+ // transport !== null check alone gets the race wrong when BOTH
+ // attempts saw transport === null — it keeps the first resolver
+ // (stale epoch) and discards the second (correct epoch). The
+ // generation check catches it regardless of transport state.
+ if (thisGen !== v2Generation) {
+ logForDebugging(
+ `[bridge:repl] CCR v2: discarding stale handshake gen=${thisGen} current=${v2Generation}`,
+ )
+ t.close()
+ return
+ }
+ wireTransport(t)
+ },
+ (err: unknown) => {
+ logForDebugging(
+ `[bridge:repl] CCR v2: createV2ReplTransport failed: ${errorMessage(err)}`,
+ { level: 'error' },
+ )
+ logEvent('tengu_bridge_repl_ccr_v2_init_failed', {})
+ // If a newer attempt is in flight or already succeeded, don't
+ // touch its work item — our failure is irrelevant.
+ if (thisGen !== v2Generation) return
+ // Release the work item so the server re-dispatches immediately
+ // instead of waiting for its own timeout. currentWorkId was set
+ // above; without this, the session looks stuck to the user.
+ if (currentWorkId) {
+ void api
+ .stopWork(environmentId, currentWorkId, false)
+ .catch((e: unknown) => {
+ logForDebugging(
+ `[bridge:repl] stopWork after v2 init failure: ${errorMessage(e)}`,
+ )
+ })
+ currentWorkId = null
+ currentIngressToken = null
+ }
+ wakePollLoop()
+ },
+ )
+ } else {
+ // v1: HybridTransport (WS reads + POST writes to Session-Ingress).
+ // autoReconnect is true (default) — when the WS dies, the transport
+ // reconnects automatically with exponential backoff. POST writes
+ // continue during reconnection (they use getSessionIngressAuthToken()
+ // independently of WS state). The poll loop remains as a secondary
+ // fallback if the reconnect budget is exhausted (10 min).
+ //
+ // Auth: uses OAuth tokens directly instead of the JWT from the work
+ // secret. refreshHeaders picks up the latest OAuth token on each
+ // WS reconnect attempt.
+ const wsUrl = buildSdkUrl(sessionIngressUrl, workSessionId)
+ logForDebugging(`[bridge:repl] Ingress URL: ${wsUrl}`)
+ logForDebugging(
+ `[bridge:repl] Creating HybridTransport: session=${workSessionId}`,
+ )
+ // v1OauthToken was validated non-null above (we'd have returned early).
+ const oauthToken = v1OauthToken ?? ''
+ wireTransport(
+ createV1ReplTransport(
+ new HybridTransport(
+ new URL(wsUrl),
+ {
+ Authorization: `Bearer ${oauthToken}`,
+ 'anthropic-version': '2023-06-01',
+ },
+ workSessionId,
+ () => ({
+ Authorization: `Bearer ${getOAuthToken() ?? oauthToken}`,
+ 'anthropic-version': '2023-06-01',
+ }),
+ // Cap retries so a persistently-failing session-ingress can't
+ // pin the uploader drain loop for the lifetime of the bridge.
+ // 50 attempts ≈ 20 min (15s POST timeout + 8s backoff + jitter
+ // per cycle at steady state). Bridge-only — 1P keeps indefinite.
+ {
+ maxConsecutiveFailures: 50,
+ isBridge: true,
+ onBatchDropped: () => {
+ onStateChange?.(
+ 'reconnecting',
+ 'Lost sync with Remote Control — events could not be delivered',
+ )
+ // SI has been down ~20 min. Wake the poll loop so that when
+ // SI recovers, next poll → onWorkReceived → fresh transport
+ // → initial flush succeeds → onStateChange('connected') at
+ // ~line 1420. Without this, state stays 'reconnecting' even
+ // after SI recovers — daemon.ts:437 denies all permissions,
+ // useReplBridge.ts:311 keeps replBridgeSessionActive=false.
+ // If the env was archived during the outage, poll 404 →
+ // onEnvironmentLost recovery path handles it.
+ wakePollLoop()
+ },
+ },
+ ),
+ ),
+ )
+ }
+ },
+ }
+ void startWorkPollLoop(pollOpts)
+
+ // Perpetual mode: hourly mtime refresh of the crash-recovery pointer.
+ // The onWorkReceived refresh only fires per user prompt — a
+ // daemon idle for >4h would have a stale pointer, and the next restart
+ // would clear it (readBridgePointer TTL check) → fresh session. The
+ // standalone bridge (bridgeMain.ts) has an identical hourly timer.
+ const pointerRefreshTimer = perpetual
+ ? setInterval(() => {
+ // doReconnect() reassigns currentSessionId/environmentId non-
+ // atomically (env at ~:634, session at ~:719, awaits in between).
+ // If this timer fires in that window, its fire-and-forget write can
+ // race with (and overwrite) doReconnect's own pointer write at ~:740,
+ // leaving the pointer at the now-archived old session. doReconnect
+ // writes the pointer itself, so skipping here is free.
+ if (reconnectPromise) return
+ void writeBridgePointer(dir, {
+ sessionId: currentSessionId,
+ environmentId,
+ source: 'repl',
+ })
+ }, 60 * 60_000)
+ : null
+ pointerRefreshTimer?.unref?.()
+
+ // 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; 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.
+ const keepAliveIntervalMs =
+ getPollIntervalConfig().session_keepalive_interval_v2_ms
+ const keepAliveTimer =
+ keepAliveIntervalMs > 0
+ ? setInterval(() => {
+ if (!transport) return
+ logForDebugging('[bridge:repl] keep_alive sent')
+ void transport.write({ type: 'keep_alive' }).catch((err: unknown) => {
+ logForDebugging(
+ `[bridge:repl] keep_alive write failed: ${errorMessage(err)}`,
+ )
+ })
+ }, keepAliveIntervalMs)
+ : null
+ keepAliveTimer?.unref?.()
+
+ // Shared teardown sequence used by both cleanup registration and
+ // the explicit teardown() method on the returned handle.
+ let teardownStarted = false
+ doTeardownImpl = async (): Promise => {
+ if (teardownStarted) {
+ logForDebugging(
+ `[bridge:repl] Teardown already in progress, skipping duplicate call env=${environmentId} session=${currentSessionId}`,
+ )
+ return
+ }
+ teardownStarted = true
+ const teardownStart = Date.now()
+ logForDebugging(
+ `[bridge:repl] Teardown starting: env=${environmentId} session=${currentSessionId} workId=${currentWorkId ?? 'none'} transportState=${transport?.getStateLabel() ?? 'null'}`,
+ )
+
+ if (pointerRefreshTimer !== null) {
+ clearInterval(pointerRefreshTimer)
+ }
+ if (keepAliveTimer !== null) {
+ clearInterval(keepAliveTimer)
+ }
+ if (sigusr2Handler) {
+ process.off('SIGUSR2', sigusr2Handler)
+ }
+ if (process.env.USER_TYPE === 'ant') {
+ clearBridgeDebugHandle()
+ debugFireClose = null
+ }
+ pollController.abort()
+ logForDebugging('[bridge:repl] Teardown: poll loop aborted')
+
+ // Capture the live transport's seq BEFORE close() — close() is sync
+ // (just aborts the SSE fetch) and does NOT invoke onClose, so the
+ // setOnClose capture path never runs for explicit teardown.
+ // Without this, getSSESequenceNum() after teardown returns the stale
+ // lastTransportSequenceNum (captured at the last transport swap), and
+ // daemon callers persisting that value lose all events since then.
+ if (transport) {
+ const finalSeq = transport.getLastSequenceNum()
+ if (finalSeq > lastTransportSequenceNum) {
+ lastTransportSequenceNum = finalSeq
+ }
+ }
+
+ if (perpetual) {
+ // Perpetual teardown is LOCAL-ONLY — do not send result, do not call
+ // stopWork, do not close the transport. All of those signal the
+ // server (and any mobile/attach subscribers) that the session is
+ // ending. Instead: stop polling, let the socket die with the
+ // process; the backend times the work-item lease back to pending on
+ // its own (TTL 300s). Next daemon start reads the pointer and
+ // reconnectSession re-queues work.
+ transport = null
+ flushGate.drop()
+ // Refresh the pointer mtime so that sessions lasting longer than
+ // BRIDGE_POINTER_TTL_MS (4h) don't appear stale on next start.
+ await writeBridgePointer(dir, {
+ sessionId: currentSessionId,
+ environmentId,
+ source: 'repl',
+ })
+ logForDebugging(
+ `[bridge:repl] Teardown (perpetual): leaving env=${environmentId} session=${currentSessionId} alive on server, duration=${Date.now() - teardownStart}ms`,
+ )
+ return
+ }
+
+ // Fire the result message, then archive, THEN close. transport.write()
+ // only enqueues (SerialBatchEventUploader resolves on buffer-add); the
+ // stopWork/archive latency (~200-500ms) is the drain window for the
+ // result POST. Closing BEFORE archive meant relying on HybridTransport's
+ // void-ed 3s grace period, which nothing awaits — forceExit can kill the
+ // socket mid-POST. Same reorder as remoteBridgeCore.ts teardown (#22803).
+ const teardownTransport = transport
+ transport = null
+ flushGate.drop()
+ if (teardownTransport) {
+ void teardownTransport.write(makeResultMessage(currentSessionId))
+ }
+
+ const stopWorkP = currentWorkId
+ ? api
+ .stopWork(environmentId, currentWorkId, true)
+ .then(() => {
+ logForDebugging('[bridge:repl] Teardown: stopWork completed')
+ })
+ .catch((err: unknown) => {
+ logForDebugging(
+ `[bridge:repl] Teardown stopWork failed: ${errorMessage(err)}`,
+ )
+ })
+ : Promise.resolve()
+
+ // Run stopWork and archiveSession in parallel. gracefulShutdown.ts:407
+ // races runCleanupFunctions() against 2s (NOT the 5s outer failsafe),
+ // so archive is capped at 1.5s at the injection site to stay under budget.
+ // archiveSession is contractually no-throw; the injected implementations
+ // log their own success/failure internally.
+ await Promise.all([stopWorkP, archiveSession(currentSessionId)])
+
+ teardownTransport?.close()
+ logForDebugging('[bridge:repl] Teardown: transport closed')
+
+ await api.deregisterEnvironment(environmentId).catch((err: unknown) => {
+ logForDebugging(
+ `[bridge:repl] Teardown deregister failed: ${errorMessage(err)}`,
+ )
+ })
+
+ // Clear the crash-recovery pointer — explicit disconnect or clean REPL
+ // exit means the user is done with this session. Crash/kill-9 never
+ // reaches this line, leaving the pointer for next-launch recovery.
+ await clearBridgePointer(dir)
+
+ logForDebugging(
+ `[bridge:repl] Teardown complete: env=${environmentId} duration=${Date.now() - teardownStart}ms`,
+ )
+ }
+
+ // 8. Register cleanup for graceful shutdown
+ const unregister = registerCleanup(() => doTeardownImpl?.())
+
+ logForDebugging(
+ `[bridge:repl] Ready: env=${environmentId} session=${currentSessionId}`,
+ )
+ onStateChange?.('ready')
+
+ return {
+ get bridgeSessionId() {
+ return currentSessionId
+ },
+ get environmentId() {
+ return environmentId
+ },
+ getSSESequenceNum() {
+ // lastTransportSequenceNum only updates when a transport is CLOSED
+ // (captured at swap/onClose). During normal operation the CURRENT
+ // transport's live seq isn't reflected there. Merge both so callers
+ // (e.g. daemon persistState()) get the actual high-water mark.
+ const live = transport?.getLastSequenceNum() ?? 0
+ return Math.max(lastTransportSequenceNum, live)
+ },
+ sessionIngressUrl,
+ writeMessages(messages) {
+ // Filter to user/assistant messages that haven't already been sent.
+ // Two layers of dedup:
+ // - initialMessageUUIDs: messages sent as session creation events
+ // - recentPostedUUIDs: messages recently sent via POST
+ const filtered = messages.filter(
+ m =>
+ isEligibleBridgeMessage(m) &&
+ !initialMessageUUIDs.has(m.uuid) &&
+ !recentPostedUUIDs.has(m.uuid),
+ )
+ if (filtered.length === 0) return
+
+ // Fire onUserMessage for title derivation. Scan before the flushGate
+ // check — prompts are title-worthy even if they queue behind the
+ // initial history flush. Keeps calling on every title-worthy message
+ // until the callback returns true; the caller owns the policy.
+ if (!userMessageCallbackDone) {
+ for (const m of filtered) {
+ const text = extractTitleText(m)
+ if (text !== undefined && onUserMessage?.(text, currentSessionId)) {
+ userMessageCallbackDone = true
+ break
+ }
+ }
+ }
+
+ // Queue messages while the initial flush is in progress to prevent
+ // them from arriving at the server interleaved with history.
+ if (flushGate.enqueue(...filtered)) {
+ logForDebugging(
+ `[bridge:repl] Queued ${filtered.length} message(s) during initial flush`,
+ )
+ return
+ }
+
+ if (!transport) {
+ const types = filtered.map(m => m.type).join(',')
+ logForDebugging(
+ `[bridge:repl] Transport not configured, dropping ${filtered.length} message(s) [${types}] for session=${currentSessionId}`,
+ { level: 'warn' },
+ )
+ return
+ }
+
+ // Track in the bounded ring buffer for echo filtering and dedup.
+ for (const msg of filtered) {
+ recentPostedUUIDs.add(msg.uuid)
+ }
+
+ logForDebugging(
+ `[bridge:repl] Sending ${filtered.length} message(s) via transport`,
+ )
+
+ // Convert to SDK format and send via HTTP POST (HybridTransport).
+ // The web UI receives them via the subscribe WebSocket.
+ const sdkMessages = toSDKMessages(filtered)
+ const events = sdkMessages.map(sdkMsg => ({
+ ...sdkMsg,
+ session_id: currentSessionId,
+ }))
+ void transport.writeBatch(events)
+ },
+ writeSdkMessages(messages) {
+ // Daemon path: query() already yields SDKMessage, skip conversion.
+ // Still run echo dedup (server bounces writes back on the WS).
+ // No initialMessageUUIDs filter — daemon has no initial messages.
+ // No flushGate — daemon never starts it (no initial flush).
+ const filtered = messages.filter(
+ m => !m.uuid || !recentPostedUUIDs.has(m.uuid),
+ )
+ if (filtered.length === 0) return
+ if (!transport) {
+ logForDebugging(
+ `[bridge:repl] Transport not configured, dropping ${filtered.length} SDK message(s) for session=${currentSessionId}`,
+ { level: 'warn' },
+ )
+ return
+ }
+ for (const msg of filtered) {
+ if (msg.uuid) recentPostedUUIDs.add(msg.uuid)
+ }
+ const events = filtered.map(m => ({ ...m, session_id: currentSessionId }))
+ void transport.writeBatch(events)
+ },
+ sendControlRequest(request: SDKControlRequest) {
+ if (!transport) {
+ logForDebugging(
+ '[bridge:repl] Transport not configured, skipping control_request',
+ )
+ return
+ }
+ const event = { ...request, session_id: currentSessionId }
+ void transport.write(event)
+ logForDebugging(
+ `[bridge:repl] Sent control_request request_id=${request.request_id}`,
+ )
+ },
+ sendControlResponse(response: SDKControlResponse) {
+ if (!transport) {
+ logForDebugging(
+ '[bridge:repl] Transport not configured, skipping control_response',
+ )
+ return
+ }
+ const event = { ...response, session_id: currentSessionId }
+ void transport.write(event)
+ logForDebugging('[bridge:repl] Sent control_response')
+ },
+ sendControlCancelRequest(requestId: string) {
+ if (!transport) {
+ logForDebugging(
+ '[bridge:repl] Transport not configured, skipping control_cancel_request',
+ )
+ return
+ }
+ const event = {
+ type: 'control_cancel_request' as const,
+ request_id: requestId,
+ session_id: currentSessionId,
+ }
+ void transport.write(event)
+ logForDebugging(
+ `[bridge:repl] Sent control_cancel_request request_id=${requestId}`,
+ )
+ },
+ sendResult() {
+ if (!transport) {
+ logForDebugging(
+ `[bridge:repl] sendResult: skipping, transport not configured session=${currentSessionId}`,
+ )
+ return
+ }
+ void transport.write(makeResultMessage(currentSessionId))
+ logForDebugging(
+ `[bridge:repl] Sent result for session=${currentSessionId}`,
+ )
+ },
+ async teardown() {
+ unregister()
+ await doTeardownImpl?.()
+ logForDebugging('[bridge:repl] Torn down')
+ logEvent('tengu_bridge_repl_teardown', {})
+ },
+ }
+}
+
+/**
+ * Persistent poll loop for work items. Runs in the background for the
+ * lifetime of the bridge connection.
+ *
+ * When a work item arrives, acknowledges it and calls onWorkReceived
+ * with the session ID and ingress token (which connects the ingress
+ * WebSocket). Then continues polling — the server will dispatch a new
+ * work item if the ingress WebSocket drops, allowing automatic
+ * reconnection without tearing down the bridge.
+ */
+async function startWorkPollLoop({
+ api,
+ getCredentials,
+ signal,
+ onStateChange,
+ onWorkReceived,
+ onEnvironmentLost,
+ getWsState,
+ isAtCapacity,
+ capacitySignal,
+ onFatalError,
+ getPollIntervalConfig = () => DEFAULT_POLL_CONFIG,
+ getHeartbeatInfo,
+ onHeartbeatFatal,
+}: {
+ api: BridgeApiClient
+ getCredentials: () => { environmentId: string; environmentSecret: string }
+ signal: AbortSignal
+ onStateChange?: (state: BridgeState, detail?: string) => void
+ onWorkReceived: (
+ sessionId: string,
+ ingressToken: string,
+ workId: string,
+ useCodeSessions: boolean,
+ ) => void
+ /** Called when the environment has been deleted. Returns new credentials or null. */
+ onEnvironmentLost?: () => Promise<{
+ environmentId: string
+ environmentSecret: string
+ } | null>
+ /** Returns the current WebSocket readyState label for diagnostic logging. */
+ getWsState?: () => string
+ /**
+ * Returns true when the caller cannot accept new work (transport already
+ * connected). When true, the loop polls at the configured at-capacity
+ * interval as a heartbeat only. Server-side BRIDGE_LAST_POLL_TTL is
+ * 4 hours — anything shorter than that is sufficient for liveness.
+ */
+ isAtCapacity?: () => boolean
+ /**
+ * Produces a signal that aborts when capacity frees up (transport lost),
+ * merged with the loop signal. Used to interrupt the at-capacity sleep
+ * so recovery polling starts immediately.
+ */
+ capacitySignal?: () => CapacitySignal
+ /** Called on unrecoverable errors (e.g. server-side expiry) to trigger full teardown. */
+ onFatalError?: () => void
+ /** Poll interval config getter — defaults to DEFAULT_POLL_CONFIG. */
+ getPollIntervalConfig?: () => PollIntervalConfig
+ /**
+ * Returns the current work ID and session ingress token for heartbeat.
+ * When null, heartbeat is not possible (no active work item).
+ */
+ getHeartbeatInfo?: () => {
+ environmentId: string
+ workId: string
+ sessionToken: string
+ } | null
+ /**
+ * Called when heartbeatWork throws BridgeFatalError (401/403/404/410 —
+ * JWT expired or work item gone). Caller should tear down the transport
+ * + work state so isAtCapacity() flips to false and the loop fast-polls
+ * for the server's re-dispatched work item. When provided, the loop
+ * SKIPS the at-capacity backoff sleep (which would otherwise cause a
+ * ~10-minute dead window before recovery). When omitted, falls back to
+ * the backoff sleep to avoid a tight poll+heartbeat loop.
+ */
+ onHeartbeatFatal?: (err: BridgeFatalError) => void
+}): Promise {
+ const MAX_ENVIRONMENT_RECREATIONS = 3
+
+ logForDebugging(
+ `[bridge:repl] Starting work poll loop for env=${getCredentials().environmentId}`,
+ )
+
+ let consecutiveErrors = 0
+ let firstErrorTime: number | null = null
+ let lastPollErrorTime: number | null = null
+ let environmentRecreations = 0
+ // Set when the at-capacity sleep overruns its deadline by a large margin
+ // (process suspension). Consumed at the top of the next iteration to
+ // force one fast-poll cycle — isAtCapacity() is `transport !== null`,
+ // which stays true while the transport auto-reconnects, so the poll
+ // loop would otherwise go straight back to a 10-minute sleep on a
+ // transport that may be pointed at a dead socket.
+ let suspensionDetected = false
+
+ while (!signal.aborted) {
+ // Capture credentials outside try so the catch block can detect
+ // whether a concurrent reconnection replaced the environment.
+ const { environmentId: envId, environmentSecret: envSecret } =
+ getCredentials()
+ const pollConfig = getPollIntervalConfig()
+ try {
+ const work = await api.pollForWork(
+ envId,
+ envSecret,
+ signal,
+ pollConfig.reclaim_older_than_ms,
+ )
+
+ // A successful poll proves the env is genuinely healthy — reset the
+ // env-loss counter so events hours apart each start fresh. Outside
+ // the state-change guard below because onEnvLost's success path
+ // already emits 'ready'; emitting again here would be a duplicate.
+ // (onEnvLost returning creds does NOT reset this — that would break
+ // oscillation protection when the new env immediately dies.)
+ environmentRecreations = 0
+
+ // Reset error tracking on successful poll
+ if (consecutiveErrors > 0) {
+ logForDebugging(
+ `[bridge:repl] Poll recovered after ${consecutiveErrors} consecutive error(s)`,
+ )
+ consecutiveErrors = 0
+ firstErrorTime = null
+ lastPollErrorTime = null
+ onStateChange?.('ready')
+ }
+
+ if (!work) {
+ // Read-and-clear: after a detected suspension, skip the at-capacity
+ // branch exactly once. The pollForWork above already refreshed the
+ // server's BRIDGE_LAST_POLL_TTL; this fast cycle gives any
+ // re-dispatched work item a chance to land before we go back under.
+ const skipAtCapacityOnce = suspensionDetected
+ suspensionDetected = false
+ if (isAtCapacity?.() && capacitySignal && !skipAtCapacityOnce) {
+ const atCapMs = pollConfig.poll_interval_ms_at_capacity
+ // Heartbeat loops WITHOUT polling. When at-capacity polling is also
+ // enabled (atCapMs > 0), the loop tracks a deadline and breaks out
+ // to poll at that interval — heartbeat and poll compose instead of
+ // one suppressing the other. Breaks out when:
+ // - Poll deadline reached (atCapMs > 0 only)
+ // - Auth fails (JWT expired → poll refreshes tokens)
+ // - Capacity wake fires (transport lost → poll for new work)
+ // - Heartbeat config disabled (GrowthBook update)
+ // - Loop aborted (shutdown)
+ if (
+ pollConfig.non_exclusive_heartbeat_interval_ms > 0 &&
+ getHeartbeatInfo
+ ) {
+ logEvent('tengu_bridge_heartbeat_mode_entered', {
+ heartbeat_interval_ms:
+ pollConfig.non_exclusive_heartbeat_interval_ms,
+ })
+ // Deadline computed once at entry — GB updates to atCapMs don't
+ // shift an in-flight deadline (next entry picks up the new value).
+ const pollDeadline = atCapMs > 0 ? Date.now() + atCapMs : null
+ let needsBackoff = false
+ let hbCycles = 0
+ while (
+ !signal.aborted &&
+ isAtCapacity() &&
+ (pollDeadline === null || Date.now() < pollDeadline)
+ ) {
+ const hbConfig = getPollIntervalConfig()
+ if (hbConfig.non_exclusive_heartbeat_interval_ms <= 0) break
+
+ const info = getHeartbeatInfo()
+ if (!info) break
+
+ // Capture capacity signal BEFORE the async heartbeat call so
+ // a transport loss during the HTTP request is caught by the
+ // subsequent sleep.
+ const cap = capacitySignal()
+
+ try {
+ await api.heartbeatWork(
+ info.environmentId,
+ info.workId,
+ info.sessionToken,
+ )
+ } catch (err) {
+ logForDebugging(
+ `[bridge:repl:heartbeat] Failed: ${errorMessage(err)}`,
+ )
+ if (err instanceof BridgeFatalError) {
+ cap.cleanup()
+ logEvent('tengu_bridge_heartbeat_error', {
+ status:
+ err.status as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ error_type: (err.status === 401 || err.status === 403
+ ? 'auth_failed'
+ : 'fatal') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ // JWT expired (401/403) or work item gone (404/410).
+ // Either way the current transport is dead — SSE
+ // reconnects and CCR writes will fail on the same
+ // stale token. If the caller gave us a recovery hook,
+ // tear down work state and skip backoff: isAtCapacity()
+ // flips to false, next outer-loop iteration fast-polls
+ // for the server's re-dispatched work item. Without
+ // the hook, backoff to avoid tight poll+heartbeat loop.
+ if (onHeartbeatFatal) {
+ onHeartbeatFatal(err)
+ logForDebugging(
+ `[bridge:repl:heartbeat] Fatal (status=${err.status}), work state cleared — fast-polling for re-dispatch`,
+ )
+ } else {
+ needsBackoff = true
+ }
+ break
+ }
+ }
+
+ hbCycles++
+ await sleep(
+ hbConfig.non_exclusive_heartbeat_interval_ms,
+ cap.signal,
+ )
+ cap.cleanup()
+ }
+
+ const exitReason = needsBackoff
+ ? 'error'
+ : signal.aborted
+ ? 'shutdown'
+ : !isAtCapacity()
+ ? 'capacity_changed'
+ : pollDeadline !== null && Date.now() >= pollDeadline
+ ? 'poll_due'
+ : 'config_disabled'
+ logEvent('tengu_bridge_heartbeat_mode_exited', {
+ reason:
+ exitReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ heartbeat_cycles: hbCycles,
+ })
+
+ // On auth_failed or fatal, backoff before polling to avoid a
+ // tight poll+heartbeat loop. Fall through to the shared sleep
+ // below — it's the same capacitySignal-wrapped sleep the legacy
+ // path uses, and both need the suspension-overrun check.
+ if (!needsBackoff) {
+ if (exitReason === 'poll_due') {
+ // bridgeApi throttles empty-poll logs (EMPTY_POLL_LOG_INTERVAL=100)
+ // so the once-per-10min poll_due poll is invisible at counter=2.
+ // Log it here so verification runs see both endpoints in the debug log.
+ logForDebugging(
+ `[bridge:repl] Heartbeat poll_due after ${hbCycles} cycles — falling through to pollForWork`,
+ )
+ }
+ continue
+ }
+ }
+ // At-capacity sleep — reached by both the legacy path (heartbeat
+ // disabled) and the heartbeat-backoff path (needsBackoff=true).
+ // Merged so the suspension detector covers both; previously the
+ // backoff path had no overrun check and could go straight back
+ // under for 10 min after a laptop wake. Use atCapMs when enabled,
+ // else the heartbeat interval as a floor (guaranteed > 0 on the
+ // backoff path) so heartbeat-only configs don't tight-loop.
+ const sleepMs =
+ atCapMs > 0
+ ? atCapMs
+ : pollConfig.non_exclusive_heartbeat_interval_ms
+ if (sleepMs > 0) {
+ const cap = capacitySignal()
+ const sleepStart = Date.now()
+ await sleep(sleepMs, cap.signal)
+ cap.cleanup()
+ // Process-suspension detector. A setTimeout overshooting its
+ // deadline by 60s means the process was suspended (laptop lid,
+ // SIGSTOP, VM pause) — even a pathological GC pause is seconds,
+ // not minutes. Early aborts (wakePollLoop → cap.signal) produce
+ // overrun < 0 and fall through. Note: this only catches sleeps
+ // that outlast their deadline; WebSocketTransport's ping
+ // interval (10s granularity) is the primary detector for shorter
+ // suspensions. This is the backstop for when that detector isn't
+ // running (transport mid-reconnect, interval stopped).
+ const overrun = Date.now() - sleepStart - sleepMs
+ if (overrun > 60_000) {
+ logForDebugging(
+ `[bridge:repl] At-capacity sleep overran by ${Math.round(overrun / 1000)}s — process suspension detected, forcing one fast-poll cycle`,
+ )
+ logEvent('tengu_bridge_repl_suspension_detected', {
+ overrun_ms: overrun,
+ })
+ suspensionDetected = true
+ }
+ }
+ } else {
+ await sleep(pollConfig.poll_interval_ms_not_at_capacity, signal)
+ }
+ continue
+ }
+
+ // Decode before type dispatch — need the JWT for the explicit ack.
+ let secret
+ try {
+ secret = decodeWorkSecret(work.secret)
+ } catch (err) {
+ logForDebugging(
+ `[bridge:repl] Failed to decode work secret: ${errorMessage(err)}`,
+ )
+ logEvent('tengu_bridge_repl_work_secret_failed', {})
+ // Can't ack (needs the JWT we failed to decode). stopWork uses OAuth.
+ // Prevents XAUTOCLAIM re-delivering this poisoned item every cycle.
+ await api.stopWork(envId, work.id, false).catch(() => {})
+ continue
+ }
+
+ // Explicitly acknowledge to prevent redelivery. Non-fatal on failure:
+ // server re-delivers, and the onWorkReceived callback handles dedup.
+ logForDebugging(`[bridge:repl] Acknowledging workId=${work.id}`)
+ try {
+ await api.acknowledgeWork(envId, work.id, secret.session_ingress_token)
+ } catch (err) {
+ logForDebugging(
+ `[bridge:repl] Acknowledge failed workId=${work.id}: ${errorMessage(err)}`,
+ )
+ }
+
+ if (work.data.type === 'healthcheck') {
+ logForDebugging('[bridge:repl] Healthcheck received')
+ continue
+ }
+
+ if (work.data.type === 'session') {
+ const workSessionId = work.data.id
+ try {
+ validateBridgeId(workSessionId, 'session_id')
+ } catch {
+ logForDebugging(
+ `[bridge:repl] Invalid session_id in work: ${workSessionId}`,
+ )
+ continue
+ }
+
+ onWorkReceived(
+ workSessionId,
+ secret.session_ingress_token,
+ work.id,
+ secret.use_code_sessions === true,
+ )
+ logForDebugging('[bridge:repl] Work accepted, continuing poll loop')
+ }
+ } catch (err) {
+ if (signal.aborted) break
+
+ // Detect permanent "environment deleted" error — no amount of
+ // retrying will recover. Re-register a new environment instead.
+ // Checked BEFORE the generic BridgeFatalError bail. pollForWork uses
+ // validateStatus: s => s < 500, so 404 is always wrapped into a
+ // BridgeFatalError by handleErrorStatus() — never an axios-shaped
+ // error. The poll endpoint's only path param is the env ID; 404
+ // unambiguously means env-gone (no-work is a 200 with null body).
+ // The server sends error.type='not_found_error' (standard Anthropic
+ // API shape), not a bridge-specific string — but status===404 is
+ // the real signal and survives body-shape changes.
+ if (
+ err instanceof BridgeFatalError &&
+ err.status === 404 &&
+ onEnvironmentLost
+ ) {
+ // If credentials have already been refreshed by a concurrent
+ // reconnection (e.g. WS close handler), the stale poll's error
+ // is expected — skip onEnvironmentLost and retry with fresh creds.
+ const currentEnvId = getCredentials().environmentId
+ if (envId !== currentEnvId) {
+ logForDebugging(
+ `[bridge:repl] Stale poll error for old env=${envId}, current env=${currentEnvId} — skipping onEnvironmentLost`,
+ )
+ consecutiveErrors = 0
+ firstErrorTime = null
+ continue
+ }
+
+ environmentRecreations++
+ logForDebugging(
+ `[bridge:repl] Environment deleted, attempting re-registration (attempt ${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS})`,
+ )
+ logEvent('tengu_bridge_repl_env_lost', {
+ attempt: environmentRecreations,
+ } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
+
+ if (environmentRecreations > MAX_ENVIRONMENT_RECREATIONS) {
+ logForDebugging(
+ `[bridge:repl] Environment re-registration limit reached (${MAX_ENVIRONMENT_RECREATIONS}), giving up`,
+ )
+ onStateChange?.(
+ 'failed',
+ 'Environment deleted and re-registration limit reached',
+ )
+ onFatalError?.()
+ break
+ }
+
+ onStateChange?.('reconnecting', 'environment lost, recreating session')
+ const newCreds = await onEnvironmentLost()
+ // doReconnect() makes several sequential network calls (1-5s).
+ // If the user triggered teardown during that window, its internal
+ // abort checks return false — but we need to re-check here to
+ // avoid emitting a spurious 'failed' + onFatalError() during
+ // graceful shutdown.
+ if (signal.aborted) break
+ if (newCreds) {
+ // Credentials are updated in the outer scope via
+ // reconnectEnvironmentWithSession — getCredentials() will
+ // return the fresh values on the next poll iteration.
+ // Do NOT reset environmentRecreations here — onEnvLost returning
+ // creds only proves we tried to fix it, not that the env is
+ // healthy. A successful poll (above) is the reset point; if the
+ // new env immediately dies again we still want the limit to fire.
+ consecutiveErrors = 0
+ firstErrorTime = null
+ onStateChange?.('ready')
+ logForDebugging(
+ `[bridge:repl] Re-registered environment: ${newCreds.environmentId}`,
+ )
+ continue
+ }
+
+ onStateChange?.(
+ 'failed',
+ 'Environment deleted and re-registration failed',
+ )
+ onFatalError?.()
+ break
+ }
+
+ // Fatal errors (401/403/404/410) — no point retrying
+ if (err instanceof BridgeFatalError) {
+ const isExpiry = isExpiredErrorType(err.errorType)
+ const isSuppressible = isSuppressible403(err)
+ logForDebugging(
+ `[bridge:repl] Fatal poll error: ${err.message} (status=${err.status}, type=${err.errorType ?? 'unknown'})${isSuppressible ? ' (suppressed)' : ''}`,
+ )
+ logEvent('tengu_bridge_repl_fatal_error', {
+ status: err.status,
+ error_type:
+ err.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ })
+ logForDiagnosticsNoPII(
+ isExpiry ? 'info' : 'error',
+ 'bridge_repl_fatal_error',
+ { status: err.status, error_type: err.errorType },
+ )
+ // Cosmetic 403 errors (e.g., external_poll_sessions scope,
+ // environments:manage permission) — suppress user-visible error
+ // but always trigger teardown so cleanup runs.
+ if (!isSuppressible) {
+ onStateChange?.(
+ 'failed',
+ isExpiry
+ ? 'session expired · /remote-control to reconnect'
+ : err.message,
+ )
+ }
+ // Always trigger teardown — matches bridgeMain.ts where fatalExit=true
+ // is unconditional and post-loop cleanup always runs.
+ onFatalError?.()
+ break
+ }
+
+ const now = Date.now()
+
+ // Detect system sleep/wake: if the gap since the last poll error
+ // greatly exceeds the max backoff delay, the machine likely slept.
+ // Reset error tracking so we retry with a fresh budget instead of
+ // immediately giving up.
+ if (
+ lastPollErrorTime !== null &&
+ now - lastPollErrorTime > POLL_ERROR_MAX_DELAY_MS * 2
+ ) {
+ logForDebugging(
+ `[bridge:repl] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting poll error budget`,
+ )
+ logForDiagnosticsNoPII('info', 'bridge_repl_poll_sleep_detected', {
+ gapMs: now - lastPollErrorTime,
+ })
+ consecutiveErrors = 0
+ firstErrorTime = null
+ }
+ lastPollErrorTime = now
+
+ consecutiveErrors++
+ if (firstErrorTime === null) {
+ firstErrorTime = now
+ }
+ const elapsed = now - firstErrorTime
+ const httpStatus = extractHttpStatus(err)
+ const errMsg = describeAxiosError(err)
+ const wsLabel = getWsState?.() ?? 'unknown'
+
+ logForDebugging(
+ `[bridge:repl] Poll error (attempt ${consecutiveErrors}, elapsed ${Math.round(elapsed / 1000)}s, ws=${wsLabel}): ${errMsg}`,
+ )
+ logEvent('tengu_bridge_repl_poll_error', {
+ status: httpStatus,
+ consecutiveErrors,
+ elapsedMs: elapsed,
+ } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
+
+ // Only transition to 'reconnecting' on the first error — stay
+ // there until a successful poll (avoid flickering the UI state).
+ if (consecutiveErrors === 1) {
+ onStateChange?.('reconnecting', errMsg)
+ }
+
+ // Give up after continuous failures
+ if (elapsed >= POLL_ERROR_GIVE_UP_MS) {
+ logForDebugging(
+ `[bridge:repl] Poll failures exceeded ${POLL_ERROR_GIVE_UP_MS / 1000}s (${consecutiveErrors} errors), giving up`,
+ )
+ logForDiagnosticsNoPII('info', 'bridge_repl_poll_give_up')
+ logEvent('tengu_bridge_repl_poll_give_up', {
+ consecutiveErrors,
+ elapsedMs: elapsed,
+ lastStatus: httpStatus,
+ } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
+ onStateChange?.('failed', 'connection to server lost')
+ break
+ }
+
+ // Exponential backoff: 2s → 4s → 8s → 16s → 32s → 60s (cap)
+ const backoff = Math.min(
+ POLL_ERROR_INITIAL_DELAY_MS * 2 ** (consecutiveErrors - 1),
+ POLL_ERROR_MAX_DELAY_MS,
+ )
+ // The poll_due heartbeat-loop exit leaves a healthy lease exposed to
+ // this backoff path. Heartbeat before each sleep so /poll outages
+ // (the VerifyEnvironmentSecretAuth DB path heartbeat was introduced to
+ // avoid) don't kill the 300s lease TTL.
+ if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) {
+ const info = getHeartbeatInfo?.()
+ if (info) {
+ try {
+ await api.heartbeatWork(
+ info.environmentId,
+ info.workId,
+ info.sessionToken,
+ )
+ } catch {
+ // Best-effort — if heartbeat also fails the lease dies, same as
+ // pre-poll_due behavior (where the only heartbeat-loop exits were
+ // ones where the lease was already dying).
+ }
+ }
+ }
+ await sleep(backoff, signal)
+ }
+ }
+
+ logForDebugging(
+ `[bridge:repl] Work poll loop ended (aborted=${signal.aborted}) env=${getCredentials().environmentId}`,
+ )
+}
+
+// Exported for testing only
+export {
+ startWorkPollLoop as _startWorkPollLoopForTesting,
+ POLL_ERROR_INITIAL_DELAY_MS as _POLL_ERROR_INITIAL_DELAY_MS_ForTesting,
+ POLL_ERROR_MAX_DELAY_MS as _POLL_ERROR_MAX_DELAY_MS_ForTesting,
+ POLL_ERROR_GIVE_UP_MS as _POLL_ERROR_GIVE_UP_MS_ForTesting,
+}
+
+
diff --git a/src/bridge/replBridgeHandle.ts b/src/bridge/replBridgeHandle.ts
new file mode 100644
index 0000000..afb323f
--- /dev/null
+++ b/src/bridge/replBridgeHandle.ts
@@ -0,0 +1,38 @@
+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
+}
+
+
diff --git a/src/bridge/replBridgeTransport.ts b/src/bridge/replBridgeTransport.ts
new file mode 100644
index 0000000..b37709b
--- /dev/null
+++ b/src/bridge/replBridgeTransport.ts
@@ -0,0 +1,372 @@
+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