-[English](../../README.md) | [中文](../../README.md#中文版) | [Français](README_FR.md) | [Русский](README_RU.md) | **हिन्दी** | [العربية](README_AR.md) | [Português](README_PT.md)
+[English](../../README.md) | [中文](README_ZH.md) | [Français](README_FR.md) | [Русский](README_RU.md) | **हिन्दी** | [العربية](README_AR.md) | [Português](README_PT.md)
# 🚀 Claude Code Python
@@ -72,7 +72,16 @@
### बहु-प्रदाता समर्थन
```python
-providers = ["Anthropic Claude", "OpenAI GPT", "Zhipu GLM"] # + आसानी से विस्तारणीय
+providers = [
+ # नेटिव / विशिष्ट प्रोटोकॉल
+ "anthropic", "minimax", "deepseek", "zai", "openrouter", "openai", "gemini",
+ # OpenAI-संगत प्रदाता
+ "nvidia-nim", "atlascloud", "wanjie-ark", "volcengine", "xiaomi-mimo",
+ "novita", "fireworks", "siliconflow", "siliconflow-cn", "arcee", "moonshot",
+ "huggingface", "together", "stepfun", "deepinfra",
+ # लोकल सर्वर (कोई API key आवश्यक नहीं)
+ "ollama", "vllm", "sglang",
+] # 25 प्रदाता; nim, kimi, hf जैसे उपनाम स्वतः हल हो जाते हैं
```
### इंटरैक्टिव REPL
@@ -96,7 +105,7 @@ See [README.md](../../README.md#skills-slash-commands) for a quick tutorial on c
### पूर्ण CLI
```bash
-clawcodex # REPL प्रारंभ करें
+clawcodex --dangerously-skip-permissions # REPL प्रारंभ करें
clawcodex login # API कॉन्फ़िगर करें
clawcodex --version # संस्करण जांचें
clawcodex config # सेटिंग्स देखें
@@ -131,6 +140,28 @@ source .venv/bin/activate
uv pip install -r requirements.txt
```
+कॉन्फ़िगरेशन फ़ाइल `~/.clawcodex/config.json` में सहेजी जाती है। न्यूनतम उदाहरण:
+
+```json
+{
+ "default_provider": "deepseek",
+ "providers": {
+ "deepseek": {
+ "api_key": "xxx-xxx",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
+ }
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
+ }
+}
+```
+
+> **नोट:** WebSearch टूल के लिए `TAVILY_API_KEY` आवश्यक है — [tavily.com](https://tavily.com) पर कुंजी प्राप्त करें।
+
+`session`, `settings` और `env` ब्लॉक वैकल्पिक हैं — इन्हें छोड़ने पर उचित डिफ़ॉल्ट मान लागू होते हैं (पूरी संरचना नीचे)।
+
### कॉन्फ़िगर करें
#### विकल्प 1: इंटरैक्टिव (अनुशंसित)
@@ -141,7 +172,7 @@ python -m src.cli login
यह प्रक्रिया:
-1. आपको एक प्रदाता चुनने के लिए कहेगी: anthropic / openai / glm
+1. आपको एक प्रदाता चुनने के लिए कहेगी: anthropic / openai / gemini / zai / minimax / openrouter / deepseek, या कोई भी OpenAI-संगत वेंडर (together, novita, fireworks, moonshot, nvidia-nim, siliconflow, deepinfra, huggingface, …) और स्थानीय सर्वर (ollama / vllm / sglang)
2. उस प्रदाता की API कुंजी मांगेगी
3. वैकल्पिक रूप से एक कस्टम base URL सहेजेगी
4. वैकल्पिक रूप से एक डिफ़ॉल्ट मॉडल सहेजेगी
@@ -151,23 +182,51 @@ python -m src.cli login
```json
{
- "default_provider": "glm",
+ "default_provider": "deepseek",
"providers": {
"anthropic": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.anthropic.com",
- "default_model": "claude-sonnet-4-20250514"
+ "default_model": "claude-sonnet-4-6"
},
"openai": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.openai.com/v1",
- "default_model": "gpt-4"
+ "default_model": "gpt-5.4"
+ },
+ "zai": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "default_model": "glm-5.2"
+ },
+ "minimax": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.minimaxi.com/anthropic",
+ "default_model": "MiniMax-M2.7"
+ },
+ "openrouter": {
+ "api_key": "your-api-key",
+ "base_url": "https://openrouter.ai/api/v1",
+ "default_model": "deepseek/deepseek-v4-pro"
},
- "glm": {
- "api_key": "base64-encoded-key",
- "base_url": "https://open.bigmodel.cn/api/paas/v4",
- "default_model": "glm-4.5"
+ "deepseek": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
}
+ },
+ "session": {
+ "auto_save": true,
+ "max_history": 100
+ },
+ "settings": {
+ "advisor_enabled": false,
+ "advisor_model": "claude-sonnet-4-6",
+ "advisor_client_mode": false,
+ "advisor_provider": "openai"
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
}
}
```
diff --git a/docs/i18n/README_PT.md b/docs/i18n/README_PT.md
index 0e8505c1a..df6de3f5a 100644
--- a/docs/i18n/README_PT.md
+++ b/docs/i18n/README_PT.md
@@ -1,6 +1,6 @@
-[English](../../README.md) | [中文](../../README.md#中文版) | [Français](README_FR.md) | [Русский](README_RU.md) | [हिन्दी](README_HI.md) | [العربية](README_AR.md) | **Português**
+[English](../../README.md) | [中文](README_ZH.md) | [Français](README_FR.md) | [Русский](README_RU.md) | [हिन्दी](README_HI.md) | [العربية](README_AR.md) | **Português**
# 🚀 Claude Code Python
@@ -72,7 +72,16 @@
### Suporte Multi-Provedor
```python
-providers = ["Anthropic Claude", "OpenAI GPT", "Zhipu GLM"] # + fácil de estender
+providers = [
+ # Protocolos nativos / específicos
+ "anthropic", "minimax", "deepseek", "zai", "openrouter", "openai", "gemini",
+ # Provedores compatíveis com OpenAI
+ "nvidia-nim", "atlascloud", "wanjie-ark", "volcengine", "xiaomi-mimo",
+ "novita", "fireworks", "siliconflow", "siliconflow-cn", "arcee", "moonshot",
+ "huggingface", "together", "stepfun", "deepinfra",
+ # Servidores locais (nenhuma chave de API necessária)
+ "ollama", "vllm", "sglang",
+] # 25 provedores; aliases como nim, kimi, hf são resolvidos automaticamente
```
### REPL Interativo
@@ -96,7 +105,7 @@ See [README.md](../../README.md#skills-slash-commands) for a quick tutorial on c
### CLI Completo
```bash
-clawcodex # Iniciar REPL
+clawcodex --dangerously-skip-permissions # Iniciar REPL
clawcodex login # Configurar API
clawcodex --version # Verificar versão
clawcodex config # Ver configurações
@@ -131,6 +140,28 @@ source .venv/bin/activate
uv pip install -r requirements.txt
```
+O arquivo de configuração é salvo em `~/.clawcodex/config.json`. Exemplo mínimo:
+
+```json
+{
+ "default_provider": "deepseek",
+ "providers": {
+ "deepseek": {
+ "api_key": "xxx-xxx",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
+ }
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
+ }
+}
+```
+
+> **Nota:** `TAVILY_API_KEY` é necessário para a ferramenta WebSearch — obtenha uma chave em [tavily.com](https://tavily.com).
+
+Os blocos `session`, `settings` e `env` são opcionais — valores padrão razoáveis se aplicam quando omitidos (estrutura completa abaixo).
+
### Configurar
#### Opção 1: Interativo (Recomendado)
@@ -141,7 +172,7 @@ python -m src.cli login
Este processo irá:
-1. pedir que você escolha um provedor: anthropic / openai / glm
+1. pedir que você escolha um provedor: anthropic / openai / gemini / zai / minimax / openrouter / deepseek, ou qualquer provedor compatível com OpenAI (together, novita, fireworks, moonshot, nvidia-nim, siliconflow, deepinfra, huggingface, …) e servidores locais (ollama / vllm / sglang)
2. pedir a chave API desse provedor
3. salvar opcionalmente uma URL base personalizada
4. salvar opcionalmente um modelo padrão
@@ -151,23 +182,51 @@ O arquivo de configuração é salvo em `~/.clawcodex/config.json`. Exemplo de e
```json
{
- "default_provider": "glm",
+ "default_provider": "deepseek",
"providers": {
"anthropic": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.anthropic.com",
- "default_model": "claude-sonnet-4-20250514"
+ "default_model": "claude-sonnet-4-6"
},
"openai": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.openai.com/v1",
- "default_model": "gpt-4"
+ "default_model": "gpt-5.4"
+ },
+ "zai": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "default_model": "glm-5.2"
+ },
+ "minimax": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.minimaxi.com/anthropic",
+ "default_model": "MiniMax-M2.7"
+ },
+ "openrouter": {
+ "api_key": "your-api-key",
+ "base_url": "https://openrouter.ai/api/v1",
+ "default_model": "deepseek/deepseek-v4-pro"
},
- "glm": {
- "api_key": "base64-encoded-key",
- "base_url": "https://open.bigmodel.cn/api/paas/v4",
- "default_model": "glm-4.5"
+ "deepseek": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
}
+ },
+ "session": {
+ "auto_save": true,
+ "max_history": 100
+ },
+ "settings": {
+ "advisor_enabled": false,
+ "advisor_model": "claude-sonnet-4-6",
+ "advisor_client_mode": false,
+ "advisor_provider": "openai"
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
}
}
```
diff --git a/docs/i18n/README_RU.md b/docs/i18n/README_RU.md
index 5eb3dd632..685d30778 100644
--- a/docs/i18n/README_RU.md
+++ b/docs/i18n/README_RU.md
@@ -1,6 +1,6 @@
-[English](../../README.md) | [中文](../../README.md#中文版) | [Français](README_FR.md) | **Русский** | [हिन्दी](README_HI.md) | [العربية](README_AR.md) | [Português](README_PT.md)
+[English](../../README.md) | [中文](README_ZH.md) | [Français](README_FR.md) | **Русский** | [हिन्दी](README_HI.md) | [العربية](README_AR.md) | [Português](README_PT.md)
# 🚀 Claude Code Python
@@ -72,7 +72,16 @@
### Поддержка нескольких провайдеров
```python
-providers = ["Anthropic Claude", "OpenAI GPT", "Zhipu GLM"] # + легко расширить
+providers = [
+ # Нативные / специфичные протоколы
+ "anthropic", "minimax", "deepseek", "zai", "openrouter", "openai", "gemini",
+ # OpenAI-совместимые провайдеры
+ "nvidia-nim", "atlascloud", "wanjie-ark", "volcengine", "xiaomi-mimo",
+ "novita", "fireworks", "siliconflow", "siliconflow-cn", "arcee", "moonshot",
+ "huggingface", "together", "stepfun", "deepinfra",
+ # Локальные серверы (ключ API не требуется)
+ "ollama", "vllm", "sglang",
+] # 25 провайдеров; псевдонимы вроде nim, kimi, hf разрешаются автоматически
```
### Интерактивный REPL
@@ -96,7 +105,7 @@ See [README.md](../../README.md#skills-slash-commands) for a quick tutorial on c
### Полный CLI
```bash
-clawcodex # Запустить REPL
+clawcodex --dangerously-skip-permissions # Запустить REPL
clawcodex login # Настроить API
clawcodex --version # Проверить версию
clawcodex config # Просмотреть настройки
@@ -131,6 +140,28 @@ source .venv/bin/activate
uv pip install -r requirements.txt
```
+Файл конфигурации сохраняется в `~/.clawcodex/config.json`. Минимальный пример:
+
+```json
+{
+ "default_provider": "deepseek",
+ "providers": {
+ "deepseek": {
+ "api_key": "xxx-xxx",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
+ }
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
+ }
+}
+```
+
+> **Примечание:** `TAVILY_API_KEY` требуется для инструмента WebSearch — получите ключ на [tavily.com](https://tavily.com).
+
+Блоки `session`, `settings` и `env` необязательны — при их отсутствии применяются разумные значения по умолчанию (полная структура ниже).
+
### Настройка
#### Вариант 1: Интерактивный (Рекомендуется)
@@ -141,7 +172,7 @@ python -m src.cli login
Этот процесс:
-1. попросит вас выбрать провайдера: anthropic / openai / glm
+1. попросит вас выбрать провайдера: anthropic / openai / gemini / zai / minimax / openrouter / deepseek, либо любой OpenAI-совместимый провайдер (together, novita, fireworks, moonshot, nvidia-nim, siliconflow, deepinfra, huggingface, …) и локальные серверы (ollama / vllm / sglang)
2. попросит ввести API ключ этого провайдера
3. при необходимости сохранит пользовательский base URL
4. при необходимости сохранит модель по умолчанию
@@ -151,23 +182,51 @@ python -m src.cli login
```json
{
- "default_provider": "glm",
+ "default_provider": "deepseek",
"providers": {
"anthropic": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.anthropic.com",
- "default_model": "claude-sonnet-4-20250514"
+ "default_model": "claude-sonnet-4-6"
},
"openai": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.openai.com/v1",
- "default_model": "gpt-4"
+ "default_model": "gpt-5.4"
+ },
+ "zai": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "default_model": "glm-5.2"
+ },
+ "minimax": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.minimaxi.com/anthropic",
+ "default_model": "MiniMax-M2.7"
+ },
+ "openrouter": {
+ "api_key": "your-api-key",
+ "base_url": "https://openrouter.ai/api/v1",
+ "default_model": "deepseek/deepseek-v4-pro"
},
- "glm": {
- "api_key": "base64-encoded-key",
- "base_url": "https://open.bigmodel.cn/api/paas/v4",
- "default_model": "glm-4.5"
+ "deepseek": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
}
+ },
+ "session": {
+ "auto_save": true,
+ "max_history": 100
+ },
+ "settings": {
+ "advisor_enabled": false,
+ "advisor_model": "claude-sonnet-4-6",
+ "advisor_client_mode": false,
+ "advisor_provider": "openai"
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
}
}
```
diff --git a/docs/i18n/README_ZH.md b/docs/i18n/README_ZH.md
new file mode 100644
index 000000000..2f39b6bca
--- /dev/null
+++ b/docs/i18n/README_ZH.md
@@ -0,0 +1,601 @@
+
+
+[English](../../README.md) | **中文** | [Français](README_FR.md) | [Русский](README_RU.md) | [हिन्दी](README_HI.md) | [العربية](README_AR.md) | [Português](README_PT.md)
+
+# ClawCodex
+
+**面向生产使用的 Claude Code Python 重写版 —— 真实架构、可靠的 CLI Agent**
+
+*从 TypeScript 参考实现移植而来,并扩展了 Python 原生运行时*
+
+***
+
+[](https://github.com/agentforce314/clawcodex/stargazers)
+[](https://github.com/agentforce314/clawcodex/network/members)
+[](https://opensource.org/licenses/MIT)
+[](https://www.python.org/downloads/)
+
+
+**🔥 活跃开发中 • 每周更新新功能 🔥**
+
+
+
+
+
+***
+
+## ⚡ 快速安装
+
+```bash
+git clone https://github.com/agentforce314/clawcodex.git
+cd clawcodex
+python3 -m venv .venv && source .venv/bin/activate # Python 3.10+
+pip install -r requirements.txt
+
+python -m src.cli login # 配置写入 ~/.clawcodex/config.json
+
+python -m src.cli --dangerously-skip-permissions # 启动 REPL
+```
+
+配置文件保存在 `~/.clawcodex/config.json`。最小示例:
+
+```json
+{
+ "default_provider": "deepseek",
+ "providers": {
+ "deepseek": {
+ "api_key": "xxx-xxx",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
+ }
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
+ }
+}
+```
+
+> **注意:** WebSearch 工具需要 `TAVILY_API_KEY`——可在 [tavily.com](https://tavily.com) 获取密钥。
+
+`session`、`settings` 和 `env` 块均为可选——省略时会使用合理的默认值。完整结构见 [配置](#配置)。
+
+***
+
+## 📰 新闻
+
+- **2026-06-16:** **Z.ai GLM-5.2 支持(#343)** —— 新增 `zai` provider,对接 Z.ai 的 OpenAI 兼容 GLM 编程套餐(`https://api.z.ai/api/coding/paas/v4`),提供 `GLM-5.1` 与 `GLM-5.2` 预览版;GLM-5.2 的编码能力可比肩 Claude Opus 4.7。首个用 GLM-5.2 端到端生成的应用——一个 [2026 世界杯介绍页](../../demos/wc26-intro/index.html)(动效首屏 + 实时倒计时、三个主办国、16 座球场、赛制说明与破纪录数据)。
+- **2026-06-11:** **代码库统计** —— Python 文件总数:1,093 个;Python 代码总行数:**233,520 行**(高于 2026-05-29 的 213,777 行;新增约 1.97 万行,主要来自交互式命令系统批次、动态 workflow 引擎 + `/deep-research`,以及 Tavily 网络工具链更新)。
+- **2026-06-10 至 2026-06-11:** **动态 workflow 引擎 + `/deep-research`(#262–#264、#266–#271)** —— Python workflow 引擎核心(`agent()`/`parallel()`/`pipeline()`/`phase()`、运行日志、断点恢复)完成端到端接线:Workflow 工具、`/workflows` TUI 对话框 + 状态栏指示、按 agent 重试、worktree 隔离、结果投递,以及注册为斜杠命令的内置 `/deep-research` 研究工作流。可靠性方面:LLM 读取超时统一应用到所有 openai 兼容 provider(#269),并行 agent 不再在事件循环上串行执行(#270),deep-research 的 synthesize 步骤禁用工具、避免报告撰写 agent 陷入循环(#271)。后续修复:workflow max-turns 上限修复(#272)、deep-research verdict 枚举修复(#273)、带阶段进度与各 agent 统计的 `/workflows` 实时监控(#287)。
+- **2026-06-10:** **Web 工具链更新(#265)** —— 用基于 Tavily 的 WebSearch 取代已失效的 DuckDuckGo 抓取,并新增基于配置文件的密钥存储;WebFetch 重写为确定性的 markdown/text/html 提取(借鉴 opencode)。
+- **2026-05-30 至 2026-06-09:** **交互式命令系统对齐(#230–#261)** —— 交互式移植 `/theme`、`/effort`、`/model`、`/logo`、`/mcp`、`/tasks`、`/diff`、`/export`、`/output-style`、`/statusline`、`/release-notes`、`/copy`、`/vim`、`/memory`、`/stickers` 与 `/rename`,构建于新的 prompt-text 原语与交互式命令桥之上;技能注册与模型工具暴露接线;会话持久化生产者(`SessionPersister` + agent-bridge 接线);外加扩展思考支持(#249)与模型错误吞噬修复(#250)。
+- **2026-05-29:** **代码库统计** —— Python 文件总数:977 个;Python 代码总行数:**213,777 行**(高于 2026-05-21 的 183,768 行;新增约 3 万行,来自远程桥接对齐移植(阶段 0–18)、`/buddy` 虚拟伙伴子系统与 CLI 传输层)。
+- **2026-05-29:** **远程桥接对齐 + CLI 传输(#200–#226)** —— 完成远程控制桥接阶段 0–18 的完整移植:bridge API 客户端、子 CLI 会话运行器、免环境变量的 v2 编排器、多会话守护进程、worktree 启动、原地重连、带崩溃恢复的常驻模式、JWT 刷新,以及 v1 `WebSocketTransport` / `SerialBatchEventUploader` 写路径与混合分发。另含 CLI 传输工厂、合并式 worker 状态上传器与 `RemoteIO` 桥(#226);新增 `/buddy` 虚拟伙伴命令——孵化 / 抚摸 / 状态 / 静音(#225)。
+- **2026-05-21:** **代码库统计** —— Python 文件总数:890 个;Python 代码总行数:**183,768 行**(高于 2026-05-16 的 177,428 行;因 `src/tool_system/agent_loop.py` 合并进 `src/query/query.py`,文件数净减 4 个)。
+- **2026-05-21:** **`/advisor` 省 token 编码模式(#181–#193)** —— 让低成本 worker 模型(`haiku-4-5`,每 Mtok $1/$5)与高成本 reviewer(`opus-4-7`,$5/$25)搭档,仅在关键决策点咨询后者;典型会话比纯 opus 便宜约 6 倍。支持显式 `
:` 语法、跨 provider 路由(例如经 litellm 使用 `deepseek/deepseek-v4-pro` worker + `claude-opus-4-7` advisor),并在状态栏实时显示 worker/advisor 的 token 数与美元成本。
+- **2026-05-16:** **代码库统计** —— Python 文件总数:894 个;Python 代码总行数:**177,428 行**(高于 2026-05-14 的 167,034 行;两天新增约 1.04 万行,主要是 ESC 取消加固与图像处理对齐)。
+
+📚 更早的条目已移至完整的 **[News 归档](../NEWS.md)**。
+
+***
+
+## 🎯 为什么是 ClawCodex?
+
+**ClawCodex** 是一个**面向生产使用的 Claude Code Python 重写版**:从**真实的 TypeScript 架构**移植而来,并以**可用的 CLI Agent** 形式交付,而不只是一份源码镜像。
+
+- **真实 Agent Runtime** —— 工具调用循环、流式 REPL、会话历史与多轮执行
+- **高保真移植** —— 保留 Claude Code 的原始架构,同时做符合 Python 风格的实现
+- **适合二次开发** —— 可读的 Python 代码、丰富的测试,以及基于 Markdown 的技能扩展
+- **多 LLM 提供商** —— 相对上游最大的进展:Claude Code 仅围绕 Claude 系列模型构建,而 ClawCodex 致力于接入**所有主流 LLM 提供商**,让你为 agentic 编程选择最**灵活**、最**具性价比**的技术栈
+
+**一个真正可跑的 Claude Code 风格 Python 终端工作流:流式回答、调用工具、抓取上下文,并通过 skills 扩展行为。**
+
+**🚀 立即试用!Fork 它、修改它、让它成为你的!欢迎提交 Pull Request!**
+
+***
+
+## 🏆 SWE-bench Verified —— 相同模型下 `clawcodex` 超越 `openclaude`
+
+
+
+在完整的 **SWE-bench Verified** 数据集(499 个实例,公开的 agent 编码榜单)上,两个 agent 均由 **Gemini 2.5 Pro** 驱动,运行在我们的标准化评测框架中:
+
+| Agent | 已解决 | 未解决 | 错误 |
+|---|---:|---:|---:|
+| **clawcodex** | **291 / 499(58.2%)** | 124 | 84 |
+| openclaude | 265 / 499(53.0%) | 144 | 90 |
+
+- ✅ **两者都解决**:241 🟢 **仅 clawcodex 解决**:50 🔵 **仅 openclaude 解决**:24 ❌ **均未解决**:184
+
+本地复现 —— 完整流程(累积分批、`--predict-workers N`、`--capture-traces`)见 [`eval/README.md`](../../eval/README.md)。
+
+***
+
+## ⭐ Star 历史
+
+[在 star-history.com 查看 Star 历史图表](https://www.star-history.com/?repos=agentforce314%2Fclawcodex&type=date&legend=top-left)
+
+## ✨ 特性
+
+### 流式 Agent 体验
+
+```text
+>>> /stream on
+>>> 解释 tests/test_agent_loop.py
+[流式回答中...]
+• Read (tests/test_agent_loop.py) running...
+ ↳ lines 1-180
+>>> /render-last
+```
+
+- 直接回答支持真实 API 流式输出,带工具的 agent loop 也具备更完整的流式体验
+- 内置 `/stream` 开关用于实时输出,`/render-last` 可按需把上一条回答重新渲染为 Markdown
+- 专门为终端演示优化:一边看回答流出,一边看到工具调用,并保留稳定回退路径
+
+### 可编程 Skill Runtime
+
+```md
+---
+description: 用类比 + 图示解释代码
+allowed-tools:
+ - Read
+ - Grep
+ - Glob
+arguments: [path]
+---
+
+请解释 $path 的实现:先给一个类比,再画一个结构示意图。
+```
+
+- 基于 `SKILL.md` 的 Markdown 斜杠命令
+- 支持项目级技能、用户级技能、命名参数替换与工具限制
+
+### 多提供商支持
+
+ClawCodex 的核心优势是**多提供商支持**:Claude Code 以 **Claude** 系列模型为目标,而我们希望在同一套 Agent 运行时之上支持**所有主流 LLM 提供商**——你可以自由切换厂商、区域与价位,而不必放弃工具、技能与编码闭环。正是这种灵活性,让 agentic 编程在规模化使用时真正可行。
+
+```python
+providers = [
+ # 原生 / 专用协议
+ "anthropic", "minimax", "deepseek", "zai", "openrouter", "openai", "gemini",
+ # OpenAI 兼容厂商
+ "nvidia-nim", "atlascloud", "wanjie-ark", "volcengine", "xiaomi-mimo",
+ "novita", "fireworks", "siliconflow", "siliconflow-cn", "arcee", "moonshot",
+ "huggingface", "together", "stepfun", "deepinfra",
+ # 本地服务(无需 API Key)
+ "ollama", "vllm", "sglang",
+] # 共 25 个 provider;nim、kimi、hf 等别名自动解析
+```
+
+新增任意 OpenAI 兼容厂商,只需在 `src/providers/openai_compatible_specs.py`
+中加一行(base URL + 默认模型 + API Key 环境变量)。API Key 既可来自配置文件,
+也可来自该 provider 的标准环境变量(如 `TOGETHER_API_KEY`、`MOONSHOT_API_KEY`),
+因此大多数 provider 无需手动编辑 `config.json` 即可使用。
+
+### 交互式界面(TypeScript Ink TUI)
+
+交互界面为 **TypeScript Ink TUI** —— 一个终端客户端,它派生并托管一个 Python **agent-server** 子进程,通过管道(NDJSON)通信。直接运行 `clawcodex`(不带模式参数)即可启动;`clawcodex tui` 是其显式形式。(原先的进程内 Rich REPL 与 Textual TUI 已移除,统一改用这个更高保真度的客户端。)
+
+```text
+> 你好!
+Assistant: 嗨!我是 ClawCodex,一个 Python 重实现...
+
+> /help # 显示命令
+> /theme dark # 切换配色主题
+> @src/cli.py # @ 提及文件(模糊文件索引)
+> /explain-code qsort.py # 运行 SKILL.md 技能(或 /skill …)
+
+# 需要 Node 18+ 与已构建的 ui-tui/dist(安装脚本会自动构建);`clawcodex -p` 为无需 Node 的 headless 路径。
+```
+
+### 完整的 CLI
+
+```bash
+clawcodex # 交互式 Ink TUI(默认)
+clawcodex tui # 交互式 Ink TUI(显式)
+clawcodex login # 交互式配置 API key
+clawcodex config # 查看 ~/.clawcodex/config.json 中的配置
+clawcodex --version # 版本信息
+
+# 非交互 / 脚本化(管道、CI、自动化 agent)
+clawcodex -p "总结 src/cli.py"
+clawcodex -p "Hello" --output-format json
+clawcodex -p --output-format stream-json --input-format stream-json < events.ndjson
+
+# 单次运行覆盖配置
+clawcodex --provider anthropic --model claude-sonnet-4-6 -p "Hi"
+clawcodex --max-turns 10 --allowed-tools Read,Grep -p "查找 TODO"
+
+# 权限控制(REPL、TUI 与 -p 均生效)
+clawcodex --permission-mode plan # plan / acceptEdits / dontAsk
+clawcodex --dangerously-skip-permissions -p "ls" # 跳过所有权限检查
+clawcodex --allow-dangerously-skip-permissions # 允许之后通过 /permission-mode 切换为 bypass
+```
+
+> **`--dangerously-skip-permissions`** 会在整个会话期间禁用所有工具权限检查。
+> 仅建议在无互联网访问的沙箱容器/虚拟机中使用。当进程以 root/sudo
+> 运行时该参数会被拒绝,除非设置了 `IS_SANDBOX=1` 或 `CLAUDE_CODE_BUBBLEWRAP=1`。
+
+***
+
+## 📊 状态
+
+| 组件 | 状态 | 数量 |
+| ----- | ------ | ------ |
+| REPL 命令 | ✅ 完成 | 内置命令 + `/tools`、`/stream`、`/context`、`/compact`、技能等 |
+| 工具系统 | ✅ 完成 | 30+ 工具 |
+| 自动化测试 | ✅ 已覆盖 | 工具、agent loop、providers、parity、REPL、认证等 |
+| 文档 | ✅ 完成 | 指南、多语言 README、[FEATURE_LIST.md](../../FEATURE_LIST.md) |
+
+### 核心系统
+
+| 系统 | 状态 | 描述 |
+|------|------|------|
+| CLI 入口 | ✅ | `clawcodex`、`clawcodex tui`、`login`、`config`、`-p` / `--print`、`--version` |
+| 交互式界面 | ✅ | TypeScript Ink TUI(终端客户端 + Python agent-server 子进程);斜杠命令、@ 文件提及、主题、权限对话框 |
+| 多提供商支持 | ✅ | 25 个 provider —— Anthropic、OpenAI、Gemini、智谱 GLM、Minimax、OpenRouter、DeepSeek,外加 OpenAI 兼容 provider 注册表(NVIDIA NIM、Together、Novita、Fireworks、SiliconFlow、Moonshot/Kimi、DeepInfra、Hugging Face、火山引擎、StepFun、Arcee、AtlasCloud、小米 MiMo、万捷 Ark)以及本地服务(Ollama、vLLM、SGLang)。含 Anthropic→OpenAI 的 image / document 块转换,适配具备视觉能力的 OpenAI 兼容后端;每个 provider 均支持 API Key 环境变量回退 |
+| 会话持久化 | ✅ | 本地保存/加载会话 |
+| Agent Loop | ✅ | 工具调用循环;支持流式与无头模式 |
+| Skill 系统 | ✅ | 基于 SKILL.md 的斜杠技能:参数与工具白名单 |
+| 取消 / 中止 | ✅ | ESC 可在约 50ms 内中止进行中的 Bash、Grep/Glob 以及所有 provider 的流式 HTTP;子 agent 拥有隔离的 `AbortController`;`Bash` 的 `tool_result` 区分超时与 ESC 中止 |
+| 图像处理 | ✅ | 与 TS 对齐的 Read 管线(魔数嗅探、按 API 限制缩放/压缩);`@image.png` @-提及内联为 `ImageBlock`;`BaseProvider._prepare_messages` 中调用 API 前的 base64 大小校验;二进制 @-提及(PDF/zip/docx/…)转为 Read 工具提示而非乱码 |
+| 上下文构建 | 🟡 | workspace / git / `CLAUDE.md` 注入;更丰富的摘要与 memory 仍在演进 |
+| 权限系统 | 🟡 | 框架与检查逻辑已有;全面集成进行中 |
+| MCP | 🟡 | MCP 相关工具与接线已有;协议层/运行时仍在完善 |
+
+### 工具系统(已实现 30+ 工具)
+
+| 类别 | 工具 | 状态 |
+|------|------|------|
+| 文件操作 | Read, Write, Edit, Glob, Grep | ✅ 完成 |
+| 系统 | Bash 执行 | ✅ 完成 |
+| 网络 | WebFetch, WebSearch | ✅ 完成 |
+| 交互 | AskUserQuestion, SendMessage | ✅ 完成 |
+| 任务管理 | TodoWrite, TaskManager, TaskStop | ✅ 完成 |
+| Agent 工具 | Agent, Brief, Team | ✅ 完成 |
+| 配置 | Config, PlanMode, Cron | ✅ 完成 |
+| MCP | MCP 工具与资源 | 🟡 工具已接线;完整 client/runtime 仍在演进 |
+| 其他 | LSP, Worktree, Skill, ToolSearch | ✅ 完成 |
+
+### 路线图进度
+
+- ✅ **阶段 0**:可安装、可运行的 CLI
+- ✅ **阶段 1**:Claude Code 核心 MVP 体验
+- ✅ **阶段 2**:真实工具调用闭环
+- 🟡 **阶段 3**:上下文深度、权限集成、类 `/resume` 的恢复能力(进行中)
+- 🟡 **阶段 4**:MCP 运行时深化、插件与可扩展性(工具已有,平台能力持续推进)
+- ⏳ **阶段 5**:Python 原生差异化特性
+
+**详细功能状态和 PR 指南请查看 [FEATURE_LIST.md](../../FEATURE_LIST.md)。**
+
+## 🚀 快速开始
+
+### 安装
+
+```bash
+git clone https://github.com/agentforce314/clawcodex.git
+cd clawcodex
+
+# 创建虚拟环境(推荐使用 uv)
+uv venv --python 3.11
+source .venv/bin/activate
+
+# 安装包与 console 入口(推荐)
+uv pip install -e ".[dev]"
+
+# 或:先装依赖再 editable 安装
+# uv pip install -r requirements.txt && uv pip install -e .
+```
+
+### 配置
+
+#### 方式 1:交互式(推荐)
+
+```bash
+clawcodex login
+# 或: python -m src.cli login
+```
+
+这个流程会:
+
+1. 让你选择 provider:anthropic / openai / gemini / zai / minimax / openrouter / deepseek,或任意 OpenAI 兼容厂商(together、novita、fireworks、moonshot、nvidia-nim、siliconflow、deepinfra、huggingface 等)以及本地服务(ollama / vllm / sglang)
+2. 让你输入该 provider 的 API key
+3. 可选:保存自定义 base URL
+4. 可选:保存默认 model
+5. 将该 provider 设为默认
+
+配置文件保存在 `~/.clawcodex/config.json`。示例结构:
+
+```json
+{
+ "default_provider": "deepseek",
+ "providers": {
+ "anthropic": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.anthropic.com",
+ "default_model": "claude-sonnet-4-6"
+ },
+ "openai": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.openai.com/v1",
+ "default_model": "gpt-5.4"
+ },
+ "zai": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "default_model": "glm-5.2"
+ },
+ "minimax": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.minimaxi.com/anthropic",
+ "default_model": "MiniMax-M2.7"
+ },
+ "openrouter": {
+ "api_key": "your-api-key",
+ "base_url": "https://openrouter.ai/api/v1",
+ "default_model": "deepseek/deepseek-v4-pro"
+ },
+ "deepseek": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.deepseek.com",
+ "default_model": "deepseek-v4-pro"
+ }
+ },
+ "session": {
+ "auto_save": true,
+ "max_history": 100
+ },
+ "settings": {
+ "advisor_model": "claude-sonnet-4-6",
+ "advisor_client_mode": false,
+ "advisor_provider": "openai"
+ },
+ "env": {
+ "TAVILY_API_KEY": "tvly-YOUR-TAVILY-API-KEY"
+ }
+}
+```
+
+- **`session`** —— REPL 会话持久化:`auto_save` 自动保存每个会话;`max_history` 限制保留的对话轮数。
+- **`settings`** —— 后台辅助功能所用的 advisor 模型(`advisor_provider` / `advisor_model`,以及控制是否经由客户端路由的 `advisor_client_mode`)。
+- **`env`** —— 启动时注入的密钥与环境变量(例如用于 Web 搜索的 `TAVILY_API_KEY`)。通过 `clawcodex config` 管理;这里的键会被导出到进程环境,但不会覆盖你在 shell 中已设置的值。
+
+### 运行
+
+```bash
+clawcodex # 启动交互式 Ink TUI(等同于 python -m src.cli)
+clawcodex --help # 全部参数:-p、--provider、--model 等
+```
+
+**就这样!** 配置密钥后即可使用 CLI 或 REPL。
+
+***
+
+## 💡 使用
+
+### REPL 命令
+
+| 命令 | 描述 |
+| --- | --- |
+| `/` | 显示命令与技能 |
+| `/help` | 帮助 |
+| `/tools` | 列出已注册工具名 |
+| `/tool ` | 以 JSON 输入直接调用工具 |
+| `/stream` | 流式渲染:`/stream on`、`off` 或 `toggle` |
+| `/render-last` | 将上一条助手回复重新渲染为 Markdown |
+| `/save`、`/load ` | 保存或加载会话 |
+| `/clear` | 清空对话(亦支持 `/reset`、`/new`) |
+| `/skill` | 技能启动流程 |
+| `/context` | 工作区 / 提示上下文(若可用) |
+| `/compact` | 压缩或清空对话(不可用时回退为清空) |
+| `/exit`、`/quit`、`/q` | 退出 |
+
+### Skills(技能 / 斜杠命令)
+
+技能是存放在 `.clawcodex/skills` 下的 Markdown 斜杠命令。每个技能对应一个目录,并且文件名固定为 `SKILL.md`。
+
+**1)创建项目技能**
+
+创建:
+
+```text
+/.clawcodex/skills//SKILL.md
+```
+
+示例:
+
+```md
+---
+description: 用类比 + 图示解释代码
+when_to_use: 在解释代码如何工作时使用
+allowed-tools:
+ - Read
+ - Grep
+ - Glob
+arguments: [path]
+---
+
+请解释 $path 的实现:先给一个类比,再画一个结构示意图。
+```
+
+**2)在 REPL 中使用**
+
+```text
+❯ /
+❯ /
+```
+
+示例:
+
+```text
+❯ /explain-code qsort.py
+```
+
+**补充说明**
+
+- 用户级技能:`~/.clawcodex/skills//SKILL.md`
+- 工具限制:`allowed-tools` 用于限制技能允许调用的工具集合
+- 参数替换:支持 `$ARGUMENTS`、`$0`、`$1`、以及命名参数(例如来自 `arguments` 的 `$path`)
+- 占位符写法:请使用 `$path`,不要写成 `${path}`
+
+
+
+***
+
+## 🎨 演示
+
+**[`demos/`](../../demos/) 目录下的每个应用都由 ClawCodex 自身端到端生成** —— 用的正是你刚安装的这个 CLI、同一个 agent loop、同一套工具。零人工修改 🙂
+
+| 演示 | 技术栈 | 描述 |
+| ---- | ----- | ----------- |
+| [`demos/crm-app`](../../demos/crm-app) | React 18 + Vite + Vitest | 迷你 CRM:联系人、商机、仪表盘与完整测试套件 |
+| [`demos/linkedin-app`](../../demos/linkedin-app) | React 18 + Vite + React Router | LinkedIn 风格信息流:个人主页、人脉、职位、私信 |
+| [`demos/minecraft-app`](../../demos/minecraft-app) | React + three.js + @react-three/fiber | 浏览器体素沙盒:地形、挖掘、HUD 与玩家控制 |
+| [`demos/wc26-intro`](../../demos/wc26-intro) | 纯静态 HTML/CSS/JS | 2026 世界杯介绍页——动效首屏、实时倒计时、主办国、16 座球场、赛制与破纪录数据;用全新的 Z.ai **GLM-5.2** 模型端到端生成 |
+
+```bash
+cd demos/crm-app # 或 linkedin-app / minecraft-app
+npm install
+npm run dev # vite 开发服务器
+```
+
+`demos/wc26-intro` 是单文件静态页面——直接在浏览器中打开 [`demos/wc26-intro/index.html`](../../demos/wc26-intro/index.html) 即可。
+
+想看看它是怎么做到的?在任意空目录里打开 ClawCodex,让它构建点什么——上面这些都是这样生成的。
+
+***
+
+## 🎓 为什么选择 ClawCodex?
+
+### 基于真实源码
+
+- **不是克隆** —— 从真实的 TypeScript 实现移植而来
+- **架构保真** —— 保持经过验证的设计模式
+- **持续改进** —— 更好的错误处理、更多测试、更清晰的代码
+
+### 原生 Python
+
+- **类型提示** —— 完整的类型注解
+- **现代 Python** —— 使用 3.10+ 特性
+- **符合习惯** —— 干净的 Python 风格代码
+
+### 以用户为中心
+
+- **3 步设置** —— 克隆、配置(`clawcodex login`)、运行(`clawcodex`)
+- **交互式配置** —— 一个流程内完成 provider、Base URL 与默认模型
+- **Ink TUI** —— TypeScript 终端客户端 + Python agent-server 子进程
+- **可脚本化** —— `-p` / JSON / NDJSON 便于自动化
+- **会话持久化** —— 保存与恢复对话
+
+***
+
+## 架构
+
+关于六大核心抽象(query loop、tools、tasks、两级 state、memory、hooks),以及从用户输入到模型输出的黄金路径,请见
+[`docs/ARCHITECTURE.md`](../ARCHITECTURE.md)。推荐新贡献者从这里入手。
+
+原版 Claude Code 架构的参考资料位于
+`claude-code-from-source/book/ch01-architecture.md`;逐章的移植差距分析与重构计划存放在
+`my-docs/` 下。
+
+***
+
+
+## 📦 项目结构
+
+```text
+clawcodex/
+├── src/
+│ ├── cli.py # CLI 入口(控制台命令 clawcodex)
+│ ├── entrypoints/ # 无头(-p)、agent-server 与 Ink-TUI 启动器
+│ ├── server/ # Direct Connect agent-server(Ink TUI 后端)
+│ ├── providers/ # Anthropic、OpenAI、Gemini、智谱 GLM、Minimax、OpenRouter、DeepSeek + OpenAI 兼容注册表(openai_compatible_specs.py)
+│ ├── agent/ # 对话、会话、提示词
+│ ├── tool_system/ # Agent loop、工具与 schema
+│ ├── skills/ # SKILL.md 加载与 Skill 工具
+│ ├── services/ # MCP、compact、IDE 桥、工具执行等
+│ ├── context_system/ # workspace / git / CLAUDE.md 上下文
+│ ├── permissions/ # 权限模式与 bash 解析
+│ ├── hooks/ # Hook 类型与执行辅助
+│ └── command_system/ # 斜杠命令与参数替换
+├── typescript/ # 参考 / 对等源码(运行 Python CLI 非必需)
+├── tests/ # pytest 测试套件
+├── docs/ # 指南、多语言 README、重构笔记
+├── .clawcodex/skills/ # 项目级技能(可选)
+├── FEATURE_LIST.md # 能力矩阵与路线图
+└── pyproject.toml # 包元数据与 clawcodex 入口
+```
+
+***
+
+
+## 🤝 贡献
+
+**我们欢迎贡献!**
+
+```bash
+# 快速开发设置
+pip install -e .[dev]
+python -m pytest tests/ -v
+```
+
+查看 [CONTRIBUTING.md](../../CONTRIBUTING.md) 了解指南。
+
+***
+
+## 📖 文档
+
+- **[SETUP_GUIDE.md](../guide/SETUP_GUIDE.md)** —— 详细安装说明
+- **[CONTRIBUTING.md](../../CONTRIBUTING.md)** —— 开发指南
+- **[TESTING.md](../guide/TESTING.md)** —— 测试指南
+- **[CHANGELOG.md](../../CHANGELOG.md)** —— 版本历史
+
+***
+
+## ⚡ 性能
+
+- **启动时间**:< 1 秒
+- **内存占用**:< 50MB
+- **响应**:回合式助手输出,支持 Rich Markdown 渲染
+
+***
+
+## 🔒 安全
+
+✅ **基础本地安全实践**
+
+- Git 中无敏感数据
+- API 密钥在配置中已做混淆
+- `.env` 文件被忽略
+- 适合本地开发工作流
+
+***
+
+## 📄 许可证
+
+MIT 许可证 —— 查看 [LICENSE](../../LICENSE)
+
+***
+
+## 🙏 致谢
+
+- 基于 Claude Code TypeScript 源码
+- 独立的教育项目
+- 未隶属于 Anthropic
+
+***
+
+
+
+### 🌟 支持我们
+
+如果你觉得这个项目有用,请给个 **star** ⭐!
+
+**由 ClawCodex 团队用 ❤️ 打造**
+
+[⬆ 回到顶部](#clawcodex)
+
+
+
+***
+
+***
diff --git a/docs/workflow-commands-and-ultracode-plan.md b/docs/workflow-commands-and-ultracode-plan.md
new file mode 100644
index 000000000..fd276c094
--- /dev/null
+++ b/docs/workflow-commands-and-ultracode-plan.md
@@ -0,0 +1,99 @@
+# Plan: dynamic workflow slash commands + the `ultracode` authoring keyword
+
+> Closes the two gaps the user flagged against [`workflow-engine.md`](./workflow-engine.md)
+> §4.1 (ultracode authoring) and §4.5/§4.7 (saved `/` workflows). Status from an
+> audit of the current tree, then a concrete build.
+
+## Audit — what exists vs. what's missing
+
+### Feature 1 — `ultracode` authoring keyword (§4.1, §4.8): **NOT IMPLEMENTED**
+`grep -ri ultracode src/` → **0 hits.** None of the three pieces exist:
+- the `ultracode` keyword in a prompt injecting a `` that nudges the model to author/launch a workflow via the Workflow tool;
+- `/effort ultracode` enabling a session-long auto-orchestration mode;
+- the §4.8 gate (keyword no-ops + `ultracode` removed from the `/effort` menu when workflows are disabled).
+
+> Note: Python's effort pipeline is **inert** (`effort_command.py` docstring: `settings.effort`
+> reaches no request builder; there is no `xhigh`). So the faithfully-implementable half of
+> `/effort ultracode` is the **session orchestration mode** (a standing reminder), not a reasoning level.
+
+### Feature 2 — dynamic `/` workflow commands (§4.5, §4.7): **PARTIAL**
+- ✅ Bundled `/deep-research` + `/workflows` register into the global registry via
+ `get_builtin_commands()` → dispatch + autocomplete work (fixed in #267).
+- ✅ Discovery logic exists: `workflows_integration.load_workflow_commands(cwd)` finds project
+ `.claude/workflows/*.py` + personal `~/.claude/workflows/*.py`, project-wins-personal.
+- ❌ **Gap:** that discovery is wired only into the aggregator's `get_commands()`, which has
+ **no real REPL consumers**. Saved workflows never reach the global command registry that
+ dispatch + suggestions read. **Confirmed empirically:** a saved `.claude/workflows/triage_issues.py`
+ → `global registry (dispatch): False`, `aggregator get_commands: True`. So `/triage-issues`
+ does not dispatch or autocomplete in the REPL.
+
+The fix model already exists for skills: `load_and_register_skills(registry=None)` is called at
+startup from REPL `_init_command_system` (`core.py:1073`) and TUI `app.py:1486`. Workflows need the
+identical treatment — they have no `load_and_register_workflows`.
+
+## Build
+
+### Part A — register saved workflows into the command registry
+- **`src/command_system/workflows_integration.py`**: add
+ `load_and_register_workflows(project_root=None, registry=None) -> list[PromptCommand]`, mirroring
+ `load_and_register_skills`:
+ - reuse `_discover_dir` for project `.claude/workflows/` + personal `~/.claude/workflows/`
+ (project enumerated first → wins on clash);
+ - **shadowing guard**: skip any name colliding with an already-registered command or alias
+ (builtins/bundled win — same rule skills use);
+ - gated by `is_workflows_enabled()` → returns `[]` when disabled;
+ - registers ONLY the discovered project/personal commands (bundled `/deep-research` + `/workflows`
+ are already registered by `register_builtin_commands`).
+- **Call it at startup** after skills: REPL `core.py::_init_command_system` and TUI
+ `app.py` (next to the existing `load_and_register_skills(registry=None)`).
+- **Tests** (`tests/test_workflow_dynamic_commands.py`): a saved project workflow resolves in the
+ global registry + carries the Workflow-tool directive; project-wins-personal; builtin-wins-over-
+ workflow on a name clash; disabled ⇒ not registered.
+
+### Part B — the `ultracode` authoring keyword + session mode
+- **New `src/workflow/ultracode.py`** (pure + process-global session flag, like
+ `message_queue_manager`):
+ - `prompt_requests_ultracode(text) -> bool` — case-insensitive `\bultracode\b`, gated by
+ `is_workflows_enabled()`;
+ - session flag: `set_ultracode_session(bool)` / `is_ultracode_session()` / `reset_ultracode()`;
+ - `ultracode_reminder_for(text) -> str | None` — the single decision used by the chat seam:
+ returns the keyword `` when the keyword is present, else the standing
+ session reminder when session mode is on, else `None`; always `None` when workflows are disabled;
+ - reminder texts: instruct the model to **author a workflow and SAVE it as a `/` command**
+ (write a sandboxed-Python script to `.claude/workflows/.py` with the Write tool, then tell the
+ user to run `/`) — NOT to run it inline, and NOT to hunt for an "ultracode" tool/skill
+ (keyword = this message; session = substantive tasks this session). See **Part B.2**.
+- **REPL `chat()` wiring**: after the existing at-mention/companion-intro attachment block, append
+ `ultracode_reminder_for(user_input)` to `user_input` when non-None (mirrors the companion-intro append).
+- **`/effort ultracode`** (`effort_command.py`): an `ultracode` branch (gated by
+ `is_workflows_enabled()`) that calls `set_ultracode_session(True)` and confirms; selecting any real
+ level or `auto` calls `set_ultracode_session(False)` (reset per "reset with /effort high"). Add an
+ `ultracode` picker option **only when workflows are enabled** (§4.8 removal when disabled).
+- **Tests** (`tests/test_ultracode.py`): keyword detection (positive / negative / word-boundary /
+ case); `ultracode_reminder_for` precedence + gating (None when disabled); session set/clear/reset;
+ `/effort ultracode` enables session mode and is gated; a real `/effort` level clears it.
+
+### Part B.2 — keyword-only, author + save (the final shape)
+This went through two corrections before landing on the Claude Code shape:
+
+1. A discoverable `/ultracode ` **slash command** was added (users typed `/ultracode` out of habit
+ and it didn't autocomplete). **Reverted** — on inspection, Claude Code's ultracode is triggered by the
+ **keyword only**, never a slash command. There is deliberately **no `/ultracode` command**; `bundled_workflow_commands()`
+ contributes only `/workflows` + `/deep-research`.
+2. The keyword action is **author + SAVE**, not author + run: the model writes a sandboxed-Python workflow
+ to `.claude/workflows/.py` (modeled on `src/workflow/bundled/deep_research.py`) and tells the user
+ to run `/` — it does **not** launch a background run. The saved file becomes a `/` command
+ via Part A's `load_and_register_workflows`, re-run mid-session by `_refresh_workflow_commands` (mtime-gated)
+ so it's runnable without a restart.
+
+The keyword reminder is written as a blunt imperative ("WRITE a workflow script … with your Write tool")
+that **forbids the tool/skill hunt** — a live run had the model `ToolSearch` for a non-existent "ultracode"
+tool instead of authoring the file. Validated: a capable agent given this reminder authored a workflow that
+parses, registers as `/`, and runs end-to-end through the engine. `/effort ultracode` (session mode)
+mirrors the same author + save behavior.
+
+## Out of scope (call it out, don't half-build)
+- Wiring the keyword into the **TUI** chat path (different submission seam); the detection/reminder
+ logic is a reusable pure module so the TUI can adopt it in a follow-up. REPL is the user's surface.
+- `Option+W` keyword-highlight dismissal and the editor "View raw script" affordance (UI polish).
+- Making the effort *reasoning level* actually reach inference (a separate, already-deferred phase).
diff --git a/docs/workflow-engine-port-plan.md b/docs/workflow-engine-port-plan.md
new file mode 100644
index 000000000..2e1a246ee
--- /dev/null
+++ b/docs/workflow-engine-port-plan.md
@@ -0,0 +1,351 @@
+# Faithful Port Plan: The Workflow Engine → clawcodex (Python)
+
+> **Goal.** Port Claude Code's *dynamic workflows* subsystem into the clawcodex Python rebuild
+> (`src/` at the repo root). "Faithful" = behaviourally and structurally faithful to the official
+> spec and the TypeScript integration surface, expressed idiomatically in Python. See
+> [`workflow-engine.md`](./workflow-engine.md) for the feature behaviour this plan implements.
+>
+> **Decided:** workflow scripts are **Python** (executed by an in-process sandbox that injects async
+> orchestration globals), not JavaScript. The behaviour, API shape, and semantics are preserved;
+> only the surface syntax the model authors changes.
+
+**Anchor conventions.** Python anchors are `src/...` (repo root). TypeScript anchors are
+`typescript/src/...` and appear only as the parity/Rosetta reference. All anchors verified against
+the tree at the time of writing.
+
+---
+
+## 1. Current state in the Python tree
+
+The port was **anticipated** — two scaffold points already exist, declared but unimplemented:
+
+| Scaffold | Anchor | State |
+|---|---|---|
+| `"local_workflow"` task-type discriminant | `src/tasks_core.py:36` | Declared in `TaskType`; comment: "declared so the discriminator is byte-aligned with TS, but no Task implementation" |
+| Workflow task-id prefix `"w"` | `src/tasks_core.py:73` (`_TASK_ID_PREFIXES`) | `generate_task_id("local_workflow")` already yields `w########` |
+| `CommandBase.kind = "workflow"` badge field | `src/command_system/types.py:113` | Field present (`# "workflow" or None`); no producer yet |
+
+Everything else is greenfield. Note two **stubs to be aware of** (not blockers, but adjacent):
+- The TUI background-task panel is **broken**: `/tasks` calls `REPLScreen.focus_task_panel()` which **does not exist** (`src/tui/app.py:716-729`), so it always degrades to "Task panel focus is not available." The workflow port introduces the first working background-task UI.
+- `StructuredOutputTool` exists but is an **unvalidated no-op** whose result never returns to a caller (§4.3).
+
+---
+
+## 2. Reuse map
+
+### 2.1 Reusable as-is (the substrate)
+
+| Concern | Reuse | Anchor |
+|---|---|---|
+| Run one subagent (stream messages) | `run_agent(params)` → `AsyncGenerator[Message]` | `src/agent/run_agent.py:218` (params dataclass `:36-66`) |
+| One-shot subagent convenience | `run_query(params)` → `(messages, terminal)` | `src/query/query.py:1694` |
+| Final text + usage from a finished run | `finalize_agent_tool(messages, agent_id, metadata)` | `src/agent/agent_tool_utils.py:200` (`AgentToolResult` `:176`); partial: `extract_partial_result` `:315` |
+| Agent-type resolution + default | `get_agent_definitions_with_overrides(cwd)`, `find_agent_by_type`, `GENERAL_PURPOSE_AGENT` | `src/agent/load_agents_dir.py:115`; `src/agent/agent_definitions.py:88,248,265` |
+| JSON-Schema validation (only validator in tree) | `validate_json_schema(value, schema, root)` | `src/tool_system/schema_validation.py:37` |
+| Bounded-concurrency fan-out **pattern** | `asyncio.Semaphore + ensure_future + Queue` | `src/services/tool_execution/orchestrator.py:213-282` |
+| Cancellation | `AbortController` / `AbortSignal`, `create_child_abort_controller(parent)` | `src/utils/abort_controller.py` (child `:88`) |
+| Background-task model + store | `TaskStateBase`, `RuntimeTaskRegistry`, `Task` protocol | `src/tasks_core.py:104-158`; `src/task_registry.py:45-175` |
+| Task lifecycle template | `LocalAgentTask` (state, register, progress, terminal, eviction) | `src/tasks/local_agent.py:35-405`; eviction `src/tasks/eviction.py:41-179` |
+| Progress accounting | `AgentProgress`, `ProgressTracker`, `total_tokens_from_tracker` | `src/tasks/progress.py:67-114` |
+| Tool construction + registration | `build_tool(...)`, `ToolRegistry`, `ALL_STATIC_TOOLS` | `src/tool_system/build_tool.py:122`; `src/tool_system/registry.py:20`; `src/tool_system/tools/__init__.py:43` |
+| Command base + aggregation | `PromptCommand`/`InteractiveCommand`, `get_commands(cwd)`, builtins append-gate | `src/command_system/types.py:96-412`; `src/command_system/aggregator.py:71-118`; `src/command_system/builtins.py:1226-1266` |
+| Disk→dynamic command precedent | `skills_integration.skill_to_prompt_command` + `src/skills/loader.py` disk walk | `src/command_system/skills_integration.py:28-73`; `src/skills/loader.py:600-737` |
+| Permission decision + dialog | `has_permissions_to_use_tool`, `PermissionModal`, `auto_mode_classify` | `src/permissions/check.py:104-206,451-489`; `src/tui/screens/permission_modal.py:36-186` |
+| Permission-rule persistence ("don't ask again for X in Y") | `PermissionUpdateAddRules`, `persist_permission_update`, `create_read_rule_suggestion` | `src/permissions/types.py:141-148`; `src/permissions/updates.py:267-345,366-391` |
+| Settings/env gating pattern | advisor gate (`is_advisor_enabled`, `_env_truthy`) + `SettingsSchema` | `src/utils/advisor.py:73-133`; `src/settings/types.py:66-151`; `src/settings/settings.py:25-67` |
+| Modal/list TUI primitives | `DialogScreen(ModalScreen)`, `SelectList`, master/detail exemplar | `src/tui/screens/dialog_base.py:33`; `src/tui/widgets/select_list.py:50-77`; `src/tui/screens/mcp_dialogs.py:40` |
+| Cross-thread UI updates | `App.post_message` of `Message` subclasses via the bridge | `src/tui/messages.py`; `src/tui/agent_bridge.py:72` |
+| Cost computation + accumulator | `compute_cost`, `add_to_total_cost_state`, `get_total_cost_usd` | `src/services/pricing.py`; `src/bootstrap/state.py:534,595` |
+| Token-target budget semantics | `BudgetTracker`, `check_token_budget`, `parse_token_budget` | `src/query/token_budget.py:29,59,117` |
+
+### 2.2 Net-new (the engine and its gaps)
+
+1. **Python script sandbox** — there is **no `exec`/`eval`/`compile` of model code anywhere** (`ast` appears only in tests). Build: async-wrap + `compile` + `exec` into a curated namespace; `ast.parse` + `ast.literal_eval` for `meta`; frozen/absent `time`/`random`/`datetime`.
+2. **Schema-validated structured output with return + retry** — the biggest gap. `StructuredOutputTool` (`src/tool_system/tools/structured_output.py`) does **not** validate (schema is `additionalProperties:True`), writes to `context.outbox`, and its result **is never read back** (only `SendUserMessage` outbox entries are consumed, `agent_loop_compat.py:411-421`); `finalize_agent_tool` extracts **text only**. Build: per-call schema injection, validation via `validate_json_schema`, retrieval from a dedicated channel, and the retry cap.
+3. **Bounded subagent fan-out** — no `gather`-over-subagents exists; the Agent tool is **not concurrency-safe** (`is_destructive=True`, no `is_concurrency_safe`), so it runs serially today. Build the `parallel()`/`pipeline()` scheduler by cloning the `orchestrator.py` Semaphore/Queue pattern over `run_agent` coroutines.
+4. **Per-run caps** — ≤16 concurrent, 1000/run, 4096/call have **no precedent**.
+5. **Production-path budget predicate** — `max_budget_usd` is threaded (`src/tool_system/context.py:40`) but **inert**; the only predicate (`is_over_budget`) lives on the **deprecated** tracker (`src/services/cost_tracker.py:241`). Build a run-wide predicate over `get_total_cost_usd()` / token accounting.
+6. **Per-agent abort map** — siblings track one `asyncio.Event`; workflows need a `dict[agent_id → abort handle]` plus a run-level abort (for `x` stop-agent-vs-stop-run and `r` retry).
+7. **Background-task UI** — the pill (`get_pill_label`) and the `/workflows` progress/detail dialog do not exist; the `/tasks` panel is a broken stub.
+8. **Progress channel** — no background-task progress `Message` exists; add one or poll the registry.
+9. **Journaling + resume** — per-`runId` journal under the session dir; same-session prefix replay.
+10. **Worktree-per-agent** — `wf_-` creation/cleanup.
+
+---
+
+## 3. The Python script engine (net-new core)
+
+A new package, suggested `src/workflow/`:
+
+```
+src/workflow/
+ runtime.py # run_workflow(...) entry; owns the asyncio loop, journal, progress
+ sandbox.py # meta extraction + curated exec of the Python script
+ scheduler.py # Semaphore(≤16) + ensure_future + Queue; 1000/run + 4096/call caps
+ primitives.py # agent / pipeline / parallel / phase / log / workflow / budget impls
+ structured.py # schema-injected StructuredOutput + validate + retrieve + retry
+ journal.py # per-runId (call_index → result) persistence for resume
+ registry.py # bundled + disk-discovered workflows (name → script source)
+ budget.py # run-wide token/USD ceiling over the production accounting
+```
+
+**Verified API-usage profile** (from `demos/workflows/*.js`, four real workflows): `log` (27×),
+`args` (27×), `agent` (20×), `phase` (20×), **`schema` structured output (19×)**, `parallel` (3×);
+`pipeline`, `workflow`, `budget`, `isolation`, `model`, and `agentType` appear in the API but in
+none of these demos. So the engine's **common path** — `agent` + `parallel` + `phase`/`log` + script
+helpers + `return`, with **schema-validated structured output** — carries essentially all real
+usage and must be the most robust part; the rarer primitives can land later (Phase 4+).
+
+### 3.1 `runtime.run_workflow(...)`
+
+`async def run_workflow(source: str, *, args, run_id, tool_context, on_progress, resume_from=None) -> WorkflowResult`.
+Because the engine **owns its own async entry** (preferred over being driven by sync tool dispatch — see §6 trap), it can `await` natively. Steps: `parse_meta` → build the sandbox namespace (injected primitives bound to this run) → `exec` the wrapped script → `await __workflow_main__()` → collect the return value → finalize.
+
+### 3.2 `sandbox.py` — meta extraction + curated exec
+
+- **`extract_meta(source) -> dict`**: `tree = ast.parse(source)`; find the top-level `meta = {...}` (`ast.Assign`/`ast.AnnAssign`); `ast.literal_eval(node.value)` (rejects anything but literals — no execution); validate `name`/`description`/`phases`.
+- **Curated execution** (idiomatic Python, the only viable approach — no precedent exists):
+ ```
+ wrapped = "async def __workflow_main__():\n" + textwrap.indent(source, " ")
+ code = compile(wrapped, "", "exec")
+ ns = {"__builtins__": CURATED_BUILTINS, "agent": ..., "pipeline": ..., "parallel": ...,
+ "phase": ..., "log": ..., "workflow": ..., "args": args, "budget": budget}
+ exec(code, ns)
+ result = await ns["__workflow_main__"]()
+ ```
+- **Namespace contents — corrected against the real demo corpus.** The demos (`demos/workflows/*.js`) lean heavily on JSON, regex, and math (`JSON.parse`/`JSON.stringify`/`Math.round` are everywhere), so the sandbox must expose those, not strip all imports. Provide:
+ - Safe builtins: `len`, `range`, `enumerate`, `sorted`, `min`, `max`, `sum`, `round`, `abs`, `any`, `all`, `dict`, `list`, `set`, `tuple`, `str`, `int`, `float`, `bool`, `zip`, `map`, `filter`, `reversed`, `isinstance`, `print`→`log`, `True/False/None`.
+ - Exception types the scripts raise: `Exception`, `ValueError`, `RuntimeError`, `KeyError`, `TypeError`, `IndexError`.
+ - The **deterministic** stdlib real workflows need: `json`, `re`, `math`, `collections`, `itertools`, `functools`, `string`, `textwrap`. Expose these **either** as pre-bound globals **or** via a **whitelisted `__import__`** (so the model can write `import json` / `import re` naturally) that permits only this set. (Decision §8.)
+ - **Block**: `os`, `sys`, `subprocess`, `socket`, `pathlib`, `open`, `exec`, `eval`, `compile`, `input`, `globals`, and dunder-escape attributes. Removing the dangerous `__import__` targets (not `__import__` wholesale) keeps `import json` working while denying `os`/`subprocess`/`sys`.
+ - **Exclude `time` / `random` / `datetime`** — non-determinism breaks resume (JS parity: `Date.now`/`Math.random` are unavailable; they appear **zero** times across the demos). A workflow needing a timestamp passes one via `args` or has an agent run `date` (demo 02 does exactly this in its CSV-export step).
+ - **Define-before-use:** the script defines its own helper functions/classes (`slugify`, `formatOrganic`, …); Python does not hoist `def`, so a faithful JS→Python port must order helper definitions before their first call.
+- **Security posture:** the script is **model-authored, not adversarial** — the goal is determinism + ergonomics, not a hard boundary (this matches clawcodex, which already runs tools in `bypassPermissions` mode by default, `src/tool_system/context.py:70`). `exec` with curated builtins removes fs/shell/import reach and forces all side effects through the injected agent primitives. A harder boundary (subprocess `-I`/`-S`, WASM) is out of scope.
+
+### 3.3 `scheduler.py` — bounded concurrency + caps
+
+Clone the canonical pattern (`src/services/tool_execution/orchestrator.py:213-282`): `asyncio.Semaphore(max_concurrent)`, workers via `asyncio.ensure_future`, results via `asyncio.Queue`, cancel outstanding tasks on early exit. Parameterize over `run_agent` coroutines instead of tool calls. Enforce:
+- `WORKFLOW_MAX_CONCURRENT_AGENTS` — default **16**. House style uses fixed env/config ints (`os.cpu_count()` is used nowhere in `src/`), but the official spec reduces on low-core machines, so the faithful value is `min(16, max(1, (os.cpu_count() or 3) - 2))` with a `CLAUDE_CODE_WORKFLOW_MAX_AGENTS` override. (Decision §8.)
+- `WORKFLOW_MAX_AGENTS_PER_RUN = 1000` (raise when exceeded).
+- Per-call item cap **4096** for `pipeline`/`parallel` (explicit error).
+
+### 3.4 `primitives.py` — the injected API
+
+- **`agent(prompt, opts=None)`** — the core. Resolve `agent_type` (default `general-purpose`) via `find_agent_by_type`; build `RunAgentParams` (prompt, agent definition, model, a **child** `AbortController` from the run controller, `acceptEdits` permission mode, the inherited allowlist); acquire a scheduler slot; drive `run_agent(...)` to completion; `finalize_agent_tool(...)` → final text. If `opts.schema`, route through `structured.py` (§4.3). Record `(prompt, opts) → result` to the journal; update the task's per-agent state. Return `None` on skip/death.
+- **`parallel(items)`** — barrier. `items` is an iterable of **coroutines** (`agent(...)`, the idiomatic Python form the demos' `await parallel([...])` maps to) **or** zero-arg callables returning coroutines (thunks, faithful to the JS `() => agent(...)` surface). Gather all under the concurrency cap; a raised item → `None`; the call itself never raises; results returned in input order (so `a, b = await parallel([...])` unpacking works).
+- **`pipeline(items, *stages)`** — per-item independent stage chains, **no barrier**. Stages are async callables: the first is `stage(item)`, each later stage is `stage(prev, original_item, index)`; a raised stage drops that item to `None`. (Not used by the four demos, but part of the API surface.)
+- The wrapped body's top-level **`return` value is the workflow result** (demo 02 returns `{"keyword", "csv_path", ...}`); a top-level **`raise`** aborts the run with that error.
+- **`phase(title)` / `log(msg)`** — update the task's phase tree / emit a narrator line via the progress channel (§5.4).
+- **`workflow(name_or_ref, args=None)`** — resolve from `registry.py`, run inline via `run_workflow`, one nesting level (raise if already nested).
+- **`budget`** — a small object over `budget.py` (§4.4).
+
+---
+
+## 4. Module-by-module integration
+
+### 4.1 The Workflow tool — `src/tool_system/tools/workflow.py`
+
+`build_tool(name=WORKFLOW_TOOL_NAME, ...)` (template: the Agent tool's async-launch path,
+`src/tool_system/tools/agent.py:395-614`, and `tasks_v2.py` TaskOutput poller). Contract:
+- `input_schema` (plain JSON-Schema dict): one of `script` / `name` / `script_path`, optional `args`, `resume_from_run_id`.
+- `call(input, context) -> ToolResult` — **`Tool.call` does not stream** (`src/tool_system/build_tool.py:43`); it may be `async def` (the registry bridges coroutines, `registry.py:134-188`). Generate a `w########` id (`generate_task_id("local_workflow")`), register a `LocalWorkflowTaskState`, launch `run_workflow(...)` as a background task (mirror `_launch_async_agent`), and **return a handle immediately** (`{"status":"workflow_launched","run_id":...,"task_output_key":...}`). Progress reaches the TUI via the task registry/progress channel (§5.4), **not** via `call`.
+- `is_enabled` → gated on `disable_workflows` (§4.6). `is_read_only=True` (subagents carry their own permissions). `is_destructive` left default (False). Register in `ALL_STATIC_TOOLS` (`src/tool_system/tools/__init__.py:43`) — keep it a stable prefix entry.
+- `WORKFLOW_TOOL_NAME = "Workflow"` in `src/agent/constants.py` (alongside `AGENT_TOOL_NAME`), and **added to `ALL_AGENT_DISALLOWED_TOOLS`** (`src/agent/constants.py:26-34`) so subagents can't recursively spawn workflows.
+
+### 4.2 Subagent reuse for `agent()`
+
+Use `run_agent` + `finalize_agent_tool` directly (NOT the `Agent` tool, which adds JSON-serialization, a sync/async bridge, and its own background-task registration — overhead the engine doesn't want). `_collect_agent_messages` (`src/tool_system/tools/agent.py:802`) is the reference for collect-and-track-progress.
+
+### 4.3 Structured output — `src/workflow/structured.py` (largest gap)
+
+`StructuredOutputTool` today: `call` appends raw input to `context.outbox` and returns a fixed string; schema is `additionalProperties:True`; result never returns; it's filtered out of worker tool sets (`src/coordinator/mode.py:97`). Build:
+1. **Per-call schema tool**: construct a `StructuredOutput`-named tool whose `input_schema = opts.schema` and whose `call` runs `validate_json_schema(input, schema, "StructuredOutput")` (`src/tool_system/schema_validation.py:37`); on success, surface the validated object on a **dedicated channel** the engine reads (a fresh field on the subagent's `ToolContext`, or a sentinel in `outbox` the engine consumes — do not rely on the text-only `finalize_agent_tool`).
+2. **Steer + retry**: inject a prompt instruction ("call StructuredOutput once at the end"); count `StructuredOutput` calls and cap retries (`MAX_STRUCTURED_OUTPUT_RETRIES`, default 5 — upstream parity; the existing loop only has `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT=3` for a different purpose). On exhaustion, resolve `agent()` to `None`.
+3. Add the per-call tool to the subagent's `available_tools` for that run only.
+
+### 4.4 Budget — `src/workflow/budget.py`
+
+`budget.total` from the turn's token directive (parse via `parse_token_budget`, `src/query/token_budget.py:117`); `budget.spent()` reads the production accumulator `get_total_cost_usd()` / model-usage (`src/bootstrap/state.py:595`) — **a run-wide ceiling predicate must be added on the production path** (only `is_over_budget` exists, on the deprecated tracker `src/services/cost_tracker.py:241`). `remaining()` = `max(0, total - spent())`. Exceeding `total` makes `agent()` raise.
+
+### 4.5 Background task — `src/tasks/local_workflow.py`
+
+Mirror `src/tasks/local_agent.py`. Register in `src/tasks/__init__.py:42-44` and re-export in `__all__`.
+- **`LocalWorkflowTaskState(TaskStateBase)`** with `type: Literal["local_workflow"] = "local_workflow"`, plus: `run_id`, `summary: str | None`, a **phase/agent tree** (`phases: list[WorkflowPhaseState]`, each phase → `agents: dict[str, WorkflowAgentState]`), a **per-agent abort map** (each `WorkflowAgentState` carries an `abort_event: asyncio.Event` + `AgentProgress`), a **run-level `abort_event`** (stop-all), `is_paused`, and the usual `error`/`result`/`evict_after`. Aggregate agent count / token total / elapsed by summing `AgentProgress` + `total_tokens_from_tracker` upward.
+- All mutations go through `registry.update(task_id, mutator)` with a **synchronous pure mutator** using `dataclasses.replace` (hard contract — `update` rejects coroutine mutators before taking the lock; `src/task_registry.py:128-168`).
+- **`class LocalWorkflowTask`**: `name`, `type="local_workflow"`, `async def kill` (run-level stop). Auto-reachable from `stop_task` (`src/tasks/stop_task.py:82`) once registered — no edit there.
+- **Named API** (the TS `killWorkflowTask`/`skipWorkflowAgent`/`retryWorkflowAgent` equivalents): `kill_workflow_task(task_id, registry)`, `skip_workflow_agent(task_id, agent_id, registry)`, `retry_workflow_agent(task_id, agent_id, registry)` — these set the relevant `abort_event`(s) and re-enqueue work. Reuse `_terminal_replace` + `schedule_eviction` (`PANEL_GRACE_SECONDS=30`, `src/tasks/eviction.py:41`).
+
+### 4.6 TUI — pill + `/workflows` dialog
+
+- **Pill** (`get_pill_label`, port of `typescript/src/tasks/pillLabel.ts:60-61`): `"1 background workflow"` / `"N background workflows"`. New `src/tasks/pill_label.py`; consume in `StatusLine` (`src/tui/widgets/status_line.py`, which already polls `AppState` every 100ms — `:77`) or a footer segment. **`AppState` does not hold the registry today** — thread `runtime_tasks` (or a running-task snapshot) onto it.
+- **`/workflows` progress view** — new `src/tui/screens/workflow_dialog.py` extending `DialogScreen(ModalScreen)` (`src/tui/screens/dialog_base.py:33`), using `SelectList` (`src/tui/widgets/select_list.py:50` — already binds `j/k`, `enter`, `esc`). Master/detail exemplar: `McpListScreen` (`src/tui/screens/mcp_dialogs.py:40`). Detail analog: `typescript/src/components/tasks/AsyncAgentDetailDialog.tsx`. Wire bindings to the §4.5 named API: `p`=pause/resume run, `x`=`skip_workflow_agent` (or `kill_workflow_task` when focused on the run), `r`=`retry_workflow_agent`, `s`=save script (§4.7), `Enter/→`=drill phase→agent, `Esc/←`=back, `j/k`=scroll.
+- **Wire the command**: add `/workflows` to the TUI local builtins (`src/tui/commands.py:32-55`, dispatch `:272-291`) and an opener in `src/tui/app.py` (`_open_phase2_dialog` `:389-418`, opener pattern `_open_mcp_list` `:677-690`). (Optionally fix the broken `/tasks` `focus_task_panel` at the same time.)
+- **Progress channel** (§5.4): lowest-friction faithful fit is **poll-on-interval** — the dialog reads `runtime_tasks.get(run_id)` on a Textual `set_interval` tick (the registry is RLock-guarded, safe from the UI thread), matching the `StatusLine` precedent. Alternatively add a `WorkflowProgress(Message)` to `src/tui/messages.py` posted via `agent_bridge` for push updates.
+
+### 4.7 Commands — discovery, static, and bundled
+
+- **Dynamic per-workflow commands**: new `src/command_system/workflows_integration.py` (clone of `skills_integration.py`) + a disk walker modeled on `src/skills/loader.py` retargeted to `.claude/workflows/` (project) and `~/.claude/workflows/` (personal). Each file → a `PromptCommand`-style command with `kind="workflow"` (`src/command_system/types.py:113`) and `loaded_from="project"|"user"`, whose `get_prompt_for_command` emits a Workflow tool call with the chosen `name`/`script_path`. Splice into `get_commands` (`src/command_system/aggregator.py:89-92`); **project-wins-over-personal** falls out of enumeration order + the existing name-dedupe. Add a `_load_workflow_commands_cached` and clear it in `clear_commands_cache()` (`aggregator.py:212`).
+- **Static `/workflows` + bundled `/deep-research`**: module-level command singletons appended in `get_builtin_commands()` behind the `disable_workflows` gate (precedent: `if is_buddy_command_enabled(): cmds.append(...)`, `src/command_system/builtins.py:1264`). `/deep-research` is a markdown-bodied prompt command with a declared tool allowlist — template: `security_review.py` + `create_moved_to_plugin_command` (`src/command_system/moved_to_plugin.py:71`).
+- **Save (`s` in `/workflows`)**: write the run's script to the chosen `.claude/workflows/` location; it becomes `/` next session via the discovery loader above.
+
+### 4.8 Permission
+
+- **Approval dialog**: extend `PermissionModal` (`src/tui/screens/permission_modal.py:36-186`). Add a `"Workflow"` entry to `_TOOL_RENDERERS` (`:361-372`) rendering the planned phases + a "View raw script" body, and add the extra options ("Yes, and don't ask again for `` in ``") via the existing `enable_setting` channel (`:138-151`) → a `PermissionUpdateAddRules` scoped to name+path (`create_read_rule_suggestion` pattern, `src/permissions/updates.py:366`) persisted by `persist_permission_update` (`:267-345`).
+- **Auto-mode pre-allowlist**: add a `"Workflow"` branch to `auto_mode_classify` returning `allow=True` (next to the existing `"Agent"` branch, `src/permissions/check.py:483-484`).
+- **Subagents → `acceptEdits` + inherited allowlist**: in `_build_permission_context` (`src/agent/run_agent.py:108-161`) force `effective_mode="acceptEdits"` for workflow-spawned agents and ensure `always_allow_rules` are copied (already at `:155`); the allowlist is the agent's resolved tools (`resolve_agent_tools`, `src/agent/agent_tool_utils.py:83`).
+
+### 4.9 Gating — settings + env + `/config`
+
+No central feature-flag system exists; use the advisor pattern (`src/utils/advisor.py:73-133`):
+- Add `disable_workflows: bool = False` to `SettingsSchema` (`src/settings/types.py:66-151`) and `DEFAULT_SETTINGS` (`src/settings/constants.py`). (If keeping the literal JSON key `disableWorkflows`, it is also readable via `SettingsSchema.extra`, the pattern used for `permissions.allowBypassPermissionsMode` at `src/permissions/modes.py:137`.)
+- Env `CLAUDE_CODE_DISABLE_WORKFLOWS` via `_env_truthy` (`src/utils/advisor.py:73`); **add it to the honored-env allowlist** in `src/permissions/trust_boundary.py:75-96`.
+- `/config` toggle writes through `ConfigManager.set_global/set_project` (`src/config.py:218-226`, as `set_effort` does `:355`).
+- A gate helper `is_workflows_enabled()` (env shortcut beats settings) gates: the static/bundled command appends, the dynamic loader (returns `[]`), and the Workflow tool's `is_enabled`.
+
+---
+
+## 5. Cross-cutting design notes
+
+### 5.1 The engine owns its loop
+Launch the runtime as its own async entry (like `QueryEngine.submit_message`, `src/query/engine.py:203`) so `await agent(...)` and the bounded fan-out are native asyncio. Avoid driving it from synchronous tool dispatch; if the Workflow tool's `call` must kick it off, schedule the runtime as a background task on the running loop (mirror `agent.py:356-372`) rather than blocking dispatch.
+
+### 5.2 Cancellation tree
+One run-level `AbortController`; each `agent()` derives a child via `create_child_abort_controller` (`src/utils/abort_controller.py:88`) so ESC/`x`-on-run cascades to all subagents, while `x`-on-agent / `r` set only that agent's `abort_event`. Cancellation is **cooperative** — `run_agent` must observe the abort at yield points (today only the parent controller propagates; the per-agent `abort_event` check is part of the workflow wrapper). Tear down outstanding asyncio tasks with `task.cancel()` as the orchestrator does (`orchestrator.py:280-282`).
+
+### 5.3 Journaling + resume — `src/workflow/journal.py`
+Persist `(call_index → result)` per `runId` under the session dir (`~/.claude/projects//` — the official script-file location). On `resume_from_run_id`, the `agent()` primitive returns the cached result for unchanged `(prompt, opts)` at the same index and runs live from the first divergence. **Same-session only** (per spec); a fresh session restarts the run.
+
+### 5.4 Progress channel
+Each `phase()`/`log()`/agent-state change updates `LocalWorkflowTaskState` via `registry.update`. The TUI surfaces it by **polling** the registry on an interval (faithful, low-friction, matches `StatusLine`) or via a new `WorkflowProgress(Message)` posted through `agent_bridge` (`src/tui/agent_bridge.py:72`) for push updates. (Decision §8.)
+
+---
+
+## 6. Known traps / divergences from the TS source
+
+- **`Tool.call` does not stream** (Python diverges from TS's async-generator `call`). Progress must flow through the background-task registry + progress channel, never `call` yields.
+- **`StructuredOutputTool` is an unvalidated no-op** whose result never returns — must be upgraded, not merely reused (§4.3).
+- **The Agent tool is serial** (`is_destructive=True`, not concurrency-safe). The fan-out limiter is genuinely net-new; reuse `run_agent`, not the Agent tool.
+- **`RuntimeTaskRegistry.update` mutators must be synchronous pure functions** — never `await` under its lock (deadlocks asyncio vs. worker threads).
+- **No `os.cpu_count()` in `src/`** — the ≤16 cap should be a constant/env per house style; the low-core reduction is an explicit deviation to add (§3.3, §8).
+- **The `/tasks` background-task panel is a broken stub** — the workflow port is the first working background-task UI; budget for building it, not extending it.
+- **Cost recording is a consumer concern**, not in the loop — the budget predicate must be added on the production path (`get_total_cost_usd`), not the deprecated tracker.
+- **`src/constants/` is a placeholder**; tool-name/disallowed constants live in `src/agent/constants.py`.
+
+---
+
+## 7. Suggested sequencing
+
+Each phase is independently testable; the feature is usable behind the gate after Phase 5.
+
+| Phase | Deliverable | Gate / test |
+|---|---|---|
+| **0. Scaffold + gate** | `src/workflow/` package skeleton; `WORKFLOW_TOOL_NAME`; `disable_workflows` setting + env + `is_workflows_enabled()`; register tool (disabled by default). | Tree imports with the gate on/off; tool absent when disabled. |
+| **1. Sandbox + meta** | `extract_meta` (ast) + curated `exec` async-wrap. | A trivial script's `meta` validates; body runs; `import`/`open` raise inside it. |
+| **2. Core `agent()`** | `agent()` (text) over `run_agent` + `finalize_agent_tool`; serial scheduler. | A single-`agent()` script returns text end-to-end. |
+| **3. Structured output** | schema-injected `StructuredOutput` + `validate_json_schema` + return channel + retry cap. | A script gets a validated object; malformed output retries then `None` at the cap. |
+| **4. Concurrency + budget** | `parallel`/`pipeline`, `Semaphore(≤16)`, 1000/run + 4096/call caps, `budget`. | Fan-out respects the cap; `budget.remaining()` drives a loop; ceiling raises. |
+| **5. Background task** | `LocalWorkflowTask` (+ state, named API, abort map), registration, pill. | A run shows in the pill; `stop_task` kills it; completes within the grace window. |
+| **6. `/workflows` UI** | progress/detail dialog with `p/x/r/s/Enter/Esc/j/k`; progress channel. | Drill phase→agent; pause/stop/retry/save work live. |
+| **7. Commands** | dynamic `.claude/workflows` discovery + static `/workflows` + bundled `/deep-research`. | `/` runs a saved workflow; `/deep-research` works; project wins on clash. |
+| **8. Permission** | approval renderer + don't-ask-again rule + auto-mode allowlist + subagent acceptEdits. | Default mode prompts per run; auto pre-allowlists; subagents auto-approve edits. |
+| **9. Resume + worktree** | per-`runId` journal + `resume_from_run_id`; `wf_-` worktrees. | Same script+args ⇒ cache hit; edited call N re-runs from N; parallel file-mutating agents don't collide. |
+
+---
+
+## 8. Open decisions
+
+1. **Concurrency cap source** — ~~fixed `16` vs. `min(16, cpu_count−2)`~~ **RESOLVED:** defaults to a gentle **`4`** (`DEFAULT_MAX_CONCURRENT_AGENTS`, `CLAUDE_CODE_WORKFLOW_MAX_AGENTS` override). The `min(16, cpu−2)` heuristic was dropped — workflow agents are network/LLM-bound, so the real limiter is the provider rate-limit / token window, not core count, and 16-wide fan-out can exhaust a plan's 5-hour budget in one burst. (§3.3)
+2. **Structured-output return channel** — a new `ToolContext` field vs. a typed sentinel in `outbox`. Pick one explicit channel; do not overload the text path. (§4.3)
+3. **Progress delivery** — poll-on-interval (simplest, matches `StatusLine`) vs. a `WorkflowProgress` Textual message (push). (§5.4)
+4. **Budget unit** — token-target (parity with `token_budget.py`) vs. USD cap (parity with TS `maxBudgetUsd`). Spec leans token; implement the predicate on the production accumulator either way. (§4.4)
+5. **Save UX** — reuse `InteractiveCommand`/`ctx.ui.select` (like `copy_command.py`) for the two-location save picker, or a Textual modal. (§4.7)
+6. **Fixing `/tasks`** — implement `REPLScreen.focus_task_panel` as part of this work, or scope the workflow dialog independently and leave `/tasks` broken.
+7. **Settings key casing** — typed `disable_workflows` field vs. literal `disableWorkflows` via `SettingsSchema.extra`. (§4.9)
+8. **Sandbox stdlib exposure** — pre-bound globals vs. a whitelisted `__import__` for the deterministic modules workflows need (`json`/`re`/`math`/`collections`/…). The demos write JSON/regex/math constantly, so one of these is mandatory; pick the one that reads most naturally for model-authored scripts. (§3.2)
+
+---
+
+## 9. Effort note
+
+The integration glue (modules §4.1, §4.5–§4.9) is mechanical — every one has a sibling template in
+the tree and a contract pinned by an existing consumer or the official spec. The genuine
+engineering is **§3 (the script engine)** and **§4.3 (validated structured output with return +
+retry)**: neither has any precedent in the codebase (no `exec` of model code exists; the structured
+tool is a no-op), and they are where the upstream engine's complexity lived. Budget the bulk of the
+effort there; the rest is faithful wiring against the anchors above. The two scaffold points already
+in the tree (`local_workflow` task type, `CommandBase.kind="workflow"`) confirm the architecture was
+designed to accept this port.
+
+---
+
+## 10. Implementation status
+
+**Built (plan Phases 1–4: the engine core, `src/workflow/`) — 90 unit/integration tests passing.**
+
+| Module | What it does | Tested |
+|---|---|---|
+| `sandbox.py` | `ast` `meta` extraction; curated `exec` async-wrap; whitelisted stdlib (`json`/`re`/`math`/…) with `time`/`random`/`datetime`/host access withheld | ✅ `test_sandbox.py` |
+| `scheduler.py` | `asyncio.Semaphore` concurrency cap + per-run 1000 cap | ✅ `test_scheduler.py` |
+| `budget.py` | token ceiling (`total`/`spent()`/`remaining()`), shared-pool via `base_spent` | ✅ `test_budget.py` |
+| `callpath.py` + `journal.py` | **deterministic call-path keys** (fixes the multi-round fan-out resume bug) + per-key fingerprint matching | ✅ `test_journal.py`, `test_runtime.py::…multiround…` |
+| `primitives.py` / `runtime.py` | `agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow`/`budget`; failure→`None`; item cap; per-agent abort handles | ✅ `test_primitives.py`, `test_runtime.py` |
+| `structured.py` | schema validation + retry collector + the injected `StructuredOutput` tool (built on the real `build_tool`) | ✅ `test_structured.py` |
+| `runner.py` (`LiveAgentRunner`) | production seam: `run_agent` + `finalize_agent_tool` + structured tool | ⚠️ structured tool unit-tested; `run_agent` composition needs **live integration test** |
+| `demos/workflows/*.py` | the four JS demos ported to Python; run end-to-end against the engine | ✅ `test_demos.py` |
+
+**Built (plan Phases 5–9: integration) — the feature is now user-reachable behind the gate.**
+
+| Piece | Module | Tested |
+|---|---|---|
+| Gating + constants | `src/workflow/gating.py`, `disable_workflows` setting, `CLAUDE_CODE_DISABLE_WORKFLOWS`, `WORKFLOW_TOOL_NAME` (+ recursion guard) | ✅ |
+| Background task (Phase 5) | `src/tasks/local_workflow.py` (state, lifecycle, `kill`/`skip`/`retry` API), registered; pill `src/tasks/pill_label.py` | ✅ |
+| Workflow tool (entry) | `src/tool_system/tools/workflow.py` + `src/workflow/launch.py`; registered (gated) in `defaults.py` | ✅ |
+| Commands (Phase 7) | `src/command_system/workflows_integration.py` (discovery + `/deep-research`) + `workflows_command.py` (`/workflows`), spliced into the aggregator | ✅ |
+| Permissions (Phase 8) | `auto_mode_classify` Workflow branch; subagents forced `acceptEdits` in `LiveAgentRunner` | ✅ (classify) |
+| Resume persistence (Phase 9) | `Journal` persisted to the run's file; `resume_from_run_id` reloads it | ✅ |
+| Bundled workflow | `src/workflow/bundled/deep_research.py` (real fan-out → cross-check → cited report) | ✅ |
+
+**Polish — now built (port-plan §10 follow-ups):**
+
+| Item | Module | Tested |
+|---|---|---|
+| Result delivery to the model | `enqueue_workflow_notification` on terminal transitions | ✅ |
+| Run-file location | `get_workflow_run_path` → `~/.clawcodex/transcripts/workflows/` | ✅ |
+| `retry_workflow_agent` | engine bounded retry loop (`WorkflowRun.retry_agent`) | ✅ |
+| ProgressTracker tokens | `LiveAgentRunner` feeds `finalize_agent_tool(progress=…)` | ✅ |
+| Worktree-per-agent | `isolation="worktree"` → `wf_-` (`src/workflow/worktree.py`) | ✅ |
+| Live integration test | `LiveAgentRunner` → `run_agent` → real query loop w/ a fake provider | ✅ |
+| `/workflows` TUI dialog | `src/tui/screens/workflow_dialog.py` (list + detail, `x` stop, `r` retry) + opener | ✅ (pilot) |
+| Live pill | `StatusLine` shows "N background workflows" from `runtime_tasks` | ✅ (pilot) |
+
+The integration test caught two real bugs (fixed): tool **dispatch resolves by name from the
+registry**, so a schema agent needs a per-call registry where `StructuredOutput` is the validating
+tool; and that injected tool was **permission-blocked** in the subagent (now explicitly allowed).
+
+**Still remaining (genuinely small, not blocking):**
+- **`p` (pause) / `s` (save)** in the `/workflows` dialog — pause needs an engine pause gate; save
+ needs the `.claude/workflows` write + save dialog. The `x`/`r` actions and the detail view are built.
+- **Budget shared-pool** — `Budget(base_spent=…)` accepts a shared-pool getter, but no token-target
+ source is wired on this path (`budget_total` is `None` by default), so it's a param awaiting a caller.
+
+**Hardening (post adversarial review).** Fixed before merge: the background launch now runs on a
+dedicated daemon thread (`task_manager.start`) so the run outlives the throwaway `asyncio.run`
+dispatch loop — with a production-topology regression test; schema subagents now go through
+`resolve_agent_tools` (firewall + scoping, `Workflow` stripped — no recursion) before the injected
+`StructuredOutput` tool, instead of receiving the full base pool; and `run_agent` now honors
+`permission_mode_override`, so the `acceptEdits` guarantee for workflow subagents is actually applied.
+
+**Addendum — §4.1 ultracode + §4.7 dynamic-command registration (closed two gaps).** The Phase-7
+row above overstated discovery: saved `.claude/workflows/*.py` were produced by `load_workflow_commands`
+but only fed the aggregator's `get_commands()`, which has **no real REPL consumers**, so `/`
+never reached the global registry that dispatch + suggestions read (the same orphaning that hid
+`/deep-research` until #267). And the `ultracode` authoring keyword (§4.1) was never built (0 hits in
+`src/`). Both are now done (see `workflow-commands-and-ultracode-plan.md`):
+- **Dynamic commands** — `load_and_register_workflows(registry=None)` (mirrors `load_and_register_skills`,
+ same shadowing guard: builtins/bundled win, project beats personal) is called at REPL + TUI startup, so
+ saved workflows dispatch **and** autocomplete (with a `workflow` tag). ✅ `test_workflow_dynamic_commands.py`
+- **ultracode** — `src/workflow/ultracode.py`: the `\bultracode\b` keyword in a prompt (one-shot) and
+ `/effort ultracode` (session-long mode) append a `` nudging the model to author a
+ workflow via the Workflow tool. Gated by `is_workflows_enabled()` (§4.8: keyword no-ops, the option
+ leaves the `/effort` menu when off). Note: Python's effort pipeline is inert, so `/effort ultracode`
+ contributes the orchestration mode only, not a reasoning level. ✅ `test_ultracode.py`
diff --git a/docs/workflow-engine.md b/docs/workflow-engine.md
new file mode 100644
index 000000000..4394d05d4
--- /dev/null
+++ b/docs/workflow-engine.md
@@ -0,0 +1,321 @@
+# The Workflow Engine — Feature Deep Dive
+
+> **Purpose.** This document is the authoritative behavioural specification of Claude Code's
+> *dynamic workflows* feature, written to drive a faithful port into the **clawcodex Python
+> implementation** (`src/` at the repo root). It is implementation-neutral: it describes *what the
+> feature does* and *how a user experiences it*. The companion
+> [`workflow-engine-port-plan.md`](./workflow-engine-port-plan.md) maps every piece onto the Python
+> architecture with `file:line` anchors.
+>
+> **Sources of truth.** (1) The official documentation —
+> (feature requires Claude Code v2.1.154+). (2) The
+> public Workflow tool contract (the script API the model is taught). (3) The surviving TypeScript
+> integration surface under `typescript/src/`, which carries the real signatures of the
+> closed-source engine and is used as a Rosetta stone. Where these disagree, the official docs win.
+
+---
+
+## 1. What a workflow is
+
+A **dynamic workflow is a JavaScript script that orchestrates [subagents](https://code.claude.com/docs/en/sub-agents) at scale.** Claude writes the script for the task you describe, and a runtime executes it **in the background** while your session stays responsive. Intermediate results live in **script variables**, not in Claude's context window — so Claude's context holds only the final answer.
+
+Reach for a workflow when a task needs more agents than one conversation can coordinate, or when you want the orchestration codified as a script you can read and rerun. Canonical uses: a codebase-wide bug sweep, a 500-file migration, a research question whose sources must be cross-checked against each other, and a hard plan worth drafting from several independent angles before committing.
+
+Crucially, moving the plan into code lets a workflow apply a **repeatable quality pattern**, not just run more agents: independent agents can adversarially review each other's findings before anything is reported, or draft a plan from several angles and weigh them — yielding a more trustworthy result than a single pass.
+
+> **Port note.** In a Python host the script language is **Python**, not JavaScript (decided —
+> see the port plan). The *behaviour*, *API shape*, and *semantics* below are preserved; only the
+> surface syntax the model authors changes. Every JS signature in this document has a direct
+> Python equivalent (`await agent(...)`, `await pipeline(...)`, `meta = {...}`).
+
+### 1.1 Workflows vs. subagents, skills, and agent teams
+
+All four can run a multi-step task; the difference is **who holds the plan** (from the official docs):
+
+| | Subagents | Skills | Agent teams | **Workflows** |
+|---|---|---|---|---|
+| What it is | A worker Claude spawns | Instructions Claude follows | A lead supervising peer sessions | **A script the runtime executes** |
+| Who decides what runs next | Claude, turn by turn | Claude, per the prompt | The lead, turn by turn | **The script** |
+| Where intermediate results live | Claude's context | Claude's context | A shared task list | **Script variables** |
+| What's repeatable | The worker definition | The instructions | The team definition | **The orchestration itself** |
+| Scale | A few per turn | A few per turn | A handful of peers | **Dozens to hundreds per run** |
+| Interruption | Restarts the turn | Restarts the turn | Teammates keep running | **Resumable in the same session** |
+
+---
+
+## 2. The programming model
+
+A workflow script (Python in the port; JavaScript upstream) runs in an **async context** with a small set of orchestration primitives injected as globals. It must begin with a `meta` declaration.
+
+### 2.1 The `meta` block
+
+`meta` is a **pure literal** — no variables, calls, spreads, or interpolation — because the runtime extracts and validates it by **static analysis before executing the script**. (In the Python port this is an `ast.literal_eval` over the `meta = {...}` assignment node.)
+
+```python
+# Python-native form (port)
+meta = {
+ "name": "find-flaky-tests",
+ "description": "Find flaky tests and propose fixes", # shown in the approval prompt
+ "when_to_use": "...", # optional; shown in the workflow list
+ "phases": [ # optional; one entry per phase() call
+ {"title": "Scan", "detail": "grep test logs for retries"},
+ {"title": "Fix", "detail": "one agent per flaky test"},
+ ],
+ "model": "sonnet", # optional default/per-phase model
+}
+```
+
+Required: `name`, `description`. `phases[].title` values are matched **exactly** against `phase()` calls in the body to drive the progress display.
+
+### 2.2 Injected globals (the orchestration API)
+
+| Primitive | Signature | Behaviour |
+|---|---|---|
+| `agent` | `await agent(prompt, opts=None) -> Any` | Spawn one subagent. Without `schema`, resolves to its final text. With `schema`, the subagent is forced to emit a **validated** object. Resolves to `None` if the agent is skipped or dies after retries. |
+| `pipeline` | `await pipeline(items, stage1, stage2, ...) -> list` | Run each item through all stages **independently — no barrier between stages** (item A may be in stage 3 while item B is still in stage 1). The default for multi-stage work. Each later stage receives `(prev_result, original_item, index)`. A stage that raises drops that item to `None`. |
+| `parallel` | `await parallel(thunks) -> list` | Run thunks concurrently and **await all** (a barrier). A thunk that raises resolves to `None`; the call itself never raises. Use only when you genuinely need all results together. |
+| `phase` | `phase(title) -> None` | Start a new progress phase; subsequent `agent()` calls group under it. |
+| `log` | `log(message) -> None` | Emit a narrator progress line to the user. |
+| `workflow` | `await workflow(name_or_ref, args=None) -> Any` | Run another workflow inline as a sub-step. **One level of nesting only** — `workflow()` inside a child raises. |
+| `args` | value | The `args` input passed at invocation, verbatim (structured data — lists/dicts usable directly). `None`/`undefined` if omitted. |
+| `budget` | `{ total, spent(), remaining() }` | The turn's token target. `total` is `None` if unset; `spent()` is the shared output-token total across the main loop **and** all workflows; reaching `total` is a **hard ceiling** that makes further `agent()` calls raise. Scripts scale depth with `while budget.total and budget.remaining() > N: ...`. |
+
+`agent()` options (`opts`): `label`, `phase`, `schema`, `model`, `isolation="worktree"`, `agent_type`.
+- `schema` — a JSON Schema; forces schema-validated structured output (see §2.3).
+- `label` — overrides the display label; `phase` — explicitly assigns this call to a progress group (use inside `pipeline`/`parallel` stages to avoid racing the global `phase()` state).
+- `model` — per-call model override (omit to inherit the session model — almost always correct).
+- `isolation="worktree"` — run the agent in a fresh git worktree (expensive; only when parallel agents mutate files and would conflict).
+- `agent_type` — use a named subagent type (e.g. `Explore`) instead of the default general agent; composes with `schema`.
+
+### 2.3 Structured output
+
+`agent(prompt, {schema})` gives the subagent a `StructuredOutput` tool compiled from the JSON Schema. The subagent is steered (by the tool's prompt, not by a forced API `tool_choice`) to call it once at the end; the call's input is **validated against the schema**, and the validated object flows back to the script as the resolved value. Validation failures become an error the model sees and retries, up to a retry cap (upstream default 5). A schema reused across many `agent()` calls is compiled once (identity-cached upstream via `WeakMap`).
+
+### 2.4 Canonical composition patterns
+
+These are conventions the model is taught, not separate API:
+
+- **Pipeline by default** — multi-stage work with no cross-item barrier.
+- **Barrier only when stage N needs all of stage N−1** — dedup/merge across the full set, early-exit on zero, or cross-item comparison. Otherwise the transform goes *inside* a pipeline stage.
+- **Adversarial verify** — N independent skeptics per finding, each prompted to refute; drop on majority-refute.
+- **Perspective-diverse verify** — give each verifier a distinct lens (correctness, security, perf, reproducibility) rather than N identical refuters.
+- **Judge panel** — generate N independent attempts from different angles, score with parallel judges, synthesize from the winner.
+- **Loop-until-dry** — keep spawning finders until K consecutive rounds surface nothing new.
+- **Multi-modal sweep / completeness critic** — parallel searches each blind to the others, then a final agent asking "what's missing?".
+
+### 2.5 The runtime environment (Python port)
+
+The script runs inside an **async sandbox**. Available: the *deterministic* parts of the standard
+library that real workflows rely on — `json`, `re`, `math`, `collections`, `itertools`,
+`functools`, `string`, `textwrap` — plus the safe builtins and exception types. The script may
+define and call its own helper functions and classes (**define before use** — Python does not hoist
+`def`). Top-level `await`, `return` (whose value becomes the workflow's result), and `raise` all
+work. **Not** available: any filesystem, shell, or network access from the script itself (agents do
+all I/O), and the non-deterministic primitives `time`, `random`, and `datetime.now()` — they would
+break deterministic resume, so pass timestamps via `args` or have an agent run `date`. `parallel`
+and `pipeline` accept either coroutines (`agent(...)`, idiomatic) or zero-arg callables returning
+them.
+
+### 2.6 A worked example (Python)
+
+A faithful Python translation of the basic structure used by the real demo corpus
+(`demos/workflows/*.js`) — `meta`, `args` handling, a pure helper, `phase`/`log`, a parallel pair of
+a text agent and a schema agent, and a `return` value:
+
+```python
+meta = {
+ "name": "user-prompt-research",
+ "description": "Discover what customers ask about a keyword; score questions by search volume.",
+ "when_to_use": "Run with a company, product, or keyword to mine real user questions for AEO.",
+ "phases": [
+ {"title": "Search", "detail": "Reddit + Google search in parallel"},
+ {"title": "Questions", "detail": "Rewrite intent signals into natural user questions"},
+ ],
+}
+
+# args may be a bare keyword string or a dict; normalize both.
+data = args if isinstance(args, dict) else {}
+keyword = (args if isinstance(args, str) else data.get("keyword", "")).strip()
+if not keyword:
+ raise ValueError('Pass a keyword via args — e.g. args="AI coding assistant"')
+count = int(data.get("count", 50))
+
+def slugify(s, maxlen=80): # plain helper, defined before use (Python does not hoist)
+ return re.sub(r"-+$", "", re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:maxlen]) or "item"
+
+GOOGLE_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "organic_results": {"type": "array", "items": {
+ "type": "object",
+ "properties": {"title": {"type": "string"}, "link": {"type": "string"}},
+ "required": ["title", "link"],
+ }},
+ "related_questions": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["organic_results", "related_questions"],
+}
+
+log(f'Keyword: "{keyword}" · target questions: {count}')
+
+phase("Search")
+reddit, google = await parallel([
+ agent(f'Summarize real Reddit discussion about "{keyword}" for SEO research ...', label="reddit"),
+ agent(f'Gather the Google SERP for "{keyword}" ...', label="google", schema=GOOGLE_SCHEMA),
+])
+google = google or {"organic_results": [], "related_questions": []}
+log(f'Google: {len(google["organic_results"])} organic results')
+
+phase("Questions")
+raw = await agent(
+ f"Using this research, write {count} natural user questions, one per line:\n"
+ f"{reddit}\n{json.dumps(google)}",
+ label="questions",
+)
+questions = [q.strip() for q in (raw or "").splitlines() if "?" in q]
+if not questions:
+ raise RuntimeError("No questions produced.")
+
+return { # the returned value is the workflow's result
+ "keyword": keyword,
+ "questions": questions,
+ "csv_path": f"./out/{slugify(keyword)}-questions.csv",
+}
+```
+
+The original JavaScript form of this workflow is `demos/workflows/02-user-prompt-research.js`; the
+Python ports of the demo corpus should live alongside it (e.g. `demos/workflows/*.py`).
+
+---
+
+## 3. Execution semantics
+
+| Aspect | Behaviour (authoritative) |
+|---|---|
+| **Concurrency cap** | Excess `agent()` calls queue and run as slots free. Upstream allows **up to 16** (`min(16, cpu_cores − 2)`). **ClawCodex defaults to a gentler `4`** — workflow agents are rate-limit-bound, not CPU-bound, so a big fan-out can burn a plan's window in one burst; raise it with `CLAUDE_CODE_WORKFLOW_MAX_AGENTS`. |
+| **Lifetime agent cap** | **1,000 agents total per run** — a runaway-loop backstop. |
+| **Per-call item cap** | A single `pipeline()`/`parallel()` call accepts at most 4,096 items; more is an explicit error, not silent truncation. |
+| **No mid-run user input** | Only agent permission prompts can pause a run. For sign-off between stages, run each stage as its own workflow. |
+| **No direct fs/shell from the script** | The script coordinates; **agents** read, write, and run commands. The workflow body has no filesystem or shell access. |
+| **Budget** | A turn-level token target acts as a hard ceiling. `budget.spent()` is shared across the main loop and all workflows; once it reaches `budget.total`, `agent()` raises. The agent caps also bound the cost of a runaway script. |
+| **Worktree isolation** | `agent(..., {isolation:"worktree"})` runs the agent in a fresh git worktree, auto-removed if unchanged. Per-agent worktrees are named `wf_-`. |
+| **Sandbox restrictions** | No filesystem/host access from the script. Non-deterministic primitives (`Date.now`/`Math.random` upstream; `time`/`random`/`datetime.now` in the Python port) are unavailable or frozen — they would break deterministic resume. Timestamps come in via `args`; randomness varies by agent index. |
+| **Resume / journaling** | Every run has a `runId`. Each `agent()` call's inputs and result are journaled. Resuming replays the **longest unchanged prefix** from cache and runs the rest live; same script + same args ⇒ 100% cache hit. **Resume works only within the same session** — exiting Claude Code restarts a running workflow fresh next session. |
+| **Model** | Every agent uses the **session's model** unless the script routes a stage elsewhere via `agent(..., {model})`. |
+| **MCP access** | Workflow agents reach session-connected MCP tools (schemas load on demand). Interactively-authenticated MCP servers may be absent in headless/cron runs. |
+
+---
+
+## 4. The user experience and surfaces
+
+### 4.1 Authoring a workflow
+
+- **Ask in your prompt.** Include the keyword `ultracode` (pre-v2.1.160 it was `workflow`), or just ask in your own words ("use a workflow", "run a workflow"). Claude writes a script for the task instead of working turn by turn. `Option+W` / `Alt+W` dismisses the keyword highlight for one prompt.
+- **`/effort ultracode`.** Combines `xhigh` reasoning with automatic workflow orchestration: Claude plans a workflow for *every* substantive task in the session (often several in a row — understand, change, verify). Lasts the session; reset with `/effort high`. Only on models that support `xhigh` effort.
+
+### 4.2 Bundled workflow: `/deep-research`
+
+The one built-in workflow. `/deep-research ` fans out web searches across several angles, fetches and cross-checks sources, **votes on each claim**, and returns a cited report with claims that didn't survive cross-checking filtered out. Requires the WebSearch tool. Saved workflows become `/` commands the same way and appear in `/` autocomplete alongside it.
+
+### 4.3 Watching a run: `/workflows`
+
+Workflows run in the background. `/workflows` lists running and completed runs; select one to open the **progress view** showing each phase with its **agent count, token total, and elapsed time**. A one-line summary also appears in the task panel below the input box. Key bindings in the progress view:
+
+| Key | Action |
+|---|---|
+| `↑` / `↓` | Select a phase or agent |
+| `Enter` / `→` | Drill into the phase, then into an agent (read its prompt, recent tool calls, result) |
+| `Esc` | Back out one level |
+| `j` / `k` | Scroll within agent detail on overflow |
+| `p` | **Pause or resume** the run |
+| `x` | **Stop** the selected agent, or the whole workflow when focus is on the run |
+| `r` | **Restart** the selected running agent |
+| `s` | **Save** the run's script as a command |
+
+### 4.4 Approval and permission model
+
+The per-run prompt shows the planned phases and: **Yes, run it** / **Yes, and don't ask again for `` in ``** / **View raw script** / **No**. `Ctrl+G` opens the script in your editor; `Tab` lets you adjust the prompt before the run starts.
+
+Whether you're prompted depends on permission mode:
+
+| Permission mode | When you're prompted |
+|---|---|
+| Default, accept edits | Every run, unless you chose "don't ask again" for that workflow in this project |
+| Auto | First launch only; any **Yes** records consent in user settings; skipped entirely when ultracode is on |
+| Bypass / `claude -p` / Agent SDK | Never — the run starts immediately |
+
+**Critically:** the subagents a workflow spawns **always run in `acceptEdits` mode and inherit your tool allowlist, regardless of your session's mode.** File edits are auto-approved; shell/web/MCP calls *not* in your allowlist can still prompt mid-run. Your session's permission mode controls only the launch prompt.
+
+### 4.5 Saving, discovery, and `args`
+
+- **Save:** in `/workflows`, select a run and press `s`. `Tab` toggles two locations: `.claude/workflows/` (project, shared via the repo) or `~/.claude/workflows/` (personal, all projects). Saved workflows run as `/`. **A project workflow wins over a personal one** on name clash.
+- **`args`:** a saved workflow reads invocation input from the `args` global, passed as **structured data** (lists/dicts usable without parsing); `undefined`/`None` if omitted. Example: `Run /triage-issues on issues 1024, 1025, and 1030`.
+
+### 4.6 Persistence and resume
+
+Every run writes its script to a file under the session's directory in `~/.claude/projects//`; Claude receives the path when the run starts (you can ask for it, diff it against a prior run, or edit it and relaunch). The runtime tracks each agent's result, which is what makes a run **resumable within the same session** (`p` in `/workflows`, or ask Claude to relaunch with the same script). Exiting Claude Code mid-run restarts it fresh next session.
+
+### 4.7 Cost
+
+A workflow spawns many agents, so one run can use meaningfully more tokens than the same task in conversation; runs count toward plan usage and rate limits. The agent caps bound a runaway script. The `/workflows` view shows each agent's token usage live, and you can stop a run there without losing completed work. To gauge spend, run on a small slice first.
+
+### 4.8 Turning workflows off
+
+- `/config` → toggle **Dynamic workflows** off (persists).
+- `"disableWorkflows": true` in `~/.claude/settings.json` (persists), or in managed settings for an org.
+- `CLAUDE_CODE_DISABLE_WORKFLOWS=1` (read at startup).
+
+When disabled: bundled workflow commands are unavailable, the `ultracode` keyword no longer triggers a run, and `ultracode` is removed from the `/effort` menu.
+
+---
+
+## 5. Reference architecture (implementation-neutral)
+
+The engine is a constellation of seams, not one module. The port maps each onto a clawcodex subsystem; see the port plan for exact targets.
+
+```
+ user / model invokes a workflow
+ (ultracode keyword · /deep-research · /)
+ │
+ ▼
+ ┌───────────────────────┐ spawns + streams
+ │ Workflow tool │──────────────────────────────┐
+ │ (one tool-use call) │ │
+ └───────────┬───────────┘ ▼
+ │ runs the script ┌───────────────────────────┐
+ ▼ │ per-agent() call: │
+ ┌───────────────────────┐ agent() │ subagent runner │
+ │ Workflow runtime │──────────────►│ + finalize → text │
+ │ · sandbox (script) │◄──────────────│ + schema → validated obj │
+ │ · scheduler (≤16) │ result │ + child abort controller │
+ │ · budget · journal │ └───────────────────────────┘
+ └───────────┬───────────┘
+ │ surfaces as a background task
+ ▼
+ ┌───────────────────────────┐ ┌─────────────────────────────────────┐
+ │ Workflow background task │───►│ task-panel pill ("N background │
+ │ (phases → agents tree, │ │ workflows") · /workflows progress │
+ │ per-agent abort map) │ │ view · per-agent detail (p/x/r/s) │
+ └───────────────────────────┘ └─────────────────────────────────────┘
+
+ discovery ──► /workflows (static) · /deep-research (bundled) ·
+ / per file in .claude/workflows + ~/.claude/workflows
+ approval ──► per-run permission prompt; subagents run acceptEdits + inherit allowlist
+ gating ──► disableWorkflows setting · CLAUDE_CODE_DISABLE_WORKFLOWS · /config
+```
+
+The subsystems involved:
+
+1. **The Workflow tool** — a single tool-use the model calls; it launches the run and returns a handle/result.
+2. **The runtime** — executes the script in a sandbox, injects the primitives, schedules bounded-concurrency `agent()` fan-out, accounts budget, and journals for resume.
+3. **The subagent path** — each `agent()` reuses the host's existing subagent runner and result-finalization, plus a schema-validated structured-output mechanism.
+4. **The background task** — a phase/agent tree with per-agent abort handles, surfaced as a pill and a `/workflows` progress/detail view.
+5. **Commands** — a static `/workflows`, the bundled `/deep-research`, and one dynamic `/` command per saved workflow file.
+6. **Permission** — a per-run approval dialog with "don't ask again for `` in ``"; auto-mode pre-allowlisting; subagents forced to `acceptEdits` with the inherited allowlist.
+7. **Gating** — a settings key + env var + `/config` toggle.
+
+---
+
+## 6. Provenance — why there is no source to copy
+
+The upstream engine is gated behind the internal-only build flag `WORKFLOW_SCRIPTS`. In the bundled `typescript/` snapshot its implementation files were stripped (only the stub `typescript/src/tools/WorkflowTool/constants.ts` remains), and the compiled `dist/cli.mjs` was built with the flag off, so every workflow symbol was dead-code-eliminated to `null`. The canonical public repo `Gitlawb/openclaude` ships the identical stub. What survives is the **integration surface** — the flag-gated call sites — which carries the real signatures and is the Rosetta stone the port plan uses. There is therefore nothing to extract; the engine must be **reconstructed** against this spec. The reconstruction targets the **Python** implementation; see [`workflow-engine-port-plan.md`](./workflow-engine-port-plan.md).
diff --git a/eval/README.md b/eval/README.md
new file mode 100644
index 000000000..e05f247e0
--- /dev/null
+++ b/eval/README.md
@@ -0,0 +1,299 @@
+# Eval — clawcodex vs openclaude on SWE-bench
+
+This directory drives a side-by-side comparison of **clawcodex** (this repo)
+and **openclaude** (the TypeScript reference) against
+[SWE-bench](https://swe-bench.github.io). The goal is to confirm parity:
+when both agents are pointed at the same backing model, the same dataset, and
+the same prompts, do they resolve the same set of GitHub issues?
+
+The agent-side wrappers and prediction generation live with the SWE-bench
+harness in `SWE-bench-dev/scripts/`. The comparison logic and the driver that
+ties everything together live here.
+
+## Layout
+
+```text
+SWE-bench-dev/scripts/ # in the SWE-bench-dev repo
+├── clawcodex_api_server.py # FastAPI wrapper around `clawcodex -p ...`
+├── openclaude_api_server.py # FastAPI wrapper around `node dist/cli.mjs -p ...`
+└── run_custom_api.py # generic dataset → HTTP → predictions.jsonl
+
+eval/ # in this repo (committed on feat/eval)
+├── run_compare.py # one-command driver: prepare → run → compare
+├── compare_results.py # standalone summary diff
+├── README.md # this file
+└── runs/ # gitignored output (per-run)
+ └── compare-YYYYMMDD-HHMMSS/
+ ├── clawcodex_preds.jsonl
+ ├── openclaude_preds.jsonl
+ ├── clawcodex_server.log
+ ├── openclaude_server.log
+ ├── clawcodex_harness.log
+ ├── openclaude_harness.log
+ ├── only_clawcodex.txt # instance ids only clawcodex solved
+ ├── only_openclaude.txt # instance ids only openclaude solved
+ ├── both_solved.txt
+ └── comparison.md # the headline report
+```
+
+## Prerequisites (one-time)
+
+1. **Sibling repos.** Clone `openclaude` and `SWE-bench-dev` next to `clawcodex`:
+
+ ```bash
+ git clone https://github.com/Gitlawb/openclaude.git
+ git clone https://github.com/swe-bench/SWE-bench.git SWE-bench-dev
+ ```
+
+ (Or set `OPENCLAUDE_REPO` / `SWEBENCH_REPO` to wherever you keep them.)
+
+2. **Docker** is installed and `docker ps` works.
+
+3. **SWE-bench venv** (per `SWE-bench-dev/clawcodex_test.md` §2.1):
+
+ ```bash
+ cd SWE-bench-dev
+ python3 -m venv .venv && source .venv/bin/activate
+ pip install -U pip
+ pip install -e .
+ pip install fastapi uvicorn tiktoken transformers
+ ```
+
+ Point the driver at this interpreter via `SWEBENCH_PYTHON`:
+
+ ```bash
+ export SWEBENCH_PYTHON=/abs/path/to/SWE-bench-dev/.venv/bin/python
+ ```
+
+4. **clawcodex configured** for the model you want to compare (`clawcodex login`).
+
+5. **openclaude provider env** for the same backing model. For `gpt-4o`:
+
+ ```bash
+ export CLAUDE_CODE_USE_OPENAI=1
+ export OPENAI_API_KEY=sk-...
+ export OPENAI_MODEL=gpt-4o
+ ```
+
+ For DeepSeek through the OpenAI-compatible path:
+
+ ```bash
+ export CLAUDE_CODE_USE_OPENAI=1
+ export OPENAI_API_KEY=sk-deepseek-...
+ export OPENAI_BASE_URL=https://api.deepseek.com/v1 # the driver also sets this
+ export OPENAI_MODEL=deepseek-v4-pro
+ ```
+
+ The driver's `--provider deepseek` preset will pass `OPENAI_BASE_URL` and
+ `OPENAI_MODEL` per request, so the env exports for those two are optional;
+ `OPENAI_API_KEY` always travels via the environment.
+
+### Python that can import `swebench`
+
+`prepare` runs `python -m swebench.inference.make_datasets.create_text_dataset`, so the
+interpreter chosen by `SWEBENCH_PYTHON` (or your default `python3` / `python`) must
+have SWE-bench installed. Typical setups:
+
+**Option A — install into the clawcodex venv** (one interpreter for everything):
+
+```bash
+cd clawcodex
+# Prefer the venv binary so you are not using the Windows Store `python3` shim.
+uv pip install -e ./SWE-bench-dev fastapi uvicorn tiktoken transformers
+# Optional: `run_compare` defaults to sys.executable, so after `activate` you
+# can omit SWEBENCH_PYTHON when you launch with that same `python`.
+```
+
+If you see **`cannot import swebench`** while the error suggests
+`...\WindowsApps\python3.EXE`, you never installed `swebench` into *that* stub.
+Use `.venv/Scripts/python.exe eval/run_compare.py ...` or set
+`export SWEBENCH_PYTHON="$PWD/.venv/Scripts/python.exe"`.
+
+**Option B — separate SWE-bench venv** (matches `clawcodex_test.md`):
+
+```bash
+cd SWE-bench-dev
+python -m venv .venv
+# Windows Git Bash:
+source .venv/Scripts/activate
+pip install -U pip
+pip install -e .
+pip install fastapi uvicorn tiktoken transformers
+export SWEBENCH_PYTHON="$PWD/.venv/Scripts/python.exe"
+```
+
+Without this, `prepare` will fail with `No module named 'swebench'`.
+
+### `bun not found on PATH` during `prepare`
+
+OpenClaude’s build script expects [Bun](https://bun.com). Either install it, or
+only build the SWE-bench dataset for now:
+
+```bash
+python eval/run_compare.py prepare --skip-openclaude-build
+```
+
+Then install Bun and run `bun install && bun run build` inside `openclaude/`, or
+re-run `prepare` without `--skip-openclaude-build` once `bun` is on your `PATH`.
+On Windows, Git Bash sometimes does not see Bun until you add
+`~/.bun/bin` (or the path the installer prints) to `PATH`.
+
+### `UnicodeDecodeError: 'gbk' codec can't decode...` during `prepare`
+
+On Chinese (and some other) Windows locales, the default text encoding is GBK.
+SWE-bench’s dataset builder was opening cloned source as “system default”, which
+breaks on UTF-8 files. This repo’s `SWE-bench-dev` fork reads those paths as
+UTF-8. Re-run `prepare` after pulling the latest `create_instance.py` changes.
+
+### Windows: `No module named 'resource'`
+
+The stdlib `resource` module exists only on Unix. Upstream SWE-bench imported it
+unconditionally in `prepare_images.py`, which breaks `import swebench` on Windows.
+This repo’s `SWE-bench-dev` fork patches that import so dataset prep and imports
+work on Windows. Docker-based evaluation still requires Docker Desktop; if you hit
+other POSIX-only code paths, use WSL2 for the harness.
+
+## Quickest path to a result
+
+```bash
+# 1. Build openclaude and the SWE-bench text dataset (only needed once).
+python eval/run_compare.py prepare
+
+# 2. Smoke run: 1 known instance, both agents, gpt-4o, full Docker harness.
+python eval/run_compare.py run --scope smoke
+
+# 3. Open the report.
+ls eval/runs/ # find the latest compare-* directory
+cat eval/runs/compare-*/comparison.md
+```
+
+A smoke run takes a few minutes per instance. Scaling up:
+
+```bash
+# Pick your own instances:
+python eval/run_compare.py run \
+ --scope instances \
+ --instance-ids astropy__astropy-12907,django__django-11099
+
+# Or the full 300-instance Lite split (takes hours, costs real money):
+python eval/run_compare.py run --scope all
+```
+
+### Picking the model for both agents
+
+The `--provider` preset sets the model and per-agent provider routing in one
+flag. Available presets:
+
+| Preset | Model | clawcodex routing | openclaude routing |
+|---|---|---|---|
+| `openai` (default) | `gpt-4o` | `--provider openai` | OpenAI native |
+| `deepseek` | `deepseek-v4-pro` | `--provider deepseek` | OpenAI-compatible (`https://api.deepseek.com/v1`) |
+| `anthropic` | `claude-sonnet-4-6` | `--provider anthropic` | Anthropic native |
+| `zai` | `glm-5.2` | `--provider zai` | OpenAI-compatible (`https://api.z.ai/api/coding/paas/v4`) |
+
+Examples:
+
+```bash
+# DeepSeek v4-pro on both:
+python eval/run_compare.py run --scope smoke --provider deepseek
+
+# DeepSeek but a different model name:
+python eval/run_compare.py run --scope smoke --provider deepseek --model deepseek-coder-v4
+
+# Custom OpenAI-compatible endpoint:
+python eval/run_compare.py run --scope smoke \
+ --provider openai --model my-finetune \
+ --openclaude-base-url https://my-gateway.example.com/v1
+
+# Run only one of the two agents (sometimes useful when iterating):
+python eval/run_compare.py run --agents openclaude --provider deepseek
+```
+
+Per-field overrides (`--model`, `--clawcodex-provider`,
+`--openclaude-provider`, `--openclaude-base-url`) layer on top of the preset.
+
+## What `run` actually does (sequentially per agent)
+
+For each agent in `--agents` (default `clawcodex,openclaude`):
+
+1. **Spawn its API server** (`uvicorn scripts._api_server:app`) on its
+ port (8000 for clawcodex, 8001 for openclaude). Logs go to
+ `eval/runs//_server.log`.
+2. **Wait for `/health`** to come up. Falls back to a TCP-only liveness check
+ if the wrapper doesn't expose `/health` yet.
+3. **Generate predictions** by invoking
+ `SWE-bench-dev/scripts/run_custom_api.py` against the local `/generate`
+ endpoint. Writes `_preds.jsonl`.
+4. **Stop the server.**
+5. **Run the Docker harness** (`swebench.harness.run_evaluation`) on those
+ predictions. Writes the harness summary into the SWE-bench repo as
+ `-local..json`.
+
+After both agents are done:
+
+6. **Diff the two summary jsons** via `compare_results.py` and write
+ `comparison.md` plus `only_.txt` triage lists.
+
+## Just compare two existing runs
+
+If you already have two harness summary jsons lying around:
+
+```bash
+python eval/compare_results.py \
+ --left /path/to/clawcodex-local.run-001.json --left-label clawcodex \
+ --right /path/to/openclaude-local.run-001.json --right-label openclaude \
+ --out eval/runs/manual/comparison.md
+```
+
+`run_compare.py compare` is a thin alias for the same call.
+
+## Tunables worth knowing
+
+| Flag | Default | Notes |
+|---|---|---|
+| `--scope` | `smoke` | `smoke` / `instances` / `all` (full split) |
+| `--provider` | `openai` | Preset: `openai` / `deepseek` / `anthropic` / `zai` |
+| `--model` | (preset's default) | Override the preset's model name |
+| `--clawcodex-provider` | (preset) | Override clawcodex `--provider` flag |
+| `--openclaude-provider` | (preset) | Override openclaude routing hint |
+| `--openclaude-base-url` | (preset) | Override `OPENAI_BASE_URL` for openclaude |
+| `--max-turns` | `30` | Per-instance agent turn cap |
+| `--request-timeout` | `1800` | HTTP timeout per instance, seconds |
+| `--max-patch-retries` | `2` | Re-prompt when extracted diff is invalid |
+| `--max-workers` | `1` | Docker harness parallelism (raise carefully) |
+| `--skip-harness` | off | Generate predictions only — useful when iterating on prompts |
+| `--agents` | `clawcodex,openclaude` | Drop one to run only the other |
+
+## Troubleshooting
+
+- **`Text dataset not found`** — You skipped `prepare`, or it never finished. Run
+ `python eval/run_compare.py prepare` (from the clawcodex repo) after installing
+ `swebench` into the interpreter `SWEBENCH_PYTHON` points at (see **Python that
+ can import `swebench`** above). On Windows Git Bash you can use
+ `.venv/Scripts/python.exe eval/run_compare.py prepare`.
+
+- **`text dataset not found at .../datasets/SWE-bench__SWE-bench_Lite__style-3__fs-oracle`** —
+ Same as above: run `prepare` first, or pass `--dataset-local` if you keep the dataset elsewhere.
+
+- **`OPENCLAUDE_REPO set but dist/cli.mjs not found`** — `prepare` should
+ build it for you. If it doesn't, `cd openclaude && bun install && bun run build`.
+
+- **`No module named 'src'` from clawcodex** — the wrapper's fallback path
+ needs `CLAWCODEX_REPO` set. The driver passes it automatically; if you're
+ running the wrapper by hand, follow `SWE-bench-dev/clawcodex_test.md` §2.2.
+
+- **One agent blew up but the other was fine** — the run still produces a
+ half-finished comparison. Check `eval/runs//_server.log` and
+ `_harness.log`.
+
+- **Patch apply errors (`Only garbage was found in the patch input`)** —
+ usually the model returned prose instead of a unified diff. The patch-retry
+ loop in `run_custom_api.py` already handles this; if it persists, lower
+ `--max-turns` or pick a model with stronger tool/diff fidelity.
+
+## See also
+
+- `SWE-bench-dev/clawcodex_test.md` — manual reproduction of every step the
+ driver automates, plus the original error-recovery cookbook.
+- `SWE-bench-dev/swebench/harness/reporting.py` — defines the summary JSON
+ shape that `compare_results.py` reads.
diff --git a/eval/_clear_infra_errors.py b/eval/_clear_infra_errors.py
new file mode 100644
index 000000000..2c5c63154
--- /dev/null
+++ b/eval/_clear_infra_errors.py
@@ -0,0 +1,78 @@
+"""Identify infra-only errors (docker-image-404, docker-timeout) per agent and
+delete their per-instance harness logs so SWE-bench's skip-existing logic will
+re-run them. Predictions stay cached.
+
+Usage:
+ python eval/_clear_infra_errors.py
+"""
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import sys
+from pathlib import Path
+
+REPO = Path(r"C:\Users\fmche\PycharmProjects\clawcodex")
+SWEBENCH = REPO / "SWE-bench-dev"
+
+
+def classify(log_text: str) -> str:
+ if "ImageNotFound" in log_text or "No such image" in log_text:
+ return "docker-image-404"
+ if "patch unexpectedly ends in middle of line" in log_text:
+ return "patch-missing-newline"
+ if "Reversed (or previously applied) patch" in log_text:
+ return "patch-reversed"
+ if re.search(r"Hunk #\d+ FAILED at", log_text):
+ return "patch-context-mismatch"
+ if "Only garbage was found in the patch" in log_text:
+ return "patch-garbage"
+ if "Timeout" in log_text or "timed out" in log_text:
+ return "docker-timeout"
+ if "docker.errors" in log_text:
+ return "docker-other"
+ return "other"
+
+
+def main(run_id: str) -> None:
+ infra_categories = {"docker-image-404", "docker-timeout", "docker-other"}
+
+ for agent in ("clawcodex", "openclaude"):
+ summary_path = SWEBENCH / f"{agent}-local.{run_id}-{agent}.json"
+ if not summary_path.exists():
+ print(f" [{agent}] no summary at {summary_path}; skipping")
+ continue
+ s = json.load(open(summary_path, encoding="utf-8"))
+ err_ids = s.get("error_ids", [])
+
+ infra_ids = []
+ for iid in err_ids:
+ log = (
+ SWEBENCH / "logs" / "run_evaluation" / f"{run_id}-{agent}"
+ / f"{agent}-local" / iid / "run_instance.log"
+ )
+ if not log.exists():
+ continue
+ cat = classify(log.read_text(encoding="utf-8", errors="replace"))
+ if cat in infra_categories:
+ infra_ids.append(iid)
+
+ print(f"[{agent}] {len(infra_ids)} infra-error instances of {len(err_ids)} total errors")
+
+ # Delete per-instance dirs so harness re-runs them
+ for iid in infra_ids:
+ d = (
+ SWEBENCH / "logs" / "run_evaluation" / f"{run_id}-{agent}"
+ / f"{agent}-local" / iid
+ )
+ if d.exists():
+ shutil.rmtree(d)
+
+ # Delete summary so it gets regenerated cleanly
+ summary_path.unlink(missing_ok=True)
+ print(f" cleared {len(infra_ids)} dirs + summary JSON for {agent}")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1] if len(sys.argv) > 1 else "verified-gemini-full")
diff --git a/eval/_describe_dataset.py b/eval/_describe_dataset.py
new file mode 100644
index 000000000..213db7403
--- /dev/null
+++ b/eval/_describe_dataset.py
@@ -0,0 +1,77 @@
+"""Summarize the SWE-bench Verified text dataset we're evaluating on."""
+import statistics
+from collections import Counter
+from datasets import load_from_disk
+
+DS = r"C:\Users\fmche\PycharmProjects\clawcodex\SWE-bench-dev\datasets\SWE-bench__SWE-bench_Verified__style-3__fs-oracle"
+ds = load_from_disk(DS)["test"]
+
+print(f"Total instances: {len(ds)}")
+print(f"Fields per instance: {list(ds[0].keys())}")
+print()
+
+# By repo
+by_repo = Counter(r["repo"] for r in ds)
+print(f"By repo ({len(by_repo)} repos):")
+for repo, n in by_repo.most_common():
+ print(f" {repo:30s} {n}")
+print()
+
+# Prompt size
+prompt_sizes = [len(r["text"]) for r in ds]
+print(f"Prompt size (chars):")
+print(f" min: {min(prompt_sizes):>8,}")
+print(f" p25: {int(statistics.quantiles(prompt_sizes, n=4)[0]):>8,}")
+print(f" median: {int(statistics.median(prompt_sizes)):>8,}")
+print(f" p75: {int(statistics.quantiles(prompt_sizes, n=4)[2]):>8,}")
+print(f" p95: {int(statistics.quantiles(prompt_sizes, n=20)[18]):>8,}")
+print(f" max: {max(prompt_sizes):>8,}")
+print(f" total: {sum(prompt_sizes):>8,}")
+print()
+
+# Gold patch sizes
+patch_sizes = [len(r["patch"]) for r in ds]
+print(f"Gold patch size (chars):")
+print(f" min: {min(patch_sizes):>8,}")
+print(f" median: {int(statistics.median(patch_sizes)):>8,}")
+print(f" p95: {int(statistics.quantiles(patch_sizes, n=20)[18]):>8,}")
+print(f" max: {max(patch_sizes):>8,}")
+print()
+
+# Tests per instance
+import json
+fail_to_pass = [len(json.loads(r["FAIL_TO_PASS"]) if isinstance(r["FAIL_TO_PASS"], str) else r["FAIL_TO_PASS"]) for r in ds]
+pass_to_pass = [len(json.loads(r["PASS_TO_PASS"]) if isinstance(r["PASS_TO_PASS"], str) else r["PASS_TO_PASS"]) for r in ds]
+print(f"FAIL_TO_PASS tests per instance:")
+print(f" median: {int(statistics.median(fail_to_pass))} max: {max(fail_to_pass)}")
+print(f"PASS_TO_PASS tests per instance:")
+print(f" median: {int(statistics.median(pass_to_pass))} max: {max(pass_to_pass)}")
+print(f" total tests evaluated across all 499: {sum(fail_to_pass)+sum(pass_to_pass):,}")
+print()
+
+# Show one concrete example
+print("=" * 60)
+print("SAMPLE INSTANCE (astropy__astropy-12907)")
+print("=" * 60)
+row = next(r for r in ds if r["instance_id"] == "astropy__astropy-12907")
+print(f"repo: {row['repo']}")
+print(f"base_commit: {row['base_commit']}")
+print(f"version: {row.get('version', '(none)')}")
+print(f"prompt len: {len(row['text']):,} chars")
+print(f"gold patch len: {len(row['patch']):,} chars")
+print()
+ftp = json.loads(row["FAIL_TO_PASS"]) if isinstance(row["FAIL_TO_PASS"], str) else row["FAIL_TO_PASS"]
+ptp = json.loads(row["PASS_TO_PASS"]) if isinstance(row["PASS_TO_PASS"], str) else row["PASS_TO_PASS"]
+print(f"FAIL_TO_PASS ({len(ftp)} tests):")
+for t in ftp[:3]:
+ print(f" {t}")
+print(f"PASS_TO_PASS ({len(ptp)} tests):")
+for t in ptp[:3]:
+ print(f" {t}")
+print()
+print("--- problem_statement (first 600 chars) ---")
+ps = row.get("problem_statement", "")
+print(ps[:600] + ("..." if len(ps) > 600 else ""))
+print()
+print("--- gold patch (first 600 chars) ---")
+print(row["patch"][:600] + ("..." if len(row["patch"]) > 600 else ""))
diff --git a/eval/compare_results.py b/eval/compare_results.py
new file mode 100644
index 000000000..f47cc3ddb
--- /dev/null
+++ b/eval/compare_results.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+"""Compare two SWE-bench harness summary reports side-by-side.
+
+Reads two ``..json`` files produced by
+``swebench.harness.run_evaluation`` (see SWE-bench-dev/swebench/harness/reporting.py)
+and emits:
+
+* a top-line table (resolved / unresolved / empty-patch / error counts)
+* per-instance disagreement lists (only-A-solved, only-B-solved, both, neither)
+* a markdown report suitable for committing alongside a run
+
+Usage:
+ python eval/compare_results.py \
+ --left path/to/clawcodex..json --left-label clawcodex \
+ --right path/to/openclaude..json --right-label openclaude \
+ --out eval/runs//comparison.md
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Summary:
+ """Subset of the harness summary JSON we actually need."""
+
+ label: str
+ path: Path
+ total: int
+ submitted: int
+ completed: int
+ resolved: int
+ unresolved: int
+ empty_patch: int
+ error: int
+ resolved_ids: frozenset[str]
+ unresolved_ids: frozenset[str]
+ empty_patch_ids: frozenset[str]
+ error_ids: frozenset[str]
+ submitted_ids: frozenset[str]
+
+
+def load_summary(path: Path, label: str) -> Summary:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ return Summary(
+ label=label,
+ path=path,
+ total=int(raw.get("total_instances", 0)),
+ submitted=int(raw.get("submitted_instances", 0)),
+ completed=int(raw.get("completed_instances", 0)),
+ resolved=int(raw.get("resolved_instances", 0)),
+ unresolved=int(raw.get("unresolved_instances", 0)),
+ empty_patch=int(raw.get("empty_patch_instances", 0)),
+ error=int(raw.get("error_instances", 0)),
+ resolved_ids=frozenset(raw.get("resolved_ids", [])),
+ unresolved_ids=frozenset(raw.get("unresolved_ids", [])),
+ empty_patch_ids=frozenset(raw.get("empty_patch_ids", [])),
+ error_ids=frozenset(raw.get("error_ids", [])),
+ submitted_ids=frozenset(raw.get("submitted_ids", [])),
+ )
+
+
+def _pct(n: int, d: int) -> str:
+ return f"{(100.0 * n / d):.1f}%" if d > 0 else "n/a"
+
+
+def _format_id_list(ids: list[str], cap: int = 50) -> str:
+ if not ids:
+ return "_(none)_"
+ head = ids[:cap]
+ body = "\n".join(f"- `{i}`" for i in head)
+ if len(ids) > cap:
+ body += f"\n- _… {len(ids) - cap} more_"
+ return body
+
+
+def render_markdown(left: Summary, right: Summary) -> str:
+ """Return a markdown report for ``left`` vs ``right``."""
+ only_left = sorted(left.resolved_ids - right.resolved_ids)
+ only_right = sorted(right.resolved_ids - left.resolved_ids)
+ both = sorted(left.resolved_ids & right.resolved_ids)
+ neither = sorted(
+ (left.submitted_ids | right.submitted_ids)
+ - left.resolved_ids
+ - right.resolved_ids
+ )
+
+ denom = max(left.total, right.total, 1)
+ delta = left.resolved - right.resolved
+
+ lines: list[str] = []
+ lines.append(f"# SWE-bench comparison: `{left.label}` vs `{right.label}`")
+ lines.append("")
+ lines.append(f"- **{left.label}** report: `{left.path}`")
+ lines.append(f"- **{right.label}** report: `{right.path}`")
+ lines.append("")
+ lines.append("## Top line")
+ lines.append("")
+ lines.append(
+ f"| metric | {left.label} | {right.label} | Δ ({left.label} − {right.label}) |"
+ )
+ lines.append("|---|---:|---:|---:|")
+
+ def row(metric: str, l: int, r: int) -> str:
+ return f"| {metric} | {l} ({_pct(l, denom)}) | {r} ({_pct(r, denom)}) | {l - r:+d} |"
+
+ lines.append(row("resolved", left.resolved, right.resolved))
+ lines.append(row("unresolved", left.unresolved, right.unresolved))
+ lines.append(row("empty patch", left.empty_patch, right.empty_patch))
+ lines.append(row("error", left.error, right.error))
+ lines.append(row("submitted", left.submitted, right.submitted))
+ lines.append(row("completed", left.completed, right.completed))
+ lines.append("")
+ lines.append(f"_Total instances in dataset_: **{denom}**")
+ lines.append("")
+ lines.append("## Headline")
+ lines.append("")
+ if delta == 0:
+ lines.append(f"`{left.label}` and `{right.label}` resolved the same number of instances ({left.resolved}).")
+ elif delta > 0:
+ lines.append(
+ f"`{left.label}` resolved **{delta} more** instance(s) than `{right.label}` "
+ f"({left.resolved} vs {right.resolved})."
+ )
+ else:
+ lines.append(
+ f"`{right.label}` resolved **{-delta} more** instance(s) than `{left.label}` "
+ f"({right.resolved} vs {left.resolved})."
+ )
+ lines.append("")
+ lines.append(
+ f"- Both solved: **{len(both)}**\n"
+ f"- Only `{left.label}` solved: **{len(only_left)}**\n"
+ f"- Only `{right.label}` solved: **{len(only_right)}**\n"
+ f"- Neither solved: **{len(neither)}**"
+ )
+ lines.append("")
+ lines.append(f"## Only `{left.label}` solved ({len(only_left)})")
+ lines.append("")
+ lines.append(_format_id_list(only_left))
+ lines.append("")
+ lines.append(f"## Only `{right.label}` solved ({len(only_right)})")
+ lines.append("")
+ lines.append(_format_id_list(only_right))
+ lines.append("")
+ lines.append(f"## Both solved ({len(both)})")
+ lines.append("")
+ lines.append(_format_id_list(both, cap=20))
+ lines.append("")
+ lines.append(f"## Neither solved ({len(neither)})")
+ lines.append("")
+ lines.append(_format_id_list(neither, cap=20))
+ lines.append("")
+ return "\n".join(lines)
+
+
+def render_text(left: Summary, right: Summary) -> str:
+ """Compact stdout summary."""
+ only_left = len(left.resolved_ids - right.resolved_ids)
+ only_right = len(right.resolved_ids - left.resolved_ids)
+ both = len(left.resolved_ids & right.resolved_ids)
+ denom = max(left.total, right.total, 1)
+ width = max(len(left.label), len(right.label), 8)
+ lines = [
+ f"SWE-bench comparison ({left.label} vs {right.label}) — {denom} instances",
+ f" {'agent'.ljust(width)} resolved unresolved empty error",
+ f" {left.label.ljust(width)} "
+ f"{left.resolved:>8} {left.unresolved:>10} {left.empty_patch:>5} {left.error:>5}",
+ f" {right.label.ljust(width)} "
+ f"{right.resolved:>8} {right.unresolved:>10} {right.empty_patch:>5} {right.error:>5}",
+ "",
+ f" both solved: {both} | only {left.label}: {only_left} | only {right.label}: {only_right}",
+ ]
+ return "\n".join(lines)
+
+
+def write_disagreement_lists(out_dir: Path, left: Summary, right: Summary) -> dict[str, Path]:
+ """Write per-instance disagreement files for triage. Returns the paths written."""
+ out_dir.mkdir(parents=True, exist_ok=True)
+ only_left = sorted(left.resolved_ids - right.resolved_ids)
+ only_right = sorted(right.resolved_ids - left.resolved_ids)
+ both = sorted(left.resolved_ids & right.resolved_ids)
+ paths: dict[str, Path] = {}
+
+ def _write(name: str, ids: list[str]) -> Path:
+ p = out_dir / name
+ p.write_text("\n".join(ids) + ("\n" if ids else ""), encoding="utf-8")
+ return p
+
+ paths[f"only_{left.label}"] = _write(f"only_{left.label}.txt", only_left)
+ paths[f"only_{right.label}"] = _write(f"only_{right.label}.txt", only_right)
+ paths["both"] = _write("both_solved.txt", both)
+ return paths
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--left", required=True, type=Path, help="Path to first summary JSON.")
+ parser.add_argument("--right", required=True, type=Path, help="Path to second summary JSON.")
+ parser.add_argument("--left-label", default="left", help="Label for the first agent.")
+ parser.add_argument("--right-label", default="right", help="Label for the second agent.")
+ parser.add_argument(
+ "--out",
+ type=Path,
+ default=None,
+ help="Optional path to write the markdown report. Disagreement lists are written next to it.",
+ )
+ args = parser.parse_args(argv)
+
+ left = load_summary(args.left, args.left_label)
+ right = load_summary(args.right, args.right_label)
+
+ print(render_text(left, right))
+
+ if args.out is not None:
+ args.out.parent.mkdir(parents=True, exist_ok=True)
+ args.out.write_text(render_markdown(left, right), encoding="utf-8")
+ write_disagreement_lists(args.out.parent, left, right)
+ print(f"\nWrote {args.out}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/eval/make_results_chart.py b/eval/make_results_chart.py
new file mode 100644
index 000000000..56dc1bbcb
--- /dev/null
+++ b/eval/make_results_chart.py
@@ -0,0 +1,117 @@
+"""Render the headline SWE-bench result as a publication-ready chart.
+
+Produces eval/runs//results.png with:
+ - left panel: stacked bar of resolved/unresolved/error per agent
+ - right panel: Venn-style bucket breakdown (both / only-A / only-B / neither)
+"""
+import json
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+from pathlib import Path
+
+RUN_ID = "verified-gemini-full"
+REPO = Path(r"C:\Users\fmche\PycharmProjects\clawcodex")
+EVAL_DIR = REPO / "eval" / "runs" / RUN_ID
+SWEBENCH = REPO / "SWE-bench-dev"
+OUT = EVAL_DIR / "results.png"
+# Also write to a tracked path under assets/ so README can reference it
+README_OUT = REPO / "assets" / "swebench-verified-gemini.png"
+
+# Load summaries
+def load(agent: str) -> dict:
+ return json.loads((SWEBENCH / f"{agent}-local.{RUN_ID}-{agent}.json").read_text(encoding="utf-8"))
+
+cc = load("clawcodex")
+oc = load("openclaude")
+total = max(cc["total_instances"], oc["total_instances"])
+
+# Buckets
+cc_resolved = set(cc["resolved_ids"])
+oc_resolved = set(oc["resolved_ids"])
+both = cc_resolved & oc_resolved
+only_cc = cc_resolved - oc_resolved
+only_oc = oc_resolved - cc_resolved
+neither_count = total - len(both) - len(only_cc) - len(only_oc)
+
+# Colors — clawcodex brand-ish + openclaude muted
+COL_CC = "#2563eb" # blue
+COL_OC = "#94a3b8" # slate
+COL_UNRES = "#fbbf24" # amber
+COL_ERR = "#ef4444" # red
+
+fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6), gridspec_kw={"width_ratios": [1.2, 1]})
+
+# --------- Left: stacked bar of outcomes ---------
+agents = ["clawcodex", "openclaude"]
+resolved = [cc["resolved_instances"], oc["resolved_instances"]]
+unresolved = [cc["unresolved_instances"], oc["unresolved_instances"]]
+errors = [cc["error_instances"], oc["error_instances"]]
+
+x = np.arange(len(agents))
+w = 0.55
+ax1.bar(x, resolved, w, label="Resolved", color=COL_CC, edgecolor="white", linewidth=2)
+ax1.bar(x, unresolved, w, bottom=resolved, label="Unresolved", color=COL_UNRES, edgecolor="white", linewidth=2)
+ax1.bar(x, errors, w, bottom=[r+u for r, u in zip(resolved, unresolved)], label="Error", color=COL_ERR, edgecolor="white", linewidth=2)
+
+# Annotate resolved values prominently
+for i, (agent, n) in enumerate(zip(agents, resolved)):
+ pct = 100.0 * n / total
+ ax1.text(i, n / 2, f"{n}\n({pct:.1f}%)", ha="center", va="center",
+ color="white", fontsize=18, fontweight="bold")
+ ax1.text(i, n + unresolved[i] / 2, f"{unresolved[i]}", ha="center", va="center",
+ color="#7c2d12", fontsize=11)
+ ax1.text(i, n + unresolved[i] + errors[i] / 2, f"{errors[i]}", ha="center", va="center",
+ color="white", fontsize=11)
+
+ax1.set_xticks(x)
+ax1.set_xticklabels(agents, fontsize=13, fontweight="bold")
+ax1.set_ylabel(f"Instances (of {total})", fontsize=12)
+ax1.set_title("SWE-bench Verified resolve rate\n(Gemini 2.5 Pro, 499 instances)", fontsize=14, fontweight="bold")
+ax1.legend(loc="upper right", fontsize=11)
+ax1.set_ylim(0, total * 1.05)
+ax1.spines["top"].set_visible(False)
+ax1.spines["right"].set_visible(False)
+ax1.grid(axis="y", alpha=0.3, linestyle="--")
+
+# Delta annotation
+delta = resolved[0] - resolved[1]
+delta_str = f"clawcodex +{delta} ({100.0 * delta / total:+.1f} pp)" if delta > 0 else f"clawcodex {delta} ({100.0 * delta / total:+.1f} pp)"
+ax1.text(0.5, total + total*0.01, delta_str, ha="center", va="bottom", fontsize=12,
+ color=(COL_CC if delta > 0 else COL_OC), fontweight="bold",
+ transform=ax1.transData)
+
+# --------- Right: per-instance disagreement breakdown ---------
+labels = ["Both\nsolved", "Only\nclawcodex", "Only\nopenclaude", "Neither\nsolved"]
+counts = [len(both), len(only_cc), len(only_oc), neither_count]
+colors = ["#10b981", COL_CC, COL_OC, "#71717a"]
+
+bars = ax2.barh(labels, counts, color=colors, edgecolor="white", linewidth=2)
+for bar, count in zip(bars, counts):
+ pct = 100.0 * count / total
+ ax2.text(bar.get_width() + 3, bar.get_y() + bar.get_height() / 2,
+ f"{count} ({pct:.1f}%)", va="center", fontsize=11, fontweight="bold")
+
+ax2.set_xlim(0, max(counts) * 1.20)
+ax2.set_xlabel("Instances", fontsize=12)
+ax2.set_title("Per-instance disagreement\n(of 499 evaluated)", fontsize=14, fontweight="bold")
+ax2.spines["top"].set_visible(False)
+ax2.spines["right"].set_visible(False)
+ax2.invert_yaxis()
+ax2.grid(axis="x", alpha=0.3, linestyle="--")
+
+# Overall figure title
+fig.suptitle(
+ f"clawcodex vs openclaude on SWE-bench Verified — "
+ f"{resolved[0]}/{total} ({100.0*resolved[0]/total:.1f}%) vs "
+ f"{resolved[1]}/{total} ({100.0*resolved[1]/total:.1f}%)",
+ fontsize=15, y=1.02
+)
+plt.tight_layout()
+plt.savefig(OUT, dpi=150, bbox_inches="tight", facecolor="white")
+print(f"wrote {OUT}")
+README_OUT.parent.mkdir(parents=True, exist_ok=True)
+plt.savefig(README_OUT, dpi=150, bbox_inches="tight", facecolor="white")
+print(f"wrote {README_OUT}")
diff --git a/eval/pick_batch.py b/eval/pick_batch.py
new file mode 100644
index 000000000..b7db68f4e
--- /dev/null
+++ b/eval/pick_batch.py
@@ -0,0 +1,117 @@
+"""Pick the next batch of unseen SWE-bench instances for a cumulative eval run.
+
+Given a dataset and a predictions file, emit the next N instance IDs that
+haven't been predicted yet. Two picking modes:
+
+* ``--random`` (recommended) — uniform random sample from the unseen pool.
+ Best for accumulating an unbiased estimate of resolve rate over many
+ batches. Pass ``--seed`` for reproducibility.
+* default (stratified round-robin) — picks one instance from each repo in
+ turn, then loops. Skews toward alphabetically-early issues in each repo,
+ which tend to be older / better-characterized. Useful for diversity in a
+ single batch but biased over multiple batches.
+
+Examples:
+ # Random batch of 50 unseen, reproducible with seed
+ python eval/pick_batch.py --random --seed 42 \\
+ SWE-bench-dev/datasets/SWE-bench__SWE-bench_Verified__style-3__fs-oracle \\
+ eval/runs/mini-10-deepseek/clawcodex_preds.jsonl 50
+
+ # Stratified (default) — one instance per repo, rotates
+ python eval/pick_batch.py \\
+ SWE-bench-dev/datasets/SWE-bench__SWE-bench_Verified__style-3__fs-oracle \\
+ eval/runs/mini-10-deepseek/clawcodex_preds.jsonl 50
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("dataset_local", help="Path to the local SWE-bench dataset dir.")
+ parser.add_argument("preds_path", type=Path, help="Predictions JSONL to skip-already-done.")
+ parser.add_argument("n", type=int, help="How many to pick.")
+ parser.add_argument(
+ "--random",
+ action="store_true",
+ help="Uniform random sample from unseen pool (instead of stratified round-robin).",
+ )
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=None,
+ help="Random seed; only meaningful with --random. Omit for non-reproducible sampling.",
+ )
+ args = parser.parse_args()
+
+ from datasets import load_from_disk
+
+ ds = load_from_disk(args.dataset_local)["test"]
+ all_rows = [(r["instance_id"], r["repo"]) for r in ds]
+
+ seen: set[str] = set()
+ if args.preds_path.exists():
+ with args.preds_path.open(encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ rec = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ iid = rec.get("instance_id")
+ if iid:
+ seen.add(iid)
+
+ unseen = [(iid, repo) for iid, repo in all_rows if iid not in seen]
+ if not unseen:
+ print(
+ f"All {len(all_rows)} instances already predicted in {args.preds_path}",
+ file=sys.stderr,
+ )
+ return 1
+
+ if args.random:
+ rng = random.Random(args.seed)
+ sample = rng.sample(unseen, k=min(args.n, len(unseen)))
+ chosen = [iid for iid, _ in sample]
+ mode = f"random{f' (seed={args.seed})' if args.seed is not None else ''}"
+ else:
+ # Stratified round-robin: pick from each repo in turn so the batch
+ # spans the dataset breadth rather than clustering on one repo.
+ by_repo: dict[str, list[str]] = defaultdict(list)
+ for iid, repo in unseen:
+ by_repo[repo].append(iid)
+ chosen = []
+ while len(chosen) < args.n:
+ added_this_round = False
+ for repo in sorted(by_repo):
+ if not by_repo[repo]:
+ continue
+ chosen.append(by_repo[repo].pop(0))
+ added_this_round = True
+ if len(chosen) >= args.n:
+ break
+ if not added_this_round:
+ break
+ mode = "stratified"
+
+ print(
+ f"picked {len(chosen)} unseen instance(s) ({mode}) of {len(unseen)} "
+ f"remaining ({len(seen)} previously predicted in {args.preds_path.name})",
+ file=sys.stderr,
+ )
+ print(",".join(chosen))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/eval/repair_preds.py b/eval/repair_preds.py
new file mode 100644
index 000000000..05cf2c065
--- /dev/null
+++ b/eval/repair_preds.py
@@ -0,0 +1,55 @@
+"""Repair an existing predictions JSONL: re-extract diff, recompute hunk
+headers from each row's ``full_output``, and rewrite ``model_patch`` in place.
+
+Useful when ``run_custom_api.py`` produced empty patches because the LLM emitted
+malformed ``@@`` line counts and ``unidiff`` rejected them. The recomputer
+fixes the bookkeeping deterministically.
+
+Usage:
+ python eval/repair_preds.py
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "SWE-bench-dev"))
+sys.path.insert(0, str(REPO_ROOT / "SWE-bench-dev" / "scripts"))
+
+from swebench.inference.make_datasets.utils import extract_diff # noqa: E402
+from run_custom_api import is_valid_unified_diff, recompute_hunk_headers # noqa: E402
+
+
+def repair(path: Path) -> None:
+ rows = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]
+ changes = 0
+ rescued = 0
+ for r in rows:
+ old_patch = r.get("model_patch") or ""
+ full = r.get("full_output") or ""
+ new_patch = recompute_hunk_headers(extract_diff(full) or "")
+ if new_patch and not new_patch.endswith("\n"):
+ new_patch += "\n"
+ if new_patch != old_patch:
+ changes += 1
+ had_valid = is_valid_unified_diff(old_patch)
+ has_valid = is_valid_unified_diff(new_patch)
+ if not had_valid and has_valid:
+ rescued += 1
+ elif had_valid and not has_valid:
+ print(f" WARNING: {r['instance_id']} was valid, repair broke it — keeping original")
+ continue
+ r["model_patch"] = new_patch
+
+ out_lines = [json.dumps(r, ensure_ascii=False) for r in rows]
+ path.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
+ print(f" repaired {path}: changed {changes}/{len(rows)} rows; rescued {rescued} from empty/invalid")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print("usage: python eval/repair_preds.py ")
+ sys.exit(2)
+ repair(Path(sys.argv[1]))
diff --git a/eval/run_compare.py b/eval/run_compare.py
new file mode 100644
index 000000000..343f896f2
--- /dev/null
+++ b/eval/run_compare.py
@@ -0,0 +1,1015 @@
+#!/usr/bin/env python3
+"""Driver: run SWE-bench against clawcodex AND openclaude, then diff the results.
+
+This is the single entry point for parity comparisons. It composes the
+existing primitives (already documented in ``SWE-bench-dev/clawcodex_test.md``):
+
+1. ``prepare`` : build openclaude if needed, build the SWE-bench text dataset.
+2. ``run`` : for each agent, start its API server, generate predictions, run
+ the Docker harness, then write a side-by-side comparison.
+3. ``compare`` : skip predictions/harness — just diff two existing summary jsons.
+
+Defaults assume:
+- ``SWE-bench-dev/`` is cloned next to ``clawcodex/`` (override with ``SWEBENCH_REPO``)
+- ``openclaude/`` is cloned next to ``clawcodex/`` (override with ``OPENCLAUDE_REPO``)
+- The SWE-bench venv has been set up per ``clawcodex_test.md`` §2.1
+ (override the interpreter with ``SWEBENCH_PYTHON``)
+- Docker is running and clawcodex/openclaude are configured for the chosen model
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import json
+import os
+import shutil
+import signal
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Iterable
+
+EVAL_DIR = Path(__file__).resolve().parent
+CLAWCODEX_ROOT = EVAL_DIR.parent
+
+DEFAULT_SWEBENCH_REPO = CLAWCODEX_ROOT / "SWE-bench-dev"
+DEFAULT_OPENCLAUDE_REPO = CLAWCODEX_ROOT / "openclaude"
+DEFAULT_CLAWCODEX_REPO = CLAWCODEX_ROOT
+
+DEFAULT_DATASET_NAME = "SWE-bench/SWE-bench_Lite"
+DEFAULT_DATASET_LOCAL = (
+ "datasets/SWE-bench__SWE-bench_Lite__style-3__fs-oracle"
+)
+DEFAULT_PROMPT_STYLE = "style-3"
+DEFAULT_FILE_SOURCE = "oracle"
+DEFAULT_SPLIT = "test"
+
+# A handful of cheap, well-known instances for smoke runs.
+DEFAULT_SMOKE_INSTANCES: tuple[str, ...] = (
+ "astropy__astropy-12907",
+)
+
+# Where each agent's API server listens during a run.
+AGENT_PORTS: dict[str, int] = {
+ "clawcodex": 8000,
+ "openclaude": 8001,
+}
+
+
+@dataclass(frozen=True)
+class ProviderPreset:
+ """Bundle of provider/model/base-url defaults applied to both agents.
+
+ A preset selects:
+ - ``model`` : the backing model name passed to both agents
+ - ``clawcodex_provider`` : value for ``--provider`` on the clawcodex CLI
+ (must already exist in ``~/.clawcodex/config.json``)
+ - ``openclaude_provider`` : provider hint for the openclaude wrapper
+ ("openai" routes through CLAUDE_CODE_USE_OPENAI=1)
+ - ``openclaude_base_url`` : OPENAI_BASE_URL override used when the
+ provider speaks the OpenAI-compatible API
+ (e.g. DeepSeek, GLM). Empty string means
+ "leave whatever is in the environment alone".
+
+ Individual ``--model`` / ``--clawcodex-provider`` / etc. flags override
+ per-field on top of the preset.
+ """
+
+ name: str
+ model: str
+ clawcodex_provider: str
+ openclaude_provider: str
+ openclaude_base_url: str
+ description: str
+
+
+PROVIDER_PRESETS: dict[str, ProviderPreset] = {
+ "openai": ProviderPreset(
+ name="openai",
+ model="gpt-4o",
+ clawcodex_provider="openai",
+ openclaude_provider="openai",
+ openclaude_base_url="",
+ description="OpenAI gpt-4o on both agents.",
+ ),
+ "deepseek": ProviderPreset(
+ name="deepseek",
+ model="deepseek-v4-pro",
+ clawcodex_provider="deepseek",
+ openclaude_provider="openai",
+ openclaude_base_url="https://api.deepseek.com/v1",
+ description="DeepSeek v4-pro: clawcodex via 'deepseek' provider, openclaude via OpenAI-compatible API.",
+ ),
+ "anthropic": ProviderPreset(
+ name="anthropic",
+ model="claude-sonnet-4-6",
+ clawcodex_provider="anthropic",
+ openclaude_provider="anthropic",
+ openclaude_base_url="",
+ description="Anthropic Claude on both agents (uses native Claude path).",
+ ),
+ "zai": ProviderPreset(
+ name="zai",
+ model="glm-5.2",
+ clawcodex_provider="zai",
+ openclaude_provider="openai",
+ openclaude_base_url="https://api.z.ai/api/coding/paas/v4",
+ description="Z.ai GLM-5.2: clawcodex via 'zai' provider, openclaude via OpenAI-compatible API.",
+ ),
+ "gemini": ProviderPreset(
+ name="gemini",
+ model="gemini-2.5-pro",
+ clawcodex_provider="gemini",
+ openclaude_provider="gemini",
+ openclaude_base_url="",
+ description="Google Gemini 2.5 Pro: clawcodex via 'gemini' provider, openclaude via native Gemini API (CLAUDE_CODE_USE_GEMINI=1).",
+ ),
+}
+
+DEFAULT_PROVIDER_PRESET = "openai"
+
+
+@dataclass
+class AgentSpec:
+ """Static config for one agent in a comparison."""
+
+ name: str
+ server_module: str # e.g. "scripts.clawcodex_api_server:app"
+ port: int
+ extra_payload: dict[str, object] = field(default_factory=dict)
+ env_overrides: dict[str, str] = field(default_factory=dict)
+
+
+@dataclass
+class RunPaths:
+ """All paths for a single comparison run, rooted at ``run_dir``."""
+
+ run_dir: Path
+ run_id: str
+
+ @property
+ def predictions(self) -> dict[str, Path]:
+ return {
+ "clawcodex": self.run_dir / "clawcodex_preds.jsonl",
+ "openclaude": self.run_dir / "openclaude_preds.jsonl",
+ }
+
+ @property
+ def server_logs(self) -> dict[str, Path]:
+ return {
+ "clawcodex": self.run_dir / "clawcodex_server.log",
+ "openclaude": self.run_dir / "openclaude_server.log",
+ }
+
+ @property
+ def harness_logs(self) -> dict[str, Path]:
+ return {
+ "clawcodex": self.run_dir / "clawcodex_harness.log",
+ "openclaude": self.run_dir / "openclaude_harness.log",
+ }
+
+ @property
+ def comparison(self) -> Path:
+ return self.run_dir / "comparison.md"
+
+
+def _info(msg: str) -> None:
+ sys.stdout.write(f"[run_compare] {msg}\n")
+ sys.stdout.flush()
+
+
+def _resolve_swebench_repo(arg: str | None) -> Path:
+ candidate = Path(arg or os.environ.get("SWEBENCH_REPO") or DEFAULT_SWEBENCH_REPO)
+ candidate = candidate.expanduser().resolve()
+ if not (candidate / "swebench" / "harness").is_dir():
+ raise SystemExit(
+ f"SWE-bench harness not found under {candidate}. "
+ f"Set SWEBENCH_REPO or pass --swebench-repo."
+ )
+ return candidate
+
+
+def _resolve_openclaude_repo(arg: str | None) -> Path:
+ candidate = Path(arg or os.environ.get("OPENCLAUDE_REPO") or DEFAULT_OPENCLAUDE_REPO)
+ return candidate.expanduser().resolve()
+
+
+def _resolve_clawcodex_repo(arg: str | None) -> Path:
+ candidate = Path(arg or os.environ.get("CLAWCODEX_REPO") or DEFAULT_CLAWCODEX_REPO)
+ return candidate.expanduser().resolve()
+
+
+def _resolve_python(env_var: str, fallback: str = "python3") -> str:
+ """Interpreter for ``pip``/``-m swebench``/``uvicorn`` subprocesses.
+
+ When ``SWEBENCH_PYTHON`` (or the given *env_var*) is unset, we use
+ :data:`sys.executable` — the same Python that is running *this* script — so
+ a venv-local ``uv pip install -e ./SWE-bench-dev`` is visible to ``prepare``.
+
+ On Windows, ``shutil.which("python3")`` often resolves to the Microsoft Store
+ stub under ``WindowsApps``, which does not see your project venv; avoiding
+ that shims the common ``.venv/Scripts/python.exe eval/run_compare.py`` flow.
+ """
+ explicit = os.environ.get(env_var)
+ if explicit:
+ return explicit
+ exe = sys.executable
+ if exe and Path(exe).exists():
+ return exe
+ for name in (fallback, "python"):
+ found = shutil.which(name)
+ if found and "windowsapps" not in found.lower():
+ return found
+ raise SystemExit(
+ f"Could not locate a Python interpreter ({env_var} or '{fallback}'). "
+ f"Run with your project venv, e.g. `.venv/Scripts/python.exe eval/run_compare.py ...`, "
+ f"or set {env_var} to that interpreter."
+ )
+
+
+def _http_get(url: str, timeout: float) -> tuple[int, bytes]:
+ req = urllib.request.Request(url, method="GET")
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
+ return resp.status, resp.read()
+
+
+def _wait_for_health(url: str, *, timeout: float, interval: float = 0.5) -> None:
+ deadline = time.monotonic() + timeout
+ last_err: str = ""
+ while time.monotonic() < deadline:
+ try:
+ status, body = _http_get(url, timeout=2.0)
+ if 200 <= status < 300:
+ _info(f" healthy: {url} -> {body[:120]!r}")
+ return
+ last_err = f"HTTP {status}"
+ except urllib.error.URLError as exc:
+ last_err = str(exc.reason)
+ except Exception as exc: # noqa: BLE001
+ last_err = repr(exc)
+ time.sleep(interval)
+ raise RuntimeError(f"Server at {url} did not become healthy within {timeout:.0f}s: {last_err}")
+
+
+@contextlib.contextmanager
+def _spawn_server(
+ *,
+ swebench_repo: Path,
+ swebench_python: str,
+ server_module: str,
+ port: int,
+ log_path: Path,
+ env: dict[str, str],
+) -> Iterable[subprocess.Popen[bytes]]:
+ """Boot a uvicorn server in a subprocess and tear it down on exit.
+
+ ``server_module`` is e.g. ``scripts.clawcodex_api_server:app`` — passed
+ straight through to uvicorn. ``cwd`` is ``swebench_repo`` so the
+ ``scripts`` package import resolves.
+ """
+ cmd = [
+ swebench_python,
+ "-m",
+ "uvicorn",
+ server_module,
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ "--log-level",
+ "warning",
+ ]
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ log_handle = log_path.open("wb")
+ _info(f" starting: {' '.join(cmd)} (cwd={swebench_repo})")
+ # On Windows, place uvicorn in its own process group so CTRL_BREAK_EVENT
+ # only reaches uvicorn, not the run_compare process and any sibling harness
+ # subprocess we spawn next. Without this, the signal leaks to the whole
+ # console group and aborts the next harness run with an empty log.
+ popen_kwargs: dict[str, object] = {}
+ if os.name == "nt":
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
+ proc = subprocess.Popen( # noqa: S603
+ cmd,
+ cwd=str(swebench_repo),
+ stdout=log_handle,
+ stderr=subprocess.STDOUT,
+ env=env,
+ **popen_kwargs,
+ )
+ try:
+ try:
+ _wait_for_health(f"http://127.0.0.1:{port}/health", timeout=30.0)
+ except RuntimeError:
+ # /health may not exist on older clawcodex_api_server.py — accept
+ # any TCP-level liveness instead.
+ _info(" /health probe failed; falling back to socket-only liveness check")
+ _wait_for_socket("127.0.0.1", port, timeout=30.0)
+ yield proc
+ finally:
+ if proc.poll() is None:
+ _info(f" stopping uvicorn (pid={proc.pid}) on port {port}")
+ try:
+ if os.name == "nt":
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
+ else:
+ proc.terminate()
+ proc.wait(timeout=10)
+ except Exception: # noqa: BLE001
+ proc.kill()
+ log_handle.close()
+
+
+def _wait_for_socket(host: str, port: int, *, timeout: float) -> None:
+ import socket
+
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(1.0)
+ try:
+ s.connect((host, port))
+ return
+ except OSError:
+ time.sleep(0.5)
+ raise RuntimeError(f"Server at {host}:{port} did not accept connections within {timeout:.0f}s")
+
+
+def cmd_prepare(args: argparse.Namespace) -> int:
+ swebench_repo = _resolve_swebench_repo(args.swebench_repo)
+ swebench_python = _resolve_python("SWEBENCH_PYTHON")
+ try:
+ subprocess.run(
+ [swebench_python, "-c", "import swebench"],
+ cwd=str(swebench_repo),
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as exc:
+ raise SystemExit(
+ f"The interpreter {swebench_python!r} cannot import `swebench`.\n\n"
+ "Install the package (editable from your SWE-bench-dev checkout), then retry:\n\n"
+ f" {swebench_python} -m pip install -e {swebench_repo} "
+ "fastapi uvicorn tiktoken transformers\n\n"
+ "Or set SWEBENCH_PYTHON to a Python that already has SWE-bench installed."
+ ) from exc
+
+ openclaude_repo = _resolve_openclaude_repo(args.openclaude_repo)
+ skip_openclaude_build = args.skip_openclaude_build
+
+ # 1. Build openclaude dist/cli.mjs if missing.
+ if not skip_openclaude_build:
+ dist_cli = openclaude_repo / "dist" / "cli.mjs"
+ if dist_cli.is_file():
+ _info(f"openclaude dist already built: {dist_cli}")
+ elif not openclaude_repo.is_dir():
+ _info(f"openclaude repo not found at {openclaude_repo} — skipping build.")
+ else:
+ bun = shutil.which("bun")
+ if bun is None:
+ raise SystemExit(
+ "openclaude needs a build but `bun` was not found on PATH.\n\n"
+ "Pick one:\n"
+ " 1) Install Bun: https://bun.com/docs/installation "
+ "(Windows: PowerShell `irm bun.sh/install.ps1 | iex`)\n"
+ " 2) Skip the openclaude build for now and only build the SWE-bench text dataset:\n"
+ " python eval/run_compare.py prepare --skip-openclaude-build\n"
+ " You must build openclaude yourself later (`bun install && bun run build` in "
+ "openclaude/) so dist/cli.mjs exists before `run` with the openclaude agent.\n"
+ )
+ _info(f"installing openclaude deps in {openclaude_repo}")
+ subprocess.run([bun, "install"], cwd=str(openclaude_repo), check=True) # noqa: S603
+ _info("building openclaude (bun run build)")
+ subprocess.run([bun, "run", "build"], cwd=str(openclaude_repo), check=True) # noqa: S603
+ if not dist_cli.is_file():
+ raise SystemExit(f"openclaude build did not produce {dist_cli}")
+
+ # 2. Build the SWE-bench text dataset (style-3 + oracle) if missing.
+ dataset_dir = swebench_repo / args.dataset_local
+ if dataset_dir.is_dir():
+ _info(f"text dataset already exists: {dataset_dir}")
+ else:
+ cmd = [
+ swebench_python,
+ "-m",
+ "swebench.inference.make_datasets.create_text_dataset",
+ "--dataset_name_or_path",
+ args.dataset_name,
+ "--splits",
+ args.split,
+ "--output_dir",
+ str(swebench_repo / "datasets"),
+ "--prompt_style",
+ args.prompt_style,
+ "--file_source",
+ args.file_source,
+ ]
+ _info(f"building text dataset: {' '.join(cmd[3:])}")
+ subprocess.run(cmd, cwd=str(swebench_repo), check=True) # noqa: S603
+ if not dataset_dir.is_dir():
+ raise SystemExit(f"create_text_dataset finished but {dataset_dir} not found.")
+
+ _info("prepare: done")
+ return 0
+
+
+def _resolve_provider_settings(args: argparse.Namespace) -> dict[str, str]:
+ """Apply the chosen preset, then layer per-field overrides on top.
+
+ Returns a dict with keys:
+ ``model`` / ``clawcodex_provider`` / ``openclaude_provider`` / ``openclaude_base_url``
+ """
+ preset = PROVIDER_PRESETS[args.provider]
+ return {
+ "model": args.model or preset.model,
+ "clawcodex_provider": (args.clawcodex_provider
+ if args.clawcodex_provider is not None
+ else preset.clawcodex_provider),
+ "openclaude_provider": (args.openclaude_provider
+ if args.openclaude_provider is not None
+ else preset.openclaude_provider),
+ "openclaude_base_url": (args.openclaude_base_url
+ if args.openclaude_base_url is not None
+ else preset.openclaude_base_url),
+ }
+
+
+def _build_agent_specs(args: argparse.Namespace) -> list[AgentSpec]:
+ selected = [s.strip() for s in args.agents.split(",") if s.strip()]
+ if not selected:
+ raise SystemExit("--agents must list at least one agent")
+
+ settings = _resolve_provider_settings(args)
+ _info(
+ f"provider preset='{args.provider}' "
+ f"model='{settings['model']}' "
+ f"clawcodex.provider='{settings['clawcodex_provider']}' "
+ f"openclaude.provider='{settings['openclaude_provider']}' "
+ f"openclaude.base_url='{settings['openclaude_base_url'] or '(env default)'}'"
+ )
+
+ common_payload: dict[str, object] = {
+ "model": settings["model"],
+ "max_turns": args.max_turns,
+ "timeout": args.request_timeout,
+ "dangerously_skip_permissions": True,
+ }
+
+ specs: list[AgentSpec] = []
+ for name in selected:
+ if name == "clawcodex":
+ payload = dict(common_payload)
+ if settings["clawcodex_provider"]:
+ payload["provider"] = settings["clawcodex_provider"]
+ specs.append(
+ AgentSpec(
+ name="clawcodex",
+ server_module="scripts.clawcodex_api_server:app",
+ port=AGENT_PORTS["clawcodex"],
+ extra_payload=payload,
+ )
+ )
+ elif name == "openclaude":
+ payload = dict(common_payload)
+ # The openclaude wrapper accepts ``provider`` and ``base_url`` and
+ # maps them to env vars (CLAUDE_CODE_USE_OPENAI=1, OPENAI_BASE_URL,
+ # OPENAI_MODEL) for the openclaude subprocess. ``api_key`` is
+ # intentionally NOT passed in the payload — keys travel via the
+ # server's environment (e.g. exported ``OPENAI_API_KEY``).
+ if settings["openclaude_provider"]:
+ payload["provider"] = settings["openclaude_provider"]
+ if settings["openclaude_base_url"]:
+ payload["base_url"] = settings["openclaude_base_url"]
+ specs.append(
+ AgentSpec(
+ name="openclaude",
+ server_module="scripts.openclaude_api_server:app",
+ port=AGENT_PORTS["openclaude"],
+ extra_payload=payload,
+ )
+ )
+ else:
+ raise SystemExit(f"unknown agent: {name!r} (valid: clawcodex, openclaude)")
+ return specs
+
+
+def _server_env(
+ *,
+ swebench_repo: Path,
+ clawcodex_repo: Path,
+ openclaude_repo: Path,
+ agent: str,
+) -> dict[str, str]:
+ env = os.environ.copy()
+ # Make `scripts.*` imports resolve from inside the SWE-bench repo.
+ pythonpath = [str(swebench_repo)]
+ if env.get("PYTHONPATH"):
+ pythonpath.append(env["PYTHONPATH"])
+ env["PYTHONPATH"] = os.pathsep.join(pythonpath)
+
+ # Repo locations the wrappers themselves consult.
+ if clawcodex_repo.is_dir():
+ env.setdefault("CLAWCODEX_REPO", str(clawcodex_repo))
+ # Only point at a source checkout when it is actually built; otherwise the
+ # API server should fall through to global `openclaude` on PATH (npm -g).
+ if openclaude_repo.is_dir() and (openclaude_repo / "dist" / "cli.mjs").is_file():
+ env.setdefault("OPENCLAUDE_REPO", str(openclaude_repo))
+ return env
+
+
+def _run_predictions(
+ *,
+ spec: AgentSpec,
+ swebench_repo: Path,
+ swebench_python: str,
+ dataset_local: Path,
+ split: str,
+ prompt_field: str,
+ instance_ids: list[str] | None,
+ output_path: Path,
+ request_timeout: int,
+ max_patch_retries: int,
+ patch_retry_backoff: float,
+ trace_dir: Path | None = None,
+ workers: int = 1,
+) -> None:
+ cmd = [
+ swebench_python,
+ "scripts/run_custom_api.py",
+ "--api_url",
+ f"http://127.0.0.1:{spec.port}/generate",
+ "--dataset_name_or_path",
+ str(dataset_local),
+ "--split",
+ split,
+ "--prompt_field",
+ prompt_field,
+ "--model_name_or_path",
+ f"{spec.name}-local",
+ "--output_file",
+ str(output_path),
+ "--timeout",
+ str(request_timeout),
+ "--append",
+ "--max_patch_retries",
+ str(max_patch_retries),
+ "--patch_retry_backoff_seconds",
+ str(patch_retry_backoff),
+ "--extra_payload",
+ json.dumps(spec.extra_payload),
+ "--workers",
+ str(workers),
+ ]
+ if instance_ids:
+ cmd.extend(["--instance_ids", ",".join(instance_ids)])
+ if trace_dir is not None:
+ cmd.extend(["--trace_dir", str(trace_dir)])
+
+ _info(f" predictions: {spec.name} → {output_path}")
+ subprocess.run(cmd, cwd=str(swebench_repo), check=True) # noqa: S603
+
+
+def _run_harness(
+ *,
+ swebench_repo: Path,
+ swebench_python: str,
+ dataset_name: str,
+ split: str,
+ predictions_path: Path,
+ instance_ids: list[str] | None,
+ run_id: str,
+ max_workers: int,
+ log_path: Path,
+) -> None:
+ cmd = [
+ swebench_python,
+ "-m",
+ "swebench.harness.run_evaluation",
+ "--dataset_name",
+ dataset_name,
+ "--split",
+ split,
+ "--predictions_path",
+ str(predictions_path),
+ "--max_workers",
+ str(max_workers),
+ "--run_id",
+ run_id,
+ ]
+ if instance_ids:
+ cmd.extend(["--instance_ids", *instance_ids])
+ _info(f" harness: {predictions_path.name} (run_id={run_id})")
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ with log_path.open("wb") as log_handle:
+ subprocess.run( # noqa: S603
+ cmd,
+ cwd=str(swebench_repo),
+ check=True,
+ stdout=log_handle,
+ stderr=subprocess.STDOUT,
+ )
+ except subprocess.CalledProcessError as e:
+ _info(
+ f"harness subprocess failed (exit {e.returncode}). "
+ f"Later agents were not started. Full log: {log_path}"
+ )
+ raise
+
+
+def _find_summary(swebench_repo: Path, model_name: str, run_id: str) -> Path:
+ """Locate ``..json`` produced by the harness.
+
+ The harness writes summary files to the *current working directory*, which
+ in our case is ``swebench_repo``. We also check ``evaluation_results/`` for
+ forward-compatibility.
+ """
+ sanitized = model_name.replace("/", "__")
+ candidates = [
+ swebench_repo / f"{sanitized}.{run_id}.json",
+ swebench_repo / "evaluation_results" / f"{sanitized}.{run_id}.json",
+ ]
+ for c in candidates:
+ if c.is_file():
+ return c
+ raise FileNotFoundError(
+ f"Could not locate summary report for model={model_name!r} run_id={run_id!r}. "
+ f"Searched: {[str(p) for p in candidates]}"
+ )
+
+
+def cmd_run(args: argparse.Namespace) -> int:
+ swebench_repo = _resolve_swebench_repo(args.swebench_repo)
+ swebench_python = _resolve_python("SWEBENCH_PYTHON")
+ openclaude_repo = _resolve_openclaude_repo(args.openclaude_repo)
+ clawcodex_repo = _resolve_clawcodex_repo(args.clawcodex_repo)
+
+ dataset_local = swebench_repo / args.dataset_local
+ if not dataset_local.is_dir():
+ py_hint = _resolve_python("SWEBENCH_PYTHON")
+ raise SystemExit(
+ "Text dataset not found.\n\n"
+ f" Expected directory: {dataset_local}\n\n"
+ "Build it once with:\n\n"
+ f" {py_hint} eval/run_compare.py prepare \\\n"
+ f" --swebench-repo {swebench_repo}\n\n"
+ "That Python must have the `swebench` package (editable install from your "
+ "SWE-bench-dev checkout). For example:\n\n"
+ f" {py_hint} -m pip install -e {swebench_repo} fastapi uvicorn tiktoken transformers\n\n"
+ "Then re-run `run`. If the dataset lives elsewhere, pass "
+ "`--dataset-local `."
+ )
+
+ instance_ids: list[str] | None = None
+ if args.scope == "smoke":
+ instance_ids = list(args.smoke_instances or DEFAULT_SMOKE_INSTANCES)
+ elif args.scope == "instances":
+ instance_ids = [i.strip() for i in args.instance_ids.split(",") if i.strip()]
+ if not instance_ids:
+ raise SystemExit("--scope=instances requires --instance-ids")
+
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ run_id_base = args.run_id or f"compare-{timestamp}"
+ run_dir = (EVAL_DIR / "runs" / run_id_base).resolve()
+ run_dir.mkdir(parents=True, exist_ok=True)
+ paths = RunPaths(run_dir=run_dir, run_id=run_id_base)
+
+ specs = _build_agent_specs(args)
+
+ # Sequential mode: predictions → harness for each agent in turn.
+ summary_paths: dict[str, Path] = {}
+ for spec in specs:
+ _info(f"== {spec.name} ==")
+ env = _server_env(
+ swebench_repo=swebench_repo,
+ clawcodex_repo=clawcodex_repo,
+ openclaude_repo=openclaude_repo,
+ agent=spec.name,
+ )
+ env.update(spec.env_overrides)
+
+ with _spawn_server(
+ swebench_repo=swebench_repo,
+ swebench_python=swebench_python,
+ server_module=spec.server_module,
+ port=spec.port,
+ log_path=paths.server_logs[spec.name],
+ env=env,
+ ):
+ trace_dir = (
+ run_dir / "traces" / spec.name if args.capture_traces else None
+ )
+ _run_predictions(
+ spec=spec,
+ swebench_repo=swebench_repo,
+ swebench_python=swebench_python,
+ dataset_local=dataset_local,
+ split=args.split,
+ prompt_field=args.prompt_field,
+ instance_ids=instance_ids,
+ output_path=paths.predictions[spec.name],
+ request_timeout=args.request_timeout,
+ max_patch_retries=args.max_patch_retries,
+ patch_retry_backoff=args.patch_retry_backoff_seconds,
+ trace_dir=trace_dir,
+ workers=args.predict_workers,
+ )
+
+ if args.skip_harness:
+ _info(f" --skip-harness set; not running Docker for {spec.name}.")
+ continue
+
+ run_id = f"{run_id_base}-{spec.name}"
+ # In cumulative mode, drop the per-batch ``instance_ids`` filter on the
+ # harness so it scans every prediction in the file (newly-added + any
+ # prior batches). SWE-bench's skip-existing logic still avoids
+ # re-running already-evaluated instances; the resulting summary is the
+ # union of all batches.
+ harness_instance_ids = None if args.cumulative else instance_ids
+ _run_harness(
+ swebench_repo=swebench_repo,
+ swebench_python=swebench_python,
+ dataset_name=args.dataset_name,
+ split=args.split,
+ predictions_path=paths.predictions[spec.name],
+ instance_ids=harness_instance_ids,
+ run_id=run_id,
+ max_workers=args.max_workers,
+ log_path=paths.harness_logs[spec.name],
+ )
+ summary_paths[spec.name] = _find_summary(
+ swebench_repo, model_name=f"{spec.name}-local", run_id=run_id
+ )
+
+ # Comparison only makes sense if we ran both agents and the harness.
+ if len(summary_paths) == 2:
+ from compare_results import ( # noqa: PLC0415 (intentional local import)
+ load_summary,
+ render_markdown,
+ render_text,
+ write_disagreement_lists,
+ )
+
+ names = [spec.name for spec in specs if spec.name in summary_paths]
+ left = load_summary(summary_paths[names[0]], names[0])
+ right = load_summary(summary_paths[names[1]], names[1])
+ print()
+ print(render_text(left, right))
+ paths.comparison.write_text(render_markdown(left, right), encoding="utf-8")
+ write_disagreement_lists(paths.run_dir, left, right)
+ _info(f"comparison written: {paths.comparison}")
+ else:
+ _info("only one agent ran (or --skip-harness was set); skipping comparison.")
+
+ _info(f"run dir: {run_dir}")
+ return 0
+
+
+def cmd_compare(args: argparse.Namespace) -> int:
+ """Standalone comparison of two pre-existing harness summary files."""
+ sys.path.insert(0, str(EVAL_DIR))
+ from compare_results import main as compare_main # noqa: PLC0415
+
+ return compare_main(
+ [
+ "--left",
+ str(args.left),
+ "--right",
+ str(args.right),
+ "--left-label",
+ args.left_label,
+ "--right-label",
+ args.right_label,
+ *(["--out", str(args.out)] if args.out else []),
+ ]
+ )
+
+
+def _add_common_path_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--swebench-repo",
+ default=None,
+ help="Path to SWE-bench-dev/ (default: $SWEBENCH_REPO or sibling of clawcodex).",
+ )
+ parser.add_argument(
+ "--openclaude-repo",
+ default=None,
+ help="Path to openclaude/ (default: $OPENCLAUDE_REPO or sibling of clawcodex).",
+ )
+
+
+def _add_dataset_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--dataset-name",
+ default=DEFAULT_DATASET_NAME,
+ help=f"HF dataset name (default: {DEFAULT_DATASET_NAME}).",
+ )
+ parser.add_argument(
+ "--dataset-local",
+ default=DEFAULT_DATASET_LOCAL,
+ help=(
+ "Local relative path under SWE-bench-dev/ where the text dataset lives "
+ f"(default: {DEFAULT_DATASET_LOCAL})."
+ ),
+ )
+ parser.add_argument("--split", default=DEFAULT_SPLIT, help="Dataset split (default: test).")
+ parser.add_argument(
+ "--prompt-style",
+ default=DEFAULT_PROMPT_STYLE,
+ help=f"Prompt style for create_text_dataset (default: {DEFAULT_PROMPT_STYLE}).",
+ )
+ parser.add_argument(
+ "--file-source",
+ default=DEFAULT_FILE_SOURCE,
+ help=f"File source for create_text_dataset (default: {DEFAULT_FILE_SOURCE}).",
+ )
+ parser.add_argument(
+ "--prompt-field",
+ default="text",
+ help="Field in dataset row used as model prompt (default: text).",
+ )
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="run_compare",
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ # ---- prepare ----
+ p_prep = sub.add_parser("prepare", help="Build openclaude and the SWE-bench text dataset.")
+ _add_common_path_args(p_prep)
+ _add_dataset_args(p_prep)
+ p_prep.add_argument(
+ "--skip-openclaude-build",
+ action="store_true",
+ help="Don't try to bun install / bun run build inside openclaude.",
+ )
+ p_prep.set_defaults(func=cmd_prepare)
+
+ # ---- run ----
+ p_run = sub.add_parser("run", help="Run predictions + harness for both agents.")
+ _add_common_path_args(p_run)
+ _add_dataset_args(p_run)
+ p_run.add_argument(
+ "--clawcodex-repo",
+ default=None,
+ help="Path to clawcodex/ (default: this repo). Forwarded as CLAWCODEX_REPO.",
+ )
+ p_run.add_argument(
+ "--agents",
+ default="clawcodex,openclaude",
+ help="Comma-separated list of agents to run (default: both).",
+ )
+ p_run.add_argument(
+ "--scope",
+ choices=("smoke", "instances", "all"),
+ default="smoke",
+ help="Run scope (default: smoke).",
+ )
+ p_run.add_argument(
+ "--smoke-instances",
+ nargs="*",
+ default=None,
+ help=f"Override smoke instance list (default: {DEFAULT_SMOKE_INSTANCES}).",
+ )
+ p_run.add_argument(
+ "--instance-ids",
+ default="",
+ help="Comma-separated instance ids when --scope=instances.",
+ )
+ preset_help = "; ".join(
+ f"'{name}': {p.description}" for name, p in PROVIDER_PRESETS.items()
+ )
+ p_run.add_argument(
+ "--provider",
+ choices=sorted(PROVIDER_PRESETS),
+ default=DEFAULT_PROVIDER_PRESET,
+ help=(
+ f"Provider preset selecting model + per-agent provider config "
+ f"(default: {DEFAULT_PROVIDER_PRESET}). Available: {preset_help}"
+ ),
+ )
+ p_run.add_argument(
+ "--model",
+ default=None,
+ help="Override the preset's model name (e.g. --model deepseek-v4-pro).",
+ )
+ p_run.add_argument(
+ "--clawcodex-provider",
+ default=None,
+ help=(
+ "Override the clawcodex provider (must be configured via `clawcodex login`). "
+ "Pass empty string to skip --provider entirely and let clawcodex pick the default."
+ ),
+ )
+ p_run.add_argument(
+ "--openclaude-provider",
+ default=None,
+ help="Override the provider hint forwarded to openclaude.",
+ )
+ p_run.add_argument(
+ "--openclaude-base-url",
+ default=None,
+ help=(
+ "Override the OpenAI-compatible base URL the openclaude wrapper exports "
+ "as OPENAI_BASE_URL (e.g. https://api.deepseek.com/v1)."
+ ),
+ )
+ p_run.add_argument("--max-turns", type=int, default=30, help="Per-instance turn cap.")
+ p_run.add_argument(
+ "--request-timeout",
+ type=int,
+ default=1800,
+ help="HTTP timeout per instance (default: 1800s).",
+ )
+ p_run.add_argument(
+ "--max-patch-retries",
+ type=int,
+ default=2,
+ help="run_custom_api.py: invalid-patch retry count.",
+ )
+ p_run.add_argument(
+ "--patch-retry-backoff-seconds",
+ type=float,
+ default=3.0,
+ help="run_custom_api.py: invalid-patch retry backoff seconds.",
+ )
+ p_run.add_argument(
+ "--max-workers",
+ type=int,
+ default=1,
+ help="Docker harness worker count (default: 1).",
+ )
+ p_run.add_argument(
+ "--run-id",
+ default=None,
+ help="Override the auto-generated comparison run id.",
+ )
+ p_run.add_argument(
+ "--skip-harness",
+ action="store_true",
+ help="Generate predictions but skip the (slow) Docker harness step.",
+ )
+ p_run.add_argument(
+ "--capture-traces",
+ action="store_true",
+ help=(
+ "Ask each wrapper to emit a stream-json trace per instance under "
+ "/traces//.jsonl. Off by default to "
+ "keep prediction-only flows lean."
+ ),
+ )
+ p_run.add_argument(
+ "--cumulative",
+ action="store_true",
+ help=(
+ "Treat each invocation as an incremental batch against a growing "
+ "predictions file. Predictions step still honors --instance-ids "
+ "for the batch; the harness step drops the filter so its summary "
+ "covers every prediction accumulated so far (skip-existing prevents "
+ "re-running previously-evaluated instances)."
+ ),
+ )
+ p_run.add_argument(
+ "--predict-workers",
+ type=int,
+ default=1,
+ help=(
+ "Concurrent in-flight predictions per agent (default 1 = sequential). "
+ "3-5 typically gives 3-5x speedup if the model API allows. Each "
+ "worker spawns one wrapper subprocess at a time."
+ ),
+ )
+ p_run.set_defaults(func=cmd_run)
+
+ # ---- compare ----
+ p_cmp = sub.add_parser("compare", help="Diff two existing harness summary jsons.")
+ p_cmp.add_argument("--left", required=True, type=Path)
+ p_cmp.add_argument("--right", required=True, type=Path)
+ p_cmp.add_argument("--left-label", default="left")
+ p_cmp.add_argument("--right-label", default="right")
+ p_cmp.add_argument("--out", default=None, type=Path)
+ p_cmp.set_defaults(func=cmd_compare)
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ sys.path.insert(0, str(EVAL_DIR))
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/install.sh b/install.sh
new file mode 100755
index 000000000..4eba6f94c
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,1288 @@
+#!/usr/bin/env bash
+# ============================================================================
+# install.sh — One-click installer for clawcodex
+# ----------------------------------------------------------------------------
+# Run it with one line (no clone needed):
+#
+# curl -fsSL https://clawcodex.app/install.sh | bash
+#
+# To pass flags through the pipe, use bash -s --:
+#
+# curl -fsSL https://clawcodex.app/install.sh | bash -s -- --dry-run
+#
+# What it does:
+# - OS detection (Linux / macOS / WSL / Git Bash)
+# - Git prerequisite check
+# - uv installation (no sudo, via the official astral.sh installer)
+# - Python 3.10+ provisioning (via uv)
+# - Repo clone/update to ~/.clawcodex/clawcodex
+# - Venv creation (uv-managed) + dependency install (lock-pinned via uv.lock)
+# - Global command: ~/.local/bin/clawcodex
+# - Shell rc patch: .bashrc / .zshrc / .profile (PATH += ~/.local/bin)
+#
+# Subcommands (use exactly one, or omit for default 'install'):
+# install.sh # install (default)
+# install.sh status # show current install state
+# install.sh doctor # diagnose the environment
+# install.sh verify # health-check an existing install
+# install.sh update # pull latest + reinstall deps
+# install.sh uninstall # remove everything this script created
+# install.sh help # show usage
+#
+# Agent-friendly features:
+# - Subcommands (status / doctor / verify) for inspection without side effects
+# - --dry-run preview every change before applying
+# - --yes / -y assume yes for any prompts
+# - --log-file tee all output to a log file
+# - [install.sh] prefix on every line when stdout is not a TTY
+# - "DONE: success|FAILED" summary line on exit (grep-friendly)
+# - Each die() includes a "Next steps" block with actionable fixes
+# ----------------------------------------------------------------------------
+set -euo pipefail
+# ERR trap: if a command fails under set -e, print the line number and
+# failing command before exit. Makes headless / TTY / CI failures
+# self-diagnosing without requiring "bash -x".
+set -E
+trap 'log_err "Installer crash at line $LINENO: $BASH_COMMAND"' ERR
+
+# ============================================================================
+# Config (read-only defaults)
+# ============================================================================
+readonly INSTALLER_VERSION="0.7.0"
+# REPO_REF is intentionally NOT readonly — it gets reassigned when the user
+# passes --ref. We have no version tags, so the default is the main branch;
+# --ref is the escape hatch for installing a specific commit/tag/branch.
+REPO_REF="${CLAWCODEX_REF:-main}"
+readonly REPO_URL="https://github.com/agentforce314/clawcodex"
+# Install dir = where the project source is cloned and (by default) the .venv
+# lives. Runtime config (config.json, skills/, sessions/) is written by the CLI
+# to ~/.clawcodex (the parent), so everything lives under one tree.
+readonly DEFAULT_INSTALL_DIR="$HOME/.clawcodex/clawcodex"
+readonly LOCAL_BIN="$HOME/.local/bin"
+readonly PYTHON_MIN_VERSION="3.10"
+readonly ENTRY_POINT="clawcodex" # the single registered entry in pyproject.toml
+readonly RC_MARKER="# clawcodex installer — managed by install.sh"
+# Node is needed to run the Ink TUI (`clawcodex tui`). Use an existing node if
+# present; otherwise the official Node binary is fetched (no sudo) into here.
+readonly NODE_VERSION="${CLAWCODEX_NODE_VERSION:-v22.12.0}"
+readonly NODE_DIR="$HOME/.clawcodex/node"
+
+# How to refer to "this installer" in user-facing hints. When run as a file,
+# that's the script path; when piped (curl | bash) there is no file, so $0 is
+# "bash" — point users at the canonical one-liner instead so copy/paste works.
+if [[ -f "${BASH_SOURCE[0]:-}" ]]; then
+ readonly SELF_CMD="bash ${BASH_SOURCE[0]}"
+else
+ readonly SELF_CMD="curl -fsSL https://clawcodex.app/install.sh | bash -s --"
+fi
+
+# ============================================================================
+# UI helpers
+# ============================================================================
+if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
+ C_RED=$'\033[0;31m'; C_GREEN=$'\033[0;32m'; C_YELLOW=$'\033[1;33m'
+ C_BLUE=$'\033[0;34m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
+else
+ C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_BOLD=''; C_RESET=''
+fi
+
+# Agent-friendly line prefix. Emitted only when stdout/stderr is not a TTY
+# (i.e. when the script is being driven by another process, an agent, a CI
+# runner, or a piped tee). Interactive users see clean output.
+_script_p1() { [[ ! -t 1 ]] && printf '[install.sh] '; return 0; }
+_script_p2() { [[ ! -t 2 ]] && printf '[install.sh] ' >&2; return 0; }
+
+log_info() { _script_p1; echo -e "${C_BLUE}==>${C_RESET} ${C_BOLD}$1${C_RESET}"; }
+log_ok() { _script_p1; echo -e " ${C_GREEN}✓${C_RESET} $1"; }
+log_warn() { _script_p1; echo -e " ${C_YELLOW}!${C_RESET} $1"; }
+log_err() { _script_p2; echo -e "${C_RED}✗${C_RESET} $1" >&2; }
+log_step() { _script_p1; echo -e "\n${C_BOLD}${C_BLUE}>>>${C_RESET} ${C_BOLD}$1${C_RESET}"; }
+
+die() { log_err "$1"; exit 1; }
+
+# Like die(), but accepts 0+ "next steps" lines that are printed in a clear
+# "what to do next" block. Designed for agent-driven installs where the
+# failure handler needs to know what to retry.
+die_with_help() {
+ local header="$1"; shift
+ _script_p2
+ echo -e "${C_RED}✗${C_RESET} $header" >&2
+ if [[ $# -gt 0 ]]; then
+ echo "" >&2
+ echo " Next steps to try:" >&2
+ for step in "$@"; do
+ echo " → $step" >&2
+ done
+ fi
+ echo "" >&2
+ echo " For diagnosis, run: $SELF_CMD doctor" >&2
+ echo " For full usage, run: $SELF_CMD --help" >&2
+ exit 1
+}
+
+# Wrap a command: if DRY_RUN=1, just print what would happen. Otherwise run it.
+run_or_dry() {
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ _script_p1
+ echo "[DRY-RUN] would run: $*"
+ return 0
+ fi
+ "$@"
+}
+
+# Exit-time summary. Emitted by the EXIT trap after the script's main work
+# is done (success or failure). Agents tail the log for this line to know
+# whether the install succeeded.
+_on_exit_summary() {
+ local rc=$1
+ local elapsed=$(( $(date +%s) - SCRIPT_START_TS ))
+ if [[ $rc -eq 0 ]]; then
+ _script_p1
+ echo "DONE: success in ${elapsed}s"
+ if [[ -n "${LOG_FILE:-}" ]]; then
+ _script_p1
+ echo "DONE: full log saved to: $LOG_FILE"
+ fi
+ else
+ _script_p2
+ echo "DONE: FAILED (exit $rc) after ${elapsed}s" >&2
+ if [[ -n "${LOG_FILE:-}" ]]; then
+ _script_p2
+ echo "DONE: failure log saved to: $LOG_FILE" >&2
+ else
+ _script_p2
+ echo "DONE: re-run with --log-file to capture full output." >&2
+ fi
+ fi
+}
+
+# ============================================================================
+# OS detection
+# ============================================================================
+detect_os() {
+ local ostype="${OSTYPE:-}"
+ if [[ "$ostype" == "linux-gnu"* || "$ostype" == "linux-musl"* ]]; then
+ # Distinguish WSL from native Linux
+ if [[ -r /proc/version ]] && grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
+ echo "wsl"
+ else
+ echo "linux"
+ fi
+ elif [[ "$ostype" == "darwin"* ]]; then
+ echo "macos"
+ elif [[ "$ostype" == "msys"* || "$ostype" == "cygwin"* || "$ostype" == "win32" ]]; then
+ echo "windows-like"
+ elif [[ -r /proc/version ]] && grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
+ echo "wsl"
+ else
+ echo "unknown"
+ fi
+}
+
+os_install_hint() {
+ case "$1" in
+ linux|wsl)
+ cat <<'EOF'
+ Install Git for your distro, e.g.:
+ Debian/Ubuntu : sudo apt update && sudo apt install -y git
+ Fedora/RHEL : sudo dnf install -y git
+ Arch : sudo pacman -S --noconfirm git
+ openSUSE : sudo zypper install -y git
+EOF
+ ;;
+ macos)
+ cat <<'EOF'
+ Install Git on macOS:
+ xcode-select --install # Apple Command Line Tools
+ — or —
+ brew install git
+EOF
+ ;;
+ windows-like)
+ cat <<'EOF'
+ On Windows, install one of:
+ Git for Windows : https://git-scm.com/download/win (then run from Git Bash)
+ WSL : https://learn.microsoft.com/windows/wsl/install (recommended)
+EOF
+ ;;
+ esac
+}
+
+# One-liner variant for the doctor output.
+os_install_hint_oneliner() {
+ case "$1" in
+ linux|wsl) echo "sudo apt install -y git (or your distro's package manager)" ;;
+ macos) echo "xcode-select --install (or: brew install git)" ;;
+ windows-like) echo "install Git for Windows or WSL" ;;
+ *) echo "install git via your package manager" ;;
+ esac
+}
+
+# ============================================================================
+# Prerequisite: Git
+# ============================================================================
+check_git() {
+ if ! command -v git >/dev/null 2>&1; then
+ log_err "Git is not installed."
+ os_install_hint "$OS"
+ exit 1
+ fi
+ log_ok "$(git --version)"
+}
+
+# ============================================================================
+# Install / locate uv (Astral's Python package manager, no sudo)
+# ============================================================================
+install_uv() {
+ if command -v uv >/dev/null 2>&1; then
+ log_ok "uv $(uv --version | awk '{print $2}') already installed"
+ return
+ fi
+
+ log_info "Installing uv via official astral.sh installer (no sudo)..."
+ # The official installer drops uv into ~/.local/bin. We capture its output
+ # so we can show progress in our own style.
+ local tmp
+ tmp=$(mktemp)
+ if ! run_or_dry curl -LsSf --max-time 60 https://astral.sh/uv/install.sh -o "$tmp"; then
+ rm -f "$tmp"
+ die_with_help "Failed to download uv installer (network issue?)." \
+ "Check your network connection and proxy settings." \
+ "Retry: $SELF_CMD" \
+ "Manual: see https://docs.astral.sh/uv/"
+ fi
+ if ! run_or_dry env UV_INSTALL_DIR="$HOME/.local" sh "$tmp"; then
+ rm -f "$tmp"
+ die_with_help "uv installer exited with an error." \
+ "Inspect the output above for the exact failure." \
+ "Retry: $SELF_CMD" \
+ "Manual: curl -LsSf https://astral.sh/uv/install.sh | sh"
+ fi
+ rm -f "$tmp"
+
+ # Make uv visible to this session, then verify.
+ export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
+ if [[ "${DRY_RUN:-0}" -eq 0 ]] && ! command -v uv >/dev/null 2>&1; then
+ die_with_help "uv still not on PATH after install." \
+ "Check: ls -la $HOME/.local/bin/uv" \
+ "Or: export PATH=\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH" \
+ "Then: $SELF_CMD"
+ fi
+ log_ok "uv $(uv --version | awk '{print $2}') installed"
+}
+
+# ============================================================================
+# Python 3.10+ provisioning (via uv)
+# ============================================================================
+ensure_python() {
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ _script_p1
+ echo "[DRY-RUN] would check for Python $PYTHON_MIN_VERSION+ via uv"
+ return 0
+ fi
+ # Ask uv for any 3.10+ interpreter it can see (system or uv-managed).
+ # `|| true` keeps a "not found" exit from tripping the ERR trap (which
+ # set -E inherits into this command substitution); we test $py instead.
+ local py
+ py=$(uv python find "$PYTHON_MIN_VERSION" 2>/dev/null || true)
+ if [[ -n "$py" && -x "$py" ]]; then
+ log_ok "Python $($py --version 2>&1 | awk '{print $1, $2}')"
+ return
+ fi
+
+ log_info "Python $PYTHON_MIN_VERSION+ not found — provisioning via uv (no sudo)..."
+ if ! run_or_dry uv python install "$PYTHON_MIN_VERSION"; then
+ die_with_help "Failed to install Python $PYTHON_MIN_VERSION via uv." \
+ "Retry: $SELF_CMD" \
+ "Manual: uv python install $PYTHON_MIN_VERSION" \
+ "Or: install Python $PYTHON_MIN_VERSION+ from https://python.org"
+ fi
+ py=$(uv python find "$PYTHON_MIN_VERSION" 2>/dev/null || true)
+ if [[ -z "$py" || ! -x "$py" ]]; then
+ die_with_help "Python $PYTHON_MIN_VERSION still not found after uv install." \
+ "Retry: $SELF_CMD" \
+ "Diagnose: $SELF_CMD doctor"
+ fi
+ log_ok "Python $($py --version 2>&1 | awk '{print $1, $2}')"
+}
+
+# ============================================================================
+# Clone or update the repo
+# ============================================================================
+clone_or_update_repo() {
+ # Preview-only in dry-run mode. This guard MUST come before any pull /
+ # backup / clone so --dry-run never mutates the filesystem.
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ if [[ -d "$CLAWCODEX_HOME/.git" ]]; then
+ _script_p1; echo "[DRY-RUN] would update $CLAWCODEX_HOME: restore uv.lock, then git pull --ff-only (reset to origin/$REPO_REF if it can't fast-forward)"
+ elif [[ -e "$CLAWCODEX_HOME" ]]; then
+ _script_p1; echo "[DRY-RUN] would back up non-git $CLAWCODEX_HOME, then clone $REPO_URL (ref: $REPO_REF)"
+ else
+ _script_p1; echo "[DRY-RUN] would clone: $REPO_URL (ref: $REPO_REF) -> $CLAWCODEX_HOME"
+ fi
+ return 0
+ fi
+
+ if [[ -d "$CLAWCODEX_HOME/.git" ]]; then
+ log_info "Existing repo found at $CLAWCODEX_HOME — pulling latest changes..."
+ # A previous install's `uv sync` re-pins the *tracked* uv.lock in place;
+ # that local change blocks `git pull --ff-only`, so without this the
+ # installer would warn and silently keep old code on every update. This
+ # dir is a managed mirror (not a working copy), so discard the
+ # installer's own tracked churn before pulling.
+ git -C "$CLAWCODEX_HOME" checkout -- uv.lock >/dev/null 2>&1 || true
+ # git -C (not a cd-subshell) keeps this a direct `if` condition, so an
+ # expected pull failure doesn't trip the ERR trap.
+ if git -C "$CLAWCODEX_HOME" pull --ff-only >/dev/null 2>&1; then
+ log_ok "Updated via fast-forward"
+ # Fallback: shallow clones can't always fast-forward, and any stray
+ # tracked edits would block the pull. Reset the managed mirror to the
+ # remote ref so updates are never silently skipped.
+ elif git -C "$CLAWCODEX_HOME" fetch --depth 1 origin "$REPO_REF" >/dev/null 2>&1 &&
+ git -C "$CLAWCODEX_HOME" reset --hard FETCH_HEAD >/dev/null 2>&1; then
+ log_ok "Updated (reset to origin/$REPO_REF)"
+ else
+ log_warn "Could not update $CLAWCODEX_HOME to latest; continuing with existing code."
+ fi
+ return
+ fi
+
+ if [[ -e "$CLAWCODEX_HOME" ]]; then
+ # Exists but isn't a git repo — back it up so we don't clobber user work.
+ local stamp
+ stamp=$(date +%Y%m%d%H%M%S)
+ log_warn "$CLAWCODEX_HOME exists but is not a git checkout. Backing up to ${CLAWCODEX_HOME}.bak.${stamp}"
+ mv "$CLAWCODEX_HOME" "${CLAWCODEX_HOME}.bak.${stamp}"
+ fi
+
+ mkdir -p "$CLAWCODEX_PARENT_DIR"
+ log_info "Cloning $REPO_URL (ref: $REPO_REF) → $CLAWCODEX_HOME"
+ # Try the requested ref first (branch or tag).
+ if git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$CLAWCODEX_HOME" 2>/dev/null; then
+ log_ok "Cloned ref $REPO_REF"
+ return
+ fi
+
+ # The ref doesn't exist on the remote (e.g. a typo'd --ref, or a tag that
+ # isn't pushed). Fall back to the default branch so install still succeeds.
+ log_warn "Ref '$REPO_REF' not found on $REPO_URL — falling back to the default branch."
+ if ! git clone --depth 1 "$REPO_URL" "$CLAWCODEX_HOME"; then
+ die_with_help "git clone failed." \
+ "Check your network connection." \
+ "Verify: curl -I $REPO_URL" \
+ "Retry: $SELF_CMD" \
+ "Diagnose: $SELF_CMD doctor"
+ fi
+ log_ok "Cloned default branch"
+}
+
+# ============================================================================
+# Create venv
+# ============================================================================
+create_venv() {
+ if [[ "$USE_VENV" -eq 0 ]]; then
+ log_info "--no-venv specified — skipping venv creation (deps will install to system Python)"
+ return
+ fi
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ _script_p1
+ echo "[DRY-RUN] would run: uv venv --python $PYTHON_MIN_VERSION .venv (in $CLAWCODEX_HOME)"
+ return 0
+ fi
+ cd "$CLAWCODEX_HOME"
+ if [[ -d ".venv" ]]; then
+ log_ok "Existing venv at $CLAWCODEX_HOME/.venv"
+ return
+ fi
+ log_info "Creating venv with Python $PYTHON_MIN_VERSION..."
+ if ! run_or_dry uv venv --python "$PYTHON_MIN_VERSION" .venv; then
+ die_with_help "uv venv failed." \
+ "Check: uv --version" \
+ "Retry: $SELF_CMD" \
+ "Diagnose: $SELF_CMD doctor"
+ fi
+ log_ok "Venv created"
+}
+
+# ============================================================================
+# Install dependencies
+# ============================================================================
+install_deps() {
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ log_info "Installing project + dependencies (lock-pinned to uv.lock when possible)..."
+ _script_p1
+ if [[ "$USE_VENV" -eq 1 ]]; then
+ echo "[DRY-RUN] would run: uv sync (in $CLAWCODEX_HOME; fallback: uv pip install -e .)"
+ else
+ echo "[DRY-RUN] would run: uv pip install --system -e . (in $CLAWCODEX_HOME)"
+ fi
+ return 0
+ fi
+ cd "$CLAWCODEX_HOME"
+
+ # --- With venv: prefer `uv sync` (honors uv.lock → exact transitive
+ # versions), fall back to an editable `uv pip install` if the lock is
+ # out of sync with pyproject.toml.
+ if [[ "$USE_VENV" -eq 1 ]]; then
+ [[ -d ".venv" ]] || die "Venv missing at $CLAWCODEX_HOME/.venv — run without --no-venv or re-clone."
+ log_info "Installing dependencies (uv sync, lock-pinned to uv.lock)..."
+ local synclog; synclog=$(mktemp)
+ if uv sync 2>"$synclog"; then
+ rm -f "$synclog"
+ log_ok "Dependencies installed (lock-pinned via uv.lock)"
+ return
+ fi
+ local sync_err
+ sync_err=$(cat "$synclog" 2>/dev/null || true)
+ rm -f "$synclog"
+ log_warn "uv sync failed; falling back to editable install (NOT lock-pinned)."
+ log_warn " Sync error was: ${sync_err:-}"
+ if ! uv pip install --python .venv/bin/python -e .; then
+ die_with_help "Both uv sync and uv pip install failed." \
+ "Re-run with --log-file to capture full output." \
+ "Retry: $SELF_CMD" \
+ "Diagnose: $SELF_CMD doctor" \
+ "Clean: $SELF_CMD uninstall && $SELF_CMD"
+ fi
+ log_ok "Dependencies installed (editable, fresh-resolved into .venv)"
+ return
+ fi
+
+ # --- Without venv (--no-venv): install to the active system Python.
+ log_info "Installing dependencies into system Python (uv pip install --system)..."
+ local piplog; piplog=$(mktemp)
+ if uv pip install --system -e . 2>"$piplog"; then
+ rm -f "$piplog"
+ log_ok "Dependencies installed (system Python)"
+ return
+ fi
+ local pip_err
+ pip_err=$(cat "$piplog" 2>/dev/null || true)
+ rm -f "$piplog"
+ # uv's PEP 668 message has changed wording across versions; match both the
+ # structured code and the human message defensively.
+ if echo "$pip_err" | grep -qiE 'externally[ -]managed'; then
+ log_warn "System Python is externally managed (PEP 668). Retrying with --break-system-packages."
+ if ! uv pip install --system --break-system-packages -e .; then
+ die_with_help "uv pip install to system failed even with --break-system-packages." \
+ "Inspect the error above for missing system libraries." \
+ "Retry: $SELF_CMD" \
+ "Or: $SELF_CMD uninstall && $SELF_CMD (fresh install with venv)"
+ fi
+ log_ok "Dependencies installed (system Python, --break-system-packages)"
+ return
+ fi
+ log_err "uv pip install failed: ${pip_err:-}"
+ die_with_help "Dependency install failed." \
+ "Re-run with --log-file to capture full output." \
+ "Retry: $SELF_CMD" \
+ "Diagnose: $SELF_CMD doctor"
+}
+
+# ============================================================================
+# Node + the Ink TUI client (`clawcodex tui`)
+# ============================================================================
+
+# Ensure `node`/`npm` are available. Reuses an existing install; otherwise fetches
+# the official Node binary (no sudo) into ~/.clawcodex/node and links it onto PATH
+# (~/.local/bin, already added to the shell rc). Non-fatal: returns 1 if Node
+# can't be provided (the Python REPL works without it).
+provision_node() {
+ if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
+ log_ok "node $(node --version 2>/dev/null) already installed"
+ return 0
+ fi
+ if [[ "${DRY_RUN:-0}" -ne 0 ]]; then
+ _script_p1; echo "[DRY-RUN] would fetch Node $NODE_VERSION into $NODE_DIR and link node/npm into $LOCAL_BIN"
+ return 0
+ fi
+ local plat arch
+ case "$(uname -s)" in
+ Darwin) plat="darwin" ;;
+ Linux) plat="linux" ;;
+ *) log_warn "Node auto-install unsupported on $(uname -s) — install Node 18+ for 'clawcodex tui'."; return 1 ;;
+ esac
+ case "$(uname -m)" in
+ arm64|aarch64) arch="arm64" ;;
+ x86_64|amd64) arch="x64" ;;
+ *) log_warn "Node auto-install unsupported on $(uname -m) — install Node 18+ for 'clawcodex tui'."; return 1 ;;
+ esac
+ local tarball="node-${NODE_VERSION}-${plat}-${arch}.tar.gz"
+ local url="https://nodejs.org/dist/${NODE_VERSION}/${tarball}"
+ log_info "Installing Node ${NODE_VERSION} (${plat}-${arch}) for the TUI (no sudo)..."
+ local tmp; tmp="$(mktemp -d)"
+ if ! curl -fsSL --max-time 180 "$url" -o "$tmp/$tarball"; then
+ log_warn "Node download failed ($url) — install Node 18+ manually for 'clawcodex tui'."
+ rm -rf "$tmp"; return 1
+ fi
+ rm -rf "$NODE_DIR"; mkdir -p "$NODE_DIR"
+ if ! tar -xzf "$tmp/$tarball" -C "$NODE_DIR" --strip-components=1; then
+ log_warn "Node extract failed — install Node 18+ manually for 'clawcodex tui'."
+ rm -rf "$tmp"; return 1
+ fi
+ rm -rf "$tmp"
+ mkdir -p "$LOCAL_BIN"
+ ln -sf "$NODE_DIR/bin/node" "$LOCAL_BIN/node"
+ ln -sf "$NODE_DIR/bin/npm" "$LOCAL_BIN/npm"
+ ln -sf "$NODE_DIR/bin/npx" "$LOCAL_BIN/npx"
+ export PATH="$LOCAL_BIN:$PATH"
+ if command -v node >/dev/null 2>&1; then
+ log_ok "Node $(node --version 2>/dev/null) installed"
+ return 0
+ fi
+ log_warn "Node installed to $NODE_DIR but not on PATH — add $LOCAL_BIN to PATH."
+ return 1
+}
+
+# Build the TypeScript Ink TUI — the sole interactive UI (`clawcodex` and the
+# explicit `clawcodex tui`). Needs node + dist/cli.js + node_modules. Non-fatal:
+# the headless path (`clawcodex -p`) still works without it.
+build_tui() {
+ local tui_dir="$CLAWCODEX_HOME/ui-tui"
+ if [[ ! -f "$tui_dir/package.json" ]]; then
+ log_warn "ui-tui not found at $tui_dir — interactive 'clawcodex' needs it; 'clawcodex -p' (headless) still works."
+ return 0
+ fi
+ if [[ "${DRY_RUN:-0}" -ne 0 ]]; then
+ _script_p1; echo "[DRY-RUN] would run: npm install && npm run build (in $tui_dir)"
+ return 0
+ fi
+ if ! provision_node; then
+ log_warn "Skipping TUI build — Node unavailable. Interactive 'clawcodex' needs Node 18+; 'clawcodex -p' (headless) works without it."
+ return 0
+ fi
+ log_info "Building the Ink TUI client (npm install + build; first run ~30s)..."
+ if ( cd "$tui_dir" && npm install --no-audit --no-fund >/dev/null 2>&1 && npm run build >/dev/null 2>&1 ); then
+ log_ok "Ink TUI built — run 'clawcodex'"
+ else
+ log_warn "Ink TUI build failed — interactive 'clawcodex' needs it ('clawcodex -p' headless still works). Retry: (cd \"$tui_dir\" && npm install && npm run build)"
+ fi
+}
+
+# ============================================================================
+# Locate the venv's entry-point binary
+# ============================================================================
+find_venv_entry() {
+ local venv_dir="$1" name="$2"
+ # Linux/macOS layout
+ if [[ -x "$venv_dir/bin/$name" ]]; then
+ echo "$venv_dir/bin/$name"; return 0
+ fi
+ # Windows layout (Git Bash / WSL interop)
+ if [[ -x "$venv_dir/Scripts/$name.exe" ]]; then
+ echo "$venv_dir/Scripts/$name.exe"; return 0
+ fi
+ if [[ -x "$venv_dir/Scripts/$name" ]]; then
+ echo "$venv_dir/Scripts/$name"; return 0
+ fi
+ return 1
+}
+
+# ============================================================================
+# Register the global command
+# - We write a tiny wrapper script in ~/.local/bin (more portable than a
+# symlink on Windows / Git Bash, and survives venv re-creation).
+# ============================================================================
+register_commands() {
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ _script_p1
+ echo "[DRY-RUN] would register: $LOCAL_BIN/clawcodex"
+ return 0
+ fi
+ mkdir -p "$LOCAL_BIN"
+
+ local entry
+ if [[ "$USE_VENV" -eq 1 ]]; then
+ # Venv mode: look for the entry inside the project's .venv
+ if ! entry=$(find_venv_entry "$CLAWCODEX_HOME/.venv" "$ENTRY_POINT"); then
+ die "Entry point '$ENTRY_POINT' not found inside $CLAWCODEX_HOME/.venv — dependency install may have failed."
+ fi
+ else
+ # --no-venv mode: look for the entry on PATH (uv pip install --system
+ # drops scripts in /usr/local/bin or ~/.local/bin). Check a few common
+ # locations explicitly so we don't depend on the just-installed PATH
+ # being effective in this very shell.
+ entry=""
+ for candidate in \
+ "$HOME/.local/bin/$ENTRY_POINT" \
+ "/usr/local/bin/$ENTRY_POINT" \
+ "$(command -v "$ENTRY_POINT" 2>/dev/null || true)"; do
+ if [[ -n "$candidate" && ( -x "$candidate" || -L "$candidate" ) ]]; then
+ entry="$candidate"; break
+ fi
+ done
+ [[ -n "$entry" ]] || die "Entry point '$ENTRY_POINT' not found on PATH after system install — check 'which $ENTRY_POINT'."
+ fi
+
+ local wrapper="$LOCAL_BIN/$ENTRY_POINT"
+ # Always (re)write so the wrapper reflects any new install dir.
+ [[ -L "$wrapper" || -e "$wrapper" ]] && rm -f "$wrapper"
+ cat > "$wrapper" < "$CLAWCODEX_HOME/.clawcodex-install" 2>/dev/null || true
+}
+
+# ============================================================================
+# Patch shell rc files to include ~/.local/bin in PATH
+# ============================================================================
+update_shell_rc() {
+ local path_line='export PATH="$HOME/.local/bin:$PATH"'
+ local rc_files=()
+
+ [[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
+ [[ -f "$HOME/.zshrc" ]] && rc_files+=("$HOME/.zshrc")
+ [[ -f "$HOME/.profile" ]] && rc_files+=("$HOME/.profile")
+
+ if [[ ${#rc_files[@]} -eq 0 ]]; then
+ log_warn "No shell rc file detected — please add '$path_line' to your shell's startup file."
+ return
+ fi
+
+ for rc in "${rc_files[@]}"; do
+ if grep -qF "$HOME/.local/bin" "$rc" 2>/dev/null; then
+ log_ok "PATH already contains ~/.local/bin in $rc"
+ continue
+ fi
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ _script_p1
+ echo "[DRY-RUN] would append PATH entry to: $rc"
+ continue
+ fi
+ {
+ echo ""
+ echo "$RC_MARKER"
+ echo "$path_line"
+ } >> "$rc"
+ log_ok "Patched $rc (added ~/.local/bin to PATH)"
+ done
+}
+
+# ============================================================================
+# Post-install pointer (non-blocking)
+# ============================================================================
+run_post_install_setup() {
+ if [[ "$RUN_SETUP" -eq 0 ]]; then
+ log_warn "Setup pointer skipped (--no-setup). Run 'clawcodex' manually to configure."
+ return
+ fi
+ # We intentionally do NOT exec a blocking interactive REPL here — the
+ # installer must stay non-interactive so it can run unattended (CI/Docker/
+ # agents). We just point the user at the first-run commands.
+ if command -v clawcodex >/dev/null 2>&1; then
+ log_ok "Next, configure a provider + API key:"
+ echo -e " ${C_BOLD}clawcodex login${C_RESET} # interactive provider + key setup"
+ echo -e " ${C_BOLD}clawcodex${C_RESET} # start the REPL in any project"
+ echo -e " ${C_BOLD}clawcodex tui${C_RESET} # the Ink TUI (Claude-Code-style)"
+ else
+ log_warn "clawcodex not on PATH yet — run 'source ~/.bashrc' (or ~/.zshrc) first."
+ fi
+}
+
+# ============================================================================
+# Inspection subcommands (no side effects — safe for agents to call)
+# ============================================================================
+
+# Show current install state. No side effects.
+cmd_status() {
+ echo "=== clawcodex install status ==="
+ echo " Installer : v${INSTALLER_VERSION}"
+ echo " Repo URL : $REPO_URL"
+ echo " Git ref : $REPO_REF"
+ echo " Install dir : $CLAWCODEX_HOME"
+ echo " Local bin : $LOCAL_BIN"
+ echo ""
+ if [[ -d "$CLAWCODEX_HOME/.git" ]]; then
+ local installed_sha installed_branch
+ installed_sha=$(cd "$CLAWCODEX_HOME" && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
+ installed_branch=$(cd "$CLAWCODEX_HOME" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
+ echo " Git state :"
+ echo " branch : $installed_branch"
+ echo " commit : $installed_sha"
+ if [[ -d "$CLAWCODEX_HOME/.venv" ]]; then
+ local py_ver
+ py_ver=$("$CLAWCODEX_HOME/.venv/bin/python" --version 2>&1 | head -1 || echo "missing")
+ echo " Venv : present (Python: $py_ver)"
+ else
+ echo " Venv : MISSING (run '$SELF_CMD update' to recreate)"
+ fi
+ else
+ echo " Git state : NOT INSTALLED (run '$SELF_CMD install')"
+ fi
+ echo ""
+ echo " Command:"
+ if [[ -x "$LOCAL_BIN/clawcodex" ]]; then
+ echo " $LOCAL_BIN/clawcodex : present"
+ else
+ echo " $LOCAL_BIN/clawcodex : MISSING"
+ fi
+ echo ""
+ if command -v clawcodex >/dev/null 2>&1; then
+ echo " clawcodex resolves to: $(command -v clawcodex)"
+ else
+ echo " clawcodex NOT on PATH (run: source ~/.bashrc)"
+ fi
+ echo ""
+ echo "=== end of status ==="
+}
+
+# Diagnose the environment. No side effects (only reads).
+# Exit 0 if all critical checks pass, 1 if any fail.
+cmd_doctor() {
+ local fail=0 warn=0
+ echo "=== clawcodex environment doctor ==="
+ echo ""
+
+ echo "[1/9] OS detection"
+ if [[ "$OS" == "unknown" ]]; then
+ echo " ✗ unknown OS"; fail=$((fail+1))
+ else
+ echo " ✓ $OS"
+ fi
+
+ echo "[2/9] Git"
+ if command -v git >/dev/null 2>&1; then
+ echo " ✓ $(git --version)"
+ else
+ echo " ✗ git not found"
+ echo " install: $(os_install_hint_oneliner "$OS")"
+ fail=$((fail+1))
+ fi
+
+ echo "[3/9] Python >= $PYTHON_MIN_VERSION"
+ if command -v uv >/dev/null 2>&1; then
+ local py
+ py=$(uv python find "$PYTHON_MIN_VERSION" 2>/dev/null || true)
+ if [[ -n "$py" ]]; then
+ echo " ✓ $py ($($py --version 2>&1))"
+ else
+ echo " ! no Python $PYTHON_MIN_VERSION+ found (uv will provision on install)"
+ warn=$((warn+1))
+ fi
+ else
+ echo " ! uv not on PATH yet (Python check deferred to install time)"
+ warn=$((warn+1))
+ fi
+
+ echo "[4/9] uv"
+ if command -v uv >/dev/null 2>&1; then
+ echo " ✓ $(uv --version)"
+ else
+ echo " ! uv not on PATH (will be installed by the installer)"
+ warn=$((warn+1))
+ fi
+
+ echo "[5/9] Network reachability"
+ if curl -sSf --max-time 5 -o /dev/null "$REPO_URL" 2>/dev/null; then
+ echo " ✓ repo reachable: $REPO_URL"
+ else
+ echo " ✗ cannot reach $REPO_URL"
+ echo " check: proxy settings, VPN, DNS, firewall"
+ fail=$((fail+1))
+ fi
+
+ # Walk up to the nearest EXISTING ancestor and test that — doctor must not
+ # create directories (it is documented as side-effect-free).
+ local probe="$CLAWCODEX_PARENT_DIR"
+ while [[ -n "$probe" && "$probe" != "/" && ! -d "$probe" ]]; do
+ probe=$(dirname -- "$probe")
+ done
+
+ echo "[6/9] Write access to install dir"
+ if [[ -d "$probe" && -w "$probe" ]]; then
+ echo " ✓ writable: $probe"
+ else
+ echo " ✗ cannot write: $probe"
+ echo " fix: sudo chown -R \$USER $probe (or pick a different --install-dir)"
+ fail=$((fail+1))
+ fi
+
+ echo "[7/9] Disk space"
+ local avail_kb
+ avail_kb=$(df -Pk "$probe" 2>/dev/null | awk 'NR==2 {print $4}' || true)
+ if [[ -n "$avail_kb" ]] && [[ $avail_kb -gt 524288 ]]; then
+ echo " ✓ $(( avail_kb / 1024 ))MB available"
+ else
+ echo " ✗ < 512MB available at $probe (need ~500MB for venv + deps)"
+ fail=$((fail+1))
+ fi
+
+ echo "[8/9] ~/.local/bin in PATH"
+ if [[ ":$PATH:" == *":$HOME/.local/bin:"* ]]; then
+ echo " ✓ $HOME/.local/bin is in current PATH"
+ else
+ echo " ! $HOME/.local/bin NOT in current PATH (will be patched on install)"
+ warn=$((warn+1))
+ fi
+
+ echo "[9/9] Existing install"
+ if [[ -d "$CLAWCODEX_HOME/.git" ]]; then
+ echo " ✓ installed at $CLAWCODEX_HOME"
+ echo " (run '$SELF_CMD verify' to check health, '$SELF_CMD update' to refresh)"
+ else
+ echo " ! not installed yet"
+ warn=$((warn+1))
+ fi
+
+ echo ""
+ echo "=== summary ==="
+ echo " critical : $fail"
+ echo " warnings : $warn"
+ echo ""
+ if [[ $fail -gt 0 ]]; then
+ echo " Result: NOT READY ($fail critical issue(s))"
+ exit 1
+ else
+ echo " Result: READY to install (or already installed)"
+ exit 0
+ fi
+}
+
+# Health check an existing install. No side effects.
+cmd_verify() {
+ local fail=0 warn=0
+ echo "=== clawcodex install verification ==="
+ echo ""
+
+ echo "[1/6] Repo"
+ if [[ -d "$CLAWCODEX_HOME/.git" ]]; then
+ echo " ✓ present at $CLAWCODEX_HOME"
+ else
+ echo " ✗ NOT FOUND at $CLAWCODEX_HOME"
+ echo " run: $SELF_CMD install"
+ fail=$((fail+1))
+ fi
+
+ echo "[2/6] Venv"
+ if [[ -d "$CLAWCODEX_HOME/.venv" ]]; then
+ echo " ✓ present at $CLAWCODEX_HOME/.venv"
+ if [[ -x "$CLAWCODEX_HOME/.venv/bin/python" ]]; then
+ echo " ✓ python works: $($CLAWCODEX_HOME/.venv/bin/python --version 2>&1)"
+ else
+ echo " ✗ python missing in venv"; fail=$((fail+1))
+ fi
+ else
+ echo " ✗ venv MISSING at $CLAWCODEX_HOME/.venv"
+ echo " run: $SELF_CMD update (or: $SELF_CMD install)"
+ fail=$((fail+1))
+ fi
+
+ echo "[3/6] Entry point"
+ local entry=""
+ if [[ -d "$CLAWCODEX_HOME/.venv" ]]; then
+ entry=$(find_venv_entry "$CLAWCODEX_HOME/.venv" "$ENTRY_POINT" 2>/dev/null || true)
+ fi
+ if [[ -n "$entry" && -x "$entry" ]]; then
+ echo " ✓ $ENTRY_POINT at $entry"
+ else
+ echo " ✗ $ENTRY_POINT not found in venv"
+ echo " run: $SELF_CMD update"
+ fail=$((fail+1))
+ fi
+
+ echo "[4/6] Command wrapper"
+ if [[ -x "$LOCAL_BIN/clawcodex" ]]; then
+ echo " ✓ $LOCAL_BIN/clawcodex"
+ else
+ echo " ✗ $LOCAL_BIN/clawcodex MISSING"
+ echo " run: $SELF_CMD install"
+ fail=$((fail+1))
+ fi
+
+ echo "[5/6] PATH"
+ if command -v clawcodex >/dev/null 2>&1; then
+ echo " ✓ clawcodex resolves to: $(command -v clawcodex)"
+ else
+ echo " ! clawcodex NOT on PATH (wrapper exists but not exported)"
+ echo " run: source ~/.bashrc (or ~/.zshrc)"
+ warn=$((warn+1))
+ fi
+
+ echo "[6/6] Smoke test (clawcodex --version)"
+ if command -v clawcodex >/dev/null 2>&1; then
+ if clawcodex --version >/dev/null 2>&1; then
+ echo " ✓ clawcodex --version works"
+ else
+ echo " ✗ clawcodex --version FAILED"; fail=$((fail+1))
+ fi
+ else
+ echo " ! skipped (not on PATH)"; warn=$((warn+1))
+ fi
+
+ echo ""
+ if [[ $fail -gt 0 ]]; then
+ echo "=== Result: UNHEALTHY ($fail issue(s), $warn warning(s)) ==="
+ echo ""
+ echo "Try:"
+ echo " $SELF_CMD update # re-pull and re-install deps"
+ echo " $SELF_CMD uninstall && $SELF_CMD # full clean reinstall"
+ exit 1
+ else
+ echo "=== Result: HEALTHY ($warn warning(s)) ==="
+ exit 0
+ fi
+}
+
+# Update: pull latest and reinstall deps. Side effects: yes.
+cmd_update() {
+ log_info "Updating clawcodex at $CLAWCODEX_HOME (ref: $REPO_REF)..."
+ if [[ ! -d "$CLAWCODEX_HOME/.git" ]]; then
+ die_with_help "No existing install at $CLAWCODEX_HOME." \
+ "Run: $SELF_CMD install (fresh install)" \
+ "Or: $SELF_CMD doctor (diagnose environment)"
+ fi
+ clone_or_update_repo
+ create_venv
+ install_deps
+ register_commands
+ build_tui
+ log_ok "Update complete."
+ log_info "Run '$SELF_CMD verify' to confirm health."
+}
+
+# ============================================================================
+# Uninstall — only removes what this script created
+# ============================================================================
+uninstall() {
+ log_info "Uninstalling clawcodex..."
+ log_info " Install dir : $CLAWCODEX_HOME"
+ log_info " Local bin : $LOCAL_BIN"
+
+ # Hard safety: never operate on a protected path, regardless of markers.
+ case "$CLAWCODEX_HOME" in
+ "" | "/" | "$HOME" | "$HOME/")
+ die "Refusing to uninstall from a protected path: '$CLAWCODEX_HOME'." ;;
+ esac
+
+ # Ownership gate: only delete an install dir that carries OUR marker file
+ # (written by register_commands). This prevents `uninstall --install-dir
+ # ` from removing a tree this installer never created.
+ local owned=0
+ [[ -f "$CLAWCODEX_HOME/.clawcodex-install" ]] && owned=1
+
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ _script_p1; echo "[DRY-RUN] would remove wrapper $LOCAL_BIN/clawcodex (only if it points into $CLAWCODEX_HOME/)"
+ if [[ $owned -eq 1 ]]; then
+ _script_p1; echo "[DRY-RUN] would remove install dir: $CLAWCODEX_HOME"
+ _script_p1; echo "[DRY-RUN] would remove $CLAWCODEX_PARENT_DIR only if it is empty afterwards"
+ else
+ _script_p1; echo "[DRY-RUN] would SKIP $CLAWCODEX_HOME (no .clawcodex-install marker — not created by this installer)"
+ fi
+ return 0
+ fi
+
+ local wrapper="$LOCAL_BIN/clawcodex"
+ if [[ -e "$wrapper" || -L "$wrapper" ]]; then
+ # Only remove a wrapper that execs a binary inside THIS install dir. The
+ # trailing slash anchors the match so /tmp/cc can't match /tmp/cc-prod.
+ if grep -qF "$CLAWCODEX_HOME/" "$wrapper" 2>/dev/null; then
+ rm -f "$wrapper"
+ log_ok "Removed $wrapper"
+ else
+ log_warn "Skipped $wrapper — does not point inside $CLAWCODEX_HOME (other install?)"
+ fi
+ fi
+
+ if [[ $owned -eq 0 ]]; then
+ log_warn "Skipped $CLAWCODEX_HOME — no .clawcodex-install marker found."
+ log_warn " This directory was not created by this installer, so it is left untouched."
+ log_warn " (A half-finished install — deps failed before the marker was written —"
+ log_warn " can be repaired by re-running install; it reuses the clone.)"
+ log_warn " Otherwise remove it manually with 'rm -rf' if you are sure."
+ log_ok "Uninstall complete (wrapper only)."
+ return
+ fi
+
+ if [[ -d "$CLAWCODEX_HOME" ]]; then
+ rm -rf "$CLAWCODEX_HOME"
+ log_ok "Removed $CLAWCODEX_HOME"
+ fi
+ # Only auto-remove the install's parent dir if it's empty. ~/.clawcodex
+ # usually still holds the user's config.json / sessions / skills, so it
+ # will NOT be empty and we keep it by design.
+ if [[ -d "$CLAWCODEX_PARENT_DIR" ]] \
+ && [[ -z "$(ls -A "$CLAWCODEX_PARENT_DIR" 2>/dev/null)" ]]; then
+ rmdir "$CLAWCODEX_PARENT_DIR" 2>/dev/null || true
+ log_ok "Removed empty $CLAWCODEX_PARENT_DIR"
+ elif [[ -d "$CLAWCODEX_PARENT_DIR" ]]; then
+ log_warn "Preserved $CLAWCODEX_PARENT_DIR (holds your config/sessions; delete manually with 'rm -rf' if desired)"
+ fi
+
+ log_warn "Note: this script does not edit your shell rc files. To remove the"
+ log_warn "PATH entry, search for '$RC_MARKER' in ~/.bashrc / ~/.zshrc / ~/.profile"
+ log_warn "and delete the two lines under it."
+ log_ok "Uninstall complete."
+}
+
+# ============================================================================
+# Help / version
+# ============================================================================
+print_help() {
+ cat < Git ref to install (commit SHA, tag, or branch).
+ Default: ${REPO_REF}.
+ --install-dir Override the project clone + venv location.
+ Default: ${DEFAULT_INSTALL_DIR}
+ --no-venv Skip virtual-environment creation. Dependencies are
+ installed into the active system Python via
+ 'uv pip install --system'. Use in Docker images or
+ system-Python distros.
+ --no-setup Skip the post-install "next steps" pointer.
+ --debug Enable shell trace mode (set -x).
+ --dry-run Preview every change without applying it.
+ --yes, -y Assume 'yes' for any interactive prompts.
+ --log-file Tee all output (stdout + stderr) to .
+ --uninstall, -u Alias for the 'uninstall' subcommand.
+ --help, -h Show this help.
+ --version, -v Print installer version.
+
+DEFAULTS
+ Repo : ${REPO_URL}
+ Git ref : ${REPO_REF} (override with --ref)
+ Install path : ${DEFAULT_INSTALL_DIR} (override with --install-dir)
+ Python : >= ${PYTHON_MIN_VERSION} (provisioned by uv if missing)
+ Tooling : uv (Astral's package manager — installed user-local, no sudo)
+
+EXAMPLES
+ # First-time install (most common):
+ curl -fsSL https://clawcodex.app/install.sh | bash
+
+ # Pass flags through the pipe:
+ curl -fsSL https://clawcodex.app/install.sh | bash -s -- --dry-run
+
+ # If you already cloned the repo:
+ bash install.sh verify # health-check
+ bash install.sh doctor # diagnose the environment
+ bash install.sh --ref my-branch # install a specific branch/tag/commit
+ bash install.sh uninstall # remove everything this script installed
+
+EXIT CODES
+ 0 Success.
+ 1 Installation / verification / doctor found a problem.
+ 2 Invalid CLI argument (unknown flag, missing value).
+
+NOTES
+ - Re-running this script is safe: existing repos are fast-forwarded,
+ existing venvs are reused, the command wrapper is regenerated.
+ - On Windows, run from Git Bash or WSL (native cmd.exe / PowerShell are
+ detected and rejected with instructions).
+ - In non-TTY mode (piped / agent / CI), every emitted line is prefixed
+ with '[install.sh]', and a grep-friendly status line is emitted on exit.
+EOF
+}
+
+# ============================================================================
+# Install pipeline
+# ============================================================================
+install_main() {
+ echo -e "${C_BOLD}clawcodex installer v${INSTALLER_VERSION}${C_RESET}"
+ echo -e " ${C_BOLD}OS:${C_RESET} $OS"
+ echo -e " ${C_BOLD}Install dir:${C_RESET} $CLAWCODEX_HOME"
+ echo -e " ${C_BOLD}Git ref:${C_RESET} $REPO_REF"
+ echo -e " ${C_BOLD}Venv:${C_RESET} $([[ $USE_VENV -eq 1 ]] && echo "create at $CLAWCODEX_HOME/.venv" || echo "${C_YELLOW}skipped (--no-venv, system Python)${C_RESET}")"
+ if [[ "${DRY_RUN:-0}" -eq 1 ]]; then
+ echo -e " ${C_BOLD}Mode:${C_RESET} ${C_YELLOW}DRY-RUN (no changes will be made)${C_RESET}"
+ fi
+ if [[ -n "${LOG_FILE:-}" ]]; then
+ echo -e " ${C_BOLD}Log file:${C_RESET} $LOG_FILE"
+ fi
+ if [[ "${DEBUG:-0}" -eq 1 ]]; then
+ echo -e " ${C_BOLD}Debug:${C_RESET} ${C_YELLOW}ON (set -x trace)${C_RESET}"
+ fi
+
+ log_step "1/8 Checking prerequisites"
+ check_git
+
+ log_step "2/8 Installing uv (Astral, no sudo)"
+ export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
+ install_uv
+
+ log_step "3/8 Provisioning Python $PYTHON_MIN_VERSION+"
+ ensure_python
+
+ log_step "4/8 Cloning / updating repository"
+ clone_or_update_repo
+
+ log_step "5/8 $([[ $USE_VENV -eq 1 ]] && echo "Creating virtual environment" || echo "Preparing (no venv — using system Python)")"
+ create_venv
+
+ log_step "6/8 Installing dependencies"
+ install_deps
+
+ log_step "7/8 Registering global command & patching PATH"
+ register_commands
+ update_shell_rc
+
+ log_step "8/8 Building the Ink TUI client (node + dist)"
+ build_tui
+
+ echo ""
+ log_ok "Installation complete!"
+ echo ""
+ echo -e " ${C_BOLD}Installed at:${C_RESET} $CLAWCODEX_HOME"
+ echo -e " ${C_BOLD}Command at:${C_RESET} $LOCAL_BIN/clawcodex"
+ echo ""
+
+ run_post_install_setup
+ echo ""
+ log_warn "Open a new shell, or run: source ~/.bashrc (or ~/.zshrc)"
+}
+
+# ============================================================================
+# CLI argument parser
+# ============================================================================
+REF_OVERRIDE=""
+INSTALL_DIR_OVERRIDE=""
+USE_VENV=1 # --no-venv flips to 0
+RUN_SETUP=1 # --no-setup flips to 0
+DRY_RUN=0 # --dry-run flips to 1
+LOG_FILE="" # --log-file
+DEBUG=0 # --debug flips to 1 (set -x trace)
+SUBCOMMAND="" # positional verb (install/status/doctor/verify/update/uninstall/help)
+SCRIPT_START_TS=$(date +%s)
+
+print_usage_hint() {
+ echo "Try '$SELF_CMD --help' for usage." >&2
+}
+
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ install|status|doctor|verify|update|uninstall|help)
+ SUBCOMMAND="$1"; shift ;;
+ --ref)
+ [[ $# -ge 2 ]] || { log_err "--ref requires a value (commit/tag/branch)"; print_usage_hint; exit 2; }
+ REF_OVERRIDE="$2"; shift 2 ;;
+ --install-dir)
+ [[ $# -ge 2 ]] || { log_err "--install-dir requires a path"; print_usage_hint; exit 2; }
+ INSTALL_DIR_OVERRIDE="$2"; shift 2 ;;
+ --log-file)
+ [[ $# -ge 2 ]] || { log_err "--log-file requires a path"; print_usage_hint; exit 2; }
+ LOG_FILE="$2"; shift 2 ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ --yes|-y) shift ;; # accepted for ergonomics; installer is already non-interactive
+ --no-venv) USE_VENV=0; shift ;;
+ --no-setup) RUN_SETUP=0; shift ;;
+ --debug) DEBUG=1; shift ;;
+ --uninstall|-u) SUBCOMMAND="uninstall"; shift ;;
+ --help|-h) print_help; exit 0 ;;
+ --version|-v)
+ echo "install.sh v${INSTALLER_VERSION}"; exit 0 ;;
+ --) shift; break ;;
+ -*) log_err "Unknown option: $1"; print_usage_hint; exit 2 ;;
+ *) log_err "Unexpected positional argument: $1"; print_usage_hint; exit 2 ;;
+ esac
+ done
+}
+
+# ============================================================================
+# Entry point
+# ============================================================================
+# Install the EXIT trap before arg parsing so even early exits (unknown flag,
+# --version, the Windows reject) still emit the grep-friendly DONE: line.
+trap '_on_exit_summary $?' EXIT
+
+parse_args "$@"
+
+# Resolve overrides → effective paths. Must run AFTER parse_args.
+CLAWCODEX_HOME="${INSTALL_DIR_OVERRIDE:-$DEFAULT_INSTALL_DIR}"
+CLAWCODEX_PARENT_DIR="$(dirname -- "$CLAWCODEX_HOME")"
+[[ -n "$REF_OVERRIDE" ]] && REPO_REF="$REF_OVERRIDE"
+
+OS=$(detect_os)
+
+# Bail out for native Windows shells — this script targets bash, not cmd/PS.
+if [[ "$OS" == "unknown" ]] && [[ -n "${COMSPEC:-}" || -n "${WINDIR:-}" ]]; then
+ cat >&2 </dev/null || { log_warn "Cannot create log dir $log_file_dir; --log-file ignored"; LOG_FILE=""; }
+ fi
+ if [[ -n "$LOG_FILE" ]]; then
+ exec > >(tee -a "$LOG_FILE") 2>&1
+ fi
+fi
+
+# Activate debug mode (set -x) if --debug was passed.
+if [[ "$DEBUG" -eq 1 ]]; then
+ set -x
+fi
+
+# Dispatch to subcommand. Default to 'install' when none was given.
+case "${SUBCOMMAND:-install}" in
+ install) install_main ;;
+ status) cmd_status ;;
+ doctor) cmd_doctor ;;
+ verify) cmd_verify ;;
+ update) cmd_update ;;
+ uninstall) uninstall ;;
+ help) print_help ;;
+ *)
+ log_err "Unknown subcommand: $SUBCOMMAND"
+ print_usage_hint
+ exit 2
+ ;;
+esac
diff --git a/pyproject.toml b/pyproject.toml
index b0655a03a..ffcf7fd15 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "clawcodex"
-version = "0.1.0"
+version = "0.7.0"
description = "A production-oriented Python rebuild of Claude Code — real architecture, reliable CLI agent"
readme = "README.md"
license = {text = "MIT"}
@@ -26,12 +26,41 @@ classifiers = [
dependencies = [
"anthropic",
"openai",
- "zhipuai",
"python-dotenv",
"rich",
"prompt-toolkit",
"tiktoken>=0.7.0",
"textual>=0.79",
+ # YAML frontmatter parser (SKILL.md, agent files, output styles).
+ # Required for nested structures like ``hooks:`` and ``shell:`` to
+ # round-trip through ``src.skills.frontmatter.parse_frontmatter``.
+ "PyYAML>=6.0",
+ # gitwildmatch path matching for conditional-skill ``paths:`` rules.
+ # The TS port uses the `ignore` library; ``pathspec`` is the Python
+ # equivalent (full ``**`` recursion + anchoring + negation).
+ "pathspec>=0.11",
+ # Ch16 Phase 0: WebSocket client/server for the CCR bridge (Bridge v2,
+ # Direct Connect, Remote Session). The 14.0 lower bound is required for
+ # the new asyncio API's ``additional_headers`` kwarg; v12/v13 use
+ # ``extra_headers`` and would TypeError against our planned API.
+ "websockets>=14.0",
+ # Ch16 Phase 0: SSE consumer for the CCR v2 SSE read transport. ``httpx``
+ # itself is transitively available via the ``anthropic`` SDK.
+ "httpx-sse>=0.4",
+ # Official Model Context Protocol SDK used by ``src.services.mcp`` for
+ # stdio, SSE, Streamable HTTP, WebSocket, OAuth discovery, and JSON-RPC
+ # message models.
+ "mcp>=1.27.0",
+ # Image decode/resize/encode for the FileReadTool image pipeline (port
+ # of TS imageResizer.ts which uses sharp). Pillow has pure-Python wheels
+ # for all platforms and covers PNG/JPEG/GIF/WebP and palette quantization.
+ # Without this, oversized images get rejected instead of downscaled.
+ "Pillow>=10.0",
+ # HTML -> Markdown conversion for the WebFetch tool (port of TS Turndown).
+ # Preserves headings/lists/links so fetched pages are usable for research;
+ # without it WebFetch falls back to a flat regex tag-strip (no structure).
+ # Pulls in beautifulsoup4 (pure-Python).
+ "markdownify>=0.11",
]
[project.optional-dependencies]
@@ -39,6 +68,13 @@ dev = [
"build>=1.0.0",
"twine>=5.0.0",
"pytest>=8.0.0",
+ # ``pyproject.toml`` declares ``asyncio_mode = "auto"`` (below) which
+ # requires the pytest-asyncio plugin. Without it, async tests fail at
+ # run time with "async def functions are not natively supported."
+ "pytest-asyncio>=1.3.0",
+ # Ch16 Phase 0: coverage reporting for the ≥90% line-coverage DoD on
+ # the new ``src/bridge/`` modules.
+ "pytest-cov>=5.0",
]
[project.scripts]
@@ -51,7 +87,69 @@ Documentation = "https://github.com/agentforce314/clawcodex#readme"
[tool.pytest.ini_options]
asyncio_mode = "auto"
+# Prepend the project root so pytest imports the worktree's ``src/`` rather
+# than the parent repo's editable-install mapping.
+pythonpath = ["."]
+markers = [
+ "integration: marks tests as integration (deselect with '-m \"not integration\"')",
+ "linux_only: marks tests that require Linux (auto-skipped on macOS/Windows)",
+]
+filterwarnings = [
+ # Phase 18 removed src/services/bridge; the scripts/audit/remote_runtime
+ # placeholder still emits a DeprecationWarning on import (its absence
+ # would break the test that asserts the warning fires).
+ "ignore::DeprecationWarning:scripts.audit.remote_runtime",
+]
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
+
+# ---------------------------------------------------------------------------
+# import-linter — DAG-leaf enforcement for src/bootstrap/state.py
+# ---------------------------------------------------------------------------
+# Mirrors the TS ``bootstrap-isolation`` ESLint rule (typescript/src/.eslintrc).
+# Bootstrap state is a DAG leaf — it cannot import from feature subsystems
+# (``src/tui``, ``src/repl``, ``src/agent``, ``src/services``, ``src/query``,
+# ``src/context_system``, ``src/permissions``, ``src/command_system``,
+# ``src/tool_system``, ``src/coordinator``). The contract below enforces
+# this on every commit when ``lint-imports`` runs in CI.
+#
+# To run locally: ``pip install import-linter && lint-imports``.
+# (Not in ``dev`` extras yet — install ad-hoc until CI wires it in.)
+[tool.importlinter]
+root_package = "src"
+
+[[tool.importlinter.contracts]]
+name = "bootstrap/state.py cannot import from feature subsystems (DAG-leaf rule)"
+type = "forbidden"
+source_modules = ["src.bootstrap.state"]
+forbidden_modules = [
+ "src.tui",
+ "src.repl",
+ "src.agent",
+ "src.services",
+ "src.query",
+ "src.context_system",
+ "src.permissions",
+ "src.command_system",
+ "src.tool_system",
+ "src.coordinator",
+ "src.providers",
+ "src.tasks",
+ "src.bridge",
+ "src.remote",
+ "src.skills",
+]
+# ch01 round-2 P3: ``src.commands`` was removed (audit-only module relocated
+# to ``scripts/audit/commands.py``). The production command surface is
+# ``src/command_system/`` which is already covered by ``src.command_system``.
+
+# TODO: add a positive-allowlist (kept) contract restricting bootstrap to
+# import only from ``src.utils.signal`` and a small set of leaf utilities.
+# The forbidden contract above misses the case where someone adds
+# ``from src.utils.tui_helpers import foo`` to bootstrap — ``src.utils`` is
+# not in the blocklist. ``import-linter`` doesn't have a perfect positive-
+# allowlist primitive; the closest is the ``independence`` contract or a
+# custom contract. Plan §P1.0 acknowledged the ~30 min research cost.
+# Tracking as a Phase-2 follow-up.
diff --git a/requirements.txt b/requirements.txt
index 2ce8e09da..646e031f1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,13 +1,17 @@
# Core Dependencies
anthropic>=0.18.0
openai>=1.0.0
-zhipuai>=2.0.0
python-dotenv>=1.0.0
rich>=13.0.0
prompt-toolkit>=3.0.0
textual>=0.79
+PyYAML>=6.0
+pathspec>=0.11
+mcp>=1.27.0
+markdownify>=0.11
# Development Dependencies
build>=1.0.0
twine>=5.0.0
pytest>=8.0.0
+pytest-asyncio>=1.3.0
diff --git a/tests/tui/__init__.py b/scripts/audit/__init__.py
similarity index 100%
rename from tests/tui/__init__.py
rename to scripts/audit/__init__.py
diff --git a/scripts/audit/architecture_stats.py b/scripts/audit/architecture_stats.py
new file mode 100644
index 000000000..1125a07d4
--- /dev/null
+++ b/scripts/audit/architecture_stats.py
@@ -0,0 +1,205 @@
+"""Architecture-observability inspector.
+
+Chapter 18 (Epilogue) reflects on the codebase as six core abstractions and
+claims that "behavioral complexity concentrates in a small number of
+high-density files." This module produces a Python-port equivalent of that
+reading aid: for each of the six abstractions named in the book's closing,
+report the package's footprint (files, LOC, lines/file) and surface the
+high-density files (>= ``HIGH_DENSITY_THRESHOLD`` LOC) inside it.
+
+Book references:
+
+- ``claude-code-from-source/book/ch18-epilogue.md`` §Closing (line 127) —
+ enumerates the six abstractions in the order this module preserves:
+ generator loop, tools, memory, hooks, rendering engine, MCP.
+- Same file §The Cost of Complexity (line 75) — establishes the
+ "high-density files" heuristic and cites 1.7k / 4.9k / 5k LOC examples
+ from the TS reference. ``HIGH_DENSITY_THRESHOLD = 500`` is the
+ order-of-magnitude floor that catches meaningful clusters.
+
+Wired as ``python -m scripts.audit.main architecture-stats``. Importable as
+``scripts.audit.architecture_stats.build_architecture_stats``.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+# Sentinel above which a file is called out as a "high-density" file.
+# See module docstring for the rationale.
+HIGH_DENSITY_THRESHOLD = 500
+
+# Six core abstractions, in the order the book enumerates them in §Closing
+# (line 127). The order is load-bearing for the test that pins the map; do
+# not reshuffle without updating ``test_six_abstractions_present``.
+#
+# The book's fifth abstraction was the in-process "Rendering engine"
+# (``src/tui``, the Textual app). That surface was removed when the UI moved
+# to the TypeScript Ink client (``ui-tui/``), which renders against the Python
+# **agent-server** (``src/server``). The agent-server is now the Python half of
+# the UI architecture, so the slot is repointed there.
+ABSTRACTION_MAP: tuple[tuple[str, str], ...] = (
+ ("Generator loop", "src/query"),
+ ("Tools", "src/tool_system"),
+ ("Memory", "src/memdir"),
+ ("Hooks", "src/hooks"),
+ ("Agent server", "src/server"),
+ ("MCP", "src/services/mcp"),
+)
+
+# At most this many high-density files are reported per abstraction. Keeping
+# the per-section list short keeps the report readable; the threshold is the
+# real signal.
+MAX_HIGH_DENSITY_PER_ABSTRACTION = 3
+
+
+def _repo_root() -> Path:
+ # scripts/audit/architecture_stats.py -> scripts/audit -> scripts -> repo
+ return Path(__file__).resolve().parent.parent.parent
+
+
+@dataclass(frozen=True)
+class HighDensityFile:
+ """A single >= HIGH_DENSITY_THRESHOLD LOC file inside an abstraction."""
+
+ relative_path: str
+ line_count: int
+
+
+@dataclass(frozen=True)
+class AbstractionStats:
+ """Per-abstraction footprint."""
+
+ name: str
+ package: str
+ file_count: int
+ line_count: int
+ high_density_files: tuple[HighDensityFile, ...]
+
+ @property
+ def lines_per_file(self) -> float:
+ if self.file_count == 0:
+ return 0.0
+ return self.line_count / self.file_count
+
+
+@dataclass(frozen=True)
+class ArchitectureStats:
+ """The full six-abstraction report."""
+
+ abstractions: tuple[AbstractionStats, ...]
+ total_files: int
+ total_lines: int
+
+ def as_markdown(self) -> str:
+ return _render_markdown(self)
+
+
+def _walk_python_files(package_dir: Path) -> list[Path]:
+ if not package_dir.exists():
+ return []
+ return sorted(p for p in package_dir.rglob("*.py") if p.is_file())
+
+
+def _count_lines(path: Path) -> int:
+ try:
+ with path.open("rb") as fh:
+ return sum(1 for _ in fh)
+ except OSError:
+ return 0
+
+
+def _build_abstraction(name: str, package_rel: str, root: Path) -> AbstractionStats:
+ package_dir = root / package_rel
+ files = _walk_python_files(package_dir)
+ sized: list[tuple[Path, int]] = [(p, _count_lines(p)) for p in files]
+ total_lines = sum(lc for _, lc in sized)
+
+ high = sorted(
+ (
+ HighDensityFile(
+ relative_path=str(p.relative_to(root)),
+ line_count=lc,
+ )
+ for p, lc in sized
+ if lc >= HIGH_DENSITY_THRESHOLD
+ ),
+ key=lambda f: f.line_count,
+ reverse=True,
+ )
+
+ return AbstractionStats(
+ name=name,
+ package=package_rel,
+ file_count=len(files),
+ line_count=total_lines,
+ high_density_files=tuple(high[:MAX_HIGH_DENSITY_PER_ABSTRACTION]),
+ )
+
+
+def build_architecture_stats(root: Path | None = None) -> ArchitectureStats:
+ """Build the six-abstraction report.
+
+ ``root`` defaults to the repo root; passing an explicit value lets tests
+ point at a fixture tree.
+ """
+
+ repo_root = root if root is not None else _repo_root()
+ abstractions = tuple(
+ _build_abstraction(name, package_rel, repo_root)
+ for name, package_rel in ABSTRACTION_MAP
+ )
+ return ArchitectureStats(
+ abstractions=abstractions,
+ total_files=sum(a.file_count for a in abstractions),
+ total_lines=sum(a.line_count for a in abstractions),
+ )
+
+
+def _render_markdown(stats: ArchitectureStats) -> str:
+ lines: list[str] = []
+ lines.append("# Architecture Stats")
+ lines.append("")
+ lines.append(
+ "The book's epilogue identifies six core abstractions "
+ "(ch18 §Closing). This is their Python port footprint."
+ )
+ lines.append("")
+ lines.append("| # | Abstraction | Package | Files | Lines | Lines/file |")
+ lines.append("|---|---|---|---:|---:|---:|")
+ for idx, abstraction in enumerate(stats.abstractions, start=1):
+ lines.append(
+ f"| {idx} | {abstraction.name} | `{abstraction.package}` | "
+ f"{abstraction.file_count} | {abstraction.line_count} | "
+ f"{abstraction.lines_per_file:.0f} |"
+ )
+ lines.append("")
+ lines.append(
+ f"**Totals:** {stats.total_files} files, {stats.total_lines} lines "
+ f"across the six abstractions."
+ )
+ lines.append("")
+ lines.append(f"## High-density files (>= {HIGH_DENSITY_THRESHOLD} LOC)")
+ lines.append("")
+ lines.append(
+ "Per the chapter's claim that complexity concentrates in a small "
+ "number of files (ch18 §The Cost of Complexity)."
+ )
+ lines.append("")
+ any_dense = False
+ for abstraction in stats.abstractions:
+ if not abstraction.high_density_files:
+ continue
+ any_dense = True
+ lines.append(f"### {abstraction.name}")
+ for hd in abstraction.high_density_files:
+ lines.append(f"- `{hd.relative_path}` — {hd.line_count} lines")
+ lines.append("")
+ if not any_dense:
+ lines.append(
+ "_No files meet the high-density threshold; "
+ "all abstractions are evenly distributed._"
+ )
+ lines.append("")
+ return "\n".join(lines).rstrip() + "\n"
diff --git a/src/bootstrap_graph.py b/scripts/audit/bootstrap_graph.py
similarity index 100%
rename from src/bootstrap_graph.py
rename to scripts/audit/bootstrap_graph.py
diff --git a/src/command_graph.py b/scripts/audit/command_graph.py
similarity index 96%
rename from src/command_graph.py
rename to scripts/audit/command_graph.py
index 20ef85931..384cdfa49 100644
--- a/src/command_graph.py
+++ b/scripts/audit/command_graph.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass
from .commands import get_commands
-from .models import PortingModule
+from .legacy_porting_types import PortingModule
@dataclass(frozen=True)
diff --git a/src/commands.py b/scripts/audit/commands.py
similarity index 87%
rename from src/commands.py
rename to scripts/audit/commands.py
index b327977a4..bc904daef 100644
--- a/src/commands.py
+++ b/scripts/audit/commands.py
@@ -5,9 +5,12 @@
from functools import lru_cache
from pathlib import Path
-from .models import PortingBacklog, PortingModule
+from .legacy_porting_types import PortingBacklog, PortingModule
-SNAPSHOT_PATH = Path(__file__).resolve().parent / 'reference_data' / 'commands_snapshot.json'
+# ch01 round-2 P3: reference_data stays at src/reference_data/ because
+# ~20 production subsystem __init__.py files load JSON snapshots from there.
+# scripts/audit/commands.py -> scripts/audit -> scripts -> ; then /src/reference_data
+SNAPSHOT_PATH = Path(__file__).resolve().parent.parent.parent / 'src' / 'reference_data' / 'commands_snapshot.json'
@dataclass(frozen=True)
diff --git a/src/context.py b/scripts/audit/context.py
similarity index 100%
rename from src/context.py
rename to scripts/audit/context.py
diff --git a/src/direct_modes.py b/scripts/audit/direct_modes.py
similarity index 100%
rename from src/direct_modes.py
rename to scripts/audit/direct_modes.py
diff --git a/src/execution_registry.py b/scripts/audit/execution_registry.py
similarity index 100%
rename from src/execution_registry.py
rename to scripts/audit/execution_registry.py
diff --git a/src/repl.py b/scripts/audit/legacy_cli_repl.py
similarity index 98%
rename from src/repl.py
rename to scripts/audit/legacy_cli_repl.py
index 2030191d6..5369c1f25 100644
--- a/src/repl.py
+++ b/scripts/audit/legacy_cli_repl.py
@@ -67,7 +67,7 @@ def print_banner(self):
"""Print welcome banner."""
if HAS_RICH:
self.console.print(Panel.fit(
- "[bold cyan]Claw Codex[/bold cyan]\n"
+ "[bold cyan]ClawCodex[/bold cyan]\n"
"[dim]A complete reimplementation of Claude Code[/dim]",
subtitle="Interactive Mode • Type 'help' for commands",
border_style="round",
@@ -75,7 +75,7 @@ def print_banner(self):
else:
banner = f"""
{self._colorize('╔═══════════════════════════════════════════════════════════╔', 'cyan')}
-{self._colorize('║', 'cyan')} {self._colorize('Claw Codex', 'bold')} - Claude Code Reimplementation {self._colorize('║', 'cyan')}
+{self._colorize('║', 'cyan')} {self._colorize('ClawCodex', 'bold')} - Claude Code Reimplementation {self._colorize('║', 'cyan')}
{self._colorize('║', 'cyan')} Type "help" for commands • Interactive Mode {self._colorize('║', 'cyan')}
{self._colorize('╚═══════════════════════════════════════════════════════════╛', 'cyan')}
"""
diff --git a/src/models.py b/scripts/audit/legacy_porting_types.py
similarity index 58%
rename from src/models.py
rename to scripts/audit/legacy_porting_types.py
index d9fc2773b..4595c23c2 100644
--- a/src/models.py
+++ b/scripts/audit/legacy_porting_types.py
@@ -1,3 +1,11 @@
+"""Legacy porting-workbench types.
+
+Originally defined in the (long-deleted) top-level ``src/models.py``,
+then parked in ``src/models/__init__.py`` "for backward compatibility
+with src/commands.py etc." — modules that ch01 round-2 (P3) relocated
+into this package. ch01 round-3 completes the move: these types are
+audit-only scaffolding and have no production consumers.
+"""
from __future__ import annotations
from dataclasses import dataclass, field
@@ -16,7 +24,7 @@ class PortingModule:
name: str
responsibility: str
source_hint: str
- status: str = 'planned'
+ status: str = "planned"
@dataclass(frozen=True)
@@ -30,7 +38,7 @@ class UsageSummary:
input_tokens: int = 0
output_tokens: int = 0
- def add_turn(self, prompt: str, output: str) -> 'UsageSummary':
+ def add_turn(self, prompt: str, output: str) -> "UsageSummary":
return UsageSummary(
input_tokens=self.input_tokens + len(prompt.split()),
output_tokens=self.output_tokens + len(output.split()),
@@ -44,6 +52,6 @@ class PortingBacklog:
def summary_lines(self) -> list[str]:
return [
- f'- {module.name} [{module.status}] — {module.responsibility} (from {module.source_hint})'
- for module in self.modules
+ f"- {m.name} [{m.status}] — {m.responsibility} (from {m.source_hint})"
+ for m in self.modules
]
diff --git a/src/main.py b/scripts/audit/main.py
similarity index 95%
rename from src/main.py
rename to scripts/audit/main.py
index e1fa9eda1..2c3efe19e 100644
--- a/src/main.py
+++ b/scripts/audit/main.py
@@ -2,12 +2,13 @@
import argparse
+from .architecture_stats import build_architecture_stats
from .bootstrap_graph import build_bootstrap_graph
from .command_graph import build_command_graph
from .commands import execute_command, get_command, get_commands, render_command_index
from .direct_modes import run_deep_link, run_direct_connect
from .parity_audit import run_parity_audit
-from .permissions import ToolPermissionContext
+from src.permissions import ToolPermissionContext
from .port_manifest import build_port_manifest
from .query_engine import QueryEnginePort
from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode
@@ -28,6 +29,11 @@ def build_parser() -> argparse.ArgumentParser:
subparsers.add_parser('command-graph', help='show command graph segmentation')
subparsers.add_parser('tool-pool', help='show assembled tool pool with default settings')
subparsers.add_parser('bootstrap-graph', help='show the mirrored bootstrap/runtime graph stages')
+ subparsers.add_parser(
+ 'architecture-stats',
+ help="map the book's six core abstractions (ch18 §Closing) to their "
+ "Python package footprints and surface high-density files",
+ )
list_parser = subparsers.add_parser('subsystems', help='list the current Python modules in the workspace')
list_parser.add_argument('--limit', type=int, default=32)
@@ -116,6 +122,9 @@ def main(argv: list[str] | None = None) -> int:
if args.command == 'bootstrap-graph':
print(build_bootstrap_graph().as_markdown())
return 0
+ if args.command == 'architecture-stats':
+ print(build_architecture_stats().as_markdown())
+ return 0
if args.command == 'subsystems':
for subsystem in manifest.top_level_modules[: args.limit]:
print(f'{subsystem.name}\t{subsystem.file_count}\t{subsystem.notes}')
diff --git a/scripts/audit/parity_audit.py b/scripts/audit/parity_audit.py
new file mode 100644
index 000000000..5ea99432a
--- /dev/null
+++ b/scripts/audit/parity_audit.py
@@ -0,0 +1,224 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+
+# ch01 round-2 P3: this module was relocated from src/parity_audit.py to
+# scripts/audit/parity_audit.py. The audit still measures the production
+# tree at /src, while its own reference_data sits alongside it.
+_REPO_ROOT = Path(__file__).resolve().parent.parent.parent # scripts/audit -> scripts -> repo root
+ARCHIVE_ROOT = _REPO_ROOT / 'archive' / 'claude_code_ts_snapshot' / 'src'
+CURRENT_ROOT = _REPO_ROOT / 'src' # what we audit (NOT where we live)
+# Reference data stays at src/reference_data/ because the production
+# subsystem __init__.py files (src/bridge, src/server, ~20 more) read
+# their JSON snapshots from there.
+_DATA_ROOT = CURRENT_ROOT / 'reference_data'
+REFERENCE_SURFACE_PATH = _DATA_ROOT / 'archive_surface_snapshot.json'
+COMMAND_SNAPSHOT_PATH = _DATA_ROOT / 'commands_snapshot.json'
+TOOL_SNAPSHOT_PATH = _DATA_ROOT / 'tools_snapshot.json'
+
+ARCHIVE_ROOT_FILES = {
+ # ch01 round-2 P3: legacy ``src/QueryEngine.py`` shim deleted —
+ # ``QueryEngineRuntime`` had zero callers. Production ``QueryEngine``
+ # lives at ``src/query/engine.py``; this row redirects to the
+ # ``query`` package (same pattern as the ``query.ts`` row below).
+ 'QueryEngine.ts': 'query',
+ # Chapter-10 refactor (Chunk B / WI-1.1): the TS ``Task.ts`` file now maps
+ # to ``src/tasks_core.py`` (TaskType union, TaskStatus union, TaskStateBase,
+ # ``is_terminal_task_status``, ``generate_task_id``). The legacy stub at
+ # ``src/task.py`` remains for the moment as a re-export shim (PortingTask).
+ 'Task.ts': 'tasks_core.py',
+ # ch01 refactor (P2.3): the legacy ``src/Tool.py`` stub
+ # (a 15-line placeholder dataclass with zero import consumers) was
+ # deleted. The canonical tool interface lives at
+ # ``src/tool_system/build_tool.py``; the rest of the tool surface
+ # lives in the ``src/tool_system/`` package. Mapped to the package
+ # directory name (same pattern as ``'tasks.ts': 'task_registry.py'``
+ # below and ``'dialogLaunchers.tsx': 'tui'`` above).
+ 'Tool.ts': 'tool_system',
+ # ch01 round-2 P3: ``src/commands.py`` was an audit snapshot, relocated
+ # to ``scripts/audit/commands.py``. The production command surface lives
+ # under the ``src/command_system/`` package.
+ 'commands.ts': 'command_system',
+ # ch01 round-2 P3: ``src/context.py`` was audit scaffolding, relocated
+ # to ``scripts/audit/context.py``. Production context lives at
+ # ``src/context_system/``.
+ 'context.ts': 'context_system',
+ 'cost-tracker.ts': 'cost_tracker.py',
+ 'costHook.ts': 'costHook.py',
+ # Chapter-13 Phase-0 hygiene: ``dialogLaunchers.tsx``, ``ink.ts``, and
+ # ``interactiveHelpers.tsx`` map to the ``src/tui/`` package (Textual
+ # screens / app / a11y), where their behavioral equivalents live. The
+ # previous flat ``src/{ink, dialogLaunchers, interactiveHelpers}.py``
+ # stubs were decoys with no callers and were deleted in WI-0.1 / WI-0.4.
+ # All three map to the same target (``tui``) because the audit checks
+ # only immediate children of ``src/``; the per-feature breakdown lives
+ # at ``my-docs/ch13-terminal-ui-gap-analysis.md`` §4 (Concrete Reference
+ # Index). See gap #11 / #12 for the rationale and the refactoring plan
+ # Phase 0 for the audit + deletion record.
+ 'dialogLaunchers.tsx': 'tui',
+ 'history.ts': 'history.py',
+ 'ink.ts': 'tui',
+ 'interactiveHelpers.tsx': 'tui',
+ # ch01 round-2 P3: ``src/main.py`` was the audit CLI, relocated to
+ # ``scripts/audit/main.py``. Production entry is ``src/cli.py``
+ # (``pyproject.toml`` console-script).
+ 'main.tsx': 'cli.py',
+ 'projectOnboardingState.ts': 'projectOnboardingState.py',
+ # ch01 refactor (P2.3): the legacy ``src/query.py`` stub
+ # (a 13-line placeholder pair of request/response dataclasses)
+ # was shadowed by ``src/query/__init__.py`` and had zero
+ # functional consumers. Deleted. Canonical async-generator query
+ # loop lives at ``src/query/query.py``; the rest of the loop's
+ # components live in the ``src/query/`` package.
+ 'query.ts': 'query',
+ 'replLauncher.tsx': 'entrypoints/tui_launcher.py', # Ink-TUI launcher replaced the Python REPL launcher
+ # ch01 round-2 P3: ``src/setup.py`` was audit scaffolding, relocated to
+ # ``scripts/audit/setup.py``. Production initialization lives under
+ # ``src/bootstrap/``.
+ 'setup.ts': 'bootstrap',
+ # Chapter-10 refactor (Chunk B / WI-1.0): the old ``tasks.py`` flat file
+ # was deleted in favor of a real ``src/tasks/`` package. The TS root file
+ # ``tasks.ts`` now maps to ``src/task_registry.py`` (which holds
+ # ``RuntimeTaskRegistry``, ``Task`` Protocol, and ``get_all_tasks``).
+ 'tasks.ts': 'task_registry.py',
+ # ch01 round-2 P3: ``src/tools.py`` was a ``PortingModule`` snapshot,
+ # relocated to ``scripts/audit/tools.py``. Production tools live under
+ # the ``src/tool_system/`` package (same target as ``Tool.ts`` above).
+ 'tools.ts': 'tool_system',
+}
+
+ARCHIVE_DIR_MAPPINGS = {
+ 'assistant': 'assistant',
+ 'bootstrap': 'bootstrap',
+ 'bridge': 'bridge',
+ 'buddy': 'buddy',
+ # ch01 refactor (P2.3a): the production entry point
+ # (``pyproject.toml:68`` console-script ``clawcodex = "src.cli:main"``)
+ # is the file ``src/cli.py``, not a ``src/cli/`` directory. The
+ # earlier ``'cli': 'cli'`` row was a pre-existing baseline miss —
+ # ``run_parity_audit()`` reported it under
+ # ``missing_directory_targets``. Aligns with the existing
+ # ``'commands': 'commands.py'`` / ``'context': 'context.py'`` /
+ # ``'tools': 'tools.py'`` pattern.
+ 'cli': 'cli.py',
+ # ch01 round-2 P3: the audit ``src/commands.py`` snapshot was relocated
+ # to ``scripts/audit/commands.py``; production commands live under
+ # ``src/command_system/``.
+ 'commands': 'command_system',
+ 'components': 'components',
+ 'constants': 'constants',
+ # ch01 round-2 P3: the audit ``src/context.py`` scaffolding was relocated
+ # to ``scripts/audit/context.py``; production context lives under
+ # ``src/context_system/``.
+ 'context': 'context_system',
+ 'coordinator': 'coordinator',
+ 'entrypoints': 'entrypoints',
+ 'hooks': 'hooks',
+ # Chapter-13 Phase-0 hygiene: the TS ``ink/`` directory's renderer
+ # (custom DOM, Yoga, packed cells, blit, BSU/ESU) is correctly delegated
+ # to Textual + Rich; the ``src/ink.py`` decoy was deleted in WI-0.1.
+ # The user-facing surface lives at ``src/tui/`` (Textual app + widgets).
+ # See ``my-docs/ch13-terminal-ui-gap-analysis.md`` gap #11 and the
+ # ``Out of Scope`` block in the refactoring plan.
+ 'ink': 'tui',
+ 'keybindings': 'keybindings',
+ 'memdir': 'memdir',
+ 'migrations': 'migrations',
+ 'moreright': 'moreright',
+ 'native-ts': 'native_ts',
+ 'outputStyles': 'outputStyles',
+ 'plugins': 'plugins',
+ # ch01 refactor (P2.3): TS ``query/`` directory → Python ``src/query/``
+ # package (was pointing at the deleted ``src/query.py`` decoy).
+ 'query': 'query',
+ 'remote': 'remote',
+ 'schemas': 'schemas',
+ 'screens': 'screens',
+ 'server': 'server',
+ 'services': 'services',
+ 'skills': 'skills',
+ 'state': 'state',
+ # Chapter-10 refactor (Chunk B / WI-1.0): TS ``tasks/`` directory now
+ # maps to a real ``src/tasks/`` Python package (was a flat
+ # ``src/tasks.py`` stub before the refactor).
+ 'tasks': 'tasks',
+ # ch01 round-2 P3: production tools live under ``src/tool_system/``;
+ # the legacy audit snapshot at ``src/tools.py`` was relocated to
+ # ``scripts/audit/tools.py``.
+ 'tools': 'tool_system',
+ 'types': 'types',
+ 'upstreamproxy': 'upstreamproxy',
+ 'utils': 'utils',
+ 'vim': 'vim',
+ 'voice': 'voice',
+}
+
+
+@dataclass(frozen=True)
+class ParityAuditResult:
+ archive_present: bool
+ root_file_coverage: tuple[int, int]
+ directory_coverage: tuple[int, int]
+ total_file_ratio: tuple[int, int]
+ command_entry_ratio: tuple[int, int]
+ tool_entry_ratio: tuple[int, int]
+ missing_root_targets: tuple[str, ...]
+ missing_directory_targets: tuple[str, ...]
+
+ def to_markdown(self) -> str:
+ lines = ['# Parity Audit']
+ if not self.archive_present:
+ lines.append('Local archive unavailable; parity audit cannot compare against the original snapshot.')
+ return '\n'.join(lines)
+
+ lines.extend([
+ '',
+ f'Root file coverage: **{self.root_file_coverage[0]}/{self.root_file_coverage[1]}**',
+ f'Directory coverage: **{self.directory_coverage[0]}/{self.directory_coverage[1]}**',
+ f'Total Python files vs archived TS-like files: **{self.total_file_ratio[0]}/{self.total_file_ratio[1]}**',
+ f'Command entry coverage: **{self.command_entry_ratio[0]}/{self.command_entry_ratio[1]}**',
+ f'Tool entry coverage: **{self.tool_entry_ratio[0]}/{self.tool_entry_ratio[1]}**',
+ '',
+ 'Missing root targets:',
+ ])
+ if self.missing_root_targets:
+ lines.extend(f'- {item}' for item in self.missing_root_targets)
+ else:
+ lines.append('- none')
+
+ lines.extend(['', 'Missing directory targets:'])
+ if self.missing_directory_targets:
+ lines.extend(f'- {item}' for item in self.missing_directory_targets)
+ else:
+ lines.append('- none')
+ return '\n'.join(lines)
+
+
+def _reference_surface() -> dict[str, object]:
+ return json.loads(REFERENCE_SURFACE_PATH.read_text())
+
+
+def _snapshot_count(path: Path) -> int:
+ return len(json.loads(path.read_text()))
+
+
+def run_parity_audit() -> ParityAuditResult:
+ current_entries = {path.name for path in CURRENT_ROOT.iterdir()}
+ root_hits = [target for target in ARCHIVE_ROOT_FILES.values() if target in current_entries]
+ dir_hits = [target for target in ARCHIVE_DIR_MAPPINGS.values() if target in current_entries]
+ missing_roots = tuple(target for target in ARCHIVE_ROOT_FILES.values() if target not in current_entries)
+ missing_dirs = tuple(target for target in ARCHIVE_DIR_MAPPINGS.values() if target not in current_entries)
+ current_python_files = sum(1 for path in CURRENT_ROOT.rglob('*.py') if path.is_file())
+ reference = _reference_surface()
+ return ParityAuditResult(
+ archive_present=ARCHIVE_ROOT.exists(),
+ root_file_coverage=(len(root_hits), len(ARCHIVE_ROOT_FILES)),
+ directory_coverage=(len(dir_hits), len(ARCHIVE_DIR_MAPPINGS)),
+ total_file_ratio=(current_python_files, int(reference['total_ts_like_files'])),
+ command_entry_ratio=(_snapshot_count(COMMAND_SNAPSHOT_PATH), int(reference['command_entry_count'])),
+ tool_entry_ratio=(_snapshot_count(TOOL_SNAPSHOT_PATH), int(reference['tool_entry_count'])),
+ missing_root_targets=missing_roots,
+ missing_directory_targets=missing_dirs,
+ )
diff --git a/src/port_manifest.py b/scripts/audit/port_manifest.py
similarity index 89%
rename from src/port_manifest.py
rename to scripts/audit/port_manifest.py
index 8c567e91c..67b4ff172 100644
--- a/src/port_manifest.py
+++ b/scripts/audit/port_manifest.py
@@ -4,9 +4,10 @@
from dataclasses import dataclass
from pathlib import Path
-from .models import Subsystem
+from .legacy_porting_types import Subsystem
-DEFAULT_SRC_ROOT = Path(__file__).resolve().parent
+# scripts/audit/port_manifest.py → scripts/audit → scripts → ; then /src
+DEFAULT_SRC_ROOT = Path(__file__).resolve().parent.parent.parent / 'src'
@dataclass(frozen=True)
diff --git a/src/query_engine.py b/scripts/audit/query_engine.py
similarity index 99%
rename from src/query_engine.py
rename to scripts/audit/query_engine.py
index bb14c2e3a..6bb4bf78d 100644
--- a/src/query_engine.py
+++ b/scripts/audit/query_engine.py
@@ -5,7 +5,7 @@
from uuid import uuid4
from .commands import build_command_backlog
-from .models import PermissionDenial, UsageSummary
+from .legacy_porting_types import PermissionDenial, UsageSummary
from .port_manifest import PortManifest, build_port_manifest
from .session_store import StoredSession, load_session, save_session
from .tools import build_tool_backlog
diff --git a/src/remote_runtime.py b/scripts/audit/remote_runtime.py
similarity index 55%
rename from src/remote_runtime.py
rename to scripts/audit/remote_runtime.py
index 9dab99974..2ea297124 100644
--- a/src/remote_runtime.py
+++ b/scripts/audit/remote_runtime.py
@@ -1,7 +1,23 @@
+"""DEPRECATED placeholder for remote/SSH/teleport modes.
+
+The ``run_remote_mode``/``run_ssh_mode``/``run_teleport_mode`` callers
+return canned strings; real CCR remote-execution lives at ``src/remote/``
+and ``src/bridge/`` per ``my-docs/ch16-remote-refactoring-plan.md``.
+
+WI-4.5 (RESERVED, post-Phase-4 cleanup) rewrites ``run_remote_mode`` to
+delegate to ``RemoteSessionManager`` once the public API stabilizes.
+"""
from __future__ import annotations
+import warnings
from dataclasses import dataclass
+warnings.warn(
+ 'scripts.audit.remote_runtime is a placeholder; CCR remote execution lives at src/remote/ and src/bridge/.',
+ DeprecationWarning,
+ stacklevel=2,
+)
+
@dataclass(frozen=True)
class RuntimeModeReport:
diff --git a/src/runtime.py b/scripts/audit/runtime.py
similarity index 98%
rename from src/runtime.py
rename to scripts/audit/runtime.py
index c4116b7aa..f4fd57ebf 100644
--- a/src/runtime.py
+++ b/scripts/audit/runtime.py
@@ -4,8 +4,8 @@
from .commands import PORTED_COMMANDS
from .context import PortContext, build_port_context, render_context
-from .history import HistoryLog
-from .models import PermissionDenial, PortingModule
+from src.history import HistoryLog
+from .legacy_porting_types import PermissionDenial, PortingModule
from .query_engine import QueryEngineConfig, QueryEnginePort, TurnResult
from .setup import SetupReport, WorkspaceSetup, run_setup
from .system_init import build_system_init_message
diff --git a/src/session_store.py b/scripts/audit/session_store.py
similarity index 100%
rename from src/session_store.py
rename to scripts/audit/session_store.py
diff --git a/src/setup.py b/scripts/audit/setup.py
similarity index 62%
rename from src/setup.py
rename to scripts/audit/setup.py
index b8e5a4da5..81f16a450 100644
--- a/src/setup.py
+++ b/scripts/audit/setup.py
@@ -5,8 +5,43 @@
from dataclasses import dataclass
from pathlib import Path
-from .deferred_init import DeferredInitResult, run_deferred_init
-from .prefetch import PrefetchResult, start_keychain_prefetch, start_mdm_raw_read, start_project_scan
+from src.prefetch import (
+ PrefetchResult,
+ get_or_start_keychain_prefetch,
+ get_or_start_mdm_raw_read,
+ start_project_scan,
+)
+
+
+# ch02 round-3: relocated from src/deferred_init.py — this flags
+# snapshot is audit-manifest scaffolding; the production module now
+# implements the real deferred-prefetch lane (start_deferred_prefetches).
+@dataclass(frozen=True)
+class DeferredInitResult:
+ trusted: bool
+ plugin_init: bool
+ skill_init: bool
+ mcp_prefetch: bool
+ session_hooks: bool
+
+ def as_lines(self) -> tuple[str, ...]:
+ return (
+ f'- plugin_init={self.plugin_init}',
+ f'- skill_init={self.skill_init}',
+ f'- mcp_prefetch={self.mcp_prefetch}',
+ f'- session_hooks={self.session_hooks}',
+ )
+
+
+def run_deferred_init(trusted: bool) -> DeferredInitResult:
+ enabled = bool(trusted)
+ return DeferredInitResult(
+ trusted=trusted,
+ plugin_init=enabled,
+ skill_init=enabled,
+ mcp_prefetch=enabled,
+ session_hooks=enabled,
+ )
@dataclass(frozen=True)
@@ -63,9 +98,11 @@ def build_workspace_setup() -> WorkspaceSetup:
def run_setup(cwd: Path | None = None, trusted: bool = True) -> SetupReport:
root = cwd or Path(__file__).resolve().parent.parent
+ # WI-4.1: singleton getters. ``cli.py`` may have already fired these
+ # at module import time; we reuse those handles instead of re-spawning.
prefetches = [
- start_mdm_raw_read(),
- start_keychain_prefetch(),
+ get_or_start_mdm_raw_read(),
+ get_or_start_keychain_prefetch(),
start_project_scan(root),
]
return SetupReport(
diff --git a/src/system_init.py b/scripts/audit/system_init.py
similarity index 100%
rename from src/system_init.py
rename to scripts/audit/system_init.py
diff --git a/src/tool_pool.py b/scripts/audit/tool_pool.py
similarity index 90%
rename from src/tool_pool.py
rename to scripts/audit/tool_pool.py
index 428a35e58..33c4acbbc 100644
--- a/src/tool_pool.py
+++ b/scripts/audit/tool_pool.py
@@ -2,8 +2,8 @@
from dataclasses import dataclass
-from .models import PortingModule
-from .permissions import ToolPermissionContext
+from .legacy_porting_types import PortingModule
+from src.permissions import ToolPermissionContext
from .tools import get_tools
diff --git a/src/tools.py b/scripts/audit/tools.py
similarity index 90%
rename from src/tools.py
rename to scripts/audit/tools.py
index 580a078b3..44a9d5f7f 100644
--- a/src/tools.py
+++ b/scripts/audit/tools.py
@@ -5,10 +5,11 @@
from functools import lru_cache
from pathlib import Path
-from .models import PortingBacklog, PortingModule
-from .permissions import ToolPermissionContext
+from .legacy_porting_types import PortingBacklog, PortingModule
+from src.permissions import ToolPermissionContext
-SNAPSHOT_PATH = Path(__file__).resolve().parent / 'reference_data' / 'tools_snapshot.json'
+# ch01 round-2 P3: reference_data stays at src/reference_data/ (see commands.py note).
+SNAPSHOT_PATH = Path(__file__).resolve().parent.parent.parent / 'src' / 'reference_data' / 'tools_snapshot.json'
@dataclass(frozen=True)
diff --git a/src/transcript.py b/scripts/audit/transcript.py
similarity index 100%
rename from src/transcript.py
rename to scripts/audit/transcript.py
diff --git a/scripts/run_skill_smoke.py b/scripts/run_skill_smoke.py
new file mode 100644
index 000000000..dc1711cea
--- /dev/null
+++ b/scripts/run_skill_smoke.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+"""Manual smoke-test runner for the QA-2 example skills.
+
+Loads every skill under ``tests/fixtures/skills/`` (plus the bundled
+catalogue from DEV-5), prints a listing that mirrors what the model
+would see in its system-reminder, and then invokes each in turn so you
+can eyeball the rendered prompt.
+
+Useful as:
+
+ - A manual artifact for the user / team-lead ("here's what skills
+ look like once they hit the model").
+ - A quick smoke-check that the disk → registry → SkillTool pipeline
+ is wired correctly without spinning up a real REPL.
+
+Run from the repo root:
+
+ python scripts/run_skill_smoke.py
+
+No arguments. Output goes to stdout.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+
+# Make the repo root importable when the script is launched from a
+# subdirectory.
+REPO_ROOT = Path(__file__).resolve().parent.parent
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+
+from src.skills.bundled import init_bundled_skills # noqa: E402
+from src.skills.bundled_skills import clear_bundled_skills # noqa: E402
+from src.skills.loader import ( # noqa: E402
+ activate_conditional_skills_for_paths,
+ clear_dynamic_skills,
+ clear_skill_caches,
+ clear_skill_registry,
+ get_all_skills,
+)
+from src.tool_system.context import ToolContext # noqa: E402
+from src.tool_system.tools import SkillTool # noqa: E402
+
+
+FIXTURES_ROOT = REPO_ROOT / "tests" / "fixtures" / "skills"
+FIXTURE_NAMES = ("commit-helper", "frontend", "lint-py")
+PREVIEW_CHARS = 200
+
+
+def _build_workspace(tmp: Path) -> Path:
+ """Construct a workspace whose ``.claude/skills/`` mirrors the
+ fixture catalogue. Returns the workspace root."""
+ project = tmp / "proj"
+ skills_root = project / ".claude" / "skills"
+ skills_root.mkdir(parents=True)
+ for name in FIXTURE_NAMES:
+ shutil.copytree(FIXTURES_ROOT / name, skills_root / name)
+ return project
+
+
+def _isolate_env(tmp: Path) -> None:
+ """Strip every env knob that would inject non-fixture skill dirs."""
+ fake_home = tmp / "home"
+ fake_home.mkdir()
+ os.environ["HOME"] = str(fake_home)
+ os.environ["CLAUDE_MANAGED_CONFIG_DIR"] = str(tmp / "managed")
+ for var in (
+ "CLAUDE_CONFIG_DIR",
+ "CLAWCODEX_SKILLS_DIR",
+ "CLAUDE_SKILLS_DIR",
+ "CLAWCODEX_MANAGED_SKILLS_DIR",
+ "CLAUDE_CODE_BARE_MODE",
+ "CLAUDE_CODE_DISABLE_POLICY_SKILLS",
+ "CLAUDE_CODE_ADDITIONAL_DIRECTORIES",
+ ):
+ os.environ.pop(var, None)
+
+
+def _print_section(title: str) -> None:
+ bar = "=" * 72
+ print(f"\n{bar}\n{title}\n{bar}")
+
+
+def _print_listing(project: Path) -> None:
+ skills = get_all_skills(project_root=project)
+ _print_section(f"Available skills ({len(skills)}, unconditional only)")
+ print(f"{'name':<30} {'source':<10} description")
+ print("-" * 72)
+ for s in sorted(skills, key=lambda x: x.name):
+ desc = s.description or ""
+ if len(desc) > 60:
+ desc = desc[:57] + "..."
+ print(f"{s.name:<30} {s.loaded_from:<10} {desc}")
+
+
+def _print_invocation(
+ skill_name: str, args: str, ctx: ToolContext
+) -> None:
+ result = SkillTool.call({"skill": skill_name, "args": args}, ctx)
+ out = result.output
+ head = f"--- /{skill_name}"
+ if args:
+ head += f' "{args}"'
+ head += " ---"
+ print(f"\n{head}")
+ if not out.get("success"):
+ print(f" ERROR: {out}")
+ return
+ prompt = out["prompt"]
+ preview = prompt if len(prompt) <= PREVIEW_CHARS else (
+ prompt[:PREVIEW_CHARS] + f"\n... [truncated, total {len(prompt)} chars]"
+ )
+ for line in preview.splitlines():
+ print(f" {line}")
+ if out.get("loadedFrom"):
+ print(f" [loadedFrom={out['loadedFrom']}, allowedTools={out.get('allowedTools')}]")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp_str:
+ tmp = Path(tmp_str)
+ _isolate_env(tmp)
+
+ # Reset every cache + register the bundled catalogue.
+ clear_skill_caches()
+ clear_dynamic_skills()
+ clear_skill_registry()
+ clear_bundled_skills()
+ init_bundled_skills()
+
+ project = _build_workspace(tmp)
+ ctx = ToolContext(workspace_root=project)
+ ctx.session_id = "S-smoke-001"
+
+ _print_listing(project)
+
+ _print_section("Invocations (first 200 chars of each rendered prompt)")
+ # commit-helper — substitutions + args
+ _print_invocation("commit-helper", "feat", ctx)
+
+ # frontend:add-component — namespaced + named arg
+ _print_invocation("frontend:add-component", "Button", ctx)
+
+ # simplify — bundled skill (no base-dir header)
+ _print_invocation("simplify", "focus on caching", ctx)
+
+ # lint-py — conditional skill: activate it by touching a
+ # matching path, then invoke. As of bug fix #14, activated
+ # conditional skills flow through `get_all_skills` →
+ # `_skill_registry` automatically, so SkillTool resolves them
+ # naturally on the next call (no manual registry promotion).
+ _print_section("Activating conditional `lint-py` for src/foo.py")
+ py_path = project / "src" / "foo.py"
+ py_path.parent.mkdir(parents=True, exist_ok=True)
+ py_path.write_text("# placeholder")
+ activated = activate_conditional_skills_for_paths(
+ [str(py_path)], str(project)
+ )
+ print(f" activated: {activated}")
+ _print_invocation("lint-py", "", ctx)
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/try_multiline_input.py b/scripts/try_multiline_input.py
deleted file mode 100644
index b69f99e0a..000000000
--- a/scripts/try_multiline_input.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""Drive the Rich REPL's real ``PromptSession`` with raw byte sequences
-to verify the multi-line entry contract end-to-end.
-
-We don't mock out ``prompt_toolkit``: we use its official
-``create_pipe_input`` + ``DummyOutput`` harness, which is exactly how
-prompt_toolkit's own test suite drives a session. Every sequence below
-is the actual bytes a real terminal emits, so the results here
-faithfully predict what happens when you press those keys in your WSL
-terminal.
-
-Run from repo root:
- python scripts/try_multiline_input.py
-"""
-
-from __future__ import annotations
-
-from unittest.mock import Mock, patch
-
-from prompt_toolkit import PromptSession
-from prompt_toolkit.input import create_pipe_input
-from prompt_toolkit.output import DummyOutput
-
-
-# ---------------------------------------------------------------------------
-# Build the REAL REPL so we exercise the same bindings clawcodex ships with.
-# ---------------------------------------------------------------------------
-def make_repl():
- from src.repl.core import ClawcodexREPL
-
- mock_provider = Mock()
- mock_provider.model = "glm-4.5"
-
- with patch(
- "src.repl.core.get_provider_config",
- return_value={"api_key": "x", "default_model": "glm-4.5"},
- ), patch("src.repl.core.Session.create"), patch(
- "src.repl.core.get_provider_class"
- ) as gpc:
- gpc.return_value = mock_provider
- return ClawcodexREPL(provider_name="glm")
-
-
-def drive(keystrokes: str) -> str:
- """Feed raw bytes into a ``PromptSession`` configured with the REPL's
- real bindings, and return the final submitted string.
-
- We build a fresh ``PromptSession`` per call so we can attach the pipe
- input + dummy output, but we pass in ``repl.bindings`` verbatim —
- those are the same ``KeyBindings`` object the live REPL uses. In
- other words this exercises the exact Enter / Shift+Enter /
- Meta+Enter / ``\\``+Enter logic shipping in ``clawcodex``.
- """
- repl = make_repl()
- with create_pipe_input() as pipe_input:
- pipe_input.send_text(keystrokes)
- session = PromptSession(
- key_bindings=repl.bindings,
- multiline=True,
- complete_while_typing=True,
- input=pipe_input,
- output=DummyOutput(),
- )
- try:
- result = session.prompt("❯ ")
- except EOFError:
- return ""
- return result if result is not None else ""
-
-
-def show(label: str, keystrokes: str, expected: str) -> None:
- actual = drive(keystrokes)
- # Prettify output for the terminal log.
- disp = actual.replace("\n", "\\n")
- exp = expected.replace("\n", "\\n")
- ok = "PASS" if actual == expected else "FAIL"
- print(f"[{ok}] {label}")
- print(f" keystrokes: {keystrokes!r}")
- print(f" got: {disp!r}")
- print(f" expected: {exp!r}")
-
-
-# ---------------------------------------------------------------------------
-# Scenarios — each corresponds to a physical key on a Windows/WSL terminal.
-# ---------------------------------------------------------------------------
-
-SCENARIOS: list[tuple[str, str, str]] = [
- # Plain Enter: the terminal sends \r. This should submit.
- ("plain Enter submits", "hello\r", "hello"),
-
- # Alt+Enter on Windows Terminal / VSCode / xterm:
- # the terminal sends ESC + \r, which prompt_toolkit parses as
- # (Escape, ControlM). We bind that to "insert newline".
- (
- "Alt+Enter inserts newline (Windows Terminal / VSCode / xterm)",
- "line 1\x1b\rline 2\r",
- "line 1\nline 2",
- ),
-
- # Portable fallback: backslash then Enter. Works on ANY terminal.
- (
- "backslash + Enter inserts newline (portable fallback)",
- "line 1\\\rline 2\r",
- "line 1\nline 2",
- ),
-
- # Kitty keyboard protocol Shift+Enter (CSI 13;2u).
- # Terminals: Kitty, WezTerm, Ghostty, iTerm2 (with CSI u mode).
- # We registered this in ANSI_SEQUENCES → (Escape, ControlM).
- (
- "Shift+Enter via Kitty CSI 13;2u (Kitty/WezTerm/Ghostty)",
- "line 1\x1b[13;2uline 2\r",
- "line 1\nline 2",
- ),
-
- # xterm modifyOtherKeys Shift+Enter (CSI 27;2;13~).
- # Terminals: VSCode with modifyOtherKeys enabled, xterm with it on.
- # Whether this inserts a newline or submits depends on the mapping.
- (
- "Shift+Enter via xterm modifyOtherKeys CSI 27;2;13~",
- "line 1\x1b[27;2;13~line 2\r",
- "line 1\nline 2", # what we WANT; flags a gap if it doesn't.
- ),
-
- # Multiple backslash-Enter continuations.
- (
- "multi-line via backslash-Enter x2",
- "a\\\rb\\\rc\r",
- "a\nb\nc",
- ),
-
- # Mixed: Alt+Enter then backslash+Enter then plain Enter.
- (
- "mixed Alt+Enter and backslash+Enter",
- "one\x1b\rtwo\\\rthree\r",
- "one\ntwo\nthree",
- ),
-
- # Regression: a lone backslash in the middle of a line must NOT make
- # Enter act as a newline. Cursor is AFTER 'bar', so ``text[pos-1]``
- # is 'r', not '\\'. Expected: submits the whole literal string.
- (
- "backslash mid-line does NOT trigger newline",
- "foo\\bar\r",
- "foo\\bar",
- ),
-]
-
-
-def main() -> int:
- print("=" * 72)
- print("Rich REPL multi-line input — end-to-end keystroke verification")
- print("=" * 72)
- fails = 0
- for label, keys, expected in SCENARIOS:
- before = fails
- show(label, keys, expected)
- # show() already printed PASS/FAIL; recompute for counting.
- if drive(keys) != expected:
- fails = before + 1
- else:
- fails = before
- print()
- print("-" * 72)
- print(f"{len(SCENARIOS) - fails}/{len(SCENARIOS)} scenarios passed")
- return 0 if fails == 0 else 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/scripts/vendor-openclaude.sh b/scripts/vendor-openclaude.sh
new file mode 100755
index 000000000..2106c9151
--- /dev/null
+++ b/scripts/vendor-openclaude.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# Vendor the openclaude (Claude Code) Ink TUI source into the clawcodex tree.
+#
+# The migration runs the real openclaude TUI as a thin *client* of the Python
+# agent-server over Direct Connect (cc://). The TUI source must LIVE in clawcodex
+# (the reference checkout at ./typescript is gitignored and must not be a runtime
+# dependency), so this script copies the buildable source into
+# ui-tui/vendor/openclaude/
+# leaving out node_modules, build output, the web app, tests and VCS metadata.
+#
+# Reproducible: re-run after bumping the reference to refresh the vendor. Records
+# the upstream version so reviewers can see exactly what was copied.
+#
+# Usage:
+# scripts/vendor-openclaude.sh [SRC_DIR] [DEST_DIR]
+# Defaults:
+# SRC_DIR = ./typescript (the reference openclaude checkout)
+# DEST_DIR = ./ui-tui/vendor/openclaude
+#
+# After vendoring, build the connect-mode CLI (Phase 2 of the migration plan):
+# cd ui-tui/vendor/openclaude && bun install && DIRECT_CONNECT=true bun run build
+# then point `clawcodex tui` at ui-tui/vendor/openclaude/dist/cli.mjs.
+set -euo pipefail
+
+SRC_DIR="${1:-./typescript}"
+DEST_DIR="${2:-./ui-tui/vendor/openclaude}"
+
+if [[ ! -d "$SRC_DIR/src" ]]; then
+ echo "error: $SRC_DIR/src not found — pass the openclaude checkout as SRC_DIR" >&2
+ exit 1
+fi
+
+if ! command -v rsync >/dev/null 2>&1; then
+ echo "error: rsync is required" >&2
+ exit 1
+fi
+
+# M1 guard (migration plan / critic): the connect path needs files that are
+# ABSENT from the gitignored local ./typescript reference — it is an INCOMPLETE
+# subset (17 of main.tsx's imports resolve to missing files), so `bun run build`
+# over it fails. Vendor from a COMPLETE upstream checkout/tarball of
+# @gitlawb/openclaude@0.9.2 instead. Warn loudly (and stop) if the source looks
+# partial; override with VENDOR_ALLOW_INCOMPLETE=1 to copy anyway.
+_missing=()
+for _req in src/server/parseConnectUrl.ts src/server/server.ts src/server/sessionManager.ts; do
+ [[ -e "$SRC_DIR/$_req" ]] || _missing+=("$_req")
+done
+if (( ${#_missing[@]} )); then
+ echo "WARNING: $SRC_DIR looks INCOMPLETE — missing connect-path files:" >&2
+ printf ' - %s\n' "${_missing[@]}" >&2
+ echo " This subset will NOT 'bun run build' (critic M1). Vendor from a complete" >&2
+ echo " @gitlawb/openclaude@0.9.2 checkout/tarball, not the gitignored typescript/." >&2
+ echo " Pass a complete SRC_DIR, or set VENDOR_ALLOW_INCOMPLETE=1 to copy anyway." >&2
+ [[ "${VENDOR_ALLOW_INCOMPLETE:-}" == "1" ]] || exit 2
+fi
+
+# What the vendored build needs (source + build config), and what it must NOT
+# carry (deps, build output, the separate web app, tests, VCS, large assets).
+INCLUDE=(src scripts bin package.json tsconfig.json bun.lock README.md LICENSE)
+EXCLUDE=(
+ --exclude '.git' --exclude 'node_modules' --exclude 'dist' --exclude 'web'
+ --exclude 'tests' --exclude 'coverage' --exclude 'reports' --exclude '*.test.ts'
+ --exclude '*.test.tsx' --exclude 'vscode-extension' --exclude 'docs'
+)
+
+mkdir -p "$DEST_DIR"
+echo "vendoring openclaude: $SRC_DIR → $DEST_DIR"
+
+for item in "${INCLUDE[@]}"; do
+ if [[ -e "$SRC_DIR/$item" ]]; then
+ rsync -a --delete "${EXCLUDE[@]}" "$SRC_DIR/$item" "$DEST_DIR/"
+ fi
+done
+
+# Record provenance: upstream name + version + copy timestamp + source commit.
+ver="$(node -e "process.stdout.write(require('$PWD/$SRC_DIR/package.json').version||'unknown')" 2>/dev/null || echo unknown)"
+name="$(node -e "process.stdout.write(require('$PWD/$SRC_DIR/package.json').name||'unknown')" 2>/dev/null || echo unknown)"
+commit="$(git -C "$SRC_DIR" rev-parse --short HEAD 2>/dev/null || echo 'n/a')"
+cat > "$DEST_DIR/VENDOR.md" < str:
- matches = PortRuntime().route_prompt(prompt, limit=limit)
- lines = ['# Query Engine Route', '', f'Prompt: {prompt}', '']
- if not matches:
- lines.append('No mirrored command/tool matches found.')
- return '\n'.join(lines)
- lines.append('Matches:')
- lines.extend(f'- [{match.kind}] {match.name} ({match.score}) — {match.source_hint}' for match in matches)
- return '\n'.join(lines)
-
-
-__all__ = ['QueryEnginePort', 'QueryEngineRuntime']
diff --git a/src/Tool.py b/src/Tool.py
deleted file mode 100644
index 16b77aa58..000000000
--- a/src/Tool.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-
-@dataclass(frozen=True)
-class ToolDefinition:
- name: str
- purpose: str
-
-
-DEFAULT_TOOLS = (
- ToolDefinition('port_manifest', 'Summarize the active Python workspace'),
- ToolDefinition('query_engine', 'Render a Python-first porting summary'),
-)
diff --git a/src/__init__.py b/src/__init__.py
index ba8b4b307..92b861774 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -1,6 +1,6 @@
"""Claw Codex - Claude Code Python Implementation."""
-__version__ = "0.1.0"
+__version__ = "0.6.0"
__author__ = "Claw Codex Team"
from .config import load_config, get_provider_config
diff --git a/src/agent/__init__.py b/src/agent/__init__.py
index 2fb64d7dc..0054527b5 100644
--- a/src/agent/__init__.py
+++ b/src/agent/__init__.py
@@ -34,6 +34,17 @@
LEGACY_AGENT_TOOL_NAME,
ONE_SHOT_BUILTIN_AGENT_TYPES,
)
+from .filter_agents_by_mcp import (
+ filter_agents_by_mcp_requirements,
+ has_required_mcp_servers,
+)
+from .load_agents_dir import (
+ clear_agent_definitions_cache,
+ get_active_agents_from_list,
+ get_agent_definitions_with_overrides,
+)
+from .load_plugin_agents import load_plugin_agents
+from .parse_agent_markdown import parse_agent_from_markdown
from .prompt import (
format_agent_line,
get_agent_prompt,
@@ -97,4 +108,12 @@
# Subagent context
"SubagentContextOverrides",
"create_subagent_context",
+ # Custom-agent discovery
+ "clear_agent_definitions_cache",
+ "filter_agents_by_mcp_requirements",
+ "get_active_agents_from_list",
+ "get_agent_definitions_with_overrides",
+ "has_required_mcp_servers",
+ "load_plugin_agents",
+ "parse_agent_from_markdown",
]
diff --git a/src/agent/agent_definitions.py b/src/agent/agent_definitions.py
index f8cfe4ad0..c34bdcdfc 100644
--- a/src/agent/agent_definitions.py
+++ b/src/agent/agent_definitions.py
@@ -10,7 +10,7 @@
from ..permissions.types import PermissionMode
-AgentSource = Literal["built-in", "user", "plugin", "dynamic"]
+AgentSource = Literal["built-in", "user", "project", "managed", "plugin", "dynamic"]
@dataclass
@@ -158,6 +158,11 @@ def _explore_system_prompt(**_kwargs: Any) -> str:
source="built-in",
base_dir="built-in",
omit_claude_md=True,
+ # ch08 round-4 (critic M1) — Explore is the fast/cheap read-only agent;
+ # TS exploreAgent.ts:77 runs it on Haiku. get_agent_model resolves this
+ # against the session provider and inherits on providers that don't
+ # serve haiku (e.g. DeepSeek), so it is cross-provider safe.
+ model="haiku",
get_system_prompt=_explore_system_prompt,
)
@@ -249,7 +254,22 @@ def get_built_in_agents() -> list[AgentDefinition]:
"""Return the list of active built-in agent definitions.
Mirrors getBuiltInAgents() from typescript/src/tools/AgentTool/builtInAgents.ts.
+
+ Coordinator mode swaps in the coordinator agent list (worker /
+ general-purpose / explore / plan) so the coordinator system prompt's
+ ``subagent_type: "worker"`` calls resolve — mirrors
+ ``builtInAgents.ts:35-43``. Imports are function-local for the same
+ reason TS lazy-requires there: ``coordinator.worker_agent`` imports
+ this module (hard cycle at import time), and the env gate is read
+ live per call.
"""
+ from src.coordinator.mode import is_coordinator_mode
+
+ if is_coordinator_mode():
+ from src.coordinator.worker_agent import get_coordinator_agents
+
+ return get_coordinator_agents()
+
return [
GENERAL_PURPOSE_AGENT,
EXPLORE_AGENT,
diff --git a/src/agent/agent_model.py b/src/agent/agent_model.py
new file mode 100644
index 000000000..62e499fb4
--- /dev/null
+++ b/src/agent/agent_model.py
@@ -0,0 +1,112 @@
+"""ch08 round-4 WI-1 — per-subagent model resolution.
+
+Port of TS ``getAgentModel`` (``utils/model/agent.ts``, called at
+``runAgent.ts:340``): resolve the model a subagent should run on from the
+tool ``model`` param, the agent definition's ``model:`` frontmatter, and
+the session model, with a ``CLAUDE_CODE_SUBAGENT_MODEL`` env override.
+
+Multi-provider guard: the port runs on 7+ providers, and the abstract
+aliases (``sonnet``/``opus``/``haiku``) only map on Anthropic-family
+providers. So an alias/id the SESSION provider doesn't recognize falls
+back to the session model rather than 400-ing the request.
+
+Concurrency (ch07): Agent is now concurrency-safe, so N parallel
+subagents share the session ``provider`` instance. This module only
+COMPUTES the model string; the caller (``run_agent``) applies it to a
+per-subagent provider CLONE and never mutates the shared provider.
+"""
+from __future__ import annotations
+
+import logging
+import os
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+_INHERIT = "inherit"
+# Bare family aliases (TS agent.ts). A request for one of these that
+# matches the parent's TIER keeps the parent's EXACT model rather than
+# downgrading to the alias's canonical (older) target.
+_FAMILY_ALIASES = ("opus", "sonnet", "haiku")
+
+
+def _resolve_against_provider(
+ value: str, session_provider: Any, *, trust_literal: bool = False,
+) -> str:
+ """Resolve an alias/id to the model the subagent should run on; inherit
+ the session model on a miss. Never raises.
+
+ - ``'inherit'``/empty → the session model.
+ - A bare family alias whose tier == the parent's tier → the parent's
+ EXACT model (critic M2 — TS ``aliasMatchesParentTier``; avoids the
+ surprising same-tier downgrade, e.g. sonnet-4-6 → sonnet-4-2025...).
+ - A full (non-alias) model id → trusted literally when ``trust_literal``
+ (the env override / an explicit id — critic M3, TS
+ ``parseUserSpecifiedModel``); otherwise gated by availability.
+ - An alias mapped to a model the provider serves → that canonical id.
+ - Anything the provider doesn't serve → the session model.
+ """
+ session_model = getattr(session_provider, "model", "") or ""
+ normalized = (value or "").strip().lower()
+ if not normalized or normalized == _INHERIT:
+ return session_model
+
+ # M2 — same-tier alias keeps the parent's exact model.
+ if normalized in _FAMILY_ALIASES and normalized in session_model.lower():
+ return session_model
+
+ try:
+ from src.models.model import canonical_model_name
+
+ canonical = canonical_model_name(value)
+ except Exception: # noqa: BLE001 — resolution failure → inherit
+ return session_model
+
+ # M3 — a full id (canonical didn't change it, i.e. not a known alias)
+ # from the env override or an explicit pin is trusted literally, so it
+ # survives static-list staleness / proxy deployments with custom names.
+ is_bare_alias = normalized in _FAMILY_ALIASES
+ if trust_literal and not is_bare_alias:
+ return value
+
+ try:
+ available = session_provider.get_available_models() or []
+ available = [str(m) for m in available]
+ except Exception: # noqa: BLE001 — provider can't enumerate → inherit
+ available = []
+ if canonical in available:
+ return canonical
+ # Not served by this provider (e.g. 'haiku' on a DeepSeek session).
+ # Inherit rather than 400. Elevated to WARNING (M3) so an ignored
+ # explicit pin is observable, not silently dropped at debug.
+ logger.warning(
+ "agent model %r (→ %r) is not available on the session provider; "
+ "inheriting the session model %r",
+ value, canonical, session_model,
+ )
+ return session_model
+
+
+def get_agent_model(
+ tool_model: str | None,
+ agent_def_model: str | None,
+ session_provider: Any,
+) -> str:
+ """Resolve the subagent's model. Precedence (TS getAgentModel):
+ ``CLAUDE_CODE_SUBAGENT_MODEL`` env > tool ``model`` param > agent-def
+ ``model:`` > ``'inherit'`` (= the session model). Always returns a
+ non-empty model when the session provider has one; never raises."""
+ env_override = os.environ.get("CLAUDE_CODE_SUBAGENT_MODEL")
+ if env_override:
+ # M3 — the env override is honored more literally (a full id it
+ # names is trusted; TS agent.ts:43-45 bypasses provider gating).
+ return _resolve_against_provider(
+ env_override, session_provider, trust_literal=True,
+ )
+
+ chosen = tool_model or agent_def_model or _INHERIT
+ # A tool param / frontmatter that names a full model id is trusted
+ # literally; bare aliases still go through availability/tier logic.
+ return _resolve_against_provider(
+ chosen, session_provider, trust_literal=True,
+ )
diff --git a/src/agent/agent_tool_utils.py b/src/agent/agent_tool_utils.py
index a2efc6fe2..66ad1bfb7 100644
--- a/src/agent/agent_tool_utils.py
+++ b/src/agent/agent_tool_utils.py
@@ -24,6 +24,20 @@
logger = logging.getLogger(__name__)
+def get_query_source_for_agent(agent_type: str, is_built_in: bool) -> str:
+ """ch08 round-4 WI-2 — the subagent query_source label.
+
+ Port of TS ``getQuerySourceForAgent`` (``utils/promptCategory.ts``):
+ ``agent:builtin:`` for built-in agents, ``agent:custom`` for
+ user/project agents. (The fork path threads its own
+ ``agent:builtin:fork`` explicitly.) The ``agent:`` prefix keeps these
+ outside the ``("compact","session_memory")`` skip-set and inside the
+ foreground-retry family, matching TS."""
+ if is_built_in:
+ return f"agent:builtin:{agent_type}"
+ return "agent:custom"
+
+
@dataclass
class ResolvedAgentTools:
"""Result of resolving agent tools against available tools."""
@@ -201,10 +215,22 @@ def finalize_agent_tool(
agent_messages: list[Message],
agent_id: str,
metadata: dict[str, Any],
+ *,
+ progress: Any | None = None,
) -> AgentToolResult:
"""Extract final result from agent messages.
Mirrors finalizeAgentTool() from typescript/src/tools/AgentTool/agentToolUtils.ts.
+
+ Chapter-10 / Chunk C / WI-2.4: token aggregation now goes through
+ ``ProgressTracker``. Pre-WI-2.4 ``total_tokens`` was hard-coded to
+ ``0`` (gap-analysis §2.2 row); post-WI-2.4 callers pass the live
+ tracker via the ``progress`` keyword and we read the chapter-correct
+ ``latest_input_tokens + cumulative_output_tokens`` total off it. If
+ the caller can't supply a tracker (e.g. a sync agent that didn't
+ feed one during iteration), we fall back to recomputing from
+ ``message.usage`` so the behavior degrades gracefully rather than
+ silently returning zero.
"""
# Find the last assistant message
last_assistant: AssistantMessage | None = None
@@ -242,17 +268,64 @@ def finalize_agent_tool(
total_tool_use_count = count_tool_uses(agent_messages)
start_time = metadata.get("start_time", time.time())
duration_ms = int((time.time() - start_time) * 1000)
+ total_tokens = _resolve_total_tokens(progress, agent_messages)
return AgentToolResult(
agent_id=agent_id,
agent_type=metadata.get("agent_type", ""),
content=content,
total_duration_ms=duration_ms,
- total_tokens=0,
+ total_tokens=total_tokens,
total_tool_use_count=total_tool_use_count,
)
+def _resolve_total_tokens(
+ progress: Any | None,
+ agent_messages: list[Message],
+) -> int:
+ """Compute the chapter-correct total token count.
+
+ Preferred path: if the caller fed a ``ProgressTracker`` during
+ iteration, read its accumulated totals. Fallback: recompute from
+ ``message.usage`` payloads using the same arithmetic semantics
+ (cumulative-per-call inputs → keep latest; per-turn outputs → sum).
+
+ Local imports (here and below) defer the cycle: ``agent_tool_utils``
+ must not import ``src.tasks.progress`` at module load because some
+ test fixtures construct AgentToolResult directly.
+ """
+ if progress is not None:
+ try:
+ from src.tasks.progress import (
+ ProgressTracker,
+ total_tokens_from_tracker,
+ )
+ if isinstance(progress, ProgressTracker):
+ return total_tokens_from_tracker(progress)
+ except ImportError:
+ pass # Fall through to message-based recompute.
+
+ # Fallback: walk the messages and apply the same arithmetic the
+ # tracker would have used. Defensive: a sync agent that didn't
+ # feed a tracker still gets a non-zero total instead of WI-2.4's
+ # pre-fix ``total_tokens=0``.
+ latest_input = 0
+ cumulative_output = 0
+ for msg in agent_messages:
+ if not isinstance(msg, AssistantMessage):
+ continue
+ usage = msg.usage if isinstance(msg.usage, dict) else None
+ if usage is None:
+ continue
+ input_tokens = int(usage.get("input_tokens", 0) or 0)
+ cache_creation = int(usage.get("cache_creation_input_tokens", 0) or 0)
+ cache_read = int(usage.get("cache_read_input_tokens", 0) or 0)
+ latest_input = input_tokens + cache_creation + cache_read
+ cumulative_output += int(usage.get("output_tokens", 0) or 0)
+ return latest_input + cumulative_output
+
+
def extract_partial_result(messages: list[Message]) -> str | None:
"""Extract a partial result string from agent messages.
diff --git a/src/agent/constants.py b/src/agent/constants.py
index e01982060..eae3bd752 100644
--- a/src/agent/constants.py
+++ b/src/agent/constants.py
@@ -7,6 +7,7 @@
# --- Tool name constants ---
AGENT_TOOL_NAME = "Agent"
LEGACY_AGENT_TOOL_NAME = "Task"
+WORKFLOW_TOOL_NAME = "Workflow"
# --- Built-in agent type identifiers ---
VERIFICATION_AGENT_TYPE = "verification"
@@ -28,6 +29,8 @@
"ExitPlanMode",
"EnterPlanMode",
AGENT_TOOL_NAME,
+ # Prevent recursive workflow execution inside subagents.
+ WORKFLOW_TOOL_NAME,
"AskUserQuestion",
"TaskStop",
"Brief",
@@ -40,7 +43,11 @@
])
# Whitelist of tools allowed for async (background) agents.
-# Mirrors ASYNC_AGENT_ALLOWED_TOOLS from typescript/src/constants/tools.ts.
+# Mirrors ASYNC_AGENT_ALLOWED_TOOLS from typescript/src/constants/tools.ts
+# (:53-69). "PowerShell" (via TS SHELL_TOOL_NAMES) is deliberately absent —
+# this port has no PowerShell tool. This set is BOTH the async-agent runtime
+# whitelist (agent_tool_utils.filter_tools_for_agent) and the source of the
+# coordinator's rendered worker-tools list (coordinator/mode.py).
ASYNC_AGENT_ALLOWED_TOOLS: frozenset[str] = frozenset([
"Read",
"WebSearch",
@@ -51,8 +58,10 @@
"Bash",
"Edit",
"Write",
+ "NotebookEdit",
"Skill",
"StructuredOutput",
+ "ToolSearch",
"EnterWorktree",
"ExitWorktree",
])
@@ -70,4 +79,4 @@
# Fork-specific constants
FORK_SUBAGENT_TYPE = "fork"
FORK_BOILERPLATE_TAG = "fork-boilerplate"
-FORK_DIRECTIVE_PREFIX = "DIRECTIVE: "
+FORK_DIRECTIVE_PREFIX = "Your directive: "
diff --git a/src/agent/conversation.py b/src/agent/conversation.py
index 06a4309d9..21b352403 100644
--- a/src/agent/conversation.py
+++ b/src/agent/conversation.py
@@ -26,7 +26,20 @@
@dataclass
class Conversation:
messages: list[Message] = field(default_factory=list)
- max_history: int = 100
+ # Bumped from 100 to 2000 (critic ch05/F-consolidation S2).
+ # The headless+TUI cutover routes tool_use AND tool_result blocks
+ # through ``add_message`` separately (multiple messages per agent
+ # turn), so a 30-turn run with ~3 tools/turn produces ~120 messages
+ # per single user prompt — well past the old 100-cap. The cap
+ # silently truncates from the head, which removes the ORIGINAL
+ # user prompt; subsequent API calls go out without the task
+ # description and the model degrades. 2000 is enough to cover a
+ # long agentic session without changing the truncation semantics;
+ # if a deployment needs unbounded history it can override at
+ # construction. Compaction (``CompressionPipeline`` /
+ # ``/compact``) is the right tool for serious context management;
+ # this cap is just a memory-safety backstop.
+ max_history: int = 2000
def add_message(self, role: str, content: MessageContent):
if len(self.messages) >= self.max_history:
@@ -35,8 +48,13 @@ def add_message(self, role: str, content: MessageContent):
normalized_content = _normalize_message_content(content)
self.messages.append(create_message(role, normalized_content))
- def add_user_message(self, text: str):
- self.add_message("user", text)
+ def add_user_message(self, content: MessageContent):
+ # ``MessageContent = str | list[ContentBlock]``: ``add_message`` ->
+ # ``_normalize_message_content`` already handles both shapes, so
+ # callers can pass a multi-block content list (e.g. text + image
+ # blocks from @image.png @-mentions) the same way they pass a
+ # plain string.
+ self.add_message("user", content)
def add_assistant_message(self, content: MessageContent):
self.add_message("assistant", content)
diff --git a/src/agent/filter_agents_by_mcp.py b/src/agent/filter_agents_by_mcp.py
new file mode 100644
index 000000000..0a2e0ace0
--- /dev/null
+++ b/src/agent/filter_agents_by_mcp.py
@@ -0,0 +1,49 @@
+"""Filter agents by their declared ``required_mcp_servers``.
+
+Port of ``hasRequiredMcpServers`` / ``filterAgentsByMcpRequirements`` in
+typescript/src/tools/AgentTool/loadAgentsDir.ts:228-254.
+
+Built-in agents are never dropped — they're trusted regardless of MCP
+availability.
+"""
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from src.agent.agent_definitions import AgentDefinition, is_built_in_agent
+
+
+def has_required_mcp_servers(
+ agent: AgentDefinition,
+ available_servers: Iterable[str],
+) -> bool:
+ """Return True iff every required pattern matches an available server.
+
+ Matching is case-insensitive substring (same as the TS reference): the
+ pattern ``slack`` matches the server name ``MySlackServer``. Empty
+ requirements pass through.
+ """
+ if not agent.required_mcp_servers:
+ return True
+ available_lower = [s.lower() for s in available_servers]
+ return all(
+ any(pattern.lower() in server for server in available_lower)
+ for pattern in agent.required_mcp_servers
+ )
+
+
+def filter_agents_by_mcp_requirements(
+ agents: Iterable[AgentDefinition],
+ available_servers: Iterable[str],
+) -> list[AgentDefinition]:
+ """Drop agents whose required MCP servers aren't available.
+
+ Built-ins are exempt: they're not allowed to declare requirements and
+ must always be reachable.
+ """
+ available_list = list(available_servers)
+ return [
+ agent
+ for agent in agents
+ if is_built_in_agent(agent) or has_required_mcp_servers(agent, available_list)
+ ]
diff --git a/src/agent/foreground_promotion.py b/src/agent/foreground_promotion.py
new file mode 100644
index 000000000..4cd71a0d7
--- /dev/null
+++ b/src/agent/foreground_promotion.py
@@ -0,0 +1,219 @@
+"""Foreground → background agent promotion — Chunk H / WI-10.1.
+
+Mirrors the ``Promise.race`` pattern in
+``typescript/src/tasks/LocalAgentTask/LocalAgentTask.tsx:519-651``.
+The TS sync agent loop races "next message from agent" against
+"background signal"; on the bg-signal branch it cleanly returns the
+foreground iterator (triggering its ``finally`` for resource cleanup)
+and re-spawns the agent as async.
+
+Python equivalent: ``asyncio.wait({next_msg_task, bg_signal_task},
+return_when=FIRST_COMPLETED)`` — see the `Promise.race` direct
+translation. ``gather`` would wait for both, which is wrong.
+
+The chapter calls out four abort scenarios that have to behave
+correctly:
+
+* **Foreground ESC** — kills both the agent and its parent (shared
+ abort controller).
+* **Background ESC after promotion** — kills only the background
+ agent; the parent (now wholly separate) is unaffected.
+* **Background signal during foreground** — promotion: foreground
+ iterator returns cleanly, background spawn with same agent_id,
+ abort controllers swap, ``is_backgrounded`` flips True.
+* **Background signal during background** — no-op; the agent is
+ already backgrounded.
+
+Atomicity contract: no messages lost in the transition; no zombie
+iterators left running. The ``asyncio.wait`` pattern enforces this
+because the unfinished task is explicitly cancelled in the bg-signal
+branch.
+
+Per A6/C5: the promotion mutator (which flips ``is_backgrounded`` on
+``LocalAgentTaskState``) is sync — runs under ``runtime_tasks.update``
+without ``await``. The actual asyncio races happen *outside* the
+registry lock.
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+from dataclasses import replace
+from typing import Any, AsyncIterator, TYPE_CHECKING
+
+from src.tasks.local_agent import LocalAgentTaskState
+
+if TYPE_CHECKING:
+ from src.task_registry import RuntimeTaskRegistry
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Lifecycle helpers — register agent as foreground / promote to background
+# ---------------------------------------------------------------------------
+
+
+def register_agent_foreground(
+ *,
+ agent_id: str,
+ registry: "RuntimeTaskRegistry",
+) -> None:
+ """Mark the agent as foreground (``is_backgrounded=False``).
+
+ Called when an agent is first spawned via the synchronous Agent
+ tool path (no ``run_in_background: true``). The state must
+ already exist in ``runtime_tasks`` (via ``register_async_agent``);
+ this helper just flips the flag.
+ """
+ def _flip(prev: Any) -> Any:
+ if not isinstance(prev, LocalAgentTaskState):
+ return prev
+ if prev.is_backgrounded is False:
+ return prev # already foreground
+ return replace(prev, is_backgrounded=False)
+
+ registry.update(agent_id, _flip)
+
+
+def register_agent_background(
+ *,
+ agent_id: str,
+ registry: "RuntimeTaskRegistry",
+) -> bool:
+ """Promote a running foreground agent to background.
+
+ Mutates atomically under the registry lock; returns True iff the
+ promotion happened (False means the state was missing, terminal,
+ or already backgrounded — all idempotent no-ops).
+ """
+ promoted = False
+
+ def _flip(prev: Any) -> Any:
+ nonlocal promoted
+ if not isinstance(prev, LocalAgentTaskState):
+ return prev
+ if prev.status != "running":
+ return prev # terminal — no point promoting
+ if prev.is_backgrounded is True:
+ return prev # already backgrounded — idempotent
+ promoted = True
+ return replace(prev, is_backgrounded=True)
+
+ registry.update(agent_id, _flip)
+ return promoted
+
+
+def unregister_agent_foreground(
+ *,
+ agent_id: str,
+ registry: "RuntimeTaskRegistry",
+) -> None:
+ """Drop the agent's runtime entry — called when a foreground
+ agent completes WITHOUT being promoted to background. The
+ registry no longer needs to track it (the parent has the result
+ inline)."""
+ registry.remove(agent_id)
+
+
+# ---------------------------------------------------------------------------
+# The race itself — foreground generator vs. background signal
+# ---------------------------------------------------------------------------
+
+
+async def run_with_background_escape(
+ agent_iterator: AsyncIterator[Any],
+ *,
+ background_signal: asyncio.Event,
+ on_background: Any = None,
+) -> tuple[list[Any], bool]:
+ """Drive the foreground agent iterator, racing each ``next``
+ against the background signal.
+
+ Returns ``(messages, was_backgrounded)``:
+ * ``messages`` — every message the iterator yielded before the
+ race ended.
+ * ``was_backgrounded`` — True iff ``background_signal`` fired
+ mid-iteration (caller should re-spawn as async); False iff the
+ iterator completed naturally.
+
+ On bg-signal: the foreground iterator's pending ``next`` task is
+ cancelled (cleanly — ``async for`` machinery uses ``__anext__``
+ which respects cancellation). The optional ``on_background``
+ callback fires inside the bg-signal branch so the caller can
+ swap abort controllers / flip ``is_backgrounded`` while the
+ state is still well-defined.
+ """
+ messages: list[Any] = []
+
+ while True:
+ next_task = asyncio.ensure_future(_anext(agent_iterator))
+ bg_task = asyncio.ensure_future(background_signal.wait())
+
+ done, pending = await asyncio.wait(
+ {next_task, bg_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+
+ if bg_task in done and next_task not in done:
+ # Background signal fired first — cancel the pending
+ # next-message future, drain its cancellation, fire the
+ # callback. The agent's __anext__ machinery handles the
+ # cancellation cleanly via its finally clauses.
+ next_task.cancel()
+ try:
+ await next_task
+ except (asyncio.CancelledError, StopAsyncIteration, Exception):
+ # Swallow on cancellation drain — the agent is being
+ # backgrounded, so any in-flight ``__anext__`` and
+ # its finalizer exceptions don't matter; the
+ # message that would have been yielded is discarded
+ # by design (the new background spawn re-iterates
+ # from the resumed state). Catching ``Exception``
+ # rather than just ``CancelledError`` covers
+ # finalizer-raised errors from ``finally`` clauses
+ # that may surface during ``cancel()``.
+ pass
+ if on_background is not None:
+ try:
+ result = on_background()
+ if asyncio.iscoroutine(result):
+ await result
+ except Exception:
+ logger.exception("on_background callback raised")
+ return messages, True
+
+ # next_task completed (and possibly bg_task too — but the
+ # next message came in first or alongside; deliver it). We
+ # cancel bg_task because we're about to start a fresh wait
+ # with a new bg_task in the next iteration.
+ if not bg_task.done():
+ bg_task.cancel()
+ try:
+ await bg_task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ try:
+ msg = next_task.result()
+ except StopAsyncIteration:
+ return messages, False
+ except Exception:
+ # Iterator raised — re-raise so the caller's exception
+ # handlers see it.
+ raise
+ messages.append(msg)
+
+
+async def _anext(iterator: AsyncIterator[Any]) -> Any:
+ """Helper — calls ``__anext__`` directly so the ensure_future
+ target is one well-defined coroutine."""
+ return await iterator.__anext__()
+
+
+__all__ = [
+ "register_agent_foreground",
+ "register_agent_background",
+ "unregister_agent_foreground",
+ "run_with_background_escape",
+]
diff --git a/src/agent/fork_subagent.py b/src/agent/fork_subagent.py
new file mode 100644
index 000000000..b066efa53
--- /dev/null
+++ b/src/agent/fork_subagent.py
@@ -0,0 +1,297 @@
+"""Fork subagent helpers.
+
+Mirrors ``typescript/src/tools/AgentTool/forkSubagent.ts``.
+
+Fork is an implicit-spawn path where the child inherits the parent's full
+conversation context and tool array, so the API request prefix is
+byte-identical across all parallel children. Anthropic's prompt cache then
+discounts every child after the first by ~90% on the shared prefix.
+
+This module supplies:
+
+- ``is_fork_subagent_enabled`` — feature gate (env-flag plus interactivity
+ check). Coordinator-mode gating is a no-op until coordinator mode is
+ ported to Python.
+- ``FORK_AGENT`` (re-exported from agent_definitions) — synthetic agent
+ definition used when the gate is on and ``subagent_type`` is omitted.
+- ``build_forked_messages`` / ``build_child_message`` — produce the trailing
+ message pair that wraps every child's directive in the
+ ```` envelope while leaving the prefix byte-identical
+ across all children.
+- ``is_in_fork_child`` — fallback recursion guard scanning user messages for
+ the boilerplate tag.
+- ``build_worktree_notice`` — translation hint when fork combines with
+ worktree isolation.
+"""
+from __future__ import annotations
+
+import copy
+import os
+from typing import Any
+from uuid import uuid4
+
+from ..bootstrap.state import get_is_non_interactive_session
+from ..tool_system.context import ToolContext
+from ..types.content_blocks import TextBlock, ToolResultBlock, ToolUseBlock
+from ..types.messages import (
+ AssistantMessage,
+ Message,
+ UserMessage,
+ create_user_message,
+)
+
+from .agent_definitions import FORK_AGENT
+from .constants import (
+ FORK_BOILERPLATE_TAG,
+ FORK_DIRECTIVE_PREFIX,
+ FORK_SUBAGENT_TYPE,
+)
+
+
+# Placeholder tool_result text reused across every fork child so the
+# pre-directive prefix is byte-identical. Mirrors
+# ``forkSubagent.ts:93``.
+FORK_PLACEHOLDER_RESULT = "Fork started — processing in background"
+
+
+# Chunk G / WI-8.1: ``_is_env_truthy`` was hoisted to the canonical
+# ``src.utils.env`` module so coordinator-mode and fork-subagent share
+# one helper. Re-exported here under the original name for back-compat
+# with any in-tree callers that import from this module.
+from src.utils.env import is_env_truthy as _is_env_truthy
+
+
+def is_fork_subagent_enabled(context: ToolContext | None = None) -> bool:
+ """Check whether the fork-subagent path is enabled.
+
+ Mirrors ``isForkSubagentEnabled()`` from ``forkSubagent.ts:32``. The
+ gate is on when:
+
+ - ``CLAUDE_FORK_SUBAGENT`` env var is set to a truthy value
+ (Python equivalent of the GrowthBook ``feature('FORK_SUBAGENT')``).
+ - The current session is interactive (no SDK / ``--print`` mode).
+ - **Coordinator mode is NOT active** (Chunk G / WI-8.6). Fork
+ subagents and coordinator workers are mutually exclusive — the
+ chapter §"Mutual Exclusion with Fork" calls this out: fork
+ agents inherit the parent's full conversation context (prompt-
+ cache amortization); coordinator workers are independent agents
+ with fresh context. Opposing philosophies of delegation;
+ enforced at the gate level.
+ """
+ # Local import to avoid a top-of-module cycle (coordinator
+ # imports back through agent_definitions for WORKER_AGENT).
+ from src.coordinator.mode import is_coordinator_mode
+
+ if is_coordinator_mode():
+ return False
+
+ if not _is_env_truthy("CLAUDE_FORK_SUBAGENT"):
+ return False
+
+ if context is not None:
+ opts = getattr(context, "options", None)
+ if opts is not None and getattr(opts, "is_non_interactive_session", False):
+ return False
+ elif get_is_non_interactive_session():
+ return False
+
+ return True
+
+
+def is_in_fork_child(messages: list[Any] | None) -> bool:
+ """Return True if any user message contains the fork boilerplate tag.
+
+ Mirrors ``isInForkChild()`` from ``forkSubagent.ts:78-89``. This is the
+ fallback recursion guard; the primary guard checks
+ ``ToolUseOptions.query_source``.
+ """
+ if not messages:
+ return False
+ needle = f"<{FORK_BOILERPLATE_TAG}>"
+ for msg in messages:
+ # Filter to user-role messages without importing UserMessage —
+ # callers may pass dict-shaped messages too.
+ role = _get_attr(msg, "role", None)
+ msg_type = _get_attr(msg, "type", None)
+ if role != "user" and msg_type != "user":
+ continue
+ content = _get_attr(msg, "content", None)
+ if not isinstance(content, list):
+ continue
+ for block in content:
+ block_type = _get_attr(block, "type", None)
+ if block_type != "text":
+ continue
+ text = _get_attr(block, "text", None)
+ if isinstance(text, str) and needle in text:
+ return True
+ return False
+
+
+def build_child_message(directive: str) -> str:
+ """Render the boilerplate-wrapped directive for the fork child.
+
+ Mirrors ``buildChildMessage()`` from ``forkSubagent.ts:171-198``. The
+ output is one literal string. Keeping it byte-stable across the
+ codebase is important for cache parity tests.
+ """
+ return (
+ f"<{FORK_BOILERPLATE_TAG}>\n"
+ "STOP. READ THIS FIRST.\n\n"
+ "You are a forked worker process. You are NOT the main agent.\n\n"
+ "RULES (non-negotiable):\n"
+ "1. Your system prompt says \"default to forking.\" IGNORE IT — "
+ "that's for the parent. You ARE the fork. Do NOT spawn sub-agents; "
+ "execute directly.\n"
+ "2. Do NOT converse, ask questions, or suggest next steps\n"
+ "3. Do NOT editorialize or add meta-commentary\n"
+ "4. USE your tools directly: Bash, Read, Write, etc.\n"
+ "5. If you modify files, commit your changes before reporting. "
+ "Include the commit hash in your report.\n"
+ "6. Do NOT emit text between tool calls. Use tools silently, then "
+ "report once at the end.\n"
+ "7. Stay strictly within your directive's scope. If you discover "
+ "related systems outside your scope, mention them in one sentence "
+ "at most — other workers cover those areas.\n"
+ "8. Keep your report under 500 words unless the directive specifies "
+ "otherwise. Be factual and concise.\n"
+ "9. Your response MUST begin with \"Scope:\". No preamble, no "
+ "thinking-out-loud.\n"
+ "10. REPORT structured facts, then stop\n\n"
+ "Output format (plain text labels, not markdown headers):\n"
+ " Scope: \n"
+ " Result: \n"
+ " Key files: \n"
+ " Files changed: \n"
+ " Issues: \n"
+ f"{FORK_BOILERPLATE_TAG}>\n\n"
+ f"{FORK_DIRECTIVE_PREFIX}{directive}"
+ )
+
+
+def _collect_tool_use_blocks(content: Any) -> list[Any]:
+ """Return the ``tool_use`` blocks from an assistant message's content list."""
+ if not isinstance(content, list):
+ return []
+ blocks: list[Any] = []
+ for block in content:
+ if _get_attr(block, "type", None) == "tool_use":
+ blocks.append(block)
+ return blocks
+
+
+def build_forked_messages(
+ directive: str,
+ parent_assistant: AssistantMessage | None,
+) -> list[Message]:
+ """Build the trailing message pair for a fork child.
+
+ Mirrors ``buildForkedMessages()`` from ``forkSubagent.ts:107-169``.
+
+ For prompt-cache sharing, every fork child must produce a byte-identical
+ API request prefix. This function:
+
+ 1. Clones the parent assistant message (preserving every ``tool_use``
+ block and its ID).
+ 2. For each ``tool_use`` block, emits a ``tool_result`` with the
+ constant ``FORK_PLACEHOLDER_RESULT`` text.
+ 3. Appends a single user message containing all those placeholder
+ ``tool_result`` blocks followed by the boilerplate-wrapped
+ directive.
+
+ Result: ``[cloned_assistant, user_message]``. Only the final text block
+ differs per child, maximizing cache hits.
+
+ If the parent assistant has no ``tool_use`` blocks (or no parent at
+ all), we fall back to a single user message carrying the directive.
+ """
+ if parent_assistant is None:
+ return [
+ create_user_message(content=[TextBlock(text=build_child_message(directive))])
+ ]
+
+ tool_use_blocks = _collect_tool_use_blocks(parent_assistant.content)
+
+ if not tool_use_blocks:
+ return [
+ create_user_message(content=[TextBlock(text=build_child_message(directive))])
+ ]
+
+ # Clone the assistant message — new uuid, deep-copied content list — so
+ # we never mutate the parent's message in place.
+ cloned_content: list[Any] = []
+ for block in parent_assistant.content if isinstance(parent_assistant.content, list) else []:
+ cloned_content.append(copy.deepcopy(block))
+ cloned_assistant = AssistantMessage(
+ content=cloned_content,
+ uuid=str(uuid4()),
+ timestamp=parent_assistant.timestamp,
+ stop_reason=parent_assistant.stop_reason,
+ model=parent_assistant.model,
+ usage=parent_assistant.usage,
+ requestId=parent_assistant.requestId,
+ )
+
+ # Build placeholder tool_result blocks for every parent tool_use block.
+ tool_result_blocks: list[ToolResultBlock] = []
+ for block in tool_use_blocks:
+ tool_use_id = _get_attr(block, "id", "") or ""
+ tool_result_blocks.append(
+ ToolResultBlock(
+ tool_use_id=str(tool_use_id),
+ content=FORK_PLACEHOLDER_RESULT,
+ )
+ )
+
+ # Single user message: all placeholder tool_results + the per-child
+ # directive wrapped in . The directive is appended
+ # as a TextBlock sibling so the cache boundary lands right before it.
+ user_msg = create_user_message(
+ content=[*tool_result_blocks, TextBlock(text=build_child_message(directive))]
+ )
+
+ return [cloned_assistant, user_msg]
+
+
+def build_worktree_notice(parent_cwd: str, worktree_cwd: str) -> str:
+ """Build the worktree-isolation notice for a fork child.
+
+ Mirrors ``buildWorktreeNotice()`` from ``forkSubagent.ts:205-210``. The
+ notice tells the child to translate inherited paths to the worktree
+ root, re-read potentially stale files, and that its changes stay
+ isolated.
+ """
+ return (
+ f"You've inherited the conversation context above from a parent agent "
+ f"working in {parent_cwd}. You are operating in an isolated git worktree at "
+ f"{worktree_cwd} — same repository, same relative file structure, separate "
+ f"working copy. Paths in the inherited context refer to the parent's working "
+ f"directory; translate them to your worktree root. Re-read files before editing "
+ f"if the parent may have modified them since they appear in the context. Your "
+ f"changes stay in this worktree and will not affect the parent's files."
+ )
+
+
+def _get_attr(obj: Any, name: str, default: Any) -> Any:
+ """Read an attribute or mapping key from a heterogeneous object.
+
+ Forked messages can arrive as dataclass instances, plain dicts, or
+ Pydantic-style objects. This helper unifies the reads.
+ """
+ if obj is None:
+ return default
+ if isinstance(obj, dict):
+ return obj.get(name, default)
+ return getattr(obj, name, default)
+
+
+__all__ = [
+ "FORK_AGENT",
+ "FORK_PLACEHOLDER_RESULT",
+ "FORK_SUBAGENT_TYPE",
+ "build_child_message",
+ "build_forked_messages",
+ "build_worktree_notice",
+ "is_fork_subagent_enabled",
+ "is_in_fork_child",
+]
diff --git a/src/agent/load_agents_dir.py b/src/agent/load_agents_dir.py
new file mode 100644
index 000000000..a122397a8
--- /dev/null
+++ b/src/agent/load_agents_dir.py
@@ -0,0 +1,157 @@
+"""Discover and merge custom agent definitions from disk + plugins.
+
+Port of ``getAgentDefinitionsWithOverrides`` in
+typescript/src/tools/AgentTool/loadAgentsDir.ts. Combines built-in agents
+(``src/agent/agent_definitions.py:get_built_in_agents``), plugin agents
+(via ``load_plugin_agents``), and on-disk custom agents from managed /
+user / project directories (via ``load_markdown_files_for_subdir``).
+
+Last-wins merge order on duplicate ``agent_type``:
+ [built-in, plugin, user, project, managed]
+
+A module-level cache keyed on cwd avoids re-walking the filesystem on
+every prompt build. Call ``clear_agent_definitions_cache()`` after a
+known on-disk change (e.g., the user edits ``~/.claude/agents/foo.md``)
+to force a refresh.
+"""
+from __future__ import annotations
+
+import logging
+import os
+from typing import Iterable
+
+from src.agent.agent_definitions import AgentDefinition, get_built_in_agents
+from src.agent.parse_agent_markdown import parse_agent_from_markdown
+from src.utils.markdown_config_loader import (
+ SOURCE_MANAGED,
+ SOURCE_PROJECT,
+ SOURCE_USER,
+ load_markdown_files_for_subdir,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Each disk source maps to the matching ``AgentSource`` literal so
+# downstream consumers can distinguish a managed-policy agent from a
+# user one (e.g., to enforce "managed cannot be overridden by user").
+_SOURCE_TO_AGENT_SOURCE: dict[str, str] = {
+ SOURCE_MANAGED: "managed",
+ SOURCE_USER: "user",
+ SOURCE_PROJECT: "project",
+}
+
+# Priority order for last-wins merge — earlier entries are overridden by
+# later ones if they share an agent_type.
+_MERGE_ORDER: tuple[str, ...] = (
+ "built-in",
+ "plugin",
+ SOURCE_USER,
+ SOURCE_PROJECT,
+ SOURCE_MANAGED,
+)
+
+
+# Cache is keyed on ``os.path.realpath(cwd)`` so symlinked / trailing-slash
+# variants of the same project collapse into a single entry. The cache is
+# session-bound; SDK callers that hop between unrelated projects can grow
+# it unboundedly — acceptable for now since per-cwd discovery is cheap.
+_agent_dir_cache: dict[str, list[AgentDefinition]] = {}
+
+
+def _cache_key(cwd: str) -> str:
+ try:
+ return os.path.realpath(cwd)
+ except (OSError, ValueError):
+ return cwd
+
+
+def clear_agent_definitions_cache() -> None:
+ """Drop the discovery cache. Call after on-disk agent changes."""
+ _agent_dir_cache.clear()
+
+
+def get_active_agents_from_list(
+ agents: Iterable[AgentDefinition],
+) -> list[AgentDefinition]:
+ """Last-wins dedup by ``agent_type`` while preserving input order.
+
+ Mirrors ``getActiveAgentsFromList`` from loadAgentsDir.ts:192-220.
+ Callers are responsible for arranging input order so the desired
+ override priority is honoured (lowest priority first, highest last).
+ """
+ by_type: dict[str, AgentDefinition] = {}
+ order: list[str] = []
+ for agent in agents:
+ if agent.agent_type not in by_type:
+ order.append(agent.agent_type)
+ by_type[agent.agent_type] = agent
+ return [by_type[t] for t in order]
+
+
+def _load_custom_agents(cwd: str) -> dict[str, list[AgentDefinition]]:
+ """Group disk-discovered agents by their disk source label."""
+ grouped: dict[str, list[AgentDefinition]] = {
+ SOURCE_USER: [],
+ SOURCE_PROJECT: [],
+ SOURCE_MANAGED: [],
+ }
+ files = load_markdown_files_for_subdir("agents", cwd)
+ for md in files:
+ agent_source = _SOURCE_TO_AGENT_SOURCE.get(md.source, "user")
+ agent = parse_agent_from_markdown(
+ file_path=md.file_path,
+ frontmatter=md.frontmatter,
+ body=md.body,
+ source=agent_source, # type: ignore[arg-type]
+ base_dir=md.base_dir,
+ )
+ if agent is None:
+ continue
+ grouped[md.source].append(agent)
+ return grouped
+
+
+def get_agent_definitions_with_overrides(cwd: str) -> list[AgentDefinition]:
+ """Return the merged list of agents visible from ``cwd``.
+
+ Cache-keyed on ``cwd``. Built-ins are always included; the user can
+ override a built-in by defining an agent with the same ``agent_type``.
+ On any unexpected loader error the built-ins are returned alone — a
+ broken custom agent file should never disable the model's ability to
+ spawn the built-in agents.
+ """
+ key = _cache_key(cwd)
+ cached = _agent_dir_cache.get(key)
+ if cached is not None:
+ return list(cached)
+
+ try:
+ builtins = list(get_built_in_agents())
+ try:
+ from src.agent.load_plugin_agents import load_plugin_agents
+ from src.plugins import get_loaded_plugins
+ plugin_agents = load_plugin_agents(get_loaded_plugins())
+ except Exception:
+ logger.exception("plugin agent loading failed; continuing without plugin agents")
+ plugin_agents = []
+
+ custom = _load_custom_agents(cwd)
+
+ sources_in_order: dict[str, list[AgentDefinition]] = {
+ "built-in": builtins,
+ "plugin": plugin_agents,
+ SOURCE_USER: custom[SOURCE_USER],
+ SOURCE_PROJECT: custom[SOURCE_PROJECT],
+ SOURCE_MANAGED: custom[SOURCE_MANAGED],
+ }
+ flat: list[AgentDefinition] = []
+ for source_key in _MERGE_ORDER:
+ flat.extend(sources_in_order.get(source_key, []))
+
+ active = get_active_agents_from_list(flat)
+ _agent_dir_cache[key] = active
+ return list(active)
+ except Exception:
+ logger.exception("agent discovery failed; falling back to built-ins")
+ return list(get_built_in_agents())
diff --git a/src/agent/load_plugin_agents.py b/src/agent/load_plugin_agents.py
new file mode 100644
index 000000000..f7cab03b4
--- /dev/null
+++ b/src/agent/load_plugin_agents.py
@@ -0,0 +1,109 @@
+"""Load agent definitions exposed by enabled plugins.
+
+Mirrors ``loadPluginAgents`` in
+typescript/src/utils/plugins/loadPluginAgents.ts. For each enabled
+plugin with a non-empty ``agents_paths``, walks the directory
+recursively for ``*.md`` files, parses each via ``parse_agent_from_markdown``,
+and namespaces the resulting ``agent_type`` as
+``":: "`` so nested folders cannot collide.
+
+Plugin agents intentionally drop ``permission_mode``, ``hooks``, and
+``mcp_servers`` from the parsed definition — those grant capabilities
+beyond install-time trust and must come from user-controlled settings,
+not third-party plugin manifests.
+"""
+from __future__ import annotations
+
+import logging
+from dataclasses import replace
+from pathlib import Path
+
+from src.agent.agent_definitions import AgentDefinition
+from src.agent.parse_agent_markdown import parse_agent_from_markdown
+from src.plugins.types import LoadedPlugin
+from src.skills.frontmatter import parse_frontmatter
+
+logger = logging.getLogger(__name__)
+
+
+def _scan_md_files(directory: str) -> list[tuple[str, str]]:
+ """Recursively list ``*.md`` files under ``directory``.
+
+ Returns ``(absolute_file_path, relative_namespace)`` pairs where
+ ``relative_namespace`` is the parent-dir path relative to
+ ``directory``, with separators turned into ``:`` (so a file at
+ ``/foo/bar.md`` yields namespace ``"foo"``). Files directly
+ under ``directory`` yield ``""``.
+ """
+ base = Path(directory)
+ if not base.is_dir():
+ return []
+ out: list[tuple[str, str]] = []
+ try:
+ for path in base.rglob("*.md"):
+ if not path.is_file():
+ continue
+ rel = path.parent.relative_to(base)
+ namespace = ":".join(rel.parts) if rel.parts else ""
+ out.append((str(path), namespace))
+ except (OSError, PermissionError):
+ return []
+ return sorted(out)
+
+
+def _build_namespaced_agent_type(
+ plugin_name: str, namespace: str, base_name: str,
+) -> str:
+ parts = [plugin_name]
+ if namespace:
+ parts.append(namespace)
+ parts.append(base_name)
+ return ":".join(parts)
+
+
+def load_plugin_agents(plugins: list[LoadedPlugin]) -> list[AgentDefinition]:
+ """Return all agent definitions discovered across the given plugins.
+
+ Agent types are namespaced as ``:: `` to
+ mirror the TS ``walkPluginMarkdown`` convention — without the
+ ```` segment, plugins shipping multiple agents named
+ ``review.md`` in different folders would silently collide.
+ """
+ agents: list[AgentDefinition] = []
+ for plugin in plugins:
+ if not plugin.enabled or not plugin.agents_paths:
+ continue
+ for agents_dir in plugin.agents_paths:
+ for file_path, namespace in _scan_md_files(agents_dir):
+ try:
+ content = Path(file_path).read_text(encoding="utf-8")
+ except (OSError, PermissionError, UnicodeDecodeError) as exc:
+ logger.debug(
+ "plugin %s: failed to read %s: %s",
+ plugin.name, file_path, exc,
+ )
+ continue
+ parsed = parse_frontmatter(content)
+ agent = parse_agent_from_markdown(
+ file_path=file_path,
+ frontmatter=parsed.frontmatter,
+ body=parsed.body,
+ source="plugin",
+ base_dir=plugin.path,
+ )
+ if agent is None:
+ continue
+ namespaced = replace(
+ agent,
+ agent_type=_build_namespaced_agent_type(
+ plugin.name, namespace, agent.agent_type,
+ ),
+ source="plugin",
+ # Strip elevated capabilities: plugins cannot grant
+ # permission overrides, hooks, or MCP servers.
+ permission_mode=None,
+ hooks=None,
+ mcp_servers=None,
+ )
+ agents.append(namespaced)
+ return agents
diff --git a/src/agent/parse_agent_markdown.py b/src/agent/parse_agent_markdown.py
new file mode 100644
index 000000000..1a9320a30
--- /dev/null
+++ b/src/agent/parse_agent_markdown.py
@@ -0,0 +1,226 @@
+"""Parse a markdown agent definition into an ``AgentDefinition``.
+
+Mirrors ``parseAgentFromMarkdown`` in typescript/src/tools/AgentTool/loadAgentsDir.ts.
+
+Field mapping (frontmatter → AgentDefinition):
+ name → agent_type (defaults to filename stem)
+ description → when_to_use (required)
+ tools → tools (None / ['*'] both mean "all")
+ disallowed-tools → disallowed_tools
+ disallowedTools → disallowed_tools (camelCase alias)
+ model → model ('inherit' kept as a literal)
+ permission-mode → permission_mode
+ permissionMode → permission_mode (camelCase alias)
+ max-turns → max_turns
+ maxTurns → max_turns (camelCase alias)
+ background → background
+ color → color
+ memory → memory
+ omit-claude-md → omit_claude_md
+ omitClaudeMd → omit_claude_md (camelCase alias)
+ hooks → hooks
+ skills → skills
+ isolation → isolation
+ required-mcp-servers → required_mcp_servers
+ requiredMcpServers → required_mcp_servers (camelCase alias)
+ mcp-servers → mcp_servers
+ mcpServers → mcp_servers (camelCase alias)
+ effort → effort
+
+The markdown body becomes the agent's system prompt, returned by
+``agent.get_system_prompt()``. Missing required fields produce ``None``
+with a debug log; the loader silently drops the file rather than crash.
+"""
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
+from src.agent.agent_definitions import AgentDefinition, AgentSource
+from src.utils.frontmatter_validators import (
+ parse_effort_value,
+ parse_hooks,
+ parse_permission_mode,
+ parse_positive_int,
+ parse_string_list,
+)
+
+logger = logging.getLogger(__name__)
+
+
+AGENT_COLORS: frozenset[str] = frozenset(
+ {"red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"}
+)
+VALID_MEMORY_SCOPES: frozenset[str] = frozenset({"user", "project", "local"})
+VALID_ISOLATION_MODES: frozenset[str] = frozenset({"worktree", "remote"})
+
+
+def _first(d: dict[str, Any], *keys: str) -> Any:
+ """Return the first non-``None`` value among ``keys`` in ``d``."""
+ for key in keys:
+ if key in d and d[key] is not None:
+ return d[key]
+ return None
+
+
+def _parse_tools(value: Any) -> list[str] | None:
+ """Parse a tools list. ``None`` / ``['*']`` both mean "all tools".
+
+ Returns ``None`` to signal all-tools (matches AgentDefinition.tools
+ semantics: ``None`` or ``['*']`` both mean unrestricted).
+ """
+ if value is None:
+ return None
+ parsed = parse_string_list(value)
+ if not parsed:
+ return []
+ if "*" in parsed:
+ return None
+ return parsed
+
+
+def _parse_color(value: Any) -> str | None:
+ if value is None or not isinstance(value, str):
+ return None
+ color = value.strip().lower()
+ return color if color in AGENT_COLORS else None
+
+
+def _parse_memory(value: Any, *, file_path: str) -> str | None:
+ if value is None or value == "":
+ return None
+ s = str(value).strip()
+ if s in VALID_MEMORY_SCOPES:
+ return s
+ logger.debug(
+ "agent %s: invalid memory=%r (valid: %s)",
+ file_path, value, ", ".join(sorted(VALID_MEMORY_SCOPES)),
+ )
+ return None
+
+
+def _parse_isolation(value: Any, *, file_path: str) -> str | None:
+ if value is None or value == "":
+ return None
+ s = str(value).strip()
+ if s in VALID_ISOLATION_MODES:
+ return s
+ logger.debug(
+ "agent %s: invalid isolation=%r (valid: %s)",
+ file_path, value, ", ".join(sorted(VALID_ISOLATION_MODES)),
+ )
+ return None
+
+
+def _parse_bool(value: Any) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in ("true", "yes", "1")
+ return False
+
+
+def _parse_model(value: Any) -> str | None:
+ """Return ``'inherit'``, a concrete model string, or ``None``."""
+ if value is None:
+ return None
+ s = str(value).strip()
+ if not s:
+ return None
+ return "inherit" if s.lower() == "inherit" else s
+
+
+def parse_agent_from_markdown(
+ file_path: str,
+ frontmatter: dict[str, Any],
+ body: str,
+ source: AgentSource,
+ base_dir: str,
+) -> AgentDefinition | None:
+ """Map a parsed markdown agent definition to an ``AgentDefinition``.
+
+ Returns ``None`` (with a debug log) when the required ``description``
+ field is missing. Never raises — every other invalid field is dropped
+ silently so a single bad value doesn't prevent the agent from loading.
+ """
+ raw_name = _first(frontmatter, "name")
+ if raw_name is not None and not isinstance(raw_name, str):
+ # Reject non-string names (TS does the same). YAML can coerce
+ # ``name: true`` to a bool or ``name: 12345`` to an int; treating
+ # those as the agent_type would silently register agents that
+ # can't be invoked via ``@agent-True`` mention syntax.
+ logger.debug(
+ "agent file %s: 'name' must be a string (got %s); using filename",
+ file_path, type(raw_name).__name__,
+ )
+ raw_name = None
+ agent_type = (raw_name or Path(file_path).stem).strip()
+ if not agent_type:
+ logger.debug("agent file %s has empty name; skipping", file_path)
+ return None
+
+ description = _first(frontmatter, "description")
+ if not description or not isinstance(description, str):
+ logger.debug(
+ "agent file %s is missing required 'description'; skipping",
+ file_path,
+ )
+ return None
+ when_to_use = description.replace("\\n", "\n")
+
+ tools = _parse_tools(_first(frontmatter, "tools"))
+
+ disallowed_raw = _first(frontmatter, "disallowed-tools", "disallowedTools")
+ disallowed_tools = parse_string_list(disallowed_raw) if disallowed_raw is not None else None
+
+ model = _parse_model(_first(frontmatter, "model"))
+ permission_mode = parse_permission_mode(
+ _first(frontmatter, "permission-mode", "permissionMode")
+ )
+ max_turns = parse_positive_int(_first(frontmatter, "max-turns", "maxTurns"))
+ background = _parse_bool(_first(frontmatter, "background"))
+ color = _parse_color(_first(frontmatter, "color"))
+ memory = _parse_memory(_first(frontmatter, "memory"), file_path=file_path)
+ omit_claude_md = _parse_bool(_first(frontmatter, "omit-claude-md", "omitClaudeMd"))
+ hooks = parse_hooks(_first(frontmatter, "hooks"), owner_name=f"agent {agent_type}")
+ skills = parse_string_list(_first(frontmatter, "skills"))
+ isolation = _parse_isolation(
+ _first(frontmatter, "isolation"), file_path=file_path
+ )
+ required_mcp_servers = parse_string_list(
+ _first(frontmatter, "required-mcp-servers", "requiredMcpServers")
+ )
+ mcp_servers_raw = _first(frontmatter, "mcp-servers", "mcpServers")
+ mcp_servers: list[Any] | None = (
+ list(mcp_servers_raw) if isinstance(mcp_servers_raw, list) else None
+ )
+ effort = parse_effort_value(_first(frontmatter, "effort"))
+
+ body_text = body.strip()
+
+ def _get_system_prompt(**_kwargs: Any) -> str:
+ return body_text
+
+ return AgentDefinition(
+ agent_type=agent_type,
+ when_to_use=when_to_use,
+ tools=tools,
+ source=source,
+ base_dir=base_dir,
+ model=model,
+ permission_mode=permission_mode,
+ max_turns=max_turns,
+ background=background,
+ color=color,
+ memory=memory,
+ omit_claude_md=omit_claude_md,
+ disallowed_tools=disallowed_tools,
+ hooks=hooks,
+ skills=skills or None,
+ isolation=isolation, # type: ignore[arg-type]
+ required_mcp_servers=required_mcp_servers or None,
+ mcp_servers=mcp_servers,
+ effort=effort,
+ get_system_prompt=_get_system_prompt,
+ )
diff --git a/src/agent/prompt.py b/src/agent/prompt.py
index e8030062e..49ef87925 100644
--- a/src/agent/prompt.py
+++ b/src/agent/prompt.py
@@ -53,12 +53,17 @@ def get_agent_prompt(
*,
allow_async: bool = True,
allow_fork: bool = False,
+ is_coordinator: bool = False,
) -> str:
"""Build the full prompt for the Agent tool.
Mirrors getPrompt() from typescript/src/tools/AgentTool/prompt.ts.
This text is fed to the model as the Agent tool's description, instructing
the parent agent on how and when to spawn sub-agents.
+
+ ``is_coordinator``: return only the shared header — the coordinator
+ system prompt already carries usage notes, examples, and
+ when-not-to-use guidance (mirrors ``prompt.ts:206-211``).
"""
# --- Shared core prompt (used by both coordinator and non-coordinator) ---
agent_list = "\n".join(format_agent_line(a) for a in agent_definitions)
@@ -74,6 +79,11 @@ def get_agent_prompt(
f"to select which agent type to use. If omitted, the general-purpose agent is used."
)
+ # Coordinator mode gets the slim prompt — the coordinator system prompt
+ # already covers usage notes, examples, and when-not-to-use guidance.
+ if is_coordinator:
+ return shared
+
# --- "When NOT to use" section ---
when_not_to_use = (
f"\nWhen NOT to use the {_AGENT_TOOL_NAME} tool:\n"
@@ -189,8 +199,8 @@ def get_agent_prompt(
def get_agent_system_prompt(
agent_definition: AgentDefinition,
- parent_system_prompt: str | None = None,
-) -> str:
+ parent_system_prompt: "str | list | None" = None,
+) -> "str | list":
"""Get the system prompt for an agent.
Mirrors getAgentSystemPrompt() from typescript/src/tools/AgentTool/runAgent.ts.
diff --git a/src/agent/resume_agent.py b/src/agent/resume_agent.py
new file mode 100644
index 000000000..1c07e6530
--- /dev/null
+++ b/src/agent/resume_agent.py
@@ -0,0 +1,246 @@
+"""Auto-resume for terminal local_agent tasks — Chunk F / WI-7.4.
+
+Mirrors ``typescript/src/tools/AgentTool/resumeAgent.ts``. When
+SendMessage targets a terminal-state agent (completed / failed /
+killed), instead of returning an error this module re-spawns the
+agent with the prior conversation reconstructed from its sidechain
+JSONL transcript (Chunk C / WI-2.2 — gate-zero).
+
+DIP claim (concern C6 from refactoring-plan review): this module
+depends on ``TranscriptReader`` (the interface, not the writer's IO
+layer) — the reader is the canonical consumer for ``state.output_file``.
+
+Race guard
+----------
+
+Two concurrent SendMessage calls to the same dead agent_id should
+NOT both spawn replacement runs. The atomic claim:
+
+1. Read the registry entry.
+2. If state is terminal AND ``not state.is_resuming``, set
+ ``is_resuming=True`` and return "this caller wins."
+3. Else return "another caller is resuming; queue the message via
+ ``queue_pending_message`` instead."
+
+The check + flip are one ``runtime_tasks.update`` mutator call —
+atomic under the registry's RLock. Two concurrent callers see
+exactly one winner.
+
+Pending-message handoff
+-----------------------
+
+The caller that wins the resume race carries the SendMessage payload
+into the resumed run by passing it as the new ``prompt``. Losers
+queue their messages onto the new running state via
+``queue_pending_message`` (which by then sees the running entry the
+winner just registered).
+"""
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, replace
+from typing import Any, TYPE_CHECKING
+
+from src.agent.transcript import TranscriptReader
+from src.tasks.local_agent import (
+ LocalAgentTaskState,
+ register_async_agent,
+)
+from src.tasks_core import is_terminal_task_status
+
+if TYPE_CHECKING:
+ from src.task_registry import RuntimeTaskRegistry
+ from src.tool_system.context import ToolContext
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class ResumeResult:
+ """Outcome of a resume attempt.
+
+ * ``resumed``: True iff this caller won the race and re-spawned
+ the agent. False means another caller got there first or the
+ target agent isn't actually terminal.
+ * ``agent_id``: the resumed agent's id (same as the original).
+ * ``output_file``: transcript path on disk.
+ * ``replayed_message_count``: number of messages reconstructed
+ from the transcript.
+ * ``reason``: human-readable status (only populated on the loser
+ / no-op paths).
+ """
+
+ resumed: bool
+ agent_id: str
+ output_file: str = ""
+ replayed_message_count: int = 0
+ reason: str = ""
+
+
+def _claim_resume(
+ agent_id: str,
+ runtime_tasks: "RuntimeTaskRegistry",
+) -> tuple[bool, LocalAgentTaskState | None]:
+ """Atomic check-and-claim — race-safe resume gate.
+
+ Returns ``(won, prev_state)``:
+ * ``(True, terminal_state)`` — caller is the resume winner; the
+ registry entry now has ``is_resuming=True`` so concurrent
+ callers see the terminal flag and back off.
+ * ``(False, None)`` — caller lost (or task isn't resumable).
+
+ The ``is_resuming`` bookkeeping lives on
+ ``LocalAgentTaskState.is_resuming`` (Chunk-F field; the dataclass
+ grew the flag for this WI).
+
+ **Load-bearing invariant (critic Chunk-F C1):** the path from this
+ helper to ``register_async_agent`` (in
+ ``resume_agent_background``) MUST stay synchronous. Loser callers
+ rely on observing the winner's *running* fresh state — they fall
+ back to ``queue_pending_message``, which refuses terminal states.
+ If a future refactor makes ``_reconstruct_messages_from_transcript``
+ async (e.g., streaming reads of large transcripts), the winner
+ yields control mid-path; the loser then observes
+ ``is_resuming=True`` on a still-terminal state and the
+ ``queue_pending_message`` no-ops, silently dropping the loser's
+ message. If async is needed later, the fix is to gate the
+ loser's ``queue_pending_message`` with a state-refresh-loop that
+ waits for the winner to land the running state.
+ """
+ won = False
+ captured: LocalAgentTaskState | None = None
+
+ def _maybe_claim(prev: Any) -> Any:
+ nonlocal won, captured
+ if not isinstance(prev, LocalAgentTaskState):
+ return prev
+ if not is_terminal_task_status(prev.status):
+ return prev # not terminal — nothing to resume
+ if getattr(prev, "is_resuming", False):
+ return prev # someone else is already resuming
+ won = True
+ captured = prev
+ return replace(prev, is_resuming=True)
+
+ runtime_tasks.update(agent_id, _maybe_claim)
+ return won, captured
+
+
+def _reconstruct_messages_from_transcript(transcript_path: str) -> list[Any]:
+ """Read the JSONL transcript and return parseable message objects.
+
+ Tolerant of trailing partial lines (writer-crashed-mid-write —
+ the same case the chapter-correct ``TranscriptReader`` already
+ handles). Returns the raw dict / Message objects that the reader
+ yields; the caller decides how to hydrate them into typed
+ ``Message`` subclasses.
+ """
+ return TranscriptReader(transcript_path).read_all()
+
+
+async def resume_agent_background(
+ *,
+ agent_id: str,
+ prompt: str,
+ context: "ToolContext",
+) -> ResumeResult:
+ """Re-spawn a stopped agent's background lifecycle with ``prompt``
+ as the resume message.
+
+ Returns a ``ResumeResult`` describing the outcome:
+
+ * Winner of the race → ``resumed=True``; the registry holds a
+ fresh ``LocalAgentTaskState`` for ``agent_id`` with status
+ ``"running"``. The transcript is read from disk and counted in
+ ``replayed_message_count`` for the caller's diagnostics.
+ * Loser → ``resumed=False``, ``reason`` describes the situation.
+ Caller should typically follow up with ``queue_pending_message``
+ to deliver the prompt to the now-running agent.
+ * Target not terminal / not present → ``resumed=False`` with a
+ reason like ``"task not terminal"`` or ``"task not found"``.
+
+ **The resume run does NOT actually drive a model call in this
+ chunk.** Wiring the resumed lifecycle into ``run_agent`` requires
+ threading the reconstructed messages and the resume prompt through
+ ``RunAgentParams`` — that's a subsequent integration step. This
+ chunk lands the structural primitive (race-safe re-registration +
+ transcript replay scaffolding) so SendMessage's auto-resume
+ branch has something to call. The resumed entry's ``status`` is
+ ``"running"`` so other callers see it and queue rather than
+ re-resume.
+ """
+ runtime = context.runtime_tasks
+ state = runtime.get(agent_id)
+
+ if state is None:
+ return ResumeResult(
+ resumed=False, agent_id=agent_id,
+ reason="task not found in runtime_tasks",
+ )
+
+ if not isinstance(state, LocalAgentTaskState):
+ return ResumeResult(
+ resumed=False, agent_id=agent_id,
+ reason=f"task type {state.type!r} is not local_agent",
+ )
+
+ if not is_terminal_task_status(state.status):
+ return ResumeResult(
+ resumed=False, agent_id=agent_id,
+ reason=f"task is {state.status!r}, not terminal",
+ )
+
+ won, prev = _claim_resume(agent_id, runtime)
+ if not won or prev is None:
+ # Another caller won the race; the registry entry is now
+ # ``is_resuming=True`` (and likely already replaced by the
+ # winner with a fresh running state). Return a no-op so the
+ # SendMessage caller knows to fall back to queueing.
+ return ResumeResult(
+ resumed=False, agent_id=agent_id,
+ reason="another caller is resuming; queue your message instead",
+ )
+
+ # Reconstruct the prior conversation. Errors here are non-fatal —
+ # the resumed run still gets the new prompt; it just lacks the
+ # historical context.
+ transcript_path = prev.output_file
+ replayed: list[Any] = []
+ try:
+ replayed = _reconstruct_messages_from_transcript(transcript_path)
+ except Exception:
+ logger.exception(
+ "transcript reconstruction failed for %s; resuming without history",
+ agent_id,
+ )
+
+ # Re-register the agent with a fresh running state. ``register_async_agent``
+ # ``upsert``s, replacing the terminal entry. The new state has
+ # ``is_resuming=False`` (default) so a future resume can fire if
+ # this run also completes. Carry the resume ``prompt`` into
+ # pending_messages so the resumed run picks it up at its first
+ # tool-round drain (chapter-correct behavior — Chunk D / WI-3.3).
+ fresh_state = register_async_agent(
+ agent_id=agent_id,
+ description=prev.description,
+ prompt=prompt, # the SendMessage payload is the resume prompt
+ agent_type=prev.agent_type,
+ selected_agent=prev.selected_agent,
+ model=prev.model,
+ tool_use_id=prev.tool_use_id,
+ registry=runtime,
+ )
+
+ return ResumeResult(
+ resumed=True,
+ agent_id=agent_id,
+ output_file=fresh_state.output_file,
+ replayed_message_count=len(replayed),
+ reason="",
+ )
+
+
+__all__ = [
+ "ResumeResult",
+ "resume_agent_background",
+]
diff --git a/src/agent/run_agent.py b/src/agent/run_agent.py
index 5372c0939..e361ca9c3 100644
--- a/src/agent/run_agent.py
+++ b/src/agent/run_agent.py
@@ -5,6 +5,7 @@
"""
from __future__ import annotations
+import copy
import logging
import time
from dataclasses import dataclass, field
@@ -18,10 +19,14 @@
from ..tool_system.registry import ToolRegistry
from ..types.content_blocks import ToolUseBlock
from ..types.messages import AssistantMessage, Message, UserMessage
-from ..utils.abort_controller import AbortController, create_child_abort_controller
+from ..utils.abort_controller import AbortController
from .agent_definitions import AgentDefinition, is_built_in_agent
-from .agent_tool_utils import resolve_agent_tools, count_tool_uses
+from .agent_tool_utils import (
+ count_tool_uses,
+ get_query_source_for_agent,
+ resolve_agent_tools,
+)
from .constants import AGENT_TOOL_NAME
from .prompt import get_agent_system_prompt
from .subagent_context import SubagentContextOverrides, create_subagent_context
@@ -49,14 +54,25 @@ class RunAgentParams:
# Optional overrides
model: str | None = None
agent_id: str | None = None
+ # QUERY-1 — the spawn's addressable name (Agent tool `name` param).
+ # A NAMED agent inside a team is this port's teammate: the identity is
+ # threaded to the subagent context so teammate stop hooks can gate.
+ agent_name: str | None = None
is_async: bool = False
max_turns: int | None = None
system_prompt_override: str | None = None
- parent_system_prompt: str | None = None
+ parent_system_prompt: "str | list | None" = None
permission_mode_override: PermissionMode | None = None
context_messages: list[Message] | None = None
abort_controller: AbortController | None = None
on_message: Any = None
+ # When True, use ``available_tools`` directly without filtering through
+ # ``resolve_agent_tools()``. Mirrors the ``useExactTools`` flag from
+ # typescript/src/tools/AgentTool/runAgent.ts (the fork path).
+ use_exact_tools: bool = False
+ # Identifier threaded into ``ToolUseOptions.query_source`` for the
+ # primary recursive-fork guard. Mirrors the TS ``querySource`` argument.
+ query_source: str | None = None
@dataclass
@@ -103,8 +119,45 @@ def _build_permission_context(
effective_mode: PermissionMode,
is_async: bool,
) -> ToolPermissionContext:
- """Build the permission context for the subagent."""
+ """Build the permission context for the subagent.
+
+ Mirrors the prompt-avoidance cascade in
+ ``typescript/src/tools/AgentTool/runAgent.ts:449-476``:
+
+ 1. ``should_avoid_permission_prompts`` is True iff the parent already
+ avoids prompts OR the agent is async AND its effective mode is
+ not ``bubble``. Bubble mode preserves prompts even when async,
+ because the bubble path surfaces them to the parent terminal.
+ 2. ``await_automated_checks_before_dialog`` is True for async agents
+ whose prompts are still enabled — today that means bubble mode.
+ It signals the permission system to run the classifier / hooks
+ before interrupting the user.
+
+ The TS implementation also reads ``canShowPermissionPrompts`` as an
+ explicit caller override. The Python ``RunAgentParams`` does not yet
+ plumb that flag, so the cascade reduces to the ``isAsync`` /
+ ``agentPermissionMode === 'bubble'`` branch. Round-2 docs flag the
+ full ``canShowPermissionPrompts`` thread-through as out-of-scope.
+ """
parent_perm = parent_context.permission_context
+
+ # TS cascade: bubble mode is the only async mode that can still
+ # show prompts (they bubble to the parent terminal). Every other
+ # async agent must auto-deny rather than block on a missing UI.
+ if effective_mode == "bubble":
+ avoid_for_isolation = False
+ else:
+ avoid_for_isolation = is_async
+ should_avoid = (
+ parent_perm.should_avoid_permission_prompts or avoid_for_isolation
+ )
+
+ # Async-but-can-prompt agents wait for classifier / hooks before
+ # interrupting the user. Sync agents and prompt-avoiding agents
+ # skip this — there is either no async to wait inside of, or no
+ # dialog to delay.
+ await_automated = is_async and not should_avoid
+
return ToolPermissionContext(
mode=effective_mode,
additional_working_directories=parent_perm.additional_working_directories,
@@ -112,41 +165,63 @@ def _build_permission_context(
always_deny_rules=parent_perm.always_deny_rules,
always_ask_rules=parent_perm.always_ask_rules,
is_bypass_permissions_mode_available=parent_perm.is_bypass_permissions_mode_available,
- should_avoid_permission_prompts=(
- parent_perm.should_avoid_permission_prompts or is_async
- ),
+ should_avoid_permission_prompts=should_avoid,
+ await_automated_checks_before_dialog=await_automated,
)
def filter_incomplete_tool_calls(messages: list[Message]) -> list[Message]:
- """Remove trailing assistant messages that have incomplete tool_use blocks.
+ """Remove assistant messages that contain incomplete tool_use blocks.
Mirrors filterIncompleteToolCalls() from typescript/src/tools/AgentTool/runAgent.ts.
- When an agent is interrupted, the last assistant message may contain tool_use
- blocks without corresponding tool_result messages. Sending these would cause
- an API error. This function removes such trailing messages.
+ Assistant messages with tool_use blocks are only valid if each tool_use has a
+ corresponding tool_result block in a user message. This function removes any
+ assistant message containing unresolved tool_use IDs.
"""
- if not messages:
- return messages
-
- result = list(messages)
- while result:
- last = result[-1]
- if not isinstance(last, AssistantMessage):
- break
- content = last.content
- if not isinstance(content, list):
- break
- has_tool_use = any(
- isinstance(block, ToolUseBlock) for block in content
- )
- if not has_tool_use:
- break
- # Check if there's a following user message with tool_result
- result.pop()
+ tool_use_ids_with_results: set[str] = set()
- return result
+ for message in messages:
+ if not isinstance(message, UserMessage):
+ continue
+ content = message.content
+ if not isinstance(content, list):
+ continue
+ for block in content:
+ block_type = block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
+ if block_type != "tool_result":
+ continue
+ tool_use_id = (
+ block.get("tool_use_id")
+ if isinstance(block, dict)
+ else getattr(block, "tool_use_id", None)
+ )
+ if isinstance(tool_use_id, str) and tool_use_id:
+ tool_use_ids_with_results.add(tool_use_id)
+
+ filtered: list[Message] = []
+ for message in messages:
+ if isinstance(message, AssistantMessage):
+ content = message.content
+ if isinstance(content, list):
+ has_incomplete_tool_call = False
+ for block in content:
+ block_type = block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
+ if block_type != "tool_use":
+ continue
+ tool_use_id = (
+ block.get("id")
+ if isinstance(block, dict)
+ else getattr(block, "id", None)
+ )
+ if isinstance(tool_use_id, str) and tool_use_id not in tool_use_ids_with_results:
+ has_incomplete_tool_call = True
+ break
+ if has_incomplete_tool_call:
+ continue
+ filtered.append(message)
+
+ return filtered
async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
@@ -169,27 +244,35 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
start_time = time.time()
agent_messages: list[Message] = []
- # Resolve permission mode
- effective_mode = resolve_permission_mode(
+ # Resolve permission mode. An explicit ``permission_mode_override`` wins
+ # (used by the workflow engine to force ``acceptEdits`` for its subagents);
+ # otherwise fall back to the inheritance rules.
+ effective_mode = params.permission_mode_override or resolve_permission_mode(
params.parent_context,
agent_def,
is_async=params.is_async,
)
- # Resolve tools for the agent
- resolved = resolve_agent_tools(
- agent_def,
- params.available_tools,
- is_async=params.is_async,
- )
- agent_tools = resolved.resolved_tools
-
- if resolved.invalid_tools:
- logger.warning(
- "Agent %s has invalid tools: %s",
- agent_def.agent_type,
- resolved.invalid_tools,
+ # Resolve tools for the agent.
+ # When ``use_exact_tools`` is set (fork path), bypass ``resolve_agent_tools``
+ # so the child receives the parent's exact tool array. Mirrors the
+ # ``useExactTools`` branch in typescript/src/tools/AgentTool/runAgent.ts:513.
+ if params.use_exact_tools:
+ agent_tools = list(params.available_tools)
+ else:
+ resolved = resolve_agent_tools(
+ agent_def,
+ params.available_tools,
+ is_async=params.is_async,
)
+ agent_tools = resolved.resolved_tools
+
+ if resolved.invalid_tools:
+ logger.warning(
+ "Agent %s has invalid tools: %s",
+ agent_def.agent_type,
+ resolved.invalid_tools,
+ )
# Build system prompt
system_prompt = (
@@ -197,19 +280,22 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
or get_agent_system_prompt(agent_def, params.parent_system_prompt)
)
- # Determine abort controller
+ # Determine abort controller.
+ # ``params.parent_context.abort_controller`` is now non-optional on
+ # the ``ToolContext`` dataclass, so the legacy "parent has no
+ # controller → mint a fresh one" branch is gone. The remaining
+ # priority order is: explicit caller override → fresh controller
+ # for async (so background agents survive parent cancel) →
+ # share with parent for sync (so parent ESC propagates).
if params.abort_controller is not None:
abort_controller = params.abort_controller
- elif params.is_async and params.parent_context.abort_controller is not None:
- # Async agents get an independent abort controller (child linked to parent)
- abort_controller = create_child_abort_controller(
- params.parent_context.abort_controller
- )
- elif params.parent_context.abort_controller is not None:
+ elif params.is_async:
+ # Async agents run independently in the background and should survive
+ # parent cancellation events.
+ abort_controller = AbortController()
+ else:
# Sync agents share abort with parent
abort_controller = params.parent_context.abort_controller
- else:
- abort_controller = AbortController()
# Build permission context
perm_context = _build_permission_context(
@@ -218,16 +304,38 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
params.is_async,
)
+ # Strip orphaned tool_use blocks before threading parent context into the
+ # child. Mirrors typescript/src/tools/AgentTool/runAgent.ts:381-385 — the
+ # API rejects assistant messages whose tool_use IDs lack matching
+ # tool_result IDs.
+ if params.context_messages:
+ sanitized_context_messages = filter_incomplete_tool_calls(params.context_messages)
+ else:
+ sanitized_context_messages = []
+
+ # When the fork path threads its own ``query_source`` (e.g.
+ # ``"agent:builtin:fork"``), surface it on the child's options so the
+ # primary recursive-fork guard at the Agent tool's call site can read it.
+ options_override = None
+ if params.query_source is not None:
+ # Shallow-copy the parent options so we don't mutate them in place.
+ from copy import copy as _shallow_copy
+ options_override = _shallow_copy(params.parent_context.options)
+ options_override.query_source = params.query_source
+
# Build overrides
overrides = SubagentContextOverrides(
agent_id=agent_id,
agent_type=agent_def.agent_type,
- messages=params.context_messages or [],
+ teammate_name=params.agent_name,
+ messages=sanitized_context_messages,
abort_controller=abort_controller,
permission_context=perm_context,
share_abort_controller=not params.is_async,
- share_set_response_length=not params.is_async,
+ # Both sync and async subagents should contribute to response-length metrics.
+ share_set_response_length=True,
share_permission_handler=not params.is_async,
+ options=options_override,
)
# Create isolated context
@@ -236,27 +344,64 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
overrides,
)
- # Build initial messages
- prompt_message = UserMessage(content=params.prompt)
- initial_messages: list[Message] = list(subagent_context.messages) + [prompt_message]
+ # Build initial messages.
+ # When ``params.prompt`` is empty (e.g. fork path, where the directive is
+ # already embedded inside ``context_messages`` via build_forked_messages),
+ # do not append an empty user turn — it would shift the cache boundary
+ # and confuse the model with a blank input.
+ if params.prompt:
+ prompt_message = UserMessage(content=params.prompt)
+ initial_messages: list[Message] = list(subagent_context.messages) + [prompt_message]
+ else:
+ initial_messages = list(subagent_context.messages)
# Determine max turns — mirrors TS: maxTurns ?? agentDefinition.maxTurns
# TS has no built-in fallback, but we keep a safety net to prevent runaway agents.
max_turns = params.max_turns or agent_def.max_turns or SUBAGENT_DEFAULT_MAX_TURNS
+ # ch08 round-4 WI-1 — per-subagent model resolution (TS getAgentModel,
+ # runAgent.ts:340). Resolve the model from the tool param / agent-def /
+ # env, then apply it to a per-subagent provider CLONE. NEVER mutate the
+ # shared session provider: ch07 made Agent concurrency-safe, so N
+ # parallel subagents share params.provider — mutating provider.model
+ # would race across them. copy.copy shares the HTTP client (thread-safe,
+ # per-request model) and gives this subagent its own .model.
+ turn_provider = params.provider
+ try:
+ from .agent_model import get_agent_model
+
+ resolved_model = get_agent_model(
+ params.model, agent_def.model, params.provider,
+ )
+ if resolved_model and resolved_model != getattr(
+ params.provider, "model", None
+ ):
+ turn_provider = copy.copy(params.provider)
+ turn_provider.model = resolved_model
+ except Exception: # noqa: BLE001 — model resolution never blocks a spawn
+ logger.debug("subagent model resolution failed; using session model",
+ exc_info=True)
+ turn_provider = params.provider
+
# --- Query loop ---
# TS microcompact is a no-op for subagents (only fires for
# repl_main_thread). Don't pass pipeline_config so we don't
# aggressively clear tool results the model just read.
+ # ch08 round-4 WI-2 — query_source labeling parity (agent:builtin:
+ # / agent:custom), TS promptCategory.getQuerySourceForAgent. The fork
+ # path threads its own 'agent:builtin:fork' via params.query_source.
+ effective_query_source = params.query_source or get_query_source_for_agent(
+ agent_def.agent_type, is_built_in_agent(agent_def),
+ )
query_params = QueryParams(
messages=initial_messages,
system_prompt=system_prompt,
tools=agent_tools,
tool_registry=params.tool_registry,
tool_use_context=subagent_context,
- provider=params.provider,
+ provider=turn_provider,
abort_controller=abort_controller,
- query_source=f"agent_{agent_def.agent_type}",
+ query_source=effective_query_source,
max_turns=max_turns,
)
@@ -277,6 +422,19 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
logger.error("Agent %s (%s) failed: %s", agent_id, agent_def.agent_type, exc)
raise
finally:
+ # Reap any run_in_background bash this agent spawned, so a shell loop
+ # doesn't outlive the agent as a PPID=1 zombie. Placed in the CORE
+ # generator's finally (the single path every agent — async, sync, and
+ # workflow — traverses), matching TS's killShellTasksForAgent at
+ # runAgent.ts:849. Never raises.
+ try:
+ from src.tasks.local_shell import kill_shell_tasks_for_agent
+
+ registry = getattr(subagent_context, "runtime_tasks", None)
+ if registry is not None:
+ await kill_shell_tasks_for_agent(agent_id, registry)
+ except Exception: # noqa: BLE001 — cleanup must not break agent exit
+ logger.debug("kill_shell_tasks_for_agent failed", exc_info=True)
# Cleanup: release cloned file state cache memory
subagent_context.read_file_fingerprints.clear()
# Release initial messages
diff --git a/src/agent/session.py b/src/agent/session.py
index b740113b4..f678aef3c 100644
--- a/src/agent/session.py
+++ b/src/agent/session.py
@@ -1,13 +1,38 @@
-"""Session management with persistence."""
+"""Session management with persistence.
+
+The session ID is authoritative-from-bootstrap: ``Session.create`` reads
+``get_session_id()`` rather than generating its own. This fixes the
+strftime-collision bug (sessions started in the same second would have
+overlapped IDs) and unifies session identity across the codebase — the
+bootstrap singleton is the single source of truth, exactly per Chapter 3.
+
+``Session.load(sid)`` continues to read from disk by ID; the resume path
+should call ``switch_session(SessionId(sid))`` first (or via a wrapping
+helper) to update the bootstrap singleton, then call ``Session.load(sid)``
+to reconstruct the per-conversation Persistence record.
+"""
from __future__ import annotations
import json
+import time
from pathlib import Path
from datetime import datetime
from typing import Optional
from dataclasses import dataclass, field
+from src.bootstrap.state import (
+ get_model_usage,
+ get_session_id,
+ get_start_time,
+ get_total_api_duration,
+ get_total_api_duration_without_retries,
+ get_total_cost_usd,
+ get_total_lines_added,
+ get_total_lines_removed,
+ get_total_tool_duration,
+)
+
from .conversation import Conversation
@@ -22,19 +47,30 @@ class Session:
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
def save(self):
- """Save session to disk."""
+ """Save session to disk including a cost block.
+
+ Ch03 round-2 (R2.1): the ``cost`` key matches the schema read by
+ ``src/services/cost_restore.py:restore_cost_state_for_session``
+ so a save → load round-trip restores bootstrap counters
+ (`total_cost_usd`, durations, lines added/removed, per-model
+ usage). Previously this method emitted no cost block; the
+ restore reader hit defaults of 0 unconditionally.
+ """
session_dir = Path.home() / ".clawcodex" / "sessions"
session_dir.mkdir(parents=True, exist_ok=True)
session_file = session_dir / f"{self.session_id}.json"
+ cost_block = _snapshot_cost_block()
+
session_data = {
"session_id": self.session_id,
"provider": self.provider,
"model": self.model,
"conversation": self.conversation.to_dict(),
"created_at": self.created_at,
- "updated_at": datetime.now().isoformat()
+ "updated_at": datetime.now().isoformat(),
+ "cost": cost_block,
}
with open(session_file, 'w') as f:
@@ -64,10 +100,57 @@ def load(cls, session_id: str) -> Optional['Session']:
@classmethod
def create(cls, provider: str, model: str) -> 'Session':
- """Create a new session."""
- session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
+ """Create a new session using the bootstrap singleton's session ID.
+
+ Previously this generated its own strftime-based ID, producing
+ collisions when two sessions started in the same second and
+ diverging from the rest of the codebase. Now reads
+ ``get_session_id()`` — a UUID-based ID generated at bootstrap
+ import time — so every consumer that talks about "the current
+ session" agrees on the identifier.
+ """
return cls(
- session_id=session_id,
+ session_id=get_session_id(),
provider=provider,
- model=model
+ model=model,
)
+
+ @classmethod
+ def resume(cls, session_id: str) -> Optional['Session']:
+ """Resume a session: update bootstrap identity, restore cost,
+ reconstruct the per-conversation record from disk.
+
+ Ch03 round-2 (R2.2): single entry point that keeps the three
+ operations in lockstep (CC-34 single-setter discipline at the
+ resume layer). Callers (REPL ``/resume``, headless / SDK)
+ should use this rather than calling ``Session.load`` plus
+ ``switch_session`` plus ``restore_cost_state_for_session``
+ independently.
+
+ Order matters: ``switch_session`` fires BEFORE
+ ``restore_cost_state_for_session`` so any subscriber that reads
+ ``get_session_id()`` during the cost restore sees the loaded id.
+ """
+ from src.bootstrap.state import SessionId, switch_session
+ from src.services.cost_restore import restore_cost_state_for_session
+
+ loaded = cls.load(session_id)
+ if loaded is None:
+ return None
+ switch_session(SessionId(session_id))
+ restore_cost_state_for_session(session_id)
+ return loaded
+
+
+def _snapshot_cost_block() -> dict:
+ """Build the cost block written by ``Session.save``.
+
+ ch03 round-4 GAP B: the schema owner moved to
+ ``src/services/cost_restore.py:build_cost_block`` (colocated with the
+ reader so writer/reader can't drift; also used by the LIVE
+ agent-server persister). This delegation is kept for the existing
+ tests/callers of ``Session.save``.
+ """
+ from src.services.cost_restore import build_cost_block
+
+ return build_cost_block()
diff --git a/src/agent/subagent_context.py b/src/agent/subagent_context.py
index 18ed2af01..c04900008 100644
--- a/src/agent/subagent_context.py
+++ b/src/agent/subagent_context.py
@@ -34,6 +34,9 @@ class SubagentContextOverrides:
options: ToolUseOptions | None = None
agent_id: str | None = None
agent_type: str | None = None
+ # QUERY-1 — teammate identity (the Agent tool's `name`); None for
+ # anonymous subagents.
+ teammate_name: str | None = None
messages: list[Any] | None = None
read_file_state: dict[Any, Any] | None = None
abort_controller: AbortController | None = None
@@ -74,15 +77,16 @@ def create_subagent_context(
overrides = SubagentContextOverrides()
# --- Abort controller ---
- # Priority: explicit override > share parent's > new child linked to parent
+ # Priority: explicit override > share parent's > new child linked to parent.
+ # ``parent_context.abort_controller`` is now non-optional on the
+ # ``ToolContext`` dataclass, so the legacy "parent has no controller"
+ # branch is gone — every parent context carries a real controller.
if overrides.abort_controller is not None:
abort_controller = overrides.abort_controller
- elif overrides.share_abort_controller and parent_context.abort_controller is not None:
+ elif overrides.share_abort_controller:
abort_controller = parent_context.abort_controller
- elif parent_context.abort_controller is not None:
- abort_controller = create_child_abort_controller(parent_context.abort_controller)
else:
- abort_controller = AbortController()
+ abort_controller = create_child_abort_controller(parent_context.abort_controller)
# --- Permission context ---
# If sharing abort controller, it's interactive and can show UI.
@@ -107,7 +111,24 @@ def create_subagent_context(
read_file_fingerprints: dict[Any, Any] = {}
# --- Options ---
- options = overrides.options if overrides.options is not None else parent_context.options
+ # ch07 round-4 (critic MAJOR): SHALLOW-COPY the parent options even on
+ # the non-override path. The query loop does
+ # ``tool_use_context.options.tools = list(params.tools)`` per query
+ # (query.py:1207), and partition/lookup read it back. Without a copy,
+ # N parallel foreground subagents (now that Agent is concurrency-safe)
+ # — and pre-existing workflow parallel() fan-out — share ONE options
+ # object across OS threads and race on ``.tools`` (one thread's tool
+ # list clobbers another's partition/lookup). A shallow copy gives each
+ # subagent its own options object; the referenced sub-lists it will
+ # overwrite wholesale, not mutate in place, so shallow is sufficient.
+ if overrides.options is not None:
+ options = overrides.options
+ elif parent_context.options is not None:
+ import copy as _copy
+
+ options = _copy.copy(parent_context.options)
+ else:
+ options = None
# --- Messages ---
messages = overrides.messages if overrides.messages is not None else list(parent_context.messages)
@@ -160,7 +181,24 @@ def create_subagent_context(
lsp_client=parent_context.lsp_client,
# Fresh isolated collections
todos=[],
- tasks={},
+ # QUERY-1 — the task BOARD is shared for TEAMMATE spawns (named
+ # agent + parent team): TS keeps one board (AppState.tasks; the
+ # team file), so a teammate's TaskCompleted stop hooks can see the
+ # tasks the leader assigned it. Anonymous subagents keep the ch10
+ # fresh-isolation semantics. INVARIANT (critic M1): this is a plain
+ # dict shared by reference — NOT RLock-guarded like runtime_tasks.
+ # Readers snapshot (list()) before filtering; if teammate fan-out
+ # ever mutates the board from worker threads, guard it like the
+ # sibling stores.
+ tasks=(
+ parent_context.tasks
+ if (
+ overrides.teammate_name
+ and isinstance(parent_context.team, dict)
+ and parent_context.team.get("team_name")
+ )
+ else {}
+ ),
outbox=[],
crons={},
# No-op / None for UI callbacks
@@ -182,7 +220,38 @@ def create_subagent_context(
content_replacement_state=content_replacement_state,
agent_id=agent_id,
agent_type=agent_type,
+ # QUERY-1 — teammate identity: name from the spawn; team from the
+ # parent's team file (both required by the stop-hook gate, matching
+ # TS teammate.ts:125-131 — a named agent OUTSIDE a team is not a
+ # teammate).
+ teammate_name=overrides.teammate_name,
+ team_name=(
+ (parent_context.team or {}).get("team_name")
+ if isinstance(parent_context.team, dict)
+ else None
+ ),
user_modified=parent_context.user_modified,
+ # ch01 round-4 WI-1 — hooks apply to sub-agents exactly as to the
+ # parent: same config snapshot, same workspace-trust verdict.
+ # Without this, PreToolUse/PostToolUse (incl. enterprise policy
+ # hooks) silently skip every sub-agent tool call — a policy bypass
+ # via the Agent tool — and the PostSampling trust filter would
+ # treat sub-agent loops as untrusted in a trusted workspace.
+ hook_config_manager=parent_context.hook_config_manager,
+ workspace_trusted=parent_context.workspace_trusted,
+ # ch10 round-4 WI-1 — SHARE the parent's task + name registries
+ # (do NOT fall to fresh empty instances). TS keeps all task state
+ # in a single AppState.tasks threaded everywhere; the port made
+ # these per-ToolContext, so a background child ran with its OWN
+ # empty runtime_tasks. A parent SendMessage(to=name) queued the
+ # message into the PARENT's registry, but the child drained its
+ # own empty one → the message was SILENTLY DROPPED while the tool
+ # reported "queued for delivery." Sharing the same instances (both
+ # are RLock-guarded — safe under the ch07 parallel-agent fan-out)
+ # restores TS's single-shared-store semantics so the child drains
+ # the same store the parent queued into.
+ runtime_tasks=parent_context.runtime_tasks,
+ agent_name_registry=parent_context.agent_name_registry,
)
diff --git a/src/agent/transcript.py b/src/agent/transcript.py
new file mode 100644
index 000000000..529851577
--- /dev/null
+++ b/src/agent/transcript.py
@@ -0,0 +1,334 @@
+"""Sidechain JSONL transcript writer + reader — Chunk C / WI-2.2.
+
+**This is gate-zero for Chunk D and Chunk F.** Without on-disk transcript
+persistence:
+
+* Chapter §"Background: Three Channels" can't expose ``outputFile`` /
+ ``outputOffset`` to ``TaskOutput`` (Chunk D / WI-3.1 / gap #7).
+* Chapter §"Auto-Resume Pattern" can't reconstruct the conversation
+ history from disk (Chunk F / WI-7.4 / gap #10).
+
+Both depend on a JSONL file at a stable path containing one JSON object
+per ``Message`` the agent produced. This module is the single source of
+truth for that path and that file format.
+
+Format
+------
+
+JSONL — one JSON object per line, encoded as UTF-8, terminated with
+``\\n``. Per assumption A4 (gap-analysis ambiguity #4): the trade-offs
+are atomic appends without an external lock library at the cost of
+losing "read whole inbox in one parse." The reader is tolerant of a
+trailing partial line (writer-crashed-mid-write case) — it logs once
+and skips.
+
+Concurrency
+-----------
+
+Writes go through ``os.write`` on an ``O_APPEND`` file descriptor. POSIX
+guarantees ``write()`` calls of size ≤ ``PIPE_BUF`` (typically 4096
+bytes) are atomic with respect to other writers; for a single agent
+producing one line per Message, lines stay well under that and
+interleaving cannot occur. Lines >4 KiB *can* interleave under heavy
+concurrency; the reader's tolerant parser absorbs the resulting partial
+line. Documented as a known limitation; a future revision could move to
+``filelock`` if the >4 KiB regime becomes routine.
+
+Out of scope (per critic concern C4 / Phase 11 follow-up)
+---------------------------------------------------------
+
+GC / rotation / age-based eviction. Transcripts grow unbounded under
+this WI; the chapter-11 follow-up ticket will land a cleanup policy.
+This module DOES expose ``get_agent_transcript_path`` / ``ensure_transcript_dir``
+so the future GC has a stable target to walk.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+from dataclasses import asdict, is_dataclass
+from pathlib import Path
+from typing import Any, Iterator
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Path helpers — stable across Writer / Reader / future GC
+# ---------------------------------------------------------------------------
+
+
+def _transcripts_root() -> Path:
+ """Return ``~/.clawcodex/transcripts/`` (created if absent).
+
+ Mirrors the directory convention that ``typescript/src/utils/sessionStorage.ts``
+ uses for sidechain transcripts; the Python repo's home-relative
+ ``~/.clawcodex`` matches the bash background dir at
+ ``/clawcodex-bg`` philosophically (per-user, not per-workspace).
+ """
+ root = Path.home() / ".clawcodex" / "transcripts"
+ root.mkdir(parents=True, exist_ok=True)
+ return root
+
+
+def get_agent_transcript_path(agent_id: str) -> str:
+ """Absolute path to ``/.jsonl``.
+
+ Returns a string (not a ``Path``) because ``LocalAgentTaskState.output_file``
+ is typed as ``str`` for serializability. Callers that prefer
+ ``Path(get_agent_transcript_path(...))`` can still wrap it.
+ """
+ safe_id = _sanitize_agent_id(agent_id)
+ return str(_transcripts_root() / f"{safe_id}.jsonl")
+
+
+def get_workflow_run_path(run_id: str) -> str:
+ """Absolute path to a workflow run's journal file,
+ ``~/.clawcodex/transcripts/workflows/.json``.
+
+ Workflow journals live alongside agent transcripts (the per-user session
+ storage root), not under the project tree, so resume state doesn't become
+ VCS noise. ``run_id`` is sanitized like an agent id."""
+ safe_id = _sanitize_agent_id(run_id)
+ root = _transcripts_root() / "workflows"
+ root.mkdir(parents=True, exist_ok=True)
+ return str(root / f"{safe_id}.json")
+
+
+def _sanitize_agent_id(agent_id: str) -> str:
+ """Reject path-traversing agent_ids before we touch the filesystem.
+
+ ``agent_id`` is internally generated by ``generate_task_id`` (CSPRNG
+ base36), so this is defense-in-depth — a future caller passing a
+ user-supplied id would otherwise be able to write to arbitrary
+ paths via ``../../etc/passwd``. Mirrors the chapter's symlink-attack
+ rationale on TS Task.ts:96.
+ """
+ if not agent_id or not all(c.isalnum() or c in "_-" for c in agent_id):
+ raise ValueError(
+ f"invalid agent_id for transcript path: {agent_id!r} "
+ "(allowed: alphanumeric + '_' + '-')"
+ )
+ if len(agent_id) > 64:
+ raise ValueError(f"agent_id too long ({len(agent_id)} > 64 chars)")
+ return agent_id
+
+
+# ---------------------------------------------------------------------------
+# JSON serialization helper — tolerant of Message dataclasses
+# ---------------------------------------------------------------------------
+
+
+def _serialize_message(message: Any) -> str:
+ """Convert a ``Message`` (or any JSON-shaped object) to a single
+ UTF-8 JSON line. Falls back to ``repr`` for non-serializable objects
+ so a malformed message can't bring the writer down — corrupt lines
+ are reader-tolerant per the format design.
+ """
+ if is_dataclass(message) and not isinstance(message, type):
+ # Chunk-D N1 fold-in (widened from `(TypeError, ValueError)`):
+ # a pathological dataclass __reduce__/property could leak any
+ # exception class. The transcript writer is a non-essential
+ # persistence layer — no failure mode justifies bringing the
+ # whole agent run down. Catch broadly and fall through to repr.
+ try:
+ payload = asdict(message)
+ except Exception:
+ payload = {"_unserializable": repr(message)}
+ elif isinstance(message, dict):
+ payload = message
+ else:
+ # Last-resort: try to serialize a str() of it.
+ payload = {"_unserializable": repr(message)}
+ try:
+ # ``ensure_ascii=False`` keeps unicode readable in transcripts;
+ # ``separators`` with no spaces keeps lines compact.
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+ except Exception as exc:
+ return json.dumps({"_unserializable": repr(message), "_error": str(exc)})
+
+
+# ---------------------------------------------------------------------------
+# Writer — append-only, O_APPEND atomic line writes
+# ---------------------------------------------------------------------------
+
+
+class TranscriptWriter:
+ """Append-only writer for the JSONL transcript at ``path``.
+
+ Opens an ``O_APPEND`` file descriptor on construction. Each
+ ``append(message)`` call serializes one line and emits it via a
+ single ``os.write`` so concurrent writers (e.g. the same agent
+ across crash-restart, or a future multi-writer scenario) cannot
+ interleave bytes at sub-PIPE_BUF line sizes.
+
+ The writer is **synchronous** (file IO, not asyncio). Per the A6/C5
+ contract, callers must NOT hold ``RuntimeTaskRegistry``'s RLock
+ across an ``append()`` call: while file IO is fast, blocking under
+ the registry's lock would deadlock the asyncio scheduler against
+ bash worker threads.
+ """
+
+ def __init__(self, path: str | Path) -> None:
+ self._path = str(path)
+ # Ensure parent dir exists (transcript root may not have been
+ # created yet if the caller bypassed ``get_agent_transcript_path``).
+ Path(self._path).parent.mkdir(parents=True, exist_ok=True)
+ # ``O_APPEND`` makes every write atomic at the file-position
+ # level. ``O_CLOEXEC`` keeps the fd from leaking to bash
+ # subprocesses. ``0o600`` because transcripts can contain
+ # sensitive prompt content — readable by the user only.
+ self._fd: int | None = os.open(
+ self._path,
+ os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_CLOEXEC,
+ 0o600,
+ )
+ self._closed = False
+
+ @property
+ def path(self) -> str:
+ return self._path
+
+ def append(self, message: Any) -> None:
+ """Append one message as a UTF-8 JSON line, terminated with ``\\n``.
+
+ Crash-safety: a single ``os.write`` of the line ensures the
+ writer either appends the whole line or nothing. POSIX
+ guarantees this for sizes ≤ ``PIPE_BUF`` (≥4096 bytes on every
+ modern Unix); for larger lines the reader is tolerant of
+ partial trailing content per the JSONL format design.
+ """
+ if self._closed or self._fd is None:
+ raise RuntimeError("TranscriptWriter is closed")
+ line = _serialize_message(message) + "\n"
+ encoded = line.encode("utf-8")
+ # ``os.write`` may short-write under specific OS conditions;
+ # loop until the whole buffer is on disk. For O_APPEND files
+ # the returned ``n`` is byte count of THIS write, so the loop
+ # is straightforward.
+ view = memoryview(encoded)
+ while view:
+ written = os.write(self._fd, view)
+ if written <= 0:
+ # Defensive: 0-byte returns shouldn't happen on regular
+ # files but the loop would spin forever otherwise.
+ raise OSError(f"transcript write returned {written}")
+ view = view[written:]
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ fd, self._fd = self._fd, None
+ if fd is not None:
+ try:
+ os.close(fd)
+ except OSError:
+ logger.exception("transcript close failed for %s", self._path)
+
+ # Context-manager support so callers can write `with TranscriptWriter(...) as w:`
+ def __enter__(self) -> "TranscriptWriter":
+ return self
+
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+ self.close()
+
+
+# ---------------------------------------------------------------------------
+# Reader — tolerant of trailing partial lines and missing files
+# ---------------------------------------------------------------------------
+
+
+class TranscriptReader:
+ """Replay companion for ``resume_agent_background`` (Chunk F / WI-7.4).
+
+ Reads the JSONL transcript line-by-line, parsing each into a Python
+ object (typically a dict mirroring the original ``asdict(Message)``
+ payload). Callers with a typed Message hierarchy can hydrate the
+ dicts back into ``AssistantMessage``/``UserMessage`` etc. via their
+ own factories — this reader stays loose-typed to avoid a cycle
+ between the agent module and the transcript module.
+
+ Tolerant of:
+ * **Missing file** — yields nothing rather than raising.
+ * **Trailing partial line** — log-once-and-skip rather than poison
+ the entire history. Mirrors the chapter §"Mailbox" approach.
+ * **Embedded blank lines** — skipped.
+
+ Defining the reader here (per critic concern C6) so Chunk F's
+ ``resume_agent_background`` SOLID DIP claim has a real interface
+ to depend on rather than the writer's IO layer.
+ """
+
+ def __init__(self, path: str | Path) -> None:
+ self._path = str(path)
+ self._logged_partial = False
+
+ @property
+ def path(self) -> str:
+ return self._path
+
+ def __iter__(self) -> Iterator[Any]:
+ return self._iterate()
+
+ def _iterate(self) -> Iterator[Any]:
+ """Yield one parsed object per line; skip blank/unparseable lines."""
+ try:
+ handle = open(self._path, "rb")
+ except FileNotFoundError:
+ return
+ try:
+ for raw_line in handle:
+ # raw_line still has its trailing newline; strip and skip
+ # blanks. Decode is utf-8 with replacement so a corrupt
+ # byte doesn't crash the iterator.
+ #
+ # N2 caveat (Chunk-D fold-in): ``errors="replace"`` maps
+ # corrupt bytes to U+FFFD. In theory a corrupted line
+ # could still parse as JSON if the U+FFFD substitutions
+ # land inside string literals — yielding "garbage but
+ # technically valid JSON". For chapter-10 transcripts
+ # the writer is the only producer and uses utf-8
+ # round-trip, so the regime is safe; downstream replay
+ # consumers should validate message shape post-parse.
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
+ if not line:
+ continue
+ try:
+ yield json.loads(line)
+ except json.JSONDecodeError:
+ if not self._logged_partial:
+ # Log once per reader instance — a corrupt
+ # transcript shouldn't spam the log.
+ logger.warning(
+ "skipping unparseable transcript line in %s "
+ "(file may have a trailing partial-write)",
+ self._path,
+ )
+ self._logged_partial = True
+ continue
+ finally:
+ handle.close()
+
+ def read_all(self) -> list[Any]:
+ """Materialize every parseable line into a list."""
+ return list(self._iterate())
+
+
+def ensure_transcript_dir() -> str:
+ """Create (if needed) and return the transcripts root path.
+
+ Exposed so future Phase-11 GC / rotation logic has a stable target
+ to walk; today's writers also call ``_transcripts_root`` indirectly
+ through ``get_agent_transcript_path``.
+ """
+ return str(_transcripts_root())
+
+
+__all__ = [
+ "TranscriptWriter",
+ "TranscriptReader",
+ "get_agent_transcript_path",
+ "ensure_transcript_dir",
+]
diff --git a/src/assistant/__init__.py b/src/assistant/__init__.py
index a41389e07..820b59f43 100644
--- a/src/assistant/__init__.py
+++ b/src/assistant/__init__.py
@@ -5,6 +5,16 @@
import json
from pathlib import Path
+from src.assistant.session_chooser import AssistantSessionChooser
+from src.assistant.session_history import (
+ HISTORY_PAGE_SIZE,
+ HistoryAuthCtx,
+ HistoryPage,
+ create_history_auth_ctx,
+ fetch_latest_events,
+ fetch_older_events,
+)
+
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'assistant.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
@@ -13,4 +23,16 @@
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
-__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
+__all__ = [
+ 'ARCHIVE_NAME',
+ 'AssistantSessionChooser',
+ 'HISTORY_PAGE_SIZE',
+ 'HistoryAuthCtx',
+ 'HistoryPage',
+ 'MODULE_COUNT',
+ 'PORTING_NOTE',
+ 'SAMPLE_FILES',
+ 'create_history_auth_ctx',
+ 'fetch_latest_events',
+ 'fetch_older_events',
+]
diff --git a/src/assistant/session_chooser.py b/src/assistant/session_chooser.py
new file mode 100644
index 000000000..f080d49e9
--- /dev/null
+++ b/src/assistant/session_chooser.py
@@ -0,0 +1,37 @@
+"""Stub mirror of ``typescript/src/assistant/AssistantSessionChooser.tsx``.
+
+The upstream TS file is itself a stub annotated
+``// Stub — AssistantSessionChooser not included in source snapshot``.
+We mirror the stub here so the parity audit reflects that *both* trees
+are intentionally empty. The Python REPL has no interactive picker for
+``claude assistant`` yet; this module exists so future ports of
+``dialogLaunchers`` / ``main.tsx`` have a real import target with the
+same export name (``AssistantSessionChooser``, PascalCase) as the TS
+source — no rename needed at the call sites.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from typing import Any
+
+
+def AssistantSessionChooser( # noqa: N802 — mirrors TS React component name
+ sessions: Sequence[Any],
+ on_select: Callable[[str], None],
+ on_cancel: Callable[[], None],
+) -> None:
+ """Pick a session to attach to. No-op stub (returns ``None``).
+
+ Matches the TS prop shape ``{ sessions, onSelect, onCancel }`` so
+ callers can be ported without changing call sites. Name is PascalCase
+ to match the TS React component export — a future port of
+ ``dialogLaunchers.tsx::launchAssistantSessionChooser`` should be able
+ to write ``from src.assistant.session_chooser import AssistantSessionChooser``
+ without an alias.
+ """
+ del sessions, on_select, on_cancel # silence unused-argument linters
+ return None
+
+
+__all__ = ['AssistantSessionChooser']
diff --git a/src/assistant/session_history.py b/src/assistant/session_history.py
new file mode 100644
index 000000000..95730379b
--- /dev/null
+++ b/src/assistant/session_history.py
@@ -0,0 +1,214 @@
+"""Paginated assistant session-events client.
+
+Port of ``typescript/src/assistant/sessionHistory.ts (88 lines)``.
+
+Fetches pages of ``SDKMessage`` events from
+``GET ${base_url}/v1/sessions/${session_id}/events``. Used by the
+``claude assistant`` viewer-only REPL to lazy-load history on scroll-up.
+
+Signature deviation from TS:
+ TS ``createHistoryAuthCtx(sessionId)`` reads OAuth creds globally via
+ ``prepareApiRequest``. The Python project has no equivalent global, so
+ ``create_history_auth_ctx`` takes ``access_token``, ``org_uuid``, and
+ ``base_url`` explicitly. Matches ``bridge/code_session_api.py`` and
+ ``remote/remote_session_manager.py`` conventions. See the gap-analysis
+ doc for the named-and-rejected alternative.
+
+Error policy mirrors TS exactly (gap-analysis §2.1 bullet 4):
+ * HTTP failure / non-200 (any 4xx/5xx) → return ``None``.
+ * ``resp.json()`` raise → return ``None``.
+ * Body not a dict → return ``None``.
+ * Body is a dict but ``data`` missing / not a list → ``events=[]`` and
+ still return a ``HistoryPage`` (not ``None``).
+ * ``first_id`` is pass-through (``None`` if missing).
+ * ``has_more`` is pass-through with a ``False`` default for missing key —
+ mirrors TS JS-coercion of ``undefined`` to falsy.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any, Final
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+HISTORY_PAGE_SIZE: Final[int] = 100
+
+_ANTHROPIC_VERSION: Final[str] = '2023-06-01'
+_ANTHROPIC_BETA_CCR_BYOC: Final[str] = 'ccr-byoc-2025-07-29'
+_DEFAULT_TIMEOUT_SECONDS: Final[float] = 15.0
+
+
+@dataclass(frozen=True)
+class HistoryPage:
+ """One page of session events.
+
+ ``events`` is chronological within the page. ``first_id`` is the
+ oldest event ID in the page — the ``before_id`` cursor for the
+ next-older page. ``has_more=True`` means older events exist.
+ """
+
+ events: list[dict[str, Any]]
+ first_id: str | None
+ has_more: bool
+
+
+@dataclass(frozen=True)
+class HistoryAuthCtx:
+ """Reusable auth bundle for paged history fetches.
+
+ Built once via ``create_history_auth_ctx``, reused across pages.
+ ``headers`` is typed as ``Mapping`` (read-only contract); the
+ implementation passes a plain ``dict`` but consumers must not
+ mutate it.
+ """
+
+ base_url: str
+ headers: Mapping[str, str]
+
+
+def _oauth_headers(access_token: str, org_uuid: str) -> dict[str, str]:
+ """Combined header helper.
+
+ Bundles two concerns that TS separates: ``getOAuthHeaders(accessToken)``
+ (Authorization + Content-Type + anthropic-version) **and** the
+ ``anthropic-beta`` / ``x-organization-uuid`` pinning that TS does at
+ the ``createHistoryAuthCtx`` call site. Merging them here keeps the
+ builder pure; the wire output is byte-identical to TS.
+
+ Keeps ``Content-Type: application/json`` on the GET for parity even
+ though there is no request body (TS shares ``getOAuthHeaders`` with
+ POST callers — see gap-analysis §2.1 bullet 7).
+ """
+ return {
+ 'Authorization': f'Bearer {access_token}',
+ 'Content-Type': 'application/json',
+ 'anthropic-version': _ANTHROPIC_VERSION,
+ 'anthropic-beta': _ANTHROPIC_BETA_CCR_BYOC,
+ 'x-organization-uuid': org_uuid,
+ }
+
+
+async def create_history_auth_ctx(
+ session_id: str,
+ access_token: str,
+ org_uuid: str,
+ *,
+ base_url: str = 'https://api.anthropic.com',
+) -> HistoryAuthCtx:
+ """Build a reusable auth context bound to a session.
+
+ Pure builder — no I/O. Kept ``async`` for parity with TS even though
+ the Python implementation performs no I/O. A future Python port of
+ ``prepare_api_request`` may add token-fetch I/O here without changing
+ the call sites.
+ """
+ return HistoryAuthCtx(
+ base_url=f'{base_url.rstrip("/")}/v1/sessions/{session_id}/events',
+ headers=_oauth_headers(access_token, org_uuid),
+ )
+
+
+async def _fetch_page(
+ ctx: HistoryAuthCtx,
+ params: dict[str, str | int | bool],
+ label: str,
+ *,
+ client: httpx.AsyncClient | None = None,
+) -> HistoryPage | None:
+ """Shared GET wrapper. Returns ``None`` on any failure (network,
+ timeout, non-200, non-dict body, JSON parse error).
+
+ The ``label`` arg appears only in the debug log line and disambiguates
+ the two call sites in postmortems (matches TS line 59).
+ """
+ try:
+ if client is None:
+ async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT_SECONDS) as fresh:
+ resp = await fresh.get(
+ ctx.base_url, headers=ctx.headers, params=params,
+ )
+ else:
+ resp = await client.get(
+ ctx.base_url,
+ headers=ctx.headers,
+ params=params,
+ timeout=_DEFAULT_TIMEOUT_SECONDS,
+ )
+ except (httpx.HTTPError, httpx.TimeoutException):
+ logger.debug('[%s] HTTP error', label)
+ return None
+
+ if resp.status_code != 200:
+ logger.debug('[%s] HTTP %d', label, resp.status_code)
+ return None
+
+ try:
+ data = resp.json()
+ except ValueError:
+ logger.debug('[%s] non-JSON body', label)
+ return None
+
+ if not isinstance(data, dict):
+ logger.debug('[%s] body is not a dict', label)
+ return None
+
+ events_raw = data.get('data')
+ events: list[dict[str, Any]] = events_raw if isinstance(events_raw, list) else []
+ return HistoryPage(
+ events=events,
+ first_id=data.get('first_id'),
+ # ``False`` default mirrors TS JS-coercion of missing ``has_more``
+ # (undefined → falsy). No bool() cast — server is contract source.
+ has_more=data.get('has_more', False),
+ )
+
+
+async def fetch_latest_events(
+ ctx: HistoryAuthCtx,
+ limit: int = HISTORY_PAGE_SIZE,
+ *,
+ client: httpx.AsyncClient | None = None,
+) -> HistoryPage | None:
+ """Newest page: last ``limit`` events, chronological, via
+ ``anchor_to_latest=true``. ``has_more=True`` means older events exist.
+ Mirrors TS ``fetchLatestEvents``.
+ """
+ return await _fetch_page(
+ ctx,
+ {'limit': limit, 'anchor_to_latest': True},
+ 'fetch_latest_events',
+ client=client,
+ )
+
+
+async def fetch_older_events(
+ ctx: HistoryAuthCtx,
+ before_id: str,
+ limit: int = HISTORY_PAGE_SIZE,
+ *,
+ client: httpx.AsyncClient | None = None,
+) -> HistoryPage | None:
+ """Older page: events immediately before ``before_id`` cursor.
+ Mirrors TS ``fetchOlderEvents``.
+ """
+ return await _fetch_page(
+ ctx,
+ {'limit': limit, 'before_id': before_id},
+ 'fetch_older_events',
+ client=client,
+ )
+
+
+__all__ = [
+ 'HISTORY_PAGE_SIZE',
+ 'HistoryAuthCtx',
+ 'HistoryPage',
+ 'create_history_auth_ctx',
+ 'fetch_latest_events',
+ 'fetch_older_events',
+]
diff --git a/src/auth/auth.py b/src/auth/auth.py
index 8b9030baf..41ef082f9 100644
--- a/src/auth/auth.py
+++ b/src/auth/auth.py
@@ -20,6 +20,8 @@
_OPENAI_KEY_ENV_VARS = ("OPENAI_API_KEY",)
_AWS_KEY_ENV_VARS = ("AWS_ACCESS_KEY_ID",)
_GEMINI_KEY_ENV_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
+_OPENROUTER_KEY_ENV_VARS = ("OPENROUTER_API_KEY",)
+_DEEPSEEK_KEY_ENV_VARS = ("DEEPSEEK_API_KEY",)
# Provider → env var lists
_PROVIDER_ENV_VARS: dict[str, tuple[str, ...]] = {
@@ -27,12 +29,16 @@
"openai": _OPENAI_KEY_ENV_VARS,
"aws": _AWS_KEY_ENV_VARS,
"gemini": _GEMINI_KEY_ENV_VARS,
+ "openrouter": _OPENROUTER_KEY_ENV_VARS,
+ "deepseek": _DEEPSEEK_KEY_ENV_VARS,
}
# Simple format validators
_KEY_PATTERNS: dict[str, re.Pattern[str]] = {
"anthropic": re.compile(r"^sk-ant-[a-zA-Z0-9_-]{20,}$"),
"openai": re.compile(r"^sk-[a-zA-Z0-9_-]{20,}$"),
+ "openrouter": re.compile(r"^sk-or-[a-zA-Z0-9_-]{20,}$"),
+ "deepseek": re.compile(r"^sk-[a-zA-Z0-9_-]{20,}$"),
}
diff --git a/src/auth/claude_ai.py b/src/auth/claude_ai.py
new file mode 100644
index 000000000..da705eed9
--- /dev/null
+++ b/src/auth/claude_ai.py
@@ -0,0 +1,275 @@
+"""claude.ai OAuth token + entitlement helpers.
+
+Ports the consumer-facing surface of ``typescript/src/utils/auth.ts``
+that the bridge subsystem needs:
+
+* ``get_claude_ai_oauth_tokens()`` — read the persisted OAuth token set.
+* ``get_oauth_account_info()`` — read the persisted account profile (org
+ UUID, plan, etc.).
+* ``is_claude_ai_subscriber()`` — entitlement check used by
+ ``is_bridge_enabled`` and ``getBridgeDisabledReason``.
+* ``has_profile_scope()`` — token-scope check used by the same.
+* ``check_and_refresh_oauth_token_if_needed()`` — proactive refresh
+ called before every bridge API request.
+* ``handle_oauth_401_error()`` — clear caches + force refresh after a
+ server-reported 401.
+
+Per refactoring plan §0.1 Q6: the TS file reads from a keychain-backed
+secure storage that hasn't been ported to Python. Until Phase 10 lands
+that storage layer, this module reads from environment variables as a
+dev-override path:
+
+* ``CLAUDE_AI_OAUTH_ACCESS_TOKEN`` — the OAuth access token.
+* ``CLAUDE_AI_OAUTH_REFRESH_TOKEN`` — the refresh token.
+* ``CLAUDE_AI_OAUTH_EXPIRES_AT`` — Unix-seconds expiry; defaults to
+ ``0.0`` (unknown) when unset, which ``_is_near_or_past_expiry``
+ treats as not-expiring (no refresh-warning fires for dev tokens).
+* ``CLAUDE_AI_OAUTH_SCOPES`` — space-separated scope list.
+* ``CLAUDE_AI_ORG_UUID`` — organization UUID.
+* ``CLAUDE_AI_SUBSCRIBER`` — truthy/falsy override for the entitlement
+ check (defaults: truthy when an access token is present).
+* ``CLAUDE_AI_OAUTH_REFRESH_FAILED`` — set by test code to simulate a
+ refresh failure.
+
+This is intentionally similar to the existing ``bridge_config.py`` env-
+override pattern (Phase 1). The public function signatures match what
+Phase 3 (``bridgeApi``) and Phase 5 (``remoteBridgeCore``) need — so
+swapping to a real keychain backend later is purely internal.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import time
+from dataclasses import dataclass
+
+from src.auth.oauth import OAuthTokens
+
+logger = logging.getLogger(__name__)
+
+
+# Env-var keys (centralized so tests can patch them in one place).
+ENV_ACCESS_TOKEN = 'CLAUDE_AI_OAUTH_ACCESS_TOKEN'
+ENV_REFRESH_TOKEN = 'CLAUDE_AI_OAUTH_REFRESH_TOKEN'
+ENV_EXPIRES_AT = 'CLAUDE_AI_OAUTH_EXPIRES_AT'
+ENV_SCOPES = 'CLAUDE_AI_OAUTH_SCOPES'
+ENV_ORG_UUID = 'CLAUDE_AI_ORG_UUID'
+ENV_SUBSCRIBER_OVERRIDE = 'CLAUDE_AI_SUBSCRIBER'
+ENV_REFRESH_FAILED = 'CLAUDE_AI_OAUTH_REFRESH_FAILED'
+
+
+_PROFILE_SCOPE = 'user:profile'
+_INFERENCE_SCOPE = 'user:inference'
+_REFRESH_BUFFER_SECONDS = 60.0
+"""Refresh tokens within this window of expiry rather than waiting until
+they're stale. Matches TS ``checkAndRefreshOAuthTokenIfNeeded`` buffer.
+"""
+
+
+@dataclass(frozen=True)
+class OAuthAccountInfo:
+ """Persisted account profile.
+
+ Mirrors the TS ``OauthAccount`` shape (only the fields the bridge
+ needs). Phase 10 will populate this from ``/api/oauth/profile``;
+ until then we read from env vars.
+ """
+
+ organization_uuid: str | None
+ email_address: str | None = None
+ is_subscriber: bool | None = None
+
+
+def get_claude_ai_oauth_tokens() -> OAuthTokens | None:
+ """Return the persisted claude.ai OAuth tokens, or ``None``.
+
+ Mirrors TS ``getClaudeAIOAuthTokens`` consumer-facing semantics.
+ Returns a plain ``OAuthTokens`` (no claude.ai-specific subclass —
+ callers only need ``access_token`` / ``refresh_token`` /
+ ``is_expired``).
+
+ Env-var path until Phase 10 keychain lands. Returns ``None`` when
+ no access token is set, matching TS "not logged in" semantics.
+ """
+ access_token = os.environ.get(ENV_ACCESS_TOKEN)
+ if not access_token:
+ return None
+ refresh_token = os.environ.get(ENV_REFRESH_TOKEN, '')
+ expires_at_raw = os.environ.get(ENV_EXPIRES_AT)
+ if expires_at_raw:
+ try:
+ expires_at = float(expires_at_raw)
+ except ValueError:
+ expires_at = 0.0
+ else:
+ # No env override → "unknown" (matches absence of expiry on
+ # imported tokens). ``_is_near_or_past_expiry`` treats 0.0 as
+ # not-expiring, so dev tokens don't trigger refresh warnings.
+ expires_at = 0.0
+ scope = os.environ.get(ENV_SCOPES, '')
+ return OAuthTokens(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ token_type='Bearer',
+ expires_at=expires_at,
+ scope=scope,
+ )
+
+
+def get_oauth_account_info() -> OAuthAccountInfo | None:
+ """Return the persisted claude.ai account profile, or ``None``.
+
+ Mirrors TS ``getOauthAccountInfo`` consumer-facing semantics. Used
+ by ``get_organization_uuid`` and the entitlement gate
+ ``getBridgeDisabledReason`` flow.
+
+ Env-var path until Phase 10. Returns ``None`` when no org UUID is
+ set (TS returns ``undefined`` for unconfigured accounts).
+ """
+ org_uuid = os.environ.get(ENV_ORG_UUID)
+ if not org_uuid:
+ return None
+ return OAuthAccountInfo(
+ organization_uuid=org_uuid,
+ email_address=None,
+ is_subscriber=_subscriber_override(),
+ )
+
+
+def is_claude_ai_subscriber() -> bool:
+ """Entitlement check used by ``is_bridge_enabled``.
+
+ Mirrors TS ``isClaudeAISubscriber`` on ``utils/auth.ts:1585-1591``.
+ The TS implementation is ``shouldUseClaudeAIAuth(getClaudeAIOAuthTokens()?.scopes)``,
+ which checks for the ``user:inference`` scope. That scope's
+ *practical* effect is to exclude Bedrock / Vertex / Console-only API
+ keys (none of which receive ``user:inference``), but the *mechanism*
+ is a positive scope check — replicate the mechanism, not the effect.
+
+ Python port resolution order:
+
+ * If ``CLAUDE_AI_SUBSCRIBER`` env-var override is set (test path),
+ honor it unconditionally.
+ * Otherwise: the persisted token must exist AND include the
+ ``user:inference`` scope. Tokens lacking the scope (e.g.
+ profile-only or api-only OAuth) return ``False`` — matches TS.
+ """
+ override = _subscriber_override()
+ if override is not None:
+ return override
+ tokens = get_claude_ai_oauth_tokens()
+ if tokens is None or not tokens.scope:
+ return False
+ return _INFERENCE_SCOPE in tokens.scope.split()
+
+
+def has_profile_scope() -> bool:
+ """Whether the persisted token includes the ``user:profile`` scope.
+
+ Mirrors TS ``hasProfileScope``. Used by
+ ``getBridgeDisabledReason`` to suggest re-login for tokens that
+ can't populate ``oauthAccount.organizationUuid``.
+
+ Env-var path: parses ``CLAUDE_AI_OAUTH_SCOPES``. Returns ``True``
+ when the scope is present, ``False`` otherwise.
+ """
+ tokens = get_claude_ai_oauth_tokens()
+ if tokens is None:
+ return False
+ if not tokens.scope:
+ return False
+ return _PROFILE_SCOPE in tokens.scope.split()
+
+
+async def check_and_refresh_oauth_token_if_needed() -> None:
+ """**No-op stub** — does NOT actually refresh tokens.
+
+ Mirrors the *signature* of TS ``checkAndRefreshOAuthTokenIfNeeded``,
+ but the *behavior* is intentionally a no-op until Phase 10 wires
+ keychain-backed refresh. TS callers invoke this proactively before
+ every bridge API call to avoid 401s; **Python callers will still get
+ 401s when a token approaches expiry** because nothing here refreshes.
+
+ Emits a runtime warning when the persisted token is near or past
+ expiry so the gap is visible in logs — a Phase 5+ porter writing
+ against TS docs and expecting fresh tokens will see the warning
+ instead of silently shipping a token-staleness bug.
+
+ Errors are swallowed (matches TS best-effort behavior).
+ """
+ tokens = get_claude_ai_oauth_tokens()
+ if tokens is None:
+ return
+ if not _is_near_or_past_expiry(tokens):
+ return
+ logger.warning(
+ '[auth:claude_ai] token near/past expiry but '
+ 'check_and_refresh_oauth_token_if_needed is a no-op stub — '
+ 'Phase 10 keychain refresh not yet ported. 401 expected; '
+ 'handle_oauth_401_error is also a no-op (see its docstring).'
+ )
+
+
+async def handle_oauth_401_error(
+ *, stale_token: str | None = None
+) -> bool:
+ """**No-op stub** — returns truthiness but does NOT refresh tokens.
+
+ Mirrors the *signature* of TS ``handleOAuth401Error``, but the
+ *behavior* is intentionally a no-op until Phase 10 wires keychain-
+ backed refresh. Returns ``True`` when a token is persisted (caller
+ will retry with the SAME token and likely get another 401) or
+ ``False`` when no token / refresh-failure-override is set.
+
+ The ``stale_token`` arg is accepted for forward compatibility —
+ Phase 10 keychain integration will compare it against the current
+ cached token to detect parallel refresh. The Python env-var path
+ ignores it.
+
+ Emits a runtime warning so Phase 5+ porters hitting a real 401 see
+ explicitly that no refresh happened.
+ """
+ if os.environ.get(ENV_REFRESH_FAILED):
+ return False
+ has_token = get_claude_ai_oauth_tokens() is not None
+ if has_token:
+ logger.warning(
+ '[auth:claude_ai] handle_oauth_401_error called but is a '
+ 'no-op stub — token not refreshed, caller will retry with '
+ 'the same token. Phase 10 keychain refresh not yet ported.'
+ )
+ return has_token
+
+
+def _is_near_or_past_expiry(tokens: OAuthTokens) -> bool:
+ """True when ``tokens`` expire within the refresh buffer."""
+ if tokens.expires_at <= 0:
+ return False # Treat unknown-expiry as not-yet-expiring.
+ return tokens.expires_at - time.time() <= _REFRESH_BUFFER_SECONDS
+
+
+def _subscriber_override() -> bool | None:
+ """Parse ``CLAUDE_AI_SUBSCRIBER`` env var: True / False / None."""
+ raw = os.environ.get(ENV_SUBSCRIBER_OVERRIDE)
+ if raw is None:
+ return None
+ return raw.lower() in ('1', 'true', 'yes', 'on')
+
+
+__all__ = [
+ 'ENV_ACCESS_TOKEN',
+ 'ENV_EXPIRES_AT',
+ 'ENV_ORG_UUID',
+ 'ENV_REFRESH_FAILED',
+ 'ENV_REFRESH_TOKEN',
+ 'ENV_SCOPES',
+ 'ENV_SUBSCRIBER_OVERRIDE',
+ 'OAuthAccountInfo',
+ 'check_and_refresh_oauth_token_if_needed',
+ 'get_claude_ai_oauth_tokens',
+ 'get_oauth_account_info',
+ 'handle_oauth_401_error',
+ 'has_profile_scope',
+ 'is_claude_ai_subscriber',
+]
diff --git a/src/background/__init__.py b/src/background/__init__.py
new file mode 100644
index 000000000..2e2cb591f
--- /dev/null
+++ b/src/background/__init__.py
@@ -0,0 +1,8 @@
+"""Background tasks (the original's §9 backgroundable runs / Ctrl+B). A registry
+of detached shell commands running concurrently in subprocesses — no conversation
+race (each task is its own process). Surfaced via /bg and /tasks.
+"""
+
+from .tasks import BackgroundTasks, BgTask
+
+__all__ = ["BackgroundTasks", "BgTask"]
diff --git a/src/background/tasks.py b/src/background/tasks.py
new file mode 100644
index 000000000..ab57bb6c4
--- /dev/null
+++ b/src/background/tasks.py
@@ -0,0 +1,88 @@
+"""Background task registry: detached shell commands in subprocesses."""
+
+from __future__ import annotations
+
+import subprocess
+import threading
+import uuid
+from dataclasses import dataclass
+
+_MAX_OUTPUT = 8000
+
+
+@dataclass
+class BgTask:
+ id: str
+ command: str
+ status: str = "running" # running | done | failed | killed
+ output: str = ""
+ exit_code: int | None = None
+ started: float = 0.0
+
+
+class BackgroundTasks:
+ """Thread-safe registry of background subprocess tasks."""
+
+ def __init__(self) -> None:
+ self._tasks: dict[str, BgTask] = {}
+ self._procs: dict[str, subprocess.Popen] = {}
+ self._lock = threading.Lock()
+
+ def start(self, command: str, cwd: str, now: float = 0.0) -> BgTask:
+ tid = uuid.uuid4().hex[:8]
+ task = BgTask(id=tid, command=command, status="running", started=now)
+ proc = subprocess.Popen( # noqa: S602 - intentional shell task, user-initiated
+ command,
+ shell=True,
+ cwd=cwd or None,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ with self._lock:
+ self._tasks[tid] = task
+ self._procs[tid] = proc
+ threading.Thread(target=self._wait, args=(tid, proc), daemon=True).start()
+ return task
+
+ def _wait(self, tid: str, proc: subprocess.Popen) -> None:
+ out = ""
+ try:
+ out, _ = proc.communicate()
+ except Exception: # noqa: BLE001
+ pass
+ with self._lock:
+ t = self._tasks.get(tid)
+ if t is not None:
+ t.output = (out or "")[-_MAX_OUTPUT:]
+ t.exit_code = proc.returncode
+ if t.status != "killed":
+ t.status = "done" if proc.returncode == 0 else "failed"
+ self._procs.pop(tid, None)
+
+ def list(self) -> list[BgTask]:
+ with self._lock:
+ return list(self._tasks.values())
+
+ def get(self, tid: str) -> BgTask | None:
+ with self._lock:
+ return self._tasks.get(tid)
+
+ def output(self, tid: str) -> str | None:
+ with self._lock:
+ t = self._tasks.get(tid)
+ return t.output if t is not None else None
+
+ def kill(self, tid: str) -> bool:
+ with self._lock:
+ proc = self._procs.get(tid)
+ t = self._tasks.get(tid)
+ if t is not None and t.status == "running":
+ t.status = "killed"
+ if proc is not None:
+ try:
+ proc.terminate()
+ return True
+ except Exception: # noqa: BLE001
+ return False
+ return False
diff --git a/src/bootstrap/state.py b/src/bootstrap/state.py
index b8df05d3b..302a4193d 100644
--- a/src/bootstrap/state.py
+++ b/src/bootstrap/state.py
@@ -1,26 +1,484 @@
-"""Process-wide session state mirroring ``typescript/src/bootstrap/state.ts``.
+"""Process-wide session state. Mirrors ``typescript/src/bootstrap/state.ts``.
-Only the small slice of state needed to gate tool availability is modeled for
-now: whether the current process is running an interactive REPL/TUI session or
-a headless/SDK-style invocation. The default is ``isInteractive = False`` which
-matches the TypeScript default and is flipped to ``True`` by ``start_repl`` and
-``run_tui`` when they boot an interactive UI.
+DO NOT ADD MORE STATE HERE — BE JUDICIOUS WITH GLOBAL STATE.
+
+This module is a DAG leaf — it must not import from any feature subsystem
+package (``src/tui``, ``src/repl``, ``src/agent``, ``src/services``,
+``src/query``, ``src/context_system``, ``src/permissions``,
+``src/command_system``, ``src/tool_system``, ``src/coordinator``). The
+``import-linter`` contract (see ``.importlinter`` / ``pyproject.toml``)
+enforces this when the linter is installed; until then, treat the rule as
+review discipline.
+
+Phase 2 of the bootstrap-folder parity sweep (see
+``my-docs/get-parity-by-folder/bootstrap-refactoring-plan.md``) brings the
+``_BootstrapState`` field count to ~50 and adds:
+
+ * Turn-level metric accumulators (hook / tool / classifier duration + count)
+ * Token aggregators over ``model_usage`` (input / output / cache-read /
+ cache-creation / web-search)
+ * Turn-output-token budget primitives (module-level globals, matching TS
+ lines 756-775)
+ * ``get_total_duration`` (returns milliseconds for TS parity)
+ * ``get_usage_for_model``
+ * ``prefer_third_party_authentication`` derivation
+ * Allowed-settings-sources allowlist (default = the TS five-source list)
+ * Invoked-skills tracker with composite-key dict
+ * Permanent no-op stubs for slow-ops and REPL-bridge entries
+
+Class-C symbols from the gap analysis (channels, scheduled tasks,
+plugins, hook callbacks SDK, agent color picker, mode-exit one-shots,
+model strings, scroll-drain debouncer, statsStore, SDK betas, FD-borne
+credentials, plan slug cache, teleport reliability info, deferred-write
+interaction time, last API request snapshots, feature-flag toggles)
+remain deferred — they land when their consumers do.
"""
from __future__ import annotations
-from dataclasses import dataclass
-from typing import Any
+import contextlib
+import contextvars
+import os
+import time
+import unicodedata
+import uuid
+from dataclasses import dataclass, field
+from typing import Any, Iterator, NewType
+
+from src.utils.signal import Signal, create_signal
+
+
+# ---------------------------------------------------------------------------
+# Type aliases / value types
+# ---------------------------------------------------------------------------
+
+# Branded session ID (mirrors TS ``type SessionId = string & {__brand: ...}``).
+# Python's NewType is purely a static-analysis hint — it does not enforce
+# the brand at runtime — but it prevents accidental crossover with other
+# string-typed identifiers in type-checked code.
+SessionId = NewType("SessionId", str)
+
+
+@dataclass
+class ModelUsage:
+ """Per-model usage accumulator. Mirrors the TS ``ModelUsage`` type used
+ by ``addToTotalCostState`` and ``setCostStateForRestore``.
+
+ Phase 1 keeps this minimal; richer fields (webSearchRequests, tool-call
+ counts, etc.) land in Phase 2 alongside the cost-tracker consolidation.
+ """
+
+ input_tokens: int = 0
+ output_tokens: int = 0
+ cache_creation_input_tokens: int = 0
+ cache_read_input_tokens: int = 0
+ cost_usd: float = 0.0
+ web_search_requests: int = 0
+
+
+@dataclass
+class InvokedSkillInfo:
+ """One invoked skill, preserved across compaction.
+
+ Mirrors TS ``InvokedSkillInfo`` at ``bootstrap/state.ts:1425-1431``.
+ Used by ``services/compact/post_compact_attachments.py`` to re-emit
+ skill content after a ``/compact`` rewinds the transcript.
+ """
+
+ skill_name: str
+ skill_path: str
+ content: str
+ invoked_at: float # seconds since epoch (time.time())
+ agent_id: str | None
+
+
+# ---------------------------------------------------------------------------
+# Path resolution helpers
+# ---------------------------------------------------------------------------
+
+
+def _resolve_real_cwd() -> str:
+ """Resolve ``os.getcwd()`` through ``os.path.realpath`` and NFC-normalize.
+
+ Mirrors TS ``realpathSync(cwd()).normalize('NFC')`` at module init.
+ Captures the cwd at process start — this matches the chapter's note:
+ "The ``originalCwd`` is resolved through ``realpathSync`` and
+ NFC-normalized at process start. It never changes."
+
+ On filesystems where ``realpath`` raises (CloudStorage EPERM on macOS),
+ falls back to the raw cwd, still NFC-normalized.
+ """
+ raw = os.getcwd()
+ try:
+ return unicodedata.normalize("NFC", os.path.realpath(raw))
+ except OSError:
+ return unicodedata.normalize("NFC", raw)
+
+
+def _new_session_id() -> SessionId:
+ return SessionId(str(uuid.uuid4()))
+
+
+# ---------------------------------------------------------------------------
+# State dataclass
+# ---------------------------------------------------------------------------
@dataclass
class _BootstrapState:
+ """The Phase 1 field subset.
+
+ Fields are grouped by domain matching ``bootstrap/state.ts``. New
+ fields land here as their consuming subsystem is ported. **Do not**
+ split this into multiple dataclasses — the single-file discipline is
+ what enforces the DAG-leaf property (one file to lint, one file to
+ reason about).
+ """
+
+ # --- Identity & paths (TS: lines 46-50, 100-103, 219) -------------------
+ original_cwd: str = field(default_factory=_resolve_real_cwd)
+ project_root: str = field(default_factory=_resolve_real_cwd)
+ cwd: str = field(default_factory=_resolve_real_cwd)
+ session_id: SessionId = field(default_factory=_new_session_id)
+ parent_session_id: SessionId | None = None
+ session_project_dir: str | None = None
+
+ # --- Session flags (TS: lines 71-79, 153-157, etc.) ---------------------
is_interactive: bool = False
+ # Pre-existing default is "claude-code"; TS source uses "cli"
+ # (``bootstrap/state.ts:305``). Keep "claude-code" until existing
+ # call sites are migrated; tracking as a follow-up.
client_type: str = "claude-code"
+ session_trust_accepted: bool = False
+ session_persistence_disabled: bool = False
+ is_remote_mode: bool = False
+ has_exited_plan_mode: bool = False
+
+ # --- Cost & timing (TS: lines 51-66) -----------------------------------
+ total_cost_usd: float = 0.0
+ total_api_duration: int = 0
+ total_api_duration_without_retries: int = 0
+ total_tool_duration: int = 0
+ start_time: float = field(default_factory=time.time)
+ last_interaction_time: float = field(default_factory=time.time)
+ total_lines_added: int = 0
+ total_lines_removed: int = 0
+ has_unknown_model_cost: bool = False
+ model_usage: dict[str, ModelUsage] = field(default_factory=dict)
+
+ # --- Turn-level metric accumulators (TS: lines 47-52, 624-672) ---------
+ # Bumped by add_to_turn_*_duration / add_to_tool_duration / ...; reset
+ # by reset_turn_*_duration at end of each turn. The chapter's
+ # "turn breakdown" telemetry depends on these.
+ turn_hook_duration_ms: int = 0
+ turn_tool_duration_ms: int = 0
+ turn_classifier_duration_ms: int = 0
+ turn_hook_count: int = 0
+ turn_tool_count: int = 0
+ turn_classifier_count: int = 0
+
+ # --- Allowed settings sources (TS: lines 75, 287-292) -------------------
+ # Default mirrors TS line 287-292. Bootstrap is a DAG leaf, so we keep
+ # this as a plain list[str] rather than importing src/settings/ enums.
+ # Translators between this list and the settings package's internal
+ # enum live in src/settings/, not here.
+ allowed_setting_sources: list[str] = field(
+ default_factory=lambda: [
+ "userSettings",
+ "projectSettings",
+ "localSettings",
+ "flagSettings",
+ "policySettings",
+ ]
+ )
+
+ # --- Invoked skills (TS: lines 150-160) ---------------------------------
+ # Composite key: f"{agent_id or ''}:{skill_name}" — string, NOT a tuple,
+ # to keep parity with TS Map for any future serialization.
+ invoked_skills: dict[str, InvokedSkillInfo] = field(default_factory=dict)
+
+ # --- Cache optimization (TS: lines 122-123, 202-205, 207, 256) ---------
+ cached_claude_md_content: str | None = None
+ system_prompt_section_cache: dict[str, str | None] = field(default_factory=dict)
+ pending_post_compaction: bool = False
+ additional_directories_for_claude_md: list[str] = field(default_factory=list)
+
+ # --- Model (TS: lines 68-70) -------------------------------------------
+ main_loop_model_override: str | None = None
+ initial_main_loop_model: str | None = None
+
+ # --- API correlation (TS: lines 244-252, 205) --------------------------
+ prompt_id: str | None = None
+ last_main_request_id: str | None = None
+ last_api_completion_timestamp: float | None = None
+ last_emitted_date: str | None = None
+
+ # --- Backwards-compat extras (pre-existing Python field) ---------------
extra: dict[str, Any] | None = None
-_STATE = _BootstrapState()
+# ---------------------------------------------------------------------------
+# Module-scope singleton
+# ---------------------------------------------------------------------------
+
+
+_STATE: _BootstrapState = _BootstrapState()
+
+# ch07 round-4 (critic MAJOR) — the cost/duration/lines accumulators are
+# read-modify-write against the process-global _STATE. With Agent now
+# concurrency-safe (and pre-existing workflow parallel() fan-out), N
+# subagent OS threads call record_api_usage concurrently; the RMW is not
+# GIL-atomic (it spans many bytecodes with release points), so
+# unsynchronized writes lose updates → undercounted cost/tokens. This
+# reentrant lock makes the accumulator RMW atomic across threads. TS has
+# no analog (Node is single-threaded); this is a genuine port-only need.
+import threading as _threading
+
+_COST_STATE_LOCK = _threading.RLock()
+
+
+def cost_state_lock() -> "_threading.RLock":
+ """The lock guarding the cost/duration/lines accumulator RMW.
+
+ Callers whose read-modify-write spans multiple accessor calls (e.g.
+ ``cost_tracker.record_api_usage`` reads ``get_model_usage`` then calls
+ ``add_to_total_cost_state``) hold this across the whole sequence."""
+ return _COST_STATE_LOCK
+
+
+# ---------------------------------------------------------------------------
+# WI-4: Turn-output-token snapshot & budget continuation
+#
+# Module-level globals — mirror TS lines 756-775. Not on ``_BootstrapState``
+# because the TS code keeps them module-scoped, and the parity audit
+# preserves shape.
+#
+# RESET ALSO MUST HANDLE THESE: ``reset_state_for_tests()`` rebinds all three
+# below. Any future addition to this block needs a matching reset line.
+# ---------------------------------------------------------------------------
+
+_output_tokens_at_turn_start: int = 0
+_current_turn_token_budget: int | None = None
+_budget_continuation_count: int = 0
+
+
+# ---------------------------------------------------------------------------
+# Per-query SDK context (contextvars-based, mirrors TS AsyncLocalStorage)
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class SdkContext:
+ """Per-query context that overrides the global ``_STATE`` for the
+ duration of an async/sync call stack.
+
+ Mirrors TS ``SdkContext`` (``bootstrap/state.ts:439-445``). When set
+ via ``run_with_sdk_context(...)``, reads of ``session_id``,
+ ``session_project_dir``, ``cwd``, ``original_cwd``, and
+ ``parent_session_id`` return context-scoped values rather than the
+ global singleton. Used by the Agent SDK when multiple concurrent
+ queries share a process and each needs its own identity view.
+
+ Python implementation note: ``contextvars.ContextVar`` is the
+ asyncio-aware equivalent of Node's ``async_hooks.AsyncLocalStorage``.
+ Both propagate context across ``await`` points without explicit
+ threading; both isolate sibling tasks.
+
+ Sentinel semantics: ``None`` on ``cwd`` / ``original_cwd`` means
+ "not set in this context — fall back to the global". Matches TS
+ nullish-coalescing (``ctx?.originalCwd ?? STATE.originalCwd``).
+ Writing ``set_original_cwd("")`` from inside the context stores the
+ empty string explicitly and returns ``""``; only ``None`` triggers
+ the global fallback.
+ """
+
+ session_id: SessionId
+ session_project_dir: str | None = None
+ cwd: str | None = None
+ original_cwd: str | None = None
+ parent_session_id: SessionId | None = None
+
+
+_sdk_context: contextvars.ContextVar[SdkContext | None] = contextvars.ContextVar(
+ "sdk_context", default=None
+)
+
+
+def _get_sdk_context() -> SdkContext | None:
+ """Return the current per-query SDK context, or None if not inside one."""
+ return _sdk_context.get()
+
+
+@contextlib.contextmanager
+def run_with_sdk_context(context: SdkContext) -> Iterator[None]:
+ """Run a block with an SDK-specific context overriding global state.
+
+ Mirrors TS ``runWithSdkContext`` (``bootstrap/state.ts:460-462``).
+ Within the ``with`` block, ``get_session_id``, ``get_original_cwd``,
+ ``get_cwd_state``, ``get_session_project_dir``, and
+ ``get_parent_session_id`` read from ``context``. Mutations via
+ ``switch_session`` / ``regenerate_session_id`` / ``set_original_cwd``
+ / ``set_cwd_state`` mutate the context (not the global) while inside.
+
+ Example::
+
+ ctx = SdkContext(
+ session_id=SessionId("..."),
+ original_cwd="/tmp/sdk-workspace",
+ cwd="/tmp/sdk-workspace",
+ )
+ with run_with_sdk_context(ctx):
+ run_query() # reads ctx.session_id, not the global
+
+ Async usage: nested ``await`` calls within the ``with`` block inherit
+ the context automatically — contextvars propagate across asyncio
+ task boundaries created with ``asyncio.create_task`` (since 3.7).
+ """
+ token = _sdk_context.set(context)
+ try:
+ yield
+ finally:
+ _sdk_context.reset(token)
+
+
+# ---------------------------------------------------------------------------
+# Signals
+# ---------------------------------------------------------------------------
+
+# Fires whenever ``switch_session`` mutates ``session_id``. Listeners
+# receive the new session id as the single positional argument. Bootstrap
+# cannot import the listener modules (DAG-leaf rule), so consumers register
+# via ``on_session_switch(cb)`` and are responsible for unsubscribing.
+_session_switched: Signal = create_signal()
+on_session_switch = _session_switched.subscribe
+
+
+# ===========================================================================
+# Accessors — Identity & paths
+# ===========================================================================
+
+
+def get_session_id() -> SessionId:
+ """Current session ID. Mirrors TS ``getSessionId()``.
+
+ Within a ``run_with_sdk_context(...)`` block, returns the context's
+ session_id rather than the global. Outside, returns the global.
+ """
+ ctx = _get_sdk_context()
+ return ctx.session_id if ctx is not None else _STATE.session_id
+
+
+def regenerate_session_id(*, set_current_as_parent: bool = False) -> SessionId:
+ """Generate a fresh UUID session ID and install it.
+
+ If ``set_current_as_parent`` is True, the outgoing session ID becomes
+ the new ``parent_session_id``. Mirrors TS ``regenerateSessionId``.
+ Does NOT emit ``session_switched`` — that is reserved for
+ ``switch_session`` (which is the resume/teleport path). This is the
+ /clear or new-session-within-same-process path.
+
+ Inside ``run_with_sdk_context``: mutates the context. Outside:
+ mutates the global.
+ """
+ ctx = _get_sdk_context()
+ current = ctx.session_id if ctx is not None else _STATE.session_id
+ if set_current_as_parent:
+ if ctx is not None:
+ ctx.parent_session_id = current
+ else:
+ _STATE.parent_session_id = current
+ new_id = _new_session_id()
+ if ctx is not None:
+ ctx.session_id = new_id
+ ctx.session_project_dir = None
+ else:
+ _STATE.session_id = new_id
+ _STATE.session_project_dir = None
+ return new_id
+
+
+def switch_session(session_id: SessionId, project_dir: str | None = None) -> None:
+ """Atomically switch the active session.
+
+ ``session_id`` and ``session_project_dir`` always change together; this
+ is the **only** mutator for either. Mirrors TS ``switchSession``
+ (``bootstrap/state.ts:522``) and the CC-34 single-setter discipline.
+ Fires the ``session_switched`` signal after the mutation.
+
+ Inside ``run_with_sdk_context``: mutates the context. Outside:
+ mutates the global. The signal fires regardless.
+ """
+ ctx = _get_sdk_context()
+ if ctx is not None:
+ ctx.session_id = session_id
+ ctx.session_project_dir = project_dir
+ else:
+ _STATE.session_id = session_id
+ _STATE.session_project_dir = project_dir
+ _session_switched.emit(session_id)
+
+
+def get_parent_session_id() -> SessionId | None:
+ ctx = _get_sdk_context()
+ return ctx.parent_session_id if ctx is not None else _STATE.parent_session_id
+
+
+def get_session_project_dir() -> str | None:
+ ctx = _get_sdk_context()
+ return ctx.session_project_dir if ctx is not None else _STATE.session_project_dir
+
+
+def get_original_cwd() -> str:
+ ctx = _get_sdk_context()
+ if ctx is not None and ctx.original_cwd is not None:
+ return ctx.original_cwd
+ return _STATE.original_cwd
+
+
+def set_original_cwd(path: str) -> None:
+ normalized = unicodedata.normalize("NFC", path)
+ ctx = _get_sdk_context()
+ if ctx is not None:
+ ctx.original_cwd = normalized
+ else:
+ _STATE.original_cwd = normalized
+
+
+def get_project_root() -> str:
+ """Stable project root. Set once at startup (and by ``--worktree``);
+ NOT updated by mid-session ``EnterWorktreeTool``. Mirrors TS
+ ``getProjectRoot``.
+
+ Always reads from the global — project_root is process-scope, not
+ per-query, per the chapter's "project identity" framing."""
+ return _STATE.project_root
+
+
+def set_project_root(path: str) -> None:
+ """Only for ``--worktree`` startup flag. Mirrors TS ``setProjectRoot``.
+ Always mutates the global — does NOT respect SDK context."""
+ _STATE.project_root = unicodedata.normalize("NFC", path)
+
+
+def get_cwd_state() -> str:
+ ctx = _get_sdk_context()
+ if ctx is not None and ctx.cwd is not None:
+ return ctx.cwd
+ return _STATE.cwd
+
+
+def set_cwd_state(path: str) -> None:
+ normalized = unicodedata.normalize("NFC", path)
+ ctx = _get_sdk_context()
+ if ctx is not None:
+ ctx.cwd = normalized
+ else:
+ _STATE.cwd = normalized
+
+
+# ===========================================================================
+# Accessors — Session flags
+# ===========================================================================
def get_is_interactive() -> bool:
@@ -43,10 +501,759 @@ def set_client_type(value: str) -> None:
_STATE.client_type = str(value)
+def get_session_trust_accepted() -> bool:
+ return _STATE.session_trust_accepted
+
+
+def set_session_trust_accepted(value: bool) -> None:
+ _STATE.session_trust_accepted = bool(value)
+
+
+def is_session_persistence_disabled() -> bool:
+ return _STATE.session_persistence_disabled
+
+
+def set_session_persistence_disabled(value: bool) -> None:
+ _STATE.session_persistence_disabled = bool(value)
+
+
+def get_is_remote_mode() -> bool:
+ return _STATE.is_remote_mode
+
+
+def set_is_remote_mode(value: bool) -> None:
+ _STATE.is_remote_mode = bool(value)
+
+
+def has_exited_plan_mode_in_session() -> bool:
+ return _STATE.has_exited_plan_mode
+
+
+def set_has_exited_plan_mode(value: bool) -> None:
+ _STATE.has_exited_plan_mode = bool(value)
+
+
+def prefer_third_party_authentication() -> bool:
+ """Mirrors TS ``preferThirdPartyAuthentication`` (state.ts:1157-1160).
+
+ True iff the session is non-interactive AND the client type is not
+ ``'claude-vscode'`` (the IDE extension is treated as 1P for auth
+ purposes even though it runs non-interactively).
+ """
+ return get_is_non_interactive_session() and _STATE.client_type != "claude-vscode"
+
+
+# ===========================================================================
+# Accessors — Cost & timing
+# ===========================================================================
+
+
+def get_total_cost_usd() -> float:
+ return _STATE.total_cost_usd
+
+
+def add_to_total_cost_state(
+ cost: float,
+ model_usage: ModelUsage,
+ model: str,
+) -> None:
+ """Record a cost event. Mirrors TS ``addToTotalCostState``."""
+ with _COST_STATE_LOCK:
+ _STATE.model_usage[model] = model_usage
+ _STATE.total_cost_usd += cost
+
+
+def get_total_api_duration() -> int:
+ return _STATE.total_api_duration
+
+
+def get_total_api_duration_without_retries() -> int:
+ return _STATE.total_api_duration_without_retries
+
+
+def add_to_total_duration_state(duration: int, duration_without_retries: int) -> None:
+ with _COST_STATE_LOCK:
+ _STATE.total_api_duration += duration
+ _STATE.total_api_duration_without_retries += duration_without_retries
+
+
+def get_total_tool_duration() -> int:
+ return _STATE.total_tool_duration
+
+
+def add_to_tool_duration(duration: int) -> None:
+ """Record a tool-execution duration.
+
+ Mirrors TS ``addToToolDuration`` (state.ts:618-622): bumps the
+ cumulative ``total_tool_duration`` AND the per-turn
+ ``turn_tool_duration_ms`` AND the per-turn ``turn_tool_count``.
+ All three update atomically.
+ """
+ with _COST_STATE_LOCK:
+ _STATE.total_tool_duration += duration
+ _STATE.turn_tool_duration_ms += duration
+ _STATE.turn_tool_count += 1
+
+
+def get_total_lines_added() -> int:
+ return _STATE.total_lines_added
+
+
+def get_total_lines_removed() -> int:
+ return _STATE.total_lines_removed
+
+
+def add_to_total_lines_changed(added: int, removed: int) -> None:
+ with _COST_STATE_LOCK:
+ _STATE.total_lines_added += added
+ _STATE.total_lines_removed += removed
+
+
+def has_unknown_model_cost() -> bool:
+ return _STATE.has_unknown_model_cost
+
+
+def set_has_unknown_model_cost() -> None:
+ _STATE.has_unknown_model_cost = True
+
+
+def get_model_usage() -> dict[str, ModelUsage]:
+ """Return the per-model usage map. Callers may read but should not
+ mutate; use ``add_to_total_cost_state`` to record."""
+ return _STATE.model_usage
+
+
+def get_usage_for_model(model: str) -> ModelUsage | None:
+ """Return the ``ModelUsage`` recorded for ``model`` or ``None`` if no
+ cost event has been recorded for it. Mirrors TS ``getUsageForModel``
+ (state.ts:862-864).
+ """
+ return _STATE.model_usage.get(model)
+
+
+def get_total_input_tokens() -> int:
+ """Sum of ``input_tokens`` across every model in ``model_usage``.
+
+ Mirrors TS ``getTotalInputTokens`` (state.ts:736-738) — TS uses
+ ``sumBy(values, 'inputTokens')``.
+ """
+ return sum(usage.input_tokens for usage in _STATE.model_usage.values())
+
+
+def get_total_output_tokens() -> int:
+ """Sum of ``output_tokens`` across every model in ``model_usage``.
+ Mirrors TS ``getTotalOutputTokens`` (state.ts:740-742)."""
+ return sum(usage.output_tokens for usage in _STATE.model_usage.values())
+
+
+def get_total_cache_read_input_tokens() -> int:
+ """Sum of ``cache_read_input_tokens`` across every model in
+ ``model_usage``. Mirrors TS ``getTotalCacheReadInputTokens``
+ (state.ts:744-746)."""
+ return sum(
+ usage.cache_read_input_tokens for usage in _STATE.model_usage.values()
+ )
+
+
+def get_total_cache_creation_input_tokens() -> int:
+ """Sum of ``cache_creation_input_tokens`` across every model in
+ ``model_usage``. Mirrors TS ``getTotalCacheCreationInputTokens``
+ (state.ts:748-750)."""
+ return sum(
+ usage.cache_creation_input_tokens for usage in _STATE.model_usage.values()
+ )
+
+
+def get_total_web_search_requests() -> int:
+ """Sum of ``web_search_requests`` across every model in ``model_usage``.
+ Mirrors TS ``getTotalWebSearchRequests`` (state.ts:752-754)."""
+ return sum(usage.web_search_requests for usage in _STATE.model_usage.values())
+
+
+def get_start_time() -> float:
+ return _STATE.start_time
+
+
+def get_total_duration() -> int:
+ """Wall-clock duration since session start, in MILLISECONDS.
+
+ Mirrors TS ``getTotalDuration`` (state.ts:606-608), which returns
+ ``Date.now() - STATE.startTime`` (already milliseconds in JS).
+ Python ``start_time`` is in seconds, so cast to int ms here so
+ downstream formatters that assume ms match TS exactly.
+ """
+ return int((time.time() - _STATE.start_time) * 1000)
+
+
+def get_last_interaction_time() -> float:
+ return _STATE.last_interaction_time
+
+
+def update_last_interaction_time() -> None:
+ _STATE.last_interaction_time = time.time()
+
+
+def reset_cost_state() -> None:
+ """Reset accumulators for a fresh session. Mirrors TS ``resetCostState``."""
+ _STATE.total_cost_usd = 0.0
+ _STATE.total_api_duration = 0
+ _STATE.total_api_duration_without_retries = 0
+ _STATE.total_tool_duration = 0
+ _STATE.start_time = time.time()
+ _STATE.total_lines_added = 0
+ _STATE.total_lines_removed = 0
+ _STATE.has_unknown_model_cost = False
+ _STATE.model_usage = {}
+ _STATE.prompt_id = None
+
+
+def set_cost_state_for_restore(
+ *,
+ total_cost_usd: float,
+ total_api_duration: int,
+ total_api_duration_without_retries: int,
+ total_tool_duration: int,
+ total_lines_added: int,
+ total_lines_removed: int,
+ last_duration: float | None = None,
+ model_usage: dict[str, ModelUsage] | None = None,
+) -> None:
+ """Restore accumulators from a persisted session snapshot.
+
+ Called by the ``restore_cost_state_for_session``
+ orchestrator. Mirrors TS ``setCostStateForRestore``
+ (``bootstrap/state.ts:955``).
+ """
+ _STATE.total_cost_usd = total_cost_usd
+ _STATE.total_api_duration = total_api_duration
+ _STATE.total_api_duration_without_retries = total_api_duration_without_retries
+ _STATE.total_tool_duration = total_tool_duration
+ _STATE.total_lines_added = total_lines_added
+ _STATE.total_lines_removed = total_lines_removed
+ if model_usage is not None:
+ _STATE.model_usage = dict(model_usage)
+ if last_duration is not None:
+ _STATE.start_time = time.time() - last_duration
+
+
+# ===========================================================================
+# Accessors — Cache optimization
+# ===========================================================================
+
+
+def get_cached_claude_md_content() -> str | None:
+ return _STATE.cached_claude_md_content
+
+
+def set_cached_claude_md_content(content: str | None) -> None:
+ _STATE.cached_claude_md_content = content
+
+
+def get_system_prompt_section_cache() -> dict[str, str | None]:
+ return _STATE.system_prompt_section_cache
+
+
+def set_system_prompt_section_cache_entry(name: str, value: str | None) -> None:
+ _STATE.system_prompt_section_cache[name] = value
+
+
+def clear_system_prompt_section_state() -> None:
+ _STATE.system_prompt_section_cache.clear()
+
+
+def mark_post_compaction() -> None:
+ """Mark that a compaction just occurred. Consumed once by the next API
+ success event, then auto-resets. Mirrors TS ``markPostCompaction``."""
+ _STATE.pending_post_compaction = True
+
+
+def consume_post_compaction() -> bool:
+ """Returns True once after compaction, then False on subsequent calls.
+ Mirrors TS ``consumePostCompaction``."""
+ was = _STATE.pending_post_compaction
+ _STATE.pending_post_compaction = False
+ return was
+
+
+def get_additional_directories_for_claude_md() -> list[str]:
+ return _STATE.additional_directories_for_claude_md
+
+
+def set_additional_directories_for_claude_md(directories: list[str]) -> None:
+ _STATE.additional_directories_for_claude_md = list(directories)
+
+
+# ===========================================================================
+# Accessors — Model
+# ===========================================================================
+
+
+def get_main_loop_model_override() -> str | None:
+ return _STATE.main_loop_model_override
+
+
+def set_main_loop_model_override(model: str | None) -> None:
+ _STATE.main_loop_model_override = model
+
+
+def get_initial_main_loop_model() -> str | None:
+ return _STATE.initial_main_loop_model
+
+
+def set_initial_main_loop_model(model: str | None) -> None:
+ _STATE.initial_main_loop_model = model
+
+
+# ===========================================================================
+# Accessors — API correlation
+# ===========================================================================
+
+
+def get_prompt_id() -> str | None:
+ return _STATE.prompt_id
+
+
+def set_prompt_id(prompt_id: str | None) -> None:
+ _STATE.prompt_id = prompt_id
+
+
+def get_last_main_request_id() -> str | None:
+ return _STATE.last_main_request_id
+
+
+def set_last_main_request_id(request_id: str) -> None:
+ _STATE.last_main_request_id = request_id
+
+
+def get_last_api_completion_timestamp() -> float | None:
+ return _STATE.last_api_completion_timestamp
+
+
+def set_last_api_completion_timestamp(ts: float) -> None:
+ _STATE.last_api_completion_timestamp = ts
+
+
+def get_last_emitted_date() -> str | None:
+ return _STATE.last_emitted_date
+
+
+def set_last_emitted_date(date: str | None) -> None:
+ _STATE.last_emitted_date = date
+
+
+# ===========================================================================
+# Accessors — Turn-level metrics (TS: lines 624-672)
+# ===========================================================================
+
+
+def get_turn_hook_duration_ms() -> int:
+ return _STATE.turn_hook_duration_ms
+
+
+def add_to_turn_hook_duration(duration_ms: int) -> None:
+ """Bump per-turn hook duration & count. Mirrors TS
+ ``addToTurnHookDuration`` (state.ts:628-631)."""
+ _STATE.turn_hook_duration_ms += duration_ms
+ _STATE.turn_hook_count += 1
+
+
+def reset_turn_hook_duration() -> None:
+ _STATE.turn_hook_duration_ms = 0
+ _STATE.turn_hook_count = 0
+
+
+def get_turn_hook_count() -> int:
+ return _STATE.turn_hook_count
+
+
+def get_turn_tool_duration_ms() -> int:
+ return _STATE.turn_tool_duration_ms
+
+
+def reset_turn_tool_duration() -> None:
+ """Reset per-turn tool duration & count. Mirrors TS
+ ``resetTurnToolDuration`` (state.ts:646-649). ``add_to_tool_duration``
+ is the writer (bumps total + turn duration + turn count atomically)."""
+ _STATE.turn_tool_duration_ms = 0
+ _STATE.turn_tool_count = 0
+
+
+def get_turn_tool_count() -> int:
+ return _STATE.turn_tool_count
+
+
+def get_turn_classifier_duration_ms() -> int:
+ return _STATE.turn_classifier_duration_ms
+
+
+def add_to_turn_classifier_duration(duration_ms: int) -> None:
+ """Bump per-turn classifier duration & count. Mirrors TS
+ ``addToTurnClassifierDuration`` (state.ts:659-662)."""
+ _STATE.turn_classifier_duration_ms += duration_ms
+ _STATE.turn_classifier_count += 1
+
+
+def reset_turn_classifier_duration() -> None:
+ _STATE.turn_classifier_duration_ms = 0
+ _STATE.turn_classifier_count = 0
+
+
+def get_turn_classifier_count() -> int:
+ return _STATE.turn_classifier_count
+
+
+# ===========================================================================
+# Accessors — Turn-output-token budget (TS: lines 756-775)
+# ===========================================================================
+#
+# These read/write the module-level globals declared near the singleton.
+# TS keeps them module-scoped (not on STATE); we preserve the shape so the
+# parity audit stays clean. ``reset_state_for_tests`` zeros them.
+
+
+def get_turn_output_tokens() -> int:
+ """Output tokens emitted in the current turn.
+
+ Returns ``get_total_output_tokens() - ``.
+ Mirrors TS ``getTurnOutputTokens`` (state.ts:758-760).
+ """
+ return get_total_output_tokens() - _output_tokens_at_turn_start
+
+
+def get_current_turn_token_budget() -> int | None:
+ """The token budget set for the current turn, or ``None`` if no
+ budget is in effect. Mirrors TS ``getCurrentTurnTokenBudget``
+ (state.ts:761-763)."""
+ return _current_turn_token_budget
+
+
+def snapshot_output_tokens_for_turn(budget: int | None) -> None:
+ """Snapshot the output-token counter at turn start and install the
+ new turn budget. Resets the budget-continuation counter to 0.
+
+ Mirrors TS ``snapshotOutputTokensForTurn`` (state.ts:765-769).
+ ``budget=None`` is a legitimate input meaning "no budget enforced
+ this turn"; it still snapshots the counter and zeros continuations.
+ """
+ global _output_tokens_at_turn_start, _current_turn_token_budget
+ global _budget_continuation_count
+ _output_tokens_at_turn_start = get_total_output_tokens()
+ _current_turn_token_budget = budget
+ _budget_continuation_count = 0
+
+
+def get_budget_continuation_count() -> int:
+ return _budget_continuation_count
+
+
+def increment_budget_continuation_count() -> None:
+ """Bump the continuation count. Mirrors TS
+ ``incrementBudgetContinuationCount`` (state.ts:773-775)."""
+ global _budget_continuation_count
+ _budget_continuation_count += 1
+
+
+# ===========================================================================
+# Accessors — Allowed settings sources (TS: lines 75, 287-292, 1149-1155)
+# ===========================================================================
+
+
+def get_allowed_setting_sources() -> list[str]:
+ """Return a copy of the allowed-settings-sources list.
+
+ Returning a copy discourages caller mutation; consumers that need
+ to mutate must call ``set_allowed_setting_sources`` explicitly.
+ """
+ return list(_STATE.allowed_setting_sources)
+
+
+def set_allowed_setting_sources(sources: list[str]) -> None:
+ """Replace the allowed-settings-sources list atomically.
+
+ Mirrors TS ``setAllowedSettingSources`` (state.ts:1153-1155). Stores
+ a copy so subsequent mutation of the caller's list does not leak in.
+ """
+ _STATE.allowed_setting_sources = list(sources)
+
+
+# ===========================================================================
+# Accessors — Invoked skills (compaction preservation; TS: lines 1424-1486)
+# ===========================================================================
+
+
+def add_invoked_skill(
+ skill_name: str,
+ skill_path: str,
+ content: str,
+ agent_id: str | None = None,
+) -> None:
+ """Record an invoked skill so the post-compaction attachment path can
+ re-emit it after a ``/compact`` rewinds the transcript.
+
+ Composite key: ``f"{agent_id or ''}:{skill_name}"`` — string form,
+ NOT a tuple, to match TS ``${agentId ?? ''}:${skillName}`` (state.ts:
+ 1439). Same-key add overwrites. Mirrors TS ``addInvokedSkill``
+ (state.ts:1433-1447).
+ """
+ key = f"{agent_id or ''}:{skill_name}"
+ _STATE.invoked_skills[key] = InvokedSkillInfo(
+ skill_name=skill_name,
+ skill_path=skill_path,
+ content=content,
+ invoked_at=time.time(),
+ agent_id=agent_id,
+ )
+
+
+def get_invoked_skills() -> dict[str, InvokedSkillInfo]:
+ """Return the underlying invoked-skills dict (NOT a copy).
+
+ Mirrors TS ``getInvokedSkills`` which returns the live Map. Callers
+ that want a copy must copy explicitly.
+ """
+ return _STATE.invoked_skills
+
+
+def get_invoked_skills_for_agent(
+ agent_id: str | None,
+) -> dict[str, InvokedSkillInfo]:
+ """Filter the invoked-skills map to entries whose ``agent_id`` matches.
+
+ ``agent_id=None`` matches only skills recorded with ``agent_id=None``.
+ Mirrors TS ``getInvokedSkillsForAgent`` (state.ts:1453-1464); the
+ ``normalizedId = agentId ?? null`` step in TS is unnecessary in
+ Python because we never pass ``undefined``.
+ """
+ return {
+ key: skill
+ for key, skill in _STATE.invoked_skills.items()
+ if skill.agent_id == agent_id
+ }
+
+
+def clear_invoked_skills(
+ preserved_agent_ids: set[str] | None = None,
+) -> None:
+ """Clear invoked skills, optionally preserving entries tied to the
+ named agents.
+
+ Semantics (mirror TS ``clearInvokedSkills`` state.ts:1466-1478):
+ * ``preserved_agent_ids=None`` OR empty set → clear EVERYTHING.
+ * Otherwise, keep entries whose ``agent_id`` is non-None AND in
+ the set. Entries with ``agent_id is None`` are always removed
+ when a preserved set is supplied (matches TS line 1474:
+ ``if (skill.agentId === null || !preservedAgentIds.has(skill.agentId))``).
+ """
+ if not preserved_agent_ids:
+ _STATE.invoked_skills.clear()
+ return
+ for key in list(_STATE.invoked_skills.keys()):
+ skill = _STATE.invoked_skills[key]
+ if skill.agent_id is None or skill.agent_id not in preserved_agent_ids:
+ del _STATE.invoked_skills[key]
+
+
+def clear_invoked_skills_for_agent(agent_id: str) -> None:
+ """Remove every invoked skill tied to ``agent_id``. Mirrors TS
+ ``clearInvokedSkillsForAgent`` (state.ts:1480-1486)."""
+ for key in list(_STATE.invoked_skills.keys()):
+ if _STATE.invoked_skills[key].agent_id == agent_id:
+ del _STATE.invoked_skills[key]
+
+
+# ===========================================================================
+# Accessors — Slow operations (permanent no-op stubs; TS: lines 1488-1508)
+# ===========================================================================
+#
+# TS keeps these as documented no-ops (the slow-ops tracker was removed).
+# Python mirrors as no-ops so any future caller that depends on the names
+# compiles, while the actual behavior remains a permanent no-op. Do NOT
+# add backing state for these — that would resurrect a removed feature.
+
+
+_EMPTY_SLOW_OPERATIONS: tuple[dict, ...] = ()
+
+
+def add_slow_operation(operation: str, duration_ms: int) -> None:
+ """Permanent no-op. Mirrors TS ``addSlowOperation`` (state.ts:1497-1500)."""
+ return
+
+
+def get_slow_operations() -> tuple[dict, ...]:
+ """Permanent no-op — always returns the same empty tuple. Mirrors TS
+ ``getSlowOperations`` (state.ts:1502-1508)."""
+ return _EMPTY_SLOW_OPERATIONS
+
+
+# ===========================================================================
+# Accessors — REPL bridge (closed-source feature stubs; TS: lines 1647-1653)
+# ===========================================================================
+#
+# Feature-gated in TS (returns false / null in the open build). Python
+# mirrors as permanent stubs — no backing state, no toggle.
+
+
+def is_repl_bridge_active() -> bool:
+ """Permanent stub. Mirrors TS ``isReplBridgeActive`` (state.ts:1647-1649)."""
+ return False
+
+
+def get_repl_bridge_handle() -> None:
+ """Permanent stub. Mirrors TS ``getReplBridgeHandle`` (state.ts:1651-1653)."""
+ return None
+
+
+# ===========================================================================
+# Test reset
+# ===========================================================================
+
+
+def reset_state_for_tests() -> None:
+ """Wipe state to defaults. Test-only escape hatch.
+
+ Gated by the ``PYTEST_CURRENT_TEST`` environment variable, which pytest
+ sets during test execution. Production calls raise ``RuntimeError``.
+ Mirrors TS ``resetStateForTests`` (``bootstrap/state.ts:951``).
+
+ Resets:
+ * the ``_STATE`` dataclass singleton (all dataclass fields zero out)
+ * the session-switched signal listeners
+ * the WI-4 turn-budget module-level globals (these live outside
+ ``_STATE`` to mirror TS lines 756-775; the dataclass replace does
+ not touch them, so we rebind them here explicitly)
+ """
+ if os.environ.get("PYTEST_CURRENT_TEST") is None:
+ raise RuntimeError("reset_state_for_tests can only be called in tests")
+ global _STATE
+ global _output_tokens_at_turn_start, _current_turn_token_budget
+ global _budget_continuation_count
+ _STATE = _BootstrapState()
+ _session_switched.clear()
+ _output_tokens_at_turn_start = 0
+ _current_turn_token_budget = None
+ _budget_continuation_count = 0
+
+
__all__ = [
+ # Types
+ "SessionId",
+ "ModelUsage",
+ "InvokedSkillInfo",
+ "SdkContext",
+ # Per-query context
+ "run_with_sdk_context",
+ # Signal
+ "on_session_switch",
+ # Identity & paths
+ "get_session_id",
+ "regenerate_session_id",
+ "switch_session",
+ "get_parent_session_id",
+ "get_session_project_dir",
+ "get_original_cwd",
+ "set_original_cwd",
+ "get_project_root",
+ "set_project_root",
+ "get_cwd_state",
+ "set_cwd_state",
+ # Session flags
"get_is_interactive",
"set_is_interactive",
"get_is_non_interactive_session",
"get_client_type",
"set_client_type",
+ "get_session_trust_accepted",
+ "set_session_trust_accepted",
+ "is_session_persistence_disabled",
+ "set_session_persistence_disabled",
+ "get_is_remote_mode",
+ "set_is_remote_mode",
+ "has_exited_plan_mode_in_session",
+ "set_has_exited_plan_mode",
+ "prefer_third_party_authentication",
+ # Cost & timing
+ "get_total_cost_usd",
+ "add_to_total_cost_state",
+ "get_total_api_duration",
+ "get_total_api_duration_without_retries",
+ "add_to_total_duration_state",
+ "get_total_tool_duration",
+ "add_to_tool_duration",
+ "get_total_lines_added",
+ "get_total_lines_removed",
+ "add_to_total_lines_changed",
+ "has_unknown_model_cost",
+ "set_has_unknown_model_cost",
+ "get_model_usage",
+ "get_usage_for_model",
+ "get_total_input_tokens",
+ "get_total_output_tokens",
+ "get_total_cache_read_input_tokens",
+ "get_total_cache_creation_input_tokens",
+ "get_total_web_search_requests",
+ "get_start_time",
+ "get_total_duration",
+ "get_last_interaction_time",
+ "update_last_interaction_time",
+ "reset_cost_state",
+ "set_cost_state_for_restore",
+ # Turn-level metrics
+ "get_turn_hook_duration_ms",
+ "add_to_turn_hook_duration",
+ "reset_turn_hook_duration",
+ "get_turn_hook_count",
+ "get_turn_tool_duration_ms",
+ "reset_turn_tool_duration",
+ "get_turn_tool_count",
+ "get_turn_classifier_duration_ms",
+ "add_to_turn_classifier_duration",
+ "reset_turn_classifier_duration",
+ "get_turn_classifier_count",
+ # Turn-output-token budget
+ "get_turn_output_tokens",
+ "get_current_turn_token_budget",
+ "snapshot_output_tokens_for_turn",
+ "get_budget_continuation_count",
+ "increment_budget_continuation_count",
+ # Allowed settings sources
+ "get_allowed_setting_sources",
+ "set_allowed_setting_sources",
+ # Invoked skills
+ "add_invoked_skill",
+ "get_invoked_skills",
+ "get_invoked_skills_for_agent",
+ "clear_invoked_skills",
+ "clear_invoked_skills_for_agent",
+ # Slow operations (permanent no-op stubs)
+ "add_slow_operation",
+ "get_slow_operations",
+ # REPL bridge stubs
+ "is_repl_bridge_active",
+ "get_repl_bridge_handle",
+ # Cache optimization
+ "get_cached_claude_md_content",
+ "set_cached_claude_md_content",
+ "get_system_prompt_section_cache",
+ "set_system_prompt_section_cache_entry",
+ "clear_system_prompt_section_state",
+ "mark_post_compaction",
+ "consume_post_compaction",
+ "get_additional_directories_for_claude_md",
+ "set_additional_directories_for_claude_md",
+ # Model
+ "get_main_loop_model_override",
+ "set_main_loop_model_override",
+ "get_initial_main_loop_model",
+ "set_initial_main_loop_model",
+ # API correlation
+ "get_prompt_id",
+ "set_prompt_id",
+ "get_last_main_request_id",
+ "set_last_main_request_id",
+ "get_last_api_completion_timestamp",
+ "set_last_api_completion_timestamp",
+ "get_last_emitted_date",
+ "set_last_emitted_date",
+ # Test reset
+ "reset_state_for_tests",
]
diff --git a/src/bridge/__init__.py b/src/bridge/__init__.py
index 43f54f0c4..f17694d22 100644
--- a/src/bridge/__init__.py
+++ b/src/bridge/__init__.py
@@ -1,4 +1,17 @@
-"""Python package placeholder for the archived `bridge` subsystem."""
+"""CCR bridge subsystem (Bridge v1 + Bridge v2 + shared primitives).
+
+Real implementations land here per ``my-docs/get-parity-by-folder/
+bridge-refactoring-plan.md``. The ``ARCHIVE_NAME``/``MODULE_COUNT``/
+``SAMPLE_FILES``/``PORTING_NOTE`` re-exports are preserved for backwards
+compat with ``tests/test_porting_workspace.py:73-79`` (``from src import
+bridge`` then ``bridge.MODULE_COUNT > 0``).
+
+This module also re-exports the most-used Phase 1 leaves so consumers can
+write ``from src.bridge import BoundedUUIDSet`` rather than the full path.
+Per refactoring plan §4 risk row, every re-export must be importable
+cleanly with no Phase 2 module present — verified by the legacy
+test_porting_workspace test which would otherwise fail at import time.
+"""
from __future__ import annotations
@@ -13,4 +26,176 @@
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
-__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
+# -----------------------------------------------------------------------------
+# Phase 1 re-exports (Phase 2 deps not required for any of these imports)
+# -----------------------------------------------------------------------------
+
+from src.bridge.bounded_uuid_set import BoundedUUIDSet
+from src.bridge.bridge_api import (
+ ANTHROPIC_VERSION,
+ BETA_HEADER,
+ create_bridge_api_client,
+ is_expired_error_type,
+ is_suppressible_403,
+ validate_bridge_id,
+)
+from src.bridge.bridge_config import (
+ get_bridge_access_token,
+ get_bridge_base_url,
+ get_bridge_base_url_override,
+ get_bridge_token_override,
+)
+from src.bridge.bridge_permission_callbacks import (
+ BridgePermissionResponse,
+ is_bridge_permission_response,
+)
+from src.bridge.bridge_status_util import (
+ BridgeStatusInfo,
+ build_active_footer_text,
+ build_bridge_connect_url,
+ build_bridge_session_url,
+ build_idle_footer_text,
+ format_duration,
+ get_bridge_status,
+ truncate_to_width,
+ wrap_with_osc8_link,
+)
+from src.bridge.capacity_wake import CapacityWake, create_capacity_wake
+from src.bridge.close_codes import (
+ WS_CLOSE_EPOCH_MISMATCH,
+ WS_CLOSE_INIT_FAILURE,
+ WS_CLOSE_PERMANENT_UNAUTHORIZED,
+ WS_CLOSE_RECONNECT_BUDGET_EXHAUSTED,
+ WS_CLOSE_SESSION_NOT_FOUND,
+)
+from src.bridge.env_less_bridge_config import (
+ DEFAULT_ENV_LESS_BRIDGE_CONFIG,
+ EnvLessBridgeConfig,
+ get_env_less_bridge_config,
+)
+from src.bridge.exceptions import (
+ BridgeAuthError,
+ BridgeFatalError,
+ EpochSupersededError,
+)
+from src.bridge.flush_gate import FlushGate
+from src.bridge.inbound_messages import (
+ extract_inbound_message_fields,
+ normalize_image_blocks,
+)
+from src.bridge.poll_config import get_poll_interval_config
+from src.bridge.poll_config_defaults import (
+ DEFAULT_POLL_CONFIG,
+ PollIntervalConfig,
+)
+from src.bridge.repl_bridge_handle import (
+ get_repl_bridge_handle,
+ get_self_bridge_compat_id,
+ set_repl_bridge_handle,
+)
+
+
+# Lazy re-export of the Phase 5 orchestrator surface. ``remote_bridge_core``
+# transitively imports ``repl_bridge_transport`` → ``src.transports.ccr_client``
+# → ``src.bridge.exceptions``. Eagerly importing here triggers a circular
+# import the first time any consumer starts at ``src.transports.ccr_client``
+# (the ``exceptions`` import re-enters ``bridge/__init__.py`` mid-load).
+# PEP 562 ``__getattr__`` defers the import until first attribute access,
+# which by then has ``bridge/__init__.py`` fully loaded.
+def __getattr__(name: str) -> object:
+ if name in ('init_env_less_bridge_core', 'EnvLessBridgeParams',
+ 'RemoteBridgeHandle'):
+ from src.bridge import remote_bridge_core as _rbc
+
+ return getattr(_rbc, name)
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+from src.bridge.session_id_compat import (
+ set_cse_shim_gate,
+ to_compat_session_id,
+ to_infra_session_id,
+)
+from src.bridge.types import (
+ BRIDGE_LOGIN_ERROR,
+ BRIDGE_LOGIN_INSTRUCTION,
+ DEFAULT_SESSION_TIMEOUT_MS,
+ REMOTE_CONTROL_DISCONNECTED_MSG,
+ BridgeConfig,
+ SessionActivity,
+)
+from src.bridge.work_secret import (
+ WorkSecret,
+ build_ccr_v2_sdk_url,
+ build_sdk_url,
+ decode_work_secret,
+ same_session_id,
+)
+
+__all__ = [
+ # Legacy archive metadata (test_porting_workspace.py compat)
+ 'ARCHIVE_NAME',
+ 'MODULE_COUNT',
+ 'PORTING_NOTE',
+ 'SAMPLE_FILES',
+ # Phase 1 leaves + Phase 3 HTTP client surface
+ 'ANTHROPIC_VERSION',
+ 'BETA_HEADER',
+ 'BRIDGE_LOGIN_ERROR',
+ 'BRIDGE_LOGIN_INSTRUCTION',
+ 'BoundedUUIDSet',
+ 'BridgeAuthError',
+ 'BridgeConfig',
+ 'BridgeFatalError',
+ 'BridgePermissionResponse',
+ 'BridgeStatusInfo',
+ 'CapacityWake',
+ 'DEFAULT_ENV_LESS_BRIDGE_CONFIG',
+ 'DEFAULT_POLL_CONFIG',
+ 'DEFAULT_SESSION_TIMEOUT_MS',
+ 'EnvLessBridgeConfig',
+ 'EnvLessBridgeParams',
+ 'EpochSupersededError',
+ 'FlushGate',
+ 'PollIntervalConfig',
+ 'REMOTE_CONTROL_DISCONNECTED_MSG',
+ 'RemoteBridgeHandle',
+ 'SessionActivity',
+ 'WS_CLOSE_EPOCH_MISMATCH',
+ 'WS_CLOSE_INIT_FAILURE',
+ 'WS_CLOSE_PERMANENT_UNAUTHORIZED',
+ 'WS_CLOSE_RECONNECT_BUDGET_EXHAUSTED',
+ 'WS_CLOSE_SESSION_NOT_FOUND',
+ 'WorkSecret',
+ 'create_bridge_api_client',
+ 'is_expired_error_type',
+ 'is_suppressible_403',
+ 'validate_bridge_id',
+ 'build_active_footer_text',
+ 'build_bridge_connect_url',
+ 'build_bridge_session_url',
+ 'build_ccr_v2_sdk_url',
+ 'build_idle_footer_text',
+ 'build_sdk_url',
+ 'create_capacity_wake',
+ 'decode_work_secret',
+ 'extract_inbound_message_fields',
+ 'format_duration',
+ 'get_bridge_access_token',
+ 'get_bridge_base_url',
+ 'get_bridge_base_url_override',
+ 'get_bridge_status',
+ 'get_bridge_token_override',
+ 'get_env_less_bridge_config',
+ 'get_poll_interval_config',
+ 'get_repl_bridge_handle',
+ 'get_self_bridge_compat_id',
+ 'init_env_less_bridge_core',
+ 'is_bridge_permission_response',
+ 'normalize_image_blocks',
+ 'same_session_id',
+ 'set_cse_shim_gate',
+ 'set_repl_bridge_handle',
+ 'to_compat_session_id',
+ 'to_infra_session_id',
+ 'truncate_to_width',
+ 'wrap_with_osc8_link',
+]
diff --git a/src/bridge/bounded_uuid_set.py b/src/bridge/bounded_uuid_set.py
new file mode 100644
index 000000000..2aa466468
--- /dev/null
+++ b/src/bridge/bounded_uuid_set.py
@@ -0,0 +1,75 @@
+"""Fixed-capacity UUID dedup set with FIFO eviction.
+
+Mirrors ``BoundedUUIDSet`` from ``typescript/src/bridge/bridgeMessaging.ts:429-461``.
+
+The CCR bridge has an echo problem: a message a client posts may be
+re-delivered on the read stream, and a transport swap can cause the
+server to replay history. Two parallel sets at capacity 2000 act as the
+dedup buffer (one for posted UUIDs, one for inbound UUIDs).
+
+O(1) ``add``/``has``/``len``; O(capacity) memory. The TS code uses a
+manual ``writeIdx`` modulo because JS lacks a deque; Python's
+``collections.deque(maxlen=capacity)`` provides FIFO eviction natively.
+
+Concurrency: single-coroutine use only. Concurrent ``add``/``has``/``clear``
+calls from different asyncio tasks are undefined behavior. The TS source
+is single-threaded JS by definition; Python needs this said explicitly
+because asyncio + threads is a footgun.
+"""
+
+from __future__ import annotations
+
+from collections import deque
+
+DEFAULT_CAPACITY = 2000
+
+
+class BoundedUUIDSet:
+ """FIFO-bounded set; oldest entry evicted when capacity is reached."""
+
+ __slots__ = ('_capacity', '_ring', '_set')
+
+ def __init__(self, capacity: int = DEFAULT_CAPACITY) -> None:
+ if capacity <= 0:
+ raise ValueError('BoundedUUIDSet capacity must be > 0')
+ self._capacity: int = capacity
+ # ``deque(maxlen=N)`` automatically discards the leftmost element
+ # when ``append`` would exceed ``N``. We mirror that eviction in
+ # ``_set`` so membership stays consistent.
+ self._ring: deque[str] = deque(maxlen=capacity)
+ self._set: set[str] = set()
+
+ @property
+ def capacity(self) -> int:
+ return self._capacity
+
+ def __len__(self) -> int:
+ return len(self._set)
+
+ def add(self, uuid: str) -> None:
+ """Add ``uuid``; idempotent (does NOT bump LRU position).
+
+ Matches TS ``:441`` early-return-when-already-present semantics.
+ """
+ if uuid in self._set:
+ return
+ if len(self._ring) == self._capacity:
+ # deque.append will evict leftmost; remove it from the set
+ # FIRST so we don't leak it.
+ evicted = self._ring[0]
+ self._set.discard(evicted)
+ self._ring.append(uuid)
+ self._set.add(uuid)
+
+ def has(self, uuid: str) -> bool:
+ return uuid in self._set
+
+ def __contains__(self, uuid: object) -> bool:
+ return isinstance(uuid, str) and uuid in self._set
+
+ def clear(self) -> None:
+ self._ring.clear()
+ self._set.clear()
+
+
+__all__ = ['DEFAULT_CAPACITY', 'BoundedUUIDSet']
diff --git a/src/bridge/bridge_api.py b/src/bridge/bridge_api.py
new file mode 100644
index 000000000..d48b94c5d
--- /dev/null
+++ b/src/bridge/bridge_api.py
@@ -0,0 +1,740 @@
+"""OAuth-authenticated HTTP client for the bridge environments API.
+
+Ports ``typescript/src/bridge/bridgeApi.ts``.
+
+Wraps the ``/v1/environments/bridge/*``, ``/v1/sessions/*``, and ``/v1/work/*``
+endpoints behind the ``BridgeApiClient`` Protocol (defined in
+``src.bridge.types``). The client handles:
+
+* OAuth bearer auth + ``X-Trusted-Device-Token`` + ``anthropic-version`` +
+ ``anthropic-beta: environments-2025-11-01`` headers.
+* Path-segment validation (``validate_bridge_id``) so server-provided IDs
+ can't trigger path traversal.
+* One-shot 401 retry via the injected ``on_auth_401`` callback (mirrors
+ ``handleOAuth401Error`` in TS) — only on mutation endpoints that use
+ OAuth tokens directly; poll / ack / heartbeat use the environment
+ secret or session token so they don't go through the retry wrapper.
+* ``BridgeFatalError`` for 401/403/404/410 plus standard ``Exception``
+ for 429 / other 5xx-suppressed errors. ``handle_error_status`` is the
+ central router.
+* Empty-poll log throttling (first poll + every 100th) to keep debug
+ logs readable when the bridge sits idle for hours.
+
+Backed by ``httpx.AsyncClient``. Tests inject a ``MockTransport`` via
+the ``client`` parameter to ``create_bridge_api_client`` (same pattern
+as ``code_session_api.py``).
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any, Awaitable, Callable
+
+import httpx
+
+from src.bridge.debug_utils import debug_body, extract_error_detail
+from src.bridge.exceptions import BridgeFatalError
+from src.bridge.types import (
+ BRIDGE_LOGIN_INSTRUCTION,
+ BridgeApiClient,
+ BridgeConfig,
+ PermissionResponseEvent,
+ WorkResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Anthropic-beta header value for the environments API. Mirrors TS
+# ``BETA_HEADER`` on ``bridgeApi.ts:38``.
+BETA_HEADER = 'environments-2025-11-01'
+
+ANTHROPIC_VERSION = '2023-06-01'
+
+# httpx default timeout for bridge requests (seconds). Matches TS
+# axios ``timeout: 10_000`` / ``15_000`` on the registration call.
+_DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0
+_REGISTRATION_TIMEOUT_SECONDS = 15.0
+
+
+# Allowlist pattern for server-provided IDs used in URL path segments.
+# Mirrors TS ``SAFE_ID_PATTERN`` on ``bridgeApi.ts:41``.
+_SAFE_ID_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$')
+
+
+def validate_bridge_id(value: str, label: str) -> str:
+ """Validate that a server-provided ID is safe to interpolate into URLs.
+
+ Mirrors TS ``validateBridgeId`` on ``bridgeApi.ts:48-53``. Rejects
+ path traversal (``../../admin``) and IDs containing slashes, dots, or
+ other special characters. Raises ``ValueError`` on rejection so the
+ bug surfaces at the call site rather than producing a malformed URL.
+ """
+ if not value or not _SAFE_ID_PATTERN.match(value):
+ raise ValueError(f'Invalid {label}: contains unsafe characters')
+ return value
+
+
+# ── Public predicates ─────────────────────────────────────────────────────
+
+
+def is_expired_error_type(error_type: str | None) -> bool:
+ """Mirrors TS ``isExpiredErrorType`` on ``bridgeApi.ts:503-508``.
+
+ Used by callers to distinguish "session expired, recreate" from
+ "permission denied, fail loud" on a 403/410.
+ """
+ if not error_type:
+ return False
+ return 'expired' in error_type or 'lifetime' in error_type
+
+
+def is_suppressible_403(err: BridgeFatalError) -> bool:
+ """Whether a 403 is a known-suppressible scope/permission error.
+
+ Mirrors TS ``isSuppressible403`` on ``bridgeApi.ts:516-524``. Some
+ 403s are for scopes like ``external_poll_sessions`` or
+ ``environments:manage`` that don't affect core functionality —
+ callers can hide these from the user.
+ """
+ if err.status != 403:
+ return False
+ message = str(err)
+ return (
+ 'external_poll_sessions' in message
+ or 'environments:manage' in message
+ )
+
+
+# ── Factory + client implementation ───────────────────────────────────────
+
+
+OnDebug = Callable[[str], None]
+OnAuth401 = Callable[[str], Awaitable[bool]]
+GetAccessToken = Callable[[], str | None]
+GetTrustedDeviceToken = Callable[[], str | None]
+
+
+def create_bridge_api_client(
+ *,
+ base_url: str,
+ get_access_token: GetAccessToken,
+ runner_version: str,
+ on_debug: OnDebug | None = None,
+ on_auth_401: OnAuth401 | None = None,
+ get_trusted_device_token: GetTrustedDeviceToken | None = None,
+ client: httpx.AsyncClient | None = None,
+) -> BridgeApiClient:
+ """Factory mirroring TS ``createBridgeApiClient(deps)``.
+
+ Returns an object that satisfies the ``BridgeApiClient`` Protocol
+ from ``src.bridge.types``.
+
+ Args (kw-only to match the readability of the TS options object):
+ base_url: Bridge API root URL (e.g. ``https://api.anthropic.com``).
+ get_access_token: Sync callable returning the current OAuth token
+ or ``None``. Called before every OAuth-authed request.
+ runner_version: Version string sent as the
+ ``x-environment-runner-version`` header.
+ on_debug: Optional sync callable for debug log lines. When absent,
+ messages route to the module logger at DEBUG level.
+ on_auth_401: Optional async callback invoked on a 401. Should
+ attempt to refresh the OAuth token and return ``True`` on
+ success. When absent, 401s go straight to ``BridgeFatalError``.
+ See TS comment on ``bridgeApi.ts:17-25`` for why this is
+ injected rather than imported.
+ get_trusted_device_token: Optional sync callable returning the
+ ``X-Trusted-Device-Token`` header value, or ``None`` to omit
+ the header.
+ client: Optional ``httpx.AsyncClient`` for test injection. When
+ ``None``, a fresh client is created per request (and closed
+ after) — fine for tests/scripts but loses connection pooling
+ and adds ~10ms TLS handshake per call. **Strongly recommended
+ to pass a long-lived client in production** (especially for
+ the polling loop in Phase 6 — at 2s poll interval that's
+ ~300 wasted handshakes/hour).
+ """
+ return _BridgeApiClient(
+ base_url=base_url,
+ get_access_token=get_access_token,
+ runner_version=runner_version,
+ on_debug=on_debug,
+ on_auth_401=on_auth_401,
+ get_trusted_device_token=get_trusted_device_token,
+ client=client,
+ )
+
+
+class _BridgeApiClient:
+ """Concrete implementation of ``BridgeApiClient``.
+
+ Not exported — callers construct via ``create_bridge_api_client`` so
+ the Protocol stays the public surface.
+ """
+
+ _EMPTY_POLL_LOG_INTERVAL = 100
+
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ get_access_token: GetAccessToken,
+ runner_version: str,
+ on_debug: OnDebug | None,
+ on_auth_401: OnAuth401 | None,
+ get_trusted_device_token: GetTrustedDeviceToken | None,
+ client: httpx.AsyncClient | None,
+ ) -> None:
+ self._base_url = base_url.rstrip('/')
+ self._get_access_token = get_access_token
+ self._runner_version = runner_version
+ self._on_debug = on_debug
+ self._on_auth_401 = on_auth_401
+ self._get_trusted_device_token = get_trusted_device_token
+ self._client = client
+ self._consecutive_empty_polls = 0
+
+ # ── internal helpers ────────────────────────────────────────────────
+
+ def _debug(self, msg: str) -> None:
+ if self._on_debug is not None:
+ self._on_debug(msg)
+ else:
+ logger.debug(msg)
+
+ def _headers(self, access_token: str) -> dict[str, str]:
+ """Mirror TS ``getHeaders`` on ``bridgeApi.ts:76-89``."""
+ headers = {
+ 'Authorization': f'Bearer {access_token}',
+ 'Content-Type': 'application/json',
+ 'anthropic-version': ANTHROPIC_VERSION,
+ 'anthropic-beta': BETA_HEADER,
+ 'x-environment-runner-version': self._runner_version,
+ }
+ if self._get_trusted_device_token is not None:
+ token = self._get_trusted_device_token()
+ if token:
+ headers['X-Trusted-Device-Token'] = token
+ return headers
+
+ def _resolve_auth(self) -> str:
+ """Mirror TS ``resolveAuth`` on ``bridgeApi.ts:91-97``.
+
+ **Behavioral divergence (intentional)**: TS throws a plain
+ ``Error(BRIDGE_LOGIN_INSTRUCTION)``; we throw a
+ ``BridgeFatalError(status=401)`` so callers can catch the typed
+ error and inspect ``.status`` rather than string-matching the
+ message. Same observable failure (request never goes out), but
+ Python callers get a richer signal.
+ """
+ token = self._get_access_token()
+ if not token:
+ raise BridgeFatalError(BRIDGE_LOGIN_INSTRUCTION, status=401)
+ return token
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ access_token: str,
+ json_body: Any = None,
+ params: dict[str, Any] | None = None,
+ timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS,
+ ) -> httpx.Response:
+ """Single HTTP request via the injected or freshly-created client.
+
+ On the no-injected-client fallback path, a fresh ``AsyncClient``
+ is constructed per call — TLS handshake + no connection pooling —
+ which is fine for tests / scripts but suboptimal for polling
+ loops. Production callers should pass a long-lived ``client``;
+ see the factory docstring.
+ """
+ url = f'{self._base_url}{path}'
+ kwargs: dict[str, Any] = {
+ 'headers': self._headers(access_token),
+ 'timeout': timeout_seconds,
+ }
+ if json_body is not None:
+ kwargs['json'] = json_body
+ if params is not None:
+ kwargs['params'] = params
+ if self._client is not None:
+ return await self._client.request(method, url, **kwargs)
+ return await self._send_with_fresh_client(method, url, kwargs)
+
+ async def _send_with_fresh_client(
+ self,
+ method: str,
+ url: str,
+ kwargs: dict[str, Any],
+ ) -> httpx.Response:
+ """Per-request client fallback. Extracted as a seam for tests.
+
+ Tests monkeypatch this method to verify the no-injected-client
+ production path (factory called without ``client=``). Production
+ callers should not subclass.
+ """
+ async with httpx.AsyncClient() as client:
+ return await client.request(method, url, **kwargs)
+
+ async def _with_oauth_retry(
+ self,
+ do_request: Callable[[str], Awaitable[httpx.Response]],
+ context: str,
+ ) -> httpx.Response:
+ """Execute an OAuth-authed request, retrying once on 401.
+
+ Mirrors TS ``withOAuthRetry`` on ``bridgeApi.ts:106-139``. The
+ retry only fires when ``on_auth_401`` is wired and returns
+ ``True``; otherwise the 401 falls through to
+ ``_handle_error_status`` which raises ``BridgeFatalError``.
+ """
+ access_token = self._resolve_auth()
+ response = await do_request(access_token)
+ if response.status_code != 401:
+ return response
+ if self._on_auth_401 is None:
+ self._debug(
+ f'[bridge:api] {context}: 401 received, no refresh handler'
+ )
+ return response
+ self._debug(
+ f'[bridge:api] {context}: 401 received, attempting token refresh'
+ )
+ refreshed = await self._on_auth_401(access_token)
+ if refreshed:
+ self._debug(
+ f'[bridge:api] {context}: Token refreshed, retrying request'
+ )
+ new_token = self._resolve_auth()
+ retry_response = await do_request(new_token)
+ if retry_response.status_code != 401:
+ return retry_response
+ self._debug(
+ f'[bridge:api] {context}: Retry after refresh also got 401'
+ )
+ else:
+ self._debug(f'[bridge:api] {context}: Token refresh failed')
+ return response
+
+ # ── BridgeApiClient methods ─────────────────────────────────────────
+
+ async def register_bridge_environment(
+ self, config: BridgeConfig
+ ) -> dict[str, str]:
+ """POST ``/v1/environments/bridge``. Mirror TS lines 142-197."""
+ self._debug(
+ f'[bridge:api] POST /v1/environments/bridge '
+ f'bridgeId={config.bridge_id}'
+ )
+
+ body: dict[str, Any] = {
+ 'machine_name': config.machine_name,
+ 'directory': config.dir,
+ 'branch': config.branch,
+ 'git_repo_url': config.git_repo_url,
+ 'max_sessions': config.max_sessions,
+ 'metadata': {'worker_type': config.worker_type},
+ }
+ if config.reuse_environment_id:
+ body['environment_id'] = config.reuse_environment_id
+
+ async def do(access_token: str) -> httpx.Response:
+ return await self._request(
+ 'POST',
+ '/v1/environments/bridge',
+ access_token=access_token,
+ json_body=body,
+ timeout_seconds=_REGISTRATION_TIMEOUT_SECONDS,
+ )
+
+ response = await self._with_oauth_retry(do, 'Registration')
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'Registration')
+ # Mirror the defensive guard pattern from heartbeat_work — a 200
+ # with a non-JSON body (gateway HTML page, truncated response,
+ # content-type mismatch) surfaces as ``data == None`` here, and
+ # ``data.get(...)`` below would crash with ``AttributeError``.
+ if not isinstance(data, dict):
+ raise BridgeFatalError(
+ 'Registration: malformed response — non-JSON body',
+ status=response.status_code,
+ )
+ env_id = data.get('environment_id')
+ env_secret = data.get('environment_secret')
+ if not isinstance(env_id, str) or not isinstance(env_secret, str):
+ raise BridgeFatalError(
+ 'Registration: malformed response — '
+ 'missing environment_id / environment_secret',
+ status=response.status_code,
+ )
+ self._debug(
+ f'[bridge:api] POST /v1/environments/bridge -> '
+ f'{response.status_code} environment_id={env_id}'
+ )
+ self._debug(f'[bridge:api] >>> {debug_body(body)}')
+ self._debug(f'[bridge:api] <<< {debug_body(data)}')
+ return {'environment_id': env_id, 'environment_secret': env_secret}
+
+ async def poll_for_work(
+ self,
+ environment_id: str,
+ environment_secret: str,
+ cancel_event: Any | None = None, # noqa: ARG002 Phase 5 wiring
+ reclaim_older_than_ms: int | None = None,
+ ) -> WorkResponse | None:
+ """GET ``.../work/poll``. Mirror TS lines 199-247.
+
+ ``cancel_event`` is accepted for Protocol parity but not yet
+ wired — Phase 5 will inject an ``asyncio.Event`` and we'll
+ connect it via ``create_combined_abort_signal``. For now httpx's
+ request-level timeout handles slow polls; caller-driven
+ cancellation requires only task cancellation (``task.cancel()``)
+ which httpx honors natively.
+ """
+ validate_bridge_id(environment_id, 'environment_id')
+
+ # Reset and capture; restore only when the response is truly empty.
+ prev_empty_polls = self._consecutive_empty_polls
+ self._consecutive_empty_polls = 0
+
+ params: dict[str, Any] | None = (
+ {'reclaim_older_than_ms': reclaim_older_than_ms}
+ if reclaim_older_than_ms is not None
+ else None
+ )
+ response = await self._request(
+ 'GET',
+ f'/v1/environments/{environment_id}/work/poll',
+ access_token=environment_secret,
+ params=params,
+ )
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'Poll')
+
+ # ``data is None`` → no work. Matches TS ``!response.data`` for the
+ # null branch exactly. Anything else (including malformed empty
+ # dict ``{}``) falls through so the orchestrator can detect a
+ # server-contract violation rather than silently treating it as
+ # "no work."
+ if data is None:
+ self._consecutive_empty_polls = prev_empty_polls + 1
+ if (
+ self._consecutive_empty_polls == 1
+ or self._consecutive_empty_polls % self._EMPTY_POLL_LOG_INTERVAL == 0
+ ):
+ self._debug(
+ f'[bridge:api] GET .../work/poll -> '
+ f'{response.status_code} (no work, '
+ f'{self._consecutive_empty_polls} consecutive empty polls)'
+ )
+ return None
+
+ inner = data.get('data') if isinstance(data, dict) else None
+ inner_type = inner.get('type') if isinstance(inner, dict) else None
+ inner_id = inner.get('id') if isinstance(inner, dict) else None
+ self._debug(
+ f'[bridge:api] GET .../work/poll -> {response.status_code} '
+ f'workId={data.get("id")} type={inner_type}'
+ + (f' sessionId={inner_id}' if inner_id else '')
+ )
+ self._debug(f'[bridge:api] <<< {debug_body(data)}')
+ return data # type: ignore[return-value]
+
+ async def acknowledge_work(
+ self, environment_id: str, work_id: str, session_token: str
+ ) -> None:
+ """POST ``.../work/{id}/ack``. Mirror TS lines 249-271."""
+ validate_bridge_id(environment_id, 'environment_id')
+ validate_bridge_id(work_id, 'work_id')
+ self._debug(f'[bridge:api] POST .../work/{work_id}/ack')
+ response = await self._request(
+ 'POST',
+ f'/v1/environments/{environment_id}/work/{work_id}/ack',
+ access_token=session_token,
+ json_body={},
+ )
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'Acknowledge')
+ self._debug(
+ f'[bridge:api] POST .../work/{work_id}/ack -> {response.status_code}'
+ )
+
+ async def stop_work(
+ self, environment_id: str, work_id: str, force: bool
+ ) -> None:
+ """POST ``.../work/{id}/stop``. Mirror TS lines 273-299."""
+ validate_bridge_id(environment_id, 'environment_id')
+ validate_bridge_id(work_id, 'work_id')
+ self._debug(
+ f'[bridge:api] POST .../work/{work_id}/stop force={force}'
+ )
+
+ async def do(access_token: str) -> httpx.Response:
+ return await self._request(
+ 'POST',
+ f'/v1/environments/{environment_id}/work/{work_id}/stop',
+ access_token=access_token,
+ json_body={'force': force},
+ )
+
+ response = await self._with_oauth_retry(do, 'StopWork')
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'StopWork')
+ self._debug(
+ f'[bridge:api] POST .../work/{work_id}/stop -> {response.status_code}'
+ )
+
+ async def deregister_environment(self, environment_id: str) -> None:
+ """DELETE ``/v1/environments/bridge/{id}``. Mirror TS lines 301-323."""
+ validate_bridge_id(environment_id, 'environment_id')
+ self._debug(
+ f'[bridge:api] DELETE /v1/environments/bridge/{environment_id}'
+ )
+
+ async def do(access_token: str) -> httpx.Response:
+ return await self._request(
+ 'DELETE',
+ f'/v1/environments/bridge/{environment_id}',
+ access_token=access_token,
+ )
+
+ response = await self._with_oauth_retry(do, 'Deregister')
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'Deregister')
+ self._debug(
+ f'[bridge:api] DELETE /v1/environments/bridge/{environment_id} '
+ f'-> {response.status_code}'
+ )
+
+ async def archive_session(self, session_id: str) -> None:
+ """POST ``/v1/sessions/{id}/archive``. Mirror TS lines 325-356.
+
+ 409 is treated as success (idempotent — session already archived).
+ """
+ validate_bridge_id(session_id, 'session_id')
+ self._debug(f'[bridge:api] POST /v1/sessions/{session_id}/archive')
+
+ async def do(access_token: str) -> httpx.Response:
+ return await self._request(
+ 'POST',
+ f'/v1/sessions/{session_id}/archive',
+ access_token=access_token,
+ json_body={},
+ )
+
+ response = await self._with_oauth_retry(do, 'ArchiveSession')
+ if response.status_code == 409:
+ self._debug(
+ f'[bridge:api] POST /v1/sessions/{session_id}/archive '
+ f'-> 409 (already archived)'
+ )
+ return
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'ArchiveSession')
+ self._debug(
+ f'[bridge:api] POST /v1/sessions/{session_id}/archive '
+ f'-> {response.status_code}'
+ )
+
+ async def reconnect_session(
+ self, environment_id: str, session_id: str
+ ) -> None:
+ """POST ``.../bridge/reconnect``. Mirror TS lines 358-385."""
+ validate_bridge_id(environment_id, 'environment_id')
+ validate_bridge_id(session_id, 'session_id')
+ self._debug(
+ f'[bridge:api] POST /v1/environments/{environment_id}'
+ f'/bridge/reconnect session_id={session_id}'
+ )
+
+ async def do(access_token: str) -> httpx.Response:
+ return await self._request(
+ 'POST',
+ f'/v1/environments/{environment_id}/bridge/reconnect',
+ access_token=access_token,
+ json_body={'session_id': session_id},
+ )
+
+ response = await self._with_oauth_retry(do, 'ReconnectSession')
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'ReconnectSession')
+ self._debug(
+ f'[bridge:api] POST .../bridge/reconnect -> {response.status_code}'
+ )
+
+ async def heartbeat_work(
+ self, environment_id: str, work_id: str, session_token: str
+ ) -> dict[str, Any]:
+ """POST ``.../work/{id}/heartbeat``. Mirror TS lines 387-417."""
+ validate_bridge_id(environment_id, 'environment_id')
+ validate_bridge_id(work_id, 'work_id')
+ self._debug(f'[bridge:api] POST .../work/{work_id}/heartbeat')
+ response = await self._request(
+ 'POST',
+ f'/v1/environments/{environment_id}/work/{work_id}/heartbeat',
+ access_token=session_token,
+ json_body={},
+ )
+ data = _safe_json(response)
+ _handle_error_status(response.status_code, data, 'Heartbeat')
+ if not isinstance(data, dict):
+ raise BridgeFatalError(
+ 'Heartbeat: malformed response',
+ status=response.status_code,
+ )
+ self._debug(
+ f'[bridge:api] POST .../work/{work_id}/heartbeat -> '
+ f'{response.status_code} lease_extended={data.get("lease_extended")} '
+ f'state={data.get("state")}'
+ )
+ return data
+
+ async def send_permission_response_event(
+ self,
+ session_id: str,
+ event: PermissionResponseEvent,
+ session_token: str,
+ ) -> None:
+ """POST ``/v1/sessions/{id}/events``. Mirror TS lines 419-450."""
+ validate_bridge_id(session_id, 'session_id')
+ event_type = event.get('type', '')
+ self._debug(
+ f'[bridge:api] POST /v1/sessions/{session_id}/events '
+ f'type={event_type}'
+ )
+ body = {'events': [event]}
+ response = await self._request(
+ 'POST',
+ f'/v1/sessions/{session_id}/events',
+ access_token=session_token,
+ json_body=body,
+ )
+ data = _safe_json(response)
+ _handle_error_status(
+ response.status_code, data, 'SendPermissionResponseEvent'
+ )
+ self._debug(
+ f'[bridge:api] POST /v1/sessions/{session_id}/events '
+ f'-> {response.status_code}'
+ )
+ self._debug(f'[bridge:api] >>> {debug_body(body)}')
+ self._debug(f'[bridge:api] <<< {debug_body(data)}')
+
+
+# ── Error handling ────────────────────────────────────────────────────────
+
+
+def _extract_error_type_from_data(data: Any) -> str | None:
+ """Pull ``data.error.type`` from a structured error body.
+
+ Mirrors TS ``extractErrorTypeFromData`` on ``bridgeApi.ts:526-539``.
+ Internal helper — not exported (but tested via the public error
+ paths it feeds into).
+ """
+ if not isinstance(data, dict):
+ return None
+ error = data.get('error')
+ if not isinstance(error, dict):
+ return None
+ error_type = error.get('type')
+ if isinstance(error_type, str):
+ return error_type
+ return None
+
+
+def _safe_json(response: httpx.Response) -> Any:
+ """Best-effort ``response.json()`` — returns ``None`` on parse error."""
+ try:
+ return response.json()
+ except (ValueError, httpx.DecodingError):
+ return None
+
+
+def _handle_error_status(
+ status: int,
+ data: Any,
+ context: str,
+) -> None:
+ """Raise ``BridgeFatalError`` (or generic ``Exception``) on non-2xx.
+
+ Mirrors TS ``handleErrorStatus`` on ``bridgeApi.ts:454-500``. Maps
+ each well-known status to a typed exception with the most useful
+ diagnostic message we can build from the response body.
+
+ 200 / 204 → no-op.
+ 401 → BridgeFatalError(401) with login instruction.
+ 403 → BridgeFatalError(403); special-cases expired-error-type as
+ "Remote Control session has expired".
+ 404 → BridgeFatalError(404); detail-or-default message.
+ 410 → BridgeFatalError(410) tagged ``environment_expired``.
+ 429 → Plain ``Exception`` (not fatal; caller backs off).
+ other → Plain ``Exception``.
+ """
+ if status in (200, 204):
+ return
+ detail = extract_error_detail(data)
+ error_type = _extract_error_type_from_data(data)
+
+ if status == 401:
+ raise BridgeFatalError(
+ f'{context}: Authentication failed (401)'
+ + (f': {detail}' if detail else '')
+ + f'. {BRIDGE_LOGIN_INSTRUCTION}',
+ status=401,
+ error_type=error_type,
+ )
+ if status == 403:
+ if is_expired_error_type(error_type):
+ message = (
+ 'Remote Control session has expired. Please restart '
+ 'with `claude remote-control` or /remote-control.'
+ )
+ else:
+ message = (
+ f'{context}: Access denied (403)'
+ + (f': {detail}' if detail else '')
+ + '. Check your organization permissions.'
+ )
+ raise BridgeFatalError(message, status=403, error_type=error_type)
+ if status == 404:
+ raise BridgeFatalError(
+ detail
+ or (
+ f'{context}: Not found (404). Remote Control may not be '
+ 'available for this organization.'
+ ),
+ status=404,
+ error_type=error_type,
+ )
+ if status == 410:
+ raise BridgeFatalError(
+ detail
+ or (
+ 'Remote Control session has expired. Please restart with '
+ '`claude remote-control` or /remote-control.'
+ ),
+ status=410,
+ error_type=error_type or 'environment_expired',
+ )
+ if status == 429:
+ raise Exception(
+ f'{context}: Rate limited (429). Polling too frequently.'
+ )
+ raise Exception(
+ f'{context}: Failed with status {status}'
+ + (f': {detail}' if detail else '')
+ )
+
+
+__all__ = [
+ 'ANTHROPIC_VERSION',
+ 'BETA_HEADER',
+ 'BridgeFatalError', # re-exported for callers that catch it
+ 'create_bridge_api_client',
+ 'is_expired_error_type',
+ 'is_suppressible_403',
+ 'validate_bridge_id',
+]
diff --git a/src/bridge/bridge_config.py b/src/bridge/bridge_config.py
new file mode 100644
index 000000000..802e42e78
--- /dev/null
+++ b/src/bridge/bridge_config.py
@@ -0,0 +1,80 @@
+"""Shared bridge auth/URL resolution.
+
+Ports ``typescript/src/bridge/bridgeConfig.ts``.
+
+Consolidates the ``CLAUDE_BRIDGE_*`` internal-only dev overrides that were
+previously copy-pasted across the bridge subsystem. Two layers:
+
+* ``*_override()`` returns the env var if set, else ``None``.
+* The non-``_override`` wrapper falls through to the real OAuth store /
+ config.
+
+Per refactoring plan §0.1 Q6 + §2 item 9: the OAuth fallthrough (TS
+``getClaudeAIOAuthTokens()?.accessToken``) is **not yet ported in the
+Python build**. ``get_bridge_access_token`` returns ``None`` on
+fallthrough (callers treat as "not logged in"). ``get_bridge_base_url``
+returns ``_DEFAULT_PRODUCTION_BASE_URL`` (matches TS production
+``getOauthConfig().BASE_API_URL`` after the GA OAuth config was finalized
+— Anthropic's production API host). When Phase 2 lands the real OAuth
+helpers, the fallthroughs will switch to the keychain reader; callers
+see no API change.
+"""
+
+from __future__ import annotations
+
+import os
+
+_DEFAULT_PRODUCTION_BASE_URL = 'https://api.anthropic.com'
+"""Fallback when no override AND no Phase 2 OAuth config is available.
+
+Mirrors TS ``getOauthConfig().BASE_API_URL`` for the production build. The
+TS function reads from an OAuth config object populated at startup; the
+Python port lands the constant inline until that config layer is ported.
+"""
+
+
+def get_bridge_token_override() -> str | None:
+ """Dev override: ``CLAUDE_BRIDGE_OAUTH_TOKEN``, else ``None``.
+
+ Mirrors TS ``getBridgeTokenOverride`` on ``bridgeConfig.ts:18-20``.
+ Empty string is treated as unset (matches the TS ``|| undefined``).
+ """
+ value = os.environ.get('CLAUDE_BRIDGE_OAUTH_TOKEN')
+ return value or None
+
+
+def get_bridge_base_url_override() -> str | None:
+ """Dev override: ``CLAUDE_BRIDGE_BASE_URL``, else ``None``.
+
+ Mirrors TS ``getBridgeBaseUrlOverride`` on ``bridgeConfig.ts:23-25``.
+ """
+ value = os.environ.get('CLAUDE_BRIDGE_BASE_URL')
+ return value or None
+
+
+def get_bridge_access_token() -> str | None:
+ """Access token for bridge API calls.
+
+ Mirrors TS ``getBridgeAccessToken`` on ``bridgeConfig.ts:31-33``.
+ Returns the dev override if set, else ``None`` (Phase 2 will swap
+ this fallthrough for the OAuth keychain read).
+ """
+ return get_bridge_token_override()
+
+
+def get_bridge_base_url() -> str:
+ """Base URL for bridge API calls.
+
+ Mirrors TS ``getBridgeBaseUrl`` on ``bridgeConfig.ts:39-41``. Always
+ returns a non-``None`` URL. Returns the dev override if set, else the
+ production base URL constant.
+ """
+ return get_bridge_base_url_override() or _DEFAULT_PRODUCTION_BASE_URL
+
+
+__all__ = [
+ 'get_bridge_access_token',
+ 'get_bridge_base_url',
+ 'get_bridge_base_url_override',
+ 'get_bridge_token_override',
+]
diff --git a/src/bridge/bridge_enabled.py b/src/bridge/bridge_enabled.py
new file mode 100644
index 000000000..521676763
--- /dev/null
+++ b/src/bridge/bridge_enabled.py
@@ -0,0 +1,101 @@
+"""Bridge enablement and entitlement gates.
+
+Ports ``typescript/src/bridge/bridgeEnabled.ts``.
+
+Per refactoring plan §0.1 Q2, GrowthBook is not wired in the Python build,
+so every gate is stubbed to a static value matching the v2-priority decision
+(§0.1 Q3 — v2-first). When/if GrowthBook integration lands in Phase 10, the
+stubs swap to real evaluators; callers see no API change.
+
+Returning ``True`` for ``is_bridge_enabled*`` is intentional — orchestrators
+(Phase 5+) need to be exercisable end-to-end without a runtime gate refusing
+them. The real entitlement decision belongs to the auth subsystem, which is
+out of scope for the Python port (per §0.1 Q6).
+"""
+
+from __future__ import annotations
+
+
+def is_bridge_enabled() -> bool:
+ """Cached, non-blocking entitlement check.
+
+ Mirrors TS ``isBridgeEnabled`` on ``bridgeEnabled.ts:28-36``. Always
+ True in the Python build — see module docstring.
+ """
+ return True
+
+
+async def is_bridge_enabled_blocking() -> bool:
+ """Blocking entitlement check (awaits GrowthBook in TS).
+
+ Mirrors TS ``isBridgeEnabledBlocking`` on ``bridgeEnabled.ts:50-55``.
+ Returns ``True`` synchronously — there's no GB to await.
+ """
+ return True
+
+
+async def get_bridge_disabled_reason() -> str | None:
+ """Diagnostic reason for "bridge not available", or None if enabled.
+
+ Mirrors TS ``getBridgeDisabledReason`` on ``bridgeEnabled.ts:70-87``.
+ Always None in the Python build (bridge always enabled per stubs).
+ """
+ return None
+
+
+def is_env_less_bridge_enabled() -> bool:
+ """Whether the env-less (v2) REPL bridge path is enabled.
+
+ Mirrors TS ``isEnvLessBridgeEnabled`` on ``bridgeEnabled.ts:126-130``.
+ Always True (v2-first per refactoring plan §0.1 Q3).
+ """
+ return True
+
+
+def is_cse_shim_enabled() -> bool:
+ """Whether the ``cse_*`` → ``session_*`` retag shim is active.
+
+ Mirrors TS ``isCseShimEnabled`` on ``bridgeEnabled.ts:141-148``. Always
+ True — matches the TS default and `session_id_compat.py`'s default-active
+ behavior when ``set_cse_shim_gate`` is never called.
+ """
+ return True
+
+
+def check_bridge_min_version() -> str | None:
+ """Returns an error message if CLI version is below the v1 min, else None.
+
+ Mirrors TS ``checkBridgeMinVersion`` on ``bridgeEnabled.ts:160-173``. The
+ Python build has no GrowthBook version floor, so always None.
+ """
+ return None
+
+
+def get_ccr_auto_connect_default() -> bool:
+ """Default for ``remote_control_at_startup``.
+
+ Mirrors TS ``getCcrAutoConnectDefault`` on ``bridgeEnabled.ts:185-189``.
+ Always False — the user must opt in.
+ """
+ return False
+
+
+def is_ccr_mirror_enabled() -> bool:
+ """Whether CCR mirror mode is enabled.
+
+ Mirrors TS ``isCcrMirrorEnabled`` on ``bridgeEnabled.ts:197-202``. Always
+ False in the Python build — mirror mode is a niche internal feature.
+ """
+ return False
+
+
+__all__ = [
+ 'check_bridge_min_version',
+ 'get_bridge_disabled_reason',
+ 'get_ccr_auto_connect_default',
+ 'is_bridge_enabled',
+ 'is_bridge_enabled_blocking',
+ 'is_ccr_mirror_enabled',
+ 'is_cse_shim_enabled',
+ 'is_env_less_bridge_enabled',
+]
diff --git a/src/bridge/bridge_main.py b/src/bridge/bridge_main.py
new file mode 100644
index 000000000..6f50297ff
--- /dev/null
+++ b/src/bridge/bridge_main.py
@@ -0,0 +1,1243 @@
+"""Multi-session bridge daemon — Phase 8 MVP slice.
+
+Ports ``typescript/src/bridge/bridgeMain.ts`` (2991 lines in TS).
+
+The TS file is the daemon/orchestrator entry point: parses CLI args,
+registers a multi-session environment, runs a polling loop that spawns
+session children up to capacity, manages per-session timeouts +
+worktree directories + status display + heartbeat mode + two-track
+error backoff + graceful shutdown.
+
+For autonomous porting in one session, this module implements the
+**structural skeleton + happy path**:
+
+* ``parse_args(args)`` — flag parser (--verbose, --sandbox, --spawn,
+ --capacity, --debug-file, --session-timeout, --permission-mode,
+ --name, --help)
+* ``BackoffConfig`` + ``DEFAULT_BACKOFF`` constants
+* ``ParsedArgs`` dataclass
+* ``BridgeHeadlessPermanentError`` exception (signals "stop trying;
+ not a transient failure")
+* ``is_connection_error(err)`` + ``is_server_error(err)`` predicates
+* ``run_bridge_loop(config, env_id, env_secret, api, spawner, logger,
+ cancel_event, ...)`` — multi-session work poll loop
+* ``bridge_main(args, *, ...)`` — end-to-end daemon entry: parse →
+ register → loop → shutdown
+
+What is **explicitly deferred** with TODOs at the call sites:
+
+* **Worktree spawn mode** — needs full ``git worktree`` integration
+ (``createAgentWorktree`` / ``removeAgentWorktree``). The MVP accepts
+ ``--spawn worktree`` but spawns sessions in the same dir with a
+ warning.
+* **Status display + UI** — no terminal renderer (no
+ ``createBridgeLogger`` equivalent yet). A logger is plumbed through;
+ output is logger-only.
+* **Per-session timeout watchdog** — ``--session-timeout`` is parsed
+ but not enforced. Phase 10 follow-up.
+* **Heartbeat mode** (``non_exclusive_heartbeat_interval_ms > 0``) —
+ defers to plain poll loop only.
+* **Two-track error backoff** (connection vs general error tracks with
+ independent give-up thresholds) — uses fixed sleep on errors.
+* **KAIROS conditional logic** (resumable shutdown, --session-id /
+ --continue resume) — flags rejected with a clear error.
+* **Title derivation via onFirstUserMessage** — not wired (Phase 10).
+* **Worktree analytics + spawn-mode display toggles** — out of scope.
+
+What IS ported in full:
+
+* CLI arg parsing for the common flag surface
+* Multi-session capacity control (single + multi)
+* Session bookkeeping (active_sessions, work_ids, compat_ids, timers
+ maps for future expansion)
+* Work poll loop with capacity gating
+* stop_work retry on shutdown
+* archive + deregister sequence
+* Graceful shutdown signal handler
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import signal
+import socket
+import time
+import uuid
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+from src.bridge.jwt_utils import TokenRefreshScheduler
+from src.bridge.bridge_api import (
+ BridgeFatalError,
+ create_bridge_api_client,
+ is_expired_error_type,
+)
+from src.bridge.poll_config_defaults import (
+ DEFAULT_POLL_CONFIG,
+ PollIntervalConfig,
+)
+from src.bridge.session_runner import (
+ SessionSpawnerDeps,
+ create_session_spawner,
+)
+from src.bridge.types import (
+ BridgeApiClient,
+ BridgeConfig,
+ SessionHandle,
+ SessionSpawnOpts,
+ SessionSpawner,
+ SpawnMode,
+)
+from src.bridge.work_secret import (
+ build_ccr_v2_sdk_url,
+ build_sdk_url,
+ decode_work_secret,
+)
+from src.bridge.worktree import (
+ WorktreePaths,
+ create_agent_worktree,
+ remove_agent_worktree,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ── Backoff config (mirrors TS BackoffConfig + DEFAULT_BACKOFF) ──────────
+
+
+@dataclass(frozen=True)
+class BackoffConfig:
+ """Backoff knobs for the poll loop.
+
+ Mirrors TS ``BackoffConfig`` on ``bridgeMain.ts:53-66``. The MVP uses
+ a much simpler fixed-interval strategy; this dataclass exists so the
+ public surface matches TS and a future port can wire the full
+ two-track backoff machinery.
+ """
+
+ conn_initial_ms: int = 2_000
+ conn_cap_ms: int = 120_000
+ conn_give_up_ms: int = 600_000
+ general_initial_ms: int = 500
+ general_cap_ms: int = 30_000
+ general_give_up_ms: int = 600_000
+ shutdown_grace_ms: int = 30_000
+ stop_work_base_delay_ms: int = 1_000
+
+
+DEFAULT_BACKOFF = BackoffConfig()
+
+
+# ── Arg parsing ──────────────────────────────────────────────────────────
+
+
+@dataclass
+class ParsedArgs:
+ """Output of ``parse_args``. Mirrors TS ``ParsedArgs`` on
+ ``bridgeMain.ts:1694-1717``."""
+
+ verbose: bool = False
+ sandbox: bool = False
+ debug_file: str | None = None
+ session_timeout_ms: int | None = None
+ permission_mode: str | None = None
+ name: str | None = None
+ spawn_mode: SpawnMode | None = None
+ capacity: int | None = None
+ create_session_in_dir: bool | None = None
+ session_id: str | None = None
+ continue_session: bool = False
+ help: bool = False
+ error: str | None = None
+
+
+def _make_error(msg: str) -> ParsedArgs:
+ return ParsedArgs(error=msg)
+
+
+def _parse_spawn_value(raw: str | None) -> SpawnMode | str:
+ if raw == 'session':
+ return 'single-session'
+ if raw == 'same-dir':
+ return 'same-dir'
+ if raw == 'worktree':
+ return 'worktree'
+ return (
+ '--spawn requires one of: session, same-dir, worktree (got: '
+ f'{raw or ""})'
+ )
+
+
+def _parse_capacity_value(raw: str | None) -> int | str:
+ if raw is None:
+ return '--capacity requires a positive integer (got: )'
+ try:
+ n = int(raw)
+ except ValueError:
+ return f'--capacity requires a positive integer (got: {raw})'
+ if n < 1:
+ return f'--capacity requires a positive integer (got: {raw})'
+ return n
+
+
+def parse_args(args: list[str]) -> ParsedArgs:
+ """Parse command-line flags. Mirrors TS ``parseArgs`` on ``bridgeMain.ts:1736``.
+
+ Supported flags (Phase 8 MVP):
+
+ * ``--verbose`` / ``-v``
+ * ``--sandbox`` / ``--no-sandbox``
+ * ``--debug-file PATH`` (or ``--debug-file=PATH``)
+ * ``--session-timeout SECONDS`` (or ``--session-timeout=SECONDS``)
+ * ``--permission-mode MODE``
+ * ``--name NAME``
+ * ``--spawn {session,same-dir,worktree}``
+ * ``--capacity N``
+ * ``--create-session-in-dir`` / ``--no-create-session-in-dir``
+ * ``--help`` / ``-h``
+
+ KAIROS-only flags (``--session-id``, ``--continue`` / ``-c``) are
+ explicitly rejected — those depend on perpetual mode which the MVP
+ doesn't yet support.
+ """
+ out = ParsedArgs()
+ i = 0
+ while i < len(args):
+ arg = args[i]
+ if arg in ('--help', '-h'):
+ out.help = True
+ elif arg in ('--verbose', '-v'):
+ out.verbose = True
+ elif arg == '--sandbox':
+ out.sandbox = True
+ elif arg == '--no-sandbox':
+ out.sandbox = False
+ elif arg == '--debug-file' and i + 1 < len(args):
+ i += 1
+ out.debug_file = args[i]
+ elif arg.startswith('--debug-file='):
+ out.debug_file = arg[len('--debug-file='):]
+ elif arg == '--session-timeout' and i + 1 < len(args):
+ i += 1
+ out.session_timeout_ms = _int_seconds_to_ms(args[i])
+ elif arg.startswith('--session-timeout='):
+ out.session_timeout_ms = _int_seconds_to_ms(
+ arg[len('--session-timeout='):]
+ )
+ elif arg == '--permission-mode' and i + 1 < len(args):
+ i += 1
+ out.permission_mode = args[i]
+ elif arg.startswith('--permission-mode='):
+ out.permission_mode = arg[len('--permission-mode='):]
+ elif arg == '--name' and i + 1 < len(args):
+ i += 1
+ out.name = args[i]
+ elif arg.startswith('--name='):
+ out.name = arg[len('--name='):]
+ elif arg in ('--session-id', '-c', '--continue') or arg.startswith(
+ '--session-id='
+ ):
+ return _make_error(
+ f'{arg.split("=")[0]} is a KAIROS-only flag not yet '
+ 'supported in the MVP'
+ )
+ elif arg == '--spawn' or arg.startswith('--spawn='):
+ if out.spawn_mode is not None:
+ return _make_error('--spawn may only be specified once')
+ raw: str | None
+ if arg.startswith('--spawn='):
+ raw = arg[len('--spawn='):]
+ elif i + 1 < len(args):
+ i += 1
+ raw = args[i]
+ else:
+ raw = None
+ v = _parse_spawn_value(raw)
+ if isinstance(v, str) and v not in ('single-session', 'same-dir', 'worktree'):
+ return _make_error(v)
+ out.spawn_mode = v # type: ignore[assignment]
+ elif arg == '--capacity' or arg.startswith('--capacity='):
+ if out.capacity is not None:
+ return _make_error('--capacity may only be specified once')
+ if arg.startswith('--capacity='):
+ raw = arg[len('--capacity='):]
+ elif i + 1 < len(args):
+ i += 1
+ raw = args[i]
+ else:
+ raw = None
+ cv = _parse_capacity_value(raw)
+ if isinstance(cv, int):
+ out.capacity = cv
+ else:
+ return _make_error(cv)
+ elif arg == '--create-session-in-dir':
+ out.create_session_in_dir = True
+ elif arg == '--no-create-session-in-dir':
+ out.create_session_in_dir = False
+ else:
+ return _make_error(f'Unknown argument: {arg}')
+ i += 1
+ return out
+
+
+def _int_seconds_to_ms(value: str) -> int:
+ """Parse a positive-integer seconds value into ms."""
+ try:
+ n = int(value)
+ except ValueError as err:
+ raise ValueError(
+ f'--session-timeout requires an integer (got: {value!r})'
+ ) from err
+ return n * 1000
+
+
+def _now_ms() -> float:
+ """Current wall-clock time in ms since the Unix epoch (float for
+ sub-ms precision; backoff give-up math compares against it)."""
+ return time.time() * 1000.0
+
+
+# ── Error predicates ─────────────────────────────────────────────────────
+
+
+def is_connection_error(err: Exception) -> bool:
+ """True for transport-level errors (connection refused/reset, DNS, etc.).
+
+ Mirrors TS ``isConnectionError`` on ``bridgeMain.ts:1589-1602``. Used
+ by callers to choose the connection-error backoff track vs the
+ general-error track.
+ """
+ import httpx
+
+ return isinstance(
+ err,
+ (
+ ConnectionError, ConnectionRefusedError, ConnectionResetError,
+ ConnectionAbortedError, TimeoutError,
+ socket.timeout, socket.gaierror,
+ httpx.ConnectError, httpx.ConnectTimeout, httpx.NetworkError,
+ ),
+ )
+
+
+def is_server_error(err: Exception) -> bool:
+ """True for 5xx errors (server-side failures, retryable)."""
+ if isinstance(err, BridgeFatalError):
+ return err.status >= 500
+ return False
+
+
+# ── Exceptions ───────────────────────────────────────────────────────────
+
+
+class BridgeHeadlessPermanentError(Exception):
+ """Signal that the daemon hit a permanent failure (don't retry).
+
+ Mirrors TS ``BridgeHeadlessPermanentError`` on ``bridgeMain.ts:2773-2778``.
+ Caller (e.g. ``runBridgeHeadless`` wrapper) propagates this so the
+ supervising process can decide to back off vs exit.
+ """
+
+
+# ── Multi-session orchestrator ───────────────────────────────────────────
+
+
+async def run_bridge_loop(
+ config: BridgeConfig,
+ environment_id: str,
+ environment_secret: str,
+ api: BridgeApiClient,
+ spawner: SessionSpawner,
+ cancel_event: asyncio.Event,
+ *,
+ backoff_config: BackoffConfig = DEFAULT_BACKOFF,
+ initial_session_id: str | None = None, # noqa: ARG001 future use
+ poll_config: PollIntervalConfig = DEFAULT_POLL_CONFIG,
+ get_access_token: Callable[[], str | None] | None = None,
+) -> None:
+ """Run the multi-session work-poll loop.
+
+ Mirrors TS ``runBridgeLoop`` on ``bridgeMain.ts:140``. The MVP
+ implements:
+
+ * Capacity-aware polling (up to ``config.max_sessions``)
+ * Spawn → register → wait-done → stop_work per session
+ * Graceful exit when ``cancel_event`` is set: SIGTERM all, wait up
+ to ``backoff_config.shutdown_grace_ms``, SIGKILL stragglers
+ * Best-effort stopWork on shutdown, with retry up to
+ ``backoff_config.stop_work_base_delay_ms`` per attempt
+ * Phase 15: proactive JWT refresh per session, with v1/v2 split
+ (v1 pushes OAuth to child stdin; v2 triggers server re-dispatch
+ via ``reconnect_session``). Enabled when ``get_access_token`` is
+ provided; otherwise sessions use their initial JWT until expiry.
+ """
+ daemon = _BridgeDaemon(
+ config=config,
+ environment_id=environment_id,
+ environment_secret=environment_secret,
+ api=api,
+ spawner=spawner,
+ cancel_event=cancel_event,
+ backoff_config=backoff_config,
+ poll_config=poll_config,
+ get_access_token=get_access_token,
+ )
+ try:
+ await daemon.run()
+ finally:
+ await daemon.shutdown()
+
+
+@dataclass
+class _BackoffTrack:
+ """One exponential-backoff state machine.
+
+ Mirrors TS's two-track pattern on ``bridgeMain.ts:1269-1399`` where
+ connection errors and general errors each have an independent
+ counter with separate cap + give-up thresholds. Calling ``fail()``
+ increments the delay; ``reset()`` returns to ``initial_ms``.
+
+ ``give_up_at_ms`` is set on the first failure and cleared on reset.
+ The track is "given up" when wall-clock time exceeds it.
+ """
+
+ initial_ms: int
+ cap_ms: int
+ give_up_ms: int
+ current_ms: int = 0
+ give_up_at_ms: float | None = None
+
+ def reset(self) -> None:
+ self.current_ms = 0
+ self.give_up_at_ms = None
+
+ def fail(self, now_ms: float) -> int:
+ """Record a failure; return the next sleep duration in ms.
+
+ Doubles ``current_ms`` (capped at ``cap_ms``). On first failure,
+ arms the give-up deadline.
+ """
+ if self.current_ms == 0:
+ self.current_ms = self.initial_ms
+ self.give_up_at_ms = now_ms + self.give_up_ms
+ else:
+ self.current_ms = min(self.current_ms * 2, self.cap_ms)
+ return self.current_ms
+
+ def is_given_up(self, now_ms: float) -> bool:
+ return (
+ self.give_up_at_ms is not None
+ and now_ms >= self.give_up_at_ms
+ )
+
+
+class _BridgeDaemon:
+ """Encapsulates the multi-session daemon state.
+
+ Methods mirror the TS closure-heavy code structurally so the port
+ is easy to audit.
+ """
+
+ def __init__(
+ self,
+ *,
+ config: BridgeConfig,
+ environment_id: str,
+ environment_secret: str,
+ api: BridgeApiClient,
+ spawner: SessionSpawner,
+ cancel_event: asyncio.Event,
+ backoff_config: BackoffConfig,
+ poll_config: PollIntervalConfig,
+ get_access_token: Callable[[], str | None] | None = None,
+ ) -> None:
+ self.config = config
+ self.environment_id = environment_id
+ self.environment_secret = environment_secret
+ self.api = api
+ self.spawner = spawner
+ self.cancel_event = cancel_event
+ self.backoff_config = backoff_config
+ self.poll_config = poll_config
+
+ # Per-session bookkeeping (matches TS active_sessions et al.)
+ self.active_sessions: dict[str, SessionHandle] = {}
+ self.session_work_ids: dict[str, str] = {}
+ self.session_compat_ids: dict[str, str] = {}
+ self.completed_work_ids: set[str] = set()
+ self.session_timer_tasks: dict[str, asyncio.Task[None]] = {}
+ self.timed_out_sessions: set[str] = set()
+ # Phase 12a: per-session git worktree paths for ``--spawn worktree``.
+ # Populated only when worktree creation succeeded; ``_on_session_done``
+ # and ``shutdown`` consult it for cleanup.
+ self.session_worktrees: dict[str, WorktreePaths] = {}
+ # Phase 12a: refs to the ``_on_session_done`` background tasks so
+ # ``shutdown`` can await them (they own per-session cleanup like
+ # worktree removal — letting them outlive shutdown leaks state).
+ self.session_done_tasks: dict[str, asyncio.Task[None]] = {}
+ # Phase 15: track which session ids run with use_ccr_v2=True so
+ # the JWT refresh callback can branch v1 (push token to child)
+ # vs v2 (server re-dispatch via reconnect_session). Mirrors TS
+ # ``v2Sessions`` set on ``bridgeMain.ts:276``.
+ self.v2_sessions: set[str] = set()
+ # Phase 15: proactive JWT refresh scheduler. Conditional on
+ # ``get_access_token`` being provided; matches TS pattern
+ # (``const tokenRefresh = getAccessToken ? createTokenRefreshScheduler(...) : null``).
+ self.token_refresh: TokenRefreshScheduler | None = (
+ self._build_token_refresh_scheduler(get_access_token)
+ if get_access_token is not None else None
+ )
+
+ # Two-track exponential backoff state. Reset on any successful
+ # poll (including empty-poll = no work).
+ bc = backoff_config
+ self._conn_track = _BackoffTrack(
+ initial_ms=bc.conn_initial_ms,
+ cap_ms=bc.conn_cap_ms,
+ give_up_ms=bc.conn_give_up_ms,
+ )
+ self._general_track = _BackoffTrack(
+ initial_ms=bc.general_initial_ms,
+ cap_ms=bc.general_cap_ms,
+ give_up_ms=bc.general_give_up_ms,
+ )
+
+ # ─── Phase 15: JWT refresh ──────────────────────────────────────
+
+ def _build_token_refresh_scheduler(
+ self,
+ get_access_token: Callable[[], str | None],
+ ) -> TokenRefreshScheduler:
+ """Create a per-daemon scheduler with v1/v2-aware ``on_refresh``.
+
+ Mirrors TS ``bridgeMain.ts:283-312``.
+
+ * **v2 (CCR worker endpoints)**: ``register_worker.go:32``
+ validates the JWT's ``session_id`` claim, so pushing an
+ OAuth token to the child's stdin would break subsequent
+ ``/worker/*`` requests. Instead, call
+ ``api.reconnect_session(env_id, session_id)`` — the server
+ re-dispatches the work item with a fresh JWT, which flows
+ through the next poll's ``_process_work``.
+ * **v1 (Session-Ingress)**: push the fresh OAuth token via
+ ``handle.update_access_token`` (session-ingress accepts
+ OAuth or JWT).
+ """
+ def on_refresh(session_id: str, fresh_token: str) -> None:
+ handle = self.active_sessions.get(session_id)
+ if handle is None:
+ # Session ended after the refresh was scheduled but
+ # before the callback fired. _on_session_done already
+ # cancelled the scheduler entry; if we got here anyway,
+ # just no-op.
+ return
+ if session_id in self.v2_sessions:
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ logger.warning(
+ '[bridge:main] token refresh fired outside an '
+ 'asyncio loop (sessionId=%s) — cannot dispatch '
+ 'v2 reconnect', session_id,
+ )
+ return
+ loop.create_task(
+ self._safe_reconnect_for_refresh(session_id),
+ name=f'bridge-main-refresh-{session_id}',
+ )
+ return
+ # v1: push fresh OAuth/JWT to child stdin.
+ try:
+ handle.update_access_token(fresh_token)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] update_access_token via stdin '
+ 'failed for sessionId=%s: %s', session_id, err,
+ )
+
+ async def get_access_token_async() -> str | None:
+ return get_access_token()
+
+ return TokenRefreshScheduler(
+ get_access_token=get_access_token_async,
+ on_refresh=on_refresh,
+ label='bridge-main',
+ )
+
+ async def _safe_reconnect_for_refresh(self, session_id: str) -> None:
+ """v2 token-refresh helper. Calls ``api.reconnect_session``
+ and swallows errors — the next refresh fire will retry.
+
+ Mirrors TS ``bridgeMain.ts:295-305`` (``void
+ api.reconnectSession(...).catch(...)``).
+ """
+ try:
+ await self.api.reconnect_session(self.environment_id, session_id)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] v2 token refresh via reconnect_session '
+ 'failed for sessionId=%s: %s', session_id, err,
+ )
+
+ async def run(self) -> None:
+ cfg = self.poll_config
+ seek_interval = cfg.poll_interval_ms_not_at_capacity / 1000.0
+ at_cap_interval = (
+ cfg.poll_interval_ms_at_capacity / 1000.0
+ if cfg.poll_interval_ms_at_capacity > 0
+ else 60.0
+ )
+ heartbeat_interval = (
+ cfg.non_exclusive_heartbeat_interval_ms / 1000.0
+ if cfg.non_exclusive_heartbeat_interval_ms > 0
+ else None
+ )
+ while not self.cancel_event.is_set():
+ if len(self.active_sessions) >= self.config.max_sessions:
+ # At capacity: optionally heartbeat active work items to
+ # keep server-side leases alive between polls.
+ if heartbeat_interval is not None:
+ await self._heartbeat_active_work()
+ await self._sleep_or_cancel(heartbeat_interval)
+ else:
+ await self._sleep_or_cancel(at_cap_interval)
+ continue
+ try:
+ work = await self.api.poll_for_work(
+ self.environment_id, self.environment_secret,
+ )
+ except BridgeFatalError as err:
+ if is_expired_error_type(err.error_type) or err.status == 404:
+ logger.error(
+ '[bridge:main] Environment expired/lost '
+ '(MVP gives up): %s', err,
+ )
+ raise BridgeHeadlessPermanentError(str(err)) from err
+ logger.error('[bridge:main] Poll fatal: %s', err)
+ raise
+ except (asyncio.CancelledError, KeyboardInterrupt):
+ raise
+ except Exception as err: # noqa: BLE001
+ # Two-track exponential backoff. Connection-class
+ # errors get a longer initial delay (TCP/TLS handshake
+ # cost) and an independent give-up timer.
+ now_ms = _now_ms()
+ track = (
+ self._conn_track
+ if is_connection_error(err)
+ else self._general_track
+ )
+ delay_ms = track.fail(now_ms)
+ if track.is_given_up(now_ms):
+ label = (
+ 'connection-error' if track is self._conn_track
+ else 'general-error'
+ )
+ logger.error(
+ '[bridge:main] %s track exceeded give-up '
+ 'threshold (%sms): %s',
+ label, track.give_up_ms, err,
+ )
+ raise BridgeHeadlessPermanentError(
+ f'{label} give-up: {err}',
+ ) from err
+ logger.warning(
+ '[bridge:main] Poll error (%s, sleeping %sms): %s',
+ 'conn' if track is self._conn_track else 'general',
+ delay_ms, err,
+ )
+ await self._sleep_or_cancel(delay_ms / 1000.0)
+ continue
+ # Successful poll resets BOTH tracks — any prior error is
+ # forgiven once we get a clean response.
+ self._conn_track.reset()
+ self._general_track.reset()
+ if work is None:
+ await self._sleep_or_cancel(seek_interval)
+ continue
+ await self._process_work(work)
+
+ async def _heartbeat_active_work(self) -> None:
+ """Send a lightweight heartbeat for each active work item.
+
+ Mirrors TS heartbeat-mode behavior on ``bridgeMain.ts:649-730``.
+ Best-effort: a single failure is logged but doesn't tear down
+ the loop — the next poll-deadline tick will surface a permanent
+ failure if the server has rejected the lease.
+ """
+ for session_id, work_id in list(self.session_work_ids.items()):
+ session = self.active_sessions.get(session_id)
+ if session is None:
+ continue
+ try:
+ await self.api.heartbeat_work(
+ self.environment_id, work_id, session.access_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] heartbeat work_id=%s failed: %s',
+ work_id, err,
+ )
+
+ async def _process_work(self, work: dict[str, Any]) -> None:
+ work_id = work.get('id')
+ if not isinstance(work_id, str):
+ return
+ if work_id in self.completed_work_ids:
+ # Stale redelivery — server hadn't yet processed stop_work.
+ return
+ data = work.get('data') or {}
+ if not isinstance(data, dict):
+ return
+ work_type = data.get('type')
+ if work_type == 'healthcheck':
+ await self._safe_ack(work_id, self.environment_secret)
+ return
+ if work_type != 'session':
+ return
+ try:
+ secret = decode_work_secret(work.get('secret') or '')
+ except Exception as err: # noqa: BLE001
+ logger.error(
+ '[bridge:main] decode_work_secret failed: %s', err
+ )
+ await self._safe_stop_work(work_id, force=True)
+ return
+ session_id = data.get('id')
+ if not isinstance(session_id, str):
+ return
+ await self._safe_ack(work_id, secret.session_ingress_token)
+
+ # Phase 15: existingHandle path. If this work item is for a
+ # session that's already running (e.g. server re-dispatched
+ # work after a v2 JWT refresh's ``reconnect_session`` call,
+ # or after a WS drop), push the fresh JWT to the live child
+ # and reschedule the refresh — DO NOT spawn a duplicate
+ # subprocess. Mirrors TS ``bridgeMain.ts:868-885``.
+ existing = self.active_sessions.get(session_id)
+ if existing is not None:
+ try:
+ existing.update_access_token(secret.session_ingress_token)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] update_access_token for existing '
+ 'sessionId=%s failed: %s', session_id, err,
+ )
+ self.session_work_ids[session_id] = work_id
+ if self.token_refresh is not None:
+ try:
+ self.token_refresh.schedule(
+ session_id, secret.session_ingress_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:main] reschedule token refresh for '
+ 'existing sessionId=%s failed: %s',
+ session_id, err,
+ )
+ logger.info(
+ '[bridge:main] Updated access token for existing '
+ 'sessionId=%s workId=%s', session_id, work_id,
+ )
+ return
+
+ # Phase 14c: dispatch v1 + v2 work items. v1 uses the bridge's
+ # configured ``session_ingress_url`` (NOT ``secret.api_base_url``
+ # — see TS ``bridgeMain.ts:905-907``: the secret's url may be a
+ # remote proxy/tunnel that doesn't know about locally-created
+ # sessions). v2 uses ``secret.api_base_url`` (the
+ # server-controlled CCR endpoint). The child SDK constructs
+ # its own transport from sdk_url + access_token.
+ use_ccr_v2 = bool(secret.use_code_sessions)
+ if use_ccr_v2:
+ sdk_url = build_ccr_v2_sdk_url(secret.api_base_url, session_id)
+ else:
+ sdk_url = build_sdk_url(
+ self.config.session_ingress_url, session_id,
+ )
+ spawn_opts: SessionSpawnOpts = {
+ 'session_id': session_id,
+ 'sdk_url': sdk_url,
+ 'access_token': secret.session_ingress_token,
+ 'use_ccr_v2': use_ccr_v2,
+ 'worker_epoch': 0, # MVP: full port fetches via /worker/register
+ }
+ working_dir = self.config.dir
+ if self.config.spawn_mode == 'worktree':
+ # Phase 12a: real ``git worktree`` integration. Best-effort:
+ # ``create_agent_worktree`` is documented to never raise and
+ # to fall back to base_dir on any failure (not a repo, git
+ # missing, subprocess timeout, etc.). The outer try/except
+ # is belt-and-braces — if a future change to that contract
+ # accidentally lets an exception escape, we'd rather spawn
+ # in cwd than crash the daemon.
+ try:
+ paths = await create_agent_worktree(working_dir, session_id)
+ working_dir = paths.working_dir
+ if paths.created:
+ self.session_worktrees[session_id] = paths
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] worktree setup raised (continuing in '
+ 'base dir): %s', err,
+ )
+ # Invariant: at this point, neither ``session_timer_tasks`` nor
+ # ``session_done_tasks`` has a key for ``session_id`` — both are
+ # populated below, AFTER the spawn succeeds. The spawn-fail
+ # cleanup branch below therefore only needs to remove the
+ # worktree entry.
+ try:
+ session = self.spawner.spawn(spawn_opts, working_dir)
+ except Exception as err: # noqa: BLE001
+ logger.error('[bridge:main] spawn failed: %s', err)
+ await self._safe_stop_work(work_id, force=True)
+ # Clean up the worktree we just created — the session never
+ # started, so leaving it behind is just litter.
+ wt = self.session_worktrees.pop(session_id, None)
+ if wt is not None:
+ await remove_agent_worktree(wt)
+ return
+ self.active_sessions[session_id] = session
+ self.session_work_ids[session_id] = work_id
+ # session_compat_ids cached for future title/archive ops that
+ # the MVP doesn't yet exercise — populated for forward compat.
+ from src.bridge.session_id_compat import to_compat_session_id
+ self.session_compat_ids[session_id] = to_compat_session_id(session_id)
+ # Per-session timeout watchdog. ``--session-timeout SECONDS``
+ # → ``config.session_timeout_ms`` (or None to disable). Mirrors
+ # TS watchdog on ``bridgeMain.ts:1177-1192`` / ``1677-1692``.
+ # When the timer fires, ``timed_out_sessions`` is marked so
+ # ``_on_session_done`` can distinguish timeout from clean exit
+ # in logs / future telemetry.
+ if self.config.session_timeout_ms:
+ timer_task = asyncio.create_task(
+ self._session_timeout_watchdog(
+ session_id,
+ self.config.session_timeout_ms / 1000.0,
+ ),
+ name=f'bridge-timer-{session_id}',
+ )
+ self.session_timer_tasks[session_id] = timer_task
+ logger.info(
+ '[bridge:main] Spawned session_id=%s work_id=%s '
+ '(%s/%s active)',
+ session_id, work_id,
+ len(self.active_sessions), self.config.max_sessions,
+ )
+ # Phase 15: track v1/v2 + schedule JWT refresh.
+ if use_ccr_v2:
+ self.v2_sessions.add(session_id)
+ if self.token_refresh is not None:
+ try:
+ self.token_refresh.schedule(
+ session_id, secret.session_ingress_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:main] schedule token refresh failed for '
+ 'sessionId=%s (likely undecodable JWT — session '
+ 'uses initial token): %s', session_id, err,
+ )
+ # Phase 12a: track the wait-done task so ``shutdown`` can await
+ # any in-flight cleanup (worktree removal, stop_work, etc.).
+ self.session_done_tasks[session_id] = asyncio.create_task(
+ self._on_session_done(session_id),
+ name=f'bridge-await-{session_id}',
+ )
+
+ async def _session_timeout_watchdog(
+ self, session_id: str, timeout_seconds: float,
+ ) -> None:
+ """Kill the session after ``timeout_seconds`` if still active.
+
+ Mirrors TS ``onSessionTimeout`` semantics. The session's
+ ``wait_done`` will then resolve with whatever status the kill
+ produced (typically 'interrupted'); ``_on_session_done`` checks
+ ``timed_out_sessions`` to log this distinctly.
+ """
+ try:
+ await asyncio.sleep(timeout_seconds)
+ except asyncio.CancelledError:
+ return
+ session = self.active_sessions.get(session_id)
+ if session is None:
+ return
+ logger.warning(
+ '[bridge:main] Session %s exceeded timeout %.1fs — killing',
+ session_id, timeout_seconds,
+ )
+ self.timed_out_sessions.add(session_id)
+ try:
+ session.kill()
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] watchdog kill failed: %s', err
+ )
+
+ async def _on_session_done(self, session_id: str) -> None:
+ session = self.active_sessions.get(session_id)
+ if session is None:
+ return
+ try:
+ status = await session.wait_done()
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] wait_done(%s) raised: %s', session_id, err
+ )
+ status = 'failed'
+ # Cancel any pending timeout watchdog — session is done.
+ timer = self.session_timer_tasks.pop(session_id, None)
+ if timer is not None and not timer.done():
+ timer.cancel()
+ work_id = self.session_work_ids.get(session_id)
+ if work_id is not None:
+ await self._safe_stop_work(work_id, force=False)
+ self.completed_work_ids.add(work_id)
+ # Phase 15: cancel the pending JWT refresh for this session
+ # so it can't fire on a dead handle. ``cancel`` is a no-op if
+ # the session was never scheduled (e.g. undecodable JWT).
+ if self.token_refresh is not None:
+ self.token_refresh.cancel(session_id)
+ self.v2_sessions.discard(session_id)
+ self.active_sessions.pop(session_id, None)
+ self.session_work_ids.pop(session_id, None)
+ self.session_compat_ids.pop(session_id, None)
+ # ``discard`` doesn't return a value; check membership first.
+ was_timeout = session_id in self.timed_out_sessions
+ self.timed_out_sessions.discard(session_id)
+ # Phase 12a: remove the per-session worktree (best-effort).
+ # The ``pop`` happens BEFORE the await so a concurrent
+ # ``shutdown`` sweep can't double-claim this entry. The
+ # ``session_done_tasks.pop`` at the end of this method closes
+ # the cleanup-tracking loop the same way.
+ wt = self.session_worktrees.pop(session_id, None)
+ if wt is not None:
+ try:
+ await remove_agent_worktree(wt)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] worktree cleanup failed for %s: %s',
+ session_id, err,
+ )
+ timeout_marker = ' (TIMEOUT)' if was_timeout else ''
+ logger.info(
+ '[bridge:main] Session done session_id=%s status=%s%s',
+ session_id, status, timeout_marker,
+ )
+ # Drop the self-reference last so ``shutdown`` doesn't try to
+ # await a completed task (it's already finished anyway, but a
+ # stale dict entry is just litter for a long-running daemon).
+ self.session_done_tasks.pop(session_id, None)
+
+ async def shutdown(self) -> None:
+ """SIGTERM all sessions, wait up to ``shutdown_grace_ms``, SIGKILL
+ stragglers, stop_work + deregister.
+
+ Mirrors TS shutdown sequence on ``bridgeMain.ts:1402-1577``.
+ Idempotent — safe to call multiple times.
+ """
+ active_snapshot = list(self.active_sessions.values())
+ work_id_snapshot = dict(self.session_work_ids)
+
+ # Phase 15: cancel all pending JWT refresh timers so they
+ # don't fire mid-shutdown against dead session handles. Also
+ # clear ``v2_sessions`` since a future ``_BridgeDaemon`` may
+ # be reconstructed (currently ``run_bridge_loop`` makes a
+ # fresh daemon per call, but the clear here is defensive
+ # hygiene).
+ if self.token_refresh is not None:
+ self.token_refresh.cancel_all()
+ self.v2_sessions.clear()
+
+ # Cancel any pending per-session timeout watchdogs so they
+ # don't fire mid-shutdown and inject confusing log lines.
+ for timer in self.session_timer_tasks.values():
+ if not timer.done():
+ timer.cancel()
+ self.session_timer_tasks.clear()
+
+ # SIGTERM all.
+ for session in active_snapshot:
+ try:
+ session.kill()
+ except Exception as err: # noqa: BLE001
+ logger.warning('[bridge:main] kill failed: %s', err)
+
+ # Wait up to shutdown_grace_ms for the children to exit.
+ if active_snapshot:
+ grace = self.backoff_config.shutdown_grace_ms / 1000.0
+ try:
+ await asyncio.wait_for(
+ asyncio.gather(
+ *(s.wait_done() for s in active_snapshot),
+ return_exceptions=True,
+ ),
+ timeout=grace,
+ )
+ except asyncio.TimeoutError:
+ # SIGKILL stragglers.
+ for session in active_snapshot:
+ try:
+ session.force_kill()
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] force_kill failed: %s', err
+ )
+
+ # Stop all outstanding work items.
+ for work_id in work_id_snapshot.values():
+ await self._safe_stop_work(work_id, force=True)
+
+ # Phase 12a: wait for any in-flight ``_on_session_done`` tasks
+ # to finish — they own per-session cleanup (worktree removal,
+ # stop_work) and racing against them leaves orphaned state on
+ # disk. Bounded by a short timeout so a stuck cleanup task
+ # can't hang shutdown indefinitely. On timeout we cancel the
+ # stragglers and drain them — letting them outlive shutdown
+ # would leak both ``WorktreePaths`` refs and live ``git``
+ # subprocesses, which matters because ``shutdown`` is
+ # documented as idempotent and may be called multiple times.
+ done_snapshot = [
+ t for t in self.session_done_tasks.values() if not t.done()
+ ]
+ if done_snapshot:
+ try:
+ await asyncio.wait_for(
+ asyncio.gather(*done_snapshot, return_exceptions=True),
+ timeout=2.0,
+ )
+ except asyncio.TimeoutError:
+ logger.warning(
+ '[bridge:main] %d session-done task(s) didn\'t '
+ 'finish within 2s during shutdown — cancelling',
+ len(done_snapshot),
+ )
+ for t in done_snapshot:
+ if not t.done():
+ t.cancel()
+ # Drain. The cancelled tasks return promptly with
+ # ``CancelledError``; ``_run_git``'s try/finally then
+ # kills any subprocess that was mid-``communicate()``
+ # so we don't leak ``git`` processes past shutdown.
+ await asyncio.gather(*done_snapshot, return_exceptions=True)
+
+ # Phase 12a: clean up any worktrees still on the books. After
+ # the wait above, ``_on_session_done`` has handled most of
+ # these — what remains is sessions that never completed (e.g.
+ # because the grace timeout expired before SIGKILL took effect).
+ # Best-effort — orphaned worktrees are recoverable via
+ # ``git worktree prune``.
+ for wt in list(self.session_worktrees.values()):
+ try:
+ await remove_agent_worktree(wt)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] shutdown worktree cleanup failed: %s',
+ err,
+ )
+ self.session_worktrees.clear()
+
+ # Deregister the environment (best-effort).
+ try:
+ await self.api.deregister_environment(self.environment_id)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] deregister_environment failed: %s', err
+ )
+
+ async def _safe_ack(self, work_id: str, session_token: str) -> None:
+ try:
+ await self.api.acknowledge_work(
+ self.environment_id, work_id, session_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] ack failed work_id=%s: %s', work_id, err
+ )
+
+ async def _safe_stop_work(self, work_id: str, *, force: bool) -> None:
+ try:
+ await self.api.stop_work(
+ self.environment_id, work_id, force,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:main] stop_work failed work_id=%s: %s',
+ work_id, err,
+ )
+
+ async def _sleep_or_cancel(self, seconds: float) -> None:
+ try:
+ await asyncio.wait_for(self.cancel_event.wait(), timeout=seconds)
+ except asyncio.TimeoutError:
+ pass
+
+
+# ── End-to-end entry point ──────────────────────────────────────────────
+
+
+async def bridge_main(
+ args: list[str],
+ *,
+ api: BridgeApiClient | None = None,
+ spawner: SessionSpawner | None = None,
+ get_access_token: Callable[[], str | None] = lambda: 'tok-placeholder',
+ runner_version: str = 'py-bridge-mvp',
+ base_url: str = 'https://api.anthropic.com',
+ machine_name: str = 'localhost',
+ branch: str = 'main',
+ git_repo_url: str | None = None,
+ working_dir: str = '.',
+ cancel_event: asyncio.Event | None = None,
+) -> int:
+ """End-to-end daemon entry: parse → register → run loop → shutdown.
+
+ Returns a process exit code: 0 = clean shutdown, 1 = parse error /
+ help, 2 = registration failed, 3 = permanent runtime error.
+
+ Test seams:
+
+ * ``api`` / ``spawner``: pre-built for tests.
+ * ``get_access_token``: OAuth token getter.
+ * ``cancel_event``: optional ``asyncio.Event`` so tests can ask the
+ daemon to shut down without sending a real signal.
+ """
+ parsed = parse_args(args)
+ if parsed.error is not None:
+ logger.error('[bridge:main] %s', parsed.error)
+ return 1
+ if parsed.help:
+ _print_usage()
+ return 0
+
+ spawn_mode = parsed.spawn_mode or 'single-session'
+ capacity = parsed.capacity or (
+ 1 if spawn_mode == 'single-session' else 4
+ )
+
+ bridge_config = BridgeConfig(
+ dir=working_dir,
+ machine_name=machine_name,
+ branch=branch,
+ git_repo_url=git_repo_url,
+ max_sessions=capacity,
+ spawn_mode=spawn_mode,
+ verbose=parsed.verbose,
+ sandbox=parsed.sandbox,
+ bridge_id=str(uuid.uuid4()),
+ worker_type='claude_code',
+ environment_id='', # filled by registration
+ api_base_url=base_url,
+ session_ingress_url=base_url,
+ debug_file=parsed.debug_file,
+ session_timeout_ms=parsed.session_timeout_ms,
+ )
+
+ if api is None:
+ api = create_bridge_api_client(
+ base_url=base_url,
+ get_access_token=get_access_token,
+ runner_version=runner_version,
+ )
+
+ try:
+ registration = await api.register_bridge_environment(bridge_config)
+ except BridgeFatalError as err:
+ logger.error('[bridge:main] Registration failed: %s', err)
+ return 2
+ environment_id = registration['environment_id']
+ environment_secret = registration['environment_secret']
+ logger.info(
+ '[bridge:main] Registered environment_id=%s capacity=%s mode=%s',
+ environment_id, capacity, spawn_mode,
+ )
+
+ if spawner is None:
+ spawner = create_session_spawner(SessionSpawnerDeps(
+ exec_path='claude',
+ verbose=parsed.verbose,
+ sandbox=parsed.sandbox,
+ debug_file=parsed.debug_file,
+ permission_mode=parsed.permission_mode,
+ ))
+
+ if cancel_event is None:
+ cancel_event = asyncio.Event()
+ _install_signal_handlers(cancel_event)
+
+ try:
+ await run_bridge_loop(
+ bridge_config,
+ environment_id,
+ environment_secret,
+ api,
+ spawner,
+ cancel_event,
+ get_access_token=get_access_token,
+ )
+ except BridgeHeadlessPermanentError as err:
+ logger.error('[bridge:main] Permanent error: %s', err)
+ return 3
+ return 0
+
+
+def _install_signal_handlers(cancel_event: asyncio.Event) -> None:
+ """Register SIGINT/SIGTERM handlers that set ``cancel_event``.
+
+ No-op on platforms where ``loop.add_signal_handler`` isn't available
+ (notably Windows). The MVP tolerates that by relying on the test
+ seam ``cancel_event`` instead.
+ """
+ import sys
+
+ if sys.platform == 'win32':
+ return
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ return
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ try:
+ loop.add_signal_handler(sig, cancel_event.set)
+ except (NotImplementedError, RuntimeError):
+ pass
+
+
+def _print_usage() -> None:
+ """Print a minimal usage banner. Mirrors TS help text shape."""
+ usage = """Usage: claude remote-control [options]
+
+Options:
+ --verbose, -v Enable verbose logging
+ --sandbox Run children in sandbox
+ --no-sandbox Disable sandbox (default)
+ --debug-file PATH Write per-session debug log
+ --session-timeout SECONDS Per-session timeout (parsed but not yet enforced)
+ --permission-mode MODE Default permission mode for children
+ --name NAME Friendly name for the registered environment
+ --spawn {session,same-dir,worktree}
+ Spawn mode (worktree mode logs a warning)
+ --capacity N Max concurrent sessions (default 1 or 4)
+ --create-session-in-dir Override default session-in-dir behavior
+ --no-create-session-in-dir Disable session-in-dir behavior
+ --help, -h Show this help
+
+Note: --session-id / --continue (perpetual mode) are not yet supported.
+"""
+ print(usage)
+
+
+__all__ = [
+ 'BackoffConfig',
+ 'BridgeHeadlessPermanentError',
+ 'DEFAULT_BACKOFF',
+ 'ParsedArgs',
+ 'bridge_main',
+ 'is_connection_error',
+ 'is_server_error',
+ 'parse_args',
+ 'run_bridge_loop',
+]
diff --git a/src/bridge/bridge_permission_callbacks.py b/src/bridge/bridge_permission_callbacks.py
new file mode 100644
index 000000000..fb36b61e3
--- /dev/null
+++ b/src/bridge/bridge_permission_callbacks.py
@@ -0,0 +1,101 @@
+"""Bridge permission callbacks Protocol.
+
+Ports ``typescript/src/bridge/bridgePermissionCallbacks.ts``.
+
+Defines the bridge-side surface for handling permission requests routed
+from the server: a ``BridgePermissionResponse`` payload (``allow`` /
+``deny`` with optional input rewrite + updated permissions), a type guard
+for validating parsed wire payloads, and a ``BridgePermissionCallbacks``
+Protocol bundling the send/receive surface that orchestrators wire to the
+transport.
+
+The richer ``AllowResponse``/``DenyResponse`` discriminated union (used
+post-parse) lives in ``messaging.py``; this module is the lower-level
+TypedDict + type-guard layer.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Protocol, TypedDict, TypeGuard
+
+
+class BridgePermissionResponse(TypedDict, total=False):
+ """Permission response payload as it appears on the wire.
+
+ Mirrors TS ``BridgePermissionResponse`` on ``bridgePermissionCallbacks.ts:3-8``.
+ ``behavior`` is required; the other fields are optional and only meaningful
+ when ``behavior == 'allow'`` (updatedInput / updatedPermissions) or
+ ``'deny'`` (message).
+
+ **camelCase field names are intentional**: this TypedDict is the
+ *pre-normalization* wire shape sent by the claude.ai web app. The
+ *post-normalization* discriminated union (``AllowResponse`` /
+ ``DenyResponse`` in ``messaging.py``) uses snake_case Python idioms.
+ The router (``messaging.normalize_control_message_keys``) converts
+ between the two.
+ """
+
+ behavior: str # 'allow' | 'deny'
+ updatedInput: dict[str, Any]
+ updatedPermissions: list[dict[str, Any]]
+ message: str
+
+
+def is_bridge_permission_response(
+ value: object,
+) -> TypeGuard[BridgePermissionResponse]:
+ """Type guard for a parsed control_response payload.
+
+ Mirrors TS ``isBridgePermissionResponse`` on
+ ``bridgePermissionCallbacks.ts:32-40``. Checks the required ``behavior``
+ discriminant rather than using an unsafe cast.
+ """
+ if not isinstance(value, dict):
+ return False
+ behavior = value.get('behavior')
+ return behavior == 'allow' or behavior == 'deny'
+
+
+class BridgePermissionCallbacks(Protocol):
+ """Send/receive surface for permission requests.
+
+ Mirrors TS ``BridgePermissionCallbacks`` on
+ ``bridgePermissionCallbacks.ts:10-27``. Wired by orchestrators to a
+ transport (``ReplBridgeHandle``) and a request registry. Implementations
+ are stateful (the ``on_response`` handler registry); the Protocol just
+ declares the surface.
+ """
+
+ def send_request(
+ self,
+ request_id: str,
+ tool_name: str,
+ input: dict[str, Any],
+ tool_use_id: str,
+ description: str,
+ permission_suggestions: list[dict[str, Any]] | None = None,
+ blocked_path: str | None = None,
+ ) -> None: ...
+
+ def send_response(
+ self, request_id: str, response: BridgePermissionResponse
+ ) -> None: ...
+
+ def cancel_request(self, request_id: str) -> None:
+ """Cancel a pending control_request so the web app can dismiss its prompt."""
+ ...
+
+ def on_response(
+ self,
+ request_id: str,
+ handler: Callable[[BridgePermissionResponse], None],
+ ) -> Callable[[], None]:
+ """Register a one-shot response handler. Returns an unsubscribe callable."""
+ ...
+
+
+__all__ = [
+ 'BridgePermissionCallbacks',
+ 'BridgePermissionResponse',
+ 'is_bridge_permission_response',
+]
diff --git a/src/bridge/bridge_pointer.py b/src/bridge/bridge_pointer.py
new file mode 100644
index 000000000..02b47056b
--- /dev/null
+++ b/src/bridge/bridge_pointer.py
@@ -0,0 +1,259 @@
+"""Crash-recovery pointer for perpetual-mode bridges.
+
+The pointer is a small JSON file written under the daemon's working
+directory at ``/.claude/bridge-pointer.json``. It records the
+identity (``bridge_id``, ``environment_id``, ``session_id``) of the
+currently-running bridge so that a subsequent restart (after a clean
+shutdown OR a crash) can:
+
+* Reuse the same ``environment_id`` via ``reuse_environment_id`` —
+ the server may resurrect the env state if the lease hasn't expired.
+* Resume the same ``session_id`` rather than spawning a fresh
+ Claude Code subprocess (preserves history, in-flight work, etc.).
+* Detect a different host or working directory (``machine_name`` /
+ ``dir`` mismatch) and drop the pointer instead of corrupting another
+ daemon's state.
+
+Best-effort semantics
+---------------------
+Every operation in this module is best-effort:
+
+* :func:`read_pointer` returns ``None`` on any IO/JSON failure rather
+ than raising — perpetual mode degrades to "fresh env+session" if the
+ pointer is unreadable.
+* :func:`write_pointer` swallows IO errors with a warning log — a
+ failed write means the next restart won't recover, but it doesn't
+ break the running daemon.
+* :func:`clear_pointer` ignores missing-file errors (no-op).
+
+Schema (version 1)
+------------------
+::
+
+ {
+ "schema_version": 1,
+ "bridge_id": "",
+ "environment_id": "",
+ "session_id": "",
+ "machine_name": "",
+ "dir": "",
+ "created_at_ms": ,
+ "updated_at_ms":
+ }
+
+Mismatched ``schema_version``, ``machine_name``, or ``dir`` causes
+:func:`read_pointer` to return ``None`` (treats the pointer as stale).
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import time
+from dataclasses import asdict, dataclass
+
+logger = logging.getLogger(__name__)
+
+_SCHEMA_VERSION = 1
+_POINTER_REL = os.path.join('.claude', 'bridge-pointer.json')
+
+
+@dataclass
+class BridgePointer:
+ """In-memory representation of the pointer file. Mirrors the
+ on-disk JSON schema 1:1.
+
+ Fields
+ ------
+ bridge_id
+ Client-generated stable identity. Survives across restarts —
+ the daemon picks a new one only if the pointer is dropped.
+ environment_id
+ Server-assigned env id from the most recent registration.
+ session_id
+ Currently-active session id; may be ``None`` when no session
+ is running (e.g., right after init but before first poll).
+ machine_name
+ Hostname captured at write time. ``read_pointer`` rejects
+ the file if this doesn't match the current host.
+ dir
+ Working directory captured at write time. Rejected on mismatch.
+ created_at_ms / updated_at_ms
+ Milliseconds since epoch. ``created_at`` is set once;
+ ``updated_at`` bumps on every write.
+ """
+
+ bridge_id: str
+ environment_id: str
+ session_id: str | None
+ machine_name: str
+ dir: str
+ created_at_ms: int
+ updated_at_ms: int
+
+ def to_json(self) -> dict[str, object]:
+ d = asdict(self)
+ d['schema_version'] = _SCHEMA_VERSION
+ return d
+
+
+def _pointer_path(working_dir: str) -> str:
+ return os.path.join(working_dir, _POINTER_REL)
+
+
+def _now_ms() -> int:
+ return int(time.time() * 1000)
+
+
+def read_pointer(
+ working_dir: str, *, machine_name: str,
+) -> BridgePointer | None:
+ """Return the pointer for ``working_dir`` if one exists and is
+ valid for the current host, else ``None``.
+
+ Returns ``None`` for any of: file missing, JSON malformed,
+ ``schema_version`` mismatch, ``machine_name`` mismatch, ``dir``
+ mismatch (the file was written by another working tree using a
+ shared HOME), or any required field missing/wrong type.
+ """
+ path = _pointer_path(working_dir)
+ try:
+ with open(path, 'r', encoding='utf-8') as fh:
+ raw = fh.read()
+ except FileNotFoundError:
+ return None
+ except OSError as err:
+ logger.warning(
+ '[bridge:pointer] read %s failed: %s — treating as absent',
+ path, err,
+ )
+ return None
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as err:
+ logger.warning(
+ '[bridge:pointer] %s is not valid JSON (%s) — treating as absent',
+ path, err,
+ )
+ return None
+ if not isinstance(data, dict):
+ return None
+ if data.get('schema_version') != _SCHEMA_VERSION:
+ logger.info(
+ '[bridge:pointer] schema_version mismatch in %s '
+ '(file=%r, expected=%r) — treating as absent',
+ path, data.get('schema_version'), _SCHEMA_VERSION,
+ )
+ return None
+ # Required string fields.
+ try:
+ bridge_id = str(data['bridge_id'])
+ environment_id = str(data['environment_id'])
+ file_machine = str(data['machine_name'])
+ file_dir = str(data['dir'])
+ created_at_ms = int(data['created_at_ms'])
+ updated_at_ms = int(data['updated_at_ms'])
+ except (KeyError, TypeError, ValueError) as err:
+ logger.info(
+ '[bridge:pointer] %s missing or malformed required fields '
+ '(%s) — treating as absent', path, err,
+ )
+ return None
+ session_id_raw = data.get('session_id')
+ session_id = (
+ str(session_id_raw) if isinstance(session_id_raw, str) else None
+ )
+
+ # Host/dir staleness check. Two daemons running in different
+ # checkouts of the same repo (or different machines mounting the
+ # same NFS HOME) could otherwise collide on each other's pointers.
+ if file_machine != machine_name:
+ logger.info(
+ '[bridge:pointer] %s machine mismatch (file=%r, current=%r) '
+ '— treating as absent', path, file_machine, machine_name,
+ )
+ return None
+ if os.path.abspath(file_dir) != os.path.abspath(working_dir):
+ logger.info(
+ '[bridge:pointer] %s dir mismatch (file=%r, current=%r) '
+ '— treating as absent', path, file_dir, working_dir,
+ )
+ return None
+
+ return BridgePointer(
+ bridge_id=bridge_id,
+ environment_id=environment_id,
+ session_id=session_id,
+ machine_name=file_machine,
+ dir=file_dir,
+ created_at_ms=created_at_ms,
+ updated_at_ms=updated_at_ms,
+ )
+
+
+def write_pointer(
+ working_dir: str, *,
+ bridge_id: str,
+ environment_id: str,
+ session_id: str | None,
+ machine_name: str,
+ created_at_ms: int | None = None,
+) -> None:
+ """Write/overwrite the pointer for ``working_dir``.
+
+ ``created_at_ms`` defaults to the current time on first write;
+ callers preserving a pre-existing pointer's identity should pass
+ its ``created_at_ms`` so the field doesn't reset on every update.
+
+ Writes through a tmpfile + ``os.replace`` for atomicity — a crash
+ during the write cannot leave a half-written pointer that
+ ``read_pointer`` then parses incorrectly.
+ """
+ path = _pointer_path(working_dir)
+ now = _now_ms()
+ pointer = BridgePointer(
+ bridge_id=bridge_id,
+ environment_id=environment_id,
+ session_id=session_id,
+ machine_name=machine_name,
+ dir=working_dir,
+ created_at_ms=created_at_ms if created_at_ms is not None else now,
+ updated_at_ms=now,
+ )
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ except OSError as err:
+ logger.warning(
+ '[bridge:pointer] mkdir(%s) failed: %s — pointer not written',
+ os.path.dirname(path), err,
+ )
+ return
+ tmp_path = f'{path}.tmp'
+ try:
+ with open(tmp_path, 'w', encoding='utf-8') as fh:
+ json.dump(pointer.to_json(), fh, indent=2)
+ os.replace(tmp_path, path)
+ except OSError as err:
+ logger.warning(
+ '[bridge:pointer] write %s failed: %s — pointer not written',
+ path, err,
+ )
+ # Best-effort cleanup of the tmpfile.
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def clear_pointer(working_dir: str) -> None:
+ """Remove the pointer for ``working_dir``. No-op if missing."""
+ path = _pointer_path(working_dir)
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ return
+ except OSError as err:
+ logger.warning(
+ '[bridge:pointer] unlink(%s) failed: %s', path, err,
+ )
diff --git a/src/bridge/bridge_status_util.py b/src/bridge/bridge_status_util.py
new file mode 100644
index 000000000..6604e5522
--- /dev/null
+++ b/src/bridge/bridge_status_util.py
@@ -0,0 +1,448 @@
+"""Status-line formatting + URL helpers for the bridge UI.
+
+Ports ``typescript/src/bridge/bridgeStatusUtil.ts``.
+
+The TS file pulls from four upstream modules:
+
+* ``utils/format.formatDuration`` + ``truncateToWidth``
+* ``constants/product.getClaudeAiBaseUrl`` + ``getRemoteSessionUrl``
+* ``ink/stringWidth.stringWidth``
+* ``utils/intl.getGraphemeSegmenter``
+
+This module:
+
+* **Imports** ``format_duration`` from ``src.utils.format`` (already
+ ported, more sophisticated than what TS bridge needs — see "Behavior
+ note" below).
+* **Inlines** ``truncate_to_width``, ``string_width``, the grapheme
+ segmenter, and the claude.ai URL helpers per refactoring plan §2 item
+ 13. ``wcwidth`` substitutes for ``stringWidth`` (handles CJK + emoji
+ width correctly), and the ``regex`` package's ``\\X`` grapheme pattern
+ substitutes for ``Intl.Segmenter``. The URL builders are inlined as
+ production-only constants/functions until ``constants/product.py`` is
+ ported.
+
+**Behavior note on ``format_duration``**: TS ``utils/format.ts:formatDuration``
+has no hours/days branch — anything ≥1h renders as ``"60m"``, ``"120m"``,
+etc. The Python ``src.utils.format.format_duration`` extends this with
+days/hours and ``hide_trailing_zeros`` / ``most_significant_only``
+flags. We re-export it unmodified; bridge UI sites that need TS-exact
+output should be aware of the divergence (mostly cosmetic — affects only
+the very long-duration case which the bridge rarely hits). Trailing-zero
+seconds are not hidden by default (e.g. ``"1m 0s"`` not ``"1m"``); pass
+``hide_trailing_zeros=True`` to match TS bridge exactly.
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Literal
+
+import regex
+import wcwidth
+
+# Re-export the canonical format_duration so callers can write
+# ``from src.bridge.bridge_status_util import format_duration``. See
+# module docstring "Behavior note" for the divergence vs TS bridge.
+from src.utils.format import format_duration # noqa: F401 re-export
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+StatusState = Literal['idle', 'attached', 'titled', 'reconnecting', 'failed']
+"""Mirrors TS ``StatusState`` on ``bridgeStatusUtil.ts:10-15``."""
+
+TOOL_DISPLAY_EXPIRY_MS: int = 30_000
+"""How long a tool activity line stays visible after last tool_start (ms).
+
+Mirrors TS ``TOOL_DISPLAY_EXPIRY_MS`` on ``bridgeStatusUtil.ts:18``.
+"""
+
+SHIMMER_INTERVAL_MS: int = 150
+"""Interval for the shimmer animation tick (ms).
+
+Mirrors TS ``SHIMMER_INTERVAL_MS`` on ``bridgeStatusUtil.ts:21``.
+"""
+
+FAILED_FOOTER_TEXT: str = 'Something went wrong, please try again'
+"""Footer text shown when the bridge has failed.
+
+Mirrors TS ``FAILED_FOOTER_TEXT`` on ``bridgeStatusUtil.ts:154``.
+"""
+
+
+_CLAUDE_AI_PRODUCTION_BASE: str = 'https://claude.ai'
+"""Production claude.ai base URL.
+
+Inlined substitute for TS ``getClaudeAiBaseUrl()`` happy-path return.
+Staging / FedStart variants land when ``constants/product.py`` is ported.
+"""
+
+
+# ---------------------------------------------------------------------------
+# Inlined utility helpers (substitute for cross-folder TS imports)
+# ---------------------------------------------------------------------------
+
+
+def string_width(s: str) -> int:
+ """Visual width of a string in terminal columns.
+
+ Inlined substitute for TS ``ink/stringWidth``. Uses ``wcwidth`` which
+ correctly handles CJK (2 cols), emoji (typically 2 cols), and ANSI
+ escape sequences (0 cols).
+ """
+ if not s:
+ return 0
+ # ``wcwidth.wcswidth`` returns -1 if the string contains a control
+ # character that has no defined width. Treat those as 0 to match the
+ # TS string-width behavior (which strips control chars before measuring).
+ w = wcwidth.wcswidth(_strip_ansi(s))
+ return max(w, 0)
+
+
+def truncate_to_width(s: str, max_width: int) -> str:
+ """Truncate a string to ``max_width`` visual columns with ellipsis.
+
+ Inlined substitute for TS ``utils/format.truncateToWidth``. Appends
+ ``'…'`` (1 col) when truncation occurs; if ``max_width`` is too small
+ to fit even the ellipsis, returns the empty string. Grapheme-aware so
+ multi-byte chars don't get split in the middle.
+
+ **ANSI-aware**: SGR escape sequences (``\\x1b[...m``) and OSC 8
+ hyperlink markers contribute zero visual width and are kept whole —
+ they will never be split mid-escape, even when truncation lands on
+ a colored substring. After truncation, all open SGR groups are
+ implicitly closed by the upstream renderer's ``\\x1b[0m`` reset (the
+ bridge UI always appends one); callers that don't do this should
+ append ``'\\x1b[0m'`` to the result to avoid color bleed.
+ """
+ if max_width <= 0:
+ return ''
+ if string_width(s) <= max_width:
+ return s
+ if max_width == 1:
+ return '…'
+ # Walk the string as a sequence of (token, visible_width) pairs where
+ # token is either an ANSI escape (width 0, kept whole) or a Unicode
+ # grapheme (width per wcwidth). Accumulate visible tokens until
+ # adding the next would exceed budget = max_width - 1, then switch
+ # to "truncated" mode where we keep emitting zero-width tokens only
+ # so trailing ``\x1b[0m`` resets (and any other escapes in the tail)
+ # survive — preventing color bleed into terminal output that follows.
+ budget = max_width - 1
+ out: list[str] = []
+ acc = 0
+ truncated = False
+ for token, width in _iter_tokens(s):
+ if width == 0:
+ out.append(token)
+ continue
+ if truncated:
+ continue # post-break visible content is dropped
+ if acc + width > budget:
+ truncated = True
+ continue
+ out.append(token)
+ acc += width
+ return ''.join(out) + '…'
+
+
+# Backwards-compat alias matching TS export name ``truncatePrompt``.
+truncate_prompt = truncate_to_width
+
+
+_ANSI_RE = regex.compile(r'\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07')
+
+
+def _strip_ansi(s: str) -> str:
+ """Strip ANSI SGR + OSC 8 hyperlink sequences for width calculation."""
+ return _ANSI_RE.sub('', s)
+
+
+def _grapheme_split(s: str) -> list[str]:
+ """Split a string into Unicode graphemes.
+
+ Substitute for TS ``Intl.Segmenter``. Uses the ``regex`` package's
+ ``\\X`` (extended grapheme cluster) pattern, which is the canonical
+ Unicode definition.
+ """
+ return regex.findall(r'\X', s)
+
+
+def _iter_tokens(s: str):
+ """Iterate ``(token, visible_width)`` pairs preserving ANSI escapes.
+
+ Yields ANSI SGR / OSC 8 sequences as zero-width whole tokens so
+ ``truncate_to_width`` never splits them mid-escape. Non-escape spans
+ are grapheme-split via ``\\X`` and yielded one cluster at a time with
+ their ``wcwidth`` value.
+ """
+ pos = 0
+ for match in _ANSI_RE.finditer(s):
+ if match.start() > pos:
+ for grapheme in regex.findall(r'\X', s[pos:match.start()]):
+ yield grapheme, max(wcwidth.wcswidth(grapheme), 0)
+ yield match.group(0), 0
+ pos = match.end()
+ if pos < len(s):
+ for grapheme in regex.findall(r'\X', s[pos:]):
+ yield grapheme, max(wcwidth.wcswidth(grapheme), 0)
+
+
+def get_claude_ai_base_url(ingress_url: str | None = None) -> str:
+ """Base URL for claude.ai links shown in the terminal UI.
+
+ Inlined substitute for TS ``constants/product.getClaudeAiBaseUrl``.
+ The TS version derives staging/FedStart variants from ``ingress_url``;
+ this Python port returns the production URL unconditionally until
+ ``constants/product.py`` is ported.
+ """
+ return _CLAUDE_AI_PRODUCTION_BASE
+
+
+def get_remote_session_url(
+ session_id: str,
+ ingress_url: str | None = None,
+) -> str:
+ """Build the claude.ai/code URL for an attached session.
+
+ Inlined substitute for TS ``constants/product.getRemoteSessionUrl``.
+ Performs the same ``cse_`` → ``session_`` retag the TS version does
+ (so the URL is browser-routable through the v1 compat layer).
+ """
+ if session_id.startswith('cse_'):
+ compat_id = 'session_' + session_id[len('cse_'):]
+ else:
+ compat_id = session_id
+ return f'{get_claude_ai_base_url(ingress_url)}/code/{compat_id}'
+
+
+# ---------------------------------------------------------------------------
+# Public timestamp / formatting helpers
+# ---------------------------------------------------------------------------
+
+
+def timestamp() -> str:
+ """Format current local time as ``HH:MM:SS``.
+
+ Mirrors TS ``timestamp`` on ``bridgeStatusUtil.ts:23-29``.
+ """
+ now = time.localtime()
+ return f'{now.tm_hour:02d}:{now.tm_min:02d}:{now.tm_sec:02d}'
+
+
+def abbreviate_activity(summary: str) -> str:
+ """Abbreviate a tool activity summary for the trail display.
+
+ Mirrors TS ``abbreviateActivity`` on ``bridgeStatusUtil.ts:34-36``.
+ """
+ return truncate_to_width(summary, 30)
+
+
+# ---------------------------------------------------------------------------
+# URL builders for the bridge
+# ---------------------------------------------------------------------------
+
+
+def build_bridge_connect_url(
+ environment_id: str,
+ ingress_url: str | None = None,
+) -> str:
+ """URL shown when the bridge is idle (waiting for a session).
+
+ Mirrors TS ``buildBridgeConnectUrl`` on ``bridgeStatusUtil.ts:39-45``.
+ """
+ base = get_claude_ai_base_url(ingress_url)
+ return f'{base}/code?bridge={environment_id}'
+
+
+def build_bridge_session_url(
+ session_id: str,
+ environment_id: str,
+ ingress_url: str | None = None,
+) -> str:
+ """URL shown when a session is attached.
+
+ Mirrors TS ``buildBridgeSessionUrl`` on ``bridgeStatusUtil.ts:52-58``.
+ Delegates to ``get_remote_session_url`` for the cse_→session_ retag,
+ then appends the v1-specific ``?bridge={environment_id}`` query.
+ """
+ base = get_remote_session_url(session_id, ingress_url)
+ return f'{base}?bridge={environment_id}'
+
+
+# ---------------------------------------------------------------------------
+# Shimmer animation math
+# ---------------------------------------------------------------------------
+
+
+def compute_glimmer_index(tick: int, message_width: int) -> int:
+ """Compute the column index for a reverse-sweep shimmer animation.
+
+ Mirrors TS ``computeGlimmerIndex`` on ``bridgeStatusUtil.ts:61-67``.
+ Walks right-to-left, off-screen-padded by 10 cols on each side so the
+ shimmer doesn't visually stick at the edges.
+ """
+ cycle_length = message_width + 20
+ return message_width + 10 - (tick % cycle_length)
+
+
+@dataclass(frozen=True)
+class ShimmerSegments:
+ """Three-segment split of a string by visual column position.
+
+ Mirrors TS ``{before, shimmer, after}`` return on
+ ``bridgeStatusUtil.ts:82``.
+ """
+
+ before: str
+ shimmer: str
+ after: str
+
+
+def compute_shimmer_segments(text: str, glimmer_index: int) -> ShimmerSegments:
+ """Split text into three segments by visual column position.
+
+ Mirrors TS ``computeShimmerSegments`` on ``bridgeStatusUtil.ts:79-111``.
+ Multi-byte chars + emoji + CJK are kept whole; the split is by
+ cumulative column position, not codepoint count.
+
+ Returns ``before``, ``shimmer`` (≤ 3 cols centered on
+ ``glimmer_index``), and ``after``. When the shimmer is offscreen,
+ everything goes into ``before``.
+ """
+ message_width = string_width(text)
+ shimmer_start = glimmer_index - 1
+ shimmer_end = glimmer_index + 1
+
+ if shimmer_start >= message_width or shimmer_end < 0:
+ return ShimmerSegments(before=text, shimmer='', after='')
+
+ clamped_start = max(0, shimmer_start)
+ col_pos = 0
+ before = []
+ shimmer = []
+ after = []
+ for grapheme in _grapheme_split(text):
+ seg_width = string_width(grapheme)
+ if col_pos + seg_width <= clamped_start:
+ before.append(grapheme)
+ elif col_pos > shimmer_end:
+ after.append(grapheme)
+ else:
+ shimmer.append(grapheme)
+ col_pos += seg_width
+
+ return ShimmerSegments(
+ before=''.join(before),
+ shimmer=''.join(shimmer),
+ after=''.join(after),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Status state machine
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class BridgeStatusInfo:
+ """Computed bridge status label and color from connection state.
+
+ Mirrors TS ``BridgeStatusInfo`` on ``bridgeStatusUtil.ts:115-121``.
+ """
+
+ label: str
+ color: Literal['error', 'warning', 'success']
+
+
+def get_bridge_status(
+ *,
+ error: str | None,
+ connected: bool,
+ session_active: bool,
+ reconnecting: bool,
+) -> BridgeStatusInfo:
+ """Derive a status label + color from the bridge connection state.
+
+ Mirrors TS ``getBridgeStatus`` on ``bridgeStatusUtil.ts:124-141``.
+ Keyword-only args to make call sites self-documenting (TS uses an
+ object literal; Python uses kw-only).
+ """
+ if error:
+ return BridgeStatusInfo(label='Remote Control failed', color='error')
+ if reconnecting:
+ return BridgeStatusInfo(
+ label='Remote Control reconnecting', color='warning'
+ )
+ if session_active or connected:
+ return BridgeStatusInfo(
+ label='Remote Control active', color='success'
+ )
+ return BridgeStatusInfo(
+ label='Remote Control connecting…', color='warning'
+ )
+
+
+# ---------------------------------------------------------------------------
+# Footer text builders
+# ---------------------------------------------------------------------------
+
+
+def build_idle_footer_text(url: str) -> str:
+ """Footer text shown when bridge is idle (Ready state).
+
+ Mirrors TS ``buildIdleFooterText`` on ``bridgeStatusUtil.ts:144-146``.
+ """
+ return f'Code everywhere with the Claude app or {url}'
+
+
+def build_active_footer_text(url: str) -> str:
+ """Footer text shown when a session is active (Connected state).
+
+ Mirrors TS ``buildActiveFooterText`` on ``bridgeStatusUtil.ts:149-151``.
+ """
+ return f'Continue coding in the Claude app or {url}'
+
+
+# ---------------------------------------------------------------------------
+# Terminal hyperlink wrapping
+# ---------------------------------------------------------------------------
+
+
+def wrap_with_osc8_link(text: str, url: str) -> str:
+ """Wrap text in an OSC 8 terminal hyperlink. Zero visual width.
+
+ Mirrors TS ``wrapWithOsc8Link`` on ``bridgeStatusUtil.ts:161-163``.
+ Strippable by ``_strip_ansi`` so ``string_width`` returns the visible
+ text width only.
+ """
+ return f'\x1b]8;;{url}\x07{text}\x1b]8;;\x07'
+
+
+__all__ = [
+ 'BridgeStatusInfo',
+ 'FAILED_FOOTER_TEXT',
+ 'SHIMMER_INTERVAL_MS',
+ 'ShimmerSegments',
+ 'StatusState',
+ 'TOOL_DISPLAY_EXPIRY_MS',
+ 'abbreviate_activity',
+ 'build_active_footer_text',
+ 'build_bridge_connect_url',
+ 'build_bridge_session_url',
+ 'build_idle_footer_text',
+ 'compute_glimmer_index',
+ 'compute_shimmer_segments',
+ 'format_duration',
+ 'get_bridge_status',
+ 'get_claude_ai_base_url',
+ 'get_remote_session_url',
+ 'string_width',
+ 'timestamp',
+ 'truncate_prompt',
+ 'truncate_to_width',
+ 'wrap_with_osc8_link',
+]
diff --git a/src/bridge/capacity_wake.py b/src/bridge/capacity_wake.py
new file mode 100644
index 000000000..d7e13a909
--- /dev/null
+++ b/src/bridge/capacity_wake.py
@@ -0,0 +1,114 @@
+"""Shared capacity-wake primitive for bridge poll loops.
+
+Ports ``typescript/src/bridge/capacityWake.ts``.
+
+The TS implementation merges a long-lived outer abort signal with a short-lived
+"wake" controller so a poll loop can sleep while at capacity but wake early
+when (a) the outer shutdown signal fires, or (b) capacity frees up (session
+done / transport lost). This Python port substitutes ``asyncio.Event`` for
+``AbortSignal`` and tracks per-``signal()``-call cleanup so callers can release
+listeners deterministically when their sleep resolves normally.
+
+The Python API mirrors TS:
+
+* ``CapacityWake.signal()`` returns a ``CapacitySignal`` whose ``.event`` is
+ set when *either* the outer signal or the wake controller fires. Callers
+ use ``await wait_first(event, ...)`` or ``await event.wait()`` then call
+ ``cleanup()`` to detach listeners.
+* ``CapacityWake.wake()`` aborts the current wake controller and arms a
+ fresh one so subsequent ``signal()`` calls return a new pair.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from typing import Callable
+
+
+@dataclass
+class CapacitySignal:
+ """A merged signal that fires when either source fires.
+
+ ``event`` is an ``asyncio.Event`` that becomes set when *either* the outer
+ signal or the wake controller fires. ``cleanup`` detaches the listeners
+ so the caller's sleep can release resources when it resolves normally.
+ """
+
+ event: asyncio.Event
+ cleanup: Callable[[], None]
+
+
+class CapacityWake:
+ """Wake-on-demand abstraction for bridge poll loops.
+
+ See module docstring. Single-loop use only — concurrent ``wake()`` from
+ different asyncio tasks is undefined; callers serialize externally.
+ """
+
+ def __init__(self, outer_signal: asyncio.Event) -> None:
+ self._outer_signal = outer_signal
+ self._wake_event = asyncio.Event()
+
+ def wake(self) -> None:
+ """Abort the current at-capacity sleep and arm a fresh wake event.
+
+ Mirrors TS ``wake()``: any existing ``signal()`` returned earlier
+ fires immediately (its merged event becomes set), and any subsequent
+ ``signal()`` call returns a fresh pair tied to a new internal event.
+ """
+ previous = self._wake_event
+ self._wake_event = asyncio.Event()
+ previous.set()
+
+ def signal(self) -> CapacitySignal:
+ """Build a merged signal for a single at-capacity sleep.
+
+ The returned ``event`` is set when either the outer signal or this
+ wake controller fires. ``cleanup`` cancels the background watcher
+ tasks; callers should call it when their sleep resolves normally so
+ the tasks don't leak. If either source has already fired by the time
+ ``signal()`` is called, the merged event is returned pre-set with a
+ no-op cleanup (matches TS short-circuit on lines 39-42).
+
+ **Async constraint**: Must be called from inside a running asyncio
+ event loop — ``signal()`` spawns watcher tasks via
+ ``asyncio.create_task`` which raises ``RuntimeError`` if invoked
+ from sync code with no current loop. The short-circuit path (either
+ source pre-fired) avoids this and is safe to call from sync code,
+ but callers must not rely on that behavior.
+ """
+ merged = asyncio.Event()
+
+ if self._outer_signal.is_set() or self._wake_event.is_set():
+ merged.set()
+ return CapacitySignal(event=merged, cleanup=lambda: None)
+
+ # Snapshot the current wake event: if wake() is called between this
+ # signal() returning and the caller awaiting, we still want to fire
+ # via the *original* event (matches TS captured-binding semantics on
+ # capacityWake.ts:44 where capSig = wakeController.signal).
+ wake_event = self._wake_event
+ outer_signal = self._outer_signal
+
+ async def _watch_outer() -> None:
+ await outer_signal.wait()
+ merged.set()
+
+ async def _watch_wake() -> None:
+ await wake_event.wait()
+ merged.set()
+
+ outer_task = asyncio.create_task(_watch_outer())
+ wake_task = asyncio.create_task(_watch_wake())
+
+ def _cleanup() -> None:
+ outer_task.cancel()
+ wake_task.cancel()
+
+ return CapacitySignal(event=merged, cleanup=_cleanup)
+
+
+def create_capacity_wake(outer_signal: asyncio.Event) -> CapacityWake:
+ """Factory mirroring TS ``createCapacityWake(outerSignal)``."""
+ return CapacityWake(outer_signal)
diff --git a/src/bridge/close_codes.py b/src/bridge/close_codes.py
new file mode 100644
index 000000000..7aa019e91
--- /dev/null
+++ b/src/bridge/close_codes.py
@@ -0,0 +1,52 @@
+"""WebSocket close codes used by the CCR bridge layer.
+
+Ports the close-code constants synthesized in
+``typescript/src/bridge/replBridgeTransport.ts:209-365`` and
+``typescript/src/remote/SessionsWebSocket.ts:34-37``.
+
+Single source of truth so consumers (Phase 3 `replBridgeTransport`,
+Phase 4 `SessionsWebSocket`, Phase 1 `directConnectManager`) do not
+re-define these magic numbers.
+"""
+
+from __future__ import annotations
+
+from typing import Final
+
+# ─── Server-initiated close codes the bridge transport synthesizes ─────
+
+#: Worker epoch superseded — closes both transports; replBridge's poll
+#: loop reconnects with a fresh epoch.
+#: ``replBridgeTransport.ts:220``.
+WS_CLOSE_EPOCH_MISMATCH: Final = 4090
+
+#: CCRClient.initialize failed — closes both; poll loop will retry on
+#: the next work dispatch.
+#: ``replBridgeTransport.ts:365``.
+WS_CLOSE_INIT_FAILURE: Final = 4091
+
+#: SSE reconnect-budget exhausted — distinguishable from HTTP-status
+#: closes so ws_closed telemetry can branch on it.
+#: ``replBridgeTransport.ts:313``.
+WS_CLOSE_RECONNECT_BUDGET_EXHAUSTED: Final = 4092
+
+# ─── claude.ai → CCR session close codes (Phase 4 SessionsWebSocket) ───
+
+#: Server says "you are not authorized for this session" — stop
+#: reconnecting permanently.
+#: ``SessionsWebSocket.ts:35``.
+WS_CLOSE_PERMANENT_UNAUTHORIZED: Final = 4003
+
+#: Server says "session not found" — could be transient during
+#: compaction; retry with limited linear backoff (max 3 attempts).
+#: ``SessionsWebSocket.ts:26``.
+WS_CLOSE_SESSION_NOT_FOUND: Final = 4001
+
+
+__all__ = [
+ 'WS_CLOSE_EPOCH_MISMATCH',
+ 'WS_CLOSE_INIT_FAILURE',
+ 'WS_CLOSE_PERMANENT_UNAUTHORIZED',
+ 'WS_CLOSE_RECONNECT_BUDGET_EXHAUSTED',
+ 'WS_CLOSE_SESSION_NOT_FOUND',
+]
diff --git a/src/bridge/code_session_api.py b/src/bridge/code_session_api.py
new file mode 100644
index 000000000..8e7eab7ee
--- /dev/null
+++ b/src/bridge/code_session_api.py
@@ -0,0 +1,265 @@
+"""HTTP wrappers for the CCR v2 code-session API + worker registration.
+
+Ports ``typescript/src/bridge/codeSessionApi.ts:26-168`` and the
+``register_worker`` half of ``workSecret.ts:97-127``.
+
+Three calls:
+
+ * ``create_code_session(base_url, access_token, title, ...)`` — POST
+ ``/v1/code/sessions`` and return the new session ID (``cse_*``).
+ * ``fetch_remote_credentials(session_id, base_url, access_token, ...)``
+ — POST ``/v1/code/sessions/{id}/bridge`` and return the
+ ``RemoteCredentials`` (``worker_jwt``/``api_base_url``/
+ ``expires_in``/``worker_epoch``). Each call to ``/bridge`` IS the
+ registration; the server bumps ``worker_epoch`` on every call.
+ * ``register_worker(session_url, access_token)`` — POST
+ ``/worker/register`` (legacy v2 path used when ``/bridge`` did not
+ return an epoch). Returns the ``worker_epoch`` integer.
+
+All return ``None`` on best-effort failure (network, non-2xx, malformed
+response) — matches TS ``return null`` rather than raising. Callers log
+and continue.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+from .protojson import coerce_int64
+
+logger = logging.getLogger(__name__)
+
+ANTHROPIC_VERSION = '2023-06-01'
+
+DEFAULT_TIMEOUT_SECONDS = 30.0
+
+
+def _oauth_headers(access_token: str) -> dict[str, str]:
+ return {
+ 'Authorization': f'Bearer {access_token}',
+ 'Content-Type': 'application/json',
+ 'anthropic-version': ANTHROPIC_VERSION,
+ }
+
+
+@dataclass(frozen=True)
+class RemoteCredentials:
+ """Output of ``fetch_remote_credentials`` (POST /bridge).
+
+ Mirrors TS ``RemoteCredentials`` at ``codeSessionApi.ts:84-91``.
+ The JWT is opaque — callers should not decode it (use
+ ``schedule_from_expires_in`` for the refresh timer, not
+ ``decode_jwt_expiry``).
+ """
+
+ worker_jwt: str
+ api_base_url: str
+ expires_in: int
+ worker_epoch: int
+
+
+# ─── POST /v1/code/sessions ────────────────────────────────────────────────
+
+
+async def create_code_session(
+ base_url: str,
+ access_token: str,
+ title: str,
+ *,
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
+ tags: list[str] | None = None,
+ client: httpx.AsyncClient | None = None,
+) -> str | None:
+ """POST ``/v1/code/sessions`` and return the new session ID.
+
+ Body: ``{title, bridge: {}, tags?}`` — the ``bridge: {}`` is the
+ positive signal for the oneof runner; omitting it 400s.
+
+ Returns ``None`` on any failure (network, non-2xx, missing or
+ malformed ``session.id``); the caller logs and falls through.
+
+ Mirrors ``codeSessionApi.ts:26-80``.
+ """
+ url = f'{base_url.rstrip("/")}/v1/code/sessions'
+ body: dict[str, Any] = {'title': title, 'bridge': {}}
+ if tags:
+ body['tags'] = tags
+
+ try:
+ if client is None:
+ async with httpx.AsyncClient(timeout=timeout_seconds) as fresh:
+ resp = await fresh.post(url, json=body, headers=_oauth_headers(access_token))
+ else:
+ resp = await client.post(
+ url, json=body, headers=_oauth_headers(access_token), timeout=timeout_seconds,
+ )
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
+ logger.debug('[code-session] Session create request failed: %s', exc)
+ return None
+
+ if resp.status_code not in (200, 201):
+ logger.debug(
+ '[code-session] Session create failed %d', resp.status_code
+ )
+ return None
+
+ try:
+ data = resp.json()
+ except ValueError:
+ return None
+
+ session_obj = data.get('session') if isinstance(data, dict) else None
+ if not isinstance(session_obj, dict):
+ return None
+ sid = session_obj.get('id')
+ if not isinstance(sid, str) or not sid.startswith('cse_'):
+ logger.debug('[code-session] No session.id (cse_*) in response')
+ return None
+ return sid
+
+
+# ─── POST /v1/code/sessions/{id}/bridge ────────────────────────────────────
+
+
+async def fetch_remote_credentials(
+ session_id: str,
+ base_url: str,
+ access_token: str,
+ *,
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
+ trusted_device_token: str | None = None,
+ client: httpx.AsyncClient | None = None,
+) -> RemoteCredentials | None:
+ """POST ``/v1/code/sessions/{id}/bridge`` and return credentials.
+
+ Mirrors ``codeSessionApi.ts:93-168``. ``trusted_device_token`` is
+ passed as ``X-Trusted-Device-Token`` if set; otherwise omitted.
+
+ Returns ``None`` on failure. ``worker_epoch`` is coerced via
+ ``coerce_int64`` (handles protojson string-OR-number).
+ """
+ url = f'{base_url.rstrip("/")}/v1/code/sessions/{session_id}/bridge'
+ headers = _oauth_headers(access_token)
+ if trusted_device_token:
+ headers['X-Trusted-Device-Token'] = trusted_device_token
+
+ try:
+ if client is None:
+ async with httpx.AsyncClient(timeout=timeout_seconds) as fresh:
+ resp = await fresh.post(url, json={}, headers=headers)
+ else:
+ resp = await client.post(
+ url, json={}, headers=headers, timeout=timeout_seconds,
+ )
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
+ logger.debug('[code-session] /bridge request failed: %s', exc)
+ return None
+
+ if resp.status_code != 200:
+ logger.debug('[code-session] /bridge failed %d', resp.status_code)
+ return None
+
+ try:
+ data = resp.json()
+ except ValueError:
+ return None
+
+ if not isinstance(data, dict):
+ return None
+ worker_jwt = data.get('worker_jwt')
+ api_base = data.get('api_base_url')
+ expires_in = data.get('expires_in')
+ raw_epoch = data.get('worker_epoch')
+
+ if not isinstance(worker_jwt, str) or not worker_jwt:
+ logger.debug('[code-session] /bridge missing worker_jwt')
+ return None
+ if not isinstance(api_base, str) or not api_base:
+ logger.debug('[code-session] /bridge missing api_base_url')
+ return None
+ if not isinstance(expires_in, int):
+ logger.debug('[code-session] /bridge missing or invalid expires_in')
+ return None
+ if raw_epoch is None:
+ logger.debug('[code-session] /bridge missing worker_epoch')
+ return None
+ try:
+ epoch = coerce_int64(raw_epoch)
+ except ValueError as exc:
+ logger.debug('[code-session] /bridge worker_epoch invalid: %s', exc)
+ return None
+ return RemoteCredentials(
+ worker_jwt=worker_jwt,
+ api_base_url=api_base,
+ expires_in=expires_in,
+ worker_epoch=epoch,
+ )
+
+
+# ─── POST /worker/register (legacy path) ───────────────────────────────────
+
+
+async def register_worker(
+ session_url: str,
+ access_token: str,
+ *,
+ timeout_seconds: float = 10.0,
+ client: httpx.AsyncClient | None = None,
+) -> int:
+ """POST ``${session_url}/worker/register`` and return the ``worker_epoch``.
+
+ Used by the v1 CCR-v2 path (``replBridge`` poll loop) when ``/bridge``
+ didn't return an epoch directly. Raises ``RuntimeError`` on
+ network/HTTP failure or malformed response — callers wrap in their
+ own retry budget.
+
+ Mirrors ``workSecret.ts:97-127``.
+ """
+ url = f'{session_url.rstrip("/")}/worker/register'
+ headers = _oauth_headers(access_token)
+ try:
+ if client is None:
+ async with httpx.AsyncClient(timeout=timeout_seconds) as fresh:
+ resp = await fresh.post(url, json={}, headers=headers)
+ else:
+ resp = await client.post(
+ url, json={}, headers=headers, timeout=timeout_seconds,
+ )
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
+ raise RuntimeError(f'register_worker failed: {exc}') from exc
+
+ if resp.status_code not in (200, 201):
+ raise RuntimeError(
+ f'register_worker: unexpected status {resp.status_code}'
+ )
+
+ try:
+ data = resp.json()
+ except ValueError as exc:
+ raise RuntimeError(f'register_worker: invalid JSON response: {exc}') from exc
+
+ if not isinstance(data, dict):
+ raise RuntimeError('register_worker: response is not an object')
+ raw = data.get('worker_epoch')
+ if raw is None:
+ raise RuntimeError('register_worker: response missing worker_epoch')
+ try:
+ return coerce_int64(raw)
+ except ValueError as exc:
+ raise RuntimeError(
+ f'register_worker: invalid worker_epoch: {exc}'
+ ) from exc
+
+
+__all__ = [
+ 'ANTHROPIC_VERSION',
+ 'DEFAULT_TIMEOUT_SECONDS',
+ 'RemoteCredentials',
+ 'create_code_session',
+ 'fetch_remote_credentials',
+ 'register_worker',
+]
diff --git a/src/bridge/debug_utils.py b/src/bridge/debug_utils.py
new file mode 100644
index 000000000..7bdba6eb3
--- /dev/null
+++ b/src/bridge/debug_utils.py
@@ -0,0 +1,186 @@
+"""Debug logging utilities for the bridge subsystem.
+
+Ports ``typescript/src/bridge/debugUtils.ts``.
+
+Helpers for redacting secrets, truncating debug bodies, extracting error
+messages from HTTP errors, and emitting analytics events for skipped bridge
+init. The TS file uses axios-specific error types; Python uses ``httpx`` or
+plain ``Exception`` — the API is generalized as ``describe_http_error``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+DEBUG_MSG_LIMIT = 2000
+
+_SECRET_FIELD_NAMES = (
+ 'session_ingress_token',
+ 'environment_secret',
+ 'access_token',
+ 'secret',
+ 'token',
+)
+
+_SECRET_PATTERN = re.compile(
+ r'"(' + '|'.join(_SECRET_FIELD_NAMES) + r')"\s*:\s*"([^"]*)"',
+)
+
+_REDACT_MIN_LENGTH = 16
+
+
+def redact_secrets(s: str) -> str:
+ """Replace secret-field values in a JSON-like string with redacted forms.
+
+ Mirrors TS ``redactSecrets`` on ``debugUtils.ts:26-34``. Short tokens
+ (<16 chars) are fully redacted; longer ones keep the first 8 + last 4
+ chars so debug logs are still triagable while not leaking the secret.
+ """
+
+ def _replace(match: re.Match[str]) -> str:
+ field = match.group(1)
+ value = match.group(2)
+ if len(value) < _REDACT_MIN_LENGTH:
+ return f'"{field}":"[REDACTED]"'
+ return f'"{field}":"{value[:8]}...{value[-4:]}"'
+
+ return _SECRET_PATTERN.sub(_replace, s)
+
+
+def debug_truncate(s: str) -> str:
+ """Truncate a string for debug logging, collapsing newlines.
+
+ Mirrors TS ``debugTruncate`` on ``debugUtils.ts:37-43``.
+ """
+ flat = s.replace('\n', '\\n')
+ if len(flat) <= DEBUG_MSG_LIMIT:
+ return flat
+ return flat[:DEBUG_MSG_LIMIT] + f'... ({len(flat)} chars)'
+
+
+def debug_body(data: Any) -> str:
+ """Truncate a JSON-serializable value for debug logging, with redaction.
+
+ Mirrors TS ``debugBody`` on ``debugUtils.ts:46-53``. Strings pass through
+ as-is; everything else is JSON-encoded first.
+
+ **Behavioral divergence from TS** (intentional): the Python port passes
+ ``default=str`` to ``json.dumps`` so non-JSON-serializable values
+ (sets, datetimes, custom classes) render as their ``str()`` repr instead
+ of throwing. TS ``jsonStringify`` throws on non-serializable values.
+ The Python behavior is strictly safer for debug logging — no caller
+ should rely on the throw.
+ """
+ if isinstance(data, str):
+ raw = data
+ else:
+ try:
+ raw = json.dumps(data, default=str)
+ except (TypeError, ValueError):
+ raw = repr(data)
+ s = redact_secrets(raw)
+ if len(s) <= DEBUG_MSG_LIMIT:
+ return s
+ return s[:DEBUG_MSG_LIMIT] + f'... ({len(s)} chars)'
+
+
+def describe_http_error(err: object) -> str:
+ """Extract a descriptive error message from an HTTP error.
+
+ Mirrors TS ``describeAxiosError`` on ``debugUtils.ts:60-82``. Renamed
+ for Python (httpx, not axios). For responses with a structured error
+ body, appends the server's ``message`` / ``error.message`` field.
+
+ Works with httpx ``HTTPStatusError`` (has ``.response.json()``),
+ ``RequestError`` (no response), and plain ``Exception``. Best-effort —
+ returns just ``str(err)`` when the response isn't structured.
+ """
+ msg = str(err)
+ response = getattr(err, 'response', None)
+ if response is None:
+ return msg
+ try:
+ data = response.json() if hasattr(response, 'json') else None
+ except Exception:
+ return msg
+ detail = extract_error_detail(data)
+ if detail:
+ return f'{msg}: {detail}'
+ return msg
+
+
+def extract_http_status(err: object) -> int | None:
+ """Extract the HTTP status code from an error, if present.
+
+ Mirrors TS ``extractHttpStatus`` on ``debugUtils.ts:88-100``. Returns
+ ``None`` for non-HTTP errors (network failures, timeouts without a
+ response).
+ """
+ response = getattr(err, 'response', None)
+ if response is None:
+ return None
+ status = getattr(response, 'status_code', None)
+ if isinstance(status, int):
+ return status
+ # httpx uses ``status_code``; some libraries use ``status``.
+ status = getattr(response, 'status', None)
+ if isinstance(status, int):
+ return status
+ return None
+
+
+def extract_error_detail(data: Any) -> str | None:
+ """Pull a human-readable message out of an API error response body.
+
+ Mirrors TS ``extractErrorDetail`` on ``debugUtils.ts:106-121``. Checks
+ ``data['message']`` first, then ``data['error']['message']``.
+ """
+ if not isinstance(data, dict):
+ return None
+ message = data.get('message')
+ if isinstance(message, str):
+ return message
+ error = data.get('error')
+ if isinstance(error, dict):
+ inner = error.get('message')
+ if isinstance(inner, str):
+ return inner
+ return None
+
+
+def log_bridge_skip(
+ reason: str,
+ debug_msg: str | None = None,
+ v2: bool | None = None,
+) -> None:
+ """Log a bridge init skip — debug message + analytics event.
+
+ Mirrors TS ``logBridgeSkip`` on ``debugUtils.ts:128-141``. The TS version
+ sends a ``tengu_bridge_repl_skipped`` analytics event via ``logEvent``;
+ the Python port logs at INFO level since no GrowthBook analytics client
+ exists yet (per refactoring-plan §0.1 Q2 — analytics stubbed).
+ """
+ if debug_msg:
+ logger.debug(debug_msg)
+ extra: dict[str, Any] = {'reason': reason}
+ if v2 is not None:
+ extra['v2'] = v2
+ logger.info('bridge_skip reason=%s%s', reason,
+ f' v2={v2}' if v2 is not None else '')
+
+
+__all__ = [
+ 'DEBUG_MSG_LIMIT',
+ 'debug_body',
+ 'debug_truncate',
+ 'describe_http_error',
+ 'extract_error_detail',
+ 'extract_http_status',
+ 'log_bridge_skip',
+ 'redact_secrets',
+]
diff --git a/src/bridge/env_less_bridge_config.py b/src/bridge/env_less_bridge_config.py
new file mode 100644
index 000000000..8c946b6a5
--- /dev/null
+++ b/src/bridge/env_less_bridge_config.py
@@ -0,0 +1,142 @@
+"""Env-less (v2) bridge timing config.
+
+Ports ``typescript/src/bridge/envLessBridgeConfig.ts``.
+
+Defines the per-session timing knobs used by the v2 (env-less) bridge
+path: init retry backoff, HTTP timeouts, JWT refresh buffer, heartbeat
+cadence, archive teardown deadline, connect timeout, version floor, and
+the app-upgrade nudge bit. Numeric values match TS exactly so the port is
+behavior-preserving — see ``test_env_less_bridge_config.py`` for the
+contract.
+
+Per refactoring plan §0.1 Q2, GrowthBook is not wired in the Python build;
+``get_env_less_bridge_config()`` returns the validated defaults rather than
+fetching ``tengu_bridge_repl_v2_config`` from the GrowthBook client. When
+GB lands in Phase 10, the function swaps to fetch + validate; callers see
+no API change.
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field, ValidationError
+
+from src.bridge.bridge_enabled import is_env_less_bridge_enabled
+
+
+class EnvLessBridgeConfig(BaseModel):
+ """Validated env-less bridge timing config.
+
+ Mirrors TS ``EnvLessBridgeConfig`` on ``envLessBridgeConfig.ts:7-42``.
+ Field names + numeric values + bounds match TS Zod schema exactly.
+
+ Bounds are enforced by ``Field(..., ge=..., le=...)``; out-of-range
+ inputs trigger a ``ValidationError`` and the caller falls back to
+ ``DEFAULT_ENV_LESS_BRIDGE_CONFIG`` (matching the TS pattern of
+ rejecting the whole object on any field violation).
+
+ ``strict=True`` matches TS Zod's no-coercion semantics: ``z.number()``
+ rejects strings, ``z.boolean()`` rejects strings — pydantic v2 with
+ ``strict=True`` does the same. Without this, a GrowthBook payload
+ with JSON-string integers ("5" instead of 5) would silently coerce
+ and validate, hiding upstream schema drift.
+ """
+
+ model_config = ConfigDict(strict=True)
+
+ init_retry_max_attempts: int = Field(default=3, ge=1, le=10)
+ init_retry_base_delay_ms: int = Field(default=500, ge=100)
+ init_retry_jitter_fraction: float = Field(default=0.25, ge=0.0, le=1.0)
+ init_retry_max_delay_ms: int = Field(default=4000, ge=500)
+ http_timeout_ms: int = Field(default=10_000, ge=2000)
+ uuid_dedup_buffer_size: int = Field(default=2000, ge=100, le=50_000)
+ # Server TTL is 60s. Floor 5s prevents thrash; cap 30s keeps ≥2× margin.
+ heartbeat_interval_ms: int = Field(default=20_000, ge=5_000, le=30_000)
+ # ±fraction per beat. Cap 0.5: at 30s × 1.5 = 45s worst case, still
+ # under the 60s server TTL.
+ heartbeat_jitter_fraction: float = Field(default=0.1, ge=0.0, le=0.5)
+ # Floor 30s prevents tight-looping. Cap 30min rejects buffer-vs-delay
+ # semantic inversion (see TS comment lines 80-86).
+ token_refresh_buffer_ms: int = Field(
+ default=300_000, ge=30_000, le=1_800_000
+ )
+ # Cap 2000 keeps this under gracefulShutdown's 2s cleanup race.
+ teardown_archive_timeout_ms: int = Field(default=1500, ge=500, le=2000)
+ # Observed p99 connect ~2-3s; 15s = ~5× headroom. Floor 5s, cap 60s.
+ connect_timeout_ms: int = Field(default=15_000, ge=5_000, le=60_000)
+ min_version: str = Field(default='0.0.0')
+ should_show_app_upgrade_message: bool = False
+
+
+DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = EnvLessBridgeConfig()
+"""Defaults matching TS ``DEFAULT_ENV_LESS_BRIDGE_CONFIG`` on
+``envLessBridgeConfig.ts:44-58`` exactly. See ``test_env_less_bridge_config.py``
+for the per-field assertion contract.
+"""
+
+
+async def get_env_less_bridge_config() -> EnvLessBridgeConfig:
+ """Fetch + validate the env-less bridge timing config.
+
+ Mirrors TS ``getEnvLessBridgeConfig`` on ``envLessBridgeConfig.ts:130-137``.
+ The TS version fetches ``tengu_bridge_repl_v2_config`` from GrowthBook
+ and parses it through the Zod schema; on any validation error, falls
+ back to the defaults. The Python port returns defaults directly — see
+ module docstring for the rationale.
+ """
+ return DEFAULT_ENV_LESS_BRIDGE_CONFIG
+
+
+def validate_env_less_bridge_config_raw(
+ raw: object,
+) -> EnvLessBridgeConfig:
+ """Validate a raw dict against the schema; fall back to defaults on error.
+
+ Public helper for downstream callers (Phase 10 GrowthBook integration
+ or test code) that have a raw payload and want the same "reject the
+ whole object on any field violation" semantics TS Zod provides.
+ """
+ if not isinstance(raw, dict):
+ return DEFAULT_ENV_LESS_BRIDGE_CONFIG
+ try:
+ return EnvLessBridgeConfig.model_validate(raw)
+ except ValidationError:
+ return DEFAULT_ENV_LESS_BRIDGE_CONFIG
+
+
+async def check_env_less_bridge_min_version() -> str | None:
+ """Returns an error message if CLI version is below v2 min, else None.
+
+ Mirrors TS ``checkEnvLessBridgeMinVersion`` on
+ ``envLessBridgeConfig.ts:147-153``. The Python build has no semver
+ comparator wired and no GrowthBook-served min_version (default
+ ``'0.0.0'`` passes everything), so this always returns ``None``.
+ """
+ cfg = await get_env_less_bridge_config()
+ if cfg.min_version == '0.0.0':
+ return None
+ # When a real min_version arrives via Phase 10 GB integration, swap
+ # this branch for a real semver comparison.
+ return None
+
+
+async def should_show_app_upgrade_message() -> bool:
+ """Whether to nudge users toward upgrading their claude.ai app.
+
+ Mirrors TS ``shouldShowAppUpgradeMessage`` on
+ ``envLessBridgeConfig.ts:161-165``. True only when (a) the v2 bridge
+ is active AND (b) the config bit is set.
+ """
+ if not is_env_less_bridge_enabled():
+ return False
+ cfg = await get_env_less_bridge_config()
+ return cfg.should_show_app_upgrade_message
+
+
+__all__ = [
+ 'DEFAULT_ENV_LESS_BRIDGE_CONFIG',
+ 'EnvLessBridgeConfig',
+ 'check_env_less_bridge_min_version',
+ 'get_env_less_bridge_config',
+ 'should_show_app_upgrade_message',
+ 'validate_env_less_bridge_config_raw',
+]
diff --git a/src/bridge/exceptions.py b/src/bridge/exceptions.py
new file mode 100644
index 000000000..f6e160cad
--- /dev/null
+++ b/src/bridge/exceptions.py
@@ -0,0 +1,62 @@
+"""Project-wide exceptions for the CCR bridge layer.
+
+Phase-local exceptions (e.g., ``DirectConnectError``) live with their
+respective modules; only cross-phase exceptions live here.
+"""
+
+from __future__ import annotations
+
+
+class EpochSupersededError(Exception):
+ """Raised by Phase 3's V2 transport when the server returns 409
+ (worker_epoch superseded).
+
+ Mirrors TS ``replBridgeTransport.ts:230`` which throws ``new Error(
+ 'epoch superseded')`` to unwind in-flight callers (``request()``).
+ Catching this is how callers learn the epoch was bumped — they should
+ drop the current transport and recreate it with a fresh epoch.
+ """
+
+
+class BridgeAuthError(Exception):
+ """Raised when JWT cannot be decoded or refresh fails permanently.
+
+ The ``TokenRefreshScheduler`` (WI-2.4) has a 3-failure cap; past that,
+ the scheduler stops retrying and surfaces this exception to the caller.
+ """
+
+
+class BridgeFatalError(Exception):
+ """Non-retryable error from the environments API.
+
+ Mirrors TS ``BridgeFatalError`` exported from ``bridgeApi.ts:56-66``.
+ Carries the HTTP status code and an optional server-provided error type
+ (e.g. ``'environment_expired'``, ``'lifetime'``). Callers use
+ ``is_expired_error_type(err.error_type)`` (Phase 3) to decide between
+ teardown-and-recreate (expired) vs. fail-loudly (genuine 401/403).
+
+ Args:
+ message: Human-readable detail (typically ``f'{verb} {status}'``).
+ status: HTTP status code.
+ error_type: Optional server-provided error code from the response
+ body's ``data.error.type`` field.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ status: int,
+ error_type: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.status = status
+ self.error_type = error_type
+
+ def __repr__(self) -> str:
+ return (
+ f'BridgeFatalError(status={self.status}, '
+ f'error_type={self.error_type!r}, message={str(self)!r})'
+ )
+
+
+__all__ = ['BridgeAuthError', 'BridgeFatalError', 'EpochSupersededError']
diff --git a/src/bridge/flush_gate.py b/src/bridge/flush_gate.py
new file mode 100644
index 000000000..b317c4587
--- /dev/null
+++ b/src/bridge/flush_gate.py
@@ -0,0 +1,92 @@
+"""State machine for gating message writes during an initial flush.
+
+Ports ``typescript/src/bridge/flushGate.ts``.
+
+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 ones.
+
+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 queued items
+ (transport replacement — the new transport will drain
+ the pending items)
+
+Concurrency: single-coroutine use only — concurrent ``enqueue``/``end``
+calls from different asyncio tasks are undefined behavior. Asyncio +
+threads is a footgun; the TS source assumes a single-threaded event loop.
+"""
+
+from __future__ import annotations
+
+from typing import Generic, TypeVar
+
+T = TypeVar('T')
+
+
+class FlushGate(Generic[T]):
+ """Queue-with-lifecycle that gates writes during a one-shot flush."""
+
+ __slots__ = ('_active', '_pending')
+
+ def __init__(self) -> None:
+ self._active: bool = False
+ self._pending: list[T] = []
+
+ @property
+ def active(self) -> bool:
+ return self._active
+
+ @property
+ def pending_count(self) -> int:
+ return len(self._pending)
+
+ def start(self) -> None:
+ """Mark flush as in-progress. ``enqueue`` will start queuing items."""
+ self._active = True
+
+ def end(self) -> list[T]:
+ """End the flush and return queued items for draining.
+
+ The caller is responsible for sending the returned items.
+ Subsequent ``enqueue`` calls return False until ``start`` is called
+ again.
+ """
+ self._active = False
+ items = self._pending
+ self._pending = []
+ return items
+
+ def enqueue(self, *items: T) -> bool:
+ """If flush is active, queue ``items`` and return True.
+
+ If flush is not active, return False (caller should send directly).
+ """
+ if not self._active:
+ return False
+ self._pending.extend(items)
+ return True
+
+ def drop(self) -> int:
+ """Discard all queued items (permanent transport close).
+
+ Returns the number of items dropped.
+ """
+ self._active = False
+ count = len(self._pending)
+ self._pending = []
+ return count
+
+ def deactivate(self) -> None:
+ """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.
+ """
+ self._active = False
+
+
+__all__ = ['FlushGate']
diff --git a/src/bridge/inbound_attachments.py b/src/bridge/inbound_attachments.py
new file mode 100644
index 000000000..8f7cf8c8a
--- /dev/null
+++ b/src/bridge/inbound_attachments.py
@@ -0,0 +1,272 @@
+"""Resolve ``file_uuid`` attachments on inbound bridge user messages.
+
+Ports ``typescript/src/bridge/inboundAttachments.ts``.
+
+Web composer uploads files via cookie-authed ``/api/{org}/upload``, then
+sends ``file_uuid`` alongside the message. This module fetches each via
+GET ``/api/oauth/files/{uuid}/content`` (OAuth-authed, same store),
+writes to ``~/.claude/uploads/{sessionId}/``, and returns ``@path`` refs
+the Read tool can pick up.
+
+**Best-effort**: any failure (no token, network, non-2xx, disk) logs at
+debug and skips that attachment. The message still reaches Claude, just
+without the ``@path`` ref.
+
+The Phase 10 caveat: ``get_bridge_access_token()`` returns ``None`` in
+the Python build until Phase 10 wires the keychain OAuth read — so this
+module degrades to "extract attachments + skip resolution" until then.
+The shape of the module is correct; only the network fetch is gated.
+"""
+
+from __future__ import annotations
+
+import base64
+import logging
+import os
+import re
+import uuid
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+from src.bootstrap.state import get_session_id
+from src.bridge.bridge_config import get_bridge_access_token, get_bridge_base_url
+
+logger = logging.getLogger(__name__)
+
+
+_DOWNLOAD_TIMEOUT_SECONDS = 30.0
+
+
+_SAFE_FILENAME_RE = re.compile(r'[^a-zA-Z0-9._-]')
+
+
+# ── Public surface ────────────────────────────────────────────────────────
+
+
+def extract_inbound_attachments(msg: Any) -> list[dict[str, str]]:
+ """Pull ``file_attachments`` off a loosely-typed inbound message.
+
+ Mirrors TS ``extractInboundAttachments`` on ``inboundAttachments.ts:42-48``.
+ Returns a list of ``{file_uuid, file_name}`` dicts; empty when the
+ message has no ``file_attachments`` field or the field is malformed.
+ """
+ if not isinstance(msg, dict):
+ return []
+ raw = msg.get('file_attachments')
+ if not isinstance(raw, list):
+ return []
+ out: list[dict[str, str]] = []
+ for item in raw:
+ if not isinstance(item, dict):
+ continue
+ file_uuid = item.get('file_uuid')
+ file_name = item.get('file_name')
+ if isinstance(file_uuid, str) and isinstance(file_name, str):
+ out.append({'file_uuid': file_uuid, 'file_name': file_name})
+ return out
+
+
+async def resolve_inbound_attachments(
+ attachments: list[dict[str, str]],
+ *,
+ http_client: httpx.AsyncClient | None = None,
+) -> str:
+ """Fetch every attachment, write to disk, return the ``@path`` ref prefix.
+
+ Mirrors TS ``resolveInboundAttachments`` on
+ ``inboundAttachments.ts:123-134``. Returns the empty string when
+ nothing resolves (no auth token, all fetches failed, no
+ attachments). Multiple successful resolutions are joined with
+ spaces and end with a trailing space.
+
+ The quoted ``@"/path/with spaces"`` form prevents the
+ ``extractAtMentionedFiles`` consumer from truncating home dirs that
+ contain spaces (e.g. ``/Users/John Smith/``).
+ """
+ if not attachments:
+ return ''
+ _debug(f'resolving {len(attachments)} attachment(s)')
+ paths: list[str] = []
+ for att in attachments:
+ path = await _resolve_one(att, http_client=http_client)
+ if path is not None:
+ paths.append(path)
+ if not paths:
+ return ''
+ return ' '.join(f'@"{p}"' for p in paths) + ' '
+
+
+def prepend_path_refs(content: Any, prefix: str) -> Any:
+ """Prepend ``@path`` refs to message content.
+
+ Mirrors TS ``prependPathRefs`` on ``inboundAttachments.ts:142-161``.
+ For string content: simple concatenation. For list content
+ (mixed text + image): target the LAST text block so the consumer's
+ ``processedBlocks[-1]`` read picks them up. If there's no text
+ block, append one at the end.
+
+ Returns the original content unchanged when ``prefix`` is empty
+ (zero-allocation happy path).
+ """
+ if not prefix:
+ return content
+ if isinstance(content, str):
+ return prefix + content
+ if not isinstance(content, list):
+ return content
+ # Find the LAST text block index.
+ last_text_idx = -1
+ for i, block in enumerate(content):
+ if isinstance(block, dict) and block.get('type') == 'text':
+ last_text_idx = i
+ if last_text_idx >= 0:
+ block = content[last_text_idx]
+ existing_text = block.get('text', '') if isinstance(block, dict) else ''
+ new_block = {**block, 'text': prefix + existing_text}
+ return [
+ *content[:last_text_idx],
+ new_block,
+ *content[last_text_idx + 1:],
+ ]
+ # No text block — append one.
+ return [*content, {'type': 'text', 'text': prefix.rstrip()}]
+
+
+async def resolve_and_prepend(
+ msg: Any,
+ content: Any,
+ *,
+ http_client: httpx.AsyncClient | None = None,
+) -> Any:
+ """Convenience: extract + resolve + prepend in one call.
+
+ Mirrors TS ``resolveAndPrepend`` on ``inboundAttachments.ts:167-175``.
+ No-op when the message has no ``file_attachments`` field (returns
+ the same content reference).
+ """
+ attachments = extract_inbound_attachments(msg)
+ if not attachments:
+ return content
+ prefix = await resolve_inbound_attachments(
+ attachments, http_client=http_client,
+ )
+ return prepend_path_refs(content, prefix)
+
+
+# ── Internals ─────────────────────────────────────────────────────────────
+
+
+def _sanitize_filename(name: str) -> str:
+ """Strip path components + keep only filename-safe chars.
+
+ Mirrors TS ``sanitizeFileName`` on ``inboundAttachments.ts:55-58``.
+ File name comes from the network, so treat as untrusted even though
+ the web composer controls it.
+ """
+ base = os.path.basename(name)
+ cleaned = _SAFE_FILENAME_RE.sub('_', base)
+ return cleaned or 'attachment'
+
+
+def _uploads_dir() -> Path:
+ """Per-session uploads directory under ``~/.claude/uploads/``.
+
+ Mirrors TS ``uploadsDir`` on ``inboundAttachments.ts:60-62``. The TS
+ version reads ``getClaudeConfigHomeDir()`` (which honors a
+ ``CLAUDE_CONFIG_DIR`` env override); the Python port checks the
+ same env var first, falling back to ``~/.claude``.
+ """
+ override = os.environ.get('CLAUDE_CONFIG_DIR')
+ home = Path(override) if override else Path.home() / '.claude'
+ return home / 'uploads' / str(get_session_id())
+
+
+def _debug(message: str) -> None:
+ """One-line debug logger matching the TS ``debug()`` style."""
+ logger.debug('[bridge:inbound-attach] %s', message)
+
+
+async def _resolve_one(
+ att: dict[str, str],
+ *,
+ http_client: httpx.AsyncClient | None,
+) -> str | None:
+ """Fetch + write one attachment. Returns absolute path or None.
+
+ Mirrors TS ``resolveOne`` on ``inboundAttachments.ts:68-117``.
+ """
+ token = get_bridge_access_token()
+ if not token:
+ _debug('skip: no oauth token')
+ return None
+
+ base_url = get_bridge_base_url()
+ file_uuid = att['file_uuid']
+ file_name = att['file_name']
+ url = (
+ f'{base_url.rstrip("/")}'
+ f'/api/oauth/files/{_urlencode_segment(file_uuid)}/content'
+ )
+
+ try:
+ if http_client is not None:
+ response = await http_client.get(
+ url,
+ headers={'Authorization': f'Bearer {token}'},
+ timeout=_DOWNLOAD_TIMEOUT_SECONDS,
+ )
+ else:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ url,
+ headers={'Authorization': f'Bearer {token}'},
+ timeout=_DOWNLOAD_TIMEOUT_SECONDS,
+ )
+ except httpx.HTTPError as err:
+ _debug(f'fetch {file_uuid} threw: {err}')
+ return None
+
+ if response.status_code != 200:
+ _debug(f'fetch {file_uuid} failed: status={response.status_code}')
+ return None
+ data = response.content
+
+ # ``uuid_prefix-safe_name`` makes collisions impossible across
+ # messages and within one message (same filename, different files).
+ safe_name = _sanitize_filename(file_name)
+ prefix_source = file_uuid[:8] or uuid.uuid4().hex[:8]
+ prefix = _SAFE_FILENAME_RE.sub('_', prefix_source)
+ out_dir = _uploads_dir()
+ out_path = out_dir / f'{prefix}-{safe_name}'
+
+ try:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path.write_bytes(data)
+ except OSError as err:
+ _debug(f'write {out_path} failed: {err}')
+ return None
+
+ _debug(f'resolved {file_uuid} -> {out_path} ({len(data)} bytes)')
+ return str(out_path)
+
+
+def _urlencode_segment(value: str) -> str:
+ """Minimal URL-segment encoder for the file UUID path component.
+
+ Mirrors TS ``encodeURIComponent(att.file_uuid)``. We use Python's
+ ``urllib.parse.quote`` with no safe chars so any non-alphanumeric
+ character is escaped.
+ """
+ from urllib.parse import quote
+
+ return quote(value, safe='')
+
+
+__all__ = [
+ 'extract_inbound_attachments',
+ 'prepend_path_refs',
+ 'resolve_and_prepend',
+ 'resolve_inbound_attachments',
+]
diff --git a/src/bridge/inbound_messages.py b/src/bridge/inbound_messages.py
new file mode 100644
index 000000000..36b931758
--- /dev/null
+++ b/src/bridge/inbound_messages.py
@@ -0,0 +1,143 @@
+"""Process inbound user messages from the bridge.
+
+Ports ``typescript/src/bridge/inboundMessages.ts``.
+
+Two responsibilities:
+
+1. ``extract_inbound_message_fields`` — pull (content, uuid) off a parsed
+ SDKMessage of type ``user`` for enqueueing. Returns ``None`` when the
+ message should be skipped (non-user, missing/empty content).
+
+2. ``normalize_image_blocks`` — fix camelCase ``mediaType`` → snake_case
+ ``media_type`` on image blocks from bridge clients (iOS/web composer).
+ Returns the original list when no normalization is needed (zero-alloc
+ happy path).
+
+The TS file uses ``detectImageFormatFromBase64`` from ``utils/imageResizer.js``;
+the Python port inlines a small magic-byte sniffer since the only formats
+needed are PNG / JPEG / GIF / WebP.
+"""
+
+from __future__ import annotations
+
+import base64
+from typing import Any
+
+
+def detect_image_format_from_base64(data: str) -> str:
+ """Sniff PNG / JPEG / GIF / WebP from the first 12 base64-decoded bytes.
+
+ Inlined substitute for TS ``utils/imageResizer.detectImageFormatFromBase64``.
+ Returns the Anthropic ``media_type`` string. Falls back to ``image/png``
+ when the format cannot be identified (matches what the upstream API
+ would default to and avoids breaking the request on a slightly-malformed
+ payload).
+ """
+ try:
+ decoded = base64.b64decode(data[:24]) # 24 base64 chars ≈ 16 bytes
+ except (ValueError, base64.binascii.Error): # type: ignore[attr-defined]
+ return 'image/png'
+
+ if len(decoded) < 4:
+ return 'image/png'
+ # PNG: 89 50 4E 47
+ if decoded[:4] == b'\x89PNG':
+ return 'image/png'
+ # JPEG: FF D8 FF
+ if decoded[:3] == b'\xff\xd8\xff':
+ return 'image/jpeg'
+ # GIF: 47 49 46 38 (GIF8)
+ if decoded[:4] == b'GIF8':
+ return 'image/gif'
+ # WebP: RIFF + WEBP at offset 8
+ if len(decoded) >= 12 and decoded[:4] == b'RIFF' and decoded[8:12] == b'WEBP':
+ return 'image/webp'
+ return 'image/png'
+
+
+def extract_inbound_message_fields(
+ msg: dict[str, Any],
+) -> tuple[Any, str | None] | None:
+ """Extract (content, uuid) from a parsed user SDKMessage, or None to skip.
+
+ Mirrors TS ``extractInboundMessageFields`` on ``inboundMessages.ts:21-40``.
+ Returns ``None`` for non-user messages and for empty content (which is
+ also what the TS function returns via ``undefined``).
+
+ **Return shape divergence from TS**: TS returns an object
+ ``{ content, uuid }``; Python returns a tuple ``(content, uuid)`` —
+ matches Python idiom. Phase 5+ orchestrator porters should unpack the
+ tuple, not destructure as if it were a dict.
+
+ Image content blocks are normalized as a side effect of returning them
+ via ``normalize_image_blocks``.
+ """
+ if msg.get('type') != 'user':
+ return None
+ inner = msg.get('message') or {}
+ content = inner.get('content')
+ if not content:
+ return None
+ if isinstance(content, list) and len(content) == 0:
+ return None
+
+ raw_uuid = msg.get('uuid')
+ uuid_str = raw_uuid if isinstance(raw_uuid, str) else None
+
+ if isinstance(content, list):
+ return normalize_image_blocks(content), uuid_str
+ return content, uuid_str
+
+
+def normalize_image_blocks(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Fix malformed image source blocks. Returns input ref on the happy path.
+
+ Mirrors TS ``normalizeImageBlocks`` on ``inboundMessages.ts:52-73``.
+ Bridge clients may send ``mediaType`` (camelCase) instead of
+ ``media_type`` (snake_case), or omit it entirely. Without normalization
+ the upstream API rejects the request with "media_type: Field required".
+
+ Fast-path: scan blocks; if none are malformed, return the input list
+ reference unchanged (zero allocation).
+ """
+ if not any(_is_malformed_base64_image(b) for b in blocks):
+ return blocks
+
+ out: list[dict[str, Any]] = []
+ for block in blocks:
+ if not _is_malformed_base64_image(block):
+ out.append(block)
+ continue
+ source = block.get('source') or {}
+ camel = source.get('mediaType')
+ media_type: str
+ if isinstance(camel, str) and camel:
+ media_type = camel
+ else:
+ data = source.get('data', '')
+ data_str = data if isinstance(data, str) else ''
+ media_type = detect_image_format_from_base64(data_str)
+ new_source = {
+ 'type': 'base64',
+ 'media_type': media_type,
+ 'data': source.get('data', ''),
+ }
+ out.append({**block, 'source': new_source})
+ return out
+
+
+def _is_malformed_base64_image(block: dict[str, Any]) -> bool:
+ """A base64 image block missing the snake_case ``media_type`` field."""
+ if block.get('type') != 'image':
+ return False
+ source = block.get('source') or {}
+ if source.get('type') != 'base64':
+ return False
+ return 'media_type' not in source
+
+
+__all__ = [
+ 'detect_image_format_from_base64',
+ 'extract_inbound_message_fields',
+ 'normalize_image_blocks',
+]
diff --git a/src/bridge/init_repl_bridge.py b/src/bridge/init_repl_bridge.py
new file mode 100644
index 000000000..93ad43826
--- /dev/null
+++ b/src/bridge/init_repl_bridge.py
@@ -0,0 +1,398 @@
+"""REPL bridge bootstrap — Phase 7 MVP slice.
+
+Ports ``typescript/src/bridge/initReplBridge.ts`` (570 lines).
+
+The TS file is a thick bootstrap layer that reads bootstrap state (cwd,
+session ID, git context, OAuth tokens, persisted title), runs pre-flight
+checks (auth, version, org UUID, entitlement), branches between the
+env-less (v2) and env-based (v1) bridge cores via the
+``tengu_bridge_repl_v2`` GrowthBook gate, and wires the two-stage
+title-derivation pipeline (instant placeholder + async Haiku
+regeneration).
+
+This Phase 7 MVP ports the structural skeleton:
+
+* ``init_repl_bridge(options) -> ReplBridgeHandle | None``
+* Pre-flight: OAuth token, org UUID, entitlement (via Phase 1 stubs)
+* v1/v2 branching based on ``is_env_less_bridge_enabled() and not perpetual``
+* Delegates to Phase 5 ``init_env_less_bridge_core`` (v2) or Phase 6
+ ``init_bridge_core`` (v1, MVP slice)
+* ``derive_title(raw)`` — synchronous quick placeholder (strip display
+ tags, first sentence, truncate to 50 chars)
+
+Explicit deferrals (Phase 10 follow-ups):
+
+* **Two-stage Haiku title generation** — requires ``generate_session_title``
+ from a future ``utils/session_title.py`` port (Haiku model call with
+ 15s timeout + fire-and-forget guards). Until then, only ``derive_title``'s
+ instant placeholder is used; long-form titles wait for the user to
+ rename via ``/rename``.
+* **OAuth waterfall** (proactive refresh + cross-process backoff) —
+ ``check_and_refresh_oauth_token_if_needed`` is a no-op stub today
+ (Phase 2). The MVP just reads the token and proceeds.
+* **KAIROS / assistant-mode worker_type detection** — defaults to
+ ``claude_code``. Future port can extend.
+* **Policy-limits gating** — TS calls ``isPolicyAllowed`` + waits for
+ policy limits to load. The MVP skips this.
+* **``previously_flushed_uuids``** — propagated to ``init_bridge_core``
+ but the MVP doesn't maintain it across sessions yet.
+
+The function signature matches TS so a future expansion is non-breaking.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+from src.auth.claude_ai import (
+ check_and_refresh_oauth_token_if_needed,
+ has_profile_scope,
+ is_claude_ai_subscriber,
+)
+from src.bridge.bridge_config import (
+ get_bridge_access_token,
+ get_bridge_base_url,
+)
+from src.bridge.bridge_enabled import (
+ check_bridge_min_version,
+ is_env_less_bridge_enabled,
+)
+from src.bridge.debug_utils import log_bridge_skip
+from src.bridge.env_less_bridge_config import (
+ check_env_less_bridge_min_version,
+)
+from src.bridge.remote_bridge_core import (
+ EnvLessBridgeParams,
+ init_env_less_bridge_core,
+)
+from src.bridge.repl_bridge import (
+ BridgeCoreParams,
+ ReplBridgeHandle,
+ init_bridge_core,
+)
+from src.services.oauth.client import get_organization_uuid
+
+logger = logging.getLogger(__name__)
+
+
+# ── Public surface ────────────────────────────────────────────────────────
+
+
+OnInboundMessage = Callable[[dict[str, Any]], Any]
+OnUserMessage = Callable[[str, str], bool]
+OnPermissionResponse = Callable[[dict[str, Any]], None]
+OnInterrupt = Callable[[], None]
+OnSetModel = Callable[[str | None], None]
+OnSetMaxThinkingTokens = Callable[[int | None], None]
+OnSetPermissionMode = Callable[[str], Any]
+OnStateChange = Callable[..., None]
+
+
+@dataclass
+class InitBridgeOptions:
+ """Caller-supplied configuration for ``init_repl_bridge``.
+
+ Mirrors TS ``InitBridgeOptions`` consumer surface. Most fields are
+ optional callbacks; the only required-by-policy field is
+ ``initial_name`` (which defaults to a generated session title).
+ """
+
+ initial_name: str | None = None
+ initial_messages: list[Any] | None = None
+ previously_flushed_uuids: set[str] | None = None
+ perpetual: bool = False
+ outbound_only: bool = False
+ tags: list[str] | None = None
+ initial_history_cap: int = 200
+
+ on_inbound_message: OnInboundMessage | None = None
+ on_user_message: OnUserMessage | None = None
+ on_permission_response: OnPermissionResponse | None = None
+ on_interrupt: OnInterrupt | None = None
+ on_set_model: OnSetModel | None = None
+ on_set_max_thinking_tokens: OnSetMaxThinkingTokens | None = None
+ on_set_permission_mode: OnSetPermissionMode | None = None
+ on_state_change: OnStateChange | None = None
+
+ # Inject sync createSession / archiveSession for v1 path — daemon /
+ # REPL wrappers fill these with their own org-scoped HTTP wrappers.
+ # MVP provides defaults that wire through the v2 ``code_session_api``
+ # which is sufficient for the env-less code path.
+ create_session: (
+ Callable[[dict[str, Any]], Awaitable[str | None]] | None
+ ) = None
+ archive_session: Callable[[str], Awaitable[None]] | None = None
+
+ # Phase 11c hooks:
+
+ # Override the ``worker_type`` metadata sent at env registration.
+ # Mirrors TS KAIROS / assistant-mode detection where the worker
+ # advertises itself as ``claude_code_assistant`` so the claude.ai
+ # session picker can filter it into the assistant tab. Defaults to
+ # ``claude_code``. Callers can pass a callable so detection runs at
+ # init time (e.g. reading bootstrap state).
+ worker_type: str | Callable[[], str] = 'claude_code'
+
+ # Whether to run a proactive OAuth-token refresh during pre-flight.
+ # The check_and_refresh_oauth_token_if_needed call is a no-op stub
+ # today (Phase 2 deferral) but emits a warning if a near-expiry
+ # token is detected; flipping this off skips the warning when
+ # callers know they're using a long-lived token.
+ proactive_oauth_refresh: bool = True
+
+
+async def init_repl_bridge(
+ options: InitBridgeOptions | None = None,
+ *,
+ machine_name: str = 'localhost',
+ branch: str = 'main',
+ git_repo_url: str | None = None,
+ working_dir: str = '.',
+) -> ReplBridgeHandle | Any | None:
+ """Bootstrap the REPL bridge: pre-flight + v1/v2 branching + delegate.
+
+ Returns the bridge handle (``ReplBridgeHandle`` for v1 or
+ ``RemoteBridgeHandle`` for v2) on success, or ``None`` on any
+ pre-flight failure.
+
+ Mirrors TS ``initReplBridge`` consumer surface. The wrapper fields
+ (``machine_name``, ``branch``, ``git_repo_url``, ``working_dir``) are
+ explicit kw-only args here rather than being read from bootstrap
+ state — the Python port doesn't bind to the REPL's bootstrap layer
+ yet, so callers supply them. A future expansion can default them
+ from ``src/bootstrap/state``.
+ """
+ opts = options or InitBridgeOptions()
+
+ # ── 1. Proactive OAuth refresh ─────────────────────────────────────
+ # Phase 11c addition: run the refresh waterfall BEFORE reading the
+ # access token. Real impl (Phase 10 keychain port) refreshes tokens
+ # near expiry; the current stub is a no-op that emits a warning
+ # when expiry is detected — useful signal for diagnosing 401s in
+ # tests / dev.
+ if opts.proactive_oauth_refresh:
+ try:
+ await check_and_refresh_oauth_token_if_needed()
+ except Exception as err: # noqa: BLE001
+ # Best-effort: never block init on a refresh failure. The
+ # subsequent token read + entitlement check will fail
+ # explicitly if the token actually is stale.
+ logger.debug(
+ '[bridge:repl] proactive OAuth refresh raised '
+ '(continuing): %s', err
+ )
+
+ # ── 2. OAuth token pre-check ───────────────────────────────────────
+ access_token = get_bridge_access_token()
+ if not access_token:
+ log_bridge_skip(
+ 'no_oauth_token',
+ '[bridge:repl] Skipping: no OAuth token',
+ )
+ _fire_state(opts.on_state_change, 'failed', '/login')
+ return None
+
+ # ── 2. Entitlement check (subscriber + profile scope) ──────────────
+ if not is_claude_ai_subscriber():
+ log_bridge_skip(
+ 'not_subscriber',
+ '[bridge:repl] Skipping: not a claude.ai subscriber',
+ )
+ _fire_state(opts.on_state_change, 'failed', '/login')
+ return None
+ if not has_profile_scope():
+ log_bridge_skip(
+ 'no_profile_scope',
+ '[bridge:repl] Skipping: token missing user:profile scope',
+ )
+ _fire_state(opts.on_state_change, 'failed', '/login')
+ return None
+
+ # ── 3. Org UUID (needed by both v1 and v2 paths) ───────────────────
+ org_uuid = await get_organization_uuid()
+ if not org_uuid:
+ log_bridge_skip(
+ 'no_org_uuid',
+ '[bridge:repl] Skipping: no org UUID',
+ )
+ _fire_state(opts.on_state_change, 'failed', '/login')
+ return None
+
+ title = opts.initial_name or 'Remote Control session'
+ base_url = get_bridge_base_url()
+
+ # ── 4. v1/v2 branching ─────────────────────────────────────────────
+ # The env-less (v2) path skips the Environments API entirely. Per
+ # TS comment: perpetual mode is env-coupled and falls back to v1.
+ if is_env_less_bridge_enabled() and not opts.perpetual:
+ version_error = await check_env_less_bridge_min_version()
+ if version_error:
+ log_bridge_skip(
+ 'version_too_old',
+ f'[bridge:repl] Skipping: {version_error}',
+ v2=True,
+ )
+ _fire_state(
+ opts.on_state_change, 'failed',
+ 'run `openclaude update` to upgrade',
+ )
+ return None
+ logger.debug(
+ '[bridge:repl] Using env-less bridge path '
+ '(tengu_bridge_repl_v2 stub returns True)'
+ )
+ return await init_env_less_bridge_core(EnvLessBridgeParams(
+ base_url=base_url,
+ org_uuid=org_uuid,
+ title=title,
+ get_access_token=get_bridge_access_token,
+ initial_history_cap=opts.initial_history_cap,
+ initial_messages=opts.initial_messages,
+ on_inbound_message=opts.on_inbound_message,
+ on_user_message=opts.on_user_message,
+ on_permission_response=opts.on_permission_response,
+ on_interrupt=opts.on_interrupt,
+ on_set_model=opts.on_set_model,
+ on_set_max_thinking_tokens=opts.on_set_max_thinking_tokens,
+ on_set_permission_mode=opts.on_set_permission_mode,
+ on_state_change=opts.on_state_change,
+ outbound_only=opts.outbound_only,
+ tags=opts.tags,
+ ))
+
+ # ── v1 path: env-based (register/poll/ack/heartbeat) ──────────────
+ version_error_sync = check_bridge_min_version()
+ if version_error_sync:
+ log_bridge_skip(
+ 'version_too_old',
+ f'[bridge:repl] Skipping: {version_error_sync}',
+ )
+ _fire_state(
+ opts.on_state_change, 'failed',
+ 'run `openclaude update` to upgrade',
+ )
+ return None
+
+ # v1 path requires create_session + archive_session injections.
+ # Without them we can't delegate to init_bridge_core. MVP returns
+ # None with a clear log so the caller can wire them.
+ if opts.create_session is None or opts.archive_session is None:
+ log_bridge_skip(
+ 'v1_path_missing_callbacks',
+ '[bridge:repl] Skipping: v1 path requires create_session + '
+ 'archive_session callbacks (Phase 7 MVP — wrappers come later)',
+ )
+ _fire_state(
+ opts.on_state_change, 'failed',
+ 'v1 path not yet supported in MVP',
+ )
+ return None
+
+ # Phase 11c: resolve worker_type from callable-or-string.
+ worker_type = (
+ opts.worker_type()
+ if callable(opts.worker_type)
+ else opts.worker_type
+ )
+ return await init_bridge_core(BridgeCoreParams(
+ dir=working_dir,
+ machine_name=machine_name,
+ branch=branch,
+ git_repo_url=git_repo_url,
+ title=title,
+ base_url=base_url,
+ session_ingress_url=base_url,
+ worker_type=worker_type,
+ get_access_token=get_bridge_access_token,
+ create_session=opts.create_session,
+ archive_session=opts.archive_session,
+ on_inbound_message=opts.on_inbound_message,
+ on_user_message=opts.on_user_message,
+ on_permission_response=opts.on_permission_response,
+ on_interrupt=opts.on_interrupt,
+ on_set_model=opts.on_set_model,
+ on_set_max_thinking_tokens=opts.on_set_max_thinking_tokens,
+ on_set_permission_mode=opts.on_set_permission_mode,
+ on_state_change=opts.on_state_change,
+ initial_history_cap=opts.initial_history_cap,
+ initial_messages=opts.initial_messages,
+ perpetual=opts.perpetual,
+ ))
+
+
+# ── Title derivation ──────────────────────────────────────────────────────
+
+
+TITLE_MAX_LEN = 50
+
+
+_FIRST_SENTENCE_RE = re.compile(r'^(.*?[.!?])\s', re.DOTALL)
+_WHITESPACE_RE = re.compile(r'\s+')
+_DISPLAY_TAG_RE = re.compile(
+ r'<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?\1>\n?',
+)
+
+
+def _strip_display_tags(text: str) -> str:
+ """Strip XML display tags (mirrors TS ``stripDisplayTagsAllowEmpty``).
+
+ Returns the empty string (not the original) when all content is tags,
+ matching the TS contract.
+ """
+ return _DISPLAY_TAG_RE.sub('', text)
+
+
+def derive_title(raw: str) -> str | None:
+ """Quick placeholder title from a raw user message.
+
+ Mirrors TS ``deriveTitle`` on ``initReplBridge.ts:556-569``:
+
+ 1. Strip ````, ````, etc.
+ (display tags injected by IDE/hooks).
+ 2. Take the first sentence (terminator: ``.``, ``!``, ``?``).
+ 3. Collapse newlines/tabs into single spaces.
+ 4. Truncate to 50 chars (with an ``…`` if truncated).
+
+ Returns ``None`` for empty / pure-display-tag content so callers
+ can fall through to the generated title.
+ """
+ clean = _strip_display_tags(raw)
+ match = _FIRST_SENTENCE_RE.match(clean)
+ first_sentence = match.group(1) if match else clean
+ flat = _WHITESPACE_RE.sub(' ', first_sentence).strip()
+ if not flat:
+ return None
+ if len(flat) > TITLE_MAX_LEN:
+ return flat[:TITLE_MAX_LEN - 1] + '…'
+ return flat
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────
+
+
+def _fire_state(
+ cb: OnStateChange | None,
+ state: str,
+ detail: str | None = None,
+) -> None:
+ if cb is None:
+ return
+ try:
+ if detail is None:
+ cb(state)
+ else:
+ cb(state, detail)
+ except Exception as err: # noqa: BLE001
+ logger.warning('[bridge:repl] on_state_change raised: %s', err)
+
+
+__all__ = [
+ 'TITLE_MAX_LEN',
+ 'InitBridgeOptions',
+ 'derive_title',
+ 'init_repl_bridge',
+]
diff --git a/src/bridge/jwt_utils.py b/src/bridge/jwt_utils.py
new file mode 100644
index 000000000..e4f236c50
--- /dev/null
+++ b/src/bridge/jwt_utils.py
@@ -0,0 +1,348 @@
+"""JWT payload decoder + proactive token refresh scheduler.
+
+Ports ``typescript/src/bridge/jwtUtils.ts``. **Does NOT verify
+signatures** — the TS source doesn't either; this is a payload reader,
+not a JWT validator.
+
+Two surfaces:
+
+1. ``decode_jwt_payload`` / ``decode_jwt_expiry`` — pure functions for
+ reading the payload + ``exp`` claim from a base64url-encoded JWT.
+ Strips the ``sk-ant-si-`` session-ingress prefix if present.
+
+2. ``TokenRefreshScheduler`` — per-session timer that proactively
+ refreshes tokens 5 minutes before expiry, with a generation counter
+ to invalidate cancelled-then-rescheduled refreshes, fallback
+ ``schedule_from_expires_in`` for opaque JWTs (30 s floor clamp), and
+ a 30-min follow-up so long-running sessions stay authenticated.
+ Constants match TS exactly.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import logging
+import time
+from collections.abc import Awaitable, Callable
+from typing import Any, Mapping, Union
+
+logger = logging.getLogger(__name__)
+
+# ─── JWT payload reading ───────────────────────────────────────────────────
+
+
+def decode_jwt_payload(token: str) -> Mapping[str, Any] | None:
+ """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 ``Mapping`` (project convention uses
+ ``Any`` not ``object``; see ``src/types/messages.py:115``), or
+ ``None`` if the token is malformed or the payload is not valid JSON.
+
+ Mirrors ``typescript/src/bridge/jwtUtils.ts:21-32``.
+ """
+ jwt = token[len('sk-ant-si-'):] if token.startswith('sk-ant-si-') else token
+ parts = jwt.split('.')
+ if len(parts) != 3 or not parts[1]:
+ return None
+ try:
+ # base64url payload may omit padding.
+ body = parts[1]
+ body += '=' * ((-len(body)) % 4)
+ decoded = base64.urlsafe_b64decode(body).decode('utf-8')
+ parsed: Any = json.loads(decoded)
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ return parsed
+
+
+def decode_jwt_expiry(token: str) -> int | None:
+ """Return the ``exp`` claim (Unix seconds) or None if unparseable.
+
+ Mirrors ``typescript/src/bridge/jwtUtils.ts:38-49``.
+ """
+ payload = decode_jwt_payload(token)
+ if payload is None:
+ return None
+ exp = payload.get('exp')
+ if isinstance(exp, int):
+ return exp
+ return None
+
+
+# ─── Token refresh scheduler ───────────────────────────────────────────────
+
+# Constants — keep identical to TS so behavior matches.
+TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
+"""Refresh buffer: request a new token this many ms before expiry."""
+
+FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000
+"""Fallback refresh interval when the new token's expiry is unknown."""
+
+MAX_REFRESH_FAILURES = 3
+"""Cap on consecutive failures before giving up on the refresh chain."""
+
+REFRESH_RETRY_DELAY_MS = 60_000
+"""Retry delay when ``get_access_token`` returns None."""
+
+SCHEDULE_FROM_EXPIRES_IN_FLOOR_MS = 30_000
+"""30 s floor for ``schedule_from_expires_in`` to avoid tight-loop refresh."""
+
+
+GetAccessToken = Callable[[], Union[str, None, Awaitable[Union[str, None]]]]
+OnRefresh = Callable[[str, str], None]
+
+
+def _format_duration_ms(ms: float) -> str:
+ """Format a millisecond duration as a human-readable string."""
+ if ms < 60_000:
+ return f'{round(ms / 1000)}s'
+ minutes = int(ms // 60_000)
+ seconds = round((ms % 60_000) / 1000)
+ return f'{minutes}m {seconds}s' if seconds > 0 else f'{minutes}m'
+
+
+class TokenRefreshScheduler:
+ """Per-session refresh-timer manager.
+
+ Mirrors ``typescript/src/bridge/jwtUtils.ts:72-256`` (``createTokenRefreshScheduler``).
+
+ Public API:
+ schedule(session_id, token) — refresh based on JWT.exp
+ schedule_from_expires_in(sid, sec) — refresh based on TTL
+ cancel(session_id) — cancel one session
+ cancel_all() — cancel all sessions
+
+ Concurrency: all methods assume single-event-loop use AND must be
+ called from inside a running asyncio loop (we use
+ ``asyncio.get_running_loop()`` rather than the deprecated
+ ``get_event_loop()`` to avoid the implicit-loop-creation hazard on
+ Python 3.12+). Cancellation via ``loop.call_later(...).cancel()`` is
+ synchronous — returns immediately; the callback is just unscheduled.
+ """
+
+ def __init__(
+ self,
+ get_access_token: GetAccessToken,
+ on_refresh: OnRefresh,
+ label: str,
+ refresh_buffer_ms: int = TOKEN_REFRESH_BUFFER_MS,
+ ) -> None:
+ self._get_access_token = get_access_token
+ self._on_refresh = on_refresh
+ self._label = label
+ self._refresh_buffer_ms = refresh_buffer_ms
+ self._timers: dict[str, asyncio.TimerHandle] = {}
+ self._failure_counts: dict[str, int] = {}
+ # Generation counter per session — bumped by schedule() and
+ # cancel() so in-flight async _do_refresh calls can detect when
+ # they've been superseded and bail out.
+ self._generations: dict[str, int] = {}
+
+ # ─── Public API ────────────────────────────────────────────────────────
+
+ def schedule(self, session_id: str, token: str) -> None:
+ """Schedule refresh based on the token's ``exp`` claim."""
+ expiry_seconds = decode_jwt_expiry(token)
+ if expiry_seconds is None:
+ # Token is not a decodable JWT (e.g., an OAuth token from the
+ # REPL bridge WS open handler). Preserve any existing timer
+ # (such as the follow-up refresh set by _do_refresh) so the
+ # refresh chain isn't broken.
+ logger.debug(
+ '[%s:token] Could not decode JWT expiry for sessionId=%s, token prefix=%s..., keeping existing timer',
+ self._label,
+ session_id,
+ token[:15],
+ )
+ return
+
+ # Cancel any existing timer — we have a concrete expiry to replace it.
+ existing = self._timers.pop(session_id, None)
+ if existing is not None:
+ existing.cancel()
+
+ gen = self._next_generation(session_id)
+ delay_ms = expiry_seconds * 1000 - int(time.time() * 1000) - self._refresh_buffer_ms
+ if delay_ms <= 0:
+ logger.debug(
+ '[%s:token] Token for sessionId=%s already past refresh buffer; refreshing immediately',
+ self._label,
+ session_id,
+ )
+ asyncio.get_running_loop().create_task(self._do_refresh(session_id, gen))
+ return
+
+ logger.debug(
+ '[%s:token] Scheduled token refresh for sessionId=%s in %s (buffer=%ss)',
+ self._label,
+ session_id,
+ _format_duration_ms(delay_ms),
+ self._refresh_buffer_ms / 1000,
+ )
+ loop = asyncio.get_running_loop()
+ handle = loop.call_later(delay_ms / 1000, self._fire_refresh, session_id, gen)
+ self._timers[session_id] = handle
+
+ def schedule_from_expires_in(self, session_id: str, expires_in_seconds: int) -> None:
+ """Schedule refresh from an explicit TTL (for opaque JWTs).
+
+ Used by callers whose JWT is opaque (e.g., POST /v1/code/sessions/
+ {id}/bridge returns ``expires_in`` directly).
+
+ Clamped to ``SCHEDULE_FROM_EXPIRES_IN_FLOOR_MS`` (30 s) to avoid a
+ tight-loop refresh if the buffer exceeds the server's TTL.
+ """
+ existing = self._timers.pop(session_id, None)
+ if existing is not None:
+ existing.cancel()
+ gen = self._next_generation(session_id)
+ delay_ms = max(
+ expires_in_seconds * 1000 - self._refresh_buffer_ms,
+ SCHEDULE_FROM_EXPIRES_IN_FLOOR_MS,
+ )
+ logger.debug(
+ '[%s:token] Scheduled token refresh for sessionId=%s in %s (expires_in=%ss, buffer=%ss)',
+ self._label,
+ session_id,
+ _format_duration_ms(delay_ms),
+ expires_in_seconds,
+ self._refresh_buffer_ms / 1000,
+ )
+ loop = asyncio.get_running_loop()
+ handle = loop.call_later(delay_ms / 1000, self._fire_refresh, session_id, gen)
+ self._timers[session_id] = handle
+
+ def cancel(self, session_id: str) -> None:
+ """Cancel the timer for one session and bump its generation."""
+ # Bump generation to invalidate any in-flight async _do_refresh.
+ self._next_generation(session_id)
+ timer = self._timers.pop(session_id, None)
+ if timer is not None:
+ timer.cancel()
+ self._failure_counts.pop(session_id, None)
+
+ def cancel_all(self) -> None:
+ """Cancel all timers and bump all generations."""
+ for sid in list(self._generations.keys()):
+ self._next_generation(sid)
+ for handle in self._timers.values():
+ handle.cancel()
+ self._timers.clear()
+ self._failure_counts.clear()
+
+ # ─── Internal ──────────────────────────────────────────────────────────
+
+ def _next_generation(self, session_id: str) -> int:
+ gen = self._generations.get(session_id, 0) + 1
+ self._generations[session_id] = gen
+ return gen
+
+ def _fire_refresh(self, session_id: str, gen: int) -> None:
+ """Timer callback — schedules the async refresh as a task."""
+ asyncio.get_running_loop().create_task(self._do_refresh(session_id, gen))
+
+ async def _do_refresh(self, session_id: str, gen: int) -> None:
+ """Run one refresh cycle.
+
+ If ``get_access_token`` returns None, increments failure count
+ and reschedules until ``MAX_REFRESH_FAILURES`` is reached.
+ On success: fires ``on_refresh(session_id, token)`` and schedules
+ the follow-up at ``FALLBACK_REFRESH_INTERVAL_MS``.
+ """
+ oauth_token: str | None = None
+ try:
+ result = self._get_access_token()
+ if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
+ oauth_token = await result # type: ignore[assignment]
+ else:
+ oauth_token = result # type: ignore[assignment]
+ except Exception as exc: # noqa: BLE001 -- log + continue, like TS
+ logger.error(
+ '[%s:token] get_access_token threw for sessionId=%s: %s',
+ self._label,
+ session_id,
+ exc,
+ )
+
+ # If the session was cancelled or rescheduled while we were
+ # awaiting, the generation will have changed — bail out to avoid
+ # orphaning timers.
+ if self._generations.get(session_id) != gen:
+ logger.debug(
+ '[%s:token] _do_refresh for sessionId=%s stale (gen %s vs %s), skipping',
+ self._label,
+ session_id,
+ gen,
+ self._generations.get(session_id),
+ )
+ return
+
+ if not oauth_token:
+ failures = self._failure_counts.get(session_id, 0) + 1
+ self._failure_counts[session_id] = failures
+ logger.error(
+ '[%s:token] No OAuth token available for refresh sessionId=%s (failure %d/%d)',
+ self._label,
+ session_id,
+ failures,
+ MAX_REFRESH_FAILURES,
+ )
+ if failures < MAX_REFRESH_FAILURES:
+ loop = asyncio.get_running_loop()
+ handle = loop.call_later(
+ REFRESH_RETRY_DELAY_MS / 1000,
+ self._fire_refresh,
+ session_id,
+ gen,
+ )
+ self._timers[session_id] = handle
+ return
+
+ # Success: reset failure counter; fire the refresh callback;
+ # schedule the follow-up.
+ self._failure_counts.pop(session_id, None)
+ logger.debug(
+ '[%s:token] Refreshing token for sessionId=%s: new prefix=%s...',
+ self._label,
+ session_id,
+ oauth_token[:15],
+ )
+ try:
+ self._on_refresh(session_id, oauth_token)
+ except Exception as exc: # noqa: BLE001 -- never let on_refresh throw kill the chain
+ logger.error('[%s:token] on_refresh raised for sessionId=%s: %s', self._label, session_id, exc)
+
+ # Schedule a follow-up so long-running sessions stay authenticated.
+ loop = asyncio.get_running_loop()
+ handle = loop.call_later(
+ FALLBACK_REFRESH_INTERVAL_MS / 1000,
+ self._fire_refresh,
+ session_id,
+ gen,
+ )
+ self._timers[session_id] = handle
+ logger.debug(
+ '[%s:token] Scheduled follow-up refresh for sessionId=%s in %s',
+ self._label,
+ session_id,
+ _format_duration_ms(FALLBACK_REFRESH_INTERVAL_MS),
+ )
+
+
+__all__ = [
+ 'FALLBACK_REFRESH_INTERVAL_MS',
+ 'GetAccessToken',
+ 'MAX_REFRESH_FAILURES',
+ 'OnRefresh',
+ 'REFRESH_RETRY_DELAY_MS',
+ 'SCHEDULE_FROM_EXPIRES_IN_FLOOR_MS',
+ 'TOKEN_REFRESH_BUFFER_MS',
+ 'TokenRefreshScheduler',
+ 'decode_jwt_expiry',
+ 'decode_jwt_payload',
+]
diff --git a/src/bridge/messaging.py b/src/bridge/messaging.py
new file mode 100644
index 000000000..35bc371f3
--- /dev/null
+++ b/src/bridge/messaging.py
@@ -0,0 +1,402 @@
+"""CCR bridge ingress router + message filters/adapters.
+
+Ports the **router** half (``handle_ingress_message``, type guards,
+``normalize_control_message_keys``) and the **filter/adapter** half
+(``is_eligible_bridge_message``, ``extract_title_text``,
+``make_result_message``, ``RemotePermissionResponse``) of
+``typescript/src/bridge/bridgeMessaging.ts``.
+
+The **server-control-request handler** (``handle_server_control_request``
+plus the per-subtype handler dispatch) lives in
+``src/bridge/messaging_handlers.py`` so the router stays small and stable
+while the handler set evolves.
+
+Concurrency: ingress messages flow on a single asyncio task; the
+``BoundedUUIDSet`` instances passed in must not be shared across tasks
+without external locking.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+import logging
+import re
+import uuid as _uuid
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any, Union
+
+from .bounded_uuid_set import BoundedUUIDSet
+from .sdk_types import (
+ SDKControlRequest,
+ SDKControlResponse,
+ SDKMessage,
+ SDKResultSuccess,
+)
+
+logger = logging.getLogger(__name__)
+
+# ─── Type guards ───────────────────────────────────────────────────────────
+
+
+def is_sdk_message(value: object) -> bool:
+ """True if ``value`` looks like an SDKMessage (has a string ``type``).
+
+ Mirrors ``bridgeMessaging.ts:36-43``. Permissive on purpose — callers
+ narrow further by branching on ``type``.
+ """
+ return (
+ isinstance(value, dict)
+ and 'type' in value
+ and isinstance(value.get('type'), str)
+ )
+
+
+def is_sdk_control_response(value: object) -> bool:
+ """True for ``{type:'control_response', response}``-shaped dicts."""
+ return (
+ isinstance(value, dict)
+ and value.get('type') == 'control_response'
+ and 'response' in value
+ )
+
+
+def is_sdk_control_request(value: object) -> bool:
+ """True for ``{type:'control_request', request_id, request}``-shaped dicts."""
+ return (
+ isinstance(value, dict)
+ and value.get('type') == 'control_request'
+ and 'request_id' in value
+ and 'request' in value
+ )
+
+
+# ─── camelCase ↔ snake_case normalization for control envelopes ────────────
+
+# Hand-ported known-key set from ``typescript/src/utils/controlMessageCompat.ts``
+# (referenced by ``bridgeMessaging.ts:141``). Unknown keys pass through
+# unchanged with a debug log entry.
+_KNOWN_CAMEL_TO_SNAKE: dict[str, str] = {
+ 'requestId': 'request_id',
+ 'toolUseId': 'tool_use_id',
+ 'parentToolUseId': 'parent_tool_use_id',
+ 'controlRequest': 'control_request',
+ 'controlResponse': 'control_response',
+ 'controlCancelRequest': 'control_cancel_request',
+ 'sessionId': 'session_id',
+ 'workerEpoch': 'worker_epoch',
+ 'workerJwt': 'worker_jwt',
+ 'apiBaseUrl': 'api_base_url',
+ 'expiresIn': 'expires_in',
+ 'updatedInput': 'updated_input',
+ 'maxThinkingTokens': 'max_thinking_tokens',
+ 'permissionMode': 'permission_mode',
+ 'toolName': 'tool_name',
+}
+
+
+def normalize_control_message_keys(value: object) -> object:
+ """Walk the wire payload and normalize known camelCase keys to snake_case.
+
+ Recurses into nested dicts and lists. Unknown camelCase keys are
+ passed through unchanged with a one-line debug log so the messaging
+ layer surfaces wire-format drift without silently dropping anything.
+
+ Mirrors ``typescript/src/utils/controlMessageCompat.ts``.
+ """
+ if isinstance(value, list):
+ return [normalize_control_message_keys(item) for item in value]
+ if not isinstance(value, dict):
+ return value
+ out: dict[str, Any] = {}
+ for key, val in value.items():
+ normalized_key = _KNOWN_CAMEL_TO_SNAKE.get(key, key)
+ if normalized_key == key and _looks_camel_case(key):
+ logger.debug(
+ '[bridge:messaging] Unknown camelCase key passed through unchanged: %s',
+ key,
+ )
+ out[normalized_key] = normalize_control_message_keys(val)
+ return out
+
+
+def _looks_camel_case(key: str) -> bool:
+ """Heuristic: contains an internal uppercase letter and starts lowercase."""
+ return bool(key) and key[0].islower() and any(c.isupper() for c in key[1:])
+
+
+# ─── Ingress router ────────────────────────────────────────────────────────
+
+
+def handle_ingress_message(
+ data: str,
+ recent_posted_uuids: BoundedUUIDSet,
+ recent_inbound_uuids: BoundedUUIDSet,
+ on_inbound_message: Callable[[dict[str, Any]], Union[None, Awaitable[None]]] | None = None,
+ on_permission_response: Callable[[dict[str, Any]], None] | None = None,
+ on_control_request: Callable[[dict[str, Any]], None] | None = None,
+) -> None:
+ """Parse an ingress WebSocket message and route it to the right handler.
+
+ Mirrors ``bridgeMessaging.ts:132-208``.
+
+ Routing rules:
+ 1. Parse JSON (silently drop on parse error — log only).
+ 2. Normalize control-message keys.
+ 3. ``control_response`` → ``on_permission_response``.
+ 4. ``control_request`` → ``on_control_request``.
+ 5. UUID dedup: drop if in ``recent_posted_uuids`` (echo of our own
+ write) or ``recent_inbound_uuids`` (re-delivery from history
+ replay).
+ 6. ``user`` SDKMessage → ``on_inbound_message`` (and add UUID to
+ ``recent_inbound_uuids``).
+ 7. Other SDK message types → log + drop (server only wants user
+ turns on the read side; we filter in
+ ``is_eligible_bridge_message`` for the write side).
+ """
+ try:
+ raw = json.loads(data)
+ except json.JSONDecodeError as exc:
+ logger.debug('[bridge:messaging] Failed to parse ingress message: %s', exc)
+ return
+
+ parsed = normalize_control_message_keys(raw)
+
+ if is_sdk_control_response(parsed):
+ logger.debug('[bridge:messaging] Ingress message type=control_response')
+ if on_permission_response is not None:
+ on_permission_response(parsed) # type: ignore[arg-type]
+ return
+
+ if is_sdk_control_request(parsed):
+ subtype = parsed.get('request', {}).get('subtype', '') # type: ignore[union-attr]
+ logger.debug('[bridge:messaging] Inbound control_request subtype=%s', subtype)
+ if on_control_request is not None:
+ on_control_request(parsed) # type: ignore[arg-type]
+ return
+
+ if not is_sdk_message(parsed):
+ return
+
+ msg = parsed # narrowed to dict by is_sdk_message
+ msg_uuid = msg.get('uuid') if isinstance(msg.get('uuid'), str) else None # type: ignore[union-attr]
+
+ if msg_uuid is not None and recent_posted_uuids.has(msg_uuid):
+ logger.debug(
+ '[bridge:messaging] Ignoring echo: type=%s uuid=%s',
+ msg.get('type'), # type: ignore[union-attr]
+ msg_uuid,
+ )
+ return
+
+ if msg_uuid is not None and recent_inbound_uuids.has(msg_uuid):
+ logger.debug(
+ '[bridge:messaging] Ignoring re-delivered inbound: type=%s uuid=%s',
+ msg.get('type'), # type: ignore[union-attr]
+ msg_uuid,
+ )
+ return
+
+ msg_type = msg.get('type') # type: ignore[union-attr]
+ logger.debug(
+ '[bridge:messaging] Ingress message type=%s%s',
+ msg_type,
+ f' uuid={msg_uuid}' if msg_uuid else '',
+ )
+
+ if msg_type == 'user':
+ if msg_uuid is not None:
+ recent_inbound_uuids.add(msg_uuid)
+ if on_inbound_message is not None:
+ result = on_inbound_message(msg) # type: ignore[arg-type]
+ # If the handler is async, fire-and-forget on the running loop.
+ # Matches TS ``void onInboundMessage?.(parsed)`` discard.
+ # Caller MUST invoke ``handle_ingress_message`` from inside a
+ # running asyncio loop when ``on_inbound_message`` is async;
+ # use ``get_running_loop`` (raises RuntimeError if no loop)
+ # rather than the deprecated ``get_event_loop``.
+ if inspect.isawaitable(result):
+ asyncio.get_running_loop().create_task(result) # type: ignore[arg-type]
+ else:
+ logger.debug(
+ '[bridge:messaging] Ignoring non-user inbound message: type=%s',
+ msg_type,
+ )
+
+
+# ─── Forward-filter for outbound bridge messages ───────────────────────────
+
+
+def is_eligible_bridge_message(message: dict[str, Any]) -> bool:
+ """True if ``message`` should be forwarded to the bridge transport.
+
+ Mirrors ``bridgeMessaging.ts:77-88``: filters out virtual REPL
+ inner-call messages, tool_results, progress, non-human origins, etc.
+ Forwards user/assistant turns and ``system`` messages of subtype
+ ``local_command``.
+ """
+ msg_type = message.get('type')
+ if msg_type in ('user', 'assistant'):
+ if message.get('isVirtual'):
+ return False
+ return True
+ if msg_type == 'system':
+ return message.get('subtype') == 'local_command'
+ return False
+
+
+# Mirror of TS ``utils/displayTags.ts:14`` ``XML_TAG_BLOCK_PATTERN`` —
+# lowercase-only opening (so user prose like "" passes through),
+# backreference for the closing tag (so adjacent blocks don't merge and
+# mismatched ``x`` is left alone), optional attributes,
+# multi-line content (``[\s\S]`` is the JS equivalent of ``re.DOTALL``).
+# Trailing ``\n?`` strips the newline that wraps system-injected blocks.
+_DISPLAY_TAG_RE = re.compile(r'<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?\1>\n?')
+
+
+def _strip_display_tags(text: str) -> str:
+ """Strip XML-like display tags from ``text``.
+
+ Equivalent to TS ``stripDisplayTagsAllowEmpty`` (``displayTags.ts:37``)
+ — returns empty string when all content is tags. Used by
+ ``extract_title_text`` to skip pure-XML messages during bridge title
+ derivation.
+ """
+ return _DISPLAY_TAG_RE.sub('', text).strip()
+
+
+def extract_title_text(message: dict[str, Any]) -> str | None:
+ """Extract title-worthy text from a Message for ``onUserMessage``.
+
+ Returns None 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.
+
+ Mirrors ``bridgeMessaging.ts:103-122``.
+ """
+ if message.get('type') != 'user':
+ return None
+ # Match JS truthy semantics: ``isMeta``/``isCompactSummary`` are
+ # bool flags (False/None means "not meta"), but ``toolUseResult`` is
+ # a dict where even ``{}`` should disqualify (in JS ``{}`` is truthy;
+ # in Python ``{}`` is falsy, so we check for *presence*, not truthiness).
+ if message.get('isMeta'):
+ return None
+ if 'toolUseResult' in message and message.get('toolUseResult') is not None:
+ return None
+ if message.get('isCompactSummary'):
+ return None
+ origin = message.get('origin')
+ if isinstance(origin, dict) and origin.get('kind') != 'human':
+ return None
+
+ inner = message.get('message') or {}
+ content = inner.get('content') if isinstance(inner, dict) else None
+ raw: str | None
+ if isinstance(content, str):
+ raw = content
+ elif isinstance(content, list):
+ raw = None
+ for block in content:
+ if isinstance(block, dict) and block.get('type') == 'text':
+ raw = block.get('text')
+ if isinstance(raw, str):
+ break
+ raw = None
+ else:
+ raw = None
+
+ if not raw:
+ return None
+ cleaned = _strip_display_tags(raw)
+ return cleaned or None
+
+
+# ─── Result message (for session archival on teardown) ─────────────────────
+
+
+def make_result_message(session_id: str) -> SDKResultSuccess:
+ """Build a minimal ``SDKResultSuccess`` for session archival.
+
+ The server needs this event before a WS close to trigger archival.
+ Mirrors ``bridgeMessaging.ts:399-416``.
+ """
+ return {
+ 'type': 'result',
+ 'subtype': 'success',
+ 'duration_ms': 0,
+ 'duration_api_ms': 0,
+ 'is_error': False,
+ 'num_turns': 0,
+ 'result': '',
+ 'stop_reason': None,
+ 'total_cost_usd': 0.0,
+ 'usage': {},
+ 'modelUsage': {},
+ 'permission_denials': [],
+ 'session_id': session_id,
+ 'uuid': str(_uuid.uuid4()),
+ }
+
+
+# ─── Remote permission response (Phase 4 + Direct Connect consumer) ────────
+
+
+@dataclass(frozen=True)
+class AllowResponse:
+ """Permission allow with possibly-rewritten input."""
+
+ updated_input: dict[str, Any] = field(default_factory=dict)
+ behavior: str = 'allow'
+
+
+@dataclass(frozen=True)
+class DenyResponse:
+ """Permission deny with a user-visible message."""
+
+ message: str
+ behavior: str = 'deny'
+
+
+# Discriminated union — callers branch on ``isinstance(...)``.
+RemotePermissionResponse = Union[AllowResponse, DenyResponse]
+
+
+def remote_permission_response_from_dict(payload: dict[str, Any]) -> RemotePermissionResponse:
+ """Wire-format → ``RemotePermissionResponse``.
+
+ Mirrors ``RemotePermissionResponse`` discriminated union from
+ ``remote/RemoteSessionManager.ts:40-48``.
+ """
+ behavior = payload.get('behavior')
+ if behavior == 'allow':
+ updated = payload.get('updated_input') or payload.get('updatedInput') or {}
+ if not isinstance(updated, dict):
+ raise ValueError(
+ f'allow response: updated_input must be dict, got {type(updated).__name__}'
+ )
+ return AllowResponse(updated_input=updated)
+ if behavior == 'deny':
+ msg = payload.get('message')
+ if not isinstance(msg, str):
+ raise ValueError('deny response: message must be a string')
+ return DenyResponse(message=msg)
+ raise ValueError(f'unknown permission behavior: {behavior!r}')
+
+
+__all__ = [
+ 'AllowResponse',
+ 'DenyResponse',
+ 'RemotePermissionResponse',
+ 'extract_title_text',
+ 'handle_ingress_message',
+ 'is_eligible_bridge_message',
+ 'is_sdk_control_request',
+ 'is_sdk_control_response',
+ 'is_sdk_message',
+ 'make_result_message',
+ 'normalize_control_message_keys',
+ 'remote_permission_response_from_dict',
+]
diff --git a/src/bridge/messaging_handlers.py b/src/bridge/messaging_handlers.py
new file mode 100644
index 000000000..8c844e03f
--- /dev/null
+++ b/src/bridge/messaging_handlers.py
@@ -0,0 +1,261 @@
+"""Server-initiated control_request handler.
+
+Ports ``handle_server_control_request`` from
+``typescript/src/bridge/bridgeMessaging.ts:243-391`` plus the per-subtype
+dispatch (``initialize``, ``set_model``, ``set_max_thinking_tokens``,
+``set_permission_mode``, ``interrupt``) AND the **unknown-subtype error
+response default** + outbound-only error response.
+
+Lives in a separate file from ``messaging.py`` so the router stays small
+and stable while the handler set evolves.
+
+Dispatch flow:
+ server WS → handle_ingress_message (router)
+ → on_control_request callback
+ → handle_server_control_request (this module)
+ → per-subtype handler (callbacks supplied by ``handlers``)
+ → transport.write(control_response)
+"""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+import logging
+import os
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any, Protocol, Union
+
+logger = logging.getLogger(__name__)
+
+OUTBOUND_ONLY_ERROR = (
+ 'This session is outbound-only. Enable Remote Control locally to allow '
+ 'inbound control.'
+)
+
+
+# ─── Verdict type for set_permission_mode (WI-3.7a wiring point) ───────────
+
+
+@dataclass(frozen=True)
+class Ok:
+ """Verdict: policy permitted the mode change."""
+
+
+@dataclass(frozen=True)
+class Err:
+ """Verdict: policy rejected the mode change with a user-visible reason."""
+
+ error: str
+
+
+Verdict = Union[Ok, Err]
+
+
+# ─── Transport surface this module needs ───────────────────────────────────
+
+
+class _TransportLike(Protocol):
+ """Minimal write-side surface used by ``handle_server_control_request``.
+
+ Real implementations (``ReplBridgeTransport``,
+ ``DirectConnectSessionManager``) provide more; this Protocol pins
+ what we depend on.
+
+ **Sync OR async write is supported.** TS ``ReplBridgeTransport.write``
+ is async (returns ``Promise`` per
+ ``replBridgeTransport.ts:24``); TS consumers fire-and-forget with
+ ``void transport.write(event)``. The Python equivalent: if ``write``
+ returns an awaitable, ``_send`` schedules it on the running loop via
+ ``asyncio.create_task``. If it returns ``None`` (sync), nothing extra
+ happens. Callers MUST invoke ``handle_server_control_request`` from
+ inside a running loop when the transport's ``write`` is async.
+ """
+
+ def write( # pragma: no cover -- structural
+ self, message: dict[str, Any]
+ ) -> Any:
+ ...
+
+
+# ─── Handlers struct ───────────────────────────────────────────────────────
+
+
+@dataclass
+class ServerControlRequestHandlers:
+ """Per-callsite wiring for the 5 control_request subtypes.
+
+ Mirrors the TS ``ServerControlRequestHandlers`` type at
+ ``bridgeMessaging.ts:212-229``.
+
+ Required fields:
+ transport — the write surface the response is sent through.
+ session_id — included in every emitted control_response.
+
+ Optional callbacks (None means "subtype not supported here"):
+ on_interrupt — called for ``interrupt``; success response is sent.
+ on_set_model — called for ``set_model(model)``; success response.
+ on_set_max_thinking_tokens — called for that subtype; success.
+ on_set_permission_mode — called for ``set_permission_mode(mode)``;
+ **must return** ``Ok()`` or ``Err(error=...)``. If None, the
+ handler returns ``Err`` with a "not supported in this context"
+ message — the chapter-prescribed unknown-handler pattern at
+ ``bridgeMessaging.ts:336-340``. WI-3.7b will replace the default
+ with real policy gates once those land.
+
+ outbound_only — when True, all mutable subtypes (everything except
+ ``initialize``) reply with an error so claude.ai sees a proper error
+ instead of false-success. ``initialize`` still succeeds — the server
+ kills the connection within 10-14 s otherwise.
+ """
+
+ transport: _TransportLike
+ session_id: str
+ outbound_only: bool = False
+ on_interrupt: Callable[[], None] | None = None
+ on_set_model: Callable[[str | None], None] | None = None
+ on_set_max_thinking_tokens: Callable[[int | None], None] | None = None
+ on_set_permission_mode: Callable[[str], Verdict] | None = None
+
+
+# ─── Main dispatch ─────────────────────────────────────────────────────────
+
+
+def handle_server_control_request(
+ request: dict[str, Any],
+ handlers: ServerControlRequestHandlers,
+) -> None:
+ """Dispatch a server-initiated ``control_request`` message.
+
+ Mirrors ``bridgeMessaging.ts:243-391``. Sends exactly one
+ ``control_response`` via ``handlers.transport.write`` per call. Never
+ silent — unknown subtypes get an error response so the server doesn't
+ hang waiting (~10-14 s timeout before WS kill).
+ """
+ inner = request.get('request') or {}
+ if not isinstance(inner, dict):
+ logger.debug('[bridge:messaging] control_request.request is not a dict; ignoring')
+ return
+ request_id = request.get('request_id', '')
+ subtype = inner.get('subtype')
+
+ response_inner: dict[str, Any]
+
+ # 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).
+ if handlers.outbound_only and subtype != 'initialize':
+ response_inner = {
+ 'subtype': 'error',
+ 'request_id': request_id,
+ 'error': OUTBOUND_ONLY_ERROR,
+ }
+ _send(handlers, response_inner, subtype, kind='outbound-only')
+ return
+
+ if subtype == 'initialize':
+ # Minimal capabilities — the REPL handles commands/models/account
+ # info itself.
+ response_inner = {
+ 'subtype': 'success',
+ 'request_id': request_id,
+ 'response': {
+ 'commands': [],
+ 'output_style': 'normal',
+ 'available_output_styles': ['normal'],
+ 'models': [],
+ 'account': {},
+ 'pid': os.getpid(),
+ },
+ }
+ elif subtype == 'set_model':
+ if handlers.on_set_model is not None:
+ handlers.on_set_model(inner.get('model'))
+ response_inner = {'subtype': 'success', 'request_id': request_id}
+ elif subtype == 'set_max_thinking_tokens':
+ if handlers.on_set_max_thinking_tokens is not None:
+ handlers.on_set_max_thinking_tokens(inner.get('max_thinking_tokens'))
+ response_inner = {'subtype': 'success', 'request_id': request_id}
+ elif subtype == 'set_permission_mode':
+ # The callback returns a Verdict so we can send an error
+ # control_response without importing policy gates here.
+ # If no callback is registered (daemon context, which doesn't
+ # wire this), return an error verdict rather than silent
+ # false-success — the chapter-prescribed pattern at
+ # bridgeMessaging.ts:336-340 (WI-3.7b).
+ mode = inner.get('mode', '')
+ if handlers.on_set_permission_mode is None:
+ verdict: Verdict = Err(
+ error=(
+ 'set_permission_mode is not supported in this context '
+ '(on_set_permission_mode callback not registered)'
+ )
+ )
+ else:
+ verdict = handlers.on_set_permission_mode(mode)
+ if isinstance(verdict, Ok):
+ response_inner = {'subtype': 'success', 'request_id': request_id}
+ else:
+ response_inner = {
+ 'subtype': 'error',
+ 'request_id': request_id,
+ 'error': verdict.error,
+ }
+ elif subtype == 'interrupt':
+ if handlers.on_interrupt is not None:
+ handlers.on_interrupt()
+ response_inner = {'subtype': 'success', 'request_id': request_id}
+ else:
+ # Unknown subtype — respond with error so the server doesn't
+ # hang waiting for a reply that never comes. Chapter explicit
+ # pattern at bridgeMessaging.ts:373-384.
+ response_inner = {
+ 'subtype': 'error',
+ 'request_id': request_id,
+ 'error': f'REPL bridge does not handle control_request subtype: {subtype}',
+ }
+
+ _send(handlers, response_inner, subtype, kind='dispatch')
+
+
+def _send(
+ handlers: ServerControlRequestHandlers,
+ response_inner: dict[str, Any],
+ subtype: object,
+ *,
+ kind: str,
+) -> None:
+ """Build the ``control_response`` envelope and write it.
+
+ Supports both sync and async ``transport.write``. If ``write``
+ returns an awaitable (the TS-equivalent path), schedule it on the
+ running loop via ``create_task`` — matches TS's
+ ``void transport.write(event)`` fire-and-forget at
+ ``bridgeMessaging.ts:278, 387``.
+ """
+ envelope: dict[str, Any] = {
+ 'type': 'control_response',
+ 'response': response_inner,
+ 'session_id': handlers.session_id,
+ }
+ result = handlers.transport.write(envelope)
+ if inspect.isawaitable(result):
+ asyncio.get_running_loop().create_task(result)
+ logger.debug(
+ '[bridge:messaging] Sent control_response (%s) for %s request_id=%s result=%s',
+ kind,
+ subtype,
+ response_inner.get('request_id'),
+ response_inner.get('subtype'),
+ )
+
+
+__all__ = [
+ 'Err',
+ 'OUTBOUND_ONLY_ERROR',
+ 'Ok',
+ 'ServerControlRequestHandlers',
+ 'Verdict',
+ 'handle_server_control_request',
+]
diff --git a/src/bridge/no_proxy.py b/src/bridge/no_proxy.py
new file mode 100644
index 000000000..2da29e41a
--- /dev/null
+++ b/src/bridge/no_proxy.py
@@ -0,0 +1,50 @@
+"""NO_PROXY allowlist for the CCR upstream proxy (Phase 5).
+
+Mirrors ``typescript/src/upstreamproxy/upstreamproxy.ts:37-63`` — the
+exact ordering of the three Anthropic forms matters:
+``'anthropic.com'`` (apex/Python urllib suffix), then
+``'.anthropic.com'`` (Python urllib also strips leading dot), then
+``'*.anthropic.com'`` (Bun, curl, Go glob match).
+
+If any of the three forms is missing, runtimes that parse NO_PROXY
+strictly will MITM the model API itself, breaking inference. Hence
+the explicit triple form rather than relying on glob matching.
+"""
+
+from __future__ import annotations
+
+# Order matters — see module docstring for the rationale on the three
+# Anthropic forms specifically.
+NO_PROXY_LIST: tuple[str, ...] = (
+ 'localhost',
+ '127.0.0.1',
+ '::1',
+ '169.254.0.0/16', # IMDS
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16',
+ # Anthropic API: no upstream route will ever match, and the MITM
+ # breaks non-Bun runtimes (Python httpx/certifi doesn't trust the
+ # forged CA). Three forms because NO_PROXY parsing differs across
+ # runtimes — see module docstring.
+ 'anthropic.com',
+ '.anthropic.com',
+ '*.anthropic.com',
+ 'github.com',
+ 'api.github.com',
+ '*.github.com',
+ '*.githubusercontent.com',
+ 'registry.npmjs.org',
+ 'pypi.org',
+ 'files.pythonhosted.org',
+ 'index.crates.io',
+ 'proxy.golang.org',
+)
+
+
+def default_no_proxy() -> str:
+ """Comma-joined ``NO_PROXY`` string for the upstream proxy env."""
+ return ','.join(NO_PROXY_LIST)
+
+
+__all__ = ['NO_PROXY_LIST', 'default_no_proxy']
diff --git a/src/bridge/poll_config.py b/src/bridge/poll_config.py
new file mode 100644
index 000000000..1b4d18a07
--- /dev/null
+++ b/src/bridge/poll_config.py
@@ -0,0 +1,186 @@
+"""Bridge poll-loop interval validator + accessor.
+
+Ports ``typescript/src/bridge/pollConfig.ts``.
+
+Pydantic v2 schema for the bridge poll-interval config served via the
+``tengu_bridge_poll_interval_config`` GrowthBook feature. The schema has
+two cross-field validators that enforce **at-capacity liveness**:
+
+* (single-session) heartbeat > 0 OR ``poll_interval_ms_at_capacity`` > 0
+* (multisession) heartbeat > 0 OR ``multisession_poll_interval_ms_at_capacity`` > 0
+
+Without those refinements, a fat-fingered config that disables both
+heartbeat AND at-capacity poll would tight-loop the bridge's throttle
+sites at HTTP-round-trip speed (no sleep between checks). Same defense
+the TS Zod schema provides via ``.refine()`` on
+``pollConfig.ts:74-91``.
+
+Per refactoring plan §0.1 Q2, GrowthBook is not wired in the Python
+build; ``get_poll_interval_config()`` returns ``DEFAULT_POLL_CONFIG``
+directly. The validator exists for Phase 10 / test code that wants to
+exercise the schema on raw inputs.
+"""
+
+from __future__ import annotations
+
+from typing import Self
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ ValidationError,
+ model_validator,
+)
+
+from src.bridge.poll_config_defaults import (
+ DEFAULT_POLL_CONFIG,
+ PollIntervalConfig,
+)
+
+
+class _PollIntervalSchema(BaseModel):
+ """Pydantic validator that mirrors TS Zod ``pollIntervalConfigSchema``.
+
+ Field-level bounds + cross-field refines + per-field defaults match
+ TS exactly. After validation, ``.to_dataclass()`` converts to the
+ frozen ``PollIntervalConfig`` dataclass that the rest of the codebase
+ consumes (the dataclass is hashable + frozen — properties the poll
+ loop relies on).
+
+ ``poll_interval_ms_at_capacity`` and the multisession at-capacity
+ variant accept 0 (= disabled) OR >= 100; values 1-99 are rejected to
+ catch unit-confusion (ops thinks "seconds", enters 10).
+
+ ``strict=True`` matches TS Zod's no-coercion semantics. See
+ ``env_less_bridge_config.EnvLessBridgeConfig`` for the same rationale.
+ """
+
+ model_config = ConfigDict(strict=True)
+
+ poll_interval_ms_not_at_capacity: int = Field(
+ default=DEFAULT_POLL_CONFIG.poll_interval_ms_not_at_capacity,
+ ge=100,
+ )
+ poll_interval_ms_at_capacity: int = Field(
+ default=DEFAULT_POLL_CONFIG.poll_interval_ms_at_capacity,
+ )
+ non_exclusive_heartbeat_interval_ms: int = Field(
+ default=DEFAULT_POLL_CONFIG.non_exclusive_heartbeat_interval_ms,
+ ge=0,
+ )
+ multisession_poll_interval_ms_not_at_capacity: int = Field(
+ default=DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_not_at_capacity,
+ ge=100,
+ )
+ multisession_poll_interval_ms_partial_capacity: int = Field(
+ default=DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_partial_capacity,
+ ge=100,
+ )
+ multisession_poll_interval_ms_at_capacity: int = Field(
+ default=DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_at_capacity,
+ )
+ reclaim_older_than_ms: int = Field(
+ default=DEFAULT_POLL_CONFIG.reclaim_older_than_ms,
+ ge=1,
+ )
+ session_keepalive_interval_v2_ms: int = Field(
+ default=DEFAULT_POLL_CONFIG.session_keepalive_interval_v2_ms,
+ ge=0,
+ )
+
+ @model_validator(mode='after')
+ def _validate_at_capacity_liveness(self) -> Self:
+ """Enforce the two TS ``.refine()`` cross-field invariants.
+
+ Mirrors TS ``pollConfig.ts:74-91``. Both single-session AND
+ multisession at-capacity loops must have *some* liveness signal:
+ either a positive heartbeat interval or a positive at-capacity
+ poll interval. All-zero is rejected; the caller falls back to
+ ``DEFAULT_POLL_CONFIG`` via ``get_poll_interval_config``.
+ """
+ if (
+ self.non_exclusive_heartbeat_interval_ms == 0
+ and self.poll_interval_ms_at_capacity == 0
+ ):
+ raise ValueError(
+ 'at-capacity liveness requires '
+ 'non_exclusive_heartbeat_interval_ms > 0 or '
+ 'poll_interval_ms_at_capacity > 0'
+ )
+ if (
+ self.non_exclusive_heartbeat_interval_ms == 0
+ and self.multisession_poll_interval_ms_at_capacity == 0
+ ):
+ raise ValueError(
+ 'at-capacity liveness requires '
+ 'non_exclusive_heartbeat_interval_ms > 0 or '
+ 'multisession_poll_interval_ms_at_capacity > 0'
+ )
+ return self
+
+ @model_validator(mode='after')
+ def _validate_at_capacity_zero_or_min(self) -> Self:
+ """``poll_interval_ms_at_capacity`` must be 0 (disabled) OR >= 100.
+
+ Mirrors TS refine on ``pollConfig.ts:34-38`` and ``63-65``. Values
+ 1-99 are rejected as "unit confusion".
+ """
+ v = self.poll_interval_ms_at_capacity
+ if v != 0 and v < 100:
+ raise ValueError(
+ 'poll_interval_ms_at_capacity must be 0 (disabled) or ≥100ms'
+ )
+ v = self.multisession_poll_interval_ms_at_capacity
+ if v != 0 and v < 100:
+ raise ValueError(
+ 'multisession_poll_interval_ms_at_capacity must be 0 '
+ '(disabled) or ≥100ms'
+ )
+ return self
+
+ def to_dataclass(self) -> PollIntervalConfig:
+ """Convert the validated schema model into the frozen dataclass."""
+ return PollIntervalConfig(
+ poll_interval_ms_not_at_capacity=self.poll_interval_ms_not_at_capacity,
+ poll_interval_ms_at_capacity=self.poll_interval_ms_at_capacity,
+ non_exclusive_heartbeat_interval_ms=self.non_exclusive_heartbeat_interval_ms,
+ multisession_poll_interval_ms_not_at_capacity=self.multisession_poll_interval_ms_not_at_capacity,
+ multisession_poll_interval_ms_partial_capacity=self.multisession_poll_interval_ms_partial_capacity,
+ multisession_poll_interval_ms_at_capacity=self.multisession_poll_interval_ms_at_capacity,
+ reclaim_older_than_ms=self.reclaim_older_than_ms,
+ session_keepalive_interval_v2_ms=self.session_keepalive_interval_v2_ms,
+ )
+
+
+def get_poll_interval_config() -> PollIntervalConfig:
+ """Return the validated poll-interval config.
+
+ Mirrors TS ``getPollIntervalConfig`` on ``pollConfig.ts:102-110``.
+ The TS version fetches ``tengu_bridge_poll_interval_config`` from
+ GrowthBook and validates it; on failure, falls back to defaults.
+ Python returns the defaults directly — no GrowthBook in this build.
+ """
+ return DEFAULT_POLL_CONFIG
+
+
+def validate_poll_interval_config_raw(raw: object) -> PollIntervalConfig:
+ """Validate a raw dict against the schema; fall back to defaults on error.
+
+ Public helper for downstream callers (Phase 10 GrowthBook integration
+ or tests) that have a raw payload and want TS Zod's "reject the whole
+ object on any field violation" semantics.
+ """
+ if not isinstance(raw, dict):
+ return DEFAULT_POLL_CONFIG
+ try:
+ model = _PollIntervalSchema.model_validate(raw)
+ except ValidationError:
+ return DEFAULT_POLL_CONFIG
+ return model.to_dataclass()
+
+
+__all__ = [
+ 'get_poll_interval_config',
+ 'validate_poll_interval_config_raw',
+]
diff --git a/src/bridge/poll_config_defaults.py b/src/bridge/poll_config_defaults.py
new file mode 100644
index 000000000..3d5e3b5cc
--- /dev/null
+++ b/src/bridge/poll_config_defaults.py
@@ -0,0 +1,95 @@
+"""Bridge poll interval default constants.
+
+Ports ``typescript/src/bridge/pollConfigDefaults.ts``.
+
+Extracted from ``poll_config.py`` (Phase 2) so callers that don't need live
+GrowthBook tuning (e.g. daemon via Agent SDK) can import the defaults
+without pulling in the schema validation layer. Matches TS organization on
+``pollConfigDefaults.ts:1-7``.
+
+Numeric values match TS exactly; tests in ``test_poll_config_defaults.py``
+assert this for every field.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+# ---------------------------------------------------------------------------
+# Numeric constants
+# ---------------------------------------------------------------------------
+
+POLL_INTERVAL_MS_NOT_AT_CAPACITY: int = 2000
+"""Poll interval when actively seeking work (no transport / below maxSessions).
+
+Mirrors TS ``POLL_INTERVAL_MS_NOT_AT_CAPACITY`` on ``pollConfigDefaults.ts:13``.
+"""
+
+POLL_INTERVAL_MS_AT_CAPACITY: int = 600_000
+"""Poll interval when the transport is connected. 10 minutes.
+
+Mirrors TS ``POLL_INTERVAL_MS_AT_CAPACITY`` on ``pollConfigDefaults.ts:30``.
+Bounded by the server's BRIDGE_LAST_POLL_TTL (4h) and max_poll_stale_seconds
+(24h); 10min gives 24× headroom on the Redis TTL.
+"""
+
+MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY: int = POLL_INTERVAL_MS_NOT_AT_CAPACITY
+MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY: int = POLL_INTERVAL_MS_NOT_AT_CAPACITY
+MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY: int = POLL_INTERVAL_MS_AT_CAPACITY
+
+
+# ---------------------------------------------------------------------------
+# Config dataclass
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class PollIntervalConfig:
+ """Tunable poll-loop intervals for the bridge.
+
+ Mirrors TS ``PollIntervalConfig`` on ``pollConfigDefaults.ts:44-53``.
+ Frozen because instances are shared across the poll loop (single source
+ of truth per call); mutations would race with the loop reading the
+ fields.
+
+ Field naming follows the TS wire-format snake_case (matches what
+ GrowthBook served when v1 was production-tuned).
+ """
+
+ poll_interval_ms_not_at_capacity: int
+ poll_interval_ms_at_capacity: int
+ non_exclusive_heartbeat_interval_ms: int
+ multisession_poll_interval_ms_not_at_capacity: int
+ multisession_poll_interval_ms_partial_capacity: int
+ multisession_poll_interval_ms_at_capacity: int
+ reclaim_older_than_ms: int
+ session_keepalive_interval_v2_ms: int
+
+
+DEFAULT_POLL_CONFIG: PollIntervalConfig = PollIntervalConfig(
+ poll_interval_ms_not_at_capacity=POLL_INTERVAL_MS_NOT_AT_CAPACITY,
+ poll_interval_ms_at_capacity=POLL_INTERVAL_MS_AT_CAPACITY,
+ 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,
+ reclaim_older_than_ms=5000,
+ session_keepalive_interval_v2_ms=120_000,
+)
+"""Default poll config matching TS ``DEFAULT_POLL_CONFIG``.
+
+See TS comments at ``pollConfigDefaults.ts:55-82`` for the rationale behind
+each value (heartbeat disabled by default, 5s reclaim matches server
+DEFAULT_RECLAIM_OLDER_THAN_MS, 2min keepalive prevents upstream-proxy GC).
+"""
+
+
+__all__ = [
+ 'DEFAULT_POLL_CONFIG',
+ 'MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY',
+ 'MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY',
+ 'MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY',
+ 'POLL_INTERVAL_MS_AT_CAPACITY',
+ 'POLL_INTERVAL_MS_NOT_AT_CAPACITY',
+ 'PollIntervalConfig',
+]
diff --git a/src/bridge/protojson.py b/src/bridge/protojson.py
new file mode 100644
index 000000000..d8770138b
--- /dev/null
+++ b/src/bridge/protojson.py
@@ -0,0 +1,63 @@
+"""Protojson-encoding helpers for CCR v2 wire-format quirks.
+
+Per gap analysis §1 #21 universal rule: every CCR v2 endpoint that
+returns an int64 may arrive as **string** OR **number** depending on the
+encoder settings. ``protojson`` (Go) serializes int64 as string by
+default to avoid JS precision loss; some endpoints serialize as number.
+
+This helper normalizes both shapes into Python ``int``. Used by
+``register_worker`` (WI-3.4) and ``fetch_remote_credentials`` (WI-3.3).
+
+**Bounds:** matches TS ``Number.isSafeInteger`` (``±(2^53 - 1)``), NOT
+the int64 range Python could otherwise represent. Wire-format symmetry
+with the JS encoder is more important than Python's larger-int capacity:
+the field is constrained at the JS-side encoder anyway, so accepting
+larger values in Python would create a Python↔TS interop divergence the
+server-side schema does not guarantee.
+"""
+
+from __future__ import annotations
+
+# Per JS Number.isSafeInteger:
+# Number.MAX_SAFE_INTEGER = 2^53 - 1
+# Number.MIN_SAFE_INTEGER = -(2^53 - 1)
+SAFE_INTEGER_MIN = -((2**53) - 1)
+SAFE_INTEGER_MAX = (2**53) - 1
+
+# Aliases kept for callers that prefer the int64 naming. Same values as
+# the safe-integer pair so the wire-format symmetry holds. If a future
+# server endpoint truly needs the full int64 range, add a separate
+# ``coerce_full_int64`` helper rather than widening these.
+INT64_MIN = SAFE_INTEGER_MIN
+INT64_MAX = SAFE_INTEGER_MAX
+
+
+def coerce_int64(value: object) -> int:
+ """Return ``value`` as a Python ``int``, accepting string or int input.
+
+ Raises ``ValueError`` if the value is neither, or if the parsed result
+ overflows ``Number.isSafeInteger`` bounds (``±(2^53 - 1)``). Matches
+ TS ``Number.isSafeInteger`` semantics for wire-format symmetry; see
+ module docstring for the rationale.
+ """
+ if isinstance(value, bool):
+ # bool is a subclass of int in Python; reject explicitly to avoid
+ # accepting True/False as 1/0 from a wire payload.
+ raise ValueError(f'coerce_int64: refusing bool: {value!r}')
+ if isinstance(value, int):
+ parsed = value
+ elif isinstance(value, str):
+ try:
+ parsed = int(value, 10)
+ except ValueError as exc:
+ raise ValueError(f'coerce_int64: not a base-10 integer string: {value!r}') from exc
+ else:
+ raise ValueError(f'coerce_int64: unsupported type {type(value).__name__}: {value!r}')
+ if not (SAFE_INTEGER_MIN <= parsed <= SAFE_INTEGER_MAX):
+ raise ValueError(
+ f'coerce_int64: out of safe-integer range (TS Number.isSafeInteger): {parsed}'
+ )
+ return parsed
+
+
+__all__ = ['INT64_MAX', 'INT64_MIN', 'SAFE_INTEGER_MAX', 'SAFE_INTEGER_MIN', 'coerce_int64']
diff --git a/src/bridge/remote_bridge_core.py b/src/bridge/remote_bridge_core.py
new file mode 100644
index 000000000..92d56e1b1
--- /dev/null
+++ b/src/bridge/remote_bridge_core.py
@@ -0,0 +1,1176 @@
+"""Env-less Remote Control bridge core — Phase 5 MVP.
+
+Ports ``typescript/src/bridge/remoteBridgeCore.ts``.
+
+"Env-less" means no Environments API layer — this connects directly to
+the session-ingress (CCR v2) layer:
+
+ 1. POST ``/v1/code/sessions`` → ``cse_*`` session id
+ 2. POST ``/v1/code/sessions/{id}/bridge`` → worker JWT + epoch + TTL
+ 3. ``create_v2_repl_transport`` → SSE reads + CCRClient writes
+ 4. ``TokenRefreshScheduler`` → proactive ``/bridge`` re-call before expiry
+ 5. 401 on SSE → re-fetch ``/bridge`` and rebuild transport
+
+No register/poll/ack/stop/heartbeat/deregister environment lifecycle.
+Each ``/bridge`` call bumps ``worker_epoch`` server-side, so any refresh
+path must rebuild the transport (a JWT-only swap leaves the old
+CCRClient heartbeating with a stale epoch → 409 within 20s).
+
+**MVP scope** (this Phase 5 port intentionally defers a few things):
+
+* **No connect-timeout telemetry** — TS arms a ``setTimeout`` that
+ emits ``tengu_bridge_repl_connect_timeout`` if neither onConnect nor
+ onClose fires before ``cfg.connect_timeout_ms``. The Python port logs
+ the deadline as a debug warning instead; analytics wiring lands in
+ Phase 10.
+* **No CCR mirror-mode telemetry** — the ``CCR_MIRROR`` feature flag
+ branches on telemetry event names; we route everything through
+ ``tengu_bridge_repl_*`` for now.
+* **Trusted-device token** — passes ``None`` (no Phase 10 keychain).
+* **``ConnectCause`` enum** — kept as a string for log clarity but not
+ used for telemetry discriminator (no analytics in this build).
+
+What IS ported in full:
+
+* OAuth → ``/code/sessions`` → ``/bridge`` init with retry+jitter
+* v2 transport build (SSE + CCRClient via existing factory)
+* FlushGate + dual-set UUID dedup (echo + re-delivery)
+* Proactive JWT refresh via ``TokenRefreshScheduler`` (epoch-bumping
+ rebuild on fire)
+* 401 SSE recovery (token refresh + rebuild)
+* ``ReplBridgeHandle``-style return surface: ``write_messages``,
+ ``write_sdk_messages``, ``send_control_request``,
+ ``send_control_response``, ``send_cancel_request``, ``send_result``,
+ ``teardown``
+* Idempotent teardown: cancel scheduler → drop gate → reportState idle →
+ write result → archive (with 401 retry) → close
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import random
+from dataclasses import dataclass, field
+from typing import Any, Awaitable, Callable
+
+import httpx
+
+from src.bridge.bounded_uuid_set import BoundedUUIDSet
+from src.bridge.code_session_api import (
+ RemoteCredentials,
+ create_code_session,
+ fetch_remote_credentials,
+)
+from src.bridge.env_less_bridge_config import (
+ DEFAULT_ENV_LESS_BRIDGE_CONFIG,
+ EnvLessBridgeConfig,
+ get_env_less_bridge_config,
+)
+from src.bridge.flush_gate import FlushGate
+from src.bridge.jwt_utils import TokenRefreshScheduler
+from src.bridge.messaging import (
+ extract_title_text,
+ handle_ingress_message,
+ is_eligible_bridge_message,
+ make_result_message,
+)
+from src.bridge.messaging_handlers import (
+ ServerControlRequestHandlers,
+ handle_server_control_request,
+)
+from src.bridge.repl_bridge_transport import (
+ ReplBridgeTransport,
+ V2TransportOptions,
+ create_v2_repl_transport,
+)
+from src.bridge.session_id_compat import to_compat_session_id
+from src.bridge.work_secret import build_ccr_v2_sdk_url
+from src.types.messages import Message
+from src.utils.message_mappers import to_sdk_messages
+
+logger = logging.getLogger(__name__)
+
+
+_TEARDOWN_RESULT_WRITE_TIMEOUT_SECONDS = 0.5
+"""Cap on the teardown result-write enqueue wait. ``gracefulShutdown``
+races teardown against a 2s budget; 0.5s leaves headroom for archive +
+close while still containing back-pressure stalls."""
+
+
+# ── Public types ──────────────────────────────────────────────────────────
+
+
+BridgeState = str
+"""Lifecycle states emitted via ``on_state_change``: ``'ready'``,
+``'connected'``, ``'reconnecting'``, ``'failed'``. Kept as ``str`` to
+stay compatible with Phase 6+ orchestrators that share the same union.
+"""
+
+
+OnInboundMessage = Callable[[dict[str, Any]], Any]
+OnUserMessage = Callable[[str, str], bool]
+OnPermissionResponse = Callable[[dict[str, Any]], None]
+OnInterrupt = Callable[[], None]
+OnSetModel = Callable[[str | None], None]
+OnSetMaxThinkingTokens = Callable[[int | None], None]
+OnSetPermissionMode = Callable[[str], Any]
+OnStateChange = Callable[..., None]
+"""``on_state_change(state, detail=None)`` — kept as ``...`` so call
+sites can omit detail."""
+
+OnAuth401 = Callable[[str], Awaitable[bool]]
+GetAccessToken = Callable[[], str | None]
+
+
+@dataclass
+class EnvLessBridgeParams:
+ """Configuration for ``init_env_less_bridge_core``.
+
+ Mirrors TS ``EnvLessBridgeParams`` on ``remoteBridgeCore.ts:89-131``.
+ Required: ``base_url``, ``org_uuid``, ``title``, ``get_access_token``,
+ ``initial_history_cap``. All callbacks are optional.
+ """
+
+ base_url: str
+ org_uuid: str
+ title: str
+ get_access_token: GetAccessToken
+ initial_history_cap: int
+ initial_messages: list[Message] | None = None
+ on_auth_401: OnAuth401 | None = None
+ on_inbound_message: OnInboundMessage | None = None
+ on_user_message: OnUserMessage | None = None
+ on_permission_response: OnPermissionResponse | None = None
+ on_interrupt: OnInterrupt | None = None
+ on_set_model: OnSetModel | None = None
+ on_set_max_thinking_tokens: OnSetMaxThinkingTokens | None = None
+ on_set_permission_mode: OnSetPermissionMode | None = None
+ on_state_change: OnStateChange | None = None
+ outbound_only: bool = False
+ tags: list[str] | None = None
+
+
+@dataclass
+class RemoteBridgeHandle:
+ """Opaque handle returned by ``init_env_less_bridge_core``.
+
+ Mirrors the consumer-facing surface of TS ``ReplBridgeHandle``
+ (``remoteBridgeCore.ts:763-886``). All write methods are sync
+ fire-and-forget — the underlying transport batches writes via
+ ``SerialBatchEventUploader``. ``teardown`` is async and idempotent.
+ """
+
+ bridge_session_id: str
+ environment_id: str # always empty for env-less
+ session_ingress_url: str
+ write_messages: Callable[[list[Message]], None]
+ write_sdk_messages: Callable[[list[dict[str, Any]]], None]
+ send_control_request: Callable[[dict[str, Any]], None]
+ send_control_response: Callable[[dict[str, Any]], None]
+ send_cancel_request: Callable[[str], None]
+ send_result: Callable[[], None]
+ teardown: Callable[[], Awaitable[None]]
+
+
+# ── Init ──────────────────────────────────────────────────────────────────
+
+
+async def init_env_less_bridge_core(
+ params: EnvLessBridgeParams,
+ *,
+ http_client: httpx.AsyncClient | None = None,
+ config: EnvLessBridgeConfig | None = None,
+ transport_factory: Callable[
+ [V2TransportOptions], Awaitable[ReplBridgeTransport]
+ ] | None = None,
+) -> RemoteBridgeHandle | None:
+ """Create a session, fetch a worker JWT, connect the v2 transport.
+
+ Returns ``None`` on any pre-flight failure (session create failed,
+ ``/bridge`` failed, transport setup failed). Caller surfaces this as
+ a generic "initialization failed" state.
+
+ Test seams (kw-only — production callers omit):
+
+ * ``http_client``: optional injected ``httpx.AsyncClient`` for the
+ ``/code/sessions``, ``/bridge``, and ``archive`` calls.
+ * ``config``: override ``EnvLessBridgeConfig`` (otherwise fetched
+ via ``get_env_less_bridge_config()`` which currently returns
+ defaults).
+ * ``transport_factory``: override the v2 transport constructor for
+ tests so they can inject a fake without hitting the SSE/CCR layer.
+ """
+ cfg = config if config is not None else await get_env_less_bridge_config()
+ factory = transport_factory or create_v2_repl_transport
+
+ # ── 1. OAuth pre-check ─────────────────────────────────────────────
+ access_token = params.get_access_token()
+ if not access_token:
+ logger.debug('[remote-bridge] No OAuth token')
+ return None
+
+ # ── 2. Create session (POST /v1/code/sessions) ─────────────────────
+ timeout_seconds = cfg.http_timeout_ms / 1000.0
+ session_id = await _with_retry(
+ lambda: create_code_session(
+ params.base_url,
+ access_token,
+ params.title,
+ timeout_seconds=timeout_seconds,
+ tags=params.tags,
+ client=http_client,
+ ),
+ 'createCodeSession',
+ cfg,
+ )
+ if session_id is None:
+ _fire_state(params.on_state_change, 'failed',
+ 'Session creation failed — see debug log')
+ return None
+ logger.debug('[remote-bridge] Created session %s', session_id)
+
+ # ── 3. Fetch bridge credentials ────────────────────────────────────
+ credentials = await _with_retry(
+ lambda: fetch_remote_credentials(
+ session_id,
+ params.base_url,
+ access_token,
+ timeout_seconds=timeout_seconds,
+ client=http_client,
+ ),
+ 'fetchRemoteCredentials',
+ cfg,
+ )
+ if credentials is None:
+ _fire_state(params.on_state_change, 'failed',
+ 'Remote credentials fetch failed — see debug log')
+ await _safe_archive_session(
+ session_id,
+ params.base_url,
+ access_token,
+ params.org_uuid,
+ timeout_seconds,
+ http_client,
+ )
+ return None
+ logger.debug(
+ '[remote-bridge] Fetched bridge credentials (expires_in=%ss)',
+ credentials.expires_in,
+ )
+
+ # ── 4. Build v2 transport ──────────────────────────────────────────
+ session_url = build_ccr_v2_sdk_url(credentials.api_base_url, session_id)
+ try:
+ transport = await factory(
+ V2TransportOptions(
+ session_url=session_url,
+ ingress_token=credentials.worker_jwt,
+ session_id=session_id,
+ epoch=credentials.worker_epoch,
+ heartbeat_interval_seconds=cfg.heartbeat_interval_ms / 1000.0,
+ heartbeat_jitter_fraction=cfg.heartbeat_jitter_fraction,
+ outbound_only=params.outbound_only,
+ # Per-instance closure — keeps the worker JWT out of
+ # ``CLAUDE_CODE_SESSION_ACCESS_TOKEN`` env which mcp/client
+ # would otherwise leak to user-configured MCP servers.
+ # Frozen at construction: transport is fully rebuilt on
+ # refresh (rebuild_transport below) with a fresh closure.
+ get_auth_token=_freeze_token(credentials.worker_jwt),
+ )
+ )
+ except Exception as err: # noqa: BLE001 surface as pre-flight failure
+ logger.error('[remote-bridge] v2 transport setup failed: %s', err)
+ _fire_state(params.on_state_change, 'failed',
+ f'Transport setup failed: {err}')
+ await _safe_archive_session(
+ session_id,
+ params.base_url,
+ access_token,
+ params.org_uuid,
+ timeout_seconds,
+ http_client,
+ )
+ return None
+ logger.debug(
+ '[remote-bridge] v2 transport created (epoch=%s)',
+ credentials.worker_epoch,
+ )
+ _fire_state(params.on_state_change, 'ready')
+
+ # ── 5. State (closures shared by all callbacks) ────────────────────
+ state = _BridgeState(
+ params=params,
+ cfg=cfg,
+ session_id=session_id,
+ credentials=credentials,
+ transport=transport,
+ http_client=http_client,
+ transport_factory=factory,
+ )
+
+ # ── 6. JWT refresh scheduler ───────────────────────────────────────
+ refresh = TokenRefreshScheduler(
+ get_access_token=_async_refresh_token(params),
+ on_refresh=state.on_jwt_refresh,
+ label='remote',
+ refresh_buffer_ms=cfg.token_refresh_buffer_ms,
+ )
+ state.refresh = refresh
+ refresh.schedule_from_expires_in(session_id, credentials.expires_in)
+
+ # ── 7. Wire transport callbacks ────────────────────────────────────
+ state.wire_transport_callbacks()
+
+ # Start the flushGate BEFORE connect *unconditionally* so any
+ # write_messages() / send_* calls that arrive during the handshake
+ # are queued instead of dropped. The Python ``CCRClient.write_event``
+ # silently drops messages while ``_initialized is False`` (unlike
+ # TS's ``SerialBatchEventUploader`` which queues), so any pre-onConnect
+ # write would be lost without this gate. The gate is drained in
+ # ``_on_connect`` once the new transport's CCR is initialized.
+ state.flush_gate.start()
+ transport.connect()
+
+ # ── 8. Return handle ───────────────────────────────────────────────
+ return RemoteBridgeHandle(
+ bridge_session_id=session_id,
+ environment_id='',
+ session_ingress_url=credentials.api_base_url,
+ write_messages=state.write_messages,
+ write_sdk_messages=state.write_sdk_messages,
+ send_control_request=state.send_control_request,
+ send_control_response=state.send_control_response,
+ send_cancel_request=state.send_cancel_request,
+ send_result=state.send_result,
+ teardown=state.teardown,
+ )
+
+
+# ── Internal state machine (one instance per bridge) ──────────────────────
+
+
+@dataclass
+class _BridgeState:
+ """All mutable state for one env-less bridge.
+
+ Lives in its own class so the closure-heavy TS code maps cleanly to
+ Python — each "inner function" in TS becomes a method that reads/
+ writes ``self``.
+ """
+
+ params: EnvLessBridgeParams
+ cfg: EnvLessBridgeConfig
+ session_id: str
+ credentials: RemoteCredentials
+ transport: ReplBridgeTransport
+ http_client: httpx.AsyncClient | None
+ transport_factory: Callable[
+ [V2TransportOptions], Awaitable[ReplBridgeTransport]
+ ]
+ refresh: TokenRefreshScheduler | None = None
+
+ recent_posted_uuids: BoundedUUIDSet = field(init=False)
+ initial_message_uuids: set[str] = field(default_factory=set)
+ recent_inbound_uuids: BoundedUUIDSet = field(init=False)
+ flush_gate: FlushGate[Message] = field(default_factory=FlushGate)
+
+ initial_flush_done: bool = False
+ torn_down: bool = False
+ auth_recovery_in_flight: bool = False
+ user_message_callback_done: bool = False
+ connect_cause: str = 'initial'
+
+ def __post_init__(self) -> None:
+ # UUID dedup ring buffers, sized per env-less config.
+ self.recent_posted_uuids = BoundedUUIDSet(
+ self.cfg.uuid_dedup_buffer_size
+ )
+ self.recent_inbound_uuids = BoundedUUIDSet(
+ self.cfg.uuid_dedup_buffer_size
+ )
+
+ # Seed dedup with initial-history UUIDs so server echoes of the
+ # flushed history are recognized as our own.
+ if self.params.initial_messages:
+ for msg in self.params.initial_messages:
+ self.initial_message_uuids.add(msg.uuid)
+ self.recent_posted_uuids.add(msg.uuid)
+
+ # Latch onUserMessage as already-done when no callback was wired.
+ self.user_message_callback_done = self.params.on_user_message is None
+
+ # ── Callback wiring ────────────────────────────────────────────────
+
+ def wire_transport_callbacks(self) -> None:
+ """Wire SSE setOnConnect / setOnData / setOnClose callbacks.
+
+ Re-callable after a transport rebuild — captures ``self.transport``
+ lazily via the closures so the new transport receives the wiring.
+ """
+ active_transport = self.transport
+ self.transport.set_on_connect(
+ lambda: self._on_connect(active_transport)
+ )
+ self.transport.set_on_data(self._on_data)
+ self.transport.set_on_close(self._on_close)
+
+ def _on_connect(self, flush_transport: ReplBridgeTransport) -> None:
+ """Fired when the transport handshake completes (CCR initialized).
+
+ At this point ``CCRClient.is_initialized`` is True (the v2 transport
+ wires this callback after ``await ccr.initialize()`` resolves), so
+ it's safe to drain the flushGate — any queued writes will reach the
+ uploader and be POSTed. Flushes initial history first if present.
+
+ ``flush_transport`` is captured at wire time — if a 401/teardown
+ swaps ``self.transport`` mid-flush, the stale callback is a no-op
+ (matches TS guard pattern).
+ """
+ logger.debug('[remote-bridge] v2 transport connected')
+ if (
+ not self.initial_flush_done
+ and self.params.initial_messages
+ and len(self.params.initial_messages) > 0
+ ):
+ self.initial_flush_done = True
+ asyncio.create_task(
+ self._flush_history_then_drain(flush_transport)
+ )
+ return
+ # No initial flush — drain any pre-connect queued writes
+ # (``write_messages`` calls that landed during the handshake) and
+ # close the gate so subsequent writes go straight to the uploader.
+ if self.flush_gate.active:
+ self._drain_flush_gate()
+ _fire_state(self.params.on_state_change, 'connected')
+
+ async def _flush_history_then_drain(
+ self, flush_transport: ReplBridgeTransport
+ ) -> None:
+ """Async wrapper for flush + drain. Mirrors TS .finally() chain.
+
+ Stale-transport guards: if the transport was swapped mid-flush
+ (401 recovery, teardown), skip the drain — the new transport's
+ re-wired onConnect will re-flush.
+ """
+ try:
+ assert self.params.initial_messages is not None
+ await self._flush_history(self.params.initial_messages)
+ except Exception as err: # noqa: BLE001 log + carry on
+ logger.warning('[remote-bridge] flushHistory failed: %s', err)
+ finally:
+ if (
+ self.transport is flush_transport
+ and not self.torn_down
+ and not self.auth_recovery_in_flight
+ ):
+ self._drain_flush_gate()
+ _fire_state(self.params.on_state_change, 'connected')
+
+ def _on_data(self, data: str) -> None:
+ """Route an SSE event line to the ingress handler."""
+ params = self.params
+ transport = self.transport
+
+ on_permission_response_wrapped: OnPermissionResponse | None = None
+ if params.on_permission_response is not None:
+ inner = params.on_permission_response
+
+ def wrapped(response: dict[str, Any]) -> None:
+ # Remote client answered the prompt — turn resumes.
+ # Without reportState('running'), the server stays on
+ # ``requires_action`` until the next user message or
+ # turn-end result.
+ transport.report_state({'state': 'running'})
+ inner(response)
+
+ on_permission_response_wrapped = wrapped
+
+ def on_control_request(request: dict[str, Any]) -> None:
+ handle_server_control_request(
+ request,
+ ServerControlRequestHandlers(
+ transport=transport, # type: ignore[arg-type]
+ session_id=self.session_id,
+ outbound_only=params.outbound_only,
+ on_interrupt=params.on_interrupt,
+ on_set_model=params.on_set_model,
+ on_set_max_thinking_tokens=params.on_set_max_thinking_tokens,
+ on_set_permission_mode=params.on_set_permission_mode,
+ ),
+ )
+
+ handle_ingress_message(
+ data,
+ self.recent_posted_uuids,
+ self.recent_inbound_uuids,
+ params.on_inbound_message,
+ on_permission_response_wrapped,
+ on_control_request,
+ )
+
+ def _on_close(self, code: int | None) -> None:
+ """Fired when the transport closes terminally.
+
+ Per TS comment: onClose fires only for TERMINAL failures —
+ 401 (JWT invalid), 4090 (epoch mismatch), 4091 (init failed),
+ or SSE reconnect-budget exhausted. Transient drops are handled
+ inside SSETransport. 401 is recoverable (fetch fresh JWT,
+ rebuild transport); other codes are dead-ends.
+ """
+ if self.torn_down:
+ return
+ logger.debug('[remote-bridge] v2 transport closed (code=%s)', code)
+ if code == 401 and not self.auth_recovery_in_flight:
+ asyncio.create_task(self._recover_from_auth_failure())
+ return
+ _fire_state(
+ self.params.on_state_change, 'failed',
+ f'Transport closed (code {code})',
+ )
+
+ # ── Transport rebuild (shared by proactive refresh + 401 recovery) ──
+
+ async def _rebuild_transport(
+ self,
+ fresh: RemoteCredentials,
+ cause: str,
+ ) -> None:
+ """Replace the transport with a fresh JWT+epoch.
+
+ Caller MUST set ``self.auth_recovery_in_flight = True`` before
+ calling (synchronously, before any ``await``) and clear it in a
+ ``finally``. Moving that here would be too late to prevent a
+ double ``/bridge`` fetch.
+ """
+ self.connect_cause = cause
+ # Queue writes during rebuild — once /bridge returns, the old
+ # transport's epoch is stale and its next write/heartbeat 409s.
+ # ``deactivate()`` (vs ``drop()``) on success leaves queued items
+ # for the new transport's ``_on_connect`` to drain.
+ self.flush_gate.start()
+ success = False
+ try:
+ old_seq = self.transport.get_last_sequence_num()
+ self.transport.close()
+ self.transport = await self.transport_factory(
+ V2TransportOptions(
+ session_url=build_ccr_v2_sdk_url(
+ fresh.api_base_url, self.session_id
+ ),
+ ingress_token=fresh.worker_jwt,
+ session_id=self.session_id,
+ epoch=fresh.worker_epoch,
+ heartbeat_interval_seconds=(
+ self.cfg.heartbeat_interval_ms / 1000.0
+ ),
+ heartbeat_jitter_fraction=(
+ self.cfg.heartbeat_jitter_fraction
+ ),
+ initial_sequence_num=old_seq,
+ outbound_only=self.params.outbound_only,
+ get_auth_token=_freeze_token(fresh.worker_jwt),
+ )
+ )
+ if self.torn_down:
+ # Teardown fired during the async factory window — don't
+ # wire/connect/schedule; we'd re-arm timers after cancelAll
+ # and fire onInboundMessage into a torn-down bridge.
+ self.transport.close()
+ return
+ self.wire_transport_callbacks()
+ self.transport.connect()
+ if self.refresh is not None:
+ self.refresh.schedule_from_expires_in(
+ self.session_id, fresh.expires_in
+ )
+ self.credentials = fresh
+ # Leave the gate active — the new transport's ``_on_connect``
+ # will drain it once CCR.initialize() resolves. Python
+ # ``CCRClient.write_event`` silently drops while
+ # ``_initialized is False`` (TS ``SerialBatchEventUploader``
+ # queues instead — known divergence), so draining here would
+ # lose every queued message between now and the SSE/CCR
+ # handshake completing.
+ success = True
+ finally:
+ # Failure path (exception unwound the try, or torn_down
+ # short-circuit): drop the queued items because the new
+ # transport isn't going to come up.
+ if not success:
+ self.flush_gate.drop()
+
+ # ── 401 recovery ───────────────────────────────────────────────────
+
+ async def _recover_from_auth_failure(self) -> None:
+ """Recover from a 401 on the SSE stream.
+
+ Refresh OAuth, re-fetch ``/bridge``, rebuild transport. Shared
+ ``auth_recovery_in_flight`` flag with the proactive refresh path
+ prevents double-bumping epoch.
+ """
+ if self.auth_recovery_in_flight:
+ return
+ self.auth_recovery_in_flight = True
+ _fire_state(
+ self.params.on_state_change, 'reconnecting',
+ 'JWT expired — refreshing',
+ )
+ logger.debug('[remote-bridge] 401 on SSE — attempting JWT refresh')
+ try:
+ # getAccessToken() returns expired tokens as non-None strings
+ # — unconditional OAuth refresh below catches that.
+ stale = self.params.get_access_token()
+ if self.params.on_auth_401 is not None:
+ await self.params.on_auth_401(stale or '')
+ oauth_token = self.params.get_access_token() or stale
+ if not oauth_token or self.torn_down:
+ if not self.torn_down:
+ _fire_state(
+ self.params.on_state_change, 'failed',
+ 'JWT refresh failed: no OAuth token',
+ )
+ return
+ fresh = await _with_retry(
+ lambda: fetch_remote_credentials(
+ self.session_id,
+ self.params.base_url,
+ oauth_token,
+ timeout_seconds=self.cfg.http_timeout_ms / 1000.0,
+ client=self.http_client,
+ ),
+ 'fetchRemoteCredentials (recovery)',
+ self.cfg,
+ )
+ if fresh is None or self.torn_down:
+ if not self.torn_down:
+ _fire_state(
+ self.params.on_state_change, 'failed',
+ 'JWT refresh failed after 401',
+ )
+ return
+ # If 401 interrupted the initial flush, writeBatch may have
+ # silently no-op'd on the closed uploader. Reset so the new
+ # onConnect re-flushes.
+ self.initial_flush_done = False
+ await self._rebuild_transport(fresh, 'auth_401_recovery')
+ logger.debug('[remote-bridge] Transport rebuilt after 401')
+ except Exception as err: # noqa: BLE001 log + surface
+ logger.error(
+ '[remote-bridge] 401 recovery failed: %s', err
+ )
+ if not self.torn_down:
+ _fire_state(
+ self.params.on_state_change, 'failed',
+ f'JWT refresh failed: {err}',
+ )
+ finally:
+ self.auth_recovery_in_flight = False
+
+ def on_jwt_refresh(self, session_id: str, oauth_token: str) -> None:
+ """Called by ``TokenRefreshScheduler`` 5min before JWT expiry.
+
+ Re-fetches ``/bridge`` and rebuilds the transport. Serialized
+ against ``recover_from_auth_failure`` via ``auth_recovery_in_flight``
+ so a laptop-wake double-fire doesn't bump epoch twice.
+ """
+ async def _do() -> None:
+ if self.auth_recovery_in_flight or self.torn_down:
+ logger.debug(
+ '[remote-bridge] Recovery already in flight, '
+ 'skipping proactive refresh'
+ )
+ return
+ self.auth_recovery_in_flight = True
+ try:
+ fresh = await _with_retry(
+ lambda: fetch_remote_credentials(
+ session_id,
+ self.params.base_url,
+ oauth_token,
+ timeout_seconds=self.cfg.http_timeout_ms / 1000.0,
+ client=self.http_client,
+ ),
+ 'fetchRemoteCredentials (proactive)',
+ self.cfg,
+ )
+ if fresh is None or self.torn_down:
+ return
+ await self._rebuild_transport(fresh, 'proactive_refresh')
+ logger.debug(
+ '[remote-bridge] Transport rebuilt (proactive refresh)'
+ )
+ except Exception as err: # noqa: BLE001
+ logger.error(
+ '[remote-bridge] Proactive refresh rebuild failed: %s',
+ err,
+ )
+ if not self.torn_down:
+ _fire_state(
+ self.params.on_state_change, 'failed',
+ f'Refresh failed: {err}',
+ )
+ finally:
+ self.auth_recovery_in_flight = False
+
+ asyncio.create_task(_do())
+
+ # ── History flush + drain ──────────────────────────────────────────
+
+ async def _flush_history(self, msgs: list[Message]) -> None:
+ """POST a capped, eligible-only slice of initial history.
+
+ Cap via ``initial_history_cap``; filter via
+ ``is_eligible_bridge_message``. The cap takes the *tail* to keep
+ the most recent context. If the eligible tail is a user message
+ (pre-cap), reports state ``'running'`` so the web UI shows the
+ turn-in-progress indicator immediately on connect.
+ """
+ eligible = [m for m in msgs if is_eligible_bridge_message(_msg_dict(m))]
+ cap = self.params.initial_history_cap
+ capped = (
+ eligible[-cap:] if cap > 0 and len(eligible) > cap else eligible
+ )
+ if len(capped) < len(eligible):
+ logger.debug(
+ '[remote-bridge] Capped initial flush: %s -> %s (cap=%s)',
+ len(eligible), len(capped), cap,
+ )
+ events = [
+ {**m, 'session_id': self.session_id}
+ for m in to_sdk_messages(capped)
+ ]
+ if not events:
+ return
+ # Pre-cap eligible tail decides reportState: the cap may truncate
+ # to a user message even when the actual trailing message is
+ # assistant. Match TS behavior exactly.
+ if eligible and _msg_dict(eligible[-1]).get('type') == 'user':
+ self.transport.report_state({'state': 'running'})
+ logger.debug(
+ '[remote-bridge] Flushing %s history events', len(events)
+ )
+ await self.transport.write_batch(events)
+
+ def _drain_flush_gate(self) -> None:
+ """Send any messages queued during the flush window."""
+ msgs = self.flush_gate.end()
+ if not msgs:
+ return
+ for msg in msgs:
+ self.recent_posted_uuids.add(msg.uuid)
+ events = [
+ {**m, 'session_id': self.session_id}
+ for m in to_sdk_messages(msgs)
+ ]
+ if any(_msg_dict(m).get('type') == 'user' for m in msgs):
+ self.transport.report_state({'state': 'running'})
+ logger.debug(
+ '[remote-bridge] Drained %s queued message(s) after flush',
+ len(msgs),
+ )
+ asyncio.create_task(self.transport.write_batch(events))
+
+ # ── Public handle methods ──────────────────────────────────────────
+
+ def write_messages(self, messages: list[Message]) -> None:
+ """Write a batch of local Message[] to the transport."""
+ filtered = [
+ m for m in messages
+ if is_eligible_bridge_message(_msg_dict(m))
+ and m.uuid not in self.initial_message_uuids
+ and not self.recent_posted_uuids.has(m.uuid)
+ ]
+ if not filtered:
+ return
+
+ # Title derivation — scan BEFORE the flushGate check so prompts
+ # queued during flush still count.
+ if not self.user_message_callback_done:
+ for m in filtered:
+ text = extract_title_text(_msg_dict(m))
+ cb = self.params.on_user_message
+ if text is not None and cb is not None and cb(text, self.session_id):
+ self.user_message_callback_done = True
+ break
+
+ if self.flush_gate.enqueue(*filtered):
+ logger.debug(
+ '[remote-bridge] Queued %s message(s) during flush',
+ len(filtered),
+ )
+ return
+
+ for msg in filtered:
+ self.recent_posted_uuids.add(msg.uuid)
+ events = [
+ {**m, 'session_id': self.session_id}
+ for m in to_sdk_messages(filtered)
+ ]
+ if any(_msg_dict(m).get('type') == 'user' for m in filtered):
+ self.transport.report_state({'state': 'running'})
+ logger.debug(
+ '[remote-bridge] Sending %s message(s)', len(filtered)
+ )
+ asyncio.create_task(self.transport.write_batch(events))
+
+ def write_sdk_messages(self, messages: list[dict[str, Any]]) -> None:
+ """Write pre-shaped SDK messages (used by the daemon path)."""
+ filtered = [
+ m for m in messages
+ if not m.get('uuid') or not self.recent_posted_uuids.has(m['uuid'])
+ ]
+ if not filtered:
+ return
+ for msg in filtered:
+ uid = msg.get('uuid')
+ if uid:
+ self.recent_posted_uuids.add(uid)
+ events = [{**m, 'session_id': self.session_id} for m in filtered]
+ asyncio.create_task(self.transport.write_batch(events))
+
+ def send_control_request(self, request: dict[str, Any]) -> None:
+ if self.auth_recovery_in_flight:
+ logger.debug(
+ '[remote-bridge] Dropping control_request during '
+ '401 recovery: %s', request.get('request_id'),
+ )
+ return
+ event = {**request, 'session_id': self.session_id}
+ inner = request.get('request') or {}
+ if inner.get('subtype') == 'can_use_tool':
+ self.transport.report_state({'state': 'requires_action'})
+ asyncio.create_task(self.transport.write(event))
+ logger.debug(
+ '[remote-bridge] Sent control_request request_id=%s',
+ request.get('request_id'),
+ )
+
+ def send_control_response(self, response: dict[str, Any]) -> None:
+ if self.auth_recovery_in_flight:
+ logger.debug(
+ '[remote-bridge] Dropping control_response during 401 recovery'
+ )
+ return
+ event = {**response, 'session_id': self.session_id}
+ self.transport.report_state({'state': 'running'})
+ asyncio.create_task(self.transport.write(event))
+ logger.debug('[remote-bridge] Sent control_response')
+
+ def send_cancel_request(self, request_id: str) -> None:
+ if self.auth_recovery_in_flight:
+ logger.debug(
+ '[remote-bridge] Dropping control_cancel_request '
+ 'during 401 recovery: %s', request_id,
+ )
+ return
+ event = {
+ 'type': 'control_cancel_request',
+ 'request_id': request_id,
+ 'session_id': self.session_id,
+ }
+ # 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.
+ self.transport.report_state({'state': 'running'})
+ asyncio.create_task(self.transport.write(event))
+ logger.debug(
+ '[remote-bridge] Sent control_cancel_request request_id=%s',
+ request_id,
+ )
+
+ def send_result(self) -> None:
+ if self.auth_recovery_in_flight:
+ logger.debug(
+ '[remote-bridge] Dropping result during 401 recovery'
+ )
+ return
+ self.transport.report_state({'state': 'idle'})
+ asyncio.create_task(
+ self.transport.write(make_result_message(self.session_id))
+ )
+ logger.debug('[remote-bridge] Sent result')
+
+ # ── Teardown ───────────────────────────────────────────────────────
+
+ async def teardown(self) -> None:
+ """Idempotent shutdown.
+
+ 1. Cancel JWT refresh scheduler.
+ 2. Drop the flush gate (any queued messages are lost — transport
+ is about to close).
+ 3. ``reportState('idle')`` + write result message (fire-and-forget;
+ archive latency covers the uploader drain).
+ 4. Archive the session via the v2 compat archive endpoint, with a
+ single 401 retry through ``on_auth_401``.
+ 5. Close the transport.
+ """
+ if self.torn_down:
+ return
+ self.torn_down = True
+ if self.refresh is not None:
+ self.refresh.cancel_all()
+ self.flush_gate.drop()
+
+ # Fire the result message before archive. ``transport.write()``
+ # in Python awaits enqueue into ``CCRClient.SerialBatchEventUploader``
+ # (fast on the happy path) but blocks up to ``producer_timeout_seconds``
+ # (default 30s) when the queue is full. ``gracefulShutdown`` races
+ # this against a 2s cap, so bound the wait tightly — losing the
+ # result message under back-pressure is preferable to blocking
+ # teardown past the cap.
+ self.transport.report_state({'state': 'idle'})
+ try:
+ await asyncio.wait_for(
+ self.transport.write(make_result_message(self.session_id)),
+ timeout=_TEARDOWN_RESULT_WRITE_TIMEOUT_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ logger.warning(
+ '[remote-bridge] Result write timed out after %ss '
+ '(producer queue back-pressure)',
+ _TEARDOWN_RESULT_WRITE_TIMEOUT_SECONDS,
+ )
+ except Exception as err: # noqa: BLE001 log + carry on
+ logger.warning('[remote-bridge] Result write failed: %s', err)
+
+ token = self.params.get_access_token()
+ archive_timeout = self.cfg.teardown_archive_timeout_ms / 1000.0
+ status = await _archive_v2_session(
+ self.session_id,
+ self.params.base_url,
+ token,
+ self.params.org_uuid,
+ archive_timeout,
+ self.http_client,
+ )
+
+ # Single 401 retry — token might be stale post-laptop-wake.
+ if status == 401 and self.params.on_auth_401 is not None:
+ try:
+ await self.params.on_auth_401(token or '')
+ token = self.params.get_access_token()
+ status = await _archive_v2_session(
+ self.session_id,
+ self.params.base_url,
+ token,
+ self.params.org_uuid,
+ archive_timeout,
+ self.http_client,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.error(
+ '[remote-bridge] Teardown 401 retry threw: %s', err
+ )
+
+ self.transport.close()
+ logger.debug(
+ '[remote-bridge] Torn down (archive=%s)', status
+ )
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────
+
+
+def _freeze_token(token: str) -> Callable[[], str | None]:
+ """Capture the JWT in a closure so the transport reads it stably.
+
+ Per-instance auth header source; bypasses
+ ``CLAUDE_CODE_SESSION_ACCESS_TOKEN`` env var which mcp/client reads
+ ungatedly. Frozen at construction — the transport is fully rebuilt
+ on JWT refresh, so the closure is reissued each time.
+ """
+ return lambda: token
+
+
+def _fire_state(
+ cb: OnStateChange | None,
+ state: BridgeState,
+ detail: str | None = None,
+) -> None:
+ """Invoke ``on_state_change`` if wired, swallowing exceptions."""
+ if cb is None:
+ return
+ try:
+ if detail is None:
+ cb(state)
+ else:
+ cb(state, detail)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[remote-bridge] on_state_change callback raised: %s', err
+ )
+
+
+def _msg_dict(msg: Message | dict[str, Any]) -> dict[str, Any]:
+ """Coerce a Message dataclass to the wire-format dict shape.
+
+ The dict-shaped predicates in ``bridge.messaging`` mirror the TS
+ wire format where user/assistant messages have a nested
+ ``message: {role, content}`` field. Python ``UserMessage`` /
+ ``AssistantMessage`` keep ``role`` and ``content`` flat at the top
+ level, so a shallow ``vars()`` would give ``extract_title_text``
+ nothing to extract. We synthesize the nested ``message`` field
+ on top of the flat ``vars()`` so both ``is_eligible_bridge_message``
+ (reads ``type``/``isVirtual``/``subtype``) AND ``extract_title_text``
+ (reads ``message.content``) work against the same dict.
+ """
+ if isinstance(msg, dict):
+ return msg
+ flat = vars(msg).copy()
+ msg_type = flat.get('type')
+ # Only user/assistant carry inner ``message.content`` in the wire
+ # format; system and others stay flat.
+ if msg_type in ('user', 'assistant') and 'message' not in flat:
+ flat['message'] = {
+ 'role': flat.get('role', msg_type),
+ 'content': flat.get('content'),
+ }
+ return flat
+
+
+async def _with_retry(
+ fn: Callable[[], Awaitable[Any]],
+ label: str,
+ cfg: EnvLessBridgeConfig,
+) -> Any:
+ """Retry an async init call with exponential backoff + jitter.
+
+ Mirrors TS ``withRetry`` on ``remoteBridgeCore.ts:891-913``. Returns
+ the first non-``None`` result; ``None`` after all attempts indicates
+ permanent failure (caller logs + degrades).
+ """
+ max_attempts = cfg.init_retry_max_attempts
+ for attempt in range(1, max_attempts + 1):
+ result = await fn()
+ if result is not None:
+ return result
+ if attempt < max_attempts:
+ base_ms = cfg.init_retry_base_delay_ms * (2 ** (attempt - 1))
+ jitter_ms = (
+ base_ms
+ * cfg.init_retry_jitter_fraction
+ * (2 * random.random() - 1)
+ )
+ delay_ms = min(base_ms + jitter_ms, cfg.init_retry_max_delay_ms)
+ logger.debug(
+ '[remote-bridge] %s failed (attempt %s/%s), '
+ 'retrying in %sms',
+ label, attempt, max_attempts, round(delay_ms),
+ )
+ await asyncio.sleep(delay_ms / 1000.0)
+ return None
+
+
+def _async_refresh_token(
+ params: EnvLessBridgeParams,
+) -> Callable[[], Awaitable[str | None]]:
+ """Build the async token getter for the JWT refresh scheduler.
+
+ Always attempts OAuth refresh first — ``get_access_token()`` returns
+ expired tokens as non-None strings (no expiresAt check), so we can't
+ rely on truthiness alone. Passes the stale token to ``on_auth_401``
+ so keychain-comparison can detect parallel refresh (Phase 10 wiring).
+ """
+ async def _refresh() -> str | None:
+ stale = params.get_access_token()
+ if params.on_auth_401 is not None:
+ await params.on_auth_401(stale or '')
+ return params.get_access_token() or stale
+
+ return _refresh
+
+
+async def _safe_archive_session(
+ session_id: str,
+ base_url: str,
+ access_token: str | None,
+ org_uuid: str,
+ timeout_seconds: float,
+ http_client: httpx.AsyncClient | None,
+) -> None:
+ """Best-effort archive used by the pre-flight failure paths.
+
+ Wraps ``_archive_v2_session`` and swallows all exceptions — the
+ caller is already about to return ``None`` from init, so any archive
+ failure shouldn't prevent that.
+ """
+ try:
+ await _archive_v2_session(
+ session_id, base_url, access_token, org_uuid,
+ timeout_seconds, http_client,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[remote-bridge] Pre-flight archive failed: %s', err
+ )
+
+
+_ARCHIVE_BETA_HEADER = 'ccr-byoc-2025-07-29'
+_ANTHROPIC_VERSION = '2023-06-01'
+
+
+async def _archive_v2_session(
+ session_id: str,
+ base_url: str,
+ access_token: str | None,
+ org_uuid: str,
+ timeout_seconds: float,
+ http_client: httpx.AsyncClient | None,
+) -> int | str:
+ """Archive a v2 session via the compat ``/v1/sessions/{id}/archive`` endpoint.
+
+ Mirrors TS ``archiveSession`` on ``remoteBridgeCore.ts:963-1008``.
+ The v2 archive lives at the compat layer (``/v1/sessions/*``, not
+ ``/v1/code/sessions/*``) and requires distinct headers vs.
+ ``bridge_api.archive_session`` (which targets the environments API):
+
+ * ``anthropic-beta: ccr-byoc-2025-07-29`` (compat byoc)
+ * ``x-organization-uuid`` (required by compat gateway)
+
+ Returns the HTTP status code on success, or ``'no_token'`` /
+ ``'timeout'`` / ``'error'`` on failure. Idempotent — 409 (already
+ archived) is success.
+ """
+ if not access_token:
+ return 'no_token'
+ compat_id = to_compat_session_id(session_id)
+ headers = {
+ 'Authorization': f'Bearer {access_token}',
+ 'Content-Type': 'application/json',
+ 'anthropic-version': _ANTHROPIC_VERSION,
+ 'anthropic-beta': _ARCHIVE_BETA_HEADER,
+ 'x-organization-uuid': org_uuid,
+ }
+ url = f'{base_url.rstrip("/")}/v1/sessions/{compat_id}/archive'
+ try:
+ if http_client is not None:
+ response = await http_client.post(
+ url, headers=headers, json={}, timeout=timeout_seconds,
+ )
+ else:
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ url, headers=headers, json={}, timeout=timeout_seconds,
+ )
+ except httpx.TimeoutException:
+ logger.debug('[remote-bridge] Archive %s timed out', compat_id)
+ return 'timeout'
+ except httpx.HTTPError as err:
+ logger.debug('[remote-bridge] Archive %s failed: %s', compat_id, err)
+ return 'error'
+ logger.debug(
+ '[remote-bridge] Archive %s status=%s', compat_id, response.status_code
+ )
+ return response.status_code
+
+
+__all__ = [
+ 'BridgeState',
+ 'DEFAULT_ENV_LESS_BRIDGE_CONFIG',
+ 'EnvLessBridgeParams',
+ 'RemoteBridgeHandle',
+ 'init_env_less_bridge_core',
+]
diff --git a/src/bridge/repl_bridge.py b/src/bridge/repl_bridge.py
new file mode 100644
index 000000000..a7b1a9113
--- /dev/null
+++ b/src/bridge/repl_bridge.py
@@ -0,0 +1,1394 @@
+"""Env-based bridge orchestrator — Phase 6 MVP slice.
+
+Ports the **public surface + happy path** of
+``typescript/src/bridge/replBridge.ts`` (~2400 lines in TS).
+
+This module is the single-session bridge orchestrator. The
+multi-session daemon variant lives in ``bridge_main.py``.
+
+What IS ported in full:
+
+* Public types: ``ReplBridgeHandle``, ``BridgeState``, ``BridgeCoreParams``
+* ``init_bridge_core(params, *, http_client?, api_client?, spawner?)`` — the factory
+* Single-session lifecycle: register → poll → spawn → done → archive
+* Idempotent teardown
+* OAuth + env-secret auth via ``bridge_api``
+* Dual v1 (session-ingress WS) / v2 (CCR) dispatch — phase 14c
+* Crash-recovery pointer + perpetual mode — phase 12c
+* Env recreation: Strategy-1 in-place reconnect via
+ ``api.reconnect_session``; Strategy-2 kill+create-fresh on
+ poll 404 — phase 12b
+* Init-time pointer session validation via ``reconnect_session`` —
+ phase 13
+* JWT refresh with v1/v2-aware dispatch (v1: push token to child
+ stdin; v2: trigger server re-dispatch via ``reconnect_session``)
+ — phase 15
+
+What is **explicitly deferred**:
+
+* **Multi-session** — this module handles one session at a time;
+ second poll result is rejected. ``bridge_main.py`` is the
+ multi-session daemon orchestrator.
+* **v2 JWT refresh round-trip is best-effort in this module.**
+ Phase 15 wires the v2 ``on_refresh`` callback to call
+ ``api.reconnect_session(env_id, session_id)``, which causes the
+ server to re-dispatch the work item with a fresh JWT. **But**
+ the poll loop here (``_poll_loop``) skips polling while
+ ``active_session is not None``, so the re-dispatch sits in the
+ server queue and may expire its lease before the active session
+ completes. The existingHandle path in ``_process_work`` will
+ route the fresh JWT correctly **if and only if** the poll
+ happens to fire between the reconnect and lease expiry —
+ unlikely in practice. v2 long-running sessions on this
+ single-session bridge will silently die at JWT expiry; for
+ guaranteed v2 refresh delivery, use ``bridge_main.py``'s
+ multi-session daemon which polls continuously at the
+ at-capacity interval.
+* **Backoff/give-up logic** — the poll loop uses a fixed interval
+ from the config. The full TS backoff machinery (two-track error
+ counters, process-suspension detection, 10-min give-up) lands
+ in a future phase.
+* **Dropped-batch telemetry** + **work-id completion dedup** — both
+ log-only enhancements; deferred.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+import uuid
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+import httpx
+
+from src.bridge.bridge_api import (
+ BridgeFatalError,
+ create_bridge_api_client,
+ is_expired_error_type,
+)
+from src.bridge.bridge_pointer import (
+ BridgePointer,
+ clear_pointer,
+ read_pointer,
+ write_pointer,
+)
+from src.bridge.jwt_utils import TokenRefreshScheduler
+from src.bridge.poll_config_defaults import (
+ DEFAULT_POLL_CONFIG,
+ PollIntervalConfig,
+)
+from src.bridge.session_id_compat import (
+ to_compat_session_id,
+ to_infra_session_id,
+)
+from src.bridge.session_runner import (
+ PermissionRequest,
+ SessionSpawnerDeps,
+ create_session_spawner,
+)
+from src.bridge.types import (
+ BridgeApiClient,
+ BridgeConfig,
+ SessionActivity,
+ SessionHandle,
+ SessionSpawnOpts,
+ SessionSpawner,
+)
+from src.bridge.work_secret import (
+ build_ccr_v2_sdk_url,
+ build_sdk_url,
+ decode_work_secret,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Phase 17: hourly pointer-mtime refresh interval for perpetual mode.
+# Mirrors TS ``replBridge.ts:1541`` (``60 * 60_000`` ms = 1 hour).
+# Long-running sessions touch the pointer once per interval so the
+# mtime stays fresh; without this, a future TTL check on the pointer
+# would reject it after a few hours of no user activity.
+POINTER_MTIME_REFRESH_INTERVAL_S = 60.0 * 60.0
+
+
+# ── Public types ──────────────────────────────────────────────────────────
+
+
+BridgeState = str
+"""``'ready'`` | ``'connected'`` | ``'reconnecting'`` | ``'failed'``."""
+
+
+# Forward references via Any so we don't have to pre-define types in this
+# already-busy module. Real Message / SDK types live in their own modules.
+OnInboundMessage = Callable[[dict[str, Any]], Any]
+OnUserMessage = Callable[[str, str], bool]
+OnPermissionResponse = Callable[[dict[str, Any]], None]
+OnInterrupt = Callable[[], None]
+OnSetModel = Callable[[str | None], None]
+OnSetMaxThinkingTokens = Callable[[int | None], None]
+OnSetPermissionMode = Callable[[str], Any]
+OnStateChange = Callable[..., None]
+OnAuth401 = Callable[[str], Awaitable[bool]]
+GetAccessToken = Callable[[], str | None]
+
+
+@dataclass
+class BridgeCoreParams:
+ """Explicit-param input to ``init_bridge_core``.
+
+ Mirrors TS ``BridgeCoreParams`` on ``replBridge.ts:92-222``. Required
+ fields first; everything optional defaults sensibly.
+ """
+
+ # Identity
+ dir: str
+ machine_name: str
+ branch: str
+ git_repo_url: str | None
+ title: str
+
+ # URLs
+ base_url: str
+ session_ingress_url: str
+ worker_type: str
+
+ # Auth
+ get_access_token: GetAccessToken
+
+ # Session creation (injected for daemon vs REPL flexibility)
+ create_session: Callable[[dict[str, Any]], Awaitable[str | None]]
+ """``async def create_session({environment_id, title, gitRepoUrl, branch})
+ -> session_id | None``. Daemon/REPL wrappers pass distinct implementations
+ that differ in how they build the org-scoped HTTP headers."""
+
+ archive_session: Callable[[str], Awaitable[None]]
+ """``async def archive_session(session_id)`` — best-effort archival
+ on teardown; MUST NOT throw."""
+
+ # Optional callbacks
+ on_auth_401: OnAuth401 | None = None
+ on_inbound_message: OnInboundMessage | None = None
+ on_user_message: OnUserMessage | None = None
+ on_permission_response: OnPermissionResponse | None = None
+ on_interrupt: OnInterrupt | None = None
+ on_set_model: OnSetModel | None = None
+ on_set_max_thinking_tokens: OnSetMaxThinkingTokens | None = None
+ on_set_permission_mode: OnSetPermissionMode | None = None
+ on_state_change: OnStateChange | None = None
+
+ # Config getters
+ get_poll_interval_config: Callable[[], PollIntervalConfig] = (
+ lambda: DEFAULT_POLL_CONFIG
+ )
+ get_current_title: Callable[[], str] | None = None
+
+ # Identity for the env registration
+ max_sessions: int = 1
+ spawn_mode: str = 'single-session' # 'single-session' | 'same-dir' | 'worktree'
+ bridge_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+
+ # MVP scope: perpetual mode is not yet supported.
+ perpetual: bool = False
+
+ # Initial history (currently unused by the MVP slice — recorded for
+ # future Phase 6 work that integrates with remote_bridge_core's
+ # flush_gate pattern).
+ initial_messages: list[Any] | None = None
+ initial_history_cap: int = 200
+
+ # Max attempts to recreate the environment after a poll 404 / expired
+ # error. Mirrors TS bridgeMain's 3-attempt envelope on
+ # ``replBridge.ts:614-852``. Each attempt: re-register the env, then
+ # create a fresh session, then resume polling.
+ max_env_recreation_attempts: int = 3
+
+
+@dataclass
+class ReplBridgeHandle:
+ """Opaque handle returned by ``init_bridge_core``.
+
+ Mirrors TS ``ReplBridgeHandle`` on ``replBridge.ts:71-82``. All
+ write methods are sync fire-and-forget; ``teardown`` is async and
+ idempotent.
+ """
+
+ bridge_session_id: str
+ environment_id: str
+ session_ingress_url: str
+ write_messages: Callable[[list[Any]], None]
+ write_sdk_messages: Callable[[list[dict[str, Any]]], None]
+ send_control_request: Callable[[dict[str, Any]], None]
+ send_control_response: Callable[[dict[str, Any]], None]
+ send_cancel_request: Callable[[str], None]
+ send_result: Callable[[], None]
+ teardown: Callable[[], Awaitable[None]]
+
+
+# ── init_bridge_core ──────────────────────────────────────────────────────
+
+
+async def init_bridge_core(
+ params: BridgeCoreParams,
+ *,
+ http_client: httpx.AsyncClient | None = None,
+ api_client: BridgeApiClient | None = None,
+ spawner: SessionSpawner | None = None,
+ runner_version: str = 'py-bridge-mvp',
+) -> ReplBridgeHandle | None:
+ """Set up the env-based bridge: register → create session → start poll loop.
+
+ Returns ``None`` on any pre-flight failure (no OAuth, env registration
+ failed, initial session creation failed). The returned handle stays
+ alive until ``teardown()`` is called or the (single) session ends.
+
+ Test seams (kw-only):
+
+ * ``http_client``: optional ``httpx.AsyncClient`` for the bridge API.
+ * ``api_client``: pre-built ``BridgeApiClient`` (overrides
+ ``http_client`` if provided). Tests use this to inject fakes.
+ * ``spawner``: pre-built ``SessionSpawner``. Tests use this to skip
+ the real subprocess.
+ * ``runner_version``: header value for ``x-environment-runner-version``.
+ """
+ if api_client is None:
+ api_client = create_bridge_api_client(
+ base_url=params.base_url,
+ get_access_token=params.get_access_token,
+ runner_version=runner_version,
+ on_auth_401=params.on_auth_401,
+ client=http_client,
+ )
+
+ # ── 0. Perpetual mode: try crash-recovery pointer ──────────────────
+ # Mirrors TS ``initReplBridge`` / ``replBridge.ts`` recovery dance:
+ # if the previous run left a pointer, attempt to reuse its env id
+ # (server may resurrect the lease) and its session id (subprocess
+ # restart resumes the same conversation). Any failure — pointer
+ # absent, stale, register-with-reuse rejected, etc. — drops us
+ # back into the fresh-env+fresh-session bootstrap path.
+ pointer: BridgePointer | None = None
+ reuse_session_id: str | None = None
+ pointer_created_at_ms: int | None = None
+ effective_bridge_id = params.bridge_id
+ if params.perpetual:
+ pointer = read_pointer(
+ params.dir, machine_name=params.machine_name,
+ )
+ if pointer is not None:
+ logger.info(
+ '[bridge:repl] Perpetual: found recovery pointer '
+ 'bridge_id=%s env=%s session=%s — attempting reuse',
+ pointer.bridge_id, pointer.environment_id,
+ pointer.session_id,
+ )
+ effective_bridge_id = pointer.bridge_id
+ reuse_session_id = pointer.session_id
+ pointer_created_at_ms = pointer.created_at_ms
+
+ # ── 1. Register environment ────────────────────────────────────────
+ bridge_config = BridgeConfig(
+ dir=params.dir,
+ machine_name=params.machine_name,
+ branch=params.branch,
+ git_repo_url=params.git_repo_url,
+ max_sessions=params.max_sessions,
+ spawn_mode=_validated_spawn_mode(params.spawn_mode),
+ verbose=False,
+ sandbox=False,
+ bridge_id=effective_bridge_id,
+ worker_type=params.worker_type,
+ # client-generated placeholder; the server returns the real
+ # env id in the registration response and we overwrite then.
+ environment_id=effective_bridge_id,
+ api_base_url=params.base_url,
+ session_ingress_url=params.session_ingress_url,
+ # Hint server to resurrect the pointer's env. If the server
+ # ignores it (env lease expired), it'll just assign a fresh
+ # id and we'll fall through to creating a new session.
+ reuse_environment_id=(
+ pointer.environment_id if pointer is not None else None
+ ),
+ )
+ try:
+ registration = await api_client.register_bridge_environment(
+ bridge_config
+ )
+ except BridgeFatalError as err:
+ logger.error('[bridge:repl] Registration failed: %s', err)
+ _fire_state(params.on_state_change, 'failed',
+ f'Registration failed: {err}')
+ # A stale pointer that the server fully rejects (rather than
+ # just declining to reuse) is dead weight — clear it so the
+ # next start doesn't re-fail the same way.
+ if params.perpetual:
+ clear_pointer(params.dir)
+ return None
+ environment_id = registration['environment_id']
+ environment_secret = registration['environment_secret']
+ logger.debug(
+ '[bridge:repl] Registered environment_id=%s', environment_id
+ )
+
+ # If we asked for env reuse and the server gave us back a DIFFERENT
+ # env id, the resurrection didn't happen. The reuse_session_id
+ # we'd captured from the pointer is bound to the dead env and
+ # would be useless against the new env, so drop it — create_session
+ # will mint a fresh one below.
+ #
+ # Phase 16 / phase-13 follow-up: also clear the stale pointer
+ # eagerly. Phase 12c's original behavior relied on the post-
+ # ``create_session`` pointer write to overwrite the stale entry,
+ # but if ``create_session`` itself fails after this branch fires,
+ # the stale pointer survives on disk and a subsequent restart
+ # would re-hint the dead env again. TS clears on any failure to
+ # reuse the prior pointer (``prior && !reusedPriorSession`` at
+ # ``replBridge.ts:429-431``) — Python mirrors this with two
+ # clears: here for the env-mismatch case, and at the reconnect-
+ # validation block below for the all-candidates-refused case.
+ if (
+ pointer is not None
+ and registration['environment_id'] != pointer.environment_id
+ ):
+ logger.info(
+ '[bridge:repl] Perpetual: server didn\'t resurrect env '
+ '(pointer=%s, got=%s) — falling back to fresh session',
+ pointer.environment_id, registration['environment_id'],
+ )
+ reuse_session_id = None
+ clear_pointer(params.dir)
+
+ # ── 2. Validate the pointer's session id, reuse or create ──────────
+ # Phase 13: before trusting ``reuse_session_id``, actively probe the
+ # server via ``reconnect_session``. Without this, a session archived
+ # between restarts would resurface only after a full 404 poll cycle.
+ # Try both ``session_*`` and ``cse_*`` tags because the pointer was
+ # written under an unknown v2-compat-gate state (see TS
+ # ``replBridge.ts:392-415`` for the same rationale).
+ if reuse_session_id is not None:
+ candidates: list[str] = [reuse_session_id]
+ infra_id = to_infra_session_id(reuse_session_id)
+ if infra_id != reuse_session_id:
+ candidates.append(infra_id)
+ reconnect_ok = False
+ for candidate in candidates:
+ try:
+ await api_client.reconnect_session(
+ environment_id, candidate,
+ )
+ # Intentionally broad (mirrors TS replBridge.ts:410):
+ # transient failures (5xx, network) conservatively fall
+ # through to fresh session rather than risk reusing a
+ # session whose state is undefined. ``CancelledError``
+ # inherits from ``BaseException`` and is not caught here.
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:repl] reconnect_session(%s) failed: %s',
+ candidate, err,
+ )
+ continue
+ logger.debug(
+ '[bridge:repl] reconnect_session(%s) succeeded',
+ candidate,
+ )
+ reconnect_ok = True
+ break
+ if not reconnect_ok:
+ logger.info(
+ '[bridge:repl] Perpetual: session %s no longer reachable '
+ '(all %d candidate(s) refused) — creating fresh',
+ reuse_session_id, len(candidates),
+ )
+ clear_pointer(params.dir)
+ reuse_session_id = None
+
+ session_id: str | None
+ if reuse_session_id is not None:
+ session_id = reuse_session_id
+ logger.info(
+ '[bridge:repl] Perpetual: reusing session_id=%s '
+ '(reconnect-validated)', session_id,
+ )
+ else:
+ try:
+ session_id = await params.create_session({
+ 'environment_id': environment_id,
+ 'title': params.title,
+ 'gitRepoUrl': params.git_repo_url,
+ 'branch': params.branch,
+ })
+ except Exception as err: # noqa: BLE001
+ logger.error(
+ '[bridge:repl] Session creation threw: %s', err
+ )
+ session_id = None
+ if session_id is None:
+ _fire_state(params.on_state_change, 'failed',
+ 'Session creation failed')
+ try:
+ await api_client.deregister_environment(environment_id)
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:repl] Deregister-after-create-fail failed: %s',
+ err,
+ )
+ return None
+ logger.debug('[bridge:repl] Created session_id=%s', session_id)
+
+ # ── 3. Build the spawner (if not test-injected) ────────────────────
+ if spawner is None:
+ spawner = create_session_spawner(SessionSpawnerDeps(
+ exec_path='claude', # caller-overridable in future
+ verbose=False,
+ sandbox=False,
+ ))
+
+ # ── 4. State machine + poll loop ──────────────────────────────────
+ state = _BridgeState(
+ params=params,
+ api=api_client,
+ spawner=spawner,
+ environment_id=environment_id,
+ environment_secret=environment_secret,
+ initial_session_id=session_id,
+ bridge_config=bridge_config,
+ perpetual=params.perpetual,
+ pointer_created_at_ms=pointer_created_at_ms,
+ )
+ # Write the pointer immediately so a crash before the first poll
+ # still leaves a recoverable state for the next start. Compute the
+ # ``created_at_ms`` locally — passing it both to ``write_pointer``
+ # AND storing it on ``state.pointer_created_at_ms`` ensures future
+ # updates preserve the original timestamp. Doing this via a
+ # read-back from the file would silently lose the value if the
+ # write failed (write_pointer is best-effort and logs-and-swallows).
+ if params.perpetual:
+ if pointer_created_at_ms is None:
+ pointer_created_at_ms = int(time.time() * 1000)
+ state.pointer_created_at_ms = pointer_created_at_ms
+ write_pointer(
+ params.dir,
+ bridge_id=effective_bridge_id,
+ environment_id=environment_id,
+ session_id=session_id,
+ machine_name=params.machine_name,
+ created_at_ms=pointer_created_at_ms,
+ )
+ # Phase 17: start the periodic mtime-refresh task. Skipped in
+ # non-perpetual mode since the pointer is cleared on teardown
+ # anyway.
+ state.pointer_mtime_task = asyncio.create_task(
+ state._pointer_mtime_refresh_loop(),
+ name='bridge-repl-pointer-mtime-refresh',
+ )
+ state.start_poll_loop()
+
+ _fire_state(params.on_state_change, 'ready')
+
+ return ReplBridgeHandle(
+ bridge_session_id=session_id,
+ environment_id=environment_id,
+ session_ingress_url=params.session_ingress_url,
+ write_messages=state.write_messages,
+ write_sdk_messages=state.write_sdk_messages,
+ send_control_request=state.send_control_request,
+ send_control_response=state.send_control_response,
+ send_cancel_request=state.send_cancel_request,
+ send_result=state.send_result,
+ teardown=state.teardown,
+ )
+
+
+# ── Internal state machine ────────────────────────────────────────────────
+
+
+@dataclass
+class _BridgeState:
+ """Shared mutable state for one bridge."""
+
+ params: BridgeCoreParams
+ api: BridgeApiClient
+ spawner: SessionSpawner
+ environment_id: str
+ environment_secret: str
+ initial_session_id: str
+ bridge_config: BridgeConfig
+
+ poll_task: asyncio.Task[None] | None = None
+ poll_cancel: asyncio.Event = field(default_factory=asyncio.Event)
+ active_session: SessionHandle | None = None
+ active_work_id: str | None = None
+ active_session_id: str | None = None
+ active_token_refresh: TokenRefreshScheduler | None = None
+ #: Phase 15 — v1/v2 flag of the currently-active work item, used
+ #: by the JWT refresh callback to route between
+ #: ``session.update_access_token`` (v1) and
+ #: ``api.reconnect_session`` (v2). Set in ``_process_work``;
+ #: cleared in ``_await_session_done``.
+ active_use_ccr_v2: bool = False
+ torn_down: bool = False
+
+ # Per-session telemetry-style counters. Dropped batches is a count
+ # of times a write to the child's stdin failed (broken pipe, etc.)
+ # — surfaces silent message loss that would otherwise be invisible.
+ dropped_batch_count: int = 0
+ env_recreation_attempts: int = 0
+
+ # Phase 12c: perpetual mode + crash-recovery pointer state.
+ # ``perpetual`` decides whether to write/update/clear the pointer
+ # on lifecycle events. ``pointer_created_at_ms`` carries forward
+ # the pointer's original creation timestamp across recreations so
+ # operators can see how long a perpetual bridge has been alive.
+ perpetual: bool = False
+ pointer_created_at_ms: int | None = None
+ #: Phase 17: periodic pointer-mtime refresh task. Started in
+ #: perpetual mode at init; touches the pointer once per hour so
+ #: long-running sessions don't leave a stale mtime that triggers
+ #: the next-start TTL check (when one lands). Cancelled in teardown.
+ pointer_mtime_task: asyncio.Task[None] | None = None
+
+ async def _update_pointer(self, *, session_id: str | None) -> None:
+ """No-op when not perpetual; otherwise rewrite the pointer with
+ the current env_id + given session_id. Called at every
+ lifecycle transition (init, work-spawned, session-done,
+ recreate) so a crash always leaves a recoverable on-disk state.
+
+ ``write_pointer`` does synchronous file IO; we delegate it to
+ a worker thread so a slow disk (NFS, etc.) can't stall the
+ event loop. Best-effort — failures are logged by the writer.
+ """
+ if not self.perpetual:
+ return
+ await asyncio.to_thread(
+ write_pointer,
+ self.params.dir,
+ bridge_id=self.bridge_config.bridge_id,
+ environment_id=self.environment_id,
+ session_id=session_id,
+ machine_name=self.params.machine_name,
+ created_at_ms=self.pointer_created_at_ms,
+ )
+
+ async def _clear_pointer(self) -> None:
+ """No-op when not perpetual; otherwise remove the pointer
+ file. Called when the bridge tears down cleanly so the next
+ start doesn't try to resume a state that's no longer valid.
+
+ Also off-loaded to a worker thread (see ``_update_pointer``)."""
+ if not self.perpetual:
+ return
+ await asyncio.to_thread(clear_pointer, self.params.dir)
+
+ async def _pointer_mtime_refresh_loop(self) -> None:
+ """Phase 17: periodic pointer-mtime refresh for perpetual mode.
+
+ Mirrors TS ``replBridge.ts:1522-1543`` — a daemon idle for
+ many hours without a user prompt would have a stale pointer
+ mtime; when the next-start TTL check (future phase) lands, it
+ would reject the pointer and force a fresh session. Touching
+ the pointer hourly keeps long-running sessions recoverable.
+
+ The write is atomic via ``write_pointer``'s tmpfile + os.replace
+ primitive, so a race with ``_recreate_environment``'s own
+ pointer write cannot leave a half-corrupt file. The race is
+ benign in the common case (Strategy-1 updates env+session in
+ a tight window) but **narrowly inconsistent in Strategy-2**:
+ between the env-id swap and the new session-id assignment,
+ ``active_session_id`` is briefly ``None`` while
+ ``environment_id`` is already the new one. A refresh tick
+ landing in that window writes ``(new_env_id, session_id=None)``;
+ if the refresh's write *follows* Strategy-2's final write,
+ the pointer ends as ``(new_env_id, None)`` instead of
+ ``(new_env_id, new_session_id)``. The next start would resume
+ the env but mint a fresh session — recoverable, not corrupt.
+ Strategy-2 has already killed the prior session by that point,
+ so user-visible continuity was already lost. TS guards against
+ this with a ``reconnectPromise`` skip; we accept the benign
+ race in exchange for a simpler implementation.
+
+ Loop exits on ``CancelledError`` (teardown cancels the task,
+ catches cancellation from either the sleep or the inner
+ ``_update_pointer`` await).
+ """
+ try:
+ while not self.torn_down:
+ await asyncio.sleep(POINTER_MTIME_REFRESH_INTERVAL_S)
+ if self.torn_down:
+ return
+ # ``active_session_id`` may be None between sessions
+ # (post ``_await_session_done``); ``_update_pointer``
+ # handles None correctly by writing a null session_id.
+ await self._update_pointer(
+ session_id=self.active_session_id,
+ )
+ except asyncio.CancelledError:
+ return
+
+ # ── Poll loop ───────────────────────────────────────────────────────
+
+ def start_poll_loop(self) -> None:
+ self.poll_task = asyncio.create_task(
+ self._poll_loop(),
+ name=f'bridge-poll-{self.environment_id}',
+ )
+
+ async def _poll_loop(self) -> None:
+ cfg = self.params.get_poll_interval_config()
+ interval = cfg.poll_interval_ms_not_at_capacity / 1000.0
+ while not self.poll_cancel.is_set() and not self.torn_down:
+ try:
+ if self.active_session is not None:
+ # At capacity for the MVP — single session at a time.
+ # Sleep at the at-capacity interval, then re-check.
+ await self._sleep_or_cancel(
+ cfg.poll_interval_ms_at_capacity / 1000.0
+ )
+ continue
+
+ work = await self.api.poll_for_work(
+ self.environment_id, self.environment_secret,
+ )
+ if work is None:
+ await self._sleep_or_cancel(interval)
+ continue
+
+ await self._process_work(work)
+ except BridgeFatalError as err:
+ if is_expired_error_type(err.error_type) or err.status == 404:
+ # Env-recreation flow (Phase 11b, mirrors TS Strategy-2
+ # on ``replBridge.ts:822``). Re-register the env from
+ # scratch + create a fresh session, then keep polling.
+ # Bounded by ``max_env_recreation_attempts`` to avoid
+ # infinite loops on a permanently-broken backend.
+ if self.env_recreation_attempts >= (
+ self.params.max_env_recreation_attempts
+ ):
+ logger.error(
+ '[bridge:repl] Env recreation exhausted '
+ '(%s attempts); giving up: %s',
+ self.env_recreation_attempts, err,
+ )
+ _fire_state(
+ self.params.on_state_change, 'failed',
+ f'Env recreation exhausted ({err.status})',
+ )
+ return
+ self.env_recreation_attempts += 1
+ logger.warning(
+ '[bridge:repl] Environment lost (%s); '
+ 'recreating (attempt %s/%s)',
+ err.status,
+ self.env_recreation_attempts,
+ self.params.max_env_recreation_attempts,
+ )
+ _fire_state(
+ self.params.on_state_change, 'reconnecting',
+ f'Env recreation attempt '
+ f'{self.env_recreation_attempts}',
+ )
+ if await self._recreate_environment():
+ # Reset the attempt counter on success — a future
+ # 404 starts fresh rather than counting against
+ # this one's budget.
+ self.env_recreation_attempts = 0
+ _fire_state(
+ self.params.on_state_change, 'ready',
+ )
+ continue
+ # Recreation itself failed; loop will retry on next
+ # iteration (the attempt counter persists).
+ await self._sleep_or_cancel(interval)
+ continue
+ logger.error('[bridge:repl] Poll fatal error: %s', err)
+ _fire_state(
+ self.params.on_state_change, 'failed', str(err),
+ )
+ return
+ except (asyncio.CancelledError, KeyboardInterrupt):
+ raise
+ except Exception as err: # noqa: BLE001
+ logger.warning('[bridge:repl] Poll loop error: %s', err)
+ await self._sleep_or_cancel(interval)
+
+ async def _sleep_or_cancel(self, seconds: float) -> None:
+ """Sleep up to ``seconds``, waking early on cancellation."""
+ try:
+ await asyncio.wait_for(self.poll_cancel.wait(), timeout=seconds)
+ except asyncio.TimeoutError:
+ pass
+
+ async def _recreate_environment(self) -> bool:
+ """Re-register the environment, then **try Strategy-1 reconnect
+ first** (preserve the active session via ``reconnect_session``
+ when the server resurrects the *same* env id); fall back to
+ **Strategy-2** (kill + create fresh session) otherwise.
+
+ Mirrors TS ``replBridge.ts:614-852``. Returns True on success
+ (caller resets the attempt counter), False on failure (caller
+ backs off and retries; the attempt counter persists so we
+ eventually give up).
+
+ Strategy-1 preserves the local session subprocess and its
+ in-flight CCR client connection. It does NOT independently
+ preserve the SSE seq-num — that depends on the CCR client
+ surviving the env change, which today is best-effort.
+ """
+ # Strategy-1 only makes sense if the server hands back the
+ # SAME env id (TS ``tryReconnectInPlace`` at replBridge.ts:386-391
+ # bails when ``environmentId !== requestedEnvId``). Hint the
+ # server to reuse by setting ``reuse_environment_id`` before
+ # registering; restore it after so a future Strategy-2 cycle
+ # gets a fresh env if the server doesn't want to reuse.
+ prior_env_id = self.environment_id
+ prior_reuse = self.bridge_config.reuse_environment_id
+ # ``active_session_id`` reflects the currently-running work
+ # item (may differ from ``initial_session_id`` after earlier
+ # recreations). Strategy-1 preserves the *active* session.
+ prior_session_id = self.active_session_id
+ prior_work_id = self.active_work_id
+ had_active_session = self.active_session is not None
+ self.bridge_config.reuse_environment_id = prior_env_id
+ try:
+ try:
+ registration = await self.api.register_bridge_environment(
+ self.bridge_config,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] Re-register failed: %s', err
+ )
+ return False
+ finally:
+ self.bridge_config.reuse_environment_id = prior_reuse
+ new_env_id = registration['environment_id']
+ new_env_secret = registration['environment_secret']
+
+ # ── Strategy-1: in-place reconnect ──────────────────────────
+ # Gate on (a) same env id AND (b) active session id AND (c)
+ # active session handle. (a) is the TS preconditon; (b) and (c)
+ # together mean there's a real session to preserve. If the
+ # server changed env id (despite our reuse hint), Strategy-1
+ # is unreachable — the prior session was bound to the dead env.
+ if (
+ new_env_id == prior_env_id
+ and prior_session_id is not None
+ and had_active_session
+ ):
+ try:
+ await self.api.reconnect_session(
+ new_env_id, prior_session_id,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.info(
+ '[bridge:repl] Strategy-1 reconnect refused '
+ '(session=%s): %s — falling back to Strategy-2',
+ prior_session_id, err,
+ )
+ else:
+ # Server accepted the reconnect. The OLD work item is
+ # now stale (its work-secret was bound to the dead env
+ # state) — clear it server-side and locally so the next
+ # poll can pick up a fresh work-secret. The subprocess
+ # itself keeps running; the next poll will redeliver
+ # work for the same session_id with a fresh JWT.
+ #
+ # Invariant: ``new_env_id == prior_env_id`` here (the
+ # gate above enforces it), so ``_safe_stop_work`` —
+ # which reads ``self.environment_id`` — hits the right
+ # env whether called before or after the swap below.
+ if prior_work_id is not None:
+ await self._safe_stop_work(prior_work_id, force=False)
+ self.active_work_id = None
+ self.environment_id = new_env_id
+ self.environment_secret = new_env_secret
+ # Phase 12c: env+session preserved; pointer just needs
+ # a touch so ``updated_at_ms`` reflects the activity.
+ await self._update_pointer(session_id=prior_session_id)
+ logger.info(
+ '[bridge:repl] Strategy-1 reconnect succeeded: '
+ 'env=%s session=%s (preserved)',
+ new_env_id, prior_session_id,
+ )
+ return True
+
+ # ── Strategy-2: kill active session + create fresh ─────────
+ # Capture the session id we should archive BEFORE nulling the
+ # active fields below. Prefer the currently-running session id
+ # (what the user was actually doing) over the bridge's initial
+ # session id (which may be stale after an earlier recreation).
+ archive_id = self.active_session_id or self.initial_session_id
+ # If there's an active session, kill it before starting fresh.
+ if self.active_session is not None:
+ try:
+ self.active_session.kill()
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:repl] kill during recreation: %s', err
+ )
+ self.active_session = None
+ self.active_work_id = None
+ self.active_session_id = None
+ if self.active_token_refresh is not None:
+ self.active_token_refresh.cancel_all()
+ self.active_token_refresh = None
+ # Best-effort archive of the prior session id.
+ try:
+ await self.params.archive_session(archive_id)
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:repl] archive of prior session failed '
+ 'during recreation: %s', err
+ )
+ # Adopt the new env handles before create_session (the server
+ # binds the new session to whatever env we name).
+ self.environment_id = new_env_id
+ self.environment_secret = new_env_secret
+ # Create a fresh session on the new environment.
+ try:
+ new_session_id = await self.params.create_session({
+ 'environment_id': self.environment_id,
+ 'title': self.params.title,
+ 'gitRepoUrl': self.params.git_repo_url,
+ 'branch': self.params.branch,
+ })
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] create_session during recreation '
+ 'failed: %s', err,
+ )
+ return False
+ if new_session_id is None:
+ logger.warning(
+ '[bridge:repl] create_session during recreation returned None'
+ )
+ return False
+ # Replace the bookkeeping handle's session id (the external
+ # ReplBridgeHandle is immutable; this is the internal copy).
+ self.initial_session_id = new_session_id
+ # Phase 12c: env+session were both swapped; rewrite the pointer
+ # so a crash at this point recovers into the NEW state rather
+ # than the dead one.
+ await self._update_pointer(session_id=new_session_id)
+ logger.info(
+ '[bridge:repl] Strategy-2 recreate complete: env=%s session=%s',
+ self.environment_id, new_session_id,
+ )
+ return True
+
+ async def _process_work(self, work: dict[str, Any]) -> None:
+ """Handle one work item from the poll."""
+ work_id = work.get('id')
+ if not isinstance(work_id, str):
+ logger.warning('[bridge:repl] Work missing id: %s', work)
+ return
+ data = work.get('data') or {}
+ if not isinstance(data, dict):
+ logger.warning('[bridge:repl] Work missing data: %s', work)
+ return
+ work_type = data.get('type')
+ if work_type == 'healthcheck':
+ # Acknowledge and move on.
+ await self._safe_ack(work_id, self.environment_secret)
+ return
+ if work_type != 'session':
+ logger.warning(
+ '[bridge:repl] Unknown work type: %s', work_type
+ )
+ return
+
+ # Decode the work secret to get the session token + URL.
+ try:
+ secret = decode_work_secret(work.get('secret') or '')
+ except Exception as err: # noqa: BLE001
+ logger.error(
+ '[bridge:repl] Failed to decode work secret: %s', err
+ )
+ await self._safe_stop_work(work_id, force=True)
+ return
+
+ session_id = data.get('id')
+ if not isinstance(session_id, str):
+ logger.warning('[bridge:repl] Work session.id missing')
+ return
+
+ # Acknowledge — claims the work item so the server doesn't
+ # redispatch it after the reclaim window.
+ await self._safe_ack(work_id, secret.session_ingress_token)
+
+ # Phase 15: existingHandle path. If this work item is for a
+ # session that's already running (e.g. server re-dispatched
+ # after a v2 JWT refresh's ``reconnect_session`` call), push
+ # the fresh JWT to the live child and reschedule the refresh
+ # — DO NOT spawn a duplicate subprocess. Mirrors TS
+ # ``bridgeMain.ts:868-885``. The repl_bridge is single-session
+ # so this triggers exactly when the active session's id
+ # matches the inbound work; any other inbound work for the
+ # at-capacity bridge is rejected by ``_poll_loop`` (which
+ # short-circuits the poll when ``active_session is not None``).
+ if (
+ self.active_session is not None
+ and self.active_session_id == session_id
+ ):
+ try:
+ self.active_session.update_access_token(
+ secret.session_ingress_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] update_access_token for existing '
+ 'sessionId=%s failed: %s', session_id, err,
+ )
+ self.active_work_id = work_id
+ if self.active_token_refresh is not None:
+ try:
+ self.active_token_refresh.schedule(
+ session_id, secret.session_ingress_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:repl] reschedule token refresh for '
+ 'existing sessionId=%s failed: %s',
+ session_id, err,
+ )
+ logger.info(
+ '[bridge:repl] Updated access token for existing '
+ 'sessionId=%s workId=%s', session_id, work_id,
+ )
+ return
+
+ # Phase 14c: dispatch v1 (session-ingress WS) and v2 (CCR)
+ # work items both — the child SDK constructs its own
+ # transport from sdk_url + access_token (via env vars set
+ # in build_child_env). v1 work items used to be refused at
+ # this site; that gate is now lifted.
+ #
+ # v1 / v2 use DIFFERENT URL sources:
+ # * v2 (CCR) uses ``secret.api_base_url`` — the CCR control
+ # plane is the server-controlled endpoint and the secret
+ # carries the authoritative one for this work item.
+ # * v1 (session-ingress) uses ``params.session_ingress_url``
+ # — the bridge's own configured ingress URL. Using
+ # ``secret.api_base_url`` would break proxy/tunnel setups
+ # where the secret's URL points to a remote that doesn't
+ # know about locally-created sessions (TS comment at
+ # ``bridgeMain.ts:905-907``; ``replBridge.ts:1471``).
+ #
+ # The auth split is also different: v1 session-ingress
+ # accepts OAuth or JWT; v2 CCR /worker/* requires the JWT.
+ # The Python parent always forwards the JWT (carried by
+ # ``secret.session_ingress_token``) to the child as
+ # ``CLAUDE_CODE_SESSION_ACCESS_TOKEN`` regardless of v1/v2;
+ # the child runs the appropriate transport.
+ use_ccr_v2 = bool(secret.use_code_sessions)
+ # Phase 15: record the flag so the JWT refresh callback can
+ # dispatch v1 (push-token-to-child) vs v2 (reconnect_session)
+ # appropriately. Cleared in ``_await_session_done``.
+ self.active_use_ccr_v2 = use_ccr_v2
+ if use_ccr_v2:
+ sdk_url = build_ccr_v2_sdk_url(secret.api_base_url, session_id)
+ else:
+ sdk_url = build_sdk_url(
+ self.params.session_ingress_url, session_id,
+ )
+ # NOTE: Phase 6 full port will fetch worker_epoch via the v2
+ # /worker/register call. The MVP uses 0 as a placeholder since
+ # session_runner threads it into the child's env vars only when
+ # use_ccr_v2 is True.
+
+ # Spawn the child.
+ spawn_opts: SessionSpawnOpts = {
+ 'session_id': session_id,
+ 'sdk_url': sdk_url,
+ 'access_token': secret.session_ingress_token,
+ 'use_ccr_v2': use_ccr_v2,
+ 'worker_epoch': 0,
+ }
+ try:
+ self.active_session = self.spawner.spawn(
+ spawn_opts, self.params.dir,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.error('[bridge:repl] Spawn failed: %s', err)
+ await self._safe_stop_work(work_id, force=True)
+ return
+ self.active_work_id = work_id
+ self.active_session_id = session_id
+ # Phase 12c: the server may dispatch work for a session id we
+ # didn't bootstrap with (e.g. after a server-side session
+ # migration). Refresh the pointer so a crash mid-session
+ # recovers into the right session, not the init bootstrap.
+ await self._update_pointer(session_id=session_id)
+ # Wire JWT refresh: token expires after a finite window
+ # (typically 1h); without a refresh, long sessions silently
+ # break when the ingress token expires. The scheduler fires
+ # before expiry and pushes a fresh token to the child via
+ # session.update_access_token (which sends an
+ # update_environment_variables NDJSON line on stdin).
+ self.active_token_refresh = self._build_token_refresh_scheduler()
+ # Use the work-secret JWT's expires_in if present; else fall
+ # back to scheduler defaults. The work-secret payload doesn't
+ # currently surface expires_in (TS reads it from WorkSecret too;
+ # MVP doesn't decode that field), so we use the JWT's own
+ # ``exp`` claim via ``schedule`` rather than ``schedule_from_expires_in``.
+ try:
+ self.active_token_refresh.schedule(
+ session_id, secret.session_ingress_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.debug(
+ '[bridge:repl] schedule refresh failed (likely '
+ 'undecodable JWT — child uses initial token): %s', err
+ )
+ _fire_state(self.params.on_state_change, 'connected')
+ logger.debug(
+ '[bridge:repl] Spawned session_id=%s work_id=%s',
+ session_id, work_id,
+ )
+
+ # Wait for the session to complete, then clean up.
+ asyncio.create_task(
+ self._await_session_done(work_id),
+ name=f'bridge-session-await-{session_id}',
+ )
+
+ def _build_token_refresh_scheduler(self) -> TokenRefreshScheduler:
+ """Create a scheduler with v1/v2-aware ``on_refresh``.
+
+ Phase 15: v1 and v2 diverge on how to refresh.
+
+ * **v2 (CCR worker endpoints)**: CCR validates the JWT's
+ ``session_id`` claim (TS ``register_worker.go:32``), so
+ pushing an OAuth token to the child's stdin would break
+ subsequent ``/worker/*`` requests. Instead, call
+ ``api.reconnect_session(env_id, session_id)`` — the
+ server re-dispatches the work item with a fresh JWT,
+ which flows through the next poll's ``_process_work``.
+ * **v1 (Session-Ingress)**: session-ingress accepts OAuth
+ or JWT, so push the fresh OAuth token directly to the
+ child via ``session.update_access_token``.
+
+ Matches TS ``bridgeMain.ts:286-308``.
+ """
+ def on_refresh(session_id: str, fresh_token: str) -> None:
+ if self.active_use_ccr_v2:
+ # v2: schedule the async reconnect_session call.
+ # ``on_refresh`` is sync but reconnect is async; fire
+ # it as a task and don't await — the scheduler's
+ # follow-up timer fires regardless.
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ logger.warning(
+ '[bridge:repl] token refresh fired outside an '
+ 'asyncio loop (sessionId=%s) — cannot dispatch '
+ 'v2 reconnect', session_id,
+ )
+ return
+ loop.create_task(
+ self._safe_reconnect_for_refresh(session_id),
+ name=f'bridge-repl-refresh-{session_id}',
+ )
+ return
+ # v1: push fresh OAuth/JWT to child stdin.
+ session = self.active_session
+ if session is None:
+ return
+ try:
+ session.update_access_token(fresh_token)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] update_access_token via stdin '
+ 'failed: %s', err
+ )
+
+ async def get_access_token() -> str | None:
+ # OAuth token getter for the refresh chain. Phase 15:
+ # returns the parent's current OAuth token, which the
+ # scheduler then passes to ``on_refresh``. v1 pushes
+ # this token to the child via stdin; v2 ignores the
+ # token value and calls ``reconnect_session`` instead
+ # (the token's value only matters to the v1 branch —
+ # but the scheduler needs the call to return a non-None
+ # value to fire ``on_refresh`` at all).
+ return self.params.get_access_token()
+
+ return TokenRefreshScheduler(
+ get_access_token=get_access_token,
+ on_refresh=on_refresh,
+ label='repl-bridge',
+ )
+
+ async def _safe_reconnect_for_refresh(self, session_id: str) -> None:
+ """v2 token-refresh helper. Calls ``api.reconnect_session``
+ and swallows errors — the next refresh fire will retry.
+
+ Mirrors TS ``bridgeMain.ts:295-305`` (``void
+ api.reconnectSession(...).catch(...)``).
+ """
+ try:
+ await self.api.reconnect_session(self.environment_id, session_id)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] v2 token refresh via reconnect_session '
+ 'failed for sessionId=%s: %s', session_id, err,
+ )
+
+ async def _await_session_done(self, work_id: str) -> None:
+ if self.active_session is None:
+ return
+ try:
+ status = await self.active_session.wait_done()
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] wait_done raised: %s', err
+ )
+ status = 'failed'
+ logger.debug(
+ '[bridge:repl] Session done (status=%s)', status
+ )
+ # Cancel the JWT refresh scheduler — the session is done so any
+ # pending refresh would write to a dead stdin (v1) or trigger
+ # a reconnect for an archived session (v2).
+ if self.active_token_refresh is not None:
+ self.active_token_refresh.cancel_all()
+ self.active_token_refresh = None
+ # Phase 15: clear the v1/v2 flag so a future spawn starts
+ # with a clean v1/v2 default until ``_process_work`` sets it.
+ self.active_use_ccr_v2 = False
+ # Stop the work item to free the server-side lease.
+ await self._safe_stop_work(work_id, force=False)
+ self.active_session = None
+ self.active_work_id = None
+ self.active_session_id = None
+ # Phase 12c: the session just finished — clear ``session_id``
+ # in the pointer so a crash before the next poll doesn't try
+ # to resurrect an archived session. The pointer keeps its
+ # bridge_id + env_id so the next start can still reuse the env.
+ await self._update_pointer(session_id=None)
+
+ async def _safe_ack(self, work_id: str, session_token: str) -> None:
+ try:
+ await self.api.acknowledge_work(
+ self.environment_id, work_id, session_token,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] ack failed for work_id=%s: %s',
+ work_id, err,
+ )
+
+ async def _safe_stop_work(self, work_id: str, *, force: bool) -> None:
+ try:
+ await self.api.stop_work(
+ self.environment_id, work_id, force,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] stop_work failed for work_id=%s: %s',
+ work_id, err,
+ )
+
+ # ── Public handle methods (MVP) ────────────────────────────────────
+ # The MVP wires the write methods to the active session's stdin via
+ # NDJSON. Phase 6 full port will use the bridge's events endpoint
+ # (POST /v1/sessions/{id}/events) for some of these — particularly
+ # control_response — but the simpler "forward to child stdin"
+ # pattern matches what the env-based path historically did.
+
+ def write_messages(self, messages: list[Any]) -> None:
+ """Forward local messages to the child via stdin (MVP).
+
+ Phase 6 full port: route through the bridge events POST so
+ messages also reach claude.ai. The MVP just forwards to the
+ child so the local session sees them. Failures bump
+ ``dropped_batch_count`` so silent message loss is observable.
+ """
+ if self.active_session is None or not messages:
+ return
+ # MVP: serialize each message as an NDJSON line and write to
+ # the child stdin. The real wire format is more elaborate (see
+ # message_mappers.to_sdk_messages) and is wired in Phase 6 full.
+ import json
+ for msg in messages:
+ try:
+ line = json.dumps({
+ 'type': 'user',
+ 'message': {
+ 'role': 'user',
+ 'content': getattr(msg, 'content', ''),
+ },
+ 'uuid': getattr(msg, 'uuid', None),
+ }) + '\n'
+ self.active_session.write_stdin(line)
+ except Exception as err: # noqa: BLE001
+ self.dropped_batch_count += 1
+ logger.warning(
+ '[bridge:repl] write_messages failed '
+ '(dropped_batch_count=%s): %s',
+ self.dropped_batch_count, err,
+ )
+
+ def write_sdk_messages(self, messages: list[dict[str, Any]]) -> None:
+ """Forward pre-shaped SDK messages to the child (MVP)."""
+ if self.active_session is None:
+ return
+ import json
+ for msg in messages:
+ try:
+ self.active_session.write_stdin(json.dumps(msg) + '\n')
+ except Exception as err: # noqa: BLE001
+ self.dropped_batch_count += 1
+ logger.warning(
+ '[bridge:repl] write_sdk_messages failed '
+ '(dropped_batch_count=%s): %s',
+ self.dropped_batch_count, err,
+ )
+
+ def send_control_request(self, request: dict[str, Any]) -> None:
+ if self.active_session is None:
+ return
+ import json
+ self.active_session.write_stdin(json.dumps(request) + '\n')
+
+ def send_control_response(self, response: dict[str, Any]) -> None:
+ # Phase 6 full port: route via api.send_permission_response_event
+ # (POST /v1/sessions/{id}/events) instead of the child's stdin.
+ # The MVP keeps it on stdin for symmetry with write_messages.
+ if self.active_session is None:
+ return
+ import json
+ self.active_session.write_stdin(json.dumps(response) + '\n')
+
+ def send_cancel_request(self, request_id: str) -> None:
+ if self.active_session is None:
+ return
+ import json
+ self.active_session.write_stdin(json.dumps({
+ 'type': 'control_cancel_request',
+ 'request_id': request_id,
+ }) + '\n')
+
+ def send_result(self) -> None:
+ # MVP: no-op. The child emits its own result message when it
+ # finishes a turn; this exists for API parity with remote_bridge_core.
+ pass
+
+ # ── Teardown ───────────────────────────────────────────────────────
+
+ async def teardown(self) -> None:
+ """Stop poll loop → kill active session → stop work → archive → deregister.
+
+ Idempotent — safe to call multiple times.
+ """
+ if self.torn_down:
+ return
+ self.torn_down = True
+ self.poll_cancel.set()
+ if self.poll_task is not None:
+ self.poll_task.cancel()
+ try:
+ await self.poll_task
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
+ pass
+
+ # Phase 17: cancel the periodic pointer-mtime refresh task.
+ # The loop also checks ``torn_down`` between sleeps, but
+ # cancelling here ends it immediately instead of waiting up to
+ # an hour for the next tick.
+ if self.pointer_mtime_task is not None:
+ self.pointer_mtime_task.cancel()
+ try:
+ await self.pointer_mtime_task
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
+ pass
+ self.pointer_mtime_task = None
+
+ # Cancel the JWT refresh scheduler so any pending refresh
+ # doesn't write to a dead stdin during teardown.
+ if self.active_token_refresh is not None:
+ self.active_token_refresh.cancel_all()
+ self.active_token_refresh = None
+
+ # Kill the active session if any.
+ if self.active_session is not None:
+ try:
+ self.active_session.kill()
+ except Exception as err: # noqa: BLE001
+ logger.warning('[bridge:repl] kill failed: %s', err)
+ # Give it a brief grace, then force.
+ try:
+ await asyncio.wait_for(
+ self.active_session.wait_done(), timeout=2.0,
+ )
+ except asyncio.TimeoutError:
+ try:
+ self.active_session.force_kill()
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] force_kill failed: %s', err
+ )
+
+ # Stop any active work.
+ if self.active_work_id is not None:
+ await self._safe_stop_work(self.active_work_id, force=True)
+
+ # Archive the initial session (best-effort).
+ try:
+ await self.params.archive_session(self.initial_session_id)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] archive_session failed: %s', err
+ )
+
+ # Deregister the environment.
+ try:
+ await self.api.deregister_environment(self.environment_id)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] deregister failed: %s', err
+ )
+
+ # Phase 12c: clean teardown → remove pointer. A future restart
+ # should start fresh, not try to resurrect an env we just
+ # deregistered. Best-effort; a leftover pointer is harmless
+ # because read_pointer's host/dir staleness checks plus the
+ # server's expiry will eventually drop it.
+ await self._clear_pointer()
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────
+
+
+def _validated_spawn_mode(mode: str) -> Any:
+ """Cast a user-supplied spawn-mode string to the Literal type."""
+ if mode not in ('single-session', 'worktree', 'same-dir'):
+ raise ValueError(f'Invalid spawn_mode: {mode!r}')
+ return mode
+
+
+def _fire_state(
+ cb: OnStateChange | None,
+ state: BridgeState,
+ detail: str | None = None,
+) -> None:
+ if cb is None:
+ return
+ try:
+ if detail is None:
+ cb(state)
+ else:
+ cb(state, detail)
+ except Exception as err: # noqa: BLE001
+ logger.warning(
+ '[bridge:repl] on_state_change raised: %s', err
+ )
+
+
+__all__ = [
+ 'BridgeCoreParams',
+ 'BridgeState',
+ 'ReplBridgeHandle',
+ 'init_bridge_core',
+]
diff --git a/src/bridge/repl_bridge_handle.py b/src/bridge/repl_bridge_handle.py
new file mode 100644
index 000000000..e7340a4af
--- /dev/null
+++ b/src/bridge/repl_bridge_handle.py
@@ -0,0 +1,100 @@
+"""Process-global pointer to the active REPL bridge handle.
+
+Ports ``typescript/src/bridge/replBridgeHandle.ts``.
+
+Callers outside the React tree that owns the bridge (tools, slash
+commands) need a way to invoke handle methods (subscribe, send control
+events, etc.). Same one-bridge-per-process justification as
+``bridge_debug.ts`` — the handle's closure captures the session ID and
+``get_access_token`` that created the session.
+
+Set from the orchestrator (Phase 5/6) when init completes; cleared on
+teardown. Reading is best-effort: ``None`` means no bridge is connected.
+
+⚠️ **Phase 1 caveat — multi-peer dedup is BROKEN until Phase 2** ⚠️
+
+The TS version calls ``utils/concurrentSessions.updateSessionBridgeId()``
+on every set, which publishes the local bridge ID so other peers can
+dedup. That cross-folder dep lives in Phase 2 (per refactoring plan §5
+second-wave list). The Python port replaces it with a debug-logged
+TODO-noop until Phase 2 lands the publisher. Operationally this means
+**two concurrent Python bridges in the same workspace will not dedup
+against each other** — both will appear in claude.ai/code session lists.
+The public API of this module is unaffected; only the cross-process
+side effect is missing.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from src.bridge.session_id_compat import to_compat_session_id
+from src.bridge.types import ReplBridgeHandle
+
+logger = logging.getLogger(__name__)
+
+_handle: ReplBridgeHandle | None = None
+
+
+def set_repl_bridge_handle(h: ReplBridgeHandle | None) -> None:
+ """Register (or clear) the active REPL bridge handle.
+
+ Mirrors TS ``setReplBridgeHandle`` on ``replBridgeHandle.ts:18-23``.
+
+ The TS version also calls
+ ``updateSessionBridgeId(getSelfBridgeCompatId() ?? null)`` to publish
+ the local bridge ID for cross-peer dedup. That helper doesn't exist
+ in Python yet (Phase 2). Until then, calling ``set_repl_bridge_handle``
+ just updates the in-process pointer; cross-process dedup is a no-op.
+ """
+ global _handle
+ _handle = h
+ # TODO(phase2): once src/utils/concurrent_sessions.py exists, call
+ # update_session_bridge_id(get_self_bridge_compat_id())
+ # to publish the local bridge ID so other peers can dedup us out.
+ # Until then, cross-peer dedup is silently broken — a debug log keeps
+ # the gap visible during development.
+ logger.debug(
+ '[bridge:handle] %s (multi-peer dedup not yet wired — Phase 2)',
+ 'set' if h is not None else 'cleared',
+ )
+
+
+def get_repl_bridge_handle() -> ReplBridgeHandle | None:
+ """Get the active REPL bridge handle, or ``None`` if not connected.
+
+ Mirrors TS ``getReplBridgeHandle`` on ``replBridgeHandle.ts:25-27``.
+ """
+ return _handle
+
+
+def get_self_bridge_compat_id() -> str | None:
+ """Our own bridge session ID in the ``session_*`` compat format.
+
+ Mirrors TS ``getSelfBridgeCompatId`` on ``replBridgeHandle.ts:33-36``.
+ Returns ``None`` when no bridge is connected. The retag from ``cse_*``
+ to ``session_*`` matches what ``/v1/sessions`` responses use, so
+ server-driven peer dedup compares apples to apples.
+ """
+ h = get_repl_bridge_handle()
+ if h is None:
+ return None
+ return to_compat_session_id(h.bridge_session_id)
+
+
+def _reset_for_testing() -> None:
+ """Clear the module-global pointer (tests only).
+
+ Test cleanup helper — not part of the public API. The Phase 1 module
+ pattern (see ``session_id_compat._reset_shim_gate_for_testing``)
+ establishes this as the convention.
+ """
+ global _handle
+ _handle = None
+
+
+__all__ = [
+ 'get_repl_bridge_handle',
+ 'get_self_bridge_compat_id',
+ 'set_repl_bridge_handle',
+]
diff --git a/src/bridge/repl_bridge_transport.py b/src/bridge/repl_bridge_transport.py
new file mode 100644
index 000000000..65416d89e
--- /dev/null
+++ b/src/bridge/repl_bridge_transport.py
@@ -0,0 +1,417 @@
+"""``ReplBridgeTransport`` — unified v1/v2 transport interface.
+
+Ports the consumer-facing surface of
+``typescript/src/bridge/replBridgeTransport.ts:23-369``.
+
+Three pieces:
+
+ * ``ReplBridgeTransport`` Protocol — the 14-method contract that
+ ``replBridge`` writes against. v1 and v2 implementations conform.
+ * ``create_v2_repl_transport`` — the v2 factory: SSE reads +
+ ``CCRClient`` writes, with the epoch-mismatch handler that closes
+ both transports + raises ``EpochSupersededError`` to unwind callers.
+ * ``create_v1_repl_transport`` — phase 14c v1 factory: thin
+ pass-through over :class:`HybridTransport` (WS reads + POST
+ writes via ``SerialBatchEventUploader``).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any, Protocol
+
+from src.bridge.close_codes import (
+ WS_CLOSE_EPOCH_MISMATCH,
+ WS_CLOSE_INIT_FAILURE,
+)
+from src.bridge.exceptions import EpochSupersededError
+from src.transports.ccr_client import CCRClient, CCRClientOptions
+from src.transports.hybrid_transport import HybridTransport
+from src.transports.sse_transport import SSEEvent, SSETransport
+
+logger = logging.getLogger(__name__)
+
+
+# ─── Protocol ──────────────────────────────────────────────────────────────
+
+
+class ReplBridgeTransport(Protocol):
+ """The 14-method contract ``replBridge`` writes against.
+
+ Mirrors TS ``replBridgeTransport.ts:23-70``. ``write`` and
+ ``write_batch`` return awaitables (``async def`` in
+ implementations); the rest are sync — flag-checks, callback
+ registrations, etc.
+ """
+
+ async def write(self, message: dict[str, Any]) -> None: ...
+ async def write_batch(self, messages: list[dict[str, Any]]) -> None: ...
+ def close(self) -> None: ...
+ def is_connected_status(self) -> bool: ...
+ def get_state_label(self) -> str: ...
+ def set_on_data(self, cb: Callable[[str], None]) -> None: ...
+ def set_on_close(self, cb: Callable[[int | None], None]) -> None: ...
+ def set_on_connect(self, cb: Callable[[], None]) -> None: ...
+ def connect(self) -> None: ...
+ def get_last_sequence_num(self) -> int: ...
+
+ @property
+ def dropped_batch_count(self) -> int: ...
+
+ def report_state(self, state: dict[str, Any]) -> None: ...
+ def report_metadata(self, metadata: dict[str, Any]) -> None: ...
+ def report_delivery(self, event_id: str, status: str) -> None: ...
+ async def flush(self) -> None: ...
+
+
+# ─── v2 implementation ────────────────────────────────────────────────────
+
+
+@dataclass
+class V2TransportOptions:
+ """Construction-time knobs for ``create_v2_repl_transport``."""
+
+ session_url: str
+ ingress_token: str
+ session_id: str
+ epoch: int
+ initial_sequence_num: int = 0
+ heartbeat_interval_seconds: float | None = None
+ heartbeat_jitter_fraction: float | None = None
+ outbound_only: bool = False
+ get_auth_token: Callable[[], str | None] | None = None
+
+
+class _V2ReplTransport:
+ """Concrete v2 transport (SSE reads + CCRClient writes).
+
+ Mirrors ``replBridgeTransport.ts:119-369``. Constructed via
+ ``create_v2_repl_transport``; satisfies the ``ReplBridgeTransport``
+ Protocol structurally.
+ """
+
+ def __init__(
+ self,
+ sse: SSETransport,
+ ccr: CCRClient,
+ *,
+ epoch: int,
+ outbound_only: bool,
+ ) -> None:
+ self._sse = sse
+ self._ccr = ccr
+ self._epoch = epoch
+ self._outbound_only = outbound_only
+ self._closed = False
+
+ self._on_connect_cb: Callable[[], None] | None = None
+ self._on_close_cb: Callable[[int | None], None] | None = None
+ self._ccr_initialized = False
+
+ # ─── State ──────────────────────────────────────────────────────────
+
+ def is_connected_status(self) -> bool:
+ """Write-readiness, not read-readiness — replBridge checks this
+ before calling ``write_batch``. SSE open state is orthogonal."""
+ return self._ccr_initialized and not self._closed
+
+ def get_state_label(self) -> str:
+ if self._sse.is_closed_status():
+ return 'closed'
+ if self._sse.is_connected_status():
+ return 'connected' if self._ccr_initialized else 'init'
+ return 'connecting'
+
+ def get_last_sequence_num(self) -> int:
+ if self._outbound_only:
+ return 0 # no SSE read stream → no cursor to report
+ return self._sse.get_last_sequence_num()
+
+ @property
+ def dropped_batch_count(self) -> int:
+ return self._ccr.dropped_batch_count
+
+ # ─── Callback wiring ───────────────────────────────────────────────
+
+ def set_on_data(self, cb: Callable[[str], None]) -> None:
+ if self._outbound_only:
+ return # no inbound stream — set_on_data is a no-op
+ self._sse.set_on_data(cb)
+
+ def set_on_close(self, cb: Callable[[int | None], None]) -> None:
+ self._on_close_cb = cb
+ # When the SSE reconnect-budget is exhausted, fire onClose so
+ # replBridge can reconnect via the poll loop.
+ self._sse.set_on_close(lambda code: cb(code))
+
+ def set_on_connect(self, cb: Callable[[], None]) -> None:
+ self._on_connect_cb = cb
+
+ # ─── Lifecycle ─────────────────────────────────────────────────────
+
+ def connect(self) -> None:
+ """Open SSE + initialize CCRClient. Returns immediately."""
+ loop = asyncio.get_running_loop()
+ loop.create_task(self._do_connect(), name='v2-transport-connect')
+
+ async def _do_connect(self) -> None:
+ if not self._outbound_only:
+ await self._sse.connect()
+ try:
+ await self._ccr.initialize(self._epoch)
+ except EpochSupersededError:
+ self._fire_close(WS_CLOSE_EPOCH_MISMATCH)
+ return
+ except Exception as exc: # noqa: BLE001
+ logger.warning('[v2-transport] CCR init failed: %s', exc)
+ self._fire_close(WS_CLOSE_INIT_FAILURE)
+ return
+ self._ccr_initialized = True
+ if self._on_connect_cb is not None:
+ try:
+ self._on_connect_cb()
+ except Exception as exc: # noqa: BLE001
+ logger.warning('[v2-transport] on_connect_cb raised: %s', exc)
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ self._sse.close()
+ self._ccr.close()
+
+ async def aclose(self) -> None:
+ self.close()
+ await asyncio.gather(
+ self._sse.aclose(),
+ self._ccr.aclose(),
+ return_exceptions=True,
+ )
+
+ # ─── Write API ─────────────────────────────────────────────────────
+
+ async def write(self, message: dict[str, Any]) -> None:
+ await self._ccr.write_event(message)
+
+ async def write_batch(self, messages: list[dict[str, Any]]) -> None:
+ for m in messages:
+ if self._closed:
+ return
+ await self._ccr.write_event(m)
+
+ async def flush(self) -> None:
+ await self._ccr.flush()
+
+ # ─── State / metadata / delivery passthrough ──────────────────────
+
+ def report_state(self, state: dict[str, Any]) -> None:
+ self._ccr.report_state(state)
+
+ def report_metadata(self, metadata: dict[str, Any]) -> None:
+ self._ccr.report_metadata(metadata)
+
+ def report_delivery(self, event_id: str, status: str) -> None:
+ self._ccr.report_delivery(event_id, status)
+
+ # ─── Internal helpers ─────────────────────────────────────────────
+
+ def _fire_close(self, code: int) -> None:
+ cb = self._on_close_cb
+ if cb is None:
+ return
+ try:
+ cb(code)
+ except Exception as exc: # noqa: BLE001
+ logger.warning('[v2-transport] on_close_cb raised: %s', exc)
+
+
+# ─── Public factory ───────────────────────────────────────────────────────
+
+
+async def create_v2_repl_transport(opts: V2TransportOptions) -> _V2ReplTransport:
+ """Build a v2 transport (SSE reads + CCRClient writes).
+
+ Mirrors ``replBridgeTransport.ts:119-369``. The
+ ``on_epoch_mismatch`` handler closes both transports, fires
+ ``on_close_cb(4090)``, and raises ``EpochSupersededError`` to
+ unwind callers (matches TS ``throw 'epoch superseded'``).
+
+ The auth-header closure is per-instance so multiple concurrent
+ sessions don't clobber each other (mirrors the TS multi-session
+ safety fix).
+ """
+ # Per-instance auth header closure (multi-session safety).
+ def _auth_headers() -> dict[str, str]:
+ token: str | None
+ if opts.get_auth_token is not None:
+ token = opts.get_auth_token()
+ else:
+ token = opts.ingress_token
+ if not token:
+ return {}
+ return {'Authorization': f'Bearer {token}'}
+
+ # Derive SSE stream URL: append /worker/events/stream to the session URL.
+ sse_url = opts.session_url.rstrip('/') + '/worker/events/stream'
+ sse = SSETransport(
+ url=sse_url,
+ headers={},
+ session_id=opts.session_id,
+ get_auth_headers=_auth_headers,
+ initial_sequence_num=opts.initial_sequence_num,
+ )
+
+ transport_ref: dict[str, _V2ReplTransport | None] = {'t': None}
+
+ def _on_epoch_mismatch() -> None:
+ """Called by CCRClient on 409. Closes both, fires onClose(4090),
+ raises EpochSupersededError to unwind the caller."""
+ t = transport_ref['t']
+ if t is not None:
+ try:
+ t._sse.close()
+ t._ccr.close()
+ t._fire_close(WS_CLOSE_EPOCH_MISMATCH)
+ except Exception as exc: # noqa: BLE001
+ logger.warning('[v2-transport] epoch-mismatch cleanup: %s', exc)
+
+ # Heartbeat options: pass through if explicitly set.
+ ccr_opts_kwargs: dict[str, Any] = {
+ 'get_auth_headers': _auth_headers,
+ 'on_epoch_mismatch': _on_epoch_mismatch,
+ }
+ if opts.heartbeat_interval_seconds is not None:
+ ccr_opts_kwargs['heartbeat_interval_seconds'] = opts.heartbeat_interval_seconds
+ if opts.heartbeat_jitter_fraction is not None:
+ ccr_opts_kwargs['heartbeat_jitter_fraction'] = opts.heartbeat_jitter_fraction
+ ccr = CCRClient(
+ base_url=opts.session_url,
+ options=CCRClientOptions(**ccr_opts_kwargs),
+ )
+
+ # Wire SSE → CCRClient delivery ACKs (TS replBridgeTransport.ts:249-252).
+ # ACK both 'received' AND 'processed' immediately so daemon-path
+ # reconnects don't re-queue events forever.
+ def _ack(event: SSEEvent) -> None:
+ if not event.event_id:
+ return
+ try:
+ ccr.report_delivery(event.event_id, 'received')
+ ccr.report_delivery(event.event_id, 'processed')
+ except Exception as exc: # noqa: BLE001
+ logger.debug('[v2-transport] delivery ACK failed: %s', exc)
+
+ sse.set_on_event(_ack)
+
+ transport = _V2ReplTransport(
+ sse=sse,
+ ccr=ccr,
+ epoch=opts.epoch,
+ outbound_only=opts.outbound_only,
+ )
+ transport_ref['t'] = transport
+ return transport
+
+
+class _V1ReplTransport:
+ """v1 adapter — wraps :class:`HybridTransport` into the
+ :class:`ReplBridgeTransport` Protocol surface.
+
+ ``HybridTransport`` already has the full write/read API (it
+ extends ``WebSocketTransport``); this adapter is a thin
+ pass-through so the consumer's ``transport`` variable has a
+ single type regardless of v1/v2.
+
+ Mirrors TS ``replBridgeTransport.ts:78-103``.
+ """
+
+ def __init__(self, hybrid: 'HybridTransport') -> None:
+ self._hybrid = hybrid
+
+ async def write(self, message: dict[str, Any]) -> None:
+ await self._hybrid.write(message)
+
+ async def write_batch(self, messages: list[dict[str, Any]]) -> None:
+ await self._hybrid.write_batch(messages)
+
+ def close(self) -> None:
+ """Sync close. Inherits ``HybridTransport.close``'s drop-on-close
+ semantics for queued POSTs (grace flush is best-effort within
+ ``CLOSE_GRACE_S``). Callers wanting guaranteed delivery should
+ ``await self.flush()`` first."""
+ self._hybrid.close()
+
+ def is_connected_status(self) -> bool:
+ return self._hybrid.is_connected_status()
+
+ def get_state_label(self) -> str:
+ return self._hybrid.get_state_label()
+
+ def set_on_data(self, cb: Callable[[str], None]) -> None:
+ self._hybrid.set_on_data(cb)
+
+ def set_on_close(self, cb: Callable[[int | None], None]) -> None:
+ self._hybrid.set_on_close(cb)
+
+ def set_on_connect(self, cb: Callable[[], None]) -> None:
+ self._hybrid.set_on_connect(cb)
+
+ def connect(self) -> None:
+ """Schedule the WS connect as a background task.
+
+ ``HybridTransport.connect`` (inherited from
+ ``WebSocketTransport``) is async; the ``ReplBridgeTransport``
+ Protocol's ``connect`` is sync (fire-and-forget). Schedule the
+ coroutine and return immediately, matching the v2 factory's
+ equivalent pattern (``_V2ReplTransport.connect``).
+ """
+ loop = asyncio.get_running_loop()
+ loop.create_task(
+ self._hybrid.connect(), name='v1-repl-transport-connect',
+ )
+
+ def get_last_sequence_num(self) -> int:
+ """v1 always returns 0 — the Session-Ingress WS doesn't use
+ SSE-style sequence numbers. Mirrors TS line 94 comment."""
+ return 0
+
+ @property
+ def dropped_batch_count(self) -> int:
+ return self._hybrid.dropped_batch_count
+
+ # ─── v2-only no-ops (Protocol requires them) ─────────────────────
+
+ def report_state(self, state: dict[str, Any]) -> None:
+ return None
+
+ def report_metadata(self, metadata: dict[str, Any]) -> None:
+ return None
+
+ def report_delivery(self, event_id: str, status: str) -> None:
+ return None
+
+ async def flush(self) -> None:
+ """Drain pending POSTs. Delegates to the hybrid's flush which
+ runs the uploader's drain to completion."""
+ await self._hybrid.flush()
+
+
+def create_v1_repl_transport(hybrid: 'HybridTransport') -> ReplBridgeTransport:
+ """Build a v1 :class:`ReplBridgeTransport` from a
+ :class:`HybridTransport`.
+
+ Thin no-op adapter so ``replBridge``'s ``transport`` variable
+ has a single type. Mirrors TS ``replBridgeTransport.ts:78-103``.
+ """
+ return _V1ReplTransport(hybrid)
+
+
+__all__ = [
+ 'ReplBridgeTransport',
+ 'V2TransportOptions',
+ 'create_v1_repl_transport',
+ 'create_v2_repl_transport',
+]
diff --git a/src/bridge/sdk_types.py b/src/bridge/sdk_types.py
new file mode 100644
index 000000000..95975d92a
--- /dev/null
+++ b/src/bridge/sdk_types.py
@@ -0,0 +1,275 @@
+"""CCR wire-format types — discriminated unions used by the bridge layer.
+
+Ports a curated subset of the TS SDK schemas at
+``typescript/src/entrypoints/sdk/{coreTypes.generated.ts, shared.ts,
+controlSchemas.ts}`` (~3,393 lines combined). Only the variants this
+plan's WIs touch are defined here (~250-300 LOC). For full schema, see
+the TS sources.
+
+We use ``TypedDict(total=False)`` per variant + a top-level ``Union``
+alias so the discriminating field (``type`` / ``subtype``) tells callers
+which variant they are looking at. This matches Python's `pyright` /
+`mypy` discriminated-union pattern; consumers narrow via ``isinstance``
+or ``match`` on the discriminator field.
+
+NOTE on field naming: every wire-level field uses **snake_case** because
+that is what the CCR servers send. ``src/types/messages.py`` has a
+parallel ``Message`` hierarchy with ``camelCase`` fields for the local
+REPL; the two never alias and live in separate files.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Literal, TypedDict, Union
+
+# ─── Core SDK message variants (the `type` discriminator) ──────────────────
+
+# Each variant is a TypedDict with ``total=False`` so optional fields can
+# be omitted. The discriminating ``type`` field is required by the
+# discriminated-union convention.
+
+
+class UserMessage(TypedDict, total=False):
+ """User turn from the model API.
+
+ Source: ``coreTypes.generated.ts`` UserMessage variant.
+ """
+
+ type: Literal['user']
+ uuid: str
+ session_id: str
+ parent_tool_use_id: str | None
+ message: dict[str, Any]
+
+
+class AssistantMessage(TypedDict, total=False):
+ """Assistant turn (text + tool calls) from the model API."""
+
+ type: Literal['assistant']
+ uuid: str
+ session_id: str
+ parent_tool_use_id: str | None
+ message: dict[str, Any]
+
+
+class SystemMessage(TypedDict, total=False):
+ """System message: init, post_turn_summary, local_command, etc."""
+
+ type: Literal['system']
+ subtype: str
+ uuid: str
+ session_id: str
+ data: dict[str, Any]
+
+
+class SDKResultSuccess(TypedDict, total=False):
+ """Result message — emitted on session teardown for archival.
+
+ Used by ``make_result_message`` (WI-2.6c) to tell the server the
+ session ended cleanly. See ``bridgeMessaging.ts:399-416``.
+ """
+
+ type: Literal['result']
+ subtype: Literal['success']
+ duration_ms: int
+ duration_api_ms: int
+ is_error: bool
+ num_turns: int
+ result: str
+ stop_reason: str | None
+ total_cost_usd: float
+ usage: dict[str, Any]
+ modelUsage: dict[str, Any]
+ permission_denials: list[dict[str, Any]]
+ session_id: str
+ uuid: str
+
+
+class ToolResultMessage(TypedDict, total=False):
+ """Tool execution result, emitted by the local REPL after a tool call."""
+
+ type: Literal['tool_result']
+ uuid: str
+ session_id: str
+ tool_use_id: str
+ content: Any
+ is_error: bool
+
+
+class KeepAliveMessage(TypedDict, total=False):
+ """Empty heartbeat from the server. Filtered by the ingress router."""
+
+ type: Literal['keep_alive']
+
+
+class StreamlinedTextMessage(TypedDict, total=False):
+ """Streamlined text payload (compact wire form). Filtered by Direct Connect."""
+
+ type: Literal['streamlined_text']
+ text: str
+
+
+class StreamlinedToolUseSummaryMessage(TypedDict, total=False):
+ """Streamlined tool-use summary. Filtered by Direct Connect."""
+
+ type: Literal['streamlined_tool_use_summary']
+ tool_name: str
+ summary: str
+
+
+# ─── control_request inner subtypes (the `subtype` discriminator) ──────────
+
+# control_request.request is itself a discriminated union on ``subtype``.
+# We declare each subtype as a TypedDict and union them.
+
+
+class InitializeRequest(TypedDict, total=False):
+ subtype: Literal['initialize']
+
+
+class SetModelRequest(TypedDict, total=False):
+ subtype: Literal['set_model']
+ model: str | None
+
+
+class SetMaxThinkingTokensRequest(TypedDict, total=False):
+ subtype: Literal['set_max_thinking_tokens']
+ max_thinking_tokens: int | None
+
+
+class SetPermissionModeRequest(TypedDict, total=False):
+ subtype: Literal['set_permission_mode']
+ mode: str # PermissionMode in src/permissions/types.py — kept str on the wire.
+
+
+class InterruptRequest(TypedDict, total=False):
+ subtype: Literal['interrupt']
+
+
+class SDKControlPermissionRequest(TypedDict, total=False):
+ """``can_use_tool`` payload — the only request the server sends to ask
+ a permission decision from the client.
+ """
+
+ subtype: Literal['can_use_tool']
+ tool_name: str
+ input: dict[str, Any]
+ tool_use_id: str | None
+
+
+SDKControlRequestInner = Union[
+ InitializeRequest,
+ SetModelRequest,
+ SetMaxThinkingTokensRequest,
+ SetPermissionModeRequest,
+ InterruptRequest,
+ SDKControlPermissionRequest,
+]
+
+
+class SDKControlRequest(TypedDict, total=False):
+ """``{type:'control_request', request_id, request}`` envelope."""
+
+ type: Literal['control_request']
+ request_id: str
+ request: SDKControlRequestInner
+
+
+# ─── control_response variants ─────────────────────────────────────────────
+
+
+class ControlResponseSuccess(TypedDict, total=False):
+ subtype: Literal['success']
+ request_id: str
+ response: dict[str, Any] | None
+
+
+class ControlResponseError(TypedDict, total=False):
+ subtype: Literal['error']
+ request_id: str
+ error: str
+
+
+ControlResponseInner = Union[ControlResponseSuccess, ControlResponseError]
+
+
+class SDKControlResponse(TypedDict, total=False):
+ """``{type:'control_response', response}`` envelope."""
+
+ type: Literal['control_response']
+ response: ControlResponseInner
+
+
+# ─── Cancellation envelope ─────────────────────────────────────────────────
+
+
+class SDKControlCancelRequest(TypedDict, total=False):
+ """Server → client: cancel a pending permission prompt by ``request_id``."""
+
+ type: Literal['control_cancel_request']
+ request_id: str
+ tool_use_id: str | None
+
+
+# ─── Top-level union and aliases ───────────────────────────────────────────
+
+# The full SDKMessage union — anything the read-side router can encounter
+# on the bridge stream. Note this includes both data messages (user,
+# assistant, etc.) and control messages (control_request, control_response,
+# control_cancel_request); the router branches on ``type`` first.
+
+SDKMessage = Union[
+ UserMessage,
+ AssistantMessage,
+ SystemMessage,
+ SDKResultSuccess,
+ ToolResultMessage,
+ KeepAliveMessage,
+ StreamlinedTextMessage,
+ StreamlinedToolUseSummaryMessage,
+ SDKControlRequest,
+ SDKControlResponse,
+ SDKControlCancelRequest,
+]
+
+# Write-side alias — what the client sends to the server. control_request
+# is excluded because the local CLI never originates a control_request
+# (the server initiates those); control_response is what the client uses
+# to reply. Matches ``StdoutMessage`` in TS.
+
+StdoutMessage = Union[
+ UserMessage,
+ AssistantMessage,
+ SystemMessage,
+ SDKResultSuccess,
+ ToolResultMessage,
+ SDKControlResponse,
+ SDKControlRequest, # for client-originated interrupt
+]
+
+
+__all__ = [
+ 'AssistantMessage',
+ 'ControlResponseError',
+ 'ControlResponseInner',
+ 'ControlResponseSuccess',
+ 'InitializeRequest',
+ 'InterruptRequest',
+ 'KeepAliveMessage',
+ 'SDKControlCancelRequest',
+ 'SDKControlPermissionRequest',
+ 'SDKControlRequest',
+ 'SDKControlRequestInner',
+ 'SDKControlResponse',
+ 'SDKMessage',
+ 'SDKResultSuccess',
+ 'SetMaxThinkingTokensRequest',
+ 'SetModelRequest',
+ 'SetPermissionModeRequest',
+ 'StdoutMessage',
+ 'StreamlinedTextMessage',
+ 'StreamlinedToolUseSummaryMessage',
+ 'SystemMessage',
+ 'ToolResultMessage',
+ 'UserMessage',
+]
diff --git a/src/bridge/session_id_compat.py b/src/bridge/session_id_compat.py
new file mode 100644
index 000000000..8cd2f2d9b
--- /dev/null
+++ b/src/bridge/session_id_compat.py
@@ -0,0 +1,81 @@
+"""Session ID tag translation helpers for the CCR v2 compat layer.
+
+Ports ``typescript/src/bridge/sessionIdCompat.ts``.
+
+The CCR v2 compat layer issues tagged IDs in two prefixes:
+
+* ``cse_*`` — the v2 infrastructure tag (work poll, worker endpoints).
+* ``session_*`` — the v1 compat tag (client-facing /v1/sessions endpoints).
+
+Both encode the same underlying UUID. The translation helpers re-tag IDs as
+needed when calls cross layers. The ``set_cse_shim_gate()`` hook lets a
+caller inject a kill-switch for the shim without this module needing to
+import bridge_enabled.py (mirrors TS module-isolation reasoning at
+``sessionIdCompat.ts:11-13``).
+"""
+
+from __future__ import annotations
+
+from typing import Callable
+
+_CSE_PREFIX = 'cse_'
+_SESSION_PREFIX = 'session_'
+
+_cse_shim_enabled: Callable[[], bool] | None = None
+
+
+def set_cse_shim_gate(gate: Callable[[], bool]) -> None:
+ """Register the cse_-shim kill-switch.
+
+ Mirrors TS ``setCseShimGate`` on ``sessionIdCompat.ts:21-23``. When the
+ gate returns ``False``, ``to_compat_session_id`` becomes a no-op. The
+ gate defaults to "active" (shim on) when this is never called, matching
+ TS ``isCseShimEnabled()``'s default-true behavior.
+ """
+ global _cse_shim_enabled
+ _cse_shim_enabled = gate
+
+
+def to_compat_session_id(session_id: str) -> str:
+ """Re-tag a ``cse_*`` session ID to ``session_*`` for the v1 compat API.
+
+ Mirrors TS ``toCompatSessionId`` on ``sessionIdCompat.ts:38-42``.
+ No-op for IDs that aren't ``cse_*``. When the cse-shim gate is disabled,
+ also a no-op (returns input unchanged).
+ """
+ if not session_id.startswith(_CSE_PREFIX):
+ return session_id
+ if _cse_shim_enabled is not None and not _cse_shim_enabled():
+ return session_id
+ return _SESSION_PREFIX + session_id[len(_CSE_PREFIX):]
+
+
+def to_infra_session_id(session_id: str) -> str:
+ """Re-tag a ``session_*`` ID to ``cse_*`` for infrastructure-layer calls.
+
+ Mirrors TS ``toInfraSessionId`` on ``sessionIdCompat.ts:54-57``. Inverse
+ of ``to_compat_session_id``. No-op for IDs that aren't ``session_*``.
+ Unlike the compat direction, this is NOT gated by the cse-shim — once
+ a caller crosses below the compat layer, the infra tag is what the
+ server expects regardless of the shim's state.
+ """
+ if not session_id.startswith(_SESSION_PREFIX):
+ return session_id
+ return _CSE_PREFIX + session_id[len(_SESSION_PREFIX):]
+
+
+def _reset_shim_gate_for_testing() -> None:
+ """Reset module-global gate to ``None`` (tests only).
+
+ Test cleanup helper — not part of the public API. Callers must not
+ rely on this in production code.
+ """
+ global _cse_shim_enabled
+ _cse_shim_enabled = None
+
+
+__all__ = [
+ 'set_cse_shim_gate',
+ 'to_compat_session_id',
+ 'to_infra_session_id',
+]
diff --git a/src/bridge/session_runner.py b/src/bridge/session_runner.py
new file mode 100644
index 000000000..aec699735
--- /dev/null
+++ b/src/bridge/session_runner.py
@@ -0,0 +1,934 @@
+"""Child-CLI spawner for the bridge — Phase 4.
+
+Ports ``typescript/src/bridge/sessionRunner.ts``.
+
+Used by env-based orchestrators (Phase 6 ``replBridge`` and Phase 8
+``bridgeMain``) to spawn local Claude Code CLI children, parse their
+NDJSON stdout for activity events + control_requests + first user
+message, and expose a ``SessionHandle`` for lifecycle control.
+
+The Phase 5 env-less ``remoteBridgeCore`` does NOT use this module —
+it's an in-process bridge that connects to a remote v2 session via
+SSE+CCRClient without any local subprocess.
+
+Design notes:
+
+* Built on ``asyncio.create_subprocess_exec`` with three piped streams
+ (stdin for control, stdout for NDJSON, stderr for diagnostics).
+* Activity + stderr ring buffers via ``collections.deque(maxlen=N)``.
+* Environment is built from a strict allowlist — secrets in the parent
+ process (``CLAUDE_CODE_OAUTH_TOKEN``, ``ANTHROPIC_API_KEY``, etc.)
+ must never leak to the child, which authenticates via
+ ``CLAUDE_CODE_SESSION_ACCESS_TOKEN`` only.
+* ``SessionHandle.update_access_token`` writes an
+ ``update_environment_variables`` NDJSON line to the child's stdin so
+ the bridge can refresh tokens without restarting the child.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import signal
+import sys
+import tempfile
+import time
+from collections import deque
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from src.bridge.debug_utils import debug_truncate
+from src.bridge.types import (
+ SessionActivity,
+ SessionDoneStatus,
+ SessionHandle,
+ SessionSpawner,
+ SessionSpawnOpts,
+)
+
+logger = logging.getLogger(__name__)
+
+
+MAX_ACTIVITIES = 10
+MAX_STDERR_LINES = 10
+
+
+# ── Environment sanitizer ─────────────────────────────────────────────────
+
+
+CHILD_ENV_ALLOWLIST: frozenset[str] = frozenset({
+ # System / shell
+ 'PATH', 'HOME', 'USERPROFILE', 'HOMEPATH', 'HOMEDRIVE',
+ 'USERNAME', 'USER', 'LOGNAME',
+ 'TEMP', 'TMP', 'TMPDIR',
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC', 'WINDIR',
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
+ # Node.js runtime
+ 'NODE_OPTIONS', 'NODE_PATH', 'NODE_ENV',
+ # OpenClaude session / bridge (non-secret)
+ 'CLAUDE_CODE_ENVIRONMENT_KIND',
+ 'CLAUDE_CODE_FORCE_SANDBOX',
+ 'CLAUDE_CODE_BUBBLEWRAP',
+ 'CLAUDE_CODE_ENTRYPOINT',
+ 'CLAUDE_CODE_COORDINATOR_MODE',
+ 'CLAUDE_CODE_PERMISSIONS_VERSION',
+ 'CLAUDE_CODE_PERMISSIONS_SETTING',
+ # Display / terminal
+ 'TERM', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR',
+})
+"""Safe variables that the child needs to function. Everything else
+must not be inherited — the child authenticates exclusively via
+``CLAUDE_CODE_SESSION_ACCESS_TOKEN``. Mirrors TS allowlist on
+``sessionRunner.ts:24-43``.
+"""
+
+
+@dataclass
+class BuildChildEnvOpts:
+ """Inputs to ``build_child_env``. Mirrors TS ``BuildChildEnvOpts``."""
+
+ access_token: str
+ use_ccr_v2: bool = False
+ worker_epoch: int | None = None
+ sandbox: bool = False
+
+
+def build_child_env(
+ parent_env: dict[str, str],
+ opts: BuildChildEnvOpts,
+) -> dict[str, str]:
+ """Build the child env from an allowlist, then layer bridge overrides.
+
+ Mirrors TS ``buildChildEnv`` on ``sessionRunner.ts:56-80``.
+
+ Importantly:
+
+ * ``CLAUDE_CODE_OAUTH_TOKEN`` is explicitly omitted (allowlist does
+ not include it; even if the parent has it set, we strip).
+ * ``CLAUDE_CODE_SESSION_ACCESS_TOKEN`` is the ONLY auth signal sent
+ to the child — written explicitly, not inherited.
+ * ``CLAUDE_CODE_ENVIRONMENT_KIND='bridge'`` tells the child it's
+ running under a bridge orchestrator.
+ """
+ env = {k: v for k, v in parent_env.items() if k in CHILD_ENV_ALLOWLIST}
+ env['CLAUDE_CODE_ENVIRONMENT_KIND'] = 'bridge'
+ if opts.sandbox:
+ env['CLAUDE_CODE_FORCE_SANDBOX'] = '1'
+ env['CLAUDE_CODE_SESSION_ACCESS_TOKEN'] = opts.access_token
+ env['CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2'] = '1'
+ if opts.use_ccr_v2:
+ env['CLAUDE_CODE_USE_CCR_V2'] = '1'
+ if opts.worker_epoch is not None:
+ env['CLAUDE_CODE_WORKER_EPOCH'] = str(opts.worker_epoch)
+ return env
+
+
+def safe_filename_id(value: str) -> str:
+ """Sanitize a session ID for use in file names.
+
+ Mirrors TS ``safeFilenameId`` on ``sessionRunner.ts:87-89``. Strips
+ any character that could cause path traversal (``../``, ``/``) or
+ other filesystem issues, replacing with underscores.
+ """
+ return ''.join(c if c.isalnum() or c in '_-' else '_' for c in value)
+
+
+# ── Activity extraction ───────────────────────────────────────────────────
+
+
+TOOL_VERBS: dict[str, str] = {
+ 'Read': 'Reading',
+ 'Write': 'Writing',
+ 'Edit': 'Editing',
+ 'MultiEdit': 'Editing',
+ 'Bash': 'Running',
+ 'Glob': 'Searching',
+ 'Grep': 'Searching',
+ 'WebFetch': 'Fetching',
+ 'WebSearch': 'Searching',
+ 'Task': 'Running task',
+ 'FileReadTool': 'Reading',
+ 'FileWriteTool': 'Writing',
+ 'FileEditTool': 'Editing',
+ 'GlobTool': 'Searching',
+ 'GrepTool': 'Searching',
+ 'BashTool': 'Running',
+ 'NotebookEditTool': 'Editing notebook',
+ 'LSP': 'LSP',
+}
+"""Tool name → human-readable verb for the status display. Mirrors TS
+``TOOL_VERBS`` on ``sessionRunner.ts:133-152``."""
+
+
+def _tool_summary(name: str, tool_input: dict[str, Any]) -> str:
+ verb = TOOL_VERBS.get(name, name)
+ target: str = ''
+ for key in ('file_path', 'filePath', 'pattern'):
+ raw = tool_input.get(key)
+ if isinstance(raw, str):
+ target = raw
+ break
+ if not target:
+ cmd = tool_input.get('command')
+ if isinstance(cmd, str):
+ target = cmd[:60]
+ if not target:
+ for key in ('url', 'query'):
+ raw = tool_input.get(key)
+ if isinstance(raw, str):
+ target = raw
+ break
+ return f'{verb} {target}' if target else verb
+
+
+def _input_preview(tool_input: dict[str, Any]) -> str:
+ parts: list[str] = []
+ for key, val in tool_input.items():
+ if isinstance(val, str):
+ parts.append(f'{key}="{val[:100]}"')
+ if len(parts) >= 3:
+ break
+ return ' '.join(parts)
+
+
+def extract_activities(
+ line: str,
+ session_id: str,
+ on_debug: Callable[[str], None],
+) -> list[SessionActivity]:
+ """Parse one NDJSON line and emit SessionActivity events.
+
+ Mirrors TS ``extractActivities`` on ``sessionRunner.ts:170-263``.
+ Returns an empty list when the line isn't JSON or doesn't match a
+ known activity type. Never raises — invalid input is logged.
+ """
+ try:
+ parsed = json.loads(line)
+ except (ValueError, json.JSONDecodeError):
+ return []
+ if not isinstance(parsed, dict):
+ return []
+
+ activities: list[SessionActivity] = []
+ now = _now_ms()
+ msg_type = parsed.get('type')
+
+ if msg_type == 'assistant':
+ message = parsed.get('message')
+ if not isinstance(message, dict):
+ return []
+ content = message.get('content')
+ if not isinstance(content, list):
+ return []
+ for block in content:
+ if not isinstance(block, dict):
+ continue
+ block_type = block.get('type')
+ if block_type == 'tool_use':
+ name = block.get('name') or 'Tool'
+ if not isinstance(name, str):
+ name = 'Tool'
+ tool_input = block.get('input') or {}
+ if not isinstance(tool_input, dict):
+ tool_input = {}
+ activities.append(SessionActivity(
+ type='tool_start',
+ summary=_tool_summary(name, tool_input),
+ timestamp=now,
+ ))
+ on_debug(
+ f'[bridge:activity] sessionId={session_id} '
+ f'tool_use name={name} {_input_preview(tool_input)}'
+ )
+ elif block_type == 'text':
+ text = block.get('text') or ''
+ if isinstance(text, str) and len(text) > 0:
+ activities.append(SessionActivity(
+ type='text',
+ summary=text[:80],
+ timestamp=now,
+ ))
+ on_debug(
+ f'[bridge:activity] sessionId={session_id} '
+ f'text "{text[:100]}"'
+ )
+ elif msg_type == 'result':
+ subtype = parsed.get('subtype')
+ if subtype == 'success':
+ activities.append(SessionActivity(
+ type='result',
+ summary='Session completed',
+ timestamp=now,
+ ))
+ on_debug(
+ f'[bridge:activity] sessionId={session_id} '
+ f'result subtype=success'
+ )
+ elif isinstance(subtype, str) and subtype:
+ errors = parsed.get('errors')
+ error_summary = (
+ errors[0] if isinstance(errors, list) and errors and isinstance(errors[0], str)
+ else f'Error: {subtype}'
+ )
+ activities.append(SessionActivity(
+ type='error',
+ summary=error_summary,
+ timestamp=now,
+ ))
+ on_debug(
+ f'[bridge:activity] sessionId={session_id} '
+ f'result subtype={subtype} error="{error_summary}"'
+ )
+ else:
+ on_debug(
+ f'[bridge:activity] sessionId={session_id} '
+ f'result subtype=undefined'
+ )
+
+ return activities
+
+
+def _extract_user_message_text(msg: dict[str, Any]) -> str | None:
+ """Extract plain text from a replayed SDKUserMessage NDJSON line.
+
+ Mirrors TS ``extractUserMessageText`` on ``sessionRunner.ts:270-297``.
+ Returns the trimmed text if this looks like a real human-authored
+ message, otherwise ``None`` so the caller keeps waiting.
+ """
+ if (
+ msg.get('parent_tool_use_id') is not None
+ or msg.get('isSynthetic')
+ or msg.get('isReplay')
+ ):
+ return None
+ message = msg.get('message')
+ content = message.get('content') if isinstance(message, dict) else None
+ text: str | None = None
+ if isinstance(content, str):
+ text = content
+ elif isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict) and block.get('type') == 'text':
+ raw = block.get('text')
+ if isinstance(raw, str):
+ text = raw
+ break
+ if isinstance(text, str):
+ stripped = text.strip()
+ return stripped or None
+ return None
+
+
+# ── Permission request type ──────────────────────────────────────────────
+
+
+class PermissionRequest(dict):
+ """A ``control_request`` from the child requesting tool permission.
+
+ Mirrors TS ``PermissionRequest`` on ``sessionRunner.ts:96-106``.
+ Subclasses ``dict`` for wire-format compatibility — the orchestrator
+ forwards the raw dict to the server via the bridge transport.
+ """
+
+
+# ── Spawner ──────────────────────────────────────────────────────────────
+
+
+@dataclass
+class SessionSpawnerDeps:
+ """Configuration for ``create_session_spawner``.
+
+ Mirrors TS ``SessionSpawnerDeps`` on ``sessionRunner.ts:108-130``.
+ """
+
+ exec_path: str
+ """Path to the binary to spawn. For compiled builds this is the
+ ``claude`` binary; for npm installs this is the Node runtime and
+ ``script_args`` contains the script path."""
+
+ script_args: list[str] = field(default_factory=list)
+ """Args that must precede the CLI flags. Empty for compiled
+ binaries; contains the script path for npm installs (otherwise Node
+ sees ``--sdk-url`` as a Node option and errors)."""
+
+ env: dict[str, str] = field(default_factory=lambda: dict(os.environ))
+ verbose: bool = False
+ sandbox: bool = False
+ debug_file: str | None = None
+ permission_mode: str | None = None
+ on_debug: Callable[[str], None] = lambda msg: logger.debug(msg) # noqa: E731
+ on_activity: Callable[[str, SessionActivity], None] | None = None
+ on_permission_request: (
+ Callable[[str, PermissionRequest, str], None] | None
+ ) = None
+
+
+def create_session_spawner(deps: SessionSpawnerDeps) -> SessionSpawner:
+ """Build a ``SessionSpawner`` bound to the given deps.
+
+ Mirrors TS ``createSessionSpawner`` on ``sessionRunner.ts:311-599``.
+ The returned object's ``spawn(opts, dir)`` returns a ``SessionHandle``
+ immediately; the child runs in the background and its NDJSON stdout
+ is parsed asynchronously into ``activities``.
+ """
+ return _SubprocessSpawner(deps)
+
+
+class _SubprocessSpawner:
+ """Concrete ``SessionSpawner`` implementation. Not exported."""
+
+ def __init__(self, deps: SessionSpawnerDeps) -> None:
+ self._deps = deps
+
+ def spawn(self, opts: SessionSpawnOpts, working_dir: str) -> SessionHandle:
+ # ``spawn`` is sync to match the TS contract — orchestrators
+ # don't want to await this. The actual subprocess creation is
+ # deferred to an asyncio task that starts immediately.
+ # **Must be called from inside a running asyncio loop** —
+ # ``start()`` binds the done-future + spawn task to the
+ # orchestrator's loop via ``get_running_loop()``.
+ spawned: _SpawnedSession = _SpawnedSession(self._deps, opts, working_dir)
+ spawned.start()
+ return spawned
+
+
+# ── Per-session state machine ────────────────────────────────────────────
+
+
+class _SpawnedSession:
+ """One child CLI's lifecycle state. Implements ``SessionHandle``."""
+
+ def __init__(
+ self,
+ deps: SessionSpawnerDeps,
+ opts: SessionSpawnOpts,
+ working_dir: str,
+ ) -> None:
+ self._deps = deps
+ self._opts = opts
+ self._working_dir = working_dir
+ # ``session_id`` and ``access_token`` are typed dict keys in the
+ # SessionSpawnOpts TypedDict.
+ self._session_id: str = opts['session_id']
+ self._access_token: str = opts['access_token']
+
+ self._activities: deque[SessionActivity] = deque(maxlen=MAX_ACTIVITIES)
+ self._last_stderr: deque[str] = deque(maxlen=MAX_STDERR_LINES)
+ self._current_activity: SessionActivity | None = None
+ self._first_user_message_seen = False
+ self._sigkill_sent = False
+ self._transcript_path: str | None = None
+ self._transcript_file: Any = None # text file handle when present
+
+ self._process: asyncio.subprocess.Process | None = None
+ # Track "kill requested before _process was ready" so a caller
+ # that invokes ``handle.kill()`` between ``spawn()`` returning
+ # and ``_spawn_and_wait`` reaching the subprocess-created state
+ # doesn't silently leak a child.
+ self._kill_requested = False
+ self._force_kill_requested = False
+ # Created lazily inside ``start()`` (which runs on the asyncio
+ # loop) — ``get_event_loop()`` here would raise on Python 3.12+
+ # when no loop is running.
+ self._done_future: asyncio.Future[SessionDoneStatus] | None = None
+ self._start_task: asyncio.Task[None] | None = None
+
+ # ── Public properties ─────────────────────────────────────────────
+
+ @property
+ def session_id(self) -> str:
+ return self._session_id
+
+ @property
+ def access_token(self) -> str:
+ return self._access_token
+
+ @property
+ def activities(self) -> list[SessionActivity]:
+ # Snapshot via list() — mutations during read shouldn't surface.
+ return list(self._activities)
+
+ @property
+ def current_activity(self) -> SessionActivity | None:
+ return self._current_activity
+
+ @property
+ def last_stderr(self) -> list[str]:
+ return list(self._last_stderr)
+
+ # ── SessionHandle interface ───────────────────────────────────────
+
+ async def wait_done(self) -> SessionDoneStatus:
+ if self._done_future is None:
+ # Caller awaited before ``start()`` ran (or after the future
+ # was already consumed). Construct one now if there's a loop;
+ # otherwise raise so the bug surfaces.
+ self._done_future = asyncio.get_running_loop().create_future()
+ return await self._done_future
+
+ def kill(self) -> None:
+ """SIGTERM the child (graceful).
+
+ Mirrors TS ``kill`` on ``sessionRunner.ts:542-553``. Safe to call
+ before the subprocess has been created — the request is latched
+ and ``_spawn_and_wait`` honors it once ``_process`` is set, so a
+ ``handle.kill()`` immediately after ``spawn()`` never leaks a
+ child that's still in the middle of ``create_subprocess_exec``.
+
+ On Windows, ``terminate()`` translates to ``TerminateProcess``.
+ """
+ self._kill_requested = True
+ if self._process is None:
+ return
+ self._send_kill_signal(force=False)
+
+ def force_kill(self) -> None:
+ """SIGKILL the child.
+
+ Mirrors TS ``forceKill`` on ``sessionRunner.ts:555-569``. Guarded
+ by ``_sigkill_sent`` so calling multiple times is a no-op.
+
+ Like ``kill()``, safe to call before the subprocess is created
+ — the request is latched and ``_spawn_and_wait`` honors it once
+ ``_process`` is set.
+ """
+ if self._sigkill_sent:
+ return
+ self._sigkill_sent = True
+ self._force_kill_requested = True
+ if self._process is None:
+ return
+ self._send_kill_signal(force=True)
+
+ def _send_kill_signal(self, *, force: bool) -> None:
+ """Send SIGTERM/SIGKILL to ``self._process`` (assumed non-None).
+
+ Centralizes the platform branching + exit-status guard so the
+ latched-kill path in ``_spawn_and_wait`` and the eager-kill path
+ in ``kill()``/``force_kill()`` share one code path.
+ """
+ process = self._process
+ if process is None:
+ return
+ if process.returncode is not None:
+ return
+ sig_name = 'SIGKILL' if force else 'SIGTERM'
+ self._deps.on_debug(
+ f'[bridge:session] Sending {sig_name} to '
+ f'sessionId={self._session_id} pid={process.pid}'
+ )
+ try:
+ if sys.platform == 'win32':
+ process.kill() if force else process.terminate()
+ else:
+ process.send_signal(
+ signal.SIGKILL if force else signal.SIGTERM
+ )
+ except (ProcessLookupError, OSError) as err:
+ self._deps.on_debug(
+ f'[bridge:session] {sig_name.lower()} failed '
+ f'(already exited?): {err}'
+ )
+
+ def write_stdin(self, data: str) -> None:
+ """Write to child stdin.
+
+ Mirrors TS ``writeStdin`` on ``sessionRunner.ts:570-577``. Silent
+ no-op if the process or its stdin isn't open.
+ """
+ if self._process is None or self._process.stdin is None:
+ return
+ if self._process.stdin.is_closing():
+ return
+ self._deps.on_debug(
+ f'[bridge:ws] sessionId={self._session_id} '
+ f'>>> {debug_truncate(data)}'
+ )
+ try:
+ self._process.stdin.write(data.encode('utf-8'))
+ except (BrokenPipeError, ConnectionResetError, OSError, RuntimeError) as err:
+ # Broaden vs ``(BrokenPipeError, ConnectionResetError)``:
+ # ``asyncio.StreamWriter.write`` on a closed transport can
+ # raise plain ``RuntimeError("Transport is closed")`` on
+ # some Python versions, and OSError covers other write-side
+ # failures (ENOMEM during enlargement, etc.) that we don't
+ # want to surface as crashes.
+ self._deps.on_debug(
+ f'[bridge:session] write_stdin failed: {err}'
+ )
+
+ def update_access_token(self, token: str) -> None:
+ """Send a fresh OAuth token via stdin without restarting the child.
+
+ Mirrors TS ``updateAccessToken`` on ``sessionRunner.ts:578-593``.
+ The child's StructuredIO handles ``update_environment_variables``
+ by setting ``os.environ`` directly so the next API call picks up
+ the new token.
+ """
+ self._access_token = token
+ payload = json.dumps({
+ 'type': 'update_environment_variables',
+ 'variables': {'CLAUDE_CODE_SESSION_ACCESS_TOKEN': token},
+ }) + '\n'
+ self.write_stdin(payload)
+ self._deps.on_debug(
+ f'[bridge:session] Sent token refresh via stdin for '
+ f'sessionId={self._session_id}'
+ )
+
+ # ── Internal: spawn + parse pipeline ──────────────────────────────
+
+ def start(self) -> None:
+ """Kick off the async spawn + stream-reader tasks.
+
+ Must be called from inside a running asyncio loop. Creates the
+ ``done_future`` lazily here (rather than in ``__init__``) so the
+ future is bound to the orchestrator's loop, and ``__init__`` can
+ run from sync contexts without raising on Python 3.12+.
+ """
+ if self._done_future is None:
+ self._done_future = asyncio.get_running_loop().create_future()
+ self._start_task = asyncio.create_task(
+ self._spawn_and_wait(),
+ name=f'session-{self._session_id}',
+ )
+
+ async def _spawn_and_wait(self) -> None:
+ # Transcript file is opened only AFTER the child successfully
+ # spawns. Wrapping the whole body in try/finally ensures the
+ # file handle is closed even if an unexpected exception (or
+ # CancelledError) unwinds the task before ``process.wait()``
+ # returns.
+ try:
+ await self._spawn_and_wait_inner()
+ finally:
+ self._close_transcript()
+ # If an unexpected exception or CancelledError unwound the
+ # task before ``_resolve_done`` was reached, wake any
+ # ``wait_done()`` awaiters with 'failed' so they don't hang
+ # forever during shutdown.
+ if self._done_future is not None and not self._done_future.done():
+ self._done_future.set_result('failed')
+
+ async def _spawn_and_wait_inner(self) -> None:
+ debug_file = self._resolve_debug_file()
+ args = self._build_args(debug_file)
+ env = build_child_env(
+ self._deps.env,
+ BuildChildEnvOpts(
+ access_token=self._access_token,
+ use_ccr_v2=bool(self._opts.get('use_ccr_v2')),
+ worker_epoch=self._opts.get('worker_epoch'),
+ sandbox=self._deps.sandbox,
+ ),
+ )
+ self._deps.on_debug(
+ f'[bridge:session] Spawning sessionId={self._session_id} '
+ f'sdkUrl={self._opts["sdk_url"]} '
+ f'accessToken={"present" if self._access_token else "MISSING"}'
+ )
+ self._deps.on_debug(
+ f'[bridge:session] Child args: {" ".join(args)}'
+ )
+ if debug_file:
+ self._deps.on_debug(f'[bridge:session] Debug log: {debug_file}')
+
+ try:
+ process = await asyncio.create_subprocess_exec(
+ self._deps.exec_path,
+ *args,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=self._working_dir,
+ env=env,
+ )
+ except (FileNotFoundError, PermissionError, OSError) as err:
+ self._deps.on_debug(
+ f'[bridge:session] sessionId={self._session_id} '
+ f'spawn error: {err}'
+ )
+ self._resolve_done('failed')
+ return
+
+ self._process = process
+ self._deps.on_debug(
+ f'[bridge:session] sessionId={self._session_id} pid={process.pid}'
+ )
+
+ # Open the transcript only after the child is up — avoids leaking
+ # a file handle when the spawn fails or the task is cancelled
+ # earlier. CRITIC BLOCKING-2 fix.
+ self._open_transcript_if_needed(debug_file)
+
+ # Honor any kill request that latched while ``create_subprocess_exec``
+ # was awaiting. CRITIC MAJOR-2 fix — without this, a caller that
+ # does ``handle = spawner.spawn(...); handle.kill()`` immediately
+ # would silently leak the still-launching child.
+ if self._force_kill_requested:
+ self._send_kill_signal(force=True)
+ elif self._kill_requested:
+ self._send_kill_signal(force=False)
+
+ stdout_task = asyncio.create_task(self._read_stdout())
+ stderr_task = asyncio.create_task(self._read_stderr())
+ try:
+ returncode = await process.wait()
+ finally:
+ # Make sure reader tasks finish before the outer try/finally
+ # closes the transcript.
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
+
+ # asyncio.Process exposes the negative-of-signal convention for
+ # signal-terminated children on POSIX. SIGTERM = 15, SIGINT = 2.
+ status: SessionDoneStatus
+ if returncode == -signal.SIGTERM or returncode == -signal.SIGINT:
+ self._deps.on_debug(
+ f'[bridge:session] sessionId={self._session_id} '
+ f'interrupted signal={-returncode} pid={process.pid}'
+ )
+ status = 'interrupted'
+ elif returncode == 0:
+ self._deps.on_debug(
+ f'[bridge:session] sessionId={self._session_id} '
+ f'completed exit_code=0 pid={process.pid}'
+ )
+ status = 'completed'
+ else:
+ self._deps.on_debug(
+ f'[bridge:session] sessionId={self._session_id} '
+ f'failed exit_code={returncode} pid={process.pid}'
+ )
+ status = 'failed'
+ self._resolve_done(status)
+
+ async def _read_stdout(self) -> None:
+ if self._process is None or self._process.stdout is None:
+ return
+ async for raw in self._iter_lines(self._process.stdout):
+ line = raw.decode('utf-8', errors='replace').rstrip('\r\n')
+ if self._transcript_file is not None:
+ try:
+ self._transcript_file.write(line + '\n')
+ except (OSError, ValueError) as err:
+ self._deps.on_debug(
+ f'[bridge:session] Transcript write error: {err}'
+ )
+ self._transcript_file = None
+
+ self._deps.on_debug(
+ f'[bridge:ws] sessionId={self._session_id} '
+ f'<<< {debug_truncate(line)}'
+ )
+ if self._deps.verbose:
+ sys.stderr.write(line + '\n')
+
+ extracted = extract_activities(
+ line, self._session_id, self._deps.on_debug,
+ )
+ for activity in extracted:
+ self._activities.append(activity)
+ self._current_activity = activity
+ if self._deps.on_activity is not None:
+ try:
+ self._deps.on_activity(self._session_id, activity)
+ except Exception as err: # noqa: BLE001
+ self._deps.on_debug(
+ f'[bridge:session] on_activity raised: {err}'
+ )
+
+ self._detect_control_request_and_first_user(line)
+
+ async def _read_stderr(self) -> None:
+ if self._process is None or self._process.stderr is None:
+ return
+ async for raw in self._iter_lines(self._process.stderr):
+ line = raw.decode('utf-8', errors='replace').rstrip('\r\n')
+ if self._deps.verbose:
+ sys.stderr.write(line + '\n')
+ self._last_stderr.append(line)
+
+ @staticmethod
+ async def _iter_lines(stream: asyncio.StreamReader):
+ """Yield NDJSON lines from a stream, terminating on EOF."""
+ while True:
+ try:
+ line = await stream.readline()
+ except (asyncio.IncompleteReadError, ConnectionResetError):
+ return
+ if not line:
+ return
+ yield line
+
+ def _detect_control_request_and_first_user(self, line: str) -> None:
+ """Re-parse the line for control_request + first-user-message.
+
+ ``extract_activities`` swallows parse errors and ignores ``user``
+ type; we re-parse cheaply (NDJSON lines are small) and keep each
+ path self-contained.
+ """
+ try:
+ parsed = json.loads(line)
+ except (ValueError, json.JSONDecodeError):
+ return
+ if not isinstance(parsed, dict):
+ return
+
+ msg_type = parsed.get('type')
+ if msg_type == 'control_request':
+ request = parsed.get('request')
+ if (
+ isinstance(request, dict)
+ and request.get('subtype') == 'can_use_tool'
+ and self._deps.on_permission_request is not None
+ ):
+ try:
+ self._deps.on_permission_request(
+ self._session_id,
+ PermissionRequest(parsed),
+ self._access_token,
+ )
+ except Exception as err: # noqa: BLE001
+ self._deps.on_debug(
+ f'[bridge:session] on_permission_request raised: {err}'
+ )
+ elif (
+ msg_type == 'user'
+ and not self._first_user_message_seen
+ and self._opts.get('on_first_user_message') is not None
+ ):
+ text = _extract_user_message_text(parsed)
+ if text:
+ self._first_user_message_seen = True
+ cb = self._opts['on_first_user_message']
+ try:
+ cb(text)
+ except Exception as err: # noqa: BLE001
+ self._deps.on_debug(
+ f'[bridge:session] on_first_user_message raised: {err}'
+ )
+
+ # ── Helpers ────────────────────────────────────────────────────────
+
+ def _build_args(self, debug_file: str | None) -> list[str]:
+ args = [
+ *self._deps.script_args,
+ '--print',
+ '--sdk-url', self._opts['sdk_url'],
+ '--session-id', self._session_id,
+ '--input-format', 'stream-json',
+ '--output-format', 'stream-json',
+ '--replay-user-messages',
+ ]
+ if self._deps.verbose:
+ args.append('--verbose')
+ if debug_file:
+ args.extend(['--debug-file', debug_file])
+ if self._deps.permission_mode:
+ args.extend(['--permission-mode', self._deps.permission_mode])
+ return args
+
+ def _resolve_debug_file(self) -> str | None:
+ """Mirror TS debug-file resolution on ``sessionRunner.ts:316-329``.
+
+ Priority:
+ 1. ``deps.debug_file`` (with session ID suffix).
+ 2. Auto-generated tmpfile when verbose OR ant build.
+ 3. None.
+ """
+ safe_id = safe_filename_id(self._session_id)
+ if self._deps.debug_file is not None:
+ ext_idx = self._deps.debug_file.rfind('.')
+ if ext_idx > 0:
+ return (
+ f'{self._deps.debug_file[:ext_idx]}'
+ f'-{safe_id}'
+ f'{self._deps.debug_file[ext_idx:]}'
+ )
+ return f'{self._deps.debug_file}-{safe_id}'
+ if self._deps.verbose or os.environ.get('USER_TYPE') == 'ant':
+ return str(
+ Path(tempfile.gettempdir()) / 'claude'
+ / f'bridge-session-{safe_id}.log'
+ )
+ return None
+
+ def _open_transcript_if_needed(self, debug_file: str | None) -> None:
+ """Open the transcript file when a debug file is configured.
+
+ Mirrors TS transcript handling on ``sessionRunner.ts:331-348``.
+ Placed alongside the debug file. Best-effort — errors are logged
+ and the transcript is disabled.
+ """
+ if not self._deps.debug_file:
+ return
+ safe_id = safe_filename_id(self._session_id)
+ target_dir = Path(self._deps.debug_file).parent
+ try:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ except OSError as err:
+ self._deps.on_debug(
+ f'[bridge:session] Transcript dir mkdir failed: {err}'
+ )
+ return
+ self._transcript_path = str(
+ target_dir / f'bridge-transcript-{safe_id}.jsonl'
+ )
+ try:
+ self._transcript_file = open( # noqa: SIM115 closed in _close_transcript
+ self._transcript_path, 'a', encoding='utf-8',
+ )
+ except OSError as err:
+ self._deps.on_debug(
+ f'[bridge:session] Transcript open failed: {err}'
+ )
+ self._transcript_file = None
+ return
+ self._deps.on_debug(
+ f'[bridge:session] Transcript log: {self._transcript_path}'
+ )
+
+ def _close_transcript(self) -> None:
+ if self._transcript_file is not None:
+ try:
+ self._transcript_file.close()
+ except OSError:
+ pass
+ self._transcript_file = None
+
+ def _resolve_done(self, status: SessionDoneStatus) -> None:
+ if self._done_future is None:
+ self._done_future = asyncio.get_running_loop().create_future()
+ if not self._done_future.done():
+ self._done_future.set_result(status)
+
+
+def _now_ms() -> int:
+ """Return the current time as milliseconds since the Unix epoch.
+
+ Matches TS ``Date.now()`` units so ``SessionActivity.timestamp``
+ is interchangeable across the TS/Python port boundary. Downstream
+ consumers (e.g. ``bridgeUI`` elapsed-time displays) do
+ ``now() - activity.timestamp`` and expect ms — using seconds here
+ would silently produce values 1000× wrong.
+ """
+ return int(time.time() * 1000)
+
+
+__all__ = [
+ 'CHILD_ENV_ALLOWLIST',
+ 'MAX_ACTIVITIES',
+ 'MAX_STDERR_LINES',
+ 'TOOL_VERBS',
+ 'BuildChildEnvOpts',
+ 'PermissionRequest',
+ 'SessionSpawnerDeps',
+ 'build_child_env',
+ 'create_session_spawner',
+ 'extract_activities',
+ 'safe_filename_id',
+]
diff --git a/src/bridge/trusted_device.py b/src/bridge/trusted_device.py
new file mode 100644
index 000000000..f0260984b
--- /dev/null
+++ b/src/bridge/trusted_device.py
@@ -0,0 +1,97 @@
+"""Trusted-device token source for bridge sessions — Phase 10 stub.
+
+Ports the consumer-facing surface of
+``typescript/src/bridge/trustedDevice.ts``.
+
+**Phase 10 stub**: The full TS implementation reads a device token from
+OS-specific secure storage (Keychain on macOS, DPAPI on Windows,
+libsecret on Linux), enrolls via POST ``/auth/trusted_devices`` during
+login, and gates the header on a GrowthBook flag. The Python build has
+none of those layers yet, so this module exposes the same public API
+returning ``None`` (header omitted; server falls through to its
+no-enforcement path).
+
+Two env-var overrides are honored so callers (test scripts, daemon
+wrappers, CI rigs) can inject a token without OS secure storage:
+
+* ``CLAUDE_TRUSTED_DEVICE_TOKEN`` — direct token override (matches the
+ same env var TS recognizes).
+
+The ``enroll_trusted_device()`` async function is a no-op stub today;
+implementing it requires the OAuth keychain + ``secure_storage``
+modules that don't exist in the Python build yet (Phase 10 follow-up).
+
+The function signatures match TS so a future swap to real keychain-
+backed storage is non-breaking for callers.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+logger = logging.getLogger(__name__)
+
+
+_ENV_TOKEN = 'CLAUDE_TRUSTED_DEVICE_TOKEN'
+
+
+def get_trusted_device_token() -> str | None:
+ """Return the trusted-device token to send as ``X-Trusted-Device-Token``.
+
+ Mirrors TS ``getTrustedDeviceToken`` on ``trustedDevice.ts:54-59``.
+ Returns ``None`` to omit the header (server treats this as "no
+ elevated auth"), unless ``CLAUDE_TRUSTED_DEVICE_TOKEN`` is set.
+
+ Phase 10 will swap the ``None`` fallback for a keychain read.
+ """
+ value = os.environ.get(_ENV_TOKEN)
+ return value or None
+
+
+def clear_trusted_device_token_cache() -> None:
+ """Clear the (currently in-memory only) trusted-device-token cache.
+
+ Mirrors TS ``clearTrustedDeviceTokenCache`` on ``trustedDevice.ts:61-63``.
+ No-op in the env-var-only Python build — the env var is the source
+ of truth, no separate cache exists.
+ """
+ return None
+
+
+def clear_trusted_device_token() -> None:
+ """Clear the persisted trusted-device token from secure storage.
+
+ Mirrors TS ``clearTrustedDeviceToken`` on ``trustedDevice.ts:69-86``.
+ No-op until Phase 10 keychain integration lands. The env var (if
+ set) is not cleared — that's an external configuration concern.
+ """
+ return None
+
+
+async def enroll_trusted_device() -> None:
+ """Enroll this device via POST ``/auth/trusted_devices`` (no-op stub).
+
+ Mirrors TS ``enrollTrustedDevice`` on ``trustedDevice.ts:97-210``.
+ The TS implementation requires the OAuth keychain (to read the
+ access token + persist the issued device_token), the secure storage
+ layer (OS-specific keychain backend), and the GrowthBook gate
+ (``tengu_sessions_elevated_auth_enforcement``). All three are
+ out-of-scope for the current Python build.
+
+ A future Phase 10 expansion will fill this in. Until then the
+ function is a no-op that emits a debug log so a caller hitting it
+ sees the gap.
+ """
+ logger.debug(
+ '[trusted-device] enroll_trusted_device is a no-op stub — '
+ 'Phase 10 keychain integration not yet ported'
+ )
+
+
+__all__ = [
+ 'clear_trusted_device_token',
+ 'clear_trusted_device_token_cache',
+ 'enroll_trusted_device',
+ 'get_trusted_device_token',
+]
diff --git a/src/bridge/types.py b/src/bridge/types.py
new file mode 100644
index 000000000..a696471c8
--- /dev/null
+++ b/src/bridge/types.py
@@ -0,0 +1,500 @@
+"""Bridge subsystem types.
+
+Ports ``typescript/src/bridge/types.ts``. Consolidated type module
+containing dataclasses, TypedDicts, Protocols, and module-level constants
+shared across the bridge subsystem. The TS file mixes wire-format types
+(``WorkData``, ``WorkResponse``) with dependency-injection Protocols
+(``BridgeApiClient``, ``SessionHandle``, ``SessionSpawner``, ``BridgeLogger``);
+Python keeps the same arrangement so callers see one type module per TS file.
+
+For wire-level message types (``SDKMessage``, ``SDKControlRequest`` etc.) see
+``src.bridge.sdk_types`` — those live separately because they cross multiple
+TS files.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Literal,
+ Protocol,
+ TypedDict,
+ Union,
+)
+
+
+# ---------------------------------------------------------------------------
+# Module-level constants
+# ---------------------------------------------------------------------------
+
+DEFAULT_SESSION_TIMEOUT_MS: int = 24 * 60 * 60 * 1000
+"""Default per-session timeout (24 hours).
+
+Mirrors TS ``DEFAULT_SESSION_TIMEOUT_MS`` on ``types.ts:2``.
+"""
+
+BRIDGE_LOGIN_INSTRUCTION: str = (
+ 'Remote Control is only available with claude.ai subscriptions. '
+ 'Please use `/login` to sign in with your claude.ai account.'
+)
+"""Reusable login guidance appended to bridge auth errors.
+
+Mirrors TS ``BRIDGE_LOGIN_INSTRUCTION`` on ``types.ts:5-6``.
+"""
+
+BRIDGE_LOGIN_ERROR: str = (
+ 'Error: You must be logged in to use Remote Control.\n\n'
+ + BRIDGE_LOGIN_INSTRUCTION
+)
+"""Full error printed when ``claude remote-control`` is run without auth.
+
+Mirrors TS ``BRIDGE_LOGIN_ERROR`` on ``types.ts:9-11``.
+"""
+
+REMOTE_CONTROL_DISCONNECTED_MSG: str = 'Remote Control disconnected.'
+"""Shown when the user disconnects Remote Control via /remote-control or
+ultraplan launch.
+
+Mirrors TS ``REMOTE_CONTROL_DISCONNECTED_MSG`` on ``types.ts:14``.
+"""
+
+
+# ---------------------------------------------------------------------------
+# Wire protocol types (environments API)
+# ---------------------------------------------------------------------------
+
+
+WorkDataType = Literal['session', 'healthcheck']
+
+
+class WorkData(TypedDict):
+ """Inner data payload of a work poll response.
+
+ Mirrors TS ``WorkData`` on ``types.ts:18-21``.
+ """
+
+ type: WorkDataType
+ id: str
+
+
+class WorkResponse(TypedDict):
+ """Top-level work poll response from the environments API.
+
+ Mirrors TS ``WorkResponse`` on ``types.ts:23-31``. ``secret`` is base64url-
+ encoded JSON (a ``WorkSecret``); use ``decode_work_secret`` to parse it.
+ """
+
+ id: str
+ type: Literal['work']
+ environment_id: str
+ state: str
+ data: WorkData
+ secret: str
+ created_at: str
+
+
+SessionDoneStatus = Literal['completed', 'failed', 'interrupted']
+"""Mirrors TS ``SessionDoneStatus`` on ``types.ts:53``."""
+
+SessionActivityType = Literal['tool_start', 'text', 'result', 'error']
+"""Mirrors TS ``SessionActivityType`` on ``types.ts:55``."""
+
+
+@dataclass(frozen=True)
+class SessionActivity:
+ """A single activity event seen on the child's NDJSON stream.
+
+ Mirrors TS ``SessionActivity`` on ``types.ts:57-61``. Used in the ring
+ buffer maintained by ``SessionRunner``.
+ """
+
+ type: SessionActivityType
+ summary: str
+ timestamp: float
+
+
+SpawnMode = Literal['single-session', 'worktree', 'same-dir']
+"""How ``claude remote-control`` chooses session working directories.
+
+Mirrors TS ``SpawnMode`` on ``types.ts:69``:
+- ``single-session``: one session in cwd, bridge tears down when it ends
+- ``worktree``: persistent server, every session gets an isolated git worktree
+- ``same-dir``: persistent server, every session shares cwd
+"""
+
+
+BridgeWorkerType = Literal['claude_code', 'claude_code_assistant']
+"""Well-known ``worker_type`` values this codebase produces.
+
+Mirrors TS ``BridgeWorkerType`` on ``types.ts:79``. The backend treats this
+field as an opaque string; this narrow type is for internal exhaustiveness.
+"""
+
+
+@dataclass
+class BridgeConfig:
+ """Configuration for a single bridge instance.
+
+ Mirrors TS ``BridgeConfig`` on ``types.ts:81-115``. Mutable because
+ ``spawn_mode`` can change at runtime when the user presses ``w`` to
+ toggle between same-dir and worktree mode (see ``bridgeMain.ts``).
+ """
+
+ dir: str
+ machine_name: str
+ branch: str
+ git_repo_url: str | None
+ max_sessions: int
+ spawn_mode: SpawnMode
+ verbose: bool
+ sandbox: bool
+ bridge_id: str
+ worker_type: str
+ environment_id: str
+ api_base_url: str
+ session_ingress_url: str
+ reuse_environment_id: str | None = None
+ debug_file: str | None = None
+ session_timeout_ms: int | None = None
+
+
+# ---------------------------------------------------------------------------
+# Permission response event (control_response wrapper)
+# ---------------------------------------------------------------------------
+
+
+class _PermissionResponseInner(TypedDict):
+ subtype: Literal['success']
+ request_id: str
+ response: dict[str, Any]
+
+
+class PermissionResponseEvent(TypedDict):
+ """A ``control_response`` event sent back to a session via the events API.
+
+ Mirrors TS ``PermissionResponseEvent`` on ``types.ts:124-131``.
+ """
+
+ type: Literal['control_response']
+ response: _PermissionResponseInner
+
+
+# ---------------------------------------------------------------------------
+# Dependency-injection Protocols
+# ---------------------------------------------------------------------------
+
+
+class BridgeApiClient(Protocol):
+ """HTTP client surface for the environments API.
+
+ Mirrors TS ``BridgeApiClient`` on ``types.ts:133-176``. Concrete
+ implementation lands in Phase 3 (``src/bridge/bridge_api.py``); this
+ Protocol exists so orchestrators (Phase 5+) can be ported and unit-tested
+ against fakes before the real client is built.
+ """
+
+ async def register_bridge_environment(
+ self, config: BridgeConfig
+ ) -> dict[str, str]:
+ """POST /v1/environments/bridge. Returns ``{environment_id, environment_secret}``."""
+ ...
+
+ async def poll_for_work(
+ self,
+ environment_id: str,
+ environment_secret: str,
+ cancel_event: Any | None = None,
+ reclaim_older_than_ms: int | None = None,
+ ) -> WorkResponse | None:
+ """GET .../work/poll. Returns ``None`` when no work is queued."""
+ ...
+
+ async def acknowledge_work(
+ self, environment_id: str, work_id: str, session_token: str
+ ) -> None:
+ """POST .../work/{workId}/ack."""
+ ...
+
+ async def stop_work(
+ self, environment_id: str, work_id: str, force: bool
+ ) -> None:
+ """POST .../work/{workId}/stop."""
+ ...
+
+ async def deregister_environment(self, environment_id: str) -> None:
+ """DELETE /v1/environments/bridge/{environmentId}."""
+ ...
+
+ async def send_permission_response_event(
+ self,
+ session_id: str,
+ event: PermissionResponseEvent,
+ session_token: str,
+ ) -> None:
+ """POST /v1/sessions/{sessionId}/events."""
+ ...
+
+ async def archive_session(self, session_id: str) -> None:
+ """POST /v1/sessions/{sessionId}/archive."""
+ ...
+
+ async def reconnect_session(
+ self, environment_id: str, session_id: str
+ ) -> None:
+ """POST .../bridge/reconnect — force-stop stale workers + re-queue."""
+ ...
+
+ async def heartbeat_work(
+ self, environment_id: str, work_id: str, session_token: str
+ ) -> dict[str, Any]:
+ """POST .../work/{workId}/heartbeat. Returns ``{lease_extended, state}``."""
+ ...
+
+
+class SessionHandle(Protocol):
+ """Handle returned by a session spawner for one running child CLI.
+
+ Mirrors TS ``SessionHandle`` on ``types.ts:178-190``. The TS version has
+ a ``done: Promise``; Python uses ``asyncio.Future`` or
+ ``asyncio.Task`` — Protocol method signatures express it as
+ ``async def wait_done()``.
+ """
+
+ @property
+ def session_id(self) -> str: ...
+
+ @property
+ def access_token(self) -> str: ...
+
+ @property
+ def activities(self) -> list[SessionActivity]:
+ """Ring buffer of recent activities (last ~10)."""
+ ...
+
+ @property
+ def current_activity(self) -> SessionActivity | None: ...
+
+ @property
+ def last_stderr(self) -> list[str]:
+ """Ring buffer of last stderr lines."""
+ ...
+
+ async def wait_done(self) -> SessionDoneStatus:
+ """Block until the child exits and return the final status."""
+ ...
+
+ def kill(self) -> None:
+ """SIGTERM the child (graceful)."""
+ ...
+
+ def force_kill(self) -> None:
+ """SIGKILL the child (immediate)."""
+ ...
+
+ def write_stdin(self, data: str) -> None:
+ """Write directly to child stdin."""
+ ...
+
+ def update_access_token(self, token: str) -> None:
+ """Update the access token (e.g. after refresh)."""
+ ...
+
+
+class SessionSpawnOpts(TypedDict, total=False):
+ """Spawn-time options for one session.
+
+ Mirrors TS ``SessionSpawnOpts`` on ``types.ts:192-207``. ``use_ccr_v2``
+ + ``worker_epoch`` are required together for the v2 transport path;
+ ``on_first_user_message`` is fired once on first real user prompt for
+ title derivation.
+ """
+
+ session_id: str # required
+ sdk_url: str # required
+ access_token: str # required
+ use_ccr_v2: bool
+ worker_epoch: int
+ on_first_user_message: Callable[[str], None]
+
+
+class SessionSpawner(Protocol):
+ """Factory for child CLI processes.
+
+ Mirrors TS ``SessionSpawner`` on ``types.ts:209-211``.
+ """
+
+ def spawn(self, opts: SessionSpawnOpts, working_dir: str) -> SessionHandle: ...
+
+
+class BridgeLogger(Protocol):
+ """Status / activity logger for the bridge.
+
+ Mirrors TS ``BridgeLogger`` on ``types.ts:213-262``. The TS surface is
+ sprawling (17+ methods) because it owns terminal rendering. Phase 9
+ delivers the concrete implementation; Phase 1 just establishes the
+ Protocol so orchestrators can be wired against a fake/no-op logger.
+ """
+
+ def print_banner(
+ self, config: BridgeConfig, environment_id: str
+ ) -> None: ...
+
+ def log_session_start(self, session_id: str, prompt: str) -> None: ...
+
+ def log_session_complete(
+ self, session_id: str, duration_ms: float
+ ) -> None: ...
+
+ def log_session_failed(self, session_id: str, error: str) -> None: ...
+
+ def log_status(self, message: str) -> None: ...
+
+ def log_verbose(self, message: str) -> None: ...
+
+ def log_error(self, message: str) -> None: ...
+
+ def log_reconnected(self, disconnected_ms: float) -> None: ...
+
+ def update_idle_status(self) -> None: ...
+
+ def update_reconnecting_status(
+ self, delay_str: str, elapsed_str: str
+ ) -> None: ...
+
+ def update_session_status(
+ self,
+ session_id: str,
+ elapsed: str,
+ activity: SessionActivity,
+ trail: list[str],
+ ) -> None: ...
+
+ def clear_status(self) -> None: ...
+
+ def set_repo_info(self, repo_name: str, branch: str) -> None: ...
+
+ def set_debug_log_path(self, path: str) -> None: ...
+
+ def set_attached(self, session_id: str) -> None: ...
+
+ def update_failed_status(self, error: str) -> None: ...
+
+ def toggle_qr(self) -> None: ...
+
+ def update_session_count(
+ self, active: int, max_sessions: int, mode: SpawnMode
+ ) -> None: ...
+
+ def set_spawn_mode_display(
+ self, mode: Literal['same-dir', 'worktree'] | None
+ ) -> None: ...
+
+ def add_session(self, session_id: str, url: str) -> None: ...
+
+ def update_session_activity(
+ self, session_id: str, activity: SessionActivity
+ ) -> None: ...
+
+ def set_session_title(self, session_id: str, title: str) -> None: ...
+
+ def remove_session(self, session_id: str) -> None: ...
+
+ def refresh_display(self) -> None: ...
+
+
+# ---------------------------------------------------------------------------
+# ReplBridgeHandle Protocol (referenced by repl_bridge_handle.py)
+# ---------------------------------------------------------------------------
+
+
+class ReplBridgeHandle(Protocol):
+ """Opaque handle returned by ``init_bridge_core`` / ``init_env_less_bridge_core``.
+
+ Mirrors the consumer-facing surface of TS ``ReplBridgeHandle``
+ (``replBridge.ts`` and ``remoteBridgeCore.ts``). Implementations land in
+ Phase 5 (env-less) and Phase 6 (env-based); the Protocol exists in
+ Phase 1 so ``repl_bridge_handle.py`` (process-global pointer) and other
+ consumers can be ported now.
+
+ ``bridge_session_id`` is exposed for compat-ID derivation in
+ ``repl_bridge_handle.get_self_bridge_compat_id()``.
+ """
+
+ @property
+ def bridge_session_id(self) -> str: ...
+
+ @property
+ def environment_id(self) -> str: ...
+
+ @property
+ def session_ingress_url(self) -> str: ...
+
+ async def write_messages(self, messages: list[Any]) -> None: ...
+
+ async def write_sdk_messages(self, messages: list[Any]) -> None: ...
+
+ async def send_control_request(
+ self, request_id: str, request: dict[str, Any]
+ ) -> None: ...
+
+ async def send_control_response(
+ self, request_id: str, response: dict[str, Any]
+ ) -> None: ...
+
+ async def send_cancel_request(self, request_id: str) -> None: ...
+
+ async def send_result(self) -> None: ...
+
+ async def teardown(self) -> None: ...
+
+
+# ---------------------------------------------------------------------------
+# Internal aliases for type-checking
+# ---------------------------------------------------------------------------
+
+
+GetAccessToken = Callable[[], Union[str, None, Awaitable[Union[str, None]]]]
+"""Sync-or-async access-token getter used widely across the bridge.
+
+Mirrors the TS pattern of ``() => string | undefined | Promise<...>``.
+"""
+
+OnAuth401 = Callable[[str | None], Awaitable[bool]]
+"""Async callback invoked on a 401. Returns True if token refresh succeeded.
+
+Mirrors TS ``onAuth401`` callbacks scattered across ``bridgeApi.ts``,
+``remoteBridgeCore.ts``, and ``replBridge.ts``.
+"""
+
+
+__all__ = [
+ 'BRIDGE_LOGIN_ERROR',
+ 'BRIDGE_LOGIN_INSTRUCTION',
+ 'BridgeApiClient',
+ 'BridgeConfig',
+ 'BridgeLogger',
+ 'BridgeWorkerType',
+ 'DEFAULT_SESSION_TIMEOUT_MS',
+ 'GetAccessToken',
+ 'OnAuth401',
+ 'PermissionResponseEvent',
+ 'REMOTE_CONTROL_DISCONNECTED_MSG',
+ 'ReplBridgeHandle',
+ 'SessionActivity',
+ 'SessionActivityType',
+ 'SessionDoneStatus',
+ 'SessionHandle',
+ 'SessionSpawnOpts',
+ 'SessionSpawner',
+ 'SpawnMode',
+ 'WorkData',
+ 'WorkDataType',
+ 'WorkResponse',
+]
+
+# Re-affirm WorkDataType is exported (Literal alias — appears in WorkData).
+# Listed above explicitly so downstream consumers can write
+# ``from src.bridge.types import WorkDataType``.
diff --git a/src/bridge/work_secret.py b/src/bridge/work_secret.py
new file mode 100644
index 000000000..29e6e7a16
--- /dev/null
+++ b/src/bridge/work_secret.py
@@ -0,0 +1,156 @@
+"""WorkSecret decoder + URL builders + tagged-session-ID compat helpers.
+
+Ports ``typescript/src/bridge/workSecret.ts`` (the wire-format primitive
+half — ``decode_work_secret``, ``build_sdk_url``, ``same_session_id``,
+``build_ccr_v2_sdk_url``). The ``register_worker`` HTTP call lives in
+``src/bridge/code_session_api.py`` (Phase 3 WI-3.4) — different concern
+(HTTP), grouped with other CCR v2 API calls.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+from dataclasses import dataclass, field
+from typing import Any
+from urllib.parse import urlparse
+
+
+@dataclass(frozen=True)
+class WorkSecret:
+ """Parsed work-secret payload (Bridge v1).
+
+ Schema mirrors ``typescript/src/bridge/types.ts:33-51``. The ``version``
+ field MUST be 1; ``decode_work_secret`` raises if not.
+ """
+
+ version: int
+ session_ingress_token: str
+ api_base_url: str
+ sources: tuple[dict[str, Any], ...] = ()
+ auth: tuple[dict[str, Any], ...] = ()
+ claude_code_args: dict[str, str] | None = None
+ mcp_config: object | None = None
+ environment_variables: dict[str, str] | None = None
+ use_code_sessions: bool | None = None
+ raw: dict[str, Any] = field(default_factory=dict)
+
+
+def decode_work_secret(secret: str) -> WorkSecret:
+ """Decode a base64url-encoded work secret JSON.
+
+ Raises ``ValueError`` on:
+ - Non-base64url payload.
+ - Non-object JSON.
+ - Missing or non-1 ``version`` field.
+ - Missing/empty ``session_ingress_token``.
+ - Missing/non-string ``api_base_url``.
+
+ Mirrors ``typescript/src/bridge/workSecret.ts:6-32``.
+ """
+ try:
+ # Pad to a multiple of 4 — base64url omits padding by convention.
+ padding_needed = (-len(secret)) % 4
+ decoded_bytes = base64.urlsafe_b64decode(secret + '=' * padding_needed)
+ except (ValueError, TypeError) as exc:
+ raise ValueError(f'work secret is not valid base64url: {exc}') from exc
+
+ try:
+ parsed: Any = json.loads(decoded_bytes.decode('utf-8'))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ValueError(f'work secret payload is not valid JSON: {exc}') from exc
+
+ if not isinstance(parsed, dict):
+ raise ValueError(f'work secret must be a JSON object, got {type(parsed).__name__}')
+
+ version = parsed.get('version')
+ if version != 1:
+ raise ValueError(f'Unsupported work secret version: {version!r}')
+
+ token = parsed.get('session_ingress_token')
+ if not isinstance(token, str) or not token:
+ raise ValueError('Invalid work secret: missing or empty session_ingress_token')
+
+ base_url = parsed.get('api_base_url')
+ if not isinstance(base_url, str):
+ raise ValueError('Invalid work secret: missing api_base_url')
+
+ sources_raw = parsed.get('sources', [])
+ auth_raw = parsed.get('auth', [])
+
+ return WorkSecret(
+ version=version,
+ session_ingress_token=token,
+ api_base_url=base_url,
+ sources=tuple(sources_raw) if isinstance(sources_raw, list) else (),
+ auth=tuple(auth_raw) if isinstance(auth_raw, list) else (),
+ claude_code_args=parsed.get('claude_code_args'),
+ mcp_config=parsed.get('mcp_config'),
+ environment_variables=parsed.get('environment_variables'),
+ use_code_sessions=parsed.get('use_code_sessions'),
+ raw=parsed,
+ )
+
+
+def build_sdk_url(api_base_url: str, session_id: str) -> str:
+ """Build a session-ingress WS URL from an HTTP base + session ID.
+
+ Mirrors ``workSecret.ts:41-48``: localhost gets ``ws://`` + ``/v2/``
+ (direct to session-ingress, no Envoy rewrite); production gets
+ ``wss://`` + ``/v1/`` (Envoy rewrites ``/v1/`` → ``/v2/``).
+ """
+ parsed = urlparse(api_base_url)
+ hostname = parsed.hostname or ''
+ is_localhost = hostname in ('localhost', '127.0.0.1')
+ protocol = 'ws' if is_localhost else 'wss'
+ version = 'v2' if is_localhost else 'v1'
+ # Strip protocol + trailing slashes from the original host part.
+ host = api_base_url
+ for prefix in ('https://', 'http://'):
+ if host.startswith(prefix):
+ host = host[len(prefix):]
+ break
+ host = host.rstrip('/')
+ return f'{protocol}://{host}/{version}/session_ingress/ws/{session_id}'
+
+
+def build_ccr_v2_sdk_url(api_base_url: str, session_id: str) -> str:
+ """Build a CCR v2 session URL (HTTP base for /v1/code/sessions/{id}).
+
+ Mirrors ``workSecret.ts:81-87``. The child CC derives the SSE stream
+ path and worker endpoints from this URL.
+ """
+ base = api_base_url.rstrip('/')
+ return f'{base}/v1/code/sessions/{session_id}'
+
+
+def same_session_id(a: str, b: str) -> bool:
+ """Compare two session IDs regardless of the tagged-ID prefix family.
+
+ Tagged IDs have the shape ``{tag}_{body}`` or ``{tag}_staging_{body}``,
+ where the body encodes a UUID. CCR v2's compat layer returns
+ ``session_*`` to v1 API clients but the infra layer uses ``cse_*``;
+ both have the same underlying UUID. Without this, ``replBridge``
+ rejects its own session as "foreign" at the work-received check when
+ the ``ccr_v2_compat_enabled`` gate is on.
+
+ Mirrors ``workSecret.ts:62-73``.
+ """
+ if a == b:
+ return True
+ a_body = a[a.rfind('_') + 1:]
+ b_body = b[b.rfind('_') + 1:]
+ # Guard against IDs with no underscore (bare UUIDs): rfind returns -1,
+ # slice starts at 0, returns the whole string. We already handled
+ # ``a == b`` above; require min length 4 to avoid false matches on
+ # short suffixes (e.g., single-char tag remnants).
+ return len(a_body) >= 4 and a_body == b_body
+
+
+__all__ = [
+ 'WorkSecret',
+ 'build_ccr_v2_sdk_url',
+ 'build_sdk_url',
+ 'decode_work_secret',
+ 'same_session_id',
+]
diff --git a/src/bridge/worktree.py b/src/bridge/worktree.py
new file mode 100644
index 000000000..558823fc9
--- /dev/null
+++ b/src/bridge/worktree.py
@@ -0,0 +1,220 @@
+"""Per-session git worktree helper for ``--spawn worktree`` mode.
+
+When the bridge runs multi-session with ``--spawn worktree``, each
+session is given an isolated working tree under
+`` /.claude/worktrees/agent-/`` so concurrent edits
+don't stomp each other. This module owns the create/remove plumbing.
+
+The path layout (`` /.claude/worktrees/agent-/``) is a
+clawcodex convention. Crash-recovery — sweeping orphan worktrees from
+a previous run on daemon startup — is intentionally out of scope for
+this phase; it belongs with the perpetual-mode work in Phase 12c.
+
+Best-effort semantics
+---------------------
+``create_agent_worktree`` never raises. If the base dir isn't a git
+repo, the session_id contains path-unsafe characters, the git binary
+is missing, a subprocess fails or hangs, or the filesystem refuses
+``mkdir`` — all roads lead to a fallback ``WorktreePaths`` with
+``created=False`` and a warning log. The session still runs; it just
+won't be isolated.
+
+Subprocesses are deadline-bounded (see ``_GIT_TIMEOUT_S``) so a stuck
+``git worktree add`` from e.g. a wedged filesystem lock can't hang
+the daemon's poll loop.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import re
+from dataclasses import dataclass
+
+logger = logging.getLogger(__name__)
+
+# Strict allowlist for the directory-name portion of an agent worktree.
+# Session IDs nominally arrive as ``cse_`` from the server, but the
+# work-secret payload isn't strongly typed, so a malformed value could
+# otherwise become a path-traversal vector (``session_id='../escape'``
+# would land the worktree outside `` /.claude/worktrees/``).
+_SESSION_ID_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
+
+# Wall-clock cap for any single git subprocess. ``git worktree add``
+# and ``--force remove`` are normally sub-second, but can hang on a
+# stuck ``.git/index.lock`` or a slow network filesystem. The cap
+# turns a hang into a fallback (for add) or a logged warning (for
+# remove) instead of a wedged daemon.
+_GIT_TIMEOUT_S = 10.0
+
+
+@dataclass
+class WorktreePaths:
+ """Bookkeeping handle returned by :func:`create_agent_worktree`.
+
+ Attributes
+ ----------
+ base_dir
+ Original working directory passed to ``create_agent_worktree``.
+ Used as ``cwd`` for the eventual ``git worktree remove`` call.
+ working_dir
+ What the session should actually run in. Equal to the new
+ worktree path on success; equal to ``base_dir`` on fallback.
+ created
+ ``True`` if a real git worktree was created (and therefore must
+ be removed on cleanup). ``False`` if we fell back to ``base_dir``.
+ """
+
+ base_dir: str
+ working_dir: str
+ created: bool
+
+
+async def _run_git(*args: str, cwd: str) -> tuple[int, str]:
+ """Run ``git `` in ``cwd``. Returns ``(returncode, stderr)``.
+
+ stdout is discarded — worktree commands don't return data we need.
+ Every failure mode — missing git binary, cwd unreadable, subprocess
+ spawn failure, communicate timeout — is converted to a non-zero
+ return code so callers can fall back on a simple rc check. The
+ one thing this CAN'T do is hang: ``_GIT_TIMEOUT_S`` caps the wait
+ and we ``kill()`` the subprocess on timeout.
+ """
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ 'git', *args,
+ cwd=cwd,
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ except (OSError, ValueError) as err:
+ # FileNotFoundError (git not on PATH), PermissionError (cwd
+ # not +x), NotADirectoryError, etc. — all OSError subclasses.
+ # ValueError can come from create_subprocess_exec when args
+ # contain NULs.
+ return -1, str(err)
+ # The try/finally guarantees we kill the subprocess on every exit
+ # path: timeout, ``CancelledError`` from a parent task, or any
+ # other exception. Without it, a cancelled ``_run_git`` leaves the
+ # ``git`` process running after the daemon has shut down — see
+ # the cancellation-cleanup discussion in the Phase 12a review.
+ try:
+ try:
+ _, stderr_bytes = await asyncio.wait_for(
+ proc.communicate(), timeout=_GIT_TIMEOUT_S,
+ )
+ except asyncio.TimeoutError:
+ return -1, f'git timed out after {_GIT_TIMEOUT_S:.2f}s'
+ finally:
+ if proc.returncode is None:
+ try:
+ proc.kill()
+ except ProcessLookupError:
+ pass
+ # Drain so the transport releases its FDs. Bounded so a
+ # truly wedged process can't hang the finally block.
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
+ except (asyncio.TimeoutError, asyncio.CancelledError):
+ pass
+ rc = proc.returncode if proc.returncode is not None else -1
+ return rc, stderr_bytes.decode('utf-8', 'replace')
+
+
+async def _is_git_repo(path: str) -> bool:
+ rc, _ = await _run_git('rev-parse', '--is-inside-work-tree', cwd=path)
+ return rc == 0
+
+
+async def create_agent_worktree(
+ base_dir: str, session_id: str,
+) -> WorktreePaths:
+ """Create `` /.claude/worktrees/agent-/`` as a
+ detached worktree of ``base_dir``.
+
+ Returns a :class:`WorktreePaths` with ``created=True`` on success.
+ On any failure — bad session_id, not a git repo, mkdir error, git
+ command error, subprocess timeout — falls back to ``base_dir``
+ with ``created=False`` and a warning log. Never raises.
+ """
+ # Validate session_id before it touches the filesystem. The
+ # work-secret payload type is loose (server-provided string), so
+ # nothing else stops a malicious or buggy ``id`` value from
+ # turning into a path-traversal write target.
+ if not _SESSION_ID_RE.match(session_id):
+ logger.warning(
+ '[worktree] session_id %r is not allowlist-safe — '
+ 'spawning in base dir', session_id,
+ )
+ return WorktreePaths(
+ base_dir=base_dir, working_dir=base_dir, created=False,
+ )
+
+ if not await _is_git_repo(base_dir):
+ logger.warning(
+ '[worktree] %s is not a git repo — spawning in base dir',
+ base_dir,
+ )
+ return WorktreePaths(
+ base_dir=base_dir, working_dir=base_dir, created=False,
+ )
+
+ target = os.path.join(
+ base_dir, '.claude', 'worktrees', f'agent-{session_id}',
+ )
+ parent = os.path.dirname(target)
+ try:
+ os.makedirs(parent, exist_ok=True)
+ except OSError as err:
+ logger.warning(
+ '[worktree] mkdir(%s) failed: %s — spawning in base dir',
+ parent, err,
+ )
+ return WorktreePaths(
+ base_dir=base_dir, working_dir=base_dir, created=False,
+ )
+
+ # ``--detach`` avoids polluting the branch namespace; the worktree
+ # starts on whatever commit the base dir's HEAD pointed at when
+ # we ran ``git worktree add`` (concurrent ``git reset`` is racy
+ # but acceptable for an ephemeral agent worktree). It has no
+ # branch attached, so ``--force`` removal stays clean later.
+ rc, stderr = await _run_git(
+ 'worktree', 'add', '--detach', target, 'HEAD', cwd=base_dir,
+ )
+ if rc != 0:
+ logger.warning(
+ '[worktree] git worktree add failed (rc=%d): %s — '
+ 'spawning in base dir', rc, stderr.strip(),
+ )
+ return WorktreePaths(
+ base_dir=base_dir, working_dir=base_dir, created=False,
+ )
+
+ logger.info('[worktree] Created %s for session %s', target, session_id)
+ return WorktreePaths(
+ base_dir=base_dir, working_dir=target, created=True,
+ )
+
+
+async def remove_agent_worktree(paths: WorktreePaths) -> None:
+ """Best-effort remove. No-op if ``paths.created`` is ``False``.
+
+ Uses ``--force`` so uncommitted edits in the session's worktree
+ don't block cleanup — the entire point of an agent worktree is
+ that we don't care about its working state after the session ends.
+ """
+ if not paths.created:
+ return
+ rc, stderr = await _run_git(
+ 'worktree', 'remove', '--force', paths.working_dir,
+ cwd=paths.base_dir,
+ )
+ if rc != 0:
+ logger.warning(
+ '[worktree] git worktree remove failed (rc=%d): %s',
+ rc, stderr.strip(),
+ )
+ return
+ logger.info('[worktree] Removed %s', paths.working_dir)
diff --git a/src/buddy/__init__.py b/src/buddy/__init__.py
index 88ce77d14..1a974a373 100644
--- a/src/buddy/__init__.py
+++ b/src/buddy/__init__.py
@@ -1,16 +1,114 @@
-"""Python package placeholder for the archived `buddy` subsystem."""
+"""Python port of ``typescript/src/buddy/``.
+Headless core ports are present here; the Ink/React widget
+(``CompanionSprite``, ``useBuddyNotification``) is deferred per
+``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §3.3.
+"""
from __future__ import annotations
import json
from pathlib import Path
-SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'buddy.json'
+from src.buddy.companion import (
+ Roll,
+ SALT,
+ companion_user_id,
+ get_companion,
+ roll,
+ roll_with_seed,
+)
+from src.buddy.feature import is_buddy_enabled
+from src.buddy.notification import (
+ find_buddy_trigger_positions,
+ is_buddy_live,
+ is_buddy_teaser_window,
+)
+from src.buddy.observer import fire_companion_observer
+from src.buddy.prompt import (
+ build_companion_intro_attachment,
+ companion_intro_text,
+ format_companion_intro_attachments,
+)
+from src.buddy.soul import (
+ NAME_PREFIXES,
+ NAME_SUFFIXES,
+ PERSONALITIES,
+ create_stored_companion,
+)
+from src.buddy.sprites import (
+ BODIES,
+ HAT_LINES,
+ MIN_COLS_FOR_FULL_SPRITE,
+ render_face,
+ render_sprite,
+ sprite_frame_count,
+)
+from src.buddy.types import (
+ EYES,
+ HATS,
+ RARITIES,
+ RARITY_COLORS,
+ RARITY_STARS,
+ RARITY_WEIGHTS,
+ SPECIES,
+ STAT_NAMES,
+ Companion,
+ CompanionBones,
+ CompanionSoul,
+ Eye,
+ Hat,
+ Rarity,
+ Species,
+ StatName,
+ StoredCompanion,
+)
+
+
+SNAPSHOT_PATH = (
+ Path(__file__).resolve().parent.parent
+ / 'reference_data' / 'subsystems' / 'buddy.json'
+)
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
-PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
+PORTING_NOTE = (
+ f"Python port of '{ARCHIVE_NAME}' subsystem. Headless core ported; "
+ f"Ink/React widget (CompanionSprite, useBuddyNotification) deferred "
+ f"to future Textual companion-widget pass — see "
+ f"my-docs/get-parity-by-folder/buddy-gap-analysis.md §3.3."
+)
+
-__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
+# Private helpers (_get_or_create_user_id, _fnv1a_32, _pick_deterministic,
+# _mulberry32, _roll_from, etc.) are deliberately NOT re-exported.
+__all__ = [
+ # snapshot metadata
+ 'ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES',
+ # feature gate
+ 'is_buddy_enabled',
+ # types
+ 'Companion', 'CompanionBones', 'CompanionSoul', 'Eye', 'Hat',
+ 'Rarity', 'Species', 'StatName', 'StoredCompanion',
+ # data
+ 'EYES', 'HATS', 'RARITIES', 'RARITY_COLORS', 'RARITY_STARS',
+ 'RARITY_WEIGHTS', 'SPECIES', 'STAT_NAMES',
+ # companion
+ 'Roll', 'SALT',
+ 'companion_user_id', 'get_companion', 'roll', 'roll_with_seed',
+ # sprites
+ 'BODIES', 'HAT_LINES', 'MIN_COLS_FOR_FULL_SPRITE',
+ 'render_face', 'render_sprite', 'sprite_frame_count',
+ # prompt
+ 'build_companion_intro_attachment', 'companion_intro_text',
+ 'format_companion_intro_attachments',
+ # observer
+ 'fire_companion_observer',
+ # notification
+ 'find_buddy_trigger_positions', 'is_buddy_live',
+ 'is_buddy_teaser_window',
+ # soul
+ 'NAME_PREFIXES', 'NAME_SUFFIXES', 'PERSONALITIES',
+ 'create_stored_companion',
+]
diff --git a/src/buddy/companion.py b/src/buddy/companion.py
new file mode 100644
index 000000000..04759499d
--- /dev/null
+++ b/src/buddy/companion.py
@@ -0,0 +1,261 @@
+"""Companion rolls + identity. Port of ``typescript/src/buddy/companion.ts``.
+
+The "bones + soul" split is load-bearing (gap-analysis §2.1): bones are
+regenerated on every read from ``hash(user_id + SALT)``; the soul is the
+only thing that persists. This means species renames and edits to
+``SPECIES`` never break stored companions, and editing
+``config['companion']`` can't fake a rarity.
+
+Divergence from TS: ``companion_user_id()`` reads
+``config['user_id']`` only (skipping the TS ``oauthAccount.accountUuid``
+fallback), because ``src/auth/claude_ai.py::OAuthAccountInfo`` does not
+yet expose ``account_uuid``. Documented in gap-analysis §2.7. Future
+auth-folder pass will add ``account_uuid`` to the fallback chain.
+
+Performance: the one-entry ``_roll_cache`` keys on ``user_id + SALT``;
+``companion_user_id()`` touches config (potentially file I/O), so
+caching the rolled value is meaningful on the per-turn observer path.
+See gap-analysis §2.2.
+"""
+from __future__ import annotations
+
+import secrets
+from dataclasses import dataclass
+from typing import Callable, Sequence, TypeVar
+
+from src.buddy.types import (
+ EYES, HATS, RARITIES, RARITY_WEIGHTS, SPECIES, STAT_NAMES,
+ CompanionBones, Companion, Eye, Hat, Rarity, Species, StatName,
+)
+from src.config import load_config
+
+
+SALT = 'friend-2026-401'
+
+
+# --- Hash + PRNG -----------------------------------------------------
+
+def _hash_string(s: str) -> int:
+ """FNV-1a 32-bit, mirroring TS ``companion.ts:27-37`` plain branch.
+
+ TS includes a Bun fast-path; Python has no Bun runtime so we keep
+ only FNV-1a. See gap-analysis §3.4 S3.
+ """
+ h = 2166136261
+ for c in s:
+ h ^= ord(c)
+ h = (h * 16777619) & 0xFFFFFFFF
+ return h
+
+
+def _mulberry32(seed: int) -> Callable[[], float]:
+ """Bit-exact mulberry32 — TS ``companion.ts:16-25``.
+
+ TS source::
+
+ a = (a + 0x6d2b79f5) | 0
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+
+ Key detail: the second ``t = ... ^ t`` reads the OLD ``t`` on the
+ right of ``^`` (sequencing rule), so the assignment is
+ ``new_t = (old_t + inner) ^ old_t``. We hold ``old_t`` explicitly.
+
+ Math.imul is 32-bit signed multiply; emulated by masking
+ ``& 0xFFFFFFFF`` after the multiplication. Bit patterns match
+ regardless of signed/unsigned interpretation because subsequent
+ operations are bitwise.
+ """
+ a = seed & 0xFFFFFFFF
+
+ def rng() -> float:
+ nonlocal a
+ a = (a + 0x6D2B79F5) & 0xFFFFFFFF
+ # t = Math.imul(a ^ (a >>> 15), 1 | a)
+ t = ((a ^ (a >> 15)) * (1 | a)) & 0xFFFFFFFF
+ # t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ old_t = t
+ inner = ((t ^ (t >> 7)) * (61 | t)) & 0xFFFFFFFF
+ t = ((t + inner) & 0xFFFFFFFF) ^ old_t
+ # return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
+
+ return rng
+
+
+# --- Roll ------------------------------------------------------------
+
+@dataclass(frozen=True)
+class Roll:
+ """Result of a single roll: deterministic bones + an inspiration seed.
+
+ Mirrors TS ``Roll`` (companion.ts:86-89). The ``inspiration_seed``
+ is currently unused on the Python side but kept for parity.
+ """
+ bones: CompanionBones
+ inspiration_seed: int
+
+
+_T = TypeVar('_T')
+
+
+def _pick(rng: Callable[[], float], items: Sequence[_T]) -> _T:
+ """Index into items by ``floor(rng() * len)``.
+
+ TypeVar preserves the species/eye/hat-literal type at each call site
+ (mirrors TS generic ``pick``).
+ """
+ return items[int(rng() * len(items))]
+
+
+_RARITY_FLOOR: dict[Rarity, int] = {
+ 'common': 5,
+ 'uncommon': 15,
+ 'rare': 25,
+ 'epic': 35,
+ 'legendary': 50,
+}
+
+
+def _roll_rarity(rng: Callable[[], float]) -> Rarity:
+ """TS ``companion.ts:43-51``. Weighted choice."""
+ total = sum(RARITY_WEIGHTS.values())
+ roll_val = rng() * total
+ for r in RARITIES:
+ roll_val -= RARITY_WEIGHTS[r]
+ if roll_val < 0:
+ return r
+ return 'common'
+
+
+def _roll_stats(
+ rng: Callable[[], float], rarity: Rarity,
+) -> dict[StatName, int]:
+ """TS ``companion.ts:62-82``. One peak, one dump, rest scattered."""
+ floor = _RARITY_FLOOR[rarity]
+ peak = _pick(rng, STAT_NAMES)
+ dump = _pick(rng, STAT_NAMES)
+ while dump == peak:
+ dump = _pick(rng, STAT_NAMES)
+ out: dict[StatName, int] = {}
+ for name in STAT_NAMES:
+ if name == peak:
+ out[name] = min(100, floor + 50 + int(rng() * 30))
+ elif name == dump:
+ out[name] = max(1, floor - 10 + int(rng() * 15))
+ else:
+ out[name] = floor + int(rng() * 40)
+ return out
+
+
+def _roll_from(rng: Callable[[], float]) -> Roll:
+ """TS ``companion.ts:91-102``."""
+ rarity = _roll_rarity(rng)
+ species: Species = _pick(rng, SPECIES)
+ eye: Eye = _pick(rng, EYES)
+ hat: Hat = 'none' if rarity == 'common' else _pick(rng, HATS)
+ shiny = rng() < 0.01
+ stats = _roll_stats(rng, rarity)
+ bones = CompanionBones(
+ rarity=rarity,
+ species=species,
+ eye=eye,
+ hat=hat,
+ shiny=shiny,
+ stats=stats,
+ )
+ return Roll(bones=bones, inspiration_seed=int(rng() * 1_000_000_000))
+
+
+# --- Roll cache (gap-analysis §2.2) ---------------------------------
+
+_roll_cache: tuple[str, Roll] | None = None
+
+
+def roll(user_id: str) -> Roll:
+ """Single-entry roll cache keyed by ``user_id + SALT``.
+
+ TS hot paths (per-keystroke PromptInput, 500ms sprite tick) don't
+ exist in Python yet, but ``companion_user_id()`` touches config
+ (potentially file I/O when uncached) and the per-turn observer
+ fires per chat turn — caching the rolled value keeps roll() cheap.
+ """
+ global _roll_cache
+ key = user_id + SALT
+ if _roll_cache is not None and _roll_cache[0] == key:
+ return _roll_cache[1]
+ val = _roll_from(_mulberry32(_hash_string(key)))
+ _roll_cache = (key, val)
+ return val
+
+
+def roll_with_seed(seed: str) -> Roll:
+ """Roll from a caller-provided seed (no cache; not deterministic by user)."""
+ return _roll_from(_mulberry32(_hash_string(seed)))
+
+
+# --- User ID + companion --------------------------------------------
+
+def _get_or_create_user_id() -> str:
+ """Mirror of TS ``getOrCreateUserID()`` at ``config.ts:1858-1867``.
+
+ Reads Python ``config['user_id']``; creates a 32-byte hex on absence
+ (matching TS ``randomBytes(32).toString('hex')``) and persists via
+ ``ConfigManager.set_global``. Skips OAuth-accountUuid fallback
+ because ``OAuthAccountInfo`` doesn't expose it yet — see
+ gap-analysis §2.7.
+ """
+ cfg = load_config()
+ existing = cfg.get('user_id')
+ if isinstance(existing, str) and existing:
+ return existing
+ new_id = secrets.token_hex(32)
+ # Late import to avoid module-load cycles (config imports may pull
+ # in providers which transitively may want bootstrap state).
+ from src.config import _get_default_manager
+ _get_default_manager().set_global('user_id', new_id)
+ return new_id
+
+
+def companion_user_id() -> str:
+ """ID source for the deterministic bones roll. Pinned per §2.7."""
+ return _get_or_create_user_id()
+
+
+def get_companion() -> Companion | None:
+ """Read the persisted soul, merge with regenerated bones.
+
+ Returns ``None`` when no companion has been hatched yet. Mirrors TS
+ ``companion.ts:127-133`` semantics: missing soul → ``None``;
+ present soul → merge with fresh bones.
+ """
+ cfg = load_config()
+ stored = cfg.get('companion')
+ if not isinstance(stored, dict):
+ return None
+ bones = roll(companion_user_id()).bones
+ return Companion(
+ name=stored.get('name', ''),
+ personality=stored.get('personality', ''),
+ hatched_at=int(stored.get('hatched_at', 0)),
+ rarity=bones.rarity,
+ species=bones.species,
+ eye=bones.eye,
+ hat=bones.hat,
+ shiny=bones.shiny,
+ stats=dict(bones.stats),
+ )
+
+
+def _reset_roll_cache_for_tests() -> None:
+ """Pytest-gated escape hatch — see gap-analysis §5 / plan §5 risks."""
+ global _roll_cache
+ _roll_cache = None
+
+
+__all__ = [
+ 'Roll', 'SALT',
+ 'companion_user_id', 'get_companion',
+ 'roll', 'roll_with_seed',
+]
diff --git a/src/buddy/feature.py b/src/buddy/feature.py
new file mode 100644
index 000000000..6f323a6a5
--- /dev/null
+++ b/src/buddy/feature.py
@@ -0,0 +1,18 @@
+"""Buddy feature gate. Port of ``typescript/src/buddy/feature.ts``.
+
+Always returns ``True``. Symbol exists so call sites are auditable
+(grep-able) and a future env-var or config gate can be wired here
+without touching every consumer.
+
+See ``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §4.7 and
+``buddy-refactoring-plan.md`` §2.1 for the pinned always-True decision.
+"""
+from __future__ import annotations
+
+
+def is_buddy_enabled() -> bool:
+ """Whether the companion / buddy subsystem is active in this build."""
+ return True
+
+
+__all__ = ['is_buddy_enabled']
diff --git a/src/buddy/notification.py b/src/buddy/notification.py
new file mode 100644
index 000000000..43bb8777f
--- /dev/null
+++ b/src/buddy/notification.py
@@ -0,0 +1,62 @@
+"""Buddy notification helpers + date gates + trigger highlights.
+
+Ports the non-React parts of ``typescript/src/buddy/useBuddyNotification.tsx``.
+The React hook ``useBuddyNotification`` itself stays deferred (Class C1
+in ``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §3.3) — Python
+has no notification context outside Textual yet, and the rainbow
+renderer needs a Textual host.
+
+The TS dead ``"external" === 'ant'`` branch is dropped here: Python has
+no equivalent build-substitution facility. See gap-analysis §2.4.
+"""
+from __future__ import annotations
+
+import re
+from datetime import datetime
+
+from src.buddy.feature import is_buddy_enabled
+
+
+def is_buddy_teaser_window() -> bool:
+ """Whether today falls inside the April 1-7 2026 teaser window.
+
+ Local date, not UTC — mirrors TS ``useBuddyNotification.tsx:12-16``.
+ The teaser window is a 24h rolling sweep across timezones (avoids
+ a UTC-midnight spike on soul-gen load).
+ """
+ d = datetime.now()
+ return d.year == 2026 and d.month == 4 and d.day <= 7
+
+
+def is_buddy_live() -> bool:
+ """Whether the buddy feature is live (post April 1 2026).
+
+ Mirrors TS ``useBuddyNotification.tsx:17-21``.
+ """
+ d = datetime.now()
+ return d.year > 2026 or (d.year == 2026 and d.month >= 4)
+
+
+_BUDDY_TRIGGER_RE = re.compile(r'/buddy\b')
+
+
+def find_buddy_trigger_positions(text: str) -> list[dict[str, int]]:
+ """Return character ranges where ``/buddy`` appears (word-bounded).
+
+ Mirrors TS ``useBuddyNotification.tsx:79-97``. Returns a list of
+ ``{"start": int, "end": int}`` dicts so callers can apply highlight
+ styling to those character ranges in a future PromptInput widget.
+ """
+ if not is_buddy_enabled():
+ return []
+ return [
+ {'start': m.start(), 'end': m.end()}
+ for m in _BUDDY_TRIGGER_RE.finditer(text)
+ ]
+
+
+__all__ = [
+ 'find_buddy_trigger_positions',
+ 'is_buddy_live',
+ 'is_buddy_teaser_window',
+]
diff --git a/src/buddy/observer.py b/src/buddy/observer.py
new file mode 100644
index 000000000..9db0ecdbf
--- /dev/null
+++ b/src/buddy/observer.py
@@ -0,0 +1,138 @@
+"""Per-turn companion observer.
+
+Port of ``typescript/src/buddy/observer.ts``. Headless in Python: the
+function calls ``on_reaction(quip)`` and has no built-in UI side effect.
+The REPL caller decides what to do with the quip (today: a no-op until
+the Textual companion sprite lands; future: an AppState write).
+
+Known divergence-from-intuition: by the time the observer reads the
+most recent user message, the REPL has already prepended at-mention
+reminders and may have appended the companion-intro system-reminder to
+``user_input`` (per ``src/repl/core.py`` WI-10 edits). The
+``"/buddy"`` / companion-name match therefore can match against
+system-reminder text rather than the user's actually-typed words.
+TS has the exact same behavior (TS also prepends and reads from
+``messagesRef.current``); this is parity-correct, not a Python-side bug.
+See ``my-docs/get-parity-by-folder/buddy-refactoring-plan.md`` §2.11
+Edit 2 closing note.
+"""
+from __future__ import annotations
+
+from typing import Any, Callable, Iterable, Sequence
+
+from src.buddy.companion import get_companion
+from src.config import load_config
+
+
+_DIRECT_REPLIES: tuple[str, ...] = (
+ 'I am observing.',
+ 'I am helping from the corner.',
+ 'I saw that.',
+ 'Still here.',
+ 'Watching closely.',
+)
+
+_PET_REPLIES: tuple[str, ...] = (
+ 'happy chirp',
+ 'tiny victory dance',
+ 'quietly approves',
+ 'wiggles with joy',
+ 'looks pleased',
+)
+
+
+def _fnv1a_32(s: str) -> int:
+ """FNV-1a 32-bit. Duplicated from ``src/buddy/companion.py`` and
+ ``src/buddy/soul.py`` — see gap-analysis §2.8."""
+ h = 2166136261
+ for c in s:
+ h ^= ord(c)
+ h = (h * 16777619) & 0xFFFFFFFF
+ return h
+
+
+def _pick_deterministic(items: Sequence[str], seed: str) -> str:
+ return items[_fnv1a_32(seed) % len(items)]
+
+
+def _get_last_user_text(messages: Iterable[Any]) -> str | None:
+ """Return the most recent user message's text, or None.
+
+ TS uses ``getUserMessageText`` from ``utils/messages.ts``. Python
+ doesn't have a 1:1 port; we walk messages backward looking for
+ ``type='user'`` and extract text from string or block-list content.
+ """
+ last_user = None
+ for msg in messages:
+ m_type = getattr(msg, 'type', None)
+ if m_type is None and isinstance(msg, dict):
+ m_type = msg.get('type')
+ if m_type == 'user':
+ last_user = msg
+
+ if last_user is None:
+ return None
+
+ content = getattr(last_user, 'content', None)
+ if content is None and isinstance(last_user, dict):
+ content = last_user.get('content')
+
+ if isinstance(content, str):
+ stripped = content.strip()
+ return stripped if stripped else None
+ if isinstance(content, list):
+ parts: list[str] = []
+ for block in content:
+ if isinstance(block, dict):
+ if block.get('type') == 'text':
+ parts.append(str(block.get('text', '')))
+ else:
+ # Dataclass content block (TextBlock etc.) — best-effort.
+ btype = getattr(block, 'type', None)
+ if btype == 'text':
+ parts.append(str(getattr(block, 'text', '')))
+ joined = ' '.join(parts).strip()
+ return joined if joined else None
+ return None
+
+
+def fire_companion_observer(
+ messages: Iterable[Any],
+ on_reaction: Callable[[str | None], None],
+) -> None:
+ """TS ``observer.ts:35-65``.
+
+ Synchronous in Python (TS was async-marked but never awaits — the
+ body is pure CPU).
+ """
+ companion = get_companion()
+ if companion is None:
+ return
+ if load_config().get('companion_muted', False):
+ return
+
+ text = _get_last_user_text(messages)
+ if not text:
+ return
+
+ lower = text.lower()
+ companion_name_lower = companion.name.lower()
+
+ if '/buddy' in lower:
+ on_reaction(
+ _pick_deterministic(_PET_REPLIES, text + companion.name)
+ )
+ return
+
+ if (
+ companion_name_lower in lower
+ or 'buddy' in lower
+ or 'companion' in lower
+ ):
+ reply = _pick_deterministic(
+ _DIRECT_REPLIES, text + companion.personality
+ )
+ on_reaction(f"{companion.name}: {reply}")
+
+
+__all__ = ['fire_companion_observer']
diff --git a/src/buddy/prompt.py b/src/buddy/prompt.py
new file mode 100644
index 000000000..3b79bde48
--- /dev/null
+++ b/src/buddy/prompt.py
@@ -0,0 +1,137 @@
+"""Companion-intro attachment + system-prompt text.
+
+Port of ``typescript/src/buddy/prompt.ts``.
+
+Two functions:
+
+* :func:`companion_intro_text` — the system-prompt block (TS prompt.ts:7-13).
+* :func:`build_companion_intro_attachment` — guards + dedup + attachment
+ dict construction (TS prompt.ts:15-35). Signature matches TS exactly
+ (only ``messages`` argument; ``companion`` resolved internally) per
+ ``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §4.6 item 1.
+
+Plus :func:`format_companion_intro_attachments` — a SIBLING formatter
+to ``src.command_system.input_processing.format_at_mention_attachments``
+that renders the companion-intro into a ```` block.
+The two formatters are kept separate so neither's scope creeps into the
+other (gap-analysis §4.6 item 2).
+
+No null-rendering filter needed in Python: ``src/tui/widgets/
+transcript_view.py`` does not auto-walk ``AttachmentMessage`` instances;
+intro text reaches the model via prepend to ``user_input``, and the
+``AttachmentMessage`` marker is invisible to both renderer and API.
+See gap-analysis §3.6, §2.9.
+"""
+from __future__ import annotations
+
+from typing import Any, Iterable
+
+from src.buddy.companion import get_companion
+from src.buddy.feature import is_buddy_enabled
+from src.config import load_config
+from src.types.messages import AttachmentMessage
+
+
+def companion_intro_text(name: str, species: str) -> str:
+ """System-prompt block describing the companion to the model.
+
+ Mirrors TS ``prompt.ts:7-13`` byte-for-byte except for the leading
+ ``# Companion`` heading and the f-string interpolation.
+ """
+ return (
+ f"# Companion\n\n"
+ f"A small {species} named {name} sits beside the user's input box "
+ f"and occasionally comments in a speech bubble. You're not {name} — "
+ f"it's a separate watcher.\n\n"
+ f"When the user addresses {name} directly (by name), its bubble "
+ f"will answer. Your job in that moment is to stay out of the way: "
+ f"respond in ONE line or less, or just answer any part of the "
+ f"message meant for you. Don't explain that you're not {name} — "
+ f"they know. Don't narrate what {name} might say — the bubble "
+ f"handles that."
+ )
+
+
+def build_companion_intro_attachment(
+ messages: Iterable[Any] | None,
+) -> list[dict[str, Any]]:
+ """Build the companion-intro attachment, or ``[]``.
+
+ Returns ``[]`` when ANY of:
+
+ * buddy is disabled,
+ * no companion hatched (``get_companion()`` returns ``None``),
+ * companion is muted (``config['companion_muted']`` truthy),
+ * this companion has already been announced in this conversation
+ (dedup loop walks ``messages`` for an ``AttachmentMessage`` whose
+ ``attachments`` list contains a ``companion_intro`` matching the
+ current companion's name).
+
+ Otherwise returns a one-element list ``[{"kind": "companion_intro",
+ "name": ..., "species": ...}]``.
+
+ Dedup-loop shape mirrors TS ``prompt.ts:23-28`` adapted to Python's
+ plural ``msg.attachments`` list (TS has singular ``msg.attachment``).
+ See gap-analysis §2.8 for the key-rename (``"kind"`` vs TS's
+ ``"type"``) rationale.
+
+ Does NOT mutate state.
+ """
+ if not is_buddy_enabled():
+ return []
+ companion = get_companion()
+ if companion is None:
+ return []
+ if load_config().get('companion_muted', False):
+ return []
+
+ for msg in messages or []:
+ if not isinstance(msg, AttachmentMessage):
+ continue
+ for att in (msg.attachments or []):
+ if not isinstance(att, dict):
+ continue
+ if att.get('kind') == 'companion_intro' and att.get('name') == companion.name:
+ return []
+
+ return [{
+ 'kind': 'companion_intro',
+ 'name': companion.name,
+ 'species': companion.species,
+ }]
+
+
+def format_companion_intro_attachments(
+ attachments: list[dict[str, Any]],
+) -> str:
+ """Render companion-intro attachments as concatenated s.
+
+ Sibling formatter to
+ ``src.command_system.input_processing.format_at_mention_attachments``.
+ Filters for ``kind == "companion_intro"`` and ignores other kinds
+ (so callers can pass a heterogenous attachment list safely, though
+ in practice ``build_companion_intro_attachment`` returns only this
+ kind).
+
+ Concatenation order at the REPL caller per gap-analysis §4.6 item 2:
+ at-mention text FIRST, then this formatter's output appended.
+ """
+ blocks: list[str] = []
+ for att in attachments:
+ if not isinstance(att, dict):
+ continue
+ if att.get('kind') != 'companion_intro':
+ continue
+ text = companion_intro_text(
+ att.get('name', ''),
+ att.get('species', ''),
+ )
+ blocks.append(f"\n{text}\n ")
+ return '\n\n'.join(blocks)
+
+
+__all__ = [
+ 'build_companion_intro_attachment',
+ 'companion_intro_text',
+ 'format_companion_intro_attachments',
+]
diff --git a/src/buddy/soul.py b/src/buddy/soul.py
new file mode 100644
index 000000000..d7ea4bb32
--- /dev/null
+++ b/src/buddy/soul.py
@@ -0,0 +1,75 @@
+"""Deterministic soul generator.
+
+Splits ``NAME_PREFIXES`` / ``NAME_SUFFIXES`` / ``PERSONALITIES`` out of
+TS ``commands/buddy/buddy.tsx`` per
+``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §4.1: this
+module owns persistent soul data; ``PET_REACTIONS`` lives in
+``src/command_system/buddy_command.py`` because it's live (re-seeded
+on every pet via ``time.time()``) rather than persistent.
+"""
+from __future__ import annotations
+
+import time
+from typing import Sequence
+
+from src.buddy.types import StoredCompanion
+
+
+NAME_PREFIXES: tuple[str, ...] = (
+ 'Byte', 'Echo', 'Glint', 'Miso', 'Nova',
+ 'Pixel', 'Rune', 'Static', 'Vector', 'Whisk',
+)
+
+NAME_SUFFIXES: tuple[str, ...] = (
+ 'bean', 'bit', 'bud', 'dot', 'ling',
+ 'loop', 'moss', 'patch', 'puff', 'spark',
+)
+
+PERSONALITIES: tuple[str, ...] = (
+ 'Curious and quietly encouraging',
+ 'A patient little watcher with strong debugging instincts',
+ 'Playful, observant, and suspicious of flaky tests',
+ 'Calm under pressure and fond of clean diffs',
+ 'A tiny terminal gremlin who likes successful builds',
+)
+
+
+def _fnv1a_32(s: str) -> int:
+ """FNV-1a 32-bit hash, matching TS ``buddy.tsx:49-56``.
+
+ Duplicated from ``src/buddy/companion.py`` and
+ ``src/buddy/observer.py``. Three duplications of 5 lines is fine —
+ see gap-analysis §2.8.
+ """
+ h = 2166136261
+ for c in s:
+ h ^= ord(c)
+ h = (h * 16777619) & 0xFFFFFFFF
+ return h
+
+
+def _pick_deterministic(items: Sequence[str], seed: str) -> str:
+ return items[_fnv1a_32(seed) % len(items)]
+
+
+def create_stored_companion(user_id: str) -> StoredCompanion:
+ """Mirror of TS ``commands/buddy/buddy.tsx:66-78``.
+
+ Returns a fresh ``StoredCompanion`` with deterministic name and
+ personality keyed off ``user_id``. ``hatched_at`` is unix milliseconds
+ at call time (the only non-deterministic field — same as TS).
+ """
+ prefix = _pick_deterministic(NAME_PREFIXES, f"{user_id}:prefix")
+ suffix = _pick_deterministic(NAME_SUFFIXES, f"{user_id}:suffix")
+ personality = _pick_deterministic(PERSONALITIES, f"{user_id}:personality")
+ return {
+ 'name': f"{prefix}{suffix}",
+ 'personality': f"{personality}.",
+ 'hatched_at': int(time.time() * 1000),
+ }
+
+
+__all__ = [
+ 'NAME_PREFIXES', 'NAME_SUFFIXES', 'PERSONALITIES',
+ 'create_stored_companion',
+]
diff --git a/src/buddy/sprites.py b/src/buddy/sprites.py
new file mode 100644
index 000000000..bd2b2ca47
--- /dev/null
+++ b/src/buddy/sprites.py
@@ -0,0 +1,530 @@
+"""ASCII sprite library. Port of ``typescript/src/buddy/sprites.ts``.
+
+Each species has 3 frames; each frame is 5 lines of 12 characters with
+``{E}`` placeholders for the eye glyph. ``render_sprite()`` substitutes
+the eye and optionally overwrites line 0 with the hat.
+
+Shift rule (sprites.ts:467): when ``hat == 'none'`` AND every frame of
+the species has a blank ``lines[0]``, the rendered output drops line 0
+(reducing height from 5 to 4). When at least one frame uses line 0 for
+a fidget (penguin's ``~ ~`` waddle, dragon's ``~ ~``, robot's antenna
+sparkle, etc.), the height stays 5 even with no hat — otherwise the
+sprite would jump up and down between frames.
+
+See ``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §4.2 for the
+"port this even though the consumer (Textual widget) is deferred"
+rationale.
+"""
+from __future__ import annotations
+
+from src.buddy.types import CompanionBones, Eye, Hat, Species
+
+
+# --- Sprite width constants -----------------------------------------
+
+# Reserved-column threshold for the Textual companion widget (deferred
+# Class C). Kept here so the future widget reads it from the data layer.
+MIN_COLS_FOR_FULL_SPRITE: int = 100
+
+
+# --- Bodies ---------------------------------------------------------
+
+# Each species → 3 frames; each frame → 5 strings of width 12. ``{E}``
+# is the eye-glyph placeholder. Direct port of TS ``BODIES`` table at
+# sprites.ts:26-441. ASCII-only.
+BODIES: dict[Species, tuple[tuple[str, ...], ...]] = {
+ 'duck': (
+ (
+ ' ',
+ ' __ ',
+ ' <({E} )___ ',
+ ' ( ._> ',
+ ' `--´ ',
+ ),
+ (
+ ' ',
+ ' __ ',
+ ' <({E} )___ ',
+ ' ( ._> ',
+ ' `--´~ ',
+ ),
+ (
+ ' ',
+ ' __ ',
+ ' <({E} )___ ',
+ ' ( .__> ',
+ ' `--´ ',
+ ),
+ ),
+ 'goose': (
+ (
+ ' ',
+ ' ({E}> ',
+ ' || ',
+ ' _(__)_ ',
+ ' ^^^^ ',
+ ),
+ (
+ ' ',
+ ' ({E}> ',
+ ' || ',
+ ' _(__)_ ',
+ ' ^^^^ ',
+ ),
+ (
+ ' ',
+ ' ({E}>> ',
+ ' || ',
+ ' _(__)_ ',
+ ' ^^^^ ',
+ ),
+ ),
+ 'blob': (
+ (
+ ' ',
+ ' .----. ',
+ ' ( {E} {E} ) ',
+ ' ( ) ',
+ ' `----´ ',
+ ),
+ (
+ ' ',
+ ' .------. ',
+ ' ( {E} {E} ) ',
+ ' ( ) ',
+ ' `------´ ',
+ ),
+ (
+ ' ',
+ ' .--. ',
+ ' ({E} {E}) ',
+ ' ( ) ',
+ ' `--´ ',
+ ),
+ ),
+ 'cat': (
+ (
+ ' ',
+ ' /\\_/\\ ',
+ ' ( {E} {E}) ',
+ ' ( ω ) ',
+ ' (")_(") ',
+ ),
+ (
+ ' ',
+ ' /\\_/\\ ',
+ ' ( {E} {E}) ',
+ ' ( ω ) ',
+ ' (")_(")~ ',
+ ),
+ (
+ ' ',
+ ' /\\-/\\ ',
+ ' ( {E} {E}) ',
+ ' ( ω ) ',
+ ' (")_(") ',
+ ),
+ ),
+ 'dragon': (
+ (
+ ' ',
+ ' /^\\ /^\\ ',
+ ' < {E} {E} > ',
+ ' ( ~~ ) ',
+ ' `-vvvv-´ ',
+ ),
+ (
+ ' ',
+ ' /^\\ /^\\ ',
+ ' < {E} {E} > ',
+ ' ( ) ',
+ ' `-vvvv-´ ',
+ ),
+ (
+ ' ~ ~ ',
+ ' /^\\ /^\\ ',
+ ' < {E} {E} > ',
+ ' ( ~~ ) ',
+ ' `-vvvv-´ ',
+ ),
+ ),
+ 'octopus': (
+ (
+ ' ',
+ ' .----. ',
+ ' ( {E} {E} ) ',
+ ' (______) ',
+ ' /\\/\\/\\/\\ ',
+ ),
+ (
+ ' ',
+ ' .----. ',
+ ' ( {E} {E} ) ',
+ ' (______) ',
+ ' \\/\\/\\/\\/ ',
+ ),
+ (
+ ' o ',
+ ' .----. ',
+ ' ( {E} {E} ) ',
+ ' (______) ',
+ ' /\\/\\/\\/\\ ',
+ ),
+ ),
+ 'owl': (
+ (
+ ' ',
+ ' /\\ /\\ ',
+ ' (({E})({E})) ',
+ ' ( >< ) ',
+ ' `----´ ',
+ ),
+ (
+ ' ',
+ ' /\\ /\\ ',
+ ' (({E})({E})) ',
+ ' ( >< ) ',
+ ' .----. ',
+ ),
+ (
+ ' ',
+ ' /\\ /\\ ',
+ ' (({E})(-)) ',
+ ' ( >< ) ',
+ ' `----´ ',
+ ),
+ ),
+ 'penguin': (
+ (
+ ' ',
+ ' .---. ',
+ ' ({E}>{E}) ',
+ ' /( )\\ ',
+ ' `---´ ',
+ ),
+ (
+ ' ',
+ ' .---. ',
+ ' ({E}>{E}) ',
+ ' |( )| ',
+ ' `---´ ',
+ ),
+ (
+ ' .---. ',
+ ' ({E}>{E}) ',
+ ' /( )\\ ',
+ ' `---´ ',
+ ' ~ ~ ',
+ ),
+ ),
+ 'turtle': (
+ (
+ ' ',
+ ' _,--._ ',
+ ' ( {E} {E} ) ',
+ ' /[______]\\ ',
+ ' `` `` ',
+ ),
+ (
+ ' ',
+ ' _,--._ ',
+ ' ( {E} {E} ) ',
+ ' /[______]\\ ',
+ ' `` `` ',
+ ),
+ (
+ ' ',
+ ' _,--._ ',
+ ' ( {E} {E} ) ',
+ ' /[======]\\ ',
+ ' `` `` ',
+ ),
+ ),
+ 'snail': (
+ (
+ ' ',
+ ' {E} .--. ',
+ ' \\ ( @ ) ',
+ ' \\_`--´ ',
+ ' ~~~~~~~ ',
+ ),
+ (
+ ' ',
+ ' {E} .--. ',
+ ' | ( @ ) ',
+ ' \\_`--´ ',
+ ' ~~~~~~~ ',
+ ),
+ (
+ ' ',
+ ' {E} .--. ',
+ ' \\ ( @ ) ',
+ ' \\_`--´ ',
+ ' ~~~~~~ ',
+ ),
+ ),
+ 'ghost': (
+ (
+ ' ',
+ ' .----. ',
+ ' / {E} {E} \\ ',
+ ' | | ',
+ ' ~`~``~`~ ',
+ ),
+ (
+ ' ',
+ ' .----. ',
+ ' / {E} {E} \\ ',
+ ' | | ',
+ ' `~`~~`~` ',
+ ),
+ (
+ ' ~ ~ ',
+ ' .----. ',
+ ' / {E} {E} \\ ',
+ ' | | ',
+ ' ~~`~~`~~ ',
+ ),
+ ),
+ 'axolotl': (
+ (
+ ' ',
+ '}~(______)~{',
+ '}~({E} .. {E})~{',
+ ' ( .--. ) ',
+ ' (_/ \\_) ',
+ ),
+ (
+ ' ',
+ '~}(______){~',
+ '~}({E} .. {E}){~',
+ ' ( .--. ) ',
+ ' (_/ \\_) ',
+ ),
+ (
+ ' ',
+ '}~(______)~{',
+ '}~({E} .. {E})~{',
+ ' ( -- ) ',
+ ' ~_/ \\_~ ',
+ ),
+ ),
+ 'capybara': (
+ (
+ ' ',
+ ' n______n ',
+ ' ( {E} {E} ) ',
+ ' ( oo ) ',
+ ' `------´ ',
+ ),
+ (
+ ' ',
+ ' n______n ',
+ ' ( {E} {E} ) ',
+ ' ( Oo ) ',
+ ' `------´ ',
+ ),
+ (
+ ' ~ ~ ',
+ ' u______n ',
+ ' ( {E} {E} ) ',
+ ' ( oo ) ',
+ ' `------´ ',
+ ),
+ ),
+ 'cactus': (
+ (
+ ' ',
+ ' n ____ n ',
+ ' | |{E} {E}| | ',
+ ' |_| |_| ',
+ ' | | ',
+ ),
+ (
+ ' ',
+ ' ____ ',
+ ' n |{E} {E}| n ',
+ ' |_| |_| ',
+ ' | | ',
+ ),
+ (
+ ' n n ',
+ ' | ____ | ',
+ ' | |{E} {E}| | ',
+ ' |_| |_| ',
+ ' | | ',
+ ),
+ ),
+ 'robot': (
+ (
+ ' ',
+ ' .[||]. ',
+ ' [ {E} {E} ] ',
+ ' [ ==== ] ',
+ ' `------´ ',
+ ),
+ (
+ ' ',
+ ' .[||]. ',
+ ' [ {E} {E} ] ',
+ ' [ -==- ] ',
+ ' `------´ ',
+ ),
+ (
+ ' * ',
+ ' .[||]. ',
+ ' [ {E} {E} ] ',
+ ' [ ==== ] ',
+ ' `------´ ',
+ ),
+ ),
+ 'rabbit': (
+ (
+ ' ',
+ ' (\\__/) ',
+ ' ( {E} {E} ) ',
+ ' =( .. )= ',
+ ' (")__(") ',
+ ),
+ (
+ ' ',
+ ' (|__/) ',
+ ' ( {E} {E} ) ',
+ ' =( .. )= ',
+ ' (")__(") ',
+ ),
+ (
+ ' ',
+ ' (\\__/) ',
+ ' ( {E} {E} ) ',
+ ' =( . . )= ',
+ ' (")__(") ',
+ ),
+ ),
+ 'mushroom': (
+ (
+ ' ',
+ ' .-o-OO-o-. ',
+ '(__________)',
+ ' |{E} {E}| ',
+ ' |____| ',
+ ),
+ (
+ ' ',
+ ' .-O-oo-O-. ',
+ '(__________)',
+ ' |{E} {E}| ',
+ ' |____| ',
+ ),
+ (
+ ' . o . ',
+ ' .-o-OO-o-. ',
+ '(__________)',
+ ' |{E} {E}| ',
+ ' |____| ',
+ ),
+ ),
+ 'chonk': (
+ (
+ ' ',
+ ' /\\ /\\ ',
+ ' ( {E} {E} ) ',
+ ' ( .. ) ',
+ ' `------´ ',
+ ),
+ (
+ ' ',
+ ' /\\ /| ',
+ ' ( {E} {E} ) ',
+ ' ( .. ) ',
+ ' `------´ ',
+ ),
+ (
+ ' ',
+ ' /\\ /\\ ',
+ ' ( {E} {E} ) ',
+ ' ( .. ) ',
+ ' `------´~ ',
+ ),
+ ),
+}
+
+
+# --- Hats -----------------------------------------------------------
+
+HAT_LINES: dict[Hat, str] = {
+ 'none': '',
+ 'crown': ' \\^^^/ ',
+ 'tophat': ' [___] ',
+ 'propeller': ' -+- ',
+ 'halo': ' ( ) ',
+ 'wizard': ' /^\\ ',
+ 'beanie': ' (___) ',
+ 'tinyduck': ' ,> ',
+}
+
+
+# --- Rendering ------------------------------------------------------
+
+def render_sprite(bones: CompanionBones, frame: int = 0) -> list[str]:
+ """Render the sprite for ``bones`` at the given fidget ``frame``.
+
+ Returns a list of 4 or 5 strings (see shift-rule in module
+ docstring). Mirrors TS ``sprites.ts:454-469``.
+ """
+ frames = BODIES[bones.species]
+ raw = frames[frame % len(frames)]
+ body = [line.replace('{E}', bones.eye) for line in raw]
+ lines = list(body)
+ # Replace blank line 0 with hat IF a hat is requested AND line 0 is
+ # blank in this particular frame.
+ if bones.hat != 'none' and not lines[0].strip():
+ lines[0] = HAT_LINES[bones.hat]
+ # Drop blank hat slot — wastes a row in the Card and ambient sprite
+ # when there's no hat AND the frame isn't using line 0 for fidget.
+ # Shift is ONLY safe when ALL frames have a blank line 0 (otherwise
+ # heights oscillate between fidget frames).
+ if not lines[0].strip() and all(not f[0].strip() for f in frames):
+ lines.pop(0)
+ return lines
+
+
+def sprite_frame_count(species: Species) -> int:
+ """Number of fidget frames available for ``species``. Always 3 today."""
+ return len(BODIES[species])
+
+
+def render_face(bones: CompanionBones) -> str:
+ """Compact one-line face for narrow-terminal mode.
+
+ Mirrors TS ``sprites.ts:475-514`` switch via a dict lookup.
+ """
+ e: Eye = bones.eye
+ faces: dict[Species, str] = {
+ 'duck': f'({e}>',
+ 'goose': f'({e}>',
+ 'blob': f'({e}{e})',
+ 'cat': f'={e}ω{e}=',
+ 'dragon': f'<{e}~{e}>',
+ 'octopus': f'~({e}{e})~',
+ 'owl': f'({e})({e})',
+ 'penguin': f'({e}>)',
+ 'turtle': f'[{e}_{e}]',
+ 'snail': f'{e}(@)',
+ 'ghost': f'/{e}{e}\\',
+ 'axolotl': f'}}{e}.{e}{{',
+ 'capybara': f'({e}oo{e})',
+ 'cactus': f'|{e} {e}|',
+ 'robot': f'[{e}{e}]',
+ 'rabbit': f'({e}..{e})',
+ 'mushroom': f'|{e} {e}|',
+ 'chonk': f'({e}.{e})',
+ }
+ return faces[bones.species]
+
+
+__all__ = [
+ 'MIN_COLS_FOR_FULL_SPRITE',
+ 'BODIES', 'HAT_LINES',
+ 'render_face', 'render_sprite', 'sprite_frame_count',
+]
diff --git a/src/buddy/types.py b/src/buddy/types.py
new file mode 100644
index 000000000..41e95edba
--- /dev/null
+++ b/src/buddy/types.py
@@ -0,0 +1,175 @@
+"""Buddy data types — port of ``typescript/src/buddy/types.ts``.
+
+Five concept tiers (rarity / species / eye / hat / stat) plus the bones-
+and-soul shape types underlying ``Companion``.
+
+``RARITY_COLORS`` values are Python palette field names (matching
+``src.utils.theme.Palette`` fields), not raw color strings. Consumers
+``getattr(palette, RARITY_COLORS[rarity])`` to materialize.
+See ``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §3.5 / §4.4
+for the TS-key → Python-key mapping rationale.
+
+Audit-trail: TS encodes each species name via ``String.fromCharCode``
+to keep canary-class strings out of the build output for a build-time
+``excluded-strings.txt`` grep. The Python build has no such check, so
+the species literals here are plain strings. If a future Python build
+adds a similar canary scan, reconstruct via ``chr()`` at module load.
+See gap-analysis §2.3.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal, TypedDict
+
+
+# --- Rarity ----------------------------------------------------------
+
+Rarity = Literal['common', 'uncommon', 'rare', 'epic', 'legendary']
+
+RARITIES: tuple[Rarity, ...] = (
+ 'common', 'uncommon', 'rare', 'epic', 'legendary',
+)
+
+
+# --- Species ---------------------------------------------------------
+
+Species = Literal[
+ 'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl',
+ 'penguin', 'turtle', 'snail', 'ghost', 'axolotl', 'capybara',
+ 'cactus', 'robot', 'rabbit', 'mushroom', 'chonk',
+]
+
+SPECIES: tuple[Species, ...] = (
+ 'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl',
+ 'penguin', 'turtle', 'snail', 'ghost', 'axolotl', 'capybara',
+ 'cactus', 'robot', 'rabbit', 'mushroom', 'chonk',
+)
+
+
+# --- Eye glyphs ------------------------------------------------------
+
+Eye = Literal['·', '✦', '×', '◉', '@', '°']
+
+EYES: tuple[Eye, ...] = ('·', '✦', '×', '◉', '@', '°')
+
+
+# --- Hats ------------------------------------------------------------
+
+Hat = Literal[
+ 'none', 'crown', 'tophat', 'propeller', 'halo',
+ 'wizard', 'beanie', 'tinyduck',
+]
+
+HATS: tuple[Hat, ...] = (
+ 'none', 'crown', 'tophat', 'propeller', 'halo',
+ 'wizard', 'beanie', 'tinyduck',
+)
+
+
+# --- Stat names ------------------------------------------------------
+
+StatName = Literal['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK']
+
+STAT_NAMES: tuple[StatName, ...] = (
+ 'DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK',
+)
+
+
+# --- Shape types -----------------------------------------------------
+
+@dataclass(frozen=True)
+class CompanionBones:
+ """Deterministic-from-userId portion of a companion. Never persists.
+
+ Regenerating on every read means species renames or edits to
+ ``SPECIES`` don't break stored companions, and editing
+ ``config['companion']`` can't fake a rarity.
+ """
+ rarity: Rarity
+ species: Species
+ eye: Eye
+ hat: Hat
+ shiny: bool
+ stats: dict[StatName, int]
+
+
+class CompanionSoul(TypedDict):
+ """Persisted-in-config portion. Plain dict for JSON-roundtrip."""
+ name: str
+ personality: str
+
+
+class StoredCompanion(TypedDict):
+ """What actually goes into ``config['companion']`` — soul + hatched_at.
+
+ Snake_case ``hatched_at`` matches Python convention. TS uses
+ camelCase ``hatchedAt``. Python and TS write to different config
+ files (``~/.clawcodex/config.json`` vs ``~/.openclaude.json``) so
+ there's no cross-port migration concern. See gap-analysis §4.8.
+ """
+ name: str
+ personality: str
+ hatched_at: int # unix milliseconds
+
+
+@dataclass(frozen=True)
+class Companion:
+ """Live companion: bones (regenerated) + soul (persisted) + hatched_at."""
+ name: str
+ personality: str
+ hatched_at: int
+ rarity: Rarity
+ species: Species
+ eye: Eye
+ hat: Hat
+ shiny: bool
+ stats: dict[StatName, int]
+
+
+# --- Rarity-keyed constants ------------------------------------------
+
+RARITY_WEIGHTS: dict[Rarity, int] = {
+ 'common': 60,
+ 'uncommon': 25,
+ 'rare': 10,
+ 'epic': 4,
+ 'legendary': 1,
+}
+
+RARITY_STARS: dict[Rarity, str] = {
+ 'common': '★',
+ 'uncommon': '★★',
+ 'rare': '★★★',
+ 'epic': '★★★★',
+ 'legendary': '★★★★★',
+}
+
+# RARITY_COLORS values are src/utils/theme.py Palette field names,
+# NOT theme color strings. Consumers materialize via
+# ``getattr(palette, RARITY_COLORS[rarity])``.
+#
+# Mapped from TS keys (inactive/success/permission/autoAccept/warning)
+# to Python palette keys per gap-analysis §3.5 / §4.4. Some TS keys
+# (``inactive``, ``permission``, ``autoAccept``) don't exist on the
+# Python palette today; the mapping picks the closest semantic match.
+RARITY_COLORS: dict[Rarity, str] = {
+ 'common': 'text_muted', # was TS 'inactive'
+ 'uncommon': 'success',
+ 'rare': 'primary', # was TS 'permission' (blue)
+ 'epic': 'secondary', # was TS 'autoAccept' (violet)
+ 'legendary': 'warning',
+}
+
+
+__all__ = [
+ # Literals + tuples
+ 'Rarity', 'RARITIES',
+ 'Species', 'SPECIES',
+ 'Eye', 'EYES',
+ 'Hat', 'HATS',
+ 'StatName', 'STAT_NAMES',
+ # Shape types
+ 'CompanionBones', 'CompanionSoul', 'StoredCompanion', 'Companion',
+ # Rarity-keyed constants
+ 'RARITY_WEIGHTS', 'RARITY_STARS', 'RARITY_COLORS',
+]
diff --git a/src/cli.py b/src/cli.py
index 215e14bf0..eae8dcdc7 100644
--- a/src/cli.py
+++ b/src/cli.py
@@ -3,17 +3,48 @@
from __future__ import annotations
import argparse
+import os
import sys
from pathlib import Path
-from rich.console import Console
-from rich.prompt import Prompt
-from rich.table import Table
+# ch02 round-4 WI-3 — nothing heavy at module scope. This module IS the
+# process entry (`clawcodex = src.cli:main`), so anything fired here is
+# paid by EVERY invocation including the fast paths (--version, mcp,
+# doctor, agent-server-as-subcommand; the Ink client's spawned backend
+# does NOT route here — it runs `python -m src.entrypoints.agent_server_cli`
+# directly). TS's fast paths pay neither rich-equivalent imports nor
+# the keychain/MDM spawns because they run before main.tsx is imported
+# (cli.tsx:84-427 vs main.tsx:13-20). The keychain/MDM prefetch now fires
+# in main() once the invocation is known to need the full pipeline; rich
+# imports live in the functions that render with them. Consumers self-heal
+# regardless — init.py awaits the same get-or-start singletons, which
+# start the subprocess on first use if the early fire was skipped.
+from src.prefetch import (
+ get_or_start_keychain_prefetch,
+ get_or_start_mdm_raw_read,
+)
def main():
"""CLI main entry point."""
+ # WI-0.1 (ch17 Phase 0): instrument cold-start phases. Env-gated by
+ # ``CLAUDE_CODE_PROFILE_STARTUP``; a no-op import + no-op call when
+ # disabled (~ns overhead). On exit the profiler writes a Markdown
+ # report to ``$CLAUDE_CONFIG_DIR/startup-perf/{session_id}.txt``.
+ from src.utils.startup_profiler import profile_checkpoint
+ profile_checkpoint("cli_main_entry")
+
import os
+
+ # OpenClaude default: experimental API betas off unless the user opts in
+ # (mirrors typescript/src/entrypoints/cli.tsx:44 — tool search
+ # defer_loading / global cache scope / context management need internal
+ # API support external accounts lack → 500). Per-process entry:
+ # everything cli.main() spawns inherits this via the environment
+ # (tui_launcher passes env=dict(os.environ)); the standalone
+ # agent-server entry sets its own (agent_server_cli).
+ os.environ.setdefault("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "true")
+
if os.environ.get("CLAWCODEX_DEBUG", "").lower() in ("1", "true", "yes"):
import logging
logging.basicConfig(
@@ -29,21 +60,43 @@ def main():
# Subcommands are matched BEFORE the main parser to avoid argparse treating
# a free-form prompt (e.g. ``clawcodex -p "hello"``) as an unknown
- # subcommand. We only need to detect `login` / `config`; everything else
- # falls through to the main parser.
+ # subcommand.
+ #
+ # WI-4.3: ``mcp``, ``daemon``, and ``doctor`` are fast-path subcommands
+ # — they get a thin handler that imports only what it needs, skipping
+ # the TUI/REPL/full-tool-registry load. Mirrors TS ``main.tsx``'s
+ # specialized-subcommand early-returns.
+ #
+ # Sieve looks at ``argv[0]`` ONLY so flag values that happen to equal a
+ # subcommand name don't mis-route (e.g. ``clawcodex --model mcp`` or
+ # ``clawcodex -p "doctor"``). The TS reference also positions
+ # specialized subcommands at argv[0]; global flags don't precede them.
argv = sys.argv[1:]
- for idx, token in enumerate(argv):
- if token.startswith('-'):
- continue
- if token in ('login', 'config'):
- rest = argv[idx + 1:]
- if token == 'login':
- return handle_login()
+ if argv and not argv[0].startswith('-'):
+ token = argv[0]
+ rest = argv[1:]
+ if token == 'login':
+ return handle_login()
+ if token == 'config':
return show_config()
- break
+ if token == 'mcp':
+ from src.entrypoints.mcp import run_mcp_subcommand
+ return run_mcp_subcommand(rest)
+ if token == 'daemon':
+ from src.entrypoints.daemon import run_daemon_subcommand
+ return run_daemon_subcommand(rest)
+ if token == 'doctor':
+ from src.entrypoints.doctor import run_doctor
+ return run_doctor()
+ if token == 'agent-server':
+ from src.entrypoints.agent_server_cli import run_agent_server_subcommand
+ return run_agent_server_subcommand(rest)
+ if token == 'tui':
+ return _run_tui_subcommand(rest)
parser = _build_parser()
args = parser.parse_args(argv)
+ profile_checkpoint("argparse_done")
if args.version:
from src import __version__
@@ -53,37 +106,214 @@ def main():
if args.config:
return show_config()
+ # Plan-phase-1 wiring (ch02-bootstrap-refactoring-plan.md P1.5):
+ # ``run_pre_action(args)`` is the Python analog of Commander's
+ # ``preAction`` hook. It runs the memoized ``init()`` (chapter
+ # phase 2 — safe env vars + graceful-shutdown + API preconnect)
+ # and mutates interactive bootstrap state.
+ #
+ # MUST PRECEDE ``_resolve_permission_state`` so init-side env-var
+ # application can affect permission resolution. ``--version`` /
+ # ``--config`` short-circuit above, so the chapter's
+ # "fast paths skip init" property is preserved.
+ #
+ # The API-preconnect call previously lived here at module level;
+ # it now runs inside ``init()`` so it overlaps with any callers
+ # of ``init()`` (REPL, headless, etc.), not just the cli.py path.
+ # WI-4.1 (ch17 Phase 4) as relocated by ch02 round-4 WI-3: fire the
+ # keychain + MDM child processes the moment the invocation is known to
+ # need the full pipeline (all fast paths have returned above). Popen
+ # returns in microseconds; the subprocess work overlaps run_pre_action
+ # → init(), whose consumer awaits these same singleton handles
+ # (init.py:84-88). Non-macOS: no-op sentinels.
+ get_or_start_keychain_prefetch()
+ get_or_start_mdm_raw_read()
+
+ profile_checkpoint("phase0_end_phase2_start")
+ from src.init import run_pre_action
+ run_pre_action(args)
+ profile_checkpoint("phase2_end_phase3_start")
+
+ # Resolve permission state ONCE here so all modes (print/TUI/REPL) honor
+ # ``--dangerously-skip-permissions`` consistently. Mirrors
+ # ``typescript/src/main.tsx:1383-1389``.
+ _resolve_permission_state(args)
+ profile_checkpoint("permissions_resolved")
+
+ # Startup provider validation (ENTRY-2, port of cli.tsx:149 /
+ # providerValidation.ts:479-528): surface a broken provider config NOW
+ # instead of deep inside the first API call. TS split: non-interactive
+ # exits, an interactive TTY warns and continues (the TUI can repair).
+ # Fast paths returned above, so their cold-start cost is untouched. The
+ # headless path re-runs the same shared helper internally — idempotent
+ # defense-in-depth for direct run_headless callers, not an accident.
+ from src.entrypoints.provider_validation import validate_provider_at_startup
+
+ validate_provider_at_startup(
+ args.provider,
+ interactive=not args.print and sys.stdout.isatty(),
+ )
+ profile_checkpoint("phase3_end_phase4_start")
+
if args.print:
+ profile_checkpoint("mode_dispatch_print")
+ # ``phase4_dispatch``: launcher has chosen a mode and is about
+ # to call the mode runner. Not the same as "first render" — the
+ # mode runner is the one that paints. Per-mode first-render
+ # checkpoints are plan phase 2 work (would need a callback
+ # inside each runner).
+ profile_checkpoint("phase4_dispatch")
return _run_print_mode(args)
- # Interactive path: decide between the Textual TUI (new default) and the
- # legacy Rich REPL. Explicit flags win; otherwise auto-detect a compatible TTY.
- explicit_tui: bool | None = None
- if args.tui:
- explicit_tui = True
- elif getattr(args, 'legacy_repl', False) or args.no_tui:
- explicit_tui = False
+ # Interactive path: the sole interactive UI is the TypeScript Ink TUI, which
+ # spawns + owns a Python agent-server child (see
+ # :mod:`src.entrypoints.tui_launcher`). The former in-process surfaces — the
+ # opt-in Textual TUI (``src/tui/``) and the Rich/prompt_toolkit REPL
+ # (``src/repl/``) — were removed in favor of this single, higher-fidelity
+ # client. ``clawcodex tui`` is the explicit form (``_run_tui_subcommand``
+ # runs this same bootstrap + trust gate).
+ profile_checkpoint("mode_dispatch_tui")
+
+ # Folder-trust gate — runs HERE in the Python parent, before we hand the TTY
+ # to the Ink client, because the agent-server backend performs no trust gate
+ # of its own. Already-trusted sessions (incl. ones seeded by run_pre_action)
+ # skip the prompt.
+ if not _gate_folder_trust():
+ return 1
+
+ profile_checkpoint("phase4_dispatch")
+ from src.entrypoints.tui_launcher import launch_ink_tui
- from src.entrypoints.tui import should_use_tui
+ return launch_ink_tui(
+ provider=args.provider,
+ model=args.model,
+ permission_mode=args._resolved_permission_mode,
+ is_bypass_available=args._resolved_is_bypass_available,
+ )
+
+
+def _gate_folder_trust() -> bool:
+ """Run the folder-trust gate; return ``False`` only when an interactive user
+ declines (the caller then exits 1).
+
+ Shared by the default interactive entry and the ``clawcodex tui`` subcommand
+ — both hand control to the tool-executing agent-server, which has no trust
+ gate of its own, so the gate must run before either spawns it.
+ """
+ from src.services.startup_gates import check_trust_accepted
+
+ if check_trust_accepted():
+ return True
+ if sys.stdin.isatty():
+ return _prompt_folder_trust()
+ # Non-TTY (piped) stdin: grant trust implicitly. The Ink TUI needs a real
+ # TTY to render, so this path normally proceeds to a clean launch failure
+ # rather than an interactive session — but trust is still established so the
+ # project env is applied for any diagnostics.
+ from src.permissions.trust_boundary import establish_session_trust
+
+ establish_session_trust()
+ return True
+
+
+def _run_tui_subcommand(rest: list[str]) -> int:
+ """``clawcodex tui`` — the explicit form of the default interactive entry.
+
+ Unlike the lean ``mcp`` / ``doctor`` / ``daemon`` fast-paths, this launches
+ the tool-executing agent-server, so it runs the SAME interactive bootstrap
+ (``run_pre_action`` — config-env application + trust seeding) and
+ folder-trust gate as the default ``clawcodex`` entry before handing off to
+ the Ink client. Without this, ``clawcodex tui`` would reach the backend with
+ no trust prompt and without the project env the child inherits.
+ """
+ from types import SimpleNamespace
+
+ from src.init import run_pre_action
+
+ # Full-pipeline path — fire the keychain/MDM prefetch here just like
+ # main()'s parsed path does (ch02 round-4 WI-3): the sieve dispatched
+ # before the post-sieve fire, and init() below awaits these handles.
+ get_or_start_keychain_prefetch()
+ get_or_start_mdm_raw_read()
+
+ # ``print=False`` => treated as an interactive session by run_pre_action.
+ run_pre_action(SimpleNamespace(print=False))
+
+ # Startup provider validation — this path never reaches main()'s
+ # dispatch-region call site (the sieve returned before argparse), so it
+ # runs the same shared helper itself (critic P3). ``rest`` is parsed by
+ # run_tui_launcher, so eager-parse --provider here (the TS
+ # eagerParseCliFlag idiom, cli.tsx:159-160).
+ from src.entrypoints.provider_validation import validate_provider_at_startup
+
+ provider_flag = None
+ for i, token in enumerate(rest):
+ if token == "--provider" and i + 1 < len(rest):
+ provider_flag = rest[i + 1]
+ elif token.startswith("--provider="):
+ provider_flag = token.split("=", 1)[1]
+ validate_provider_at_startup(provider_flag, interactive=sys.stdout.isatty())
+
+ if not _gate_folder_trust():
+ return 1
+ from src.entrypoints.tui_launcher import run_tui_launcher
+
+ return run_tui_launcher(rest)
- if should_use_tui(explicit_tui):
- return _run_tui_mode(args)
- return start_repl(stream=args.stream)
+def _prompt_folder_trust() -> bool:
+ """Plain-text folder-trust prompt (port of the C8 TrustFolderScreen).
+
+ Accept → persist (``record_trust_accepted``) + grant session trust +
+ apply the full env. Decline/EOF → False (caller exits 1, mirroring TS
+ TrustDialog's "No, exit" → gracefulShutdownSync(1)).
+ """
+ from src.permissions.trust_boundary import establish_session_trust
+ from src.services.startup_gates import (
+ collect_trust_warnings,
+ record_trust_accepted,
+ )
+
+ cwd = os.getcwd()
+ print("Do you trust the files in this folder?")
+ print(f" {cwd}")
+ try:
+ warnings = collect_trust_warnings()
+ except Exception:
+ warnings = []
+ if warnings:
+ print()
+ for warning in warnings:
+ print(f" ! {warning}")
+ print()
+ print(
+ "ClawCodex may read, execute, and modify files here. Accept only if "
+ "you trust this folder's contents and configuration."
+ )
+ try:
+ answer = input("Trust this folder? [y/N] ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ return False
+ if answer not in ("y", "yes"):
+ return False
+ record_trust_accepted()
+ establish_session_trust()
+ return True
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="clawcodex",
- description="Claw Codex - Claude Code Python Implementation",
+ description="ClawCodex - Claude Code Python Implementation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
clawcodex --version Show version
clawcodex login Configure API keys
clawcodex config Show current configuration
- clawcodex --stream Start REPL with live response rendering
- clawcodex Start interactive REPL
+ clawcodex Start the interactive Ink TUI
+ clawcodex tui Start the interactive Ink TUI (explicit)
clawcodex -p "hello" Non-interactive mode (text output)
clawcodex -p "hi" --output-format json
clawcodex -p --output-format stream-json --input-format stream-json < input.ndjson
@@ -93,34 +323,14 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument('prompt', nargs='?', help='Prompt to send in non-interactive mode')
parser.add_argument('--version', action='store_true', help='Show version information')
parser.add_argument('--config', action='store_true', help='Show current configuration')
- parser.add_argument('--stream', action='store_true', help='Enable live rendering in REPL')
- # ---- Interactive UI selection ----
+ # ---- Interactive UI ----
#
- # The default interactive experience is the prompt_toolkit + rich REPL,
- # which matches the TS Ink reference's terminal-native behavior:
- # transcript flows into scrollback, only the prompt + status row are
- # live, and native mouse copy works. ``--tui`` opts into the Textual
- # in-app experience; ``--legacy-repl`` / ``--no-tui`` are kept as
- # no-op aliases for back-compat (they already select the default).
- ui_group = parser.add_mutually_exclusive_group()
- ui_group.add_argument(
- '--tui',
- action='store_true',
- help='Use the Textual in-app TUI (opt-in; default is the inline REPL)',
- )
- ui_group.add_argument(
- '--legacy-repl',
- dest='legacy_repl',
- action='store_true',
- help='Use the inline prompt_toolkit + rich REPL (this is the default)',
- )
- ui_group.add_argument(
- '--no-tui',
- dest='no_tui',
- action='store_true',
- help='Alias for --legacy-repl (kept for backward compatibility)',
- )
+ # The sole interactive UI is the TypeScript Ink TUI — ``clawcodex`` with no
+ # mode flags, or the explicit ``clawcodex tui`` subcommand. It spawns + owns
+ # a Python agent-server child (see :mod:`src.entrypoints.tui_launcher`). The
+ # former in-process Textual TUI (``--tui``) and Rich REPL (``--legacy-repl`` /
+ # ``--no-tui`` / ``--stream``) were removed in favor of this single client.
# ---- Non-interactive / print mode (Phase 1 parity) ----
noninteractive = parser.add_argument_group("non-interactive mode")
@@ -146,17 +356,11 @@ def _build_parser() -> argparse.ArgumentParser:
action='store_true',
help='Include incremental assistant text chunks in stream-json output',
)
- noninteractive.add_argument(
- '--dangerously-skip-permissions',
- dest='dangerously_skip_permissions',
- action='store_true',
- help='Bypass all tool permission checks (use with care)',
- )
noninteractive.add_argument(
'--max-turns',
type=int,
- default=20,
- help='Maximum number of agent tool turns (default: 20)',
+ default=50,
+ help='Maximum number of agent tool turns (default: 50)',
)
noninteractive.add_argument(
'--model',
@@ -164,11 +368,19 @@ def _build_parser() -> argparse.ArgumentParser:
default=None,
help='Override the model used for this run',
)
+ noninteractive.add_argument(
+ '--fallback-model',
+ type=str,
+ default=None,
+ dest='fallback_model',
+ help='Model to switch to after repeated overloaded (529) errors '
+ '(session-sticky; never persisted)',
+ )
noninteractive.add_argument(
'--provider',
type=str,
default=None,
- help='Override the provider (anthropic, openai, glm, minimax)',
+ help='Override the provider (anthropic, openai, zai, minimax, openrouter, deepseek)',
)
noninteractive.add_argument(
'--allowed-tools',
@@ -188,6 +400,39 @@ def _build_parser() -> argparse.ArgumentParser:
help='Emit verbose diagnostics to stderr',
)
+ # ---- Permissions ----
+ # ``--dangerously-skip-permissions`` and ``--allow-dangerously-skip-permissions``
+ # apply to all UI modes (REPL, TUI, headless), so they live in a top-level
+ # group rather than under ``noninteractive``. Mirrors the TS reference at
+ # ``typescript/src/main.tsx:970``.
+ permissions_group = parser.add_argument_group("permissions")
+ permissions_group.add_argument(
+ '--dangerously-skip-permissions',
+ dest='dangerously_skip_permissions',
+ action='store_true',
+ help=(
+ 'Bypass all permission checks. Recommended only for sandboxes '
+ 'with no internet access.'
+ ),
+ )
+ permissions_group.add_argument(
+ '--allow-dangerously-skip-permissions',
+ dest='allow_dangerously_skip_permissions',
+ action='store_true',
+ help=(
+ 'Enable bypassing all permission checks as an option, without it '
+ 'being enabled by default. Recommended only for sandboxes with '
+ 'no internet access.'
+ ),
+ )
+ permissions_group.add_argument(
+ '--permission-mode',
+ dest='permission_mode',
+ choices=('default', 'plan', 'acceptEdits', 'bypassPermissions', 'dontAsk'),
+ default=None,
+ help='Initial permission mode (default: default)',
+ )
+
# Subcommands are intercepted in ``main`` before argparse runs so that a
# free-form prompt argument cannot be misinterpreted as a subcommand.
# Listing them here purely for ``--help`` documentation.
@@ -204,6 +449,60 @@ def _build_parser() -> argparse.ArgumentParser:
return parser
+def _resolve_permission_state(args) -> None:
+ """Resolve and stash permission state on ``args``.
+
+ Computes the effective :class:`PermissionMode` from the CLI flags and
+ settings, runs the root/sudo safety gate, and emits a single log line
+ when either bypass flag was passed. Stashes the result on ``args`` so
+ every downstream mode (print, TUI, REPL) can read it without re-deriving.
+
+ Mirrors the wiring in ``typescript/src/main.tsx`` lines 1087-1392 plus
+ the safety check in ``typescript/src/setup.ts:382-401``.
+ """
+ import logging as _logging
+
+ from src.permissions.dangerous_safety import (
+ enforce_dangerous_skip_permissions_safety,
+ )
+ from src.permissions.modes import (
+ has_allow_bypass_permissions_mode,
+ initial_permission_mode_from_cli,
+ )
+
+ dangerously = bool(getattr(args, 'dangerously_skip_permissions', False))
+ allow_dangerously = bool(getattr(args, 'allow_dangerously_skip_permissions', False))
+ permission_mode_cli = getattr(args, 'permission_mode', None)
+
+ # Safety gate first — refuse to run as root outside a sandbox.
+ enforce_dangerous_skip_permissions_safety(
+ bypass_requested=dangerously or allow_dangerously,
+ )
+
+ mode = initial_permission_mode_from_cli(
+ permission_mode_cli=permission_mode_cli,
+ dangerously_skip_permissions=dangerously,
+ )
+
+ is_bypass_available = (
+ dangerously
+ or allow_dangerously
+ or has_allow_bypass_permissions_mode()
+ )
+
+ # Stash on args so downstream entrypoints don't need to re-derive.
+ args._resolved_permission_mode = mode
+ args._resolved_is_bypass_available = is_bypass_available
+
+ if dangerously or allow_dangerously:
+ _logging.getLogger("clawcodex.permissions").info(
+ "permission flags: dangerously_skip=%s allow_dangerously_skip=%s mode=%s",
+ dangerously,
+ allow_dangerously,
+ mode,
+ )
+
+
def _run_print_mode(args) -> int:
"""Delegate to the headless entrypoint."""
@@ -225,14 +524,23 @@ def _run_print_mode(args) -> int:
allowed = _split_csv(args.allowed_tools)
disallowed = _split_csv(args.disallowed_tools)
+ if args.fallback_model and args.fallback_model == args.model:
+ # TS validates the same way (main.tsx:1339): a fallback equal to the
+ # primary can never relieve capacity.
+ print("error: --fallback-model must differ from --model", file=sys.stderr)
+ return 2
+
options = HeadlessOptions(
prompt=args.prompt,
output_format=args.output_format,
input_format=args.input_format,
provider_name=args.provider,
model=args.model,
+ fallback_model=args.fallback_model,
max_turns=args.max_turns,
skip_permissions=bool(args.dangerously_skip_permissions),
+ permission_mode=args._resolved_permission_mode,
+ is_bypass_permissions_mode_available=args._resolved_is_bypass_available,
allowed_tools=tuple(allowed),
disallowed_tools=tuple(disallowed),
include_partial_messages=bool(args.include_partial_messages),
@@ -241,25 +549,6 @@ def _run_print_mode(args) -> int:
return run_headless(options)
-def _run_tui_mode(args) -> int:
- """Boot the Textual-based interactive TUI (Phase 11)."""
-
- from src.entrypoints.tui import TUIOptions, run_tui
-
- allowed = _split_csv(args.allowed_tools)
- disallowed = _split_csv(args.disallowed_tools)
-
- options = TUIOptions(
- provider_name=args.provider,
- model=args.model,
- max_turns=args.max_turns,
- allowed_tools=tuple(allowed),
- disallowed_tools=tuple(disallowed),
- stream=True,
- )
- return run_tui(options)
-
-
def _split_csv(value: str | None) -> list[str]:
if not value:
return []
@@ -268,6 +557,9 @@ def _split_csv(value: str | None) -> list[str]:
def _show_provider_defaults_table() -> None:
"""Print a table showing available providers and their defaults."""
+ from rich.console import Console
+ from rich.table import Table
+
from src.providers import PROVIDER_INFO
console = Console()
@@ -289,8 +581,11 @@ def _show_provider_defaults_table() -> None:
def handle_login():
"""Interactive API configuration."""
+ from rich.console import Console
+ from rich.prompt import Prompt
+
console = Console()
- console.print("\n[bold blue]Claw Codex - API Configuration[/bold blue]\n")
+ console.print("\n[bold blue]ClawCodex - API Configuration[/bold blue]\n")
_show_provider_defaults_table()
@@ -339,6 +634,8 @@ def handle_login():
def show_config():
"""Show current configuration."""
+ from rich.console import Console
+
console = Console()
try:
@@ -362,6 +659,18 @@ def show_config():
console.print(f" Base URL: {provider_config.get('base_url', 'Not set')}")
console.print(f" Default Model: {provider_config.get('default_model', 'Not set')}")
+ # Stored API keys (config "env" block, e.g. TAVILY_API_KEY). Values are
+ # masked — only the name and a hint are shown.
+ from src.secret_store import get_secret, list_secret_names
+
+ stored_names = list_secret_names()
+ if stored_names:
+ console.print("\n[cyan]Stored Keys (env):[/cyan]")
+ for name in stored_names:
+ value = get_secret(name) or ""
+ masked = f"{value[:4]}...{value[-4:]}" if len(value) > 10 else "Set"
+ console.print(f" {name}: {masked}")
+
console.print()
except Exception as e:
@@ -371,16 +680,5 @@ def show_config():
return 0
-def start_repl(stream: bool = False):
- """Start interactive REPL."""
- from src.config import get_default_provider
- from src.repl import ClawcodexREPL
-
- provider = get_default_provider()
- repl = ClawcodexREPL(provider_name=provider, stream=stream)
- repl.run()
- return 0
-
-
if __name__ == '__main__':
sys.exit(main())
diff --git a/src/command_system/__init__.py b/src/command_system/__init__.py
index 03867080c..a460c4612 100644
--- a/src/command_system/__init__.py
+++ b/src/command_system/__init__.py
@@ -4,8 +4,22 @@
A complete reimplementation of Claude Code's command system.
"""
+# WI-0.1 (ch17 Phase 0): mark the moment the command-system package starts
+# import work. Placed before the heavy re-exports so the checkpoint's
+# delta-from-previous reflects the package's cost.
+from src.utils.startup_profiler import profile_checkpoint
+
+profile_checkpoint("command_system_imported")
+
+from .aggregator import (
+ clear_commands_cache,
+ get_commands,
+ get_skill_tool_commands,
+ get_slash_command_tool_skills,
+)
from .argument_substitution import parse_argument_names, substitute_arguments
from .builtins import (
+ AUTO_FIX_COMMAND,
CLEAR_COMMAND,
COMPACT_COMMAND,
CONTEXT_COMMAND,
@@ -13,6 +27,7 @@
EXIT_COMMAND,
HELP_COMMAND,
INIT_COMMAND,
+ REVIEW_COMMAND,
SKILLS_COMMAND,
execute_command_async,
execute_command_sync,
@@ -25,6 +40,10 @@
CommandResult,
create_command_context,
)
+from .moved_to_plugin import (
+ MovedToPluginCommand,
+ create_moved_to_plugin_command,
+)
from .registry import (
CommandRegistry,
find_commands,
@@ -34,6 +53,18 @@
list_commands,
register_command,
)
+from .safe_commands import (
+ BRIDGE_SAFE_COMMANDS,
+ REMOTE_SAFE_COMMANDS,
+ filter_commands_for_remote_mode,
+ is_bridge_safe_command,
+)
+from .security_review import SECURITY_REVIEW_COMMAND
+from .statusline import STATUSLINE_COMMAND, StatuslineCommand
+from .shell_prompt import (
+ execute_shell_commands_in_prompt,
+ make_bash_shell_executor,
+)
from .skills_integration import (
get_skill_command,
load_and_register_skills,
@@ -41,14 +72,37 @@
register_skill_as_command,
skill_to_prompt_command,
)
+from .permissions_command import PERMISSIONS_COMMAND, PermissionsCommand
+from .output_style_command import OUTPUT_STYLE_COMMAND, OutputStyleCommand
+from .export_command import EXPORT_COMMAND, ExportCommand
+from .theme_command import THEME_COMMAND, ThemeCommand
+from .effort_command import EFFORT_COMMAND, EffortCommand
+from .model_command import MODEL_COMMAND, ModelCommand
+from .logo_command import LOGO_COMMAND, LogoCommand
+from .mcp_command import MCP_COMMAND, McpCommand
+from .tasks_command import TASKS_COMMAND, TasksCommand
+from .diff_command import DIFF_COMMAND, DiffCommand
+from .release_notes_command import RELEASE_NOTES_COMMAND
+from .copy_command import COPY_COMMAND, CopyCommand
+from .vim_command import VIM_COMMAND
+from .memory_command import MEMORY_COMMAND, MemoryCommand
+from .stickers_command import STICKERS_COMMAND
+from .rename_command import RENAME_COMMAND, RenameCommand
from .types import (
Command,
CommandAvailability,
CommandBase,
CommandType,
+ InteractiveCommand,
+ InteractiveOutcome,
+ InteractiveUnavailableError,
LocalCommand,
LocalCommandResult,
+ NullUIHost,
PromptCommand,
+ SkillPromptCommand,
+ UIHost,
+ UIOption,
get_command_name,
is_command_enabled,
meets_availability_requirement,
@@ -61,8 +115,15 @@
"CommandAvailability",
"CommandBase",
"PromptCommand",
+ "SkillPromptCommand",
"LocalCommand",
"LocalCommandResult",
+ "InteractiveCommand",
+ "InteractiveOutcome",
+ "InteractiveUnavailableError",
+ "UIHost",
+ "UIOption",
+ "NullUIHost",
"get_command_name",
"is_command_enabled",
"meets_availability_requirement",
@@ -91,8 +152,57 @@
"CONTEXT_COMMAND",
"COMPACT_COMMAND",
"INIT_COMMAND",
+ "AUTO_FIX_COMMAND",
+ "REVIEW_COMMAND",
+ "SECURITY_REVIEW_COMMAND",
+ "STATUSLINE_COMMAND",
+ "StatuslineCommand",
+ "PERMISSIONS_COMMAND",
+ "PermissionsCommand",
+ "OUTPUT_STYLE_COMMAND",
+ "OutputStyleCommand",
+ "EXPORT_COMMAND",
+ "ExportCommand",
+ "THEME_COMMAND",
+ "ThemeCommand",
+ "EFFORT_COMMAND",
+ "EffortCommand",
+ "MODEL_COMMAND",
+ "ModelCommand",
+ "LOGO_COMMAND",
+ "LogoCommand",
+ "MCP_COMMAND",
+ "McpCommand",
+ "TASKS_COMMAND",
+ "TasksCommand",
+ "DIFF_COMMAND",
+ "DiffCommand",
+ "RELEASE_NOTES_COMMAND",
+ "COPY_COMMAND",
+ "CopyCommand",
+ "VIM_COMMAND",
+ "MEMORY_COMMAND",
+ "MemoryCommand",
+ "STICKERS_COMMAND",
+ "RENAME_COMMAND",
+ "RenameCommand",
"get_builtin_commands",
"register_builtin_commands",
+ # Moved-to-plugin factory + shell-at-prompt-build
+ "create_moved_to_plugin_command",
+ "MovedToPluginCommand",
+ "execute_shell_commands_in_prompt",
+ "make_bash_shell_executor",
+ # Aggregator
+ "get_commands",
+ "get_skill_tool_commands",
+ "get_slash_command_tool_skills",
+ "clear_commands_cache",
+ # Safe commands
+ "REMOTE_SAFE_COMMANDS",
+ "BRIDGE_SAFE_COMMANDS",
+ "is_bridge_safe_command",
+ "filter_commands_for_remote_mode",
# Skills integration
"skill_to_prompt_command",
"register_skill_as_command",
diff --git a/src/command_system/aggregator.py b/src/command_system/aggregator.py
new file mode 100644
index 000000000..8c70fb2a2
--- /dev/null
+++ b/src/command_system/aggregator.py
@@ -0,0 +1,261 @@
+"""
+Unified command aggregator.
+
+Python port of getCommands(cwd) / loadAllCommands(cwd) from
+typescript/src/commands.ts:473-541 — the single entry point every consumer should call
+to get the live, availability-filtered command set.
+
+Phase 1 merges builtin commands + filesystem skills (NOT plugins/workflows/dynamic
+skills — those subsystems are unported; see commands-gap-analysis.md §4.3). It reads
+builtins and skills directly (not the global registry), matching TS.
+
+Because ``get_commands`` already merges skills into the unified set, it ALSO
+provides the "skills are in the unified command set" guarantee that TS's
+bootstrap ``load_and_register_skills`` exists to provide — which is why P0-6 is
+satisfied here (Option A) without wiring that call into the execution registries.
+See ``load_and_register_skills`` and the Phase 3 plan §3 D-6 for the full
+rationale.
+"""
+
+from __future__ import annotations
+
+import logging
+from functools import lru_cache
+from pathlib import Path
+
+from .builtins import get_builtin_commands
+from .skills_integration import skill_to_prompt_command
+from .types import (
+ Command,
+ CommandType,
+ is_command_enabled,
+ meets_availability_requirement,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# D-1a (Phase 3 / P0-4 — model-tool exposure). The Python skill loader emits a
+# *more granular* ``loaded_from`` than TS: ``user`` / ``project`` / ``managed`` /
+# ``plugin`` / ``mcp`` / ``bundled`` (``loader.py:_SOURCE_TO_LOADED_FROM``), where
+# TS tags *all* disk-skill-dir commands ``'skills'`` and only policy-managed
+# ``'managed'``. So Python's ``user`` and ``project`` are the equivalents of TS's
+# ``'skills'``. A *literal* port of ``loadedFrom === 'skills'`` would wrongly drop
+# the common-case user/project disk skills that have only an auto-derived
+# first-line description. We bridge the vocabulary HERE (inside the two views),
+# NOT in the loader — whose ``loaded_from`` other display/logic reads — so the
+# blast radius is just these functions. See
+# my-docs/get-parity-by-folder/commands-phase3-model-tool-exposure-plan.md §3 D-1a.
+SKILLS_DIR_BUCKET: frozenset[str] = frozenset({"skills", "user", "project"})
+
+
+def _builtin_plugin_skill_commands() -> tuple[Command, ...]:
+ """PLUGINS-1 — the commands.ts:401 analog: enabled builtin-plugin skills
+ become slash commands. NOT cached: the enabled gate (settings overlay +
+ default_enabled) must stay fresh, and the registry lookup is cheap.
+ Failures degrade to () — plugins are non-critical."""
+ try:
+ from src.plugins.builtin_plugins import get_builtin_plugin_skill_commands
+
+ return tuple(
+ skill_to_prompt_command(s) for s in get_builtin_plugin_skill_commands()
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.debug("builtin plugin skill loading failed: %s", exc)
+ return ()
+
+
+@lru_cache(maxsize=32)
+def _load_skill_commands_cached(cwd: str) -> tuple[Command, ...]:
+ """
+ Load filesystem skills for ``cwd`` and convert them to PromptCommands.
+
+ Cached by cwd because skill discovery does disk I/O + frontmatter parsing.
+ Builtins are intentionally NOT cached here (they're cheap and their is_enabled
+ gates must stay fresh). Failures degrade to an empty tuple — skills are
+ non-critical and must never break command listing (mirrors TS).
+ """
+ try:
+ from ..skills.loader import get_all_skills
+ skills = get_all_skills(project_root=cwd)
+ return tuple(skill_to_prompt_command(s) for s in skills)
+ except Exception as exc: # noqa: BLE001 — skills are non-critical
+ logger.debug("skill loading failed for %s: %s", cwd, exc)
+ return ()
+
+
+@lru_cache(maxsize=32)
+def _load_workflow_commands_cached(cwd: str) -> tuple[Command, ...]:
+ """Bundled + discovered workflow commands for ``cwd`` (cached by cwd).
+
+ Commands carry ``is_enabled=is_workflows_enabled`` so they're filtered out
+ fresh by ``get_commands`` when workflows are disabled, without busting the
+ discovery cache. Failures degrade to an empty tuple (non-critical)."""
+ try:
+ from .workflows_integration import load_workflow_commands
+ return tuple(load_workflow_commands(cwd))
+ except Exception as exc: # noqa: BLE001 — workflows are non-critical
+ logger.debug("workflow command loading failed for %s: %s", cwd, exc)
+ return ()
+
+
+def get_commands(
+ cwd: str | Path | None = None,
+ *,
+ is_claude_ai_subscriber: bool = False,
+ is_console_user: bool = False,
+) -> list[Command]:
+ """
+ Return commands available to the current user for ``cwd``.
+
+ Merges builtins (fresh) + filesystem skills (cached by cwd), then filters by
+ availability + is_enabled FRESH on every call so auth/flag changes take effect
+ immediately. De-duplicated by name (builtins own their names — see below).
+
+ Port of commands.ts:500 getCommands(cwd).
+ """
+ cwd_key = str(cwd) if cwd is not None else str(Path.cwd())
+
+ # Builtins fresh (re-evaluates conditional appends); skills cached by cwd.
+ all_commands: list[Command] = [
+ *get_builtin_commands(),
+ *_load_workflow_commands_cached(cwd_key),
+ *_load_skill_commands_cached(cwd_key),
+ *_builtin_plugin_skill_commands(),
+ ]
+
+ seen: set[str] = set()
+ result: list[Command] = []
+ for cmd in all_commands:
+ if cmd.name in seen:
+ continue # name already claimed by an earlier command
+ # Reserve the name BEFORE filtering: a builtin owns its name even when it is
+ # disabled/unavailable, so a same-named skill (enumerated later) can never
+ # shadow it. Builtins are enumerated first -> builtins win. This diverges
+ # from TS's filter-then-dedupe order on purpose (prevents a user skill named
+ # `help`/`clear` from shadowing a core builtin).
+ #
+ # This assumes filter-gated builtins (is_enabled / availability) are never
+ # meant to be *replaced* by a same-named skill. A command that should yield
+ # to a skill must instead be omitted from the source list entirely (an
+ # "append-gate", like buddy's is_buddy_command_enabled()), so it never
+ # reaches this loop and never reserves its name.
+ seen.add(cmd.name)
+ if not meets_availability_requirement(
+ cmd, is_claude_ai_subscriber, is_console_user
+ ):
+ continue
+ if not is_command_enabled(cmd):
+ continue
+ result.append(cmd)
+ return result
+
+
+@lru_cache(maxsize=32)
+def get_skill_tool_commands(cwd: str | None = None) -> tuple[Command, ...]:
+ """ALL prompt-based commands the model may invoke.
+
+ This is the source for the model-facing "# Available Skills" system-prompt
+ listing (wired into ``build_full_system_prompt_blocks(skills=...)`` at
+ ``src/query/engine.py``). Port of ``getSkillToolCommands(cwd)``
+ (``typescript/src/commands.ts:587-605``).
+
+ Filters :func:`get_commands` to prompt commands that are model-invocable,
+ non-builtin, and either live in a skill-dir / bundled / commands_DEPRECATED
+ bucket OR carry an author-written description / ``when_to_use`` (the
+ managed/mcp/plugin escape hatch — see §2 of the plan doc). Uses the D-1a
+ :data:`SKILLS_DIR_BUCKET` bridge, not the raw TS ``'skills'`` literal.
+
+ Unlike :func:`get_slash_command_tool_skills`, this is NOT wrapped in
+ try/except (matching TS): :func:`get_commands` already swallows skill-load
+ failures, and the system-prompt assembler wraps the whole call.
+
+ cwd-memoized (the listing is stable within a session; R3 accepts staleness as
+ TS-parity). :func:`clear_commands_cache` resets THIS view's cwd cache. Note
+ the *rendered* "# Available Skills" prose is cached one layer down too —
+ ``_prompt_cache["skills"]`` (SESSION scope, in ``context_system``), cleared
+ by ``clear_context_caches()``. A full mid-session refresh of what the model
+ actually sees needs BOTH; ``clear_commands_cache`` alone is not sufficient.
+ """
+ result: list[Command] = []
+ for cmd in get_commands(cwd):
+ if cmd.command_type != CommandType.PROMPT:
+ continue
+ if getattr(cmd, "disable_model_invocation", False):
+ continue
+ if getattr(cmd, "source", "builtin") == "builtin":
+ continue
+ loaded_from = getattr(cmd, "loaded_from", "builtin")
+ in_bucket = loaded_from in SKILLS_DIR_BUCKET or loaded_from in (
+ "bundled",
+ "commands_DEPRECATED",
+ )
+ if (
+ in_bucket
+ or getattr(cmd, "has_user_specified_description", False)
+ or getattr(cmd, "when_to_use", None)
+ ):
+ result.append(cmd)
+ return tuple(result)
+
+
+@lru_cache(maxsize=32)
+def get_slash_command_tool_skills(cwd: str | None = None) -> tuple[Command, ...]:
+ """Skills-only subset used for SlashCommand-tool counts / init (NOT a tool).
+
+ Port of ``getSlashCommandToolSkills(cwd)``
+ (``typescript/src/commands.ts:610-632``). Note the deliberate TS asymmetries
+ vs :func:`get_skill_tool_commands`, preserved verbatim:
+
+ * does **not** exclude ``disable_model_invocation`` — there it is an
+ *inclusion* clause (a disabled-for-model skill still counts here);
+ * **always** requires ``has_user_specified_description or when_to_use``;
+ * the bucket adds ``'plugin'`` but omits ``'commands_DEPRECATED'``;
+ * the whole body is wrapped in try/except returning ``()`` (skills are
+ non-critical — matches the ``_load_skill_commands_cached`` house style of
+ degrading to empty on failure).
+
+ cwd-memoized; cleared by :func:`clear_commands_cache`.
+ """
+ try:
+ result: list[Command] = []
+ for cmd in get_commands(cwd):
+ if cmd.command_type != CommandType.PROMPT:
+ continue
+ if getattr(cmd, "source", "builtin") == "builtin":
+ continue
+ if not (
+ getattr(cmd, "has_user_specified_description", False)
+ or getattr(cmd, "when_to_use", None)
+ ):
+ continue
+ loaded_from = getattr(cmd, "loaded_from", "builtin")
+ in_bucket = loaded_from in SKILLS_DIR_BUCKET or loaded_from in (
+ "plugin",
+ "bundled",
+ )
+ if in_bucket or getattr(cmd, "disable_model_invocation", False):
+ result.append(cmd)
+ return tuple(result)
+ except Exception as exc: # noqa: BLE001 — skills are non-critical (TS parity)
+ logger.debug("get_slash_command_tool_skills failed for %s: %s", cwd, exc)
+ return ()
+
+
+def clear_commands_cache() -> None:
+ """Clear all memoized command caches. Port of ``clearCommandsCache()``.
+
+ Covers the skill-load cache AND the two P0-4 model-tool views (R3: their
+ cwd-memoization is why a mid-session skill change needs this to show up).
+
+ Scope boundary: this clears only the *command-aggregation* caches in this
+ module. It does NOT touch the downstream prompt-assembly session cache
+ (``_prompt_cache["skills"]`` in ``context_system``) that holds the rendered
+ "# Available Skills" prose. Refreshing the model-facing listing mid-session
+ requires this call PLUS ``clear_context_caches()`` — kept decoupled on
+ purpose so the aggregator doesn't reach across into the prompt layer.
+ """
+ _load_skill_commands_cached.cache_clear()
+ _load_workflow_commands_cached.cache_clear()
+ get_skill_tool_commands.cache_clear()
+ get_slash_command_tool_skills.cache_clear()
diff --git a/src/command_system/buddy_command.py b/src/command_system/buddy_command.py
new file mode 100644
index 000000000..a1ed43c49
--- /dev/null
+++ b/src/command_system/buddy_command.py
@@ -0,0 +1,194 @@
+"""``/buddy`` command — hatch / pet / status / mute / unmute / help.
+
+Port of ``typescript/src/commands/buddy/buddy.tsx`` with TS ``local-jsx``
+semantics stripped to ``LocalCommand`` textual returns per
+``my-docs/get-parity-by-folder/buddy-gap-analysis.md`` §2.6.
+
+``PET_REACTIONS`` lives in this file (not in ``src/buddy/soul.py``)
+because each pet re-seeds the pick from ``time.time()`` — it's transient
+reaction text, not part of the persistent soul. See gap-analysis §4.1.
+
+Help/info argument parsing matches TS exactly via the shared lists in
+``src/constants/xml.py`` (``COMMON_HELP_ARGS``, ``COMMON_INFO_ARGS``).
+Notably ``?`` routes to INFO (status), not HELP.
+
+Divergence (gap-analysis §4.9): TS ``/buddy pet`` returns
+``display: 'skip'`` because the user feedback is the speech-bubble
+animation; Python returns
+``LocalCommandResult(type="text", value=reaction)`` so the headless /
+Textual REPL produces *some* visible feedback (without it, a successful
+pet would be silent).
+
+The pet path also persists ``config['companion_pet_at']`` (top-level,
+snake_case mirror of TS's transient AppState field ``companionPetAt``).
+A future Textual sprite reads this for the heart-burst animation. See
+plan §1.3.
+"""
+from __future__ import annotations
+
+import time
+from typing import Sequence
+
+from src.buddy.companion import companion_user_id, get_companion
+from src.buddy.feature import is_buddy_enabled
+from src.buddy.soul import create_stored_companion
+from src.command_system.types import (
+ CommandContext, LocalCommand, LocalCommandResult,
+)
+from src.config import _get_default_manager, load_config
+from src.constants.xml import COMMON_HELP_ARGS, COMMON_INFO_ARGS
+
+
+PET_REACTIONS: tuple[str, ...] = (
+ 'leans into the headpat',
+ 'does a proud little bounce',
+ 'emits a content beep',
+ 'looks delighted',
+ 'wiggles happily',
+)
+
+_HELP_TEXT = (
+ "Usage: /buddy [status|mute|unmute]\n\n"
+ "Run /buddy with no args to hatch your companion the first time, "
+ "then pet it on later runs."
+)
+
+
+# Local FNV-1a + pick — duplicated from ``src/buddy/soul.py`` and
+# ``src/buddy/observer.py`` per gap-analysis §2.8: 5 lines × 3 modules
+# is preferable to cross-module imports of underscore-prefixed private
+# symbols.
+def _fnv1a_32(s: str) -> int:
+ h = 2166136261
+ for c in s:
+ h ^= ord(c)
+ h = (h * 16777619) & 0xFFFFFFFF
+ return h
+
+
+def _pick_deterministic(items: Sequence[str], seed: str) -> str:
+ return items[_fnv1a_32(seed) % len(items)]
+
+
+def _title_case(s: str) -> str:
+ return s[:1].upper() + s[1:] if s else s
+
+
+def _save_companion(stored: dict) -> None:
+ mgr = _get_default_manager()
+ mgr.set_global('companion', stored)
+ mgr.set_global('companion_muted', False)
+
+
+def _set_companion_muted(muted: bool) -> None:
+ _get_default_manager().set_global('companion_muted', muted)
+
+
+def buddy_command_call(args: str, context: CommandContext) -> LocalCommandResult:
+ """``/buddy`` command body.
+
+ Parsing precedence (matches TS ``buddy.tsx:104-184``):
+
+ 1. ``help`` / ``-h`` / ``--help`` → help text
+ 2. ``status`` or any ``COMMON_INFO_ARGS`` entry (incl. ``?``) → status
+ 3. ``mute`` / ``unmute`` → toggle + confirmation
+ 4. any other non-empty arg → help text
+ 5. empty arg + no companion → hatch
+ 6. empty arg + companion exists → pet
+ """
+ arg = (args or '').strip().lower()
+
+ # Note: TS routes '?' to INFO (status), NOT HELP — preserve that mapping.
+ if arg in COMMON_HELP_ARGS:
+ return LocalCommandResult(type='text', value=_HELP_TEXT)
+
+ if arg in COMMON_INFO_ARGS:
+ companion = get_companion()
+ if companion is None:
+ return LocalCommandResult(
+ type='text',
+ value='No buddy hatched yet. Run /buddy to hatch one.',
+ )
+ return LocalCommandResult(
+ type='text',
+ value=(
+ f"{companion.name} is your {_title_case(companion.rarity)} "
+ f"{companion.species}. {companion.personality}"
+ ),
+ )
+
+ if arg in ('mute', 'unmute'):
+ muted = arg == 'mute'
+ _set_companion_muted(muted)
+ return LocalCommandResult(
+ type='text',
+ value=f"Buddy {'muted' if muted else 'unmuted'}.",
+ )
+
+ if arg != '':
+ return LocalCommandResult(type='text', value=_HELP_TEXT)
+
+ # Empty arg: hatch (first time) or pet (subsequent).
+ companion = get_companion()
+ if companion is None:
+ user_id = companion_user_id()
+ stored = create_stored_companion(user_id)
+ _save_companion(stored)
+ # Re-read with fresh bones merged for the species-name line.
+ companion = get_companion()
+ if companion is None:
+ # Defensive: should never happen — _save_companion succeeded.
+ return LocalCommandResult(
+ type='text', value='Failed to hatch companion (state error).',
+ )
+ return LocalCommandResult(
+ type='text',
+ value=(
+ f"{companion.name} the {companion.species} is now your buddy. "
+ f"Run /buddy again to pet them."
+ ),
+ )
+
+ # Pet path. Per §4.9: return text instead of TS's display='skip'.
+ now_ms = int(time.time() * 1000)
+ reaction = _pick_deterministic(
+ PET_REACTIONS,
+ f"{now_ms}:{companion.name}",
+ )
+ # Persist the pet timestamp at TOP-LEVEL config (snake_case mirror of
+ # TS's camelCase AppState ``companionPetAt``). TS stores this on
+ # AppState transiently; Python has no AppState slice yet, so we
+ # persist to config so a future Textual sprite can read the
+ # last-pet time on startup. Name pinned per plan §1.3.
+ _get_default_manager().set_global('companion_pet_at', now_ms)
+ return LocalCommandResult(
+ type='text',
+ value=f"{companion.name} {reaction}",
+ )
+
+
+BUDDY_COMMAND = LocalCommand(
+ name='buddy',
+ description='Hatch, pet, and manage your OpenClaude companion',
+ argument_hint='[status|mute|unmute|help]',
+ immediate=True,
+)
+BUDDY_COMMAND.set_call(buddy_command_call)
+
+
+def is_buddy_command_enabled() -> bool:
+ """Whether the ``/buddy`` command should be registered.
+
+ Today this is just ``is_buddy_enabled()`` — but giving it a named
+ helper makes the gate point explicit at the registration site in
+ ``command_system.builtins``.
+ """
+ return is_buddy_enabled()
+
+
+__all__ = [
+ 'BUDDY_COMMAND',
+ 'PET_REACTIONS',
+ 'buddy_command_call',
+ 'is_buddy_command_enabled',
+]
diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py
index edb39f694..796944f1c 100644
--- a/src/command_system/builtins.py
+++ b/src/command_system/builtins.py
@@ -21,9 +21,28 @@
from ..cost_tracker import CostTracker
from ..history import HistoryLog
from ..providers.base import BaseProvider
-from ..setup import run_setup
from .engine import CommandContext, CommandResult, LocalCommandResult
from .registry import CommandRegistry, get_command_registry, list_commands
+from .export_command import EXPORT_COMMAND
+from .output_style_command import OUTPUT_STYLE_COMMAND
+from .permissions_command import PERMISSIONS_COMMAND
+from .security_review import SECURITY_REVIEW_COMMAND
+from .statusline import STATUSLINE_COMMAND
+from .theme_command import THEME_COMMAND
+from .effort_command import EFFORT_COMMAND
+from .model_command import MODEL_COMMAND
+from .logo_command import LOGO_COMMAND
+from .mcp_command import MCP_COMMAND
+from .tasks_command import TASKS_COMMAND
+from .diff_command import DIFF_COMMAND
+from .release_notes_command import RELEASE_NOTES_COMMAND
+from .copy_command import COPY_COMMAND
+from .vim_command import VIM_COMMAND
+from .memory_command import MEMORY_COMMAND
+from .stickers_command import STICKERS_COMMAND
+from .rename_command import RENAME_COMMAND
+from .doctor_command import DOCTOR_COMMAND
+from .resume_command import RESUME_COMMAND
from .types import Command, CommandType, CompactionResult, LocalCommand, PromptCommand
@@ -143,6 +162,20 @@ def clear_command_call(args: str, context: CommandContext) -> LocalCommandResult
if hasattr(context.history, "events"):
context.history.events.clear()
+ # ch05 round-3 G3: a cleared conversation restarts the cache epoch —
+ # reset memoized prompt sections + beta-header latches (TS
+ # clearSystemPromptSections via /clear, commands/clear/caches.ts:74).
+ # Direct call, NOT run_post_compact_cleanup: /clear must not register
+ # as a compaction (pending_post_compaction telemetry).
+ try:
+ from src.context_system.system_prompt_cache import (
+ clear_system_prompt_sections,
+ )
+
+ clear_system_prompt_sections()
+ except Exception:
+ pass
+
return LocalCommandResult(
type="text",
value="Conversation cleared.",
@@ -252,19 +285,39 @@ def cost_command_call(args: str, context: CommandContext) -> LocalCommandResult:
Returns:
LocalCommandResult
"""
- tracker = context.cost_tracker
- if tracker is None:
- return LocalCommandResult(
- type="text",
- value="Cost tracking not available.",
+ from src.bootstrap.state import get_model_usage, get_total_cost_usd
+ from src.services.pricing import format_cost_usd
+
+ lines: list[str] = ["Session Cost:", ""]
+ lines.append(f" Total: {format_cost_usd(get_total_cost_usd())}")
+
+ # Per-model token usage + prompt-cache hit-rate. ``cache_read_input_tokens``
+ # is the cached portion of the prompt (DeepSeek hits, Anthropic cache
+ # reads); ``input_tokens`` + ``cache_creation_input_tokens`` is the
+ # uncached portion. Surfacing the hit-rate is what makes the DeepSeek
+ # prefix-cache savings visible.
+ model_usage = get_model_usage()
+ for model, u in sorted(model_usage.items()):
+ cached = int(getattr(u, "cache_read_input_tokens", 0) or 0)
+ uncached = int(
+ (getattr(u, "input_tokens", 0) or 0)
+ + (getattr(u, "cache_creation_input_tokens", 0) or 0)
+ )
+ prompt_total = cached + uncached
+ hit_pct = (100.0 * cached / prompt_total) if prompt_total else 0.0
+ lines.append("")
+ lines.append(f" {model} {format_cost_usd(u.cost_usd)}")
+ lines.append(
+ f" prompt {prompt_total:,} tok "
+ f"({cached:,} cached, {hit_pct:.0f}% hit) · "
+ f"output {int(getattr(u, 'output_tokens', 0) or 0):,} tok"
)
- lines = ["Session Cost:", ""]
- lines.append(f" Total units: {tracker.total_units}")
-
- if tracker.events:
+ # Legacy free-form units/events (costHook ``/cost`` event log).
+ tracker = context.cost_tracker
+ if tracker is not None and (tracker.total_units or tracker.events):
lines.append("")
- lines.append(" Recent events:")
+ lines.append(f" Recorded units: {tracker.total_units}")
for event in tracker.events[-10:]:
lines.append(f" - {event}")
@@ -432,6 +485,511 @@ async def _compact_async(args: str, context: CommandContext) -> LocalCommandResu
)
+def _read_current_advisor_model(context: CommandContext) -> str | None:
+ """Resolve the user's currently configured advisor model.
+
+ Prefers the reactive AppState store if the caller wired one (so a
+ just-issued ``/advisor`` from this session reads its own write
+ without a settings-cache roundtrip), and falls back to the
+ persisted settings — the source of truth on every restart.
+ """
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ try:
+ state = store.get_state()
+ value = getattr(state, "advisor_model", None)
+ if value:
+ return value
+ except Exception:
+ pass
+ try:
+ from ..settings.settings import get_settings
+ configured = (get_settings().advisor_model or "").strip()
+ return configured or None
+ except Exception:
+ return None
+
+
+def _write_advisor_model(context: CommandContext, value: str | None) -> None:
+ """Persist a new advisor_model and update the reactive store if present.
+
+ Both writes are idempotent. When an AppState store is wired (e.g.
+ tests, future TUI wiring), ``replace_state`` fires
+ ``_on_advisor_model_change`` which itself writes to settings — so
+ we skip the direct settings write to avoid double-saving. When the
+ store is absent (the current TUI configuration), we update settings
+ directly via the same chokepoint the handler uses.
+ """
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ from ..state.app_state import replace_state
+ store.set_state(lambda s: replace_state(s, advisor_model=value or None))
+ return
+ # No reactive store — write straight to settings + invalidate cache
+ # so the next API call picks up the change. Use the shared default
+ # ConfigManager (instead of a fresh one) so the in-process
+ # ``_global_cache`` field stays consistent for callers that read
+ # via ``load_config()`` / ``_get_default_manager().get_merged()``.
+ from .. import config as cfg_mod
+ from ..settings.settings import invalidate_settings_cache
+ mgr = cfg_mod._get_default_manager()
+ cfg = mgr.load_global()
+ settings_section = cfg.get("settings")
+ if not isinstance(settings_section, dict):
+ settings_section = {}
+ settings_section["advisor_model"] = value or ""
+ cfg["settings"] = settings_section
+ mgr.save_global(cfg)
+ invalidate_settings_cache()
+
+
+def _read_current_advisor_provider(context: CommandContext) -> str:
+ """Resolve the current advisor_provider (store preferred, settings
+ fallback). Empty string = unset.
+ Mirrors ``_read_current_advisor_model``."""
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ try:
+ v = getattr(store.get_state(), "advisor_provider", None)
+ return (v or "").strip()
+ except Exception:
+ pass
+ try:
+ from ..settings.settings import get_settings
+ return (getattr(get_settings(), "advisor_provider", "") or "").strip()
+ except Exception:
+ return ""
+
+
+def _write_advisor_provider(context: CommandContext, value: str | None) -> None:
+ """Persist advisor_provider (store preferred, settings fallback).
+ Mirrors ``_write_advisor_model`` — same dual-path persistence.
+ Empty / None clears the field."""
+ normalized = (value or "").strip()
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ from ..state.app_state import replace_state
+ store.set_state(
+ lambda s: replace_state(s, advisor_provider=(normalized or None))
+ )
+ return
+ from .. import config as cfg_mod
+ from ..settings.settings import invalidate_settings_cache
+ mgr = cfg_mod._get_default_manager()
+ cfg = mgr.load_global()
+ settings_section = cfg.get("settings")
+ if not isinstance(settings_section, dict):
+ settings_section = {}
+ settings_section["advisor_provider"] = normalized
+ cfg["settings"] = settings_section
+ mgr.save_global(cfg)
+ invalidate_settings_cache()
+
+
+def _list_configured_providers() -> list[str]:
+ """Return the set of provider keys configured in
+ ``~/.clawcodex/config.json``. Used by /advisor to validate that the
+ user-supplied provider prefix refers to a real entry."""
+ try:
+ from .. import config as cfg_mod
+ mgr = cfg_mod._get_default_manager()
+ cfg = mgr.load_global()
+ providers = cfg.get("providers")
+ if isinstance(providers, dict):
+ return sorted(providers.keys())
+ except Exception:
+ pass
+ return []
+
+
+def _read_current_advisor_client_mode(context: CommandContext) -> bool:
+ """Resolve the user's current advisor_client_mode flag (reactive
+ store preferred, settings fallback). Mirrors
+ ``_read_current_advisor_model``."""
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ try:
+ return bool(getattr(store.get_state(), "advisor_client_mode", False))
+ except Exception:
+ pass
+ try:
+ from ..settings.settings import get_settings
+ return bool(getattr(get_settings(), "advisor_client_mode", False))
+ except Exception:
+ return False
+
+
+def _write_advisor_client_mode(context: CommandContext, value: bool) -> None:
+ """Persist advisor_client_mode (store-preferred, settings fallback).
+ Mirrors ``_write_advisor_model`` — same dual-path persistence."""
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ from ..state.app_state import replace_state
+ store.set_state(lambda s: replace_state(s, advisor_client_mode=bool(value)))
+ return
+ from .. import config as cfg_mod
+ from ..settings.settings import invalidate_settings_cache
+ mgr = cfg_mod._get_default_manager()
+ cfg = mgr.load_global()
+ settings_section = cfg.get("settings")
+ if not isinstance(settings_section, dict):
+ settings_section = {}
+ settings_section["advisor_client_mode"] = bool(value)
+ cfg["settings"] = settings_section
+ mgr.save_global(cfg)
+ invalidate_settings_cache()
+
+
+def _read_current_advisor_enabled(context: CommandContext) -> bool:
+ """Resolve the advisor master switch (reactive store preferred, settings
+ fallback). Mirrors ``_read_current_advisor_client_mode``."""
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ try:
+ return bool(getattr(store.get_state(), "advisor_enabled", False))
+ except Exception:
+ pass
+ try:
+ from ..settings.settings import get_settings
+ return bool(getattr(get_settings(), "advisor_enabled", False))
+ except Exception:
+ return False
+
+
+def _write_advisor_enabled(context: CommandContext, value: bool) -> None:
+ """Persist the advisor master switch (store-preferred, settings fallback).
+ Mirrors ``_write_advisor_client_mode`` — the store path fires
+ ``_on_advisor_enabled_change`` which persists to settings."""
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ from ..state.app_state import replace_state
+ store.set_state(lambda s: replace_state(s, advisor_enabled=bool(value)))
+ return
+ from .. import config as cfg_mod
+ from ..settings.settings import invalidate_settings_cache
+ mgr = cfg_mod._get_default_manager()
+ cfg = mgr.load_global()
+ settings_section = cfg.get("settings")
+ if not isinstance(settings_section, dict):
+ settings_section = {}
+ settings_section["advisor_enabled"] = bool(value)
+ cfg["settings"] = settings_section
+ mgr.save_global(cfg)
+ invalidate_settings_cache()
+
+
+def advisor_command_call(args: str, context: CommandContext) -> LocalCommandResult:
+ """Handle /advisor — configure the reviewer model.
+
+ Required argument shape: ``:`` — both halves
+ explicit, separated by the first colon (so model strings
+ containing ``/`` or further ``:`` are preserved verbatim).
+ Provider must match a key in ``~/.clawcodex/config.json``'s
+ ``providers`` map; clawcodex is multi-provider and the same
+ model name (e.g. ``claude-opus-4-7``) can sit behind anthropic,
+ openai (litellm), openrouter, bedrock, etc. Name-based inference
+ was ambiguous and silently routed to the wrong endpoint.
+
+ Branches (after parsing optional ``--client`` / ``--no-client``):
+ * no args, no flags → status report (provider/model + mode).
+ * ``unset`` | ``off`` → clear advisor_model, advisor_provider,
+ and advisor_client_mode.
+ * ``--no-client`` alone → keep model+provider, clear client-mode.
+ * ``--client`` alone → keep model+provider, set client-mode.
+ * ``:`` → validate provider exists in config,
+ persist both fields together. ``--client`` flag (if present)
+ also persists advisor_client_mode.
+
+ Examples:
+ * ``/advisor anthropic:claude-opus-4-7`` (direct Anthropic API)
+ * ``/advisor openai:claude-opus-4-7`` (litellm/proxy via openai provider)
+ * ``/advisor openrouter:anthropic/claude-opus-4.1``
+ * ``/advisor gemini:gemini-2.5-pro``
+ """
+ from ..models.model import canonical_model_name, resolve_model
+ from ..models.validation import validate_model_name
+ from ..utils.advisor import (
+ ADVISOR_MODE_CLIENT_SIDE,
+ ADVISOR_MODE_INACTIVE,
+ ADVISOR_MODE_SERVER_SIDE,
+ can_user_configure_advisor,
+ decide_advisor_mode,
+ )
+
+ provider = getattr(context, "provider", None)
+
+ # Hard-reject only when env-disabled (the user would silently
+ # configure a value that no request can use).
+ if not can_user_configure_advisor(provider):
+ return LocalCommandResult(
+ type="text",
+ value=(
+ "Advisor is disabled by the CLAUDE_CODE_DISABLE_ADVISOR_TOOL "
+ "env var."
+ ),
+ )
+
+ # Tokenize raw args so flag handling is order-insensitive. A
+ # trailing or leading ``--client`` / ``--no-client`` should peel
+ # off cleanly without breaking the model identifier.
+ raw_tokens = (args or "").strip().split()
+ force_client_flag: bool | None = None # None = no flag passed
+ rest_tokens: list[str] = []
+ for tok in raw_tokens:
+ if tok == "--client":
+ force_client_flag = True
+ elif tok == "--no-client":
+ force_client_flag = False
+ else:
+ rest_tokens.append(tok)
+ arg = " ".join(rest_tokens).strip()
+ arg_lower = arg.lower()
+
+ current_advisor = _read_current_advisor_model(context)
+ current_provider = _read_current_advisor_provider(context)
+ current_client_mode = _read_current_advisor_client_mode(context)
+ current_enabled = _read_current_advisor_enabled(context)
+
+ main_loop_model = ""
+ if provider is not None:
+ main_loop_model = getattr(provider, "model", "") or ""
+ if not main_loop_model:
+ store = getattr(context, "app_state_store", None)
+ if store is not None:
+ try:
+ main_loop_model = getattr(store.get_state(), "main_loop_model", "") or ""
+ except Exception:
+ pass
+
+ def _render_status() -> str:
+ """Format the current state for the no-args branch."""
+ if not current_advisor or not current_provider:
+ # Either field missing → effectively unset. Show what's
+ # there (if anything) so users can fix a partial config.
+ partial = ""
+ if current_advisor and not current_provider:
+ partial = (
+ f"\n(Found advisor_model={current_advisor!r} but no "
+ "advisor_provider — clear with /advisor unset then "
+ "re-run with the explicit syntax.)"
+ )
+ elif current_provider and not current_advisor:
+ partial = (
+ f"\n(Found advisor_provider={current_provider!r} but "
+ "no advisor_model — clear with /advisor unset.)"
+ )
+ # Critic C1: surface advisor_client_mode even on partial
+ # configs so the user sees stored state that would silently
+ # activate as soon as both fields land.
+ if current_client_mode:
+ partial += (
+ "\n(advisor_client_mode is ON but won't engage "
+ "until both advisor_model and advisor_provider "
+ "are set.)"
+ )
+ providers = _list_configured_providers()
+ providers_hint = (
+ f"Configured providers: {', '.join(providers)}.\n"
+ if providers else ""
+ )
+ return (
+ "Advisor: not set\n"
+ f"{providers_hint}"
+ "Use \"/advisor :\" to enable, e.g.:\n"
+ ' /advisor anthropic:claude-opus-4-7 (direct Anthropic)\n'
+ ' /advisor openai:claude-opus-4-7 (via openai-compat, '
+ 'e.g. litellm)\n'
+ ' /advisor openrouter:anthropic/claude-opus-4.1'
+ f"{partial}"
+ )
+ mode = decide_advisor_mode(
+ provider,
+ main_loop_model,
+ current_advisor,
+ force_client_mode=current_client_mode,
+ advisor_provider=current_provider,
+ advisor_enabled=current_enabled,
+ )
+ mode_label = {
+ ADVISOR_MODE_SERVER_SIDE: "active (server-side)",
+ ADVISOR_MODE_CLIENT_SIDE: "active (client-side)",
+ ADVISOR_MODE_INACTIVE: "inactive",
+ }.get(mode, "inactive")
+ suffix = ""
+ if current_client_mode:
+ suffix = " [--client forced]"
+ if not current_enabled:
+ suffix += " [disabled: advisor_enabled is off]"
+ return (
+ f"Advisor: {current_provider}:{current_advisor} — {mode_label}{suffix}\n"
+ 'Use "/advisor unset" to disable or '
+ '"/advisor :" to change.'
+ )
+
+ # No model arg, no flags → status only.
+ if not arg and force_client_flag is None:
+ return LocalCommandResult(type="text", value=_render_status())
+
+ # --no-client alone (no model) → just clear the forced-client flag.
+ if not arg and force_client_flag is False:
+ if not current_client_mode:
+ return LocalCommandResult(
+ type="text", value="Advisor client mode already off.",
+ )
+ _write_advisor_client_mode(context, False)
+ return LocalCommandResult(
+ type="text",
+ value=(
+ "Advisor client mode disabled. "
+ "Server-side will be used when applicable."
+ ),
+ )
+
+ # --client alone (no model) → just turn on the forced-client flag.
+ # Critic C2: both fields are required before the flag matters; a
+ # partial config + --client would silently fail at request time.
+ if not arg and force_client_flag is True:
+ if not current_advisor or not current_provider:
+ return LocalCommandResult(
+ type="text",
+ value=(
+ "Cannot force client mode: advisor is not fully "
+ "configured. Use \"/advisor