-[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
@@ -154,17 +154,17 @@ O arquivo de configuração é salvo em `~/.clawcodex/config.json`. Exemplo de e
"default_provider": "glm",
"providers": {
"anthropic": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.anthropic.com",
"default_model": "claude-sonnet-4-20250514"
},
"openai": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.openai.com/v1",
"default_model": "gpt-4"
},
"glm": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"default_model": "glm-4.5"
}
diff --git a/docs/i18n/README_RU.md b/docs/i18n/README_RU.md
index 5eb3dd632..1f5c2a4cd 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
@@ -154,17 +154,17 @@ python -m src.cli login
"default_provider": "glm",
"providers": {
"anthropic": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.anthropic.com",
"default_model": "claude-sonnet-4-20250514"
},
"openai": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://api.openai.com/v1",
"default_model": "gpt-4"
},
"glm": {
- "api_key": "base64-encoded-key",
+ "api_key": "your-api-key",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"default_model": "glm-4.5"
}
diff --git a/docs/i18n/README_ZH.md b/docs/i18n/README_ZH.md
new file mode 100644
index 000000000..56826844c
--- /dev/null
+++ b/docs/i18n/README_ZH.md
@@ -0,0 +1,442 @@
+
+
+[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/)
+
+**🔥 活跃开发中 • 每周更新新功能 🔥**
+
+## FLEXIBLE SKILL SYSTEMS
+
+**基于 Markdown 的斜杠技能系统,支持参数替换、工具限制,以及项目级 / 用户级技能加载。**
+
+
+
+***
+
+## 🎯 为什么是 ClawCodex?
+
+**ClawCodex** 是一个面向真实使用的 **Claude Code Python 重构版**:它基于**真实 TypeScript 架构**移植而来,并且交付的是一个**可运行的 CLI Agent**,而不只是源码镜像。
+
+- **真实 Agent Runtime** — 具备工具调用循环、流式 REPL、会话历史与多轮执行能力
+- **高保真移植** — 尽可能保留 Claude Code 的原始架构,同时做符合 Python 风格的实现
+- **适合继续开发** — 代码可读、测试完善,并支持基于 Markdown 的技能扩展
+- **多 LLM 提供商** — 相对上游最大的进展之一:Claude Code 主要面向 **Claude 系列模型**,而 ClawCodex 致力于接入**各大主流 LLM 提供商**,在保持 Agent 能力的前提下,为用户提供更**灵活**、更**具性价比**的 agentic 编程体验
+
+**这是一个真正可跑的 Claude Code 风格 Python 终端工作流:能流式回答、调工具、抓外部上下文,并通过 skills 扩展行为。**
+
+**🚀 立即试用!Fork 它、修改它、让它成为你的!欢迎提交 Pull Request!**
+
+***
+
+## ⭐ Star 历史
+
+[在 star-history.com 查看 Star 历史图表](https://www.star-history.com/?repos=agentforce314%2Fclawcodex&type=date&legend=top-left)
+
+## ✨ 特性
+
+### Streaming Agent Experience
+
+```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", "openai", "glm", "minimax"] # 可继续扩展
+```
+
+### 交互式 REPL(默认)与 Textual TUI(可选)
+
+**默认**为 **prompt_toolkit + Rich** 行内 REPL(滚动区 + 状态行)。使用 **`clawcodex --tui`** 或 REPL 内的 **`/tui`** 可进入 **Textual** 全屏界面。
+
+```text
+>>> 你好!
+Assistant: 嗨!我是 ClawCodex,一个 Python 重实现...
+
+>>> /help # 显示命令
+>>> /tools # 列出已注册工具
+>>> /tui # 切换到 Textual TUI
+>>> /stream on # 流式渲染开关
+>>> /save # 保存会话
+>>> Tab # 自动补全
+>>> /explain-code qsort.py # 运行 SKILL.md 技能(或 /skill …)
+
+# 多行输入:Shift+Enter、Meta/Alt+Enter,或 `\` 后 Enter;单独 Enter 提交。
+```
+
+### 完整的 CLI
+
+```bash
+clawcodex # 行内 REPL(默认)
+clawcodex --tui # Textual TUI
+clawcodex --stream # 开启实时渲染的 REPL
+clawcodex login # 交互式配置 API
+clawcodex config # 查看配置
+clawcodex --version # 版本信息
+
+# 非交互 / 脚本(管道、CI、自动化)
+clawcodex -p "用中文总结 src/cli.py"
+clawcodex -p "Hello" --output-format json
+
+clawcodex --provider anthropic --model claude-sonnet-4-6 -p "Hi"
+clawcodex --max-turns 10 --allowed-tools Read,Grep -p "查找 TODO"
+```
+
+***
+
+## 📊 状态
+
+| 组件 | 状态 | 数量 |
+| ----- | ------ | ------ |
+| REPL 命令 | ✅ 完成 | 内置命令 + `/tools`、`/stream`、`/context`、`/compact`、技能等 |
+| 工具系统 | ✅ 完成 | 30+ 工具 |
+| 自动化测试 | ✅ 已覆盖 | 工具、agent loop、providers、parity、REPL 等 |
+| 文档 | ✅ 完成 | 指南、多语言 README、[FEATURE_LIST.md](../../FEATURE_LIST.md) |
+
+### 核心系统
+
+| 系统 | 状态 | 描述 |
+|------|------|------|
+| CLI 入口 | ✅ | `clawcodex`、`login`、`config`、`-p`、`--tui`、`--stream`、`--version` |
+| 交互式 REPL | ✅ | 默认行内 REPL;可选 Textual;历史、Tab、多行输入 |
+| 多提供商支持 | ✅ | Anthropic、OpenAI、智谱 GLM、Minimax |
+| 会话持久化 | ✅ | 本地保存/加载会话 |
+| Agent Loop | ✅ | 工具调用循环;支持流式与无头模式 |
+| Skill 系统 | ✅ | SKILL.md 斜杠技能:参数与工具白名单 |
+| 上下文构建 | 🟡 | 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(SKILL.md), 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 / glm / minimax
+2. 让你输入该 provider 的 API key
+3. 可选:保存自定义 base URL
+4. 可选:保存默认 model
+5. 将该 provider 设为默认
+
+配置文件会保存在 `~/.clawcodex/config.json`。示例结构:
+
+```json
+{
+ "default_provider": "openai",
+ "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"
+ },
+ "glm": {
+ "api_key": "your-api-key",
+ "base_url": "https://open.bigmodel.cn/api/paas/v4",
+ "default_model": "zai/glm-5"
+ },
+ "minimax": {
+ "api_key": "your-api-key",
+ "base_url": "https://api.minimaxi.com/anthropic",
+ "default_model": "MiniMax-M2.7"
+ }
+ }
+}
+```
+
+### 运行
+
+```bash
+clawcodex # 启动行内 REPL(等同于 python -m src.cli)
+clawcodex --help # 含 --tui、-p、--provider、--model 等
+```
+
+**就这样!** 配置密钥后即可使用 CLI 或 REPL。
+
+***
+
+## 💡 使用
+
+### REPL 命令
+
+| 命令 | 描述 |
+| --- | --- |
+| `/` | 显示命令与技能 |
+| `/help` | 帮助 |
+| `/tools` | 列出已注册工具名 |
+| `/tool
` | 以 JSON 输入直接调用工具 |
+| `/stream` | 流式渲染:`/stream on`、`off` 或 `toggle` |
+| `/render-last` | 将上一条助手回复重新渲染为 Markdown |
+| `/save`、`/load ` | 保存或加载会话 |
+| `/clear` | 清空对话(亦支持 `/reset`、`/new`) |
+| `/tui` | 进入 Textual TUI |
+| `/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`、以及命名参数(例如 `$path`,来自 `arguments`)
+- 占位符写法:请使用 `$path`,不要写成 `${path}`
+
+***
+
+## 🎓 为什么选择 ClawCodex?
+
+### 基于真实源码
+
+- **不是克隆** — 从真实的 TypeScript 实现移植而来
+- **架构保真** — 保持经过验证的设计模式
+- **持续改进** — 更好的错误处理、更多测试、更清晰的代码
+
+### 原生 Python
+
+- **类型提示** — 完整的类型注解
+- **现代 Python** — 使用 3.10+ 特性
+- **符合习惯** — 干净的 Python 风格代码
+
+### 以用户为中心
+
+- **3 步设置** — 克隆、`clawcodex login` 配置、`clawcodex` 运行
+- **交互式配置** — 选择 provider、Base URL、默认模型
+- **行内或 TUI** — 默认终端原生 REPL;可选 Textual
+- **可脚本化** — `-p`、JSON、NDJSON 便于自动化
+- **会话持久化** — 保存与恢复对话
+
+***
+
+## 📦 项目结构
+
+```text
+clawcodex/
+├── src/
+│ ├── cli.py # CLI 入口(控制台命令 clawcodex)
+│ ├── entrypoints/ # 无头 (-p) 与 TUI 启动
+│ ├── repl/ # 行内 REPL
+│ ├── tui/ # Textual UI(--tui、/tui)
+│ ├── providers/ # Anthropic、OpenAI、GLM、Minimax
+│ ├── agent/ # 会话、对话、提示
+│ ├── tool_system/ # Agent loop、工具与 schema
+│ ├── skills/ # SKILL.md 与 Skill 工具
+│ ├── services/ # MCP、compact、IDE、工具执行等
+│ ├── context_system/ # 工作区 / 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** ⭐!
+
+**用 ❤️ 制作 by 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..576a26c89
--- /dev/null
+++ b/docs/workflow-commands-and-ultracode-plan.md
@@ -0,0 +1,78 @@
+# 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: confirm ultracode and instruct the model to **author/launch a workflow via the
+ Workflow tool** for this task (keyword = this turn; session = every substantive task this session).
+- **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.
+
+## 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..6cfac1c53
--- /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` (house style) vs. `min(16, cpu_count−2)` (faithful low-core reduction). Recommend the latter with a `CLAUDE_CODE_WORKFLOW_MAX_AGENTS` override. (§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..6b4021081
--- /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** | **Up to 16 concurrent agents, fewer on machines with limited CPU cores.** Excess `agent()` calls queue and run as slots free. (Upstream cap: `min(16, cpu_cores − 2)`.) |
+| **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..4666f663d
--- /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 |
+| `glm` | `zai/glm-5` | `--provider glm` | OpenAI-compatible (`https://open.bigmodel.cn/api/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` / `glm` |
+| `--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..5b769b71d
--- /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).",
+ ),
+ "glm": ProviderPreset(
+ name="glm",
+ model="zai/glm-5",
+ clawcodex_provider="glm",
+ openclaude_provider="openai",
+ openclaude_base_url="https://open.bigmodel.cn/api/paas/v4",
+ description="Zhipu GLM-5: clawcodex via 'glm' 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/pyproject.toml b/pyproject.toml
index b0655a03a..10935cdfb 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.5.0"
description = "A production-oriented Python rebuild of Claude Code — real architecture, reliable CLI agent"
readme = "README.md"
license = {text = "MIT"}
@@ -32,6 +32,36 @@ dependencies = [
"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 +69,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 +88,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..0b05d924b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,8 +6,13 @@ 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/scripts/audit/__init__.py b/scripts/audit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/scripts/audit/architecture_stats.py b/scripts/audit/architecture_stats.py
new file mode 100644
index 000000000..37eb3e08d
--- /dev/null
+++ b/scripts/audit/architecture_stats.py
@@ -0,0 +1,199 @@
+"""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``.
+ABSTRACTION_MAP: tuple[tuple[str, str], ...] = (
+ ("Generator loop", "src/query"),
+ ("Tools", "src/tool_system"),
+ ("Memory", "src/memdir"),
+ ("Hooks", "src/hooks"),
+ ("Rendering engine", "src/tui"),
+ ("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 97%
rename from src/command_graph.py
rename to scripts/audit/command_graph.py
index 20ef85931..723b0061d 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 src.models 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..1fa237ae4 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 src.models 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/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..2d14c0b02
--- /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': 'replLauncher.py',
+ # 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..23c96cdba 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 src.models 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..fcd6d3171 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 src.models 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..9e57fc086 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 src.models 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 82%
rename from src/setup.py
rename to scripts/audit/setup.py
index b8e5a4da5..96350aa6a 100644
--- a/src/setup.py
+++ b/scripts/audit/setup.py
@@ -5,8 +5,13 @@
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.deferred_init import DeferredInitResult, run_deferred_init
+from src.prefetch import (
+ PrefetchResult,
+ get_or_start_keychain_prefetch,
+ get_or_start_mdm_raw_read,
+ start_project_scan,
+)
@dataclass(frozen=True)
@@ -63,9 +68,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 91%
rename from src/tool_pool.py
rename to scripts/audit/tool_pool.py
index 428a35e58..43fe2dc07 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 src.models 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 91%
rename from src/tools.py
rename to scripts/audit/tools.py
index 580a078b3..92e0ebc7d 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 src.models 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/src/QueryEngine.py b/src/QueryEngine.py
deleted file mode 100644
index a9684d542..000000000
--- a/src/QueryEngine.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from __future__ import annotations
-
-from .query_engine import QueryEnginePort
-from .runtime import PortRuntime
-
-
-class QueryEngineRuntime(QueryEnginePort):
- def route(self, prompt: str, limit: int = 5) -> 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..565f1bc0b 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.5.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..e3b139890 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
diff --git a/src/agent/agent_tool_utils.py b/src/agent/agent_tool_utils.py
index a2efc6fe2..646a7cbb1 100644
--- a/src/agent/agent_tool_utils.py
+++ b/src/agent/agent_tool_utils.py
@@ -201,10 +201,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 +254,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..81319c6f9 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",
@@ -70,4 +73,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/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..16392306a 100644
--- a/src/agent/run_agent.py
+++ b/src/agent/run_agent.py
@@ -18,7 +18,7 @@
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
@@ -57,6 +57,13 @@ class RunAgentParams:
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 +110,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 +156,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 +235,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 +271,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 +295,37 @@ 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 [],
+ 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,9 +334,16 @@ 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.
@@ -248,6 +353,7 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
# 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.
+ effective_query_source = params.query_source or f"agent_{agent_def.agent_type}"
query_params = QueryParams(
messages=initial_messages,
system_prompt=system_prompt,
@@ -256,7 +362,7 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]:
tool_use_context=subagent_context,
provider=params.provider,
abort_controller=abort_controller,
- query_source=f"agent_{agent_def.agent_type}",
+ query_source=effective_query_source,
max_turns=max_turns,
)
diff --git a/src/agent/session.py b/src/agent/session.py
index b740113b4..42784319a 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,75 @@ 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``.
+
+ Shape matches the reader at
+ ``src/services/cost_restore.py:restore_cost_state_for_session``.
+ Module-private; tests can call via the public ``Session.save``.
+ """
+ return {
+ "total_cost_usd": get_total_cost_usd(),
+ "total_api_duration": get_total_api_duration(),
+ "total_api_duration_without_retries":
+ get_total_api_duration_without_retries(),
+ "total_tool_duration": get_total_tool_duration(),
+ "total_lines_added": get_total_lines_added(),
+ "total_lines_removed": get_total_lines_removed(),
+ # last_duration = elapsed since start_time. cost_restore uses
+ # this to back-date the new session's start_time so post-resume
+ # duration accumulators continue from where they left off.
+ "last_duration": time.time() - get_start_time(),
+ "model_usage": {
+ model: {
+ "input_tokens": u.input_tokens,
+ "output_tokens": u.output_tokens,
+ "cache_creation_input_tokens": u.cache_creation_input_tokens,
+ "cache_read_input_tokens": u.cache_read_input_tokens,
+ "cost_usd": u.cost_usd,
+ }
+ for model, u in get_model_usage().items()
+ },
+ }
diff --git a/src/agent/subagent_context.py b/src/agent/subagent_context.py
index 18ed2af01..59a0879c7 100644
--- a/src/agent/subagent_context.py
+++ b/src/agent/subagent_context.py
@@ -74,15 +74,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.
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/bootstrap/state.py b/src/bootstrap/state.py
index b8df05d3b..5c77b4fba 100644
--- a/src/bootstrap/state.py
+++ b/src/bootstrap/state.py
@@ -1,26 +1,463 @@
-"""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()
+
+
+# ---------------------------------------------------------------------------
+# 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 +480,755 @@ 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``."""
+ _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:
+ _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.
+ """
+ _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:
+ _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 (deferred Phase 2) ``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:993``).
+
+ 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..6501b75e7
--- /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.tui.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/tui/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..d797573df 100644
--- a/src/cli.py
+++ b/src/cli.py
@@ -10,9 +10,36 @@
from rich.prompt import Prompt
from rich.table import Table
+# WI-4.1 (ch17 Phase 4): fire keychain + MDM child processes at
+# MODULE-IMPORT time so the OS schedules them in parallel with the rest
+# of the Python interpreter's module-loading work. The handles are
+# awaited later by the consumer (typically post-trust-gate when keychain
+# values are actually needed). subprocess.Popen returns in microseconds;
+# the actual subprocess work overlaps with the heavyweight imports the
+# CLI is about to do. On non-macOS platforms these are no-ops
+# (``process=None`` sentinels) so call sites don't need to special-case
+# the platform.
+from src.prefetch import (
+ get_or_start_keychain_prefetch,
+ get_or_start_mdm_raw_read,
+)
+
+# Fire ONCE per process via the singleton getter. ``setup.run_setup``
+# reads the same handles instead of re-spawning, so the cost is paid
+# exactly once even when both entrypoints run in the same interpreter.
+_keychain_handle = get_or_start_keychain_prefetch()
+_mdm_handle = 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
if os.environ.get("CLAWCODEX_DEBUG", "").lower() in ("1", "true", "yes"):
import logging
@@ -29,21 +56,38 @@ 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()
parser = _build_parser()
args = parser.parse_args(argv)
+ profile_checkpoint("argparse_done")
if args.version:
from src import __version__
@@ -53,7 +97,40 @@ 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.
+ 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")
+ 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
@@ -67,15 +144,23 @@ def main():
from src.entrypoints.tui import should_use_tui
if should_use_tui(explicit_tui):
+ profile_checkpoint("mode_dispatch_tui")
+ profile_checkpoint("phase4_dispatch")
return _run_tui_mode(args)
- return start_repl(stream=args.stream)
+ profile_checkpoint("mode_dispatch_repl")
+ profile_checkpoint("phase4_dispatch")
+ return start_repl(
+ stream=args.stream,
+ permission_mode=args._resolved_permission_mode,
+ is_bypass_permissions_mode_available=args._resolved_is_bypass_available,
+ )
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:
@@ -146,12 +231,6 @@ 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,
@@ -168,7 +247,7 @@ def _build_parser() -> argparse.ArgumentParser:
'--provider',
type=str,
default=None,
- help='Override the provider (anthropic, openai, glm, minimax)',
+ help='Override the provider (anthropic, openai, glm, minimax, openrouter, deepseek)',
)
noninteractive.add_argument(
'--allowed-tools',
@@ -188,6 +267,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 +316,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."""
@@ -233,6 +399,8 @@ def _run_print_mode(args) -> int:
model=args.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),
@@ -256,6 +424,8 @@ def _run_tui_mode(args) -> int:
allowed_tools=tuple(allowed),
disallowed_tools=tuple(disallowed),
stream=True,
+ permission_mode=args._resolved_permission_mode,
+ is_bypass_permissions_mode_available=args._resolved_is_bypass_available,
)
return run_tui(options)
@@ -290,7 +460,7 @@ def _show_provider_defaults_table() -> None:
def handle_login():
"""Interactive API configuration."""
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()
@@ -362,6 +532,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,13 +553,29 @@ def show_config():
return 0
-def start_repl(stream: bool = False):
- """Start interactive REPL."""
+def start_repl(
+ stream: bool = False,
+ *,
+ permission_mode: str = "default",
+ is_bypass_permissions_mode_available: bool = False,
+):
+ """Start interactive REPL.
+
+ ``permission_mode`` and ``is_bypass_permissions_mode_available`` are
+ resolved by :func:`_resolve_permission_state`. They control whether
+ the in-process tool registry will short-circuit permission checks
+ for the user (when ``--dangerously-skip-permissions`` is set).
+ """
from src.config import get_default_provider
from src.repl import ClawcodexREPL
provider = get_default_provider()
- repl = ClawcodexREPL(provider_name=provider, stream=stream)
+ repl = ClawcodexREPL(
+ provider_name=provider,
+ stream=stream,
+ permission_mode=permission_mode,
+ is_bypass_permissions_mode_available=is_bypass_permissions_mode_available,
+ )
repl.run()
return 0
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..2c409378c
--- /dev/null
+++ b/src/command_system/aggregator.py
@@ -0,0 +1,244 @@
+"""
+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"})
+
+
+@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),
+ ]
+
+ 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..eefaba3ec 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
@@ -432,6 +451,464 @@ 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 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)
+
+ 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,
+ )
+ 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]"
+ 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 : "
+ "--client\" together."
+ ),
+ )
+ if current_client_mode:
+ return LocalCommandResult(
+ type="text", value="Advisor client mode already on.",
+ )
+ _write_advisor_client_mode(context, True)
+ return LocalCommandResult(
+ type="text",
+ value=(
+ "Advisor client mode enabled. The advisor will run via "
+ "client-side dispatch on every request."
+ ),
+ )
+
+ if arg_lower in ("unset", "off"):
+ previous_model = current_advisor
+ previous_provider = current_provider
+ if previous_model:
+ _write_advisor_model(context, None)
+ if previous_provider:
+ _write_advisor_provider(context, None)
+ if current_client_mode:
+ _write_advisor_client_mode(context, False)
+ if previous_model or previous_provider:
+ prior = (
+ f"{previous_provider}:{previous_model}"
+ if previous_provider and previous_model
+ else (previous_model or previous_provider or "?")
+ )
+ return LocalCommandResult(
+ type="text", value=f"Advisor disabled (was {prior}).",
+ )
+ return LocalCommandResult(
+ type="text", value="Advisor already unset.",
+ )
+
+ # Parse : — provider must be a known config key.
+ raw = arg
+ if ":" not in raw:
+ providers = _list_configured_providers()
+ providers_hint = (
+ f" Configured providers: {', '.join(providers)}."
+ if providers else ""
+ )
+ return LocalCommandResult(
+ type="text",
+ value=(
+ "Advisor requires explicit : syntax.\n"
+ f"Got: {raw!r}.{providers_hint}\n"
+ 'Example: /advisor anthropic:claude-opus-4-7'
+ ),
+ )
+ provider_part, model_part = raw.split(":", 1)
+ provider_part = provider_part.strip()
+ model_part = model_part.strip()
+ if not provider_part or not model_part:
+ return LocalCommandResult(
+ type="text",
+ value=(
+ "Invalid syntax. Expected : with both "
+ f"halves non-empty. Got: {raw!r}."
+ ),
+ )
+ # Critic S3: validate against the configured providers list
+ # unconditionally. ``_list_configured_providers`` is empty only
+ # when ``load_global`` crashes (its try/except swallows then
+ # returns []) — in that pathological case a clearer signal is
+ # better than a friendly silent-skip that lets bad input through.
+ configured = _list_configured_providers()
+ if provider_part not in configured:
+ return LocalCommandResult(
+ type="text",
+ value=(
+ f"Unknown provider {provider_part!r}. Configured: "
+ f"{', '.join(configured) or '(none — check ~/.clawcodex/config.json)'}. "
+ "Configure new providers in ~/.clawcodex/config.json."
+ ),
+ )
+ try:
+ resolved = resolve_model(model_part)
+ except Exception as e:
+ return LocalCommandResult(
+ type="text", value=f"Invalid advisor model: {e}",
+ )
+ if not validate_model_name(resolved):
+ return LocalCommandResult(
+ type="text",
+ value=f"Unknown model: {model_part} ({resolved})",
+ )
+
+ normalized = canonical_model_name(resolved)
+ _write_advisor_model(context, normalized)
+ _write_advisor_provider(context, provider_part)
+ if force_client_flag is True:
+ _write_advisor_client_mode(context, True)
+ elif force_client_flag is False:
+ _write_advisor_client_mode(context, False)
+
+ # Report what mode the chosen pair lands in, so the user can spot
+ # mismatches immediately (e.g., they expected server-side but the
+ # main model doesn't qualify).
+ effective_client_mode = (
+ force_client_flag
+ if force_client_flag is not None
+ else current_client_mode
+ )
+ chosen_mode = decide_advisor_mode(
+ provider,
+ main_loop_model,
+ normalized,
+ force_client_mode=effective_client_mode,
+ advisor_provider=provider_part,
+ )
+ if chosen_mode == ADVISOR_MODE_SERVER_SIDE:
+ mode_msg = "Will run server-side (Anthropic beta path)."
+ elif chosen_mode == ADVISOR_MODE_CLIENT_SIDE:
+ mode_msg = "Will run client-side (separate API call)."
+ else:
+ mode_msg = (
+ "Note: advisor is currently inactive (no path applies for "
+ f"main loop {main_loop_model!r} + advisor {normalized!r})."
+ )
+ return LocalCommandResult(
+ type="text",
+ value=f"Advisor set to {provider_part}:{normalized}. {mode_msg}",
+ )
+
+
def compact_command_call(args: str, context: CommandContext) -> LocalCommandResult:
"""
Handle /compact command - compact conversation context.
@@ -610,6 +1087,23 @@ def _sync_compact_fallback(context: CommandContext) -> LocalCommandResult:
supports_non_interactive=True,
)
+# /advisor — server-side reviewer tool. Python port of
+# typescript/src/commands/advisor.ts. The `is_enabled` callable is read by
+# the help-listing and command-availability checks. We pass ``provider=None``
+# at command-list time because the registry doesn't know what provider is
+# active; the env-disable check still applies. Per-request provider gating
+# is enforced inside ``_call_model_sync`` so the user can't accidentally
+# silence the API by toggling /advisor under a non-first-party provider.
+from ..utils.advisor import can_user_configure_advisor as _can_user_configure_advisor
+
+ADVISOR_COMMAND = LocalCommand(
+ name="advisor",
+ description="Configure the advisor model (server-side on 1P Anthropic, client-side on any provider)",
+ argument_hint="[ [--client] | --no-client | off]",
+ supports_non_interactive=True,
+ is_enabled=lambda: _can_user_configure_advisor(None),
+)
+
INIT_COMMAND = PromptCommand(
name="init",
description="Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation",
@@ -619,6 +1113,64 @@ def _sync_compact_fallback(context: CommandContext) -> LocalCommandResult:
source="builtin",
)
+# Port of typescript/src/commands/auto-fix.ts (type: 'prompt'). Text is verbatim.
+AUTO_FIX_PROMPT = (
+ "The user wants to configure auto-fix settings. Auto-fix automatically runs lint "
+ "and test commands after AI file edits, feeding errors back for self-repair.\n\n"
+ "Current settings location: `.claude/settings.json` or "
+ "`.claude/settings.local.json`\n\n"
+ "Example configuration:\n```json\n{\n \"autoFix\": {\n \"enabled\": true,\n"
+ " \"lint\": \"eslint . --fix\",\n \"test\": \"bun test\",\n"
+ " \"maxRetries\": 3,\n \"timeout\": 30000\n }\n}\n```\n\n"
+ "Ask the user what lint and test commands they use, then help them set up the "
+ "configuration."
+)
+
+# Port of the /review half of typescript/src/commands/review.ts (LOCAL_REVIEW_PROMPT).
+# TS uses an indented template literal; this is intentionally dedented (the model is
+# indentation-insensitive). `${args}` becomes `$ARGUMENTS` for substitute_arguments.
+REVIEW_PROMPT = """
+You are an expert code reviewer. Follow these steps:
+
+1. If no PR number is provided in the args, run `gh pr list` to show open PRs
+2. If a PR number is provided, run `gh pr view ` to get PR details
+3. Run `gh pr diff ` to get the diff
+4. Analyze the changes and provide a thorough code review that includes:
+ - Overview of what the PR does
+ - Analysis of code quality and style
+ - Specific suggestions for improvements
+ - Any potential issues or risks
+
+Keep your review concise but thorough. Focus on:
+- Code correctness
+- Following project conventions
+- Performance implications
+- Test coverage
+- Security considerations
+
+Format your review with clear sections and bullet points.
+
+PR number: $ARGUMENTS
+"""
+
+AUTO_FIX_COMMAND = PromptCommand(
+ name="auto-fix",
+ description="Configure auto-fix: run lint/test after AI edits",
+ markdown_content=AUTO_FIX_PROMPT,
+ progress_message="Configuring auto-fix...",
+ content_length=0,
+ source="builtin",
+)
+
+REVIEW_COMMAND = PromptCommand(
+ name="review",
+ description="Review a pull request",
+ markdown_content=REVIEW_PROMPT,
+ progress_message="reviewing pull request",
+ content_length=0,
+ source="builtin",
+)
+
# Synchronous versions for REPL integration
def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> tuple[bool, str | None, str | None]:
@@ -670,11 +1222,17 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t
COST_COMMAND.set_call(cost_command_call)
CONTEXT_COMMAND.set_call(context_command_call)
COMPACT_COMMAND.set_call(compact_command_call)
+ADVISOR_COMMAND.set_call(advisor_command_call)
def get_builtin_commands() -> list[Command]:
"""Get all built-in commands."""
- return [
+ # Local import keeps the gate readable at the call site and avoids
+ # pulling buddy module dependencies into command_system on bare
+ # `import` of this module — matches pattern used elsewhere.
+ from .buddy_command import BUDDY_COMMAND, is_buddy_command_enabled
+
+ cmds: list[Command] = [
HELP_COMMAND,
CLEAR_COMMAND,
EXIT_COMMAND,
@@ -682,8 +1240,47 @@ def get_builtin_commands() -> list[Command]:
COST_COMMAND,
CONTEXT_COMMAND,
COMPACT_COMMAND,
+ ADVISOR_COMMAND,
INIT_COMMAND,
+ AUTO_FIX_COMMAND,
+ REVIEW_COMMAND,
+ SECURITY_REVIEW_COMMAND,
+ STATUSLINE_COMMAND,
+ PERMISSIONS_COMMAND,
+ OUTPUT_STYLE_COMMAND,
+ EXPORT_COMMAND,
+ THEME_COMMAND,
+ EFFORT_COMMAND,
+ MODEL_COMMAND,
+ LOGO_COMMAND,
+ MCP_COMMAND,
+ TASKS_COMMAND,
+ DIFF_COMMAND,
+ RELEASE_NOTES_COMMAND,
+ COPY_COMMAND,
+ VIM_COMMAND,
+ MEMORY_COMMAND,
+ STICKERS_COMMAND,
+ RENAME_COMMAND,
+ RESUME_COMMAND,
+ DOCTOR_COMMAND,
]
+ if is_buddy_command_enabled():
+ cmds.append(BUDDY_COMMAND)
+ # Bundled dynamic-workflow slash commands (e.g. /deep-research). Registering
+ # them here (gated) surfaces them in BOTH command suggestions and dispatch —
+ # which read the global registry this populates — instead of only the
+ # aggregator's get_commands(), which has no real consumers.
+ try:
+ from src.workflow.gating import is_workflows_enabled
+
+ if is_workflows_enabled():
+ from .workflows_integration import bundled_workflow_commands
+
+ cmds.extend(bundled_workflow_commands())
+ except Exception: # noqa: BLE001 — never break command listing on a bad file
+ pass
+ return cmds
def register_builtin_commands(registry: CommandRegistry | None = None) -> None:
diff --git a/src/command_system/copy_command.py b/src/command_system/copy_command.py
new file mode 100644
index 000000000..3f551641d
--- /dev/null
+++ b/src/command_system/copy_command.py
@@ -0,0 +1,319 @@
+"""copy — ``/copy`` copy-assistant-response command (port of TS local-jsx).
+
+Port of ``typescript/src/commands/copy/`` (``copy.tsx`` + ``index.ts``). Copies the
+latest (or Nth-latest, ``/copy N``) assistant response — or one of its fenced code
+blocks via a picker — to the clipboard, always also writing a temp-file fallback.
+
+**Headless keystone:** the *direct* path (no code blocks in the message, or the
+``copyFullResponse`` config opt-out, including via ``/copy N``) never touches
+``ctx.ui`` → works on every surface incl. ``NullUIHost``. Only the code-block picker
+needs a surface. Coexistence: **fall-through** (no TUI dialog for ``/copy``).
+
+Deliberate divergences (documented for parity review):
+ * **Clipboard via subprocess** (pbcopy / xclip / xsel / clip), not TS's OSC 52
+ escape (which is fire-and-forget and therefore always *claims* success). A failed
+ subprocess copy must not lie: clipboard-fail + file-ok reports
+ ``"Written to {path} (…)"`` instead of "Copied to clipboard".
+ * **Line-based fence parser**, not the marked lexer — indented code blocks and
+ ``~~~`` fences are not recognized (fenced ``` blocks are what assistants emit).
+ * **``COPY_DIR`` = ``{tmpdir}/clawcodex``** (TS uses ``{tmpdir}/claude``).
+ * **Codepoint-based label truncation** (TS uses display-width ``stringWidth``).
+ * **No ``isApiErrorMessage`` flag** on Python messages — empty texts are skipped,
+ which is the practical effect.
+ * **``w`` write-only keyboard shortcut dropped** (no keyboard primitive on the
+ ``select`` bridge).
+"""
+from __future__ import annotations
+
+import re
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+MAX_LOOKBACK = 20 # TS copy.tsx:25
+RESPONSE_FILENAME = "response.md"
+
+# Verbatim port of TS STRIPPED_TAGS_RE (messages.ts:2764-2765).
+_STRIPPED_TAGS_RE = re.compile(
+ r"<(commit_analysis|context|function_analysis|pr_analysis)>.*?\1>\n?",
+ re.DOTALL,
+)
+
+
+def _strip_prompt_xml_tags(content: str) -> str:
+ """TS ``stripPromptXMLTags`` (messages.ts:2767-2769)."""
+ return _STRIPPED_TAGS_RE.sub("", content).strip()
+
+
+def _copy_dir() -> Path:
+ return Path(tempfile.gettempdir()) / "clawcodex"
+
+
+@dataclass(frozen=True)
+class CodeBlock:
+ code: str
+ lang: str | None
+
+
+def _extract_code_blocks(markdown: str) -> list[CodeBlock]:
+ """Fenced ``` code blocks from the (XML-stripped) markdown. Line-based parser
+ (see module divergences): an opening ``` captures the language token; lines are
+ collected until the closing ```; an unclosed fence yields no block."""
+ blocks: list[CodeBlock] = []
+ in_fence = False
+ lang: str | None = None
+ buf: list[str] = []
+ for line in _strip_prompt_xml_tags(markdown).split("\n"):
+ stripped = line.strip()
+ if not in_fence and stripped.startswith("```"):
+ in_fence = True
+ token = stripped[3:].strip()
+ lang = token.split()[0] if token else None
+ buf = []
+ continue
+ if in_fence and stripped.startswith("```") and not stripped[3:].strip():
+ # CommonMark: a CLOSING fence may not carry an info string — a ```js
+ # line inside an open fence is content, not a close.
+ blocks.append(CodeBlock(code="\n".join(buf), lang=lang))
+ in_fence = False
+ lang = None
+ buf = []
+ continue
+ if in_fence:
+ buf.append(line)
+ return blocks
+
+
+def _message_role(msg: Any) -> Any:
+ if isinstance(msg, Mapping):
+ return msg.get("role") or msg.get("type")
+ return getattr(msg, "role", None) or getattr(msg, "type", None)
+
+
+def _message_text(msg: Any) -> str:
+ """Assistant text joined with '\\n\\n' (TS extractTextContent(content, '\\n\\n'))."""
+ from src.utils.export_renderer import extract_message_content
+
+ content = extract_message_content(msg)
+ if isinstance(content, str):
+ return content.strip()
+ if isinstance(content, list):
+ parts: list[str] = []
+ for block in content:
+ if isinstance(block, Mapping):
+ if block.get("type") in (None, "text") and block.get("text"):
+ parts.append(str(block["text"]))
+ else:
+ text = getattr(block, "text", None)
+ if text and getattr(block, "type", "text") in (None, "text"):
+ parts.append(str(text))
+ return "\n\n".join(parts).strip()
+ return ""
+
+
+def collect_recent_assistant_texts(messages: Any) -> list[str]:
+ """TS ``collectRecentAssistantTexts`` (copy.tsx:50-61): newest-first assistant
+ texts that actually said something, capped at ``MAX_LOOKBACK``."""
+ texts: list[str] = []
+ for msg in reversed(list(messages or [])):
+ if len(texts) >= MAX_LOOKBACK:
+ break
+ if _message_role(msg) != "assistant":
+ continue
+ text = _message_text(msg)
+ if text:
+ texts.append(text)
+ return texts
+
+
+def _file_extension(lang: str | None) -> str:
+ """TS ``fileExtension`` (copy.tsx:62-72) — sanitized to prevent path traversal."""
+ if lang:
+ sanitized = re.sub(r"[^a-zA-Z0-9]", "", lang)
+ if sanitized and sanitized != "plaintext":
+ return f".{sanitized}"
+ return ".txt"
+
+
+def _truncate_line(text: str, max_len: int) -> str:
+ """First line, codepoint-truncated to ``max_len`` with ``…`` (TS truncateLine,
+ minus display-width awareness — module divergences)."""
+ first = text.split("\n")[0]
+ if len(first) <= max_len:
+ return first
+ return first[: max_len - 1] + "…"
+
+
+def _set_clipboard(text: str) -> bool:
+ """Best-effort OS clipboard via subprocess; True on success."""
+ if sys.platform == "darwin":
+ candidates = [["pbcopy"]]
+ elif sys.platform.startswith("win"):
+ candidates = [["clip"]]
+ else:
+ candidates = [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
+ for cmd in candidates:
+ try:
+ proc = subprocess.run(
+ cmd, input=text.encode("utf-8"), capture_output=True, timeout=5
+ )
+ if proc.returncode == 0:
+ return True
+ except Exception:
+ continue
+ return False
+
+
+def _write_to_file(text: str, filename: str) -> str:
+ copy_dir = _copy_dir()
+ copy_dir.mkdir(parents=True, exist_ok=True)
+ path = copy_dir / filename
+ path.write_text(text, encoding="utf-8")
+ return str(path)
+
+
+def _copy_or_write(text: str, filename: str) -> str:
+ """TS ``copyOrWriteToFile`` (copy.tsx:81-94), with the honest clipboard-failure
+ divergence (module docstring). ``char_count`` is Python codepoints; TS
+ ``text.length`` is UTF-16 code units (astral chars differ by one)."""
+ copied = _set_clipboard(text)
+ line_count = text.count("\n") + 1
+ char_count = len(text)
+ try:
+ path = _write_to_file(text, filename)
+ except Exception as exc:
+ if copied:
+ return f"Copied to clipboard ({char_count} characters, {line_count} lines)"
+ return f"Failed to copy: {exc}"
+ if copied:
+ return (
+ f"Copied to clipboard ({char_count} characters, {line_count} lines)\n"
+ f"Also written to {path}"
+ )
+ return f"Written to {path} ({char_count} characters, {line_count} lines)"
+
+
+def _copy_full_response_enabled() -> bool:
+ from src.config import load_config
+
+ return bool(load_config().get("copyFullResponse"))
+
+
+def _persist_copy_full_response() -> None:
+ from src.config import _get_default_manager, load_config
+
+ if not load_config().get("copyFullResponse"):
+ _get_default_manager().set_global("copyFullResponse", True)
+
+
+@dataclass(frozen=True)
+class CopyCommand(InteractiveCommand):
+ """Copy the latest assistant response (or a code block) to the clipboard."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ messages = getattr(context.conversation, "messages", None) or []
+ texts = collect_recent_assistant_texts(messages)
+ if not texts:
+ return InteractiveOutcome(message="No assistant message to copy", display="user")
+
+ # /copy N reaches back N-1 messages (TS copy.tsx:341-355).
+ age = 0
+ arg = (args or "").strip()
+ if arg:
+ # int(arg) mirrors TS Number()+isInteger: accepts "+2"/"02", rejects
+ # floats and non-numerics.
+ try:
+ n = int(arg)
+ is_int = True
+ except ValueError:
+ n, is_int = 0, False
+ if not is_int or n < 1:
+ return InteractiveOutcome(
+ message=(
+ "Usage: /copy [N] where N is 1 (latest), 2, 3, … "
+ f"Got: {arg}"
+ ),
+ display="user",
+ )
+ if n > len(texts):
+ noun = "message" if len(texts) == 1 else "messages"
+ return InteractiveOutcome(
+ message=f"Only {len(texts)} assistant {noun} available to copy",
+ display="user",
+ )
+ age = n - 1
+
+ text = texts[age]
+ code_blocks = _extract_code_blocks(text)
+
+ if not code_blocks or _copy_full_response_enabled():
+ return InteractiveOutcome(
+ message=_copy_or_write(text, RESPONSE_FILENAME), display="user"
+ )
+
+ return await self._pick(context, text, code_blocks)
+
+ async def _pick(
+ self, context: CommandContext, full_text: str, code_blocks: list[CodeBlock]
+ ) -> InteractiveOutcome:
+ options: list[UIOption] = [
+ UIOption(
+ value="full",
+ label="Full response",
+ description=(
+ f"{len(full_text)} chars, {full_text.count(chr(10)) + 1} lines"
+ ),
+ )
+ ]
+ for i, block in enumerate(code_blocks):
+ lines = block.code.count("\n") + 1
+ desc_parts = [p for p in (block.lang, f"{lines} lines" if lines > 1 else None) if p]
+ options.append(
+ UIOption(
+ value=str(i),
+ label=_truncate_line(block.code, 60),
+ description=", ".join(desc_parts) or None,
+ )
+ )
+ options.append(
+ UIOption(
+ value="always",
+ label="Always copy full response",
+ description="Skip this picker in the future (revert via /config)",
+ )
+ )
+
+ picked = await context.ui.select("Select content to copy:", options)
+ if picked is None:
+ return InteractiveOutcome(message="Copy cancelled", display="system")
+
+ if picked in ("full", "always"):
+ result = _copy_or_write(full_text, RESPONSE_FILENAME)
+ if picked == "always":
+ _persist_copy_full_response()
+ result += "\nPreference saved. Use /config to change copyFullResponse"
+ return InteractiveOutcome(message=result, display="user")
+
+ block = code_blocks[int(picked)]
+ result = _copy_or_write(block.code, f"copy{_file_extension(block.lang)}")
+ return InteractiveOutcome(message=result, display="user")
+
+
+COPY_COMMAND = CopyCommand(
+ name="copy",
+ # Verbatim TS index.ts.
+ description="Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
+)
+
+
+__all__ = ["COPY_COMMAND", "CopyCommand", "collect_recent_assistant_texts"]
diff --git a/src/command_system/diff_command.py b/src/command_system/diff_command.py
new file mode 100644
index 000000000..b7b89e6aa
--- /dev/null
+++ b/src/command_system/diff_command.py
@@ -0,0 +1,87 @@
+"""diff — ``/diff`` uncommitted-changes view (port of TS local-jsx, degraded).
+
+TS ``/diff`` (``commands/diff/``) renders an interactive ``DiffDialog`` showing the
+uncommitted changes AND per-turn file edits (from tool results). Python's TUI ``/diff``
+reads ``app_state.pending_diffs`` (app-bound) and pushes ``DiffDialogScreen``. This
+registry port shows the **uncommitted changes (staged + unstaged) as ``git diff`` text**
+— the content TS shows via ``git diff HEAD``, reachable from ``CommandContext.cwd`` via
+the existing ``src/utils/git.py`` helpers — dropping the per-turn diffs (app-bound), the
+interactive viewer, and untracked files (TS's ``git diff HEAD`` omits those too). The TUI
+keeps its rich dialog (inversion).
+
+Follows the output-style/``/mcp``/``/tasks`` precedent: ``run()`` returns text WITHOUT
+touching ``ctx.ui``, so it works on every surface.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+# Cap so a huge diff doesn't flood a single REPL/SDK message; the TUI has the full view.
+_MAX_LINES = 500
+
+
+def _cap(text: str) -> str:
+ lines = text.split("\n")
+ if len(lines) <= _MAX_LINES:
+ return text
+ more = len(lines) - _MAX_LINES
+ head = "\n".join(lines[:_MAX_LINES])
+ return (
+ f"{head}\n… (diff truncated — {more} more line(s); use the TUI /diff for the "
+ "full interactive view)"
+ )
+
+
+def _section(label: str, result) -> str:
+ return (
+ f"{label} ({result.files_changed} file(s), +{result.insertions} "
+ f"-{result.deletions}):\n" + (result.diff_text or "").rstrip("\n")
+ )
+
+
+@dataclass(frozen=True)
+class DiffCommand(InteractiveCommand):
+ """Show the uncommitted changes (staged + unstaged) as ``git diff`` text. Frozen + no
+ new fields (the ``McpCommand`` pattern); ``run()`` returns text without touching
+ ``ctx.ui``."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ # Lazy import so `import src.command_system` doesn't pull the git/utils stack.
+ from src.utils.git import get_repo_root, get_session_diff
+
+ cwd = str(context.cwd)
+ if get_repo_root(cwd) is None:
+ # get_repo_root distinguishes not-a-repo from no-changes (both give "" from
+ # the diff helper otherwise).
+ return InteractiveOutcome("Not a git repository.", display="system")
+
+ # Staged + unstaged = the "uncommitted changes" TS shows via `git diff HEAD`,
+ # rendered as two labeled sections (avoids double-counting and handles a repo
+ # with no commits, which a literal `git diff HEAD` can't). Untracked files are
+ # not shown — matching TS's `git diff HEAD`.
+ staged = get_session_diff(cwd, staged=True)
+ unstaged = get_session_diff(cwd)
+ sections: list[str] = []
+ if (staged.diff_text or "").strip():
+ sections.append(_section("Staged changes", staged))
+ if (unstaged.diff_text or "").strip():
+ sections.append(_section("Unstaged changes", unstaged))
+ if not sections:
+ return InteractiveOutcome("No uncommitted changes.", display="system")
+ return InteractiveOutcome(_cap("\n\n".join(sections)), display="system")
+
+
+DIFF_COMMAND = DiffCommand(
+ name="diff",
+ # Verbatim TS index.ts (the port shows the git working-tree diff only — see docstring).
+ description="View uncommitted changes and per-turn diffs",
+)
+
+
+__all__ = ["DIFF_COMMAND", "DiffCommand"]
diff --git a/src/command_system/doctor_command.py b/src/command_system/doctor_command.py
new file mode 100644
index 000000000..a67155a32
--- /dev/null
+++ b/src/command_system/doctor_command.py
@@ -0,0 +1,88 @@
+"""doctor — ``/doctor`` diagnostics (port of TS local-jsx, components C6).
+
+TS ``/doctor`` (``commands/doctor``) renders ``DiagnosticsDisplay``.
+Python's rich surface is the (previously dormant, now wired) TUI
+``DoctorScreen``; this registry command serves the NON-TUI surfaces in
+the output-style precedent — ``run()`` returns a text report without
+touching ``ctx.ui`` (headless/REPL/SDK safe).
+
+Coexistence: **inversion** — the TUI intercepts ``/doctor`` and pushes
+the screen; this command serves everything else.
+"""
+
+from __future__ import annotations
+
+import platform
+import sys
+from dataclasses import dataclass
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+
+def build_doctor_report(cwd: str | None = None) -> str:
+ from src.services.config_health import collect_config_warnings
+
+ lines: list[str] = ["Diagnostics:"]
+ lines.append(
+ f"• python {platform.python_version()} on "
+ f"{platform.system()} {platform.machine()} ({sys.executable})"
+ )
+ try:
+ from src.tool_system.utils.ripgrep import find_ripgrep
+
+ rg = find_ripgrep()
+ lines.append(f"• ripgrep: {rg or 'NOT FOUND (file search degraded)'}")
+ except Exception:
+ lines.append("• ripgrep: check failed")
+ try:
+ from src.services.session_storage import SESSIONS_DIR
+
+ lines.append(f"• sessions dir: {SESSIONS_DIR}")
+ except Exception:
+ pass
+ try:
+ warnings = collect_config_warnings(cwd)
+ except Exception:
+ warnings = []
+ lines.append("• config health: check failed")
+ if warnings:
+ lines.append("Config problems:")
+ lines.extend(f" ⚠ {w.message()}" for w in warnings)
+ else:
+ lines.append("• config files: OK")
+ try:
+ from src.services.config_health import collect_rule_warnings
+
+ rule_warnings = collect_rule_warnings(cwd)
+ except Exception:
+ rule_warnings = []
+ if rule_warnings:
+ lines.append("Permission-rule warnings:")
+ lines.extend(f" ⚠ {w}" for w in rule_warnings)
+ return "\n".join(lines)
+
+
+@dataclass(frozen=True)
+class DoctorCommand(InteractiveCommand):
+ """Environment + config health report (text on every surface)."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ cwd = str(getattr(context, "cwd", "") or "") or None
+ return InteractiveOutcome(
+ message=build_doctor_report(cwd), display="system"
+ )
+
+
+DOCTOR_COMMAND = DoctorCommand(
+ name="doctor",
+ # TS doctor/index.ts:6 with the product name made neutral.
+ description="Diagnose and verify your installation and settings",
+ argument_hint="",
+)
+
+
+__all__ = ["DOCTOR_COMMAND", "DoctorCommand", "build_doctor_report"]
diff --git a/src/command_system/effort_command.py b/src/command_system/effort_command.py
new file mode 100644
index 000000000..aa4f71fdb
--- /dev/null
+++ b/src/command_system/effort_command.py
@@ -0,0 +1,262 @@
+"""effort — interactive ``/effort`` command (port of TS local-jsx).
+
+Port of ``typescript/src/commands/effort/`` (``effort.tsx`` + ``index.ts``). A
+``local-jsx`` command becomes an :class:`InteractiveCommand` (blocked remotely by
+type). Like ``/theme`` (Phase 5) and **unlike** ``/export``, this is the *inverse* of
+``/export`` at the TUI dispatch layer: the TUI keeps intercepting ``/effort``
+(``commands.py`` → ``open_dialog="effort"``) to preserve its richer ``EffortPickerScreen``.
+This command serves the surfaces that *consult the registry*: the REPL (numbered-menu
+``select``), the SDK (``NullUIHost``), and the help/aggregator listings — where
+``/effort`` was previously invisible because it lived only in the TUI's private
+``LOCAL_BUILTINS``.
+
+**Unlike ``/theme``, ``/effort`` has a real headless keystone.** TS ``call`` accepts
+args (``/effort high``, ``current``, ``help``) that need **no UI**; only the no-args
+picker path needs a surface. So the arg paths work on ``NullUIHost`` (SDK); only the
+picker raises there.
+
+**Faithfulness to TS (``effort.tsx``):**
+ * Branches mirror ``call`` (``:176-190``): help (``:178-180``), ``current``/``status``
+ (``:182-183``), no-args ⇒ picker (``:185-186``), else ``executeEffort`` (``:108-123``).
+ * **Two distinct ``auto`` messages**, preserved verbatim: the *picker* auto path emits
+ ``"Set effort level to auto: Use default effort level for your model"`` (TS ``:213``
+ with ``effort=undefined``); the *arg* auto/unset path emits ``"Effort level set to
+ auto"`` (TS ``unsetEffortLevel`` ``:102``).
+ * Every TS ``onDone`` passes **no options** ⇒ ``createUserMessage`` (model-visible,
+ ``shouldQuery ?? false``), so **all** outcomes map to ``display="user"`` — including
+ ``Cancelled`` and help (this differs from ``/theme``'s ``system`` cancel). ``display``
+ is behaviorally inert on today's surfaces (engine maps both to ``result_type="text"``).
+
+**Persistence:** writes ``settings.effort`` via :func:`src.config.set_effort` (the
+validated settings channel, mirroring TS ``updateSettingsForSource('userSettings',
+{effortLevel})``). NOTE — the persisted value is **not yet consumed by inference**:
+Python's effort pipeline is inert end-to-end (``settings.effort`` is read by no request
+builder; ``CallModelOptions.effort`` never reaches the API wire). Wiring the pipeline is a
+separate, deliberately-deferred phase. This command makes ``/effort`` *exist + persist*
+faithfully and is forward-compatible when the pipeline lands.
+
+**Deliberate divergences (documented for parity review):**
+ * **No ``xhigh``/OpenAI-effort path, no ``CLAUDE_CODE_EFFORT_LEVEL`` env override, no
+ model-default resolver.** Python's effort domain (``settings.constants.VALID_EFFORT_VALUES``)
+ is ``""``/low/medium/high/max; the TS machinery for OpenAI/env/model-default has no
+ functional Python analog (``src/utils/effort.py`` is a separate, test-only enum). So
+ ``current`` reports plain ``"Effort level: auto"`` (no TS "(currently X)"), and the
+ valid-options/help text list no ``xhigh``.
+ * **Case-insensitive args** (``a.lower()``), slightly more lenient than TS (which is
+ case-sensitive for ``help``/``current``/``status``).
+ * **Levels come from ``VALID_EFFORT_VALUES``** (the single settings source of truth,
+ imported lazily — see :func:`_levels`), so the command and the settings validator never
+ drift. The live TUI ``EffortPickerScreen`` offers ``auto/low/medium/high`` (no ``max``)
+ — a pre-existing divergence, not reconciled here.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from src.config import set_effort
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+COMMON_HELP_ARGS = frozenset({"help", "-h", "--help"})
+
+# Verbatim from TS getEffortLevelDescription (utils/effort.ts:290-303). Used in the
+# outcome messages (NOT the help text, which has its own shorter blurbs — TS effort.tsx:179).
+_DESCRIPTIONS: dict[str, str] = {
+ "low": "Quick, straightforward implementation with minimal overhead",
+ "medium": "Balanced approach with standard implementation and testing",
+ "high": "Comprehensive implementation with extensive testing and documentation",
+ "max": "Maximum capability with deepest reasoning (Opus 4.6 only)",
+}
+
+# TS effort.tsx:213: picker auto-pick description when effort is undefined.
+_AUTO_PICKER_DESC = "Use default effort level for your model"
+
+# workflow-engine §4.1: `/effort ultracode` enables the session-long workflow
+# auto-orchestration mode (not a persisted effort level — Python's effort
+# pipeline is inert, so this contributes the orchestration mode only).
+_ULTRACODE_ON_MSG = (
+ "Ultracode on: I'll plan and run a workflow (fan out subagents + verify) for "
+ "substantive tasks this session, instead of working turn by turn. "
+ "Reset with /effort high."
+)
+
+
+def _valid_options_str() -> str:
+ base = "low, medium, high, max, auto"
+ from src.workflow.gating import is_workflows_enabled
+
+ return f"{base}, ultracode" if is_workflows_enabled() else base
+
+
+def _invalid_msg(raw: str) -> str:
+ return f"Invalid argument: {raw}. Valid options are: {_valid_options_str()}"
+
+# TS effort.tsx:179 help text, minus the xhigh line (no OpenAI-effort path in Python).
+_USAGE = (
+ "Usage: /effort [low|medium|high|max|auto]\n\n"
+ "Effort levels:\n"
+ "- low: Quick, straightforward implementation\n"
+ "- medium: Balanced approach with standard testing\n"
+ "- high: Comprehensive implementation with extensive testing\n"
+ "- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n"
+ "- auto: Use the default effort level for your model"
+)
+
+
+def _levels() -> tuple[str, ...]:
+ """The persistable effort levels (``low, medium, high, max``) — the single source
+ of truth ``VALID_EFFORT_VALUES`` minus the empty ``""`` (which is ``auto``).
+
+ Imported lazily so a bare ``import src.command_system`` does not pull the settings
+ stack at module-import time (the discipline used by ``app.py``/the advisor hook)."""
+ from src.settings.constants import VALID_EFFORT_VALUES
+
+ return tuple(v for v in VALID_EFFORT_VALUES if v)
+
+
+def _settings_effort() -> str:
+ """The persisted effort (``""`` when unset/auto), read via the same ``get_settings()``
+ channel the rest of the app uses. Lazy import — see :func:`_levels`."""
+ from src.settings.settings import get_settings
+
+ return get_settings().effort
+
+
+def _current_effort() -> str:
+ """Effort to pre-highlight in the picker — the persisted level, or ``"auto"``."""
+ return _settings_effort() or "auto"
+
+
+def _effort_options(current: str) -> list[UIOption]:
+ """Picker options: ``auto`` plus the persistable levels, marking the option equal to
+ ``current`` with ``description="current"`` (the same marker ``/theme`` uses). Labels
+ are the raw values."""
+ options: list[UIOption] = [
+ UIOption(
+ value="auto",
+ label="auto",
+ description="current" if current == "auto" else None,
+ )
+ ]
+ for level in _levels():
+ options.append(
+ UIOption(
+ value=level,
+ label=level,
+ description="current" if level == current else None,
+ )
+ )
+ # Ultracode (workflow-engine §4.1) — workflow auto-orchestration mode. Only in
+ # the menu when workflows are enabled (§4.8: removed when off).
+ from src.workflow.gating import is_workflows_enabled
+ from src.workflow.ultracode import is_ultracode_session
+
+ if is_workflows_enabled():
+ options.append(
+ UIOption(
+ value="ultracode",
+ label="ultracode",
+ description="on" if is_ultracode_session() else "workflow auto-orchestration",
+ )
+ )
+ return options
+
+
+def _show_current() -> str:
+ """TS ``showCurrentEffort`` (simplified — no env override, no model-default resolver)."""
+ eff = _settings_effort()
+ if not eff:
+ return "Effort level: auto"
+ return f"Current effort level: {eff} ({_DESCRIPTIONS.get(eff, '')})"
+
+
+@dataclass(frozen=True)
+class EffortCommand(InteractiveCommand):
+ """Set the reasoning-effort level and persist it.
+
+ Frozen + no new fields (the ``ThemeCommand``/``ExportCommand`` pattern); behavior
+ lives entirely in :meth:`run`.
+ """
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ raw = (args or "").strip()
+ a = raw.lower()
+
+ # 1. help (headless — TS effort.tsx:178-180).
+ if a in COMMON_HELP_ARGS:
+ return InteractiveOutcome(message=_USAGE, display="user")
+
+ # 2. current/status (headless — TS :182-183).
+ if a in ("current", "status"):
+ return InteractiveOutcome(message=_show_current(), display="user")
+
+ # 3. no args ⇒ picker (TS :185-186). The only path that needs a UI surface;
+ # on NullUIHost the select below raises -> engine returns a clean error.
+ if not a:
+ current = _current_effort()
+ picked = await context.ui.select(
+ "Set reasoning effort:", _effort_options(current), current=current
+ )
+ if picked is None: # TS handleCancel -> onDone('Cancelled') (no options -> user).
+ return InteractiveOutcome(message="Cancelled", display="user")
+ from src.workflow.ultracode import set_ultracode_session
+
+ if picked == "ultracode":
+ set_ultracode_session(True)
+ return InteractiveOutcome(message=_ULTRACODE_ON_MSG, display="user")
+ # Any real effort choice exits ultracode mode (spec: "reset with /effort high").
+ set_ultracode_session(False)
+ if picked == "auto":
+ set_effort(None)
+ # TS effort.tsx:213 (effort=undefined).
+ return InteractiveOutcome(
+ message=f"Set effort level to auto: {_AUTO_PICKER_DESC}",
+ display="user",
+ )
+ set_effort(picked)
+ return InteractiveOutcome(
+ message=f"Set effort level to {picked}: {_DESCRIPTIONS[picked]}",
+ display="user",
+ )
+
+ # 4. explicit arg (headless — TS executeEffort :108-123).
+ from src.workflow.ultracode import set_ultracode_session
+
+ if a == "ultracode":
+ # workflow-engine §4.1 / §4.8: only valid when workflows are enabled.
+ from src.workflow.gating import is_workflows_enabled
+
+ if not is_workflows_enabled():
+ return InteractiveOutcome(message=_invalid_msg(raw), display="user")
+ set_ultracode_session(True)
+ return InteractiveOutcome(message=_ULTRACODE_ON_MSG, display="user")
+ if a in ("auto", "unset"):
+ set_effort(None) # TS unsetEffortLevel.
+ set_ultracode_session(False)
+ return InteractiveOutcome(message="Effort level set to auto", display="user")
+ if a in _levels():
+ set_effort(a) # TS setEffortValue (env-override / session-only branches dropped).
+ set_ultracode_session(False) # selecting a real level exits ultracode
+ return InteractiveOutcome(
+ message=f"Set effort level to {a}: {_DESCRIPTIONS[a]}", display="user"
+ )
+ # TS :120-122 (minus xhigh). Use the trimmed original-case arg in the message.
+ return InteractiveOutcome(message=_invalid_msg(raw), display="user")
+
+
+EFFORT_COMMAND = EffortCommand(
+ name="effort",
+ description="Set effort level for model usage", # verbatim TS index.ts
+ argument_hint="[low|medium|high|max|auto]", # TS index.ts (minus xhigh)
+)
+
+
+__all__ = [
+ "EFFORT_COMMAND",
+ "EffortCommand",
+]
diff --git a/src/command_system/engine.py b/src/command_system/engine.py
index 6bc4d5573..ad603f30a 100644
--- a/src/command_system/engine.py
+++ b/src/command_system/engine.py
@@ -16,8 +16,12 @@
Command,
CommandContext,
CommandType,
+ InteractiveCommand,
+ InteractiveOutcome,
+ InteractiveUnavailableError,
LocalCommand,
LocalCommandResult,
+ NullUIHost,
PromptCommand,
)
@@ -143,6 +147,8 @@ async def execute(
result = await self._execute_local(command, args)
elif command.command_type == CommandType.PROMPT:
result = await self._execute_prompt(command, args)
+ elif command.command_type == CommandType.INTERACTIVE:
+ result = await self._execute_interactive(command, args)
else:
result = CommandResult.error(
command_name,
@@ -172,6 +178,18 @@ async def _execute_local(
return CommandResult.skip(command.name)
display_text = local_result.display_text or local_result.value
+ if local_result.type == "compact":
+ # C3b: preserve the compact result type so UI surfaces can
+ # render a boundary row (TS CompactBoundaryMessage) instead
+ # of a plain system line. text still carries the
+ # user_display_message for surfaces that don't special-case.
+ return CommandResult(
+ success=True,
+ command_name=command.name,
+ result_type="compact",
+ text=display_text,
+ display="system",
+ )
return CommandResult.success_text(
command.name,
display_text,
@@ -201,6 +219,58 @@ async def _execute_prompt(
str(e),
)
+ async def _execute_interactive(
+ self,
+ command: InteractiveCommand,
+ args: str,
+ ) -> CommandResult:
+ """Execute an interactive command (port of TS ``local-jsx``).
+
+ Runs the command body against ``ctx.ui`` and maps its
+ :class:`InteractiveOutcome` onto a ``CommandResult``, propagating the
+ ``display`` / ``should_query`` / ``meta_messages`` fields that the
+ LOCAL arm hardcodes away (``_execute_local`` forces ``system`` /
+ ``False`` / drops meta). A surface that wired no ``ui`` gets a
+ ``NullUIHost`` so the body can always assume ``ctx.ui`` exists; that
+ host raises :class:`InteractiveUnavailableError` for mutating prompts,
+ which we surface as a clean error result.
+ """
+ # Substitute the null surface when none was wired (SDK /
+ # non-interactive). Done here, once, so command bodies never see a
+ # ``None`` ui. Idempotent: a real surface sets ``ui`` at startup.
+ if self.context.ui is None:
+ self.context.ui = NullUIHost()
+
+ try:
+ outcome = await command.run(args, self.context)
+ except InteractiveUnavailableError as e:
+ # Expected on the null surface — a clean, typed message rather
+ # than a stack trace.
+ return CommandResult.error(command.name, str(e))
+ except Exception as e:
+ return CommandResult.error(command.name, str(e))
+
+ if not isinstance(outcome, InteractiveOutcome):
+ return CommandResult.error(
+ command.name,
+ f"interactive command returned {type(outcome).__name__}, "
+ "expected InteractiveOutcome",
+ )
+
+ # ``display == 'skip'`` (e.g. the cancelled path) → no output.
+ if outcome.display == "skip":
+ return CommandResult.skip(command.name)
+
+ return CommandResult(
+ success=True,
+ command_name=command.name,
+ result_type="text",
+ text=outcome.message or "",
+ should_query=outcome.should_query,
+ display=outcome.display,
+ meta_messages=list(outcome.meta_messages),
+ )
+
def add_command_hook(
self,
hook: Callable[[str, CommandResult], None],
@@ -224,6 +294,10 @@ def create_command_context(
history: Any = None,
cwd: str | Path | None = None,
config: dict[str, Any] | None = None,
+ app_state_store: Any = None,
+ provider: Any = None,
+ ui: Any = None,
+ tool_context: Any = None,
) -> CommandContext:
"""
Create a command context.
@@ -235,6 +309,17 @@ def create_command_context(
history: History log object
cwd: Current working directory (defaults to workspace_root)
config: Optional configuration dict
+ app_state_store: Optional reactive AppState store. Commands that
+ mutate global session state (e.g. /advisor, /permissions) need
+ this.
+ provider: Optional active LLM provider. Commands that gate on
+ provider type (e.g. /advisor) need this.
+ ui: Optional ``UIHost`` interaction port. Interactive commands drive
+ it; when None the engine substitutes a ``NullUIHost``.
+ tool_context: Optional ``ToolContext``. Threaded onto the context so a
+ ``SkillPromptCommand`` can render via the same ``_run_markdown_skill``
+ path the Skill tool uses (session id + gated shell-exec). When None,
+ skill prompts degrade to a headless render.
Returns:
CommandContext instance
@@ -249,4 +334,8 @@ def create_command_context(
cost_tracker=cost_tracker,
history=history,
config=config or {},
+ app_state_store=app_state_store,
+ provider=provider,
+ ui=ui,
+ tool_context=tool_context,
)
diff --git a/src/command_system/export_command.py b/src/command_system/export_command.py
new file mode 100644
index 000000000..604c4685b
--- /dev/null
+++ b/src/command_system/export_command.py
@@ -0,0 +1,269 @@
+"""export — interactive ``/export`` command (port of TS local-jsx).
+
+Port of ``typescript/src/commands/export/`` (``export.tsx`` + ``index.ts``). A
+``local-jsx`` command becomes an :class:`InteractiveCommand` (blocked remotely by
+type), with two paths mirroring the TS command:
+
+ * **Args path** (``/export ``): render + write the file *headlessly*,
+ never touching ``ctx.ui`` — so it works on the SDK / ``NullUIHost`` surface
+ where ``select`` would raise (the headless keystone, like /output-style's
+ text result).
+ * **Wizard path** (no args): drive ``ctx.ui`` — ``select`` the format, then
+ ``prompt_text`` the filename. So one consumer exercises *both* bridge
+ primitives.
+
+Deliberate divergences from TS (documented for parity review):
+
+ * **Clipboard deferred** (plan §4.5). TS's wizard offers a "method" step
+ (clipboard vs file); a faithful clipboard write needs ``osc.ts`` ported,
+ which is out of scope this phase. The wizard ships file-only, so the
+ one-option "method" select is dropped (dead UI) and returns when clipboard
+ lands as its own PR. The command ``description`` keeps TS's "to a file or
+ clipboard" verbatim so it doesn't churn when that follow-up lands.
+ * **Esc cancels the whole export.** TS ``ExportDialog`` Esc navigates to the
+ previous step; the linear ``await select``/``await prompt_text`` shape can't
+ express back-navigation, so Esc at any step cancels the export →
+ :meth:`InteractiveOutcome.skip` (the /permissions cancel convention). TS
+ shows an "Export cancelled" toast at the format step; the skip path is
+ silent. Acceptable — the steps are cheap to re-invoke.
+"""
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Optional
+
+from src.utils.export_formats import (
+ ExportFormat,
+ ensure_export_filename_extension,
+ infer_export_format_from_filename,
+ parse_export_args,
+ resolve_export_filepath,
+)
+from src.utils.export_renderer import (
+ extract_message_content,
+ is_text_block,
+ render_messages_for_export,
+)
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+# Wizard format options — labels/descriptions mirror ExportDialog.tsx:136-148.
+_FORMAT_OPTIONS: list[UIOption] = [
+ UIOption(
+ value="text",
+ label="Plain Text (.txt)",
+ description="Plain text format",
+ ),
+ UIOption(
+ value="markdown",
+ label="Markdown (.md)",
+ description="Markdown format for readable archives",
+ ),
+ UIOption(
+ value="json",
+ label="JSON (.json)",
+ description="Structured JSON for programmatic use",
+ ),
+]
+
+
+def format_timestamp(date: datetime) -> str:
+ """``YYYY-MM-DD-HHMMSS`` in local time (port of export.tsx:11-19)."""
+ return date.strftime("%Y-%m-%d-%H%M%S")
+
+
+def _message_type(msg: Any) -> Any:
+ if isinstance(msg, Mapping):
+ return msg.get("type")
+ return getattr(msg, "type", None)
+
+
+def _block_text(block: Any) -> str:
+ if isinstance(block, Mapping):
+ return block.get("text") or ""
+ return getattr(block, "text", "") or ""
+
+
+def extract_first_prompt(messages: Any) -> str:
+ """First user message's first line, truncated to 50 chars (export.tsx:20-42).
+
+ Mirrors TS ``msg.message?.content`` reads via the renderer's
+ :func:`extract_message_content` accessor so flat dataclasses and wire-shaped
+ dicts both resolve.
+ """
+ first_user = None
+ for msg in messages or []:
+ if _message_type(msg) == "user":
+ first_user = msg
+ break
+ if first_user is None:
+ return ""
+
+ content = extract_message_content(first_user)
+ result = ""
+ if isinstance(content, str):
+ result = content.strip()
+ elif isinstance(content, list):
+ # TS uses ``find(b => b.type === 'text')`` and reads ``.text`` off the
+ # match; a malformed first text block (no ``text`` key) yields ``''``.
+ # ``is_text_block`` additionally requires a string ``text``, so a
+ # malformed block is skipped and scanning continues — a negligible
+ # divergence (TextBlock.text is typed ``str = ""``, so unreachable in
+ # practice) that fails no worse than TS.
+ for item in content:
+ if is_text_block(item):
+ result = _block_text(item).strip()
+ break
+
+ result = result.split("\n")[0]
+ if len(result) > 50:
+ result = result[:49] + "…"
+ return result
+
+
+def sanitize_filename(text: str) -> str:
+ """Lowercase + hyphenate into a filesystem-safe slug (export.tsx:43-49)."""
+ s = text.lower()
+ s = re.sub(r"[^a-z0-9\s-]", "", s) # drop special chars
+ s = re.sub(r"\s+", "-", s) # spaces -> hyphens
+ s = re.sub(r"-+", "-", s) # collapse repeated hyphens
+ s = re.sub(r"^-|-$", "", s) # trim leading/trailing hyphens
+ return s
+
+
+def _default_filename(messages: Any) -> str:
+ """``-.txt`` or ``conversation-.txt`` (export.tsx:85-91)."""
+ first_prompt = extract_first_prompt(messages)
+ timestamp = format_timestamp(datetime.now())
+ sanitized = sanitize_filename(first_prompt) if first_prompt else ""
+ if sanitized:
+ return f"{timestamp}-{sanitized}.txt"
+ return f"conversation-{timestamp}.txt"
+
+
+@dataclass(frozen=True)
+class ExportCommand(InteractiveCommand):
+ """Render the conversation and write it to a file.
+
+ Args path is headless (never reaches ``ctx.ui``); the no-args path drives
+ the file-only wizard. Frozen + no new fields (the ``StatuslineCommand``
+ pattern); behavior lives entirely in :meth:`run`.
+ """
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ parsed = parse_export_args(args)
+ if parsed.error:
+ return InteractiveOutcome(message=parsed.error, display="system")
+
+ # Resolve the conversation with the existing no-conversation idiom
+ # (builtins.py:381). Covers a None conversation too — hasattr(None, …)
+ # is False — so SDK/listing callers degrade gracefully instead of
+ # raising.
+ conversation = context.conversation
+ if not hasattr(conversation, "messages"):
+ return InteractiveOutcome(
+ message="No conversation to export.", display="system"
+ )
+ messages = conversation.messages
+
+ # Format: --format flag > filename extension > text default
+ # (export.tsx:60-63).
+ fmt: Optional[ExportFormat] = parsed.format
+ if fmt is None and parsed.filename:
+ fmt = infer_export_format_from_filename(parsed.filename)
+ if fmt is None:
+ fmt = "text"
+
+ cwd = str(context.cwd or context.workspace_root)
+
+ # --- Args path: headless render + write, never touches ctx.ui. ---
+ if parsed.filename:
+ # TS preserves a ``.markdown`` extension only when the user did not
+ # pass an explicit --format flag (export.tsx:69-71).
+ return self._write_export(
+ messages,
+ fmt,
+ parsed.filename,
+ cwd,
+ preserve_markdown_extension=parsed.format is None,
+ )
+
+ # --- Wizard path: select format, then prompt for filename. ---
+ picked = await context.ui.select(
+ "Select export format:", _FORMAT_OPTIONS, current=fmt
+ )
+ if picked is None:
+ return InteractiveOutcome.skip() # Esc -> cancel the whole export.
+ chosen: ExportFormat = picked # type: ignore[assignment]
+
+ # Default filename carries the chosen format's extension (ExportDialog
+ # recomputes it when the format changes, ExportDialog.tsx:37-66).
+ default_name = ensure_export_filename_extension(
+ _default_filename(messages), chosen, preserve_markdown_extension=True
+ )
+ name = await context.ui.prompt_text("Enter filename:", default=default_name)
+ if name is None:
+ return InteractiveOutcome.skip()
+
+ # The wizard submit always preserves a ``.markdown`` extension
+ # (ExportDialog.tsx:99-101).
+ return self._write_export(
+ messages, chosen, name, cwd, preserve_markdown_extension=True
+ )
+
+ @staticmethod
+ def _write_export(
+ messages: Any,
+ fmt: ExportFormat,
+ filename: str,
+ cwd: str,
+ *,
+ preserve_markdown_extension: bool,
+ ) -> InteractiveOutcome:
+ """Render + write the export; never raises (export.tsx:67-82,
+ ExportDialog.tsx:95-121 both wrap the write in a try/catch and report a
+ failure message rather than throwing out of the command)."""
+ try:
+ content = render_messages_for_export(messages, format=fmt)
+ final_filename = ensure_export_filename_extension(
+ filename,
+ fmt,
+ preserve_markdown_extension=preserve_markdown_extension,
+ )
+ filepath = resolve_export_filepath(cwd, final_filename)
+ with open(filepath, "w", encoding="utf-8") as fh:
+ fh.write(content)
+ return InteractiveOutcome(
+ message=f"Conversation exported to: {filepath}", display="system"
+ )
+ except Exception as exc: # mirror TS catch-all (export.tsx:79)
+ return InteractiveOutcome(
+ message=f"Failed to export conversation: {exc}", display="system"
+ )
+
+
+EXPORT_COMMAND = ExportCommand(
+ name="export",
+ # Verbatim from index.ts. Mentions clipboard, which is deferred this phase
+ # (plan §4.5); kept as-is so the description doesn't churn when the
+ # clipboard follow-up reintroduces that delivery method.
+ description="Export the current conversation to a file or clipboard",
+ argument_hint="[filename]",
+)
+
+
+__all__ = [
+ "EXPORT_COMMAND",
+ "ExportCommand",
+ "extract_first_prompt",
+ "format_timestamp",
+ "sanitize_filename",
+]
diff --git a/src/command_system/input_processing.py b/src/command_system/input_processing.py
index 488a7cbd3..c0a43723b 100644
--- a/src/command_system/input_processing.py
+++ b/src/command_system/input_processing.py
@@ -11,6 +11,7 @@
from __future__ import annotations
+import base64 as _base64
import os
import re
from dataclasses import dataclass, field
@@ -46,7 +47,12 @@
r"https?://[^\s<>\"'`\[\](){}]+",
re.IGNORECASE,
)
-_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
+# Extensions ``_extract_image_paths`` treats as images for ``ParsedInput.
+# image_paths``. Aligned with the Read tool's ``IMAGE_EXTENSIONS`` so the
+# parse-time hint and the @-mention expansion agree on what counts as an
+# image. SVG and BMP are intentionally excluded: the API doesn't accept
+# SVG, and Pillow's BMP path isn't part of the parity surface.
+_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_ESCAPE_RE = re.compile(r"^\\(/)") # Escaped slash
@@ -160,24 +166,343 @@ def _extract_urls(text: str) -> list[str]:
_MAX_DIR_ENTRIES = 1000
+# Image extensions handled as inline image content blocks (not text). Must
+# match ``IMAGE_EXTENSIONS`` in ``src/tool_system/tools/read.py`` so the
+# @-mention pipeline and the Read tool agree on what counts as an image.
+_AT_MENTION_IMAGE_EXTENSIONS = frozenset({"png", "jpg", "jpeg", "gif", "webp"})
+
+# Extensions known to be binary. Opening one in text mode with
+# ``errors="replace"`` would emit a wall of utf-8 replacement chars that
+# the model latches onto and hallucinates from (the exact failure mode the
+# image branch was added to prevent). We skip the read and emit a small
+# system-reminder pointing the model at the Read tool instead. PDFs are
+# the user-visible case (Read tool supports them via ``pages``); the rest
+# are defense in depth so a stray @archive.zip doesn't pollute the prompt
+# either.
+_AT_MENTION_BINARY_EXTENSIONS = frozenset({
+ "pdf",
+ "zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar",
+ "exe", "dll", "so", "dylib", "a", "o",
+ "bin", "iso", "dmg",
+ "docx", "xlsx", "pptx", "doc", "xls", "ppt",
+ "mp3", "wav", "flac", "ogg", "m4a",
+ "mp4", "mov", "avi", "mkv", "webm",
+ "ttf", "otf", "woff", "woff2",
+ "class", "jar", "war",
+ "pyc", "pyo",
+ "sqlite", "db",
+})
+
+# Heuristic chunk used by ``_looks_like_binary``: enough bytes to spot a
+# NUL byte in plausible binary headers without paying the full read cost
+# for huge files.
+_BINARY_SNIFF_BYTES = 8192
+
+
+# BOM signatures we know how to decode as text. Order matters: UTF-32
+# BOMs must be checked BEFORE the matching UTF-16 BOMs (UTF-32-LE BOM
+# `\xff\xfe\x00\x00` starts with the UTF-16-LE BOM `\xff\xfe`).
+#
+# Codec choice: use the BOM-aware codecs (``utf-16``, ``utf-32``) rather
+# than the explicit-byte-order forms (``utf-16-le``, ``utf-16-be``). The
+# latter PRESERVE the BOM as a literal ```` codepoint at the start
+# of the string; the BOM-aware codecs consume it. We picked the encoding
+# by reading the BOM, so feeding the file to a codec that re-reads it is
+# both correct and strips the otherwise-leaking zero-width-no-break-space.
+_BOM_ENCODINGS: tuple[tuple[bytes, str], ...] = (
+ (b"\xff\xfe\x00\x00", "utf-32"),
+ (b"\x00\x00\xfe\xff", "utf-32"),
+ (b"\xff\xfe", "utf-16"),
+ (b"\xfe\xff", "utf-16"),
+ (b"\xef\xbb\xbf", "utf-8-sig"),
+)
+
+
+def _detect_bom_encoding(head: bytes) -> str | None:
+ """Return the Python codec name implied by a BOM prefix, or None."""
+ for sig, enc in _BOM_ENCODINGS:
+ if head.startswith(sig):
+ return enc
+ return None
+
+
+def _looks_like_binary(path: str) -> bool:
+ """Return True if the first ``_BINARY_SNIFF_BYTES`` bytes contain a NUL
+ AND the file is not BOM-prefixed text.
+
+ Defense-in-depth check for files whose extension isn't in
+ ``_AT_MENTION_BINARY_EXTENSIONS`` — e.g. a misnamed ``.txt`` that's
+ actually a tarball, or any extension we haven't enumerated. NUL bytes
+ do not appear in well-formed utf-8 text, so their presence in a sniff
+ window is a high-precision signal.
+
+ Exception: UTF-16 / UTF-32 text files contain NUL bytes for every
+ ASCII char and would trip the naive NUL sniffer. A BOM prefix
+ (``\\xff\\xfe`` LE / ``\\xfe\\xff`` BE / UTF-32 variants) tells us
+ the file is real text that just needs a different decoder; we return
+ False so the text-read branch can decode it properly via
+ ``_read_text_with_encoding``. Without that paired fix this BOM
+ short-circuit would let UTF-16 mojibake flow into a system-reminder
+ (the exact failure mode the binary branch was added to prevent).
+
+ Conservative on read errors (treats unreadable files as "not binary"
+ so the existing text-read path can surface the OSError its caller
+ already handles).
+ """
+ try:
+ with open(path, "rb") as fh:
+ chunk = fh.read(_BINARY_SNIFF_BYTES)
+ except OSError:
+ return False
+ if _detect_bom_encoding(chunk) is not None:
+ # Encoded text — handled by the text branch with the right codec.
+ return False
+ return b"\x00" in chunk
+
+
+# If a decoded file has more than this fraction of U+FFFD replacement
+# chars, treat it as garbage rather than text. A real UTF-16 / UTF-8
+# text file produces well under 1% replacements even with `errors=replace`;
+# a binary blob with a fake BOM that doesn't pair to valid UTF-16
+# codepoints produces 10-50%+. Threshold picked to be generously high
+# so a UTF-16 file with a few corrupt bytes still ships its text, but
+# an adversarial binary blob with a spoofed BOM doesn't leak mojibake
+# into the prompt.
+_MAX_REPLACEMENT_CHAR_FRACTION = 0.03
+
+
+def _decoded_text_looks_garbled(decoded: str) -> bool:
+ """Return True if the decoded string looks like binary garbage rather
+ than legitimate text. Two heuristics:
+
+ 1. **NUL char presence (zero-tolerance).** A real text file has zero
+ U+0000 characters. If we decoded any, the source bytes contained
+ NULs that the codec mapped to U+0000 — i.e. binary garbage that
+ happened to align with a valid UTF-16 NUL pair. Letting these
+ through would re-introduce Bug A's NUL-in-system-reminder
+ failure: when the str round-trips through utf-8 encoding for the
+ API, those U+0000 chars become literal NUL bytes in the prompt.
+
+ 2. **U+FFFD fraction (high-confidence garbage signal).** Above
+ ``_MAX_REPLACEMENT_CHAR_FRACTION`` the file isn't the encoding
+ the BOM claimed.
+
+ Residual cases the heuristic does NOT catch: a binary blob with a
+ spoofed BOM whose every 2-byte pair decodes to a valid non-NUL
+ Unicode codepoint (e.g. all bytes in a printable plane). These
+ appear as walls of CJK or symbol characters — not Bug A's failure
+ family (no NULs, no replacement chars), and the model lacks
+ recognizable ASCII fragments to hallucinate from. Accepting this
+ as residual risk; a more aggressive detector would false-positive
+ on legitimate non-Latin-script text files.
+ """
+ if not decoded:
+ return False
+ if "\x00" in decoded:
+ return True
+ replacement_count = decoded.count("�")
+ if replacement_count == 0:
+ return False
+ return (replacement_count / len(decoded)) > _MAX_REPLACEMENT_CHAR_FRACTION
+
+
+def _read_text_with_encoding(path: str) -> str | None:
+ """Read ``path`` as text, picking a decoder from any leading BOM.
+
+ Falls back to utf-8 with ``errors="replace"`` for the common case
+ (no BOM). When a BOM is present we use the codec it implies (with
+ ``errors="replace"`` so a malformed trailing byte doesn't bring
+ down the whole read).
+
+ Post-decode garbage check: if the decoded text is mostly U+FFFD
+ replacement characters, returns ``None`` so the caller drops the
+ attachment instead of inlining mojibake. This closes the adversarial
+ case where a binary blob starts with `\\xff\\xfe` (fake UTF-16 BOM)
+ and an extension we haven't enumerated — without the check, the
+ BOM short-circuit would route the blob to the text branch and the
+ `errors="replace"` decode would land replacement-char mojibake in
+ the prompt (same failure family as Bug A even though no NULs leak).
+
+ Returns ``None`` on OSError or on garbled-decode so the caller
+ matches the existing "drop this attachment silently" behaviour for
+ unreadable files. Callers should still emit a binary-style reminder
+ via the @-mention `binary` attachment kind when this returns None
+ for a non-OSError reason — see ``expand_at_mentions``.
+ """
+ try:
+ with open(path, "rb") as fh:
+ head = fh.read(4)
+ except OSError:
+ return None
+ encoding = _detect_bom_encoding(head) or "utf-8"
+ try:
+ with open(path, "r", encoding=encoding, errors="replace") as fh:
+ data = fh.read()
+ except OSError:
+ return None
+ except (LookupError, UnicodeError):
+ # Pathological codec name from a future BOM check, or a UTF-32
+ # decode failure with errors=replace (rare but theoretically
+ # possible). Fall back to a raw utf-8 replacement read so the
+ # path still produces *some* text rather than a silent drop.
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ data = fh.read()
+ except OSError:
+ return None
+ if _decoded_text_looks_garbled(data):
+ return None
+ return data
+
+
+def _binary_hint_for_ext(ext: str) -> str:
+ """Return a per-extension hint the model can act on, or a generic
+ fallback. PDF specifically points at the Read tool's ``pages``
+ parameter, mirroring TS's behaviour where PDFs are surfaced through
+ Read rather than auto-inlined via @-mention."""
+ if ext == "pdf":
+ return (
+ "PDFs cannot be inlined as @-mention text. Use the Read tool "
+ "with the ``pages`` parameter (e.g. ``pages=\"1-5\"``) to view "
+ "specific page ranges."
+ )
+ return (
+ "This file is binary and cannot be inlined as text. Use the Read "
+ "tool to view it if it is a supported format."
+ )
+
+
+def _try_build_image_attachment(
+ expanded: str,
+ display_path: str,
+) -> dict[str, Any] | None:
+ """Try to build an ``image`` attachment from a file path.
+
+ Returns ``None`` if the file is empty, unreadable, or Pillow can't
+ decode it. On ``None`` the caller drops the attachment silently: the
+ user still sees the path in their prompt text, and the model can call
+ ``Read`` explicitly to surface a real error.
+
+ Mirrors the Read tool's image branch (``src/tool_system/tools/read.py``):
+ bounded byte read, magic-byte format sniff, resize to the 3.75 MB /
+ 1568 px envelope, return base64 + media_type. Using the same pipeline
+ means a misnamed ``.png`` containing JPEG bytes gets
+ ``media_type=image/jpeg`` the same way the Read tool reports it.
+ """
+ try:
+ st = os.stat(expanded)
+ except OSError:
+ return None
+ if st.st_size == 0:
+ return None
+
+ # Lazy import keeps this module's import cheap when the user never
+ # uses image @-mentions (Pillow takes ~25 ms to import).
+ from ..utils.image_processor import (
+ API_IMAGE_MAX_BASE64_SIZE,
+ IMAGE_READ_SAFETY_CAP,
+ ImageProcessingError,
+ compress_image_to_token_budget,
+ detect_image_format_from_buffer,
+ estimate_image_tokens_from_base64_length,
+ maybe_resize_image,
+ read_file_bytes,
+ )
+
+ try:
+ buf = read_file_bytes(Path(expanded), IMAGE_READ_SAFETY_CAP)
+ except OSError:
+ return None
+ if not buf:
+ return None
+
+ detected_media = detect_image_format_from_buffer(buf)
+ try:
+ result = maybe_resize_image(buf, st.st_size, format_hint=detected_media)
+ except ImageProcessingError:
+ # Pillow couldn't decode: bytes aren't a real image despite the
+ # extension. Skip rather than ship corrupt bytes the API would
+ # reject.
+ return None
+
+ # Token-budget fallback. ``maybe_resize_image`` may return a still-
+ # oversize buffer when even q=20 JPEG at 1568px doesn't fit the 3.75
+ # MB envelope (see image_processor.maybe_resize_image:331-350). The
+ # Read tool runs a final ``compress_image_to_token_budget`` step from
+ # the ORIGINAL buffer to force the payload under the API's 5 MB
+ # base64 cap (read.py:506-518). Without that step, oversize image
+ # @-mentions land a too-large base64 on the conversation and the
+ # Anthropic provider rejects the entire turn via
+ # ``validate_images_for_api``.
+ base64_len = ((len(result.data) + 2) // 3) * 4
+ if base64_len > API_IMAGE_MAX_BASE64_SIZE:
+ # Pick the same token budget the Read tool would use so we
+ # produce identically-sized payloads either way.
+ max_tokens = _get_read_max_output_tokens()
+ try:
+ compressed = compress_image_to_token_budget(buf, max_tokens, detected_media)
+ # Only adopt the compressed result if it actually fits under
+ # the API limit. If both attempts are oversize, drop the
+ # attachment rather than ship a payload the API will reject
+ # at the next turn -- the user's path is still in the text
+ # prompt and they can call Read explicitly for an error.
+ comp_b64_len = ((len(compressed.data) + 2) // 3) * 4
+ if comp_b64_len <= API_IMAGE_MAX_BASE64_SIZE:
+ result = compressed
+ else:
+ return None
+ except ImageProcessingError:
+ return None
+
+ b64 = _base64.b64encode(result.data).decode("ascii")
+ return {
+ "kind": "image",
+ "path": expanded,
+ "display_path": display_path,
+ "base64": b64,
+ "media_type": result.media_type,
+ }
+
+
+def _get_read_max_output_tokens() -> int:
+ """Return the Read tool's max-output-tokens budget.
+
+ Centralised so the @-mention image branch and the Read tool agree on
+ the compression target. Mirrors ``_get_max_output_tokens`` in
+ ``src/tool_system/tools/read.py``.
+ """
+ override = os.environ.get("CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS")
+ if override:
+ try:
+ val = int(override)
+ if val > 0:
+ return val
+ except ValueError:
+ pass
+ return 25_000
+
def expand_at_mentions(
text: str,
*,
cwd: str | None = None,
-) -> tuple[str, list[dict[str, str]]]:
+) -> tuple[str, list[dict[str, Any]]]:
"""Resolve ``@path`` mentions and build context attachments.
Mirrors ``processAtMentionedFiles`` in
``typescript/src/utils/attachments.ts``: if a mention resolves to a
directory we build a ``Listed directory`` attachment containing its
- entries (up to 1000); if it resolves to a readable file we attach the
+ entries (up to 1000); if it resolves to a readable image file
+ (png/jpg/jpeg/gif/webp) we attach a ``kind="image"`` attachment with
+ base64 + media_type via the image pipeline, so the REPL can inline it
+ as a real image content block in the user message instead of mojibake
+ in a system-reminder. Other readable files we attach the
file's contents verbatim. The returned ``text`` is left unchanged — the
caller prepends / appends the attachments before sending to the model.
"""
cwd = cwd or os.getcwd()
seen: set[str] = set()
- attachments: list[dict[str, str]] = []
+ attachments: list[dict[str, Any]] = []
for match in _FILE_MENTION_RE.finditer(text):
raw = match.group(1).rstrip(".,!?:;\"'`)]}")
@@ -191,6 +516,17 @@ def expand_at_mentions(
):
continue
+ # C5: the search dialog inserts ``@file#Lline`` (TS
+ # attachments.ts:2859-2861 parses the ``#L10-20`` fragment).
+ # Degraded port: strip the fragment so the FILE attaches — the
+ # line reference stays visible to the model in the prompt text;
+ # range slicing is a noted follow-up.
+ fragment_match = re.search(r"#L\d+(?:-\d+)?$", raw)
+ if fragment_match:
+ raw = raw[: fragment_match.start()]
+ if not raw:
+ continue
+
expanded = os.path.expanduser(raw)
if not os.path.isabs(expanded):
expanded = os.path.abspath(os.path.join(cwd, expanded))
@@ -217,12 +553,61 @@ def expand_at_mentions(
}
)
elif os.path.isfile(expanded):
- try:
- with open(expanded, "r", encoding="utf-8", errors="replace") as fh:
- data = fh.read()
- except OSError:
- continue
display_path = os.path.relpath(expanded, cwd)
+ # Image branch FIRST -- opening a PNG/JPEG in text mode
+ # produces mojibake (utf-8 replacement chars over the binary
+ # bytes), and the old wrapper shipped that garbage to the
+ # model inside a ``Contents of foo.png:``
+ # block. The model would latch onto any ASCII fragments
+ # (XMP metadata, type tags) and hallucinate the rest.
+ ext = os.path.splitext(expanded)[1].lstrip(".").lower()
+ if ext in _AT_MENTION_IMAGE_EXTENSIONS:
+ img_att = _try_build_image_attachment(expanded, display_path)
+ if img_att is not None:
+ attachments.append(img_att)
+ # Image read failed (empty, undecodable, ...): drop the
+ # attachment silently. The user's prompt text still
+ # contains the path, so the model can call ``Read``
+ # explicitly to see a real error.
+ continue
+ # Binary branch: known binary extensions (PDF, archives, ...)
+ # OR a content sniff that finds a NUL byte. Same Bug A
+ # pattern as the image branch -- opening a binary file in
+ # text mode produces mojibake the model hallucinates from.
+ # We emit a small reminder pointing at the Read tool
+ # instead. PDF gets a more specific hint because Read
+ # supports it via the ``pages`` parameter; everything else
+ # gets a generic message.
+ if ext in _AT_MENTION_BINARY_EXTENSIONS or _looks_like_binary(expanded):
+ attachments.append(
+ {
+ "kind": "binary",
+ "path": expanded,
+ "display_path": display_path,
+ "ext": ext,
+ "hint": _binary_hint_for_ext(ext),
+ }
+ )
+ continue
+ data = _read_text_with_encoding(expanded)
+ if data is None:
+ # ``_read_text_with_encoding`` returns None on either
+ # OSError OR when the decoded text was mostly U+FFFD
+ # replacement chars (a binary blob with a spoofed
+ # BOM, or a file in an encoding we can't auto-detect).
+ # Emit a binary attachment instead of dropping
+ # silently so the user sees what happened and the
+ # model gets a Read-tool nudge.
+ attachments.append(
+ {
+ "kind": "binary",
+ "path": expanded,
+ "display_path": display_path,
+ "ext": ext,
+ "hint": _binary_hint_for_ext(ext),
+ }
+ )
+ continue
attachments.append(
{
"kind": "file",
@@ -237,10 +622,17 @@ def expand_at_mentions(
return text, attachments
-def format_at_mention_attachments(attachments: list[dict[str, str]]) -> str:
+def format_at_mention_attachments(attachments: list[dict[str, Any]]) -> str:
"""Render attachments produced by :func:`expand_at_mentions` and
:func:`expand_agent_mentions` as a single string ready to be prepended to
the user message. Empty input returns ``""``.
+
+ Image attachments (``kind="image"``) are intentionally skipped here:
+ they carry binary base64 data that must reach the model as an actual
+ image content block (via :func:`build_image_content_blocks`), not as
+ text inside a ````. Rendering them as text would
+ reintroduce the mojibake/hallucination failure mode this module was
+ rewritten to prevent.
"""
if not attachments:
return ""
@@ -261,6 +653,19 @@ def format_at_mention_attachments(attachments: list[dict[str, str]]) -> str:
f"```\n{att['content']}\n```\n"
f" "
)
+ elif kind == "image":
+ # See docstring -- handled separately as a content block.
+ continue
+ elif kind == "binary":
+ # PDFs / archives / docx / ... — emit a short reminder pointing
+ # the model at the Read tool so the @-mention is acknowledged
+ # without flooding the prompt with mojibake.
+ blocks.append(
+ f"\n"
+ f"@-mentioned file {att['display_path']} is a binary file "
+ f"and was not inlined. {att.get('hint', '')}\n"
+ f" "
+ )
elif kind == "agent_mention":
# Mirrors ``typescript/src/utils/messages.ts`` ``agent_mention``
# case: the reminder nudges the model to delegate to the named
@@ -276,6 +681,37 @@ def format_at_mention_attachments(attachments: list[dict[str, str]]) -> str:
return "\n\n".join(blocks)
+def build_image_content_blocks(
+ attachments: list[dict[str, Any]],
+) -> list["ImageBlock"]:
+ """Build ``ImageBlock`` instances from ``kind="image"`` attachments.
+
+ The REPL appends these after the text portion of the user message so
+ the API receives a mixed text+image content list, matching the TS
+ @-mention flow which auto-Reads the image and inlines it.
+ """
+ from ..types.content_blocks import ImageBlock
+
+ blocks: list[ImageBlock] = []
+ for att in attachments:
+ if att.get("kind") != "image":
+ continue
+ b64 = att.get("base64")
+ media_type = att.get("media_type")
+ if not b64 or not media_type:
+ continue
+ blocks.append(
+ ImageBlock(
+ source={
+ "type": "base64",
+ "media_type": media_type,
+ "data": b64,
+ },
+ )
+ )
+ return blocks
+
+
def expand_agent_mentions(
text: str,
agents: list[Any] | None,
diff --git a/src/command_system/logo_command.py b/src/command_system/logo_command.py
new file mode 100644
index 000000000..44494f1d6
--- /dev/null
+++ b/src/command_system/logo_command.py
@@ -0,0 +1,101 @@
+"""logo — interactive ``/logo`` command (port of TS local-jsx).
+
+Port of ``typescript/src/commands/logo/`` (``logo.tsx`` + ``index.ts``). Picks the
+startup-banner color palette and persists it to the global config ``logoColor`` key;
+the two startup banners (REPL + TUI) resolve and render it on next launch.
+
+Coexistence: **fall-through** (the ``/export`` pattern, NOT the ``/theme``/``/effort``/
+``/model`` inversion). There is **no TUI dialog** for ``/logo`` (it is not in the TUI's
+``LOCAL_BUILTINS``/``open_dialog`` map), so the dispatch falls through and this
+``InteractiveCommand`` serves every surface via the ``UIHost`` port — TUI
+(``TextualUIHost.select``), REPL (``ReplUIHost.select``), SDK (``NullUIHost`` → clean
+error), and the help/aggregator listings.
+
+Faithfulness to TS (``logo.tsx``):
+ * ``call(onDone, _context)`` **ignores args** — the picker is the only path; no
+ headless keystone (on ``NullUIHost`` the ``select`` raises → engine clean error).
+ * **Success → ``display="user"``** (TS ``onDone("Startup logo set to …")`` with no
+ options → model-visible ``createUserMessage``).
+ * **Cancel → "Logo picker dismissed" / ``display="system"``** (verbatim TS).
+
+Deliberate divergences (documented for parity review):
+ * **No swatch preview** in the picker — TS ``LogoPicker`` shows an ANSI gradient
+ swatch per option, but the ``select`` primitive has plain labels. Labels are the
+ friendly ``LOGO_PALETTE_LABELS`` (TS shows labels too).
+ * **Static description** ("Change the startup logo color scheme"); TS's is dynamic
+ ``…(current: {label})`` — a frozen ``CommandBase.description`` can't be a getter.
+ The current palette is the picker's ``current=`` marker.
+ * **Persist via config ``logoColor``** (top-level key, like ``/theme``).
+
+``logo_palettes`` / ``set_logo_color`` are imported lazily (the ``theme``/``app.py``
+discipline).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+
+def _current_logo() -> str:
+ """The persisted palette name, or the default — mirrors TS index.ts:13-14 /
+ logo.tsx:20-23 (validate via ``is_logo_palette_name``)."""
+ from src.config import load_config
+ from src.utils.logo_palettes import DEFAULT_LOGO_PALETTE, is_logo_palette_name
+
+ current = load_config().get("logoColor")
+ return current if is_logo_palette_name(current) else DEFAULT_LOGO_PALETTE
+
+
+def _logo_options(current: str) -> list[UIOption]:
+ """Picker options: friendly labels, marking the current palette. Lazy import."""
+ from src.utils.logo_palettes import LOGO_PALETTE_LABELS, LOGO_PALETTE_NAMES
+
+ return [
+ UIOption(
+ value=name,
+ label=LOGO_PALETTE_LABELS[name],
+ description="current" if name == current else None,
+ )
+ for name in LOGO_PALETTE_NAMES
+ ]
+
+
+@dataclass(frozen=True)
+class LogoCommand(InteractiveCommand):
+ """Pick the startup logo color palette and persist it. Frozen + no new fields
+ (the ``ThemeCommand`` pattern); behavior lives in :meth:`run`."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ # TS call ignores args — the picker is the only path. No headless keystone;
+ # on NullUIHost the select below raises -> the engine returns a clean error.
+ from src.config import set_logo_color
+ from src.utils.logo_palettes import LOGO_PALETTE_LABELS
+
+ current = _current_logo()
+ picked = await context.ui.select(
+ "Select startup logo color:", _logo_options(current), current=current
+ )
+ if picked is None:
+ # TS cancel: onDone("Logo picker dismissed", {display:"system"}).
+ return InteractiveOutcome(message="Logo picker dismissed", display="system")
+ set_logo_color(picked) # persist (TS saveGlobalConfig logoColor)
+ # TS success: onDone("Startup logo set to …") with NO options => model-visible.
+ return InteractiveOutcome(
+ message=f"Startup logo set to {LOGO_PALETTE_LABELS[picked]}. Visible on next launch.",
+ display="user",
+ )
+
+
+LOGO_COMMAND = LogoCommand(
+ name="logo",
+ description="Change the startup logo color scheme", # static (TS dynamic — see docstring)
+)
+
+
+__all__ = ["LOGO_COMMAND", "LogoCommand"]
diff --git a/src/command_system/mcp_command.py b/src/command_system/mcp_command.py
new file mode 100644
index 000000000..7ecae599e
--- /dev/null
+++ b/src/command_system/mcp_command.py
@@ -0,0 +1,111 @@
+"""mcp — ``/mcp`` MCP-server list (port of TS local-jsx).
+
+TS ``/mcp`` (``commands/mcp/``) is a *manager* — a settings panel + enable/disable +
+reconnect, all driven by the MCP connection-manager subsystem. Python's ``/mcp`` is
+**display-only** (``McpListScreen`` lists the configured servers; the dialog has no
+enable/disable action). So this port lists the servers and treats the management verbs
+as unsupported (the same subsystem boundary ``/model`` hit with discovery).
+
+Follows the **output-style precedent**: a ``local-jsx`` → :class:`InteractiveCommand`
+whose ``run()`` returns text **without touching ``ctx.ui``**, so it behaves identically
+on every surface (REPL, Textual, and ``NullUIHost`` headless — no raise).
+
+Coexistence: **inversion** — the TUI keeps intercepting ``/mcp``
+(``commands.py`` → ``open_dialog="mcp"`` → ``McpListScreen``); this registry command
+serves the non-TUI surfaces (REPL/SDK) + the help/aggregator listings.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+# TS management verbs (mcp.tsx call): enable/disable -> MCPToggle, reconnect ->
+# MCPReconnect, no-redirect -> MCPSettings. All need the MCP connection-manager.
+_MGMT_ARGS = frozenset({"enable", "disable", "reconnect", "no-redirect"})
+_NOT_SUPPORTED = (
+ "MCP server management (enable/disable/reconnect/settings) is not supported in "
+ "this build."
+)
+
+
+def _collect_mcp_servers() -> list[dict[str, Any]]:
+ """Read configured MCP servers from the SAME config keys as the TUI's
+ ``_collect_mcp_servers`` (app.py) — ``mcp_servers`` / ``mcpServers`` — PLUS
+ ``error`` (which the TUI helper leaves unset but ``_status_summary`` renders).
+ Runtime ``status``/``tools`` aren't in the config schema, so real servers come
+ back ``disconnected`` (see module note); the richer branches are synthetic-only."""
+ from src.config import load_config
+
+ try:
+ cfg = load_config() or {}
+ raw = cfg.get("mcp_servers") or cfg.get("mcpServers") or {}
+ except Exception:
+ raw = {}
+ servers: list[dict[str, Any]] = []
+ if isinstance(raw, dict):
+ for server_id, entry in raw.items():
+ e = entry if isinstance(entry, dict) else {}
+ tools = e.get("tools")
+ servers.append(
+ {
+ "id": str(server_id),
+ "name": str(e.get("name", server_id)),
+ "status": e.get("status", "disconnected"),
+ "tools": list(tools) if isinstance(tools, list) else [],
+ "error": e.get("error"),
+ }
+ )
+ return servers
+
+
+def _status_summary(server: dict[str, Any]) -> str:
+ """Verbatim port of ``_mcp_status_summary`` (mcp_dialogs.py:94-101), quirks intact:
+ ``tool_suffix`` is always-plural (``"1 tools"``) and appears ONLY on ``connected``."""
+ tools = server.get("tools") or []
+ tool_suffix = f" ({len(tools)} tools)" if tools else ""
+ status = server.get("status")
+ if status == "error":
+ err = server.get("error")
+ return f"error: {err}" if err else "error"
+ if status == "connected":
+ return f"connected{tool_suffix}"
+ return "disconnected"
+
+
+@dataclass(frozen=True)
+class McpCommand(InteractiveCommand):
+ """List the configured MCP servers. Frozen + no new fields (the
+ ``OutputStyleCommand`` pattern); ``run()`` returns text without touching ``ctx.ui``."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ parts = (args or "").strip().split()
+ if parts and parts[0].lower() in _MGMT_ARGS:
+ # TS enable/disable/reconnect/settings — need the unported MCP
+ # connection-manager subsystem.
+ return InteractiveOutcome(message=_NOT_SUPPORTED, display="system")
+
+ servers = _collect_mcp_servers()
+ if not servers:
+ return InteractiveOutcome(
+ message="No MCP servers configured.", display="system"
+ )
+ lines = [f"• {s['name']} — {_status_summary(s)}" for s in servers]
+ return InteractiveOutcome(
+ message="MCP servers:\n" + "\n".join(lines), display="system"
+ )
+
+
+MCP_COMMAND = McpCommand(
+ name="mcp",
+ description="Manage MCP servers", # verbatim TS index.ts
+ argument_hint="[enable|disable [server-name]]", # verbatim TS index.ts
+)
+
+
+__all__ = ["MCP_COMMAND", "McpCommand"]
diff --git a/src/command_system/memory_command.py b/src/command_system/memory_command.py
new file mode 100644
index 000000000..c40d5e594
--- /dev/null
+++ b/src/command_system/memory_command.py
@@ -0,0 +1,184 @@
+"""memory — ``/memory`` memory-file picker (port of TS local-jsx, degraded no-spawn).
+
+Port of ``typescript/src/commands/memory/`` + the core of ``MemoryFileSelector``.
+Presents the CLAUDE.md memory hierarchy — the synthetic **User memory**
+(``~/.claude/CLAUDE.md``) and **Project memory** (nearest loaded ancestor
+``CLAUDE.md``, falling back to ``{cwd}/CLAUDE.md``) candidates, differentiated via
+option *descriptions* (the TS selector's real channel), plus the existing files
+enumerated by the ``claude_md`` port — ensure-creates the selected file
+(exclusive-create; existing content preserved), and reports its path with an editor
+hint.
+
+Deliberate divergences (documented for parity review):
+ * **No ``$EDITOR`` spawn.** TS ``editFileInEditor`` suspends the Ink app; Python has
+ no suspend-aware helper, and spawning an editor inside the Textual TUI corrupts the
+ screen. The success message is adapted accordingly (an "Opened … in your editor"
+ claim would be false — the ``/copy`` clipboard standard); the cancel/error strings
+ stay TS-verbatim.
+ * **Folder-open extras dropped** (auto-memory / team / agent folders — need the
+ open-folder mechanism and those subsystems).
+ * Rules files are listed flat (the ``select`` primitive has flat labels; TS indents).
+
+Picker-only (TS ignores args) → no headless keystone; ``NullUIHost`` gets a clean
+engine error.
+"""
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+
+def _display_path(path: str, cwd: str) -> str:
+ """``~``-abbreviated, else cwd-relative, else absolute (the TS
+ ``getRelativeMemoryPath`` shape)."""
+ home = str(Path.home())
+ real = os.path.realpath(path)
+ try:
+ rel = os.path.relpath(real, os.path.realpath(cwd))
+ if not rel.startswith(".."):
+ return rel
+ except ValueError:
+ pass
+ if real.startswith(home + os.sep) or real == home:
+ return "~" + real[len(home):]
+ return real
+
+
+def _resolve_project_memory_path(files: list, cwd: str) -> str:
+ """TS ``getProjectMemoryPathForSelector``: prefer the **nearest** already-loaded
+ root-level Project CLAUDE.md (TS walks cwd upward; ``get_memory_files`` enumerates
+ root→cwd, so iterate reversed — last match = nearest); fall back to
+ ``{cwd}/CLAUDE.md`` only when none exists — so a repo subdirectory still points at
+ the real project memory."""
+ for info in reversed(files):
+ if (
+ getattr(info, "parent", None) is None
+ and getattr(info, "type", None) == "Project"
+ and os.path.basename(info.path) == "CLAUDE.md"
+ ):
+ return info.path
+ return str(Path(cwd) / "CLAUDE.md")
+
+
+async def build_memory_options(cwd: str) -> list[UIOption]:
+ """Public: the memory-target hierarchy, shared by ``/memory`` and
+ the C9 ``#`` shortcut so the two pickers can never drift."""
+ from src.context_system.claude_md import (
+ clear_memory_file_caches,
+ get_memory_files,
+ )
+
+ try:
+ clear_memory_file_caches() # TS primes fresh per open (memory.tsx:86-87)
+ files = list(await get_memory_files(cwd=cwd))
+ except Exception:
+ files = []
+
+ home = Path.home()
+ user_path = str(home / ".claude" / "CLAUDE.md")
+ project_path = _resolve_project_memory_path(files, cwd)
+
+ # Git-aware project description (TS: `${isGit ? 'Checked in at' : 'Saved in'} ./…`).
+ try:
+ from src.utils.git import get_repo_root
+
+ in_git = get_repo_root(cwd) is not None
+ except Exception:
+ in_git = False
+ project_desc = (
+ f"{'Checked in at' if in_git else 'Saved in'} ./{os.path.basename(project_path)}"
+ )
+
+ # NOTE: TS's `" (new)"` suffix is DEAD CODE (only interpolated in the depth>0
+ # branch, where exists is always true) — the real differentiation is the
+ # description column, ported here. Labels stay plain.
+ options: list[UIOption] = [
+ UIOption(
+ value=user_path,
+ label="User memory",
+ description="Saved in ~/.claude/CLAUDE.md", # verbatim TS (hardcoded tilde)
+ ),
+ UIOption(value=project_path, label="Project memory", description=project_desc),
+ ]
+ seen: set[str] = {os.path.realpath(user_path), os.path.realpath(project_path)}
+
+ # Remaining enumerated files (managed/user/project + rules), deduped by realpath
+ # (stronger than TS's exact-path dedup — deliberate). Parented files were
+ # @-imported (TS desc); others get no description.
+ for info in files:
+ real = os.path.realpath(info.path)
+ if real in seen:
+ continue
+ seen.add(real)
+ options.append(
+ UIOption(
+ value=info.path,
+ label=_display_path(info.path, cwd),
+ description="@-imported" if getattr(info, "parent", None) else None,
+ )
+ )
+ return options
+
+
+def _ensure_file(path: str) -> None:
+ """TS parity: mkdir the claude home when the path is under it, then
+ exclusive-create an empty file (existing content preserved)."""
+ claude_home = str(Path.home() / ".claude")
+ if path.startswith(claude_home):
+ Path(claude_home).mkdir(parents=True, exist_ok=True)
+ try:
+ with open(path, "x", encoding="utf-8"):
+ pass
+ except FileExistsError:
+ pass
+
+
+_EDITOR_HINT = (
+ "> To choose an editor, set the $EDITOR or $VISUAL environment variable."
+)
+
+
+@dataclass(frozen=True)
+class MemoryCommand(InteractiveCommand):
+ """Pick a memory file, ensure it exists, and report its path."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ cwd = str(context.cwd)
+ options = await build_memory_options(cwd)
+ picked = await context.ui.select("Memory", options)
+ if picked is None:
+ # Verbatim TS (memory.tsx:65).
+ return InteractiveOutcome(
+ message="Cancelled memory editing", display="system"
+ )
+ try:
+ _ensure_file(picked)
+ except Exception as exc:
+ # Verbatim TS catch shape (memory.tsx:61) — onDone without options.
+ return InteractiveOutcome(
+ message=f"Error opening memory file: {exc}", display="user"
+ )
+ return InteractiveOutcome(
+ message=(
+ f"Memory file at {_display_path(picked, cwd)}. "
+ f"Open it in your editor.\n\n{_EDITOR_HINT}"
+ ),
+ display="system", # TS uses display:'system' for the success line
+ )
+
+
+MEMORY_COMMAND = MemoryCommand(
+ name="memory",
+ description="Edit Claude memory files", # verbatim TS index.ts
+)
+
+
+__all__ = ["MEMORY_COMMAND", "MemoryCommand", "build_memory_options"]
diff --git a/src/command_system/model_command.py b/src/command_system/model_command.py
new file mode 100644
index 000000000..9adaa53da
--- /dev/null
+++ b/src/command_system/model_command.py
@@ -0,0 +1,189 @@
+"""model — interactive ``/model`` command (port of TS local-jsx).
+
+Port of ``typescript/src/commands/model/`` (``model.tsx`` + ``index.ts``). Like
+``/theme`` and ``/effort``, this is the **inverse** of ``/export`` at the TUI dispatch
+layer: the TUI keeps intercepting ``/model`` (+ ``/models``) → ``open_dialog="model"`` to
+preserve its ``ModelPickerScreen``; this command serves the registry-consulting surfaces
+(REPL numbered-menu ``select``, SDK, help/aggregator listings) where ``/model`` was
+previously invisible (it lived only in the TUI's private ``LOCAL_BUILTINS``).
+
+**Functional** (unlike ``/effort``): the live model channel in Python is
+``provider.model`` — providers resolve the request model via ``_get_model`` =
+``kwargs.get("model", self.model)`` and neither the main loop nor the fast path passes a
+``model=`` override, so the held provider's ``.model`` decides the next query's model. So
+this command sets **``ctx.provider.model``** (the channel inference reads), reachable on the
+REPL because Phase 7 also wires ``provider`` into the REPL command context. It is the **only**
+write: the reactive ``AppState.main_loop_model`` has no production reader (its
+``set_main_loop_model_override`` mirror is dead), and TS ``/model`` never writes disk — so
+this is session-only, no settings write.
+
+**Headless keystone:** the arg paths (``/model ``, ``current``/``status``/…, ``help``)
+need no UI; only the no-args picker needs a surface (``NullUIHost.select`` raises there).
+
+**Deliberate divergences (documented for parity review):**
+ * **Dropped** (need unported subsystems): network discovery/``refresh`` (→ "not supported"),
+ org-allowlist, 1M-context gates, fast-mode, extra-usage billing, network model-validation,
+ and TS ``'default'``-reset (no provider-default reachable from ``CommandContext``).
+ * **Validation = alias-resolve + membership** in ``provider.get_available_models()`` (the
+ list the picker uses), not TS's network ``validateModel``. Makes "Model 'x' not found"
+ reachable. ``MODEL_ALIASES`` is Claude-only, so on non-Anthropic providers (incl. GLM —
+ the REPL default, whose ``get_available_models()`` lists ``zai/glm-5``) set-by-name needs
+ the **exact listed id**; the picker is the ergonomic path there.
+ * **Static description** ("Set the AI model"); TS's is dynamic ``…(currently {model})`` — a
+ frozen ``CommandBase.description: str`` can't be a getter. ``current`` shows the live model.
+ * **``provider.model`` is the sole write** (Python reads it, not AppState — an architectural
+ divergence from TS, which writes ``AppState.mainLoopModel``).
+ * **Effort suffix in ``current``** reads ``settings.effort`` (the Phase 6 channel), not
+ AppState ``effortValue``.
+ * **Label = ``display_name``** (drops TS ``renderModelLabel``'s ``(default)``/alias decoration).
+
+``disable_model_invocation=True`` — model selection is user-driven (the ``/permissions``
+stance); a model must not switch its own model via the SlashCommand tool. ``src.models`` /
+``get_settings`` are imported lazily (the ``app.py``/advisor discipline).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+COMMON_HELP_ARGS = frozenset({"help", "-h", "--help"})
+# Verbatim from TS COMMON_INFO_ARGS (model.tsx).
+COMMON_INFO_ARGS = frozenset({
+ "list", "show", "display", "current", "view", "get", "check",
+ "describe", "print", "version", "about", "status", "?",
+})
+
+_NO_PROVIDER = "Model unavailable (no active provider)."
+# TS help text (model.tsx:792), minus the dropped `refresh` clause.
+_USAGE = "Run /model to open the model selection menu, or /model [modelName] to set the model."
+
+
+def _canonical(name: str) -> str:
+ """Resolve an alias to its canonical id (``sonnet`` → ``claude-sonnet-4-...``);
+ returns the input unchanged for non-aliases. Lazy import — see module docstring."""
+ from src.models.model import canonical_model_name
+
+ return canonical_model_name(name)
+
+
+def _label(model: str | None) -> str:
+ if not model:
+ return "(none)"
+ from src.models.model import display_name
+
+ return display_name(model)
+
+
+def _list_models(provider) -> list[str]:
+ """The provider's available model ids (the source the picker uses + the validation
+ set). ``get_available_models`` is the real provider method (``list_models`` does not
+ exist on providers)."""
+ try:
+ return [str(m) for m in (provider.get_available_models() or [])]
+ except Exception:
+ return []
+
+
+def _options(models: list[str], current: str | None) -> list[UIOption]:
+ return [
+ UIOption(value=m, label=m, description="current" if m == current else None)
+ for m in models
+ ]
+
+
+def _effort_suffix() -> str:
+ """`` (effort: X)`` when an effort is persisted (Phase 6 ``settings.effort``), else ``""``."""
+ try:
+ from src.settings.settings import get_settings
+
+ eff = get_settings().effort
+ except Exception:
+ eff = ""
+ return f" (effort: {eff})" if eff else ""
+
+
+def _show_current(context: CommandContext) -> str:
+ prov = context.provider
+ cur = getattr(prov, "model", None) if prov is not None else None
+ if not cur:
+ return "Current model: (none)"
+ return f"Current model: {_label(cur)}{_effort_suffix()}"
+
+
+def _apply(provider, model: str) -> None:
+ """Set the live model. ``provider.model`` is the channel inference reads; guarded
+ exactly like the TUI's ``_open_model_picker`` (app.py)."""
+ try:
+ provider.model = model
+ except Exception:
+ pass
+
+
+@dataclass(frozen=True)
+class ModelCommand(InteractiveCommand):
+ """Pick or set the active model. Frozen + no new fields (the ``ThemeCommand`` pattern);
+ behavior lives in :meth:`run`."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ a = (args or "").strip()
+ low = a.lower()
+
+ if low in COMMON_HELP_ARGS: # TS help => display:'system' (model.tsx:792)
+ return InteractiveOutcome(message=_USAGE, display="system")
+ if low in COMMON_INFO_ARGS: # ShowModelAndClose
+ return InteractiveOutcome(message=_show_current(context), display="user")
+ if low == "refresh": # TS network discovery — dropped
+ return InteractiveOutcome(
+ message="Model refresh is not supported.", display="system"
+ )
+ if not a: # ModelPickerWrapper
+ return await self._pick(context)
+ return self._set(context, a) # SetModelAndClose (headless)
+
+ async def _pick(self, context: CommandContext) -> InteractiveOutcome:
+ prov = context.provider
+ if prov is None:
+ return InteractiveOutcome(message=_NO_PROVIDER, display="system")
+ models = _list_models(prov)
+ if not models:
+ return InteractiveOutcome(message="No models available.", display="system")
+ current = getattr(prov, "model", None)
+ picked = await context.ui.select(
+ "Select model:", _options(models, current), current=current
+ )
+ if picked is None: # TS cancel: "Kept model as …" (model.tsx:376), NOT skip
+ return InteractiveOutcome(
+ message=f"Kept model as {_label(current)}", display="system"
+ )
+ _apply(prov, picked)
+ return InteractiveOutcome(message=f"Set model to {_label(picked)}", display="user")
+
+ def _set(self, context: CommandContext, arg: str) -> InteractiveOutcome:
+ prov = context.provider
+ if prov is None or not hasattr(prov, "model"):
+ return InteractiveOutcome(message=_NO_PROVIDER, display="system")
+ canon = _canonical(arg)
+ models = _list_models(prov)
+ # Membership validation (TS network validate dropped). Permissive when the
+ # provider lists nothing (unknown provider) so a valid id still goes through.
+ if models and canon not in models:
+ return InteractiveOutcome(message=f"Model '{arg}' not found", display="system")
+ _apply(prov, canon)
+ return InteractiveOutcome(message=f"Set model to {_label(canon)}", display="user")
+
+
+MODEL_COMMAND = ModelCommand(
+ name="model",
+ description="Set the AI model", # static (TS is dynamic — see module docstring)
+ argument_hint="[model]", # verbatim TS index.ts
+ disable_model_invocation=True, # user-driven only (the /permissions stance)
+)
+
+
+__all__ = ["MODEL_COMMAND", "ModelCommand"]
diff --git a/src/command_system/moved_to_plugin.py b/src/command_system/moved_to_plugin.py
new file mode 100644
index 000000000..6cca84919
--- /dev/null
+++ b/src/command_system/moved_to_plugin.py
@@ -0,0 +1,90 @@
+"""create_moved_to_plugin_command — Python port of TS commands/createMovedToPluginCommand.ts.
+
+Builds a builtin 'prompt' command whose prompt depends on USER_TYPE: ant users get a
+static "this command moved to a plugin" message; everyone else dispatches to a private
+prompt builder (the command's real implementation while the marketplace is private).
+"""
+from __future__ import annotations
+
+import inspect
+import os
+from dataclasses import dataclass, field
+from typing import Any, Awaitable, Callable, Optional, Union
+
+from .types import CommandContext, PromptCommand
+
+# (args, context) -> prompt blocks. May be sync or async (TS returns a Promise).
+PrivatePromptBuilder = Callable[
+ [str, CommandContext],
+ Union[list[dict[str, Any]], Awaitable[list[dict[str, Any]]]],
+]
+
+
+def _moved_to_plugin_text(plugin_name: str, plugin_command: str) -> str:
+ """Verbatim from createMovedToPluginCommand.ts:48-57."""
+ return (
+ "This command has been moved to a plugin. Tell the user:\n\n"
+ "1. To install the plugin, run:\n"
+ f" openclaude plugin install {plugin_name}@claude-code-marketplace\n\n"
+ f"2. After installation, use /{plugin_name}:{plugin_command} to run this command\n\n"
+ "3. For more information, see: "
+ f"https://github.com/anthropics/claude-code-marketplace/blob/main/{plugin_name}/README.md\n\n"
+ "Do not attempt to run the command. Simply inform the user about the plugin "
+ "installation."
+ )
+
+
+@dataclass(frozen=True)
+class MovedToPluginCommand(PromptCommand):
+ """A builtin prompt command gated on USER_TYPE (see createMovedToPluginCommand.ts).
+
+ Frozen like its base; the custom prompt builder is stored as a non-comparing,
+ non-repr field (mirrors LocalCommand._call_impl) so the frozen dataclass stays
+ hashable/comparable by its data fields.
+ """
+
+ plugin_name: str = ""
+ plugin_command: str = ""
+ _private_builder: Optional[PrivatePromptBuilder] = field(
+ default=None, repr=False, compare=False
+ )
+
+ async def get_prompt_for_command(
+ self, args: str, context: CommandContext
+ ) -> list[dict[str, Any]]:
+ # TS gates on process.env.USER_TYPE === 'ant' (createMovedToPluginCommand.ts:44).
+ if os.environ.get("USER_TYPE") == "ant":
+ return [
+ {
+ "type": "text",
+ "text": _moved_to_plugin_text(self.plugin_name, self.plugin_command),
+ }
+ ]
+ if self._private_builder is None:
+ raise ValueError(f"{self.name}: no private prompt builder configured")
+ result = self._private_builder(args, context)
+ if inspect.isawaitable(result): # support async builders (TS returns a Promise)
+ result = await result
+ return result
+
+
+def create_moved_to_plugin_command(
+ *,
+ name: str,
+ description: str,
+ progress_message: str,
+ plugin_name: str,
+ plugin_command: str,
+ get_prompt_while_marketplace_is_private: PrivatePromptBuilder,
+) -> MovedToPluginCommand:
+ """Port of createMovedToPluginCommand({...}) — returns a builtin prompt command."""
+ return MovedToPluginCommand(
+ name=name,
+ description=description,
+ progress_message=progress_message,
+ content_length=0, # dynamic content (TS contentLength: 0)
+ source="builtin", # TS source: 'builtin'
+ plugin_name=plugin_name,
+ plugin_command=plugin_command,
+ _private_builder=get_prompt_while_marketplace_is_private,
+ )
diff --git a/src/command_system/output_style_command.py b/src/command_system/output_style_command.py
new file mode 100644
index 000000000..dce5c30ca
--- /dev/null
+++ b/src/command_system/output_style_command.py
@@ -0,0 +1,52 @@
+"""output-style — deprecated ``/output-style`` command (port of TS local-jsx).
+
+The TypeScript command (``typescript/src/commands/output-style/``) is a
+``local-jsx`` command that renders nothing interactive: its ``call`` immediately
+invokes ``onDone(, { display: 'system' })``. It exists only
+to tell users the feature moved to ``/config``.
+
+Ported as an :class:`InteractiveCommand` because ``local-jsx`` maps onto
+``CommandType.INTERACTIVE`` (same remote-safety blocking by type). Unlike the
+``/permissions`` exemplar it never touches ``context.ui`` — :meth:`run` returns
+the deprecation :class:`InteractiveOutcome` directly, so it behaves identically
+on every surface (REPL, Textual, and ``NullUIHost`` headless).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+# Verbatim from typescript/src/commands/output-style/output-style.tsx.
+_DEPRECATION_NOTICE = (
+ "/output-style has been deprecated. Use /config to change your output "
+ "style, or set it in your settings file. Changes take effect on the next "
+ "session."
+)
+
+
+@dataclass(frozen=True)
+class OutputStyleCommand(InteractiveCommand):
+ """Emit the deprecation notice and nothing else. Frozen + no new fields
+ (the ``PermissionsCommand`` pattern); behavior lives entirely in
+ :meth:`run`."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ return InteractiveOutcome(
+ message=_DEPRECATION_NOTICE,
+ display="system",
+ )
+
+
+OUTPUT_STYLE_COMMAND = OutputStyleCommand(
+ name="output-style",
+ description="Deprecated: use /config to change output style",
+ is_hidden=True,
+)
+
+
+__all__ = ["OUTPUT_STYLE_COMMAND", "OutputStyleCommand"]
diff --git a/src/command_system/permissions_command.py b/src/command_system/permissions_command.py
new file mode 100644
index 000000000..b52f2657f
--- /dev/null
+++ b/src/command_system/permissions_command.py
@@ -0,0 +1,121 @@
+"""permissions — interactive ``/permissions`` command (port of TS local-jsx).
+
+Reference command for the P0-3 interactive-command bridge (see
+my-docs/get-parity-by-folder/commands-phase2-interactive-bridge-plan.md): a
+single ``select`` over the user-facing permission modes, backed by the reactive
+``AppState.permission_mode`` field (``src/state/app_state.py``) and its
+persistence handler ``_on_permission_mode_change`` (which notifies the CCR
+bridge / SDK status stream).
+
+It demonstrates the keystone win: **one** command body drives the REPL numbered
+menu *and* the Textual modal through the ``UIHost`` port — no per-surface
+implementation. Chosen over ``/effort`` because its backing state genuinely
+exists/persists and it has no ``open_dialog`` intercept in the TUI, so the
+registry → ``TextualUIHost`` path is reachable on both surfaces.
+
+(The existing ``tui/screens/permission_modal.py`` is the per-tool permission
+*request* y/n prompt — a different concern, no collision.)
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+# User-facing permission modes, in the Shift+Tab cycle order
+# (``src/permissions/cycle.py``). The internal modes (dontAsk / auto / bubble)
+# are deliberately excluded from the picker — they're not user-addressable.
+_PERMISSION_MODE_OPTIONS: list[UIOption] = [
+ UIOption(
+ value="default",
+ label="default",
+ description="Prompt before each tool's first use",
+ ),
+ UIOption(
+ value="acceptEdits",
+ label="acceptEdits",
+ description="Auto-accept file edits in the workspace",
+ ),
+ UIOption(
+ value="plan",
+ label="plan",
+ description="Plan only — no edits or commands",
+ ),
+ UIOption(
+ value="bypassPermissions",
+ label="bypassPermissions",
+ description="Skip all permission prompts",
+ ),
+]
+
+
+@dataclass(frozen=True)
+class PermissionsCommand(InteractiveCommand):
+ """Select-one-of the user-facing permission modes; persist via the
+ reactive AppState store. Frozen + no new fields (the ``StatuslineCommand``
+ pattern); behavior lives entirely in :meth:`run`.
+ """
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ store: Any = context.app_state_store
+
+ current: str | None = None
+ if store is not None:
+ try:
+ current = store.get_state().permission_mode
+ except Exception:
+ current = None
+
+ # The single select — same call drives REPL menu and TUI modal.
+ pick = await context.ui.select(
+ "Permission mode",
+ _PERMISSION_MODE_OPTIONS,
+ current=current,
+ )
+ if pick is None:
+ # Cancelled — no output (display == 'skip').
+ return InteractiveOutcome.skip()
+
+ if store is None:
+ # No reactive store wired on this surface — report rather than
+ # silently no-op (mirrors the honest-failure stance of NullUIHost).
+ return InteractiveOutcome(
+ message="Permission mode unavailable (no app state store).",
+ display="system",
+ )
+
+ if pick == current:
+ return InteractiveOutcome(
+ message=f"Permission mode unchanged ({pick}).",
+ display="system",
+ )
+
+ # Persist: set_state fires on_change -> _on_permission_mode_change,
+ # which notifies the CCR bridge / SDK status stream.
+ from src.state.app_state import replace_state
+
+ store.set_state(lambda s: replace_state(s, permission_mode=pick))
+ return InteractiveOutcome(
+ message=f"Permission mode set to {pick}.",
+ display="system",
+ )
+
+
+PERMISSIONS_COMMAND = PermissionsCommand(
+ name="permissions",
+ description="Switch the tool permission mode",
+ # Security-sensitive: it can select ``bypassPermissions``. Keep it
+ # user-driven only so a future model→slash-command path can never let
+ # the model escalate its own permissions. (TS treats permission/config
+ # mutation as user-initiated.)
+ disable_model_invocation=True,
+)
+
+
+__all__ = ["PERMISSIONS_COMMAND", "PermissionsCommand"]
diff --git a/src/command_system/release_notes_command.py b/src/command_system/release_notes_command.py
new file mode 100644
index 000000000..3edd0d77c
--- /dev/null
+++ b/src/command_system/release_notes_command.py
@@ -0,0 +1,56 @@
+"""release-notes — ``/release-notes`` changelog viewer (port of TS ``type:'local'``).
+
+Port of ``typescript/src/commands/release-notes/``. TS tries a GitHub-releases fetch and
+falls back to the **stored changelog**; Python reads the project's local ``CHANGELOG.md``
+(exactly the stored-changelog path) and **drops the network fetch/cache** (the same
+unported-subsystem boundary as ``/mcp``/``/model``). When the current version has no
+section (or the changelog is absent, e.g. pip installs), the output degrades to the
+release-page URL — faithful to TS's notes-empty case.
+
+Maps to :class:`LocalCommand` (the first ``type:'local'`` single-command port of this
+batch — prior ports were ``local-jsx`` → ``InteractiveCommand``), using the established
+``set_call`` pattern from builtins (help/clear/…). Sync impl: the only async part of TS
+was the dropped fetch.
+"""
+from __future__ import annotations
+
+from .types import CommandContext, LocalCommand, LocalCommandResult
+
+
+def release_notes_call(args: str, context: CommandContext) -> LocalCommandResult:
+ # Lazy imports keep `import src.command_system` light (the established discipline).
+ from src import __version__
+ from src.utils.release_notes import (
+ format_release_notes_for_display,
+ get_release_notes_for_version,
+ get_release_tag_url,
+ normalize_public_version,
+ read_local_changelog,
+ )
+
+ # Normalize once and reuse for header + URL (TS interpolates the already-public
+ # build version; getReleaseTagUrl normalizes internally — identical result).
+ version = normalize_public_version(__version__)
+ notes = get_release_notes_for_version(version, read_local_changelog())
+ url = get_release_tag_url(version)
+ if notes:
+ return LocalCommandResult(
+ type="text",
+ value=(
+ f"Release notes for {version}:\n"
+ f"{format_release_notes_for_display(notes)}\n\n"
+ f"Full release page: {url}"
+ ),
+ )
+ return LocalCommandResult(type="text", value=f"Release notes: {url}")
+
+
+RELEASE_NOTES_COMMAND = LocalCommand(
+ name="release-notes",
+ description="View release notes", # verbatim TS index.ts
+ supports_non_interactive=True, # verbatim TS index.ts
+)
+RELEASE_NOTES_COMMAND.set_call(release_notes_call)
+
+
+__all__ = ["RELEASE_NOTES_COMMAND", "release_notes_call"]
diff --git a/src/command_system/rename_command.py b/src/command_system/rename_command.py
new file mode 100644
index 000000000..6b51d2cb7
--- /dev/null
+++ b/src/command_system/rename_command.py
@@ -0,0 +1,141 @@
+"""rename — ``/rename`` rename the current conversation (port of TS local-jsx).
+
+Port of ``typescript/src/commands/rename/`` (``rename.ts`` + ``generateSessionName.ts``).
+``/rename `` sets the name directly; bare ``/rename`` generates a short
+kebab-case name from the conversation (Haiku side-call, best-effort).
+
+**Persist channel (now genuinely live):** ``SessionStorage`` metadata ``title`` — the
+session-persistence producer (``services/session_persistence.SessionPersister``,
+driven by ``agent_bridge``) writes metadata + transcripts in normal TUI operation,
+and the resume screen lists sessions labeled by ``meta.title`` first. The id channel
+is unified by construction (``Session.create`` reads bootstrap ``get_session_id()``,
+guarded by ``test_session_id_unified_with_bootstrap``), so this command provably
+targets the same session directory the producer writes. When metadata is absent
+(e.g. headless surfaces where no producer ran) the command falls back to
+``init_metadata(title=…)`` — the session then appears in the resume list.
+
+Output-style pattern: ``run()`` never touches ``ctx.ui`` (TS has no picker) → works
+headless on every surface.
+
+Deliberate divergences (documented for parity review):
+ * **DROPPED:** the teammate guard (no ``isTeammate`` analog), ``saveAgentName`` +
+ AppState ``standaloneAgentContext.name`` (no analog), the best-effort bridge
+ title sync (only a protocol ``set_session_title`` exists — no concrete client),
+ and the compact-boundary message filter (messages used as-is).
+ * **Name generation** is anthropic-direct best-effort (the ``generate_llm_title``
+ sibling pattern); ANY failure (no key/network/parse) → the no-context message
+ (TS distinguishes only null-generation; merged — honest).
+"""
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from typing import Any, Mapping
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+# Verbatim TS generateSessionName.ts:22-24 system prompt.
+_NAME_PROMPT = (
+ "Generate a short kebab-case name (2-4 words) that captures the main topic of "
+ 'this conversation. Use lowercase words separated by hyphens. Examples: '
+ '"fix-login-bug", "add-auth-feature", "refactor-api-client", '
+ '"debug-test-failures". Return JSON with a "name" field.'
+)
+
+_NO_CONTEXT_MSG = (
+ "Could not generate a name: no conversation context yet. Usage: /rename "
+)
+
+
+def _conversation_text(messages: Any) -> str:
+ """Flatten user/assistant texts (the TS extractConversationText reduction)."""
+ parts: list[str] = []
+ for msg in list(messages or [])[:20]:
+ role = (
+ msg.get("role") or msg.get("type")
+ if isinstance(msg, Mapping)
+ else getattr(msg, "role", None) or getattr(msg, "type", None)
+ )
+ if role not in ("user", "assistant"):
+ continue
+ content = msg.get("content") if isinstance(msg, Mapping) else getattr(msg, "content", None)
+ if isinstance(content, str) and content.strip():
+ parts.append(f"{role}: {content.strip()}")
+ elif isinstance(content, list):
+ for block in content:
+ text = (
+ block.get("text")
+ if isinstance(block, Mapping)
+ else getattr(block, "text", None)
+ )
+ if text and str(text).strip():
+ parts.append(f"{role}: {str(text).strip()}")
+ return "\n".join(parts)
+
+
+async def _generate_session_name(messages: Any) -> str | None:
+ """Port of TS ``generateSessionName`` — kebab-case 2-4 words via a Haiku
+ side-call; best-effort ``None`` on any failure (the ``generate_llm_title``
+ sibling pattern)."""
+ text = _conversation_text(messages)
+ if not text:
+ return None
+ try:
+ import anthropic
+
+ client = anthropic.Anthropic()
+ result = client.messages.create(
+ model="claude-3-5-haiku-20241022",
+ max_tokens=100,
+ system=_NAME_PROMPT,
+ messages=[{"role": "user", "content": text[:4000]}],
+ )
+ raw = "".join(
+ getattr(b, "text", "") or "" for b in (result.content or [])
+ ).strip()
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
+ if not m:
+ return None
+ name = json.loads(m.group(0)).get("name")
+ return name.strip() if isinstance(name, str) and name.strip() else None
+ except Exception:
+ return None
+
+
+@dataclass(frozen=True)
+class RenameCommand(InteractiveCommand):
+ """Rename the current conversation (SessionStorage metadata title)."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ name = (args or "").strip()
+ if not name:
+ messages = getattr(context.conversation, "messages", None) or []
+ generated = await _generate_session_name(messages)
+ if not generated:
+ return InteractiveOutcome(message=_NO_CONTEXT_MSG, display="system")
+ name = generated
+
+ from src.bootstrap.state import get_session_id
+ from src.services.session_storage import SessionStorage
+
+ storage = SessionStorage(session_id=str(get_session_id()))
+ if storage.get_metadata() is None:
+ storage.init_metadata(title=name)
+ else:
+ storage.update_metadata(title=name)
+ # Verbatim TS rename.ts:85 (display:'system').
+ return InteractiveOutcome(message=f"Session renamed to: {name}", display="system")
+
+
+RENAME_COMMAND = RenameCommand(
+ name="rename",
+ description="Rename the current conversation", # verbatim TS index.ts
+)
+
+
+__all__ = ["RENAME_COMMAND", "RenameCommand"]
diff --git a/src/command_system/resume_command.py b/src/command_system/resume_command.py
new file mode 100644
index 000000000..25107d4d9
--- /dev/null
+++ b/src/command_system/resume_command.py
@@ -0,0 +1,101 @@
+"""resume — ``/resume`` session picker (port of TS local-jsx, components C2).
+
+TS ``/resume`` (``commands/resume/index.ts``: description "Resume a previous
+conversation", argumentHint "[conversation id or search term]") mounts the
+``LogSelector`` picker and swaps the live session. Python's interactive swap
+lives in the TUI (``tui/commands.py`` → ``open_dialog="resume"`` →
+``ResumeConversation`` → ``AgentBridge.resume_session``), because only the
+TUI owns a live conversation it can replace.
+
+This registry command serves the NON-TUI surfaces (REPL/SDK/help/aggregator)
+in the **output-style precedent**: ``run()`` returns text without touching
+``ctx.ui`` — a degraded-but-honest LIST of resumable sessions plus the
+pointer to the TUI for the actual swap. Filtering matches the TUI picker:
+metadata-only sessions (``message_count == 0``) are hidden and counted
+(gap-doc §5 Q2 decision — headless ``/rename`` can mint such entries).
+
+Coexistence: **inversion** (the ``/theme`` pattern) — the TUI intercept
+stays authoritative; this command never runs there.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+
+def _list_resumable(term: str = "") -> tuple[list[str], int]:
+ """``(lines, hidden_count)`` for the degraded session list.
+
+ UI-neutral by construction: imports only ``services`` modules (no
+ Textual — the dependency-direction rule from the C1/C2 reviews).
+ """
+
+ from src.bootstrap.state import get_session_id
+ from src.services.session_listing import build_resume_entries, filter_entries
+
+ try:
+ from src.services.session_storage import SessionStorage
+
+ metas = SessionStorage.list_sessions()
+ except Exception:
+ metas = []
+ entries, hidden = build_resume_entries(
+ metas, exclude_session_id=str(get_session_id())
+ )
+ if term:
+ entries = filter_entries(entries, term)
+ return [f"• {entry.label()} [{entry.session_id}]" for entry in entries], hidden
+
+
+@dataclass(frozen=True)
+class ResumeCommand(InteractiveCommand):
+ """List resumable sessions; the interactive swap is TUI-only."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ term = (args or "").strip()
+ lines, hidden = _list_resumable(term)
+ if not lines:
+ message = (
+ f"No resumable conversations match {term!r}."
+ if term
+ else "No resumable conversations yet."
+ )
+ if hidden:
+ message += (
+ f" ({hidden} metadata-only session(s) hidden — "
+ "no stored messages.)"
+ )
+ return InteractiveOutcome(message=message, display="system")
+ header = (
+ f"Resumable conversations matching {term!r}:"
+ if term
+ else "Resumable conversations:"
+ )
+ parts = [header]
+ parts.extend(lines)
+ if hidden:
+ parts.append(
+ f"({hidden} unresumable metadata-only session(s) hidden)"
+ )
+ parts.append(
+ "Resuming replaces the live conversation — run /resume inside "
+ "the TUI to pick and load one."
+ )
+ return InteractiveOutcome(message="\n".join(parts), display="system")
+
+
+RESUME_COMMAND = ResumeCommand(
+ name="resume",
+ description="Resume a previous conversation", # verbatim TS index.ts:6
+ argument_hint="[conversation id or search term]", # verbatim TS index.ts:8
+ aliases=["continue"], # verbatim TS index.ts:7
+)
+
+
+__all__ = ["RESUME_COMMAND", "ResumeCommand"]
diff --git a/src/command_system/safe_commands.py b/src/command_system/safe_commands.py
new file mode 100644
index 000000000..6dd63e429
--- /dev/null
+++ b/src/command_system/safe_commands.py
@@ -0,0 +1,66 @@
+"""
+Remote / bridge safe-command filtering.
+
+Python port of the REMOTE_SAFE_COMMANDS / BRIDGE_SAFE_COMMANDS / isBridgeSafeCommand
+logic in typescript/src/commands.ts:643-712. Gates which slash commands may run in
+--remote mode and over the Remote Control bridge (mobile/web), now load-bearing after
+the CLI-parity transport port (src/transports/).
+
+Membership is by command NAME (not object identity as in TS): a frozenset of names is
+stable across registry instances and lets the policy name commands that are not ported
+yet (the allowlist is a forward-looking policy, not a runtime object set).
+"""
+
+from __future__ import annotations
+
+from .types import Command, CommandType
+
+# Safe in --remote mode: only affect local TUI state; no fs/git/shell/IDE/MCP.
+# TS commands.ts:643-662 (18 commands).
+REMOTE_SAFE_COMMANDS: frozenset[str] = frozenset({
+ "session", "exit", "clear", "help", "theme", "logo", "color", "vim",
+ "cost", "usage", "copy", "btw", "feedback", "plan", "keybindings",
+ "statusline", "stickers", "mobile",
+})
+
+# 'local' commands explicitly safe over the Remote Control bridge: produce text
+# output that streams back to mobile/web, no terminal-only side effects.
+# TS commands.ts:676-685 (6 commands). 'summary' is an ant-internal import in TS
+# (kept here as a forward-looking policy name; not yet ported).
+BRIDGE_SAFE_COMMANDS: frozenset[str] = frozenset({
+ "compact", "clear", "cost", "summary", "release-notes", "files",
+})
+
+
+def is_bridge_safe_command(cmd: Command) -> bool:
+ """
+ Whether a slash command is safe to execute when its input arrived over the
+ Remote Control bridge. Port of commands.ts:697-701.
+
+ Rule: 'prompt' commands expand to text -> always safe; 'local' commands need an
+ explicit opt-in via BRIDGE_SAFE_COMMANDS; interactive ('local-jsx') commands render
+ UI and are always blocked.
+ """
+ if cmd.command_type == CommandType.PROMPT:
+ return True # prompt commands expand to text -> always safe
+ if cmd.command_type == CommandType.LOCAL:
+ return cmd.name in BRIDGE_SAFE_COMMANDS
+ if cmd.command_type == CommandType.INTERACTIVE:
+ # 'local-jsx' renders UI -> always blocked, BY TYPE. Naming an
+ # interactive command in BRIDGE_SAFE_COMMANDS must NOT unblock it
+ # (the gate is structural, not name-driven). Matches TS rule
+ # "local-jsx always blocked" (gap analysis §2.4).
+ return False
+ # Defensive default for any future command type.
+ return False
+
+
+def filter_commands_for_remote_mode(commands: list[Command]) -> list[Command]:
+ """Keep only commands safe for --remote mode. Port of commands.ts:709-711.
+
+ Expects the DEDUPED output of get_commands() (where builtins own their names),
+ not a raw skill list. Membership is by NAME, so a user skill named e.g. 'clear'
+ would pass this filter unless the aggregator's dedupe has already let the builtin
+ claim that name first (the TS caller likewise feeds getCommands() output here).
+ """
+ return [cmd for cmd in commands if cmd.name in REMOTE_SAFE_COMMANDS]
diff --git a/src/command_system/security_review.py b/src/command_system/security_review.py
new file mode 100644
index 000000000..c6ea6ec3d
--- /dev/null
+++ b/src/command_system/security_review.py
@@ -0,0 +1,252 @@
+"""security-review command — Python port of TS commands/security-review.ts.
+
+A moved-to-plugin builtin: ant users get the static plugin-install message; everyone
+else runs the real review prompt, which executes the four read-only ``git`` blocks at
+prompt-build time (status / diff name-only / log / full diff against ``origin/HEAD...``)
+and splices their output into the markdown sent to the model.
+
+The private builder calls the lower-level ``execute_shell_commands_in_prompt`` directly
+and IGNORES ``args`` — faithful to TS, which does not run any skill-style transforms
+(no base-dir header, no ``ARGUMENTS:`` append, no ``${…}`` substitution).
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from src.skills.frontmatter import parse_frontmatter
+from src.utils.markdown_config_loader import parse_slash_command_tools_from_frontmatter
+
+from .moved_to_plugin import create_moved_to_plugin_command
+from .shell_prompt import execute_shell_commands_in_prompt, make_bash_shell_executor
+from .types import CommandContext
+
+# Verbatim from security-review.ts:6-196 (frontmatter + body). The four `git` blocks are
+# the inline `!`…`` form wrapped in plain ``` display fences (NOT the fenced ```! exec
+# form), so they are detected as inline shell blocks.
+SECURITY_REVIEW_MARKDOWN = """---
+allowed-tools: Bash(git diff:*), Bash(git status:*), Bash(git log:*), Bash(git show:*), Bash(git remote show:*), Read, Glob, Grep, LS, Task
+description: Complete a security review of the pending changes on the current branch
+---
+
+You are a senior security engineer conducting a focused security review of the changes on this branch.
+
+GIT STATUS:
+
+```
+!`git status`
+```
+
+FILES MODIFIED:
+
+```
+!`git diff --name-only origin/HEAD...`
+```
+
+COMMITS:
+
+```
+!`git log --no-decorate origin/HEAD...`
+```
+
+DIFF CONTENT:
+
+```
+!`git diff origin/HEAD...`
+```
+
+Review the complete diff above. This contains all code changes in the PR.
+
+
+OBJECTIVE:
+Perform a security-focused code review to identify HIGH-CONFIDENCE security vulnerabilities that could have real exploitation potential. This is not a general code review - focus ONLY on security implications newly added by this PR. Do not comment on existing security concerns.
+
+CRITICAL INSTRUCTIONS:
+1. MINIMIZE FALSE POSITIVES: Only flag issues where you're >80% confident of actual exploitability
+2. AVOID NOISE: Skip theoretical issues, style concerns, or low-impact findings
+3. FOCUS ON IMPACT: Prioritize vulnerabilities that could lead to unauthorized access, data breaches, or system compromise
+4. EXCLUSIONS: Do NOT report the following issue types:
+ - Denial of Service (DOS) vulnerabilities, even if they allow service disruption
+ - Secrets or sensitive data stored on disk (these are handled by other processes)
+ - Rate limiting or resource exhaustion issues
+
+SECURITY CATEGORIES TO EXAMINE:
+
+**Input Validation Vulnerabilities:**
+- SQL injection via unsanitized user input
+- Command injection in system calls or subprocesses
+- XXE injection in XML parsing
+- Template injection in templating engines
+- NoSQL injection in database queries
+- Path traversal in file operations
+
+**Authentication & Authorization Issues:**
+- Authentication bypass logic
+- Privilege escalation paths
+- Session management flaws
+- JWT token vulnerabilities
+- Authorization logic bypasses
+
+**Crypto & Secrets Management:**
+- Hardcoded API keys, passwords, or tokens
+- Weak cryptographic algorithms or implementations
+- Improper key storage or management
+- Cryptographic randomness issues
+- Certificate validation bypasses
+
+**Injection & Code Execution:**
+- Remote code execution via deseralization
+- Pickle injection in Python
+- YAML deserialization vulnerabilities
+- Eval injection in dynamic code execution
+- XSS vulnerabilities in web applications (reflected, stored, DOM-based)
+
+**Data Exposure:**
+- Sensitive data logging or storage
+- PII handling violations
+- API endpoint data leakage
+- Debug information exposure
+
+Additional notes:
+- Even if something is only exploitable from the local network, it can still be a HIGH severity issue
+
+ANALYSIS METHODOLOGY:
+
+Phase 1 - Repository Context Research (Use file search tools):
+- Identify existing security frameworks and libraries in use
+- Look for established secure coding patterns in the codebase
+- Examine existing sanitization and validation patterns
+- Understand the project's security model and threat model
+
+Phase 2 - Comparative Analysis:
+- Compare new code changes against existing security patterns
+- Identify deviations from established secure practices
+- Look for inconsistent security implementations
+- Flag code that introduces new attack surfaces
+
+Phase 3 - Vulnerability Assessment:
+- Examine each modified file for security implications
+- Trace data flow from user inputs to sensitive operations
+- Look for privilege boundaries being crossed unsafely
+- Identify injection points and unsafe deserialization
+
+REQUIRED OUTPUT FORMAT:
+
+You MUST output your findings in markdown. The markdown output should contain the file, line number, severity, category (e.g. `sql_injection` or `xss`), description, exploit scenario, and fix recommendation.
+
+For example:
+
+# Vuln 1: XSS: `foo.py:42`
+
+* Severity: High
+* Description: User input from `username` parameter is directly interpolated into HTML without escaping, allowing reflected XSS attacks
+* Exploit Scenario: Attacker crafts URL like /bar?q= to execute JavaScript in victim's browser, enabling session hijacking or data theft
+* Recommendation: Use Flask's escape() function or Jinja2 templates with auto-escaping enabled for all user inputs rendered in HTML
+
+SEVERITY GUIDELINES:
+- **HIGH**: Directly exploitable vulnerabilities leading to RCE, data breach, or authentication bypass
+- **MEDIUM**: Vulnerabilities requiring specific conditions but with significant impact
+- **LOW**: Defense-in-depth issues or lower-impact vulnerabilities
+
+CONFIDENCE SCORING:
+- 0.9-1.0: Certain exploit path identified, tested if possible
+- 0.8-0.9: Clear vulnerability pattern with known exploitation methods
+- 0.7-0.8: Suspicious pattern requiring specific conditions to exploit
+- Below 0.7: Don't report (too speculative)
+
+FINAL REMINDER:
+Focus on HIGH and MEDIUM findings only. Better to miss some theoretical issues than flood the report with false positives. Each finding should be something a security engineer would confidently raise in a PR review.
+
+FALSE POSITIVE FILTERING:
+
+> You do not need to run commands to reproduce the vulnerability, just read the code to determine if it is a real vulnerability. Do not use the bash tool or write to any files.
+>
+> HARD EXCLUSIONS - Automatically exclude findings matching these patterns:
+> 1. Denial of Service (DOS) vulnerabilities or resource exhaustion attacks.
+> 2. Secrets or credentials stored on disk if they are otherwise secured.
+> 3. Rate limiting concerns or service overload scenarios.
+> 4. Memory consumption or CPU exhaustion issues.
+> 5. Lack of input validation on non-security-critical fields without proven security impact.
+> 6. Input sanitization concerns for GitHub Action workflows unless they are clearly triggerable via untrusted input.
+> 7. A lack of hardening measures. Code is not expected to implement all security best practices, only flag concrete vulnerabilities.
+> 8. Race conditions or timing attacks that are theoretical rather than practical issues. Only report a race condition if it is concretely problematic.
+> 9. Vulnerabilities related to outdated third-party libraries. These are managed separately and should not be reported here.
+> 10. Memory safety issues such as buffer overflows or use-after-free-vulnerabilities are impossible in rust. Do not report memory safety issues in rust or any other memory safe languages.
+> 11. Files that are only unit tests or only used as part of running tests.
+> 12. Log spoofing concerns. Outputting un-sanitized user input to logs is not a vulnerability.
+> 13. SSRF vulnerabilities that only control the path. SSRF is only a concern if it can control the host or protocol.
+> 14. Including user-controlled content in AI system prompts is not a vulnerability.
+> 15. Regex injection. Injecting untrusted content into a regex is not a vulnerability.
+> 16. Regex DOS concerns.
+> 16. Insecure documentation. Do not report any findings in documentation files such as markdown files.
+> 17. A lack of audit logs is not a vulnerability.
+>
+> PRECEDENTS -
+> 1. Logging high value secrets in plaintext is a vulnerability. Logging URLs is assumed to be safe.
+> 2. UUIDs can be assumed to be unguessable and do not need to be validated.
+> 3. Environment variables and CLI flags are trusted values. Attackers are generally not able to modify them in a secure environment. Any attack that relies on controlling an environment variable is invalid.
+> 4. Resource management issues such as memory or file descriptor leaks are not valid.
+> 5. Subtle or low impact web vulnerabilities such as tabnabbing, XS-Leaks, prototype pollution, and open redirects should not be reported unless they are extremely high confidence.
+> 6. React and Angular are generally secure against XSS. These frameworks do not need to sanitize or escape user input unless it is using dangerouslySetInnerHTML, bypassSecurityTrustHtml, or similar methods. Do not report XSS vulnerabilities in React or Angular components or tsx files unless they are using unsafe methods.
+> 7. Most vulnerabilities in github action workflows are not exploitable in practice. Before validating a github action workflow vulnerability ensure it is concrete and has a very specific attack path.
+> 8. A lack of permission checking or authentication in client-side JS/TS code is not a vulnerability. Client-side code is not trusted and does not need to implement these checks, they are handled on the server-side. The same applies to all flows that send untrusted data to the backend, the backend is responsible for validating and sanitizing all inputs.
+> 9. Only include MEDIUM findings if they are obvious and concrete issues.
+> 10. Most vulnerabilities in ipython notebooks (*.ipynb files) are not exploitable in practice. Before validating a notebook vulnerability ensure it is concrete and has a very specific attack path where untrusted input can trigger the vulnerability.
+> 11. Logging non-PII data is not a vulnerability even if the data may be sensitive. Only report logging vulnerabilities if they expose sensitive information such as secrets, passwords, or personally identifiable information (PII).
+> 12. Command injection vulnerabilities in shell scripts are generally not exploitable in practice since shell scripts generally do not run with untrusted user input. Only report command injection vulnerabilities in shell scripts if they are concrete and have a very specific attack path for untrusted input.
+>
+> SIGNAL QUALITY CRITERIA - For remaining findings, assess:
+> 1. Is there a concrete, exploitable vulnerability with a clear attack path?
+> 2. Does this represent a real security risk vs theoretical best practice?
+> 3. Are there specific code locations and reproduction steps?
+> 4. Would this finding be actionable for a security team?
+>
+> For each finding, assign a confidence score from 1-10:
+> - 1-3: Low confidence, likely false positive or noise
+> - 4-6: Medium confidence, needs investigation
+> - 7-10: High confidence, likely true vulnerability
+
+START ANALYSIS:
+
+Begin your analysis now. Do this in 3 steps:
+
+1. Use a sub-task to identify vulnerabilities. Use the repository exploration tools to understand the codebase context, then analyze the PR changes for security implications. In the prompt for this sub-task, include all of the above.
+2. Then for each vulnerability identified by the above sub-task, create a new sub-task to filter out false-positives. Launch these sub-tasks as parallel sub-tasks. In the prompt for these sub-tasks, include everything in the "FALSE POSITIVE FILTERING" instructions.
+3. Filter out any vulnerabilities where the sub-task reported a confidence less than 8.
+
+Your final reply must contain the markdown report and nothing else."""
+
+
+def _security_review_private_prompt(
+ args: str, context: CommandContext
+) -> list[dict[str, Any]]:
+ """Build the real security-review prompt (marketplace-private path).
+
+ TS ignores ``_args`` (security-review.ts:205); we accept-and-ignore for signature
+ parity — no skill-style argument substitution happens here.
+ """
+ parsed = parse_frontmatter(SECURITY_REVIEW_MARKDOWN)
+ allowed_tools = parse_slash_command_tools_from_frontmatter(
+ parsed.frontmatter.get("allowed-tools")
+ )
+ # Bridge CommandContext -> ToolContext (only workspace_root/cwd are needed; the
+ # default permission_context is bypassPermissions, matching the in-process path).
+ from src.tool_system.context import ToolContext # lazy: keep tool_system off import chain
+
+ tool_context = ToolContext(workspace_root=context.workspace_root, cwd=context.cwd)
+ executor = make_bash_shell_executor(
+ tool_context, allowed_tools, slash_command_name="security-review"
+ )
+ processed = execute_shell_commands_in_prompt(
+ parsed.body, shell_executor=executor, slash_command_name="security-review"
+ )
+ return [{"type": "text", "text": processed}]
+
+
+SECURITY_REVIEW_COMMAND = create_moved_to_plugin_command(
+ name="security-review",
+ description="Complete a security review of the pending changes on the current branch",
+ progress_message="analyzing code changes for security risks",
+ plugin_name="security-review",
+ plugin_command="security-review",
+ get_prompt_while_marketplace_is_private=_security_review_private_prompt,
+)
diff --git a/src/command_system/shell_prompt.py b/src/command_system/shell_prompt.py
new file mode 100644
index 000000000..832465e99
--- /dev/null
+++ b/src/command_system/shell_prompt.py
@@ -0,0 +1,122 @@
+"""Shell-exec-at-prompt-build for slash commands.
+
+Python port of typescript/src/utils/promptShellExecution.ts (executeShellCommandsInPrompt)
+plus the BashTool-backed executor pattern from src/tool_system/tools/skill.py
+(_make_shell_executor). Reuses the public shell-block primitives already ported in
+src/skills/runtime_substitution.py (find_shell_blocks / format_shell_output /
+format_shell_error) rather than re-deriving the regexes.
+
+Two divergences from TS, both deliberate and matching the already-shipped skills path:
+ * No permission gate. TS runs ``hasPermissionsToUseTool`` and BLOCKS any command not
+ matched by ``alwaysAllowRules.command`` (throws MalformedCommandError, block never
+ runs). Python's ``BashTool.call()`` bypasses the registry gate and runs every
+ detected block unconditionally under the ToolContext's permission mode
+ (bypassPermissions by default). The parsed ``allowed_tools`` list is accepted for
+ parity but not enforced yet.
+ * No abort on failure. TS throws and aborts the whole prompt build; Python embeds the
+ formatted error inline (``[Error: …]``) and continues, returning a complete prompt
+ with visible error text (DEV-2: surface as visible errors, not silent drops, without
+ crashing). See src/skills/runtime_substitution.py:140-157.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Callable
+
+from src.skills.runtime_substitution import (
+ find_shell_blocks,
+ format_shell_error,
+ format_shell_output,
+)
+
+logger = logging.getLogger(__name__)
+
+# (command, inline) -> rendered text to splice in. Identical contract to
+# runtime_substitution.ShellExecutor.
+ShellExecutor = Callable[[str, bool], str]
+
+
+def execute_shell_commands_in_prompt(
+ text: str,
+ *,
+ shell_executor: ShellExecutor,
+ slash_command_name: str = "",
+) -> str:
+ """Replace embedded shell blocks in ``text`` with their executed output.
+
+ Port of executeShellCommandsInPrompt (promptShellExecution.ts:73). Scans for
+ ``` ```! ... ``` ``` (fenced) and ``!`...``` (inline) forms via
+ ``find_shell_blocks``, runs each through ``shell_executor`` (which formats its own
+ success/error text), and splices the result back in.
+
+ Execution is sequential in ``find_shell_blocks`` order (TS uses ``Promise.all``);
+ this is collision-safe because ``str.replace(full_match, replacement, 1)`` targets
+ the first occurrence and the detected ``full_match`` strings do not nest. Unlike TS
+ — which raises MalformedCommandError on permission/exec failure and aborts the build
+ — a crashing executor is caught here and rendered inline so prompt-build never dies.
+ """
+ blocks = find_shell_blocks(text)
+ if not blocks:
+ return text
+ result = text
+ for full_match, command, inline in blocks:
+ try:
+ replacement = shell_executor(command, inline)
+ except Exception as exc: # noqa: BLE001 — surface anything, never crash prompt build
+ logger.exception(
+ "shell executor crashed for %s command %r", slash_command_name, command
+ )
+ replacement = format_shell_error(exc, full_match, inline=inline)
+ result = result.replace(full_match, replacement, 1)
+ return result
+
+
+def make_bash_shell_executor(
+ tool_context, # src.tool_system.context.ToolContext
+ allowed_tools: list[str] | None,
+ *,
+ slash_command_name: str,
+) -> ShellExecutor:
+ """Return a BashTool-backed ShellExecutor. Mirrors skill.py:_make_shell_executor.
+
+ ``allowed_tools`` is accepted for TS parity (TS injects it as
+ ``alwaysAllowRules.command`` for the call) but is NOT enforced — the Python
+ ``BashTool.call()`` path bypasses the registry permission gate and runs under the
+ ToolContext's permission mode (bypassPermissions by default). Precise injection is
+ deferred, identical to the documented skill limitation. ``BashTool`` is imported
+ lazily so importing this module never pulls ``tool_system`` onto the
+ ``command_system`` import chain.
+ """
+ from src.tool_system.tools.bash import BashTool # lazy: keep tool_system off import chain
+
+ _ = allowed_tools # acknowledged; precise injection deferred
+
+ def _exec(command: str, inline: bool) -> str:
+ try:
+ tr = BashTool.call({"command": command}, tool_context)
+ except Exception as exc: # noqa: BLE001 — surface every failure inline
+ return format_shell_error(exc, command, inline=inline)
+
+ output = tr.output if isinstance(tr.output, dict) else {}
+ stdout = str(output.get("stdout", ""))
+ stderr = str(output.get("stderr", ""))
+ exit_code = output.get("exit_code")
+
+ # Non-zero exit: embed the failure text inline (matches TS' ShellError) but keep
+ # going so the rest of the prompt still renders.
+ if isinstance(exit_code, int) and exit_code != 0:
+ err_text = format_shell_output(stdout, stderr, inline=inline)
+ err_text = err_text or f"command failed (exit {exit_code})"
+ return format_shell_error(err_text, command, inline=inline)
+
+ if tr.is_error:
+ err_text = (
+ format_shell_output(stdout, stderr, inline=inline)
+ or output.get("error")
+ or "command failed"
+ )
+ return format_shell_error(str(err_text), command, inline=inline)
+
+ return format_shell_output(stdout, stderr, inline=inline)
+
+ return _exec
diff --git a/src/command_system/skills_integration.py b/src/command_system/skills_integration.py
index 187a29048..04267dbba 100644
--- a/src/command_system/skills_integration.py
+++ b/src/command_system/skills_integration.py
@@ -21,21 +21,28 @@
)
from ..skills.model import Skill as BaseSkill
from .argument_substitution import substitute_arguments
-from .registry import CommandRegistry, register_command
-from .types import Command, CommandType, PromptCommand
+from .registry import CommandRegistry, get_command_registry, register_command
+from .types import Command, CommandType, PromptCommand, SkillPromptCommand
def skill_to_prompt_command(skill: PromptSkill) -> PromptCommand:
"""
- Convert a PromptSkill to a PromptCommand.
+ Convert a PromptSkill to a ``SkillPromptCommand``.
+
+ Returns a ``SkillPromptCommand`` (a ``PromptCommand`` subclass) so that when
+ this command is executed via the registry, its prompt is rendered through the
+ same ``_run_markdown_skill`` path the Skill tool uses — preserving the
+ base-dir header, ``${CLAUDE_SKILL_DIR}`` / ``${CLAUDE_SESSION_ID}``
+ substitution, and gated shell-exec. The plain base ``PromptCommand`` renderer
+ would drop all of that (P0-6 Option B / Phase 3.5).
Args:
skill: The PromptSkill to convert
Returns:
- PromptCommand instance
+ SkillPromptCommand instance (typed as its ``PromptCommand`` base)
"""
- return PromptCommand(
+ return SkillPromptCommand(
name=skill.name,
description=skill.description,
progress_message=f"Executing {skill.name}...",
@@ -56,6 +63,13 @@ def skill_to_prompt_command(skill: PromptSkill) -> PromptCommand:
user_invocable=skill.user_invocable,
loaded_from=skill.loaded_from,
is_hidden=skill.is_hidden,
+ # R2 (Phase 3 / P0-4): propagate the loader-computed flag so the
+ # model-tool views can distinguish a real author-written description
+ # from an auto-derived first-line one. ``get_slash_command_tool_skills``
+ # *requires* this (or ``when_to_use``) to include a skill; without it
+ # the predicate could never fire for managed/MCP/plugin skills. See
+ # my-docs/get-parity-by-folder/commands-phase3-model-tool-exposure-plan.md §6 R2.
+ has_user_specified_description=skill.has_user_specified_description,
)
@@ -82,6 +96,31 @@ def load_and_register_skills(
"""
Load all skills and register them as commands.
+ .. note:: **P0-6 Option B / Phase 3.5 — this IS now called at bootstrap.**
+
+ The REPL and TUI call this at startup (after builtins are registered)
+ so user-invocable skills resolve via ``CommandRegistry.get(name)`` and
+ execute through the global-registry path in
+ :func:`~src.command_system.builtins.execute_command_async`. This is safe
+ only because :func:`skill_to_prompt_command` now produces a
+ ``SkillPromptCommand``, whose renderer is byte-for-byte identical to the
+ Skill tool's (``_run_markdown_skill``). Registering plain
+ ``PromptCommand`` instances here would *regress* execution to bare
+ argument substitution — that hazard is what kept Phase 3 on Option A.
+
+ **Call globally (``registry=None``).** Execution reads the GLOBAL
+ registry (``builtins.py`` hardcodes ``get_command_registry()``), not the
+ REPL-local one, so a skill registered only into a local registry would
+ never execute. Registering global-only (and *not* into the REPL's local
+ registry) also keeps bare ``/myskill`` routing to the command palette,
+ since the REPL seeds ``_built_in_commands`` from its local registry.
+
+ **Shadowing guard (below).** Builtins are registered first and win:
+ a skill whose name collides with an existing command (or alias) is
+ skipped rather than silently overwriting it (``registry.py`` register is
+ unconditional). See
+ my-docs/get-parity-by-folder/commands-phase3.5-skill-registration-plan.md §4 D-4.
+
Args:
project_root: Optional project root directory
user_skills_dir: Optional user skills directory
@@ -95,13 +134,19 @@ def load_and_register_skills(
user_skills_dir=user_skills_dir,
)
+ target = registry if registry is not None else get_command_registry()
+
registered_commands: list[PromptCommand] = []
for skill in skills:
+ # Shadowing guard (N2): never overwrite a name already in the target
+ # registry. Builtins register first, so a skill colliding with a builtin
+ # name or alias is skipped and the builtin wins — matching the
+ # aggregator's precedence and avoiding the unconditional overwrite at
+ # registry.py register(). Skill-vs-skill ties resolve first-wins.
+ if target.get(skill.name) is not None:
+ continue
command = skill_to_prompt_command(skill)
- if registry:
- registry.register(command)
- else:
- register_command(command)
+ target.register(command)
registered_commands.append(command)
return registered_commands
diff --git a/src/command_system/statusline.py b/src/command_system/statusline.py
new file mode 100644
index 000000000..da2047412
--- /dev/null
+++ b/src/command_system/statusline.py
@@ -0,0 +1,58 @@
+"""statusline — Python port of TS commands/statusline.tsx (type: 'prompt').
+
+A dynamic ``prompt`` command: with no args it defaults the inner prompt to a
+fixed phrase; otherwise it embeds the user's trimmed args. The single text block
+instructs the model to spawn a ``statusline-setup`` subagent.
+
+Note: that subagent is not yet registered in Python (``src/agent/agent_definitions.py``
+ships only general-purpose/Explore/Plan/fork), so the emitted prompt is runtime-inert
+today — faithfully matching TS, where the command also only emits naming text and
+relies on a separately-registered agent. Porting ``tools/AgentTool/built-in/
+statuslineSetup.ts`` is a deferred follow-up.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from src.agent.constants import AGENT_TOOL_NAME
+
+from .types import CommandContext, PromptCommand
+
+# TS: args.trim() || 'Configure my statusLine from my shell PS1 configuration'
+_DEFAULT_STATUSLINE_INSTRUCTION = (
+ "Configure my statusLine from my shell PS1 configuration"
+)
+
+
+@dataclass(frozen=True)
+class StatuslineCommand(PromptCommand):
+ """Overrides get_prompt_for_command for the default-when-empty + f-string
+ template TS uses, which the base $ARGUMENTS substitution cannot express.
+
+ Frozen like its base (mirrors MovedToPluginCommand); adds no data fields, so
+ it stays comparable by the inherited fields (equality-by-data). Not hashable —
+ the base carries mutable list fields (allowed_tools/aliases) — but nothing
+ hashes commands; registration/dedupe is by .name.
+ """
+
+ async def get_prompt_for_command(
+ self, args: str, context: CommandContext
+ ) -> list[dict[str, Any]]:
+ prompt = args.strip() or _DEFAULT_STATUSLINE_INSTRUCTION
+ text = (
+ f'Create an {AGENT_TOOL_NAME} with subagent_type "statusline-setup" '
+ f'and the prompt "{prompt}"'
+ )
+ return [{"type": "text", "text": text}]
+
+
+STATUSLINE_COMMAND = StatuslineCommand(
+ name="statusline",
+ description="Set up OpenClaude's status line UI",
+ progress_message="setting up statusLine",
+ content_length=0, # TS contentLength: 0 (dynamic content)
+ source="builtin", # TS source: 'builtin'
+ allowed_tools=[AGENT_TOOL_NAME, "Read(~/**)", "Edit(~/.claude/settings.json)"],
+ disable_non_interactive=True, # TS disableNonInteractive: true
+)
diff --git a/src/command_system/stickers_command.py b/src/command_system/stickers_command.py
new file mode 100644
index 000000000..4347ad206
--- /dev/null
+++ b/src/command_system/stickers_command.py
@@ -0,0 +1,34 @@
+"""stickers — ``/stickers`` open the sticker page (port of TS ``type:'local'``).
+
+Port of ``typescript/src/commands/stickers/``. Opens the sticker-order page in the
+default browser via stdlib :mod:`webbrowser` (the ``openBrowser`` analog); on failure
+falls back to printing the URL. Messages verbatim from ``stickers.ts``.
+"""
+from __future__ import annotations
+
+from .types import CommandContext, LocalCommand, LocalCommandResult
+
+_URL = "https://www.stickermule.com/claudecode"
+
+
+def stickers_command_call(args: str, context: CommandContext) -> LocalCommandResult:
+ try:
+ import webbrowser
+
+ success = webbrowser.open(_URL)
+ except Exception:
+ success = False
+ if success:
+ return LocalCommandResult(type="text", value="Opening sticker page in browser…")
+ return LocalCommandResult(type="text", value=f"Failed to open browser. Visit: {_URL}")
+
+
+STICKERS_COMMAND = LocalCommand(
+ name="stickers",
+ description="Order OpenClaude stickers", # verbatim TS index.ts
+ supports_non_interactive=False, # verbatim TS index.ts
+)
+STICKERS_COMMAND.set_call(stickers_command_call)
+
+
+__all__ = ["STICKERS_COMMAND", "stickers_command_call"]
diff --git a/src/command_system/tasks_command.py b/src/command_system/tasks_command.py
new file mode 100644
index 000000000..3309284cf
--- /dev/null
+++ b/src/command_system/tasks_command.py
@@ -0,0 +1,72 @@
+"""tasks — ``/tasks`` background-task list (port of TS local-jsx, degraded).
+
+TS ``/tasks`` (``commands/tasks/``) renders ``BackgroundTasksDialog`` — it **lists and
+manages** (kill) background tasks. Python's TUI ``/tasks`` focuses a live task panel
+(app-bound). This registry port provides the **headless list** — the running tasks ARE
+reachable from the command surface via ``context.tool_context.runtime_tasks`` (the same
+``RuntimeTaskRegistry`` tools write to) — and **drops management** (kill needs the
+app/async ``Task.kill`` path). The TUI keeps its live panel (inversion).
+
+Follows the output-style/``/mcp`` precedent: ``run()`` returns text **without touching
+``ctx.ui``**, so it works on every surface; on the SDK (where ``tool_context`` is None)
+it reports the list is unavailable rather than raising.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+)
+
+
+def _coerce(x: Any) -> Any:
+ """Defensive: a ``.value``-bearing object → its ``.value``, else the value unchanged.
+ ``TaskType``/``TaskStatus`` are ``Literal[str]`` today (already plain strings, so this
+ just passes them through) — this only future-proofs against an enum/wrapper."""
+ return getattr(x, "value", x)
+
+
+def _format_task(t: Any) -> str:
+ status = _coerce(getattr(t, "status", "")) or "?"
+ label = getattr(t, "description", "") or _coerce(getattr(t, "type", "")) or "(task)"
+ return f"[{status}] {label} (id: {getattr(t, 'id', '')})"
+
+
+@dataclass(frozen=True)
+class TasksCommand(InteractiveCommand):
+ """List the running background tasks. Frozen + no new fields (the ``McpCommand``
+ pattern); ``run()`` returns text without touching ``ctx.ui``."""
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ tc = getattr(context, "tool_context", None)
+ registry = getattr(tc, "runtime_tasks", None) if tc is not None else None
+ if registry is None:
+ # SDK / listing surfaces have no live task registry.
+ return InteractiveOutcome(
+ message="Background tasks are unavailable on this surface.",
+ display="system",
+ )
+ try:
+ tasks = list(registry.all())
+ except Exception:
+ tasks = []
+ if not tasks:
+ return InteractiveOutcome(message="No background tasks.", display="system")
+ lines = [f"• {_format_task(t)}" for t in tasks]
+ return InteractiveOutcome(
+ message="Background tasks:\n" + "\n".join(lines), display="system"
+ )
+
+
+TASKS_COMMAND = TasksCommand(
+ name="tasks",
+ description="List and manage background tasks", # verbatim TS (port only LISTS)
+ aliases=["bashes"], # verbatim TS index.ts
+)
+
+
+__all__ = ["TASKS_COMMAND", "TasksCommand"]
diff --git a/src/command_system/theme_command.py b/src/command_system/theme_command.py
new file mode 100644
index 000000000..1ef75d3e4
--- /dev/null
+++ b/src/command_system/theme_command.py
@@ -0,0 +1,131 @@
+"""theme — interactive ``/theme`` command (port of TS local-jsx).
+
+Port of ``typescript/src/commands/theme/`` (``theme.tsx`` + ``index.ts``). A
+``local-jsx`` command becomes an :class:`InteractiveCommand` (blocked remotely by
+type). Unlike ``/export`` — which proves its bridge wiring by *falling through* the
+TUI dispatch so the registry arm runs — ``/theme`` is the **inverse**: the TUI keeps
+intercepting ``/theme`` (``commands.py`` → ``open_dialog="theme"``) to preserve its
+rich **live-preview** ``ThemePickerScreen``. This command therefore serves only the
+surfaces that *consult the registry*: the REPL (numbered-menu ``select``), the SDK
+(``NullUIHost`` → clean error), and the help/aggregator listings — where ``/theme``
+was previously invisible because it lived only in the TUI's private ``LOCAL_BUILTINS``.
+
+Faithfulness to TS (``theme.tsx``):
+
+ * ``call(onDone, _context)`` **ignores args** — the picker is the only path. We do
+ not parse args and add no headless write path; on ``NullUIHost`` (SDK) the
+ ``select`` below raises → the engine returns a clean "needs an interactive
+ surface" error. There is no ``/export``-style headless keystone because TS
+ ``/theme`` has none.
+ * **Success → ``display="user"``.** TS ``onDone(f"Theme set to {setting}")`` passes
+ **no** options, so ``processSlashCommand`` routes it through ``createUserMessage``
+ (model-visible) with ``shouldQuery ?? false``. Python's faithful equivalent is
+ ``display="user"`` — the engine's own value for model-visible content
+ (``CommandResult.success_prompt``) — with the default ``should_query=False``.
+ * **Cancel → "Theme picker dismissed" / ``display="system"``**, **not** ``skip()``.
+ Faithful to TS ``onDone("Theme picker dismissed", {display:"system"})`` (a
+ user-visible system line). Cancel does **not** persist.
+
+Deliberate divergences (documented for parity review):
+
+ * **Python's own theme set.** Options come from :func:`list_theme_names`
+ (``auto``/``dark``/``light``/``claude``) — the same source the TUI
+ ``ThemePickerScreen`` uses, so both surfaces stay in lockstep — not TS's
+ ``light-daltonized``/``*-ansi`` variants (no Python ``Palette`` exists for them)
+ nor TS's flag-gated ``auto`` (Python offers ``auto`` unconditionally). Labels are
+ the **raw** theme names, mirroring ``ThemePickerScreen``'s ``SelectOption(label=
+ name…)``, not TS's friendly labels.
+ * **Persist-only, no live hot-swap.** The command_system surface has no ``App``
+ handle (``CommandContext`` carries workspace_root/cwd/conversation/ui, not the
+ Textual app), so "Theme set to {name}" means *persisted; applies on next launch*
+ on non-TUI surfaces. The live hot-swap stays the TUI dialog's job (``apply_theme``).
+
+``list_theme_names`` is imported **lazily** inside :func:`_theme_options` (not at
+module top) so a bare ``import src.command_system`` does not pull the heavy
+``src.tui`` package — ``src/tui/__init__.py`` eagerly imports the Textual app. This
+mirrors the local-import discipline already used in ``builtins.py`` (buddy) and
+``app.py`` (config).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from src.config import load_config, set_theme
+
+from .types import (
+ CommandContext,
+ InteractiveCommand,
+ InteractiveOutcome,
+ UIOption,
+)
+
+_DEFAULT_THEME = "dark"
+
+
+def _current_theme() -> str:
+ """The persisted theme, or ``"dark"`` — the same happy-path read as
+ ``app._resolve_theme_name()`` (``load_config().get("theme") or "dark"``).
+ ``_resolve_theme_name`` additionally wraps the read in ``try/except → "dark"``;
+ here we intentionally let a ``load_config()`` failure propagate, because the
+ engine wraps :meth:`ThemeCommand.run` and converts any exception into a clean
+ error ``CommandResult`` (whereas the TUI swallows it to keep rendering). The
+ *merged* read is correct here: we only need the *effective* current value to seed
+ the picker, not a write."""
+ return load_config().get("theme") or _DEFAULT_THEME
+
+
+def _theme_options(current: str) -> list[UIOption]:
+ """Build picker options from :func:`list_theme_names`, marking the option equal to
+ ``current`` with ``description="current"`` (the same marker the TUI
+ ``ThemePickerScreen`` shows). Labels are the raw theme names.
+
+ Imported lazily — see the module docstring."""
+ from src.tui.theme import list_theme_names
+
+ options: list[UIOption] = []
+ for name in list_theme_names():
+ desc = "current" if name == current else None
+ options.append(UIOption(value=name, label=name, description=desc))
+ return options
+
+
+@dataclass(frozen=True)
+class ThemeCommand(InteractiveCommand):
+ """Pick a color theme and persist it.
+
+ Frozen + no new fields (the ``ExportCommand``/``StatuslineCommand`` pattern);
+ behavior lives entirely in :meth:`run`.
+ """
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ # TS `call(onDone, _context)` ignores args — the picker is the only path.
+ # No args path => no headless keystone (unlike /export); on NullUIHost the
+ # select below raises -> the engine returns a clean error.
+ current = _current_theme()
+ options = _theme_options(current)
+ picked = await context.ui.select("Select theme:", options, current=current)
+ if picked is None:
+ # TS cancel: onDone("Theme picker dismissed", {display:"system"}).
+ # A visible system line — NOT skip() (which /export uses for Esc).
+ return InteractiveOutcome(
+ message="Theme picker dismissed", display="system"
+ )
+ set_theme(picked) # persist (TS setTheme -> setThemeSetting -> saveGlobalConfig)
+ # TS success: onDone("Theme set to …") with NO options => createUserMessage
+ # (model-visible, shouldQuery=false). Faithful map = display="user".
+ return InteractiveOutcome(message=f"Theme set to {picked}", display="user")
+
+
+THEME_COMMAND = ThemeCommand(
+ name="theme",
+ # Verbatim from typescript/src/commands/theme/index.ts. (The TUI popup shows
+ # "Change the color theme" from _LOCAL_BUILTIN_DESCRIPTIONS, which wins the
+ # suggestion dedup; this TS-verbatim text surfaces on the registry/SDK listings.)
+ description="Change the theme",
+)
+
+
+__all__ = [
+ "THEME_COMMAND",
+ "ThemeCommand",
+]
diff --git a/src/command_system/types.py b/src/command_system/types.py
index d86f8d526..c5078f915 100644
--- a/src/command_system/types.py
+++ b/src/command_system/types.py
@@ -8,6 +8,8 @@
from __future__ import annotations
+import asyncio
+
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
@@ -18,6 +20,12 @@ class CommandType(Enum):
"""Types of commands."""
PROMPT = "prompt"
LOCAL = "local"
+ # Ports TS ``type: 'local-jsx'`` — a command that drives an interactive
+ # UI (via the surface-agnostic ``UIHost`` port) and returns an
+ # ``InteractiveOutcome``. A *distinct* type (not a LocalCommand subtype)
+ # so the engine routes it to ``_execute_interactive`` and the
+ # remote-safety gate blocks it by type (see ``safe_commands``).
+ INTERACTIVE = "interactive"
class CommandAvailability(Enum):
@@ -54,6 +62,31 @@ class CommandContext:
cost_tracker: Any
history: Any
config: dict[str, Any] = field(default_factory=dict)
+ # Optional handles for commands that need to read or mutate
+ # cross-session state. Default to None so existing call sites that
+ # build CommandContext positionally / partially still work.
+ #
+ # ``app_state_store`` is the reactive AppState store created at TUI /
+ # REPL startup. /advisor uses it to read the current advisor model
+ # and to flip it via ``set_state`` (which fires the persistence
+ # handler in ``src/state/app_state.py``).
+ #
+ # ``provider`` is the currently-active LLM provider; /advisor checks
+ # it to decide whether the user can configure the advisor at all
+ # (only first-party Anthropic supports it).
+ app_state_store: Any = None
+ provider: Any = None
+ # ``ui`` is the surface-agnostic interaction port (a ``UIHost``) that
+ # interactive commands drive. None on surfaces that didn't wire one;
+ # the engine substitutes a ``NullUIHost`` (which raises for mutating
+ # prompts) so a command body can always assume ``ctx.ui`` is present.
+ ui: Any = None # UIHost | None
+ # ``tool_context`` is the surface's ToolContext (REPL/TUI), threaded
+ # through so a SkillPromptCommand can render with the *same* session id +
+ # shell executor the model's Skill tool uses (P0-6 Option B / Phase 3.5).
+ # None on listing/aggregation paths, which never call
+ # get_prompt_for_command, so the default is correct there.
+ tool_context: Any = None # ToolContext | None
# Protocol for local command callables
@@ -75,7 +108,8 @@ class CommandBase:
version: Optional[str] = None
disable_model_invocation: bool = False
user_invocable: bool = True
- loaded_from: str = "builtin" # "builtin" | "skills" | "plugin" | "bundled" | "mcp"
+ loaded_from: str = "builtin" # "builtin" | "skills" | "plugin" | "bundled" | "mcp" | "commands_DEPRECATED"
+ is_mcp: bool = False # True for commands sourced from an MCP server (TS CommandBase.isMcp)
kind: Optional[str] = None # "workflow" or None
immediate: bool = False
is_sensitive: bool = False
@@ -125,6 +159,73 @@ async def get_prompt_for_command(
return [{"type": "text", "text": content}]
+@dataclass(frozen=True)
+class SkillPromptCommand(PromptCommand):
+ """A PromptCommand backed by a markdown skill (P0-6 Option B / Phase 3.5).
+
+ The base ``PromptCommand.get_prompt_for_command`` does only bare argument
+ substitution, which is lossy for skills (it drops the base-dir header,
+ ``${CLAUDE_SKILL_DIR}`` / ``${CLAUDE_SESSION_ID}`` substitution, and gated
+ shell-exec). This subclass overrides it with a render that is identical *by
+ construction* to the model's Skill-tool path: when the surface threads its
+ ``ToolContext`` onto the ``CommandContext`` (REPL/TUI execution), it
+ delegates to ``_run_markdown_skill`` — the same function the Skill tool runs
+ — re-resolving the skill by ``self.name`` so session id and shell-exec are
+ byte-for-byte the same. Off that path (no ToolContext: SDK / listing
+ callers) it degrades to a headless render with no shell executor.
+ """
+
+ async def get_prompt_for_command(
+ self,
+ args: str,
+ context: CommandContext,
+ ) -> list[dict[str, Any]]:
+ tc = getattr(context, "tool_context", None)
+ if tc is not None:
+ # Function-scope import: command_system must not import tool_system
+ # at module load (would cycle). The edge is one private helper, and
+ # command_system already imports ..skills.
+ from src.tool_system.tools.skill import _run_markdown_skill
+
+ # ``_run_markdown_skill`` is sync and may block (disk I/O + a gated
+ # BashTool subprocess for embedded shell blocks). Run it off the
+ # event loop so neither the REPL thread-pool loop nor the Textual
+ # loop stalls. Note: it mutates a process-global skill registry
+ # (clear + re-resolve), which is unsynchronized — safe only because
+ # both surfaces serialize command dispatch, so no two renders run
+ # concurrently. A future concurrent-dispatch change would need a lock.
+ res = await asyncio.to_thread(
+ _run_markdown_skill, self.name, args or "", tc
+ )
+ payload = res.output if isinstance(res.output, dict) else {}
+ prompt = payload.get("prompt")
+ if isinstance(prompt, str) and prompt.strip():
+ return [{"type": "text", "text": prompt}]
+ # error / empty (e.g. the skill was removed since registration) →
+ # fall through to a headless render from the cached fields.
+ return self._render_headless(args)
+
+ def _render_headless(self, args: str) -> list[dict[str, Any]]:
+ """Best-effort render with no ToolContext: base-dir header + argument /
+ ``${CLAUDE_SKILL_DIR}`` / ``${CLAUDE_SESSION_ID}`` substitution, with
+ embedded shell blocks left verbatim (no executor). Only reachable off
+ the REPL/TUI execution paths, which always thread a ToolContext."""
+ from src.bootstrap.state import get_session_id
+ from src.skills.runtime_substitution import render_skill_prompt
+
+ text = render_skill_prompt(
+ body=self.markdown_content,
+ args=args or "",
+ base_dir=self.skill_root,
+ argument_names=self.arg_names,
+ session_id=get_session_id(),
+ loaded_from=self.loaded_from,
+ slash_command_name=f"/{self.name}",
+ shell_executor=None,
+ )
+ return [{"type": "text", "text": text}]
+
+
@dataclass(frozen=True)
class LocalCommand(CommandBase):
"""A command that executes local code."""
@@ -146,8 +247,169 @@ async def call(self, args: str, context: CommandContext) -> LocalCommandResult:
return LocalCommandResult(type="text", value=f"Command {self.name} not implemented")
+# ---------------------------------------------------------------------------
+# Interactive command bridge (port of TS ``local-jsx`` commands)
+# ---------------------------------------------------------------------------
+#
+# TS interactive commands (``type: 'local-jsx'``) render an Ink element and
+# resolve via an ``onDone`` callback. The Python port replaces "render an
+# element" with "drive a surface-agnostic ``UIHost`` port", so one command
+# body works headless (REPL), in the Textual TUI, and (raising) under the
+# SDK / non-interactive null surface. See
+# my-docs/get-parity-by-folder/commands-phase2-interactive-bridge-plan.md.
+
+
+@dataclass(frozen=True)
+class UIOption:
+ """One selectable row passed to :meth:`UIHost.select`.
+
+ ``value`` is what the host returns on selection; ``label`` is shown to
+ the user; ``description`` is optional secondary text (rendered dim in
+ the TUI, parenthesized in the REPL menu).
+ """
+
+ value: str
+ label: str
+ description: Optional[str] = None
+
+
+class InteractiveUnavailableError(RuntimeError):
+ """Raised by :class:`NullUIHost` when an interactive command tries to
+ prompt on a surface with no UI (SDK / non-interactive). The engine turns
+ this into a clean error ``CommandResult`` rather than a crash.
+ """
+
+
+class UIHost(Protocol):
+ """Surface-agnostic interaction port injected as ``CommandContext.ui``.
+
+ Adapters: ``ReplUIHost`` (numbered menu), ``TextualUIHost``
+ (``push_screen_wait`` modal), ``NullUIHost`` (raises for the mutating
+ ``select``; ``display`` no-ops). Mirrors the TS pattern of injecting host
+ callbacks into the command context.
+
+ The slice ships the primitives in-scope Class-B commands need:
+ ``select`` (single choice), ``prompt_text`` (free-text line), plus
+ read-only ``display``. ``prompt_text`` lands with its first consumer
+ ``/export``. ``confirm`` stays deferred — TS expresses it as a 2-option
+ ``select`` over Yes/No, so it needs no new method. The port grows by
+ adding a method here and one line per adapter.
+ """
+
+ async def select(
+ self,
+ title: str,
+ options: Sequence[UIOption],
+ *,
+ current: Optional[str] = None,
+ ) -> Optional[str]:
+ """Prompt the user to pick one option. Returns the chosen
+ ``UIOption.value``, or ``None`` if cancelled."""
+ ...
+
+ async def prompt_text(
+ self,
+ title: str,
+ *,
+ default: str = "",
+ placeholder: Optional[str] = None,
+ ) -> Optional[str]:
+ """Prompt for a single free-text line. Returns the submitted string,
+ which MAY be ``''`` — an empty submit is valid input, not a cancel
+ (mirrors TS ``TextInput.onSubmit('')``). Returns ``None`` *only* when
+ cancelled (Esc / EOF / Ctrl-C).
+
+ ``default`` pre-fills the editable value; ``placeholder`` is a hint
+ shown while the field is empty and is never submitted."""
+ ...
+
+ async def display(self, title: str, body: str) -> None:
+ """Show read-only information. No return value."""
+ ...
+
+
+class NullUIHost:
+ """UIHost for surfaces without a UI (SDK / non-interactive).
+
+ The *mutating* primitives :meth:`select` and :meth:`prompt_text` raise
+ :class:`InteractiveUnavailableError` — deliberately NOT returning a
+ default/``current`` value, which would read as a false success. Only the
+ read-only :meth:`display` no-ops. (Resolved contract — see plan §4/§7.)
+ """
+
+ _MSG = "This command needs an interactive surface (TUI or REPL)."
+
+ async def select(
+ self,
+ title: str,
+ options: "Sequence[UIOption]",
+ *,
+ current: Optional[str] = None,
+ ) -> Optional[str]:
+ raise InteractiveUnavailableError(self._MSG)
+
+ async def prompt_text(
+ self,
+ title: str,
+ *,
+ default: str = "",
+ placeholder: Optional[str] = None,
+ ) -> Optional[str]:
+ raise InteractiveUnavailableError(self._MSG)
+
+ async def display(self, title: str, body: str) -> None:
+ return None
+
+
+@dataclass(frozen=True)
+class InteractiveOutcome:
+ """What an interactive command returns to the engine — the Python
+ analogue of the TS ``onDone`` payload (``command.ts:117-126``).
+
+ The engine maps this onto a :class:`CommandResult`, propagating
+ ``display`` / ``should_query`` / ``meta_messages`` (which the LOCAL arm
+ hardcodes away). ``display == "skip"`` signals "produce no output" (TS
+ ``display: 'skip'``); use :meth:`skip` for the cancelled path.
+ """
+
+ message: Optional[str] = None # TS onDone.result
+ display: str = "system" # "skip" | "system" | "user"
+ should_query: bool = False # TS onDone.shouldQuery
+ meta_messages: list[str] = field(default_factory=list) # TS onDone.metaMessages
+
+ @classmethod
+ def skip(cls) -> "InteractiveOutcome":
+ """Cancelled / no-op outcome — the engine returns
+ ``CommandResult.skip`` for it."""
+ return cls(display="skip")
+
+
+@dataclass(frozen=True)
+class InteractiveCommand(CommandBase):
+ """Base for commands that drive ``ctx.ui`` and return an
+ :class:`InteractiveOutcome`.
+
+ Reports ``CommandType.INTERACTIVE`` so the engine routes it to
+ ``_execute_interactive`` and the remote-safety gate blocks it *by type*
+ (``safe_commands.is_bridge_safe_command``). Concrete commands subclass
+ this and override :meth:`run` (the ``StatuslineCommand`` pattern — no new
+ dataclass fields required).
+ """
+
+ @property
+ def command_type(self) -> CommandType:
+ return CommandType.INTERACTIVE
+
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ """Drive ``context.ui`` and return the outcome. Override in
+ subclasses."""
+ raise NotImplementedError(
+ "InteractiveCommand subclasses must implement run()"
+ )
+
+
# Type alias for any command
-Command = PromptCommand | LocalCommand
+Command = PromptCommand | LocalCommand | InteractiveCommand
def get_command_name(cmd: CommandBase) -> str:
diff --git a/src/command_system/vim_command.py b/src/command_system/vim_command.py
new file mode 100644
index 000000000..c65f4db20
--- /dev/null
+++ b/src/command_system/vim_command.py
@@ -0,0 +1,61 @@
+"""vim — ``/vim`` editor-mode toggle (port of TS ``type:'local'``).
+
+Port of ``typescript/src/commands/vim/``. Toggles ``editorMode`` between ``"vim"`` and
+``"normal"`` in the global config (the TS ``saveGlobalConfig`` channel; legacy
+``"emacs"`` reads as ``"normal"``) and reports the new mode verbatim.
+
+**Functional half:** the TUI seeds ``PromptInput(vim_mode=...)`` from this config key at
+construction (``src/tui/screens/repl.py``) — so the persisted mode genuinely enables the
+existing vim subsystem (``VimState`` et al.) on the next TUI launch. Divergences:
+TS applies the toggle live (Ink re-reads config); the registry command has no widget
+handle and the TUI has no non-dialog intercept mechanism, so Python applies on the
+**next TUI launch** (``PromptInput.set_vim_mode`` exists for a future live intercept).
+The prompt-toolkit REPL has no vim editing implementation — the mode takes effect in
+the TUI.
+"""
+from __future__ import annotations
+
+from .types import CommandContext, LocalCommand, LocalCommandResult
+
+
+def initial_vim_mode() -> bool:
+ """Best-effort seed for ``PromptInput(vim_mode=...)``: True iff the persisted
+ ``editorMode`` is ``"vim"`` (False on unset/normal/legacy-emacs/any error)."""
+ try:
+ from src.config import load_config
+
+ return load_config().get("editorMode") == "vim"
+ except Exception:
+ return False
+
+
+def vim_command_call(args: str, context: CommandContext) -> LocalCommandResult:
+ from src.config import _get_default_manager, load_config
+
+ current = load_config().get("editorMode") or "normal"
+ if current == "emacs": # TS back-compat: treat 'emacs' as 'normal'
+ current = "normal"
+ new_mode = "vim" if current == "normal" else "normal"
+ _get_default_manager().set_global("editorMode", new_mode)
+
+ if new_mode == "vim":
+ suffix = "Use Escape key to toggle between INSERT and NORMAL modes."
+ else:
+ suffix = "Using standard (readline) keyboard bindings."
+ # Divergence from TS (which applies the toggle live): the trailing note keeps the
+ # mid-session instruction honest — the seed is read at PromptInput construction.
+ return LocalCommandResult(
+ type="text",
+ value=f"Editor mode set to {new_mode}. {suffix} Takes effect on next TUI launch.",
+ )
+
+
+VIM_COMMAND = LocalCommand(
+ name="vim",
+ description="Toggle between Vim and Normal editing modes", # verbatim TS index.ts
+ supports_non_interactive=False, # verbatim TS index.ts
+)
+VIM_COMMAND.set_call(vim_command_call)
+
+
+__all__ = ["VIM_COMMAND", "vim_command_call", "initial_vim_mode"]
diff --git a/src/command_system/workflows_command.py b/src/command_system/workflows_command.py
new file mode 100644
index 000000000..dd06350f8
--- /dev/null
+++ b/src/command_system/workflows_command.py
@@ -0,0 +1,71 @@
+"""``/workflows`` — list running and recent dynamic-workflow runs.
+
+The headless-capable core of the workflow view (mirrors ``/tasks``): reads the
+shared ``runtime_tasks`` registry and reports each ``local_workflow`` task with
+its status, name, and live progress summary. Works on every surface without
+touching ``ctx.ui``.
+
+The rich TUI drill-down (phases → agents with the ``p``/``x``/``r``/``s`` key
+bindings from the spec) is a follow-up; the per-run/per-agent control API it
+needs already exists (``kill_workflow_task`` / ``skip_workflow_agent`` /
+``retry_workflow_agent`` in ``src/tasks/local_workflow.py``).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from src.workflow.gating import is_workflows_enabled
+
+from .types import CommandContext, InteractiveCommand, InteractiveOutcome
+
+
+def _format_workflow(t: Any) -> str:
+ status = getattr(t, "status", "?") or "?"
+ name = getattr(t, "workflow_name", "") or "workflow"
+ summary = getattr(t, "summary", None) or ""
+ run_id = getattr(t, "run_id", "") or ""
+ tail = f" — {summary}" if summary else ""
+ return f"[{status}] {name}{tail} (run: {run_id})"
+
+
+@dataclass(frozen=True)
+class WorkflowsCommand(InteractiveCommand):
+ async def run(self, args: str, context: CommandContext) -> InteractiveOutcome:
+ tc = getattr(context, "tool_context", None)
+ registry = getattr(tc, "runtime_tasks", None) if tc is not None else None
+ if registry is None:
+ return InteractiveOutcome(
+ message="Workflows are unavailable on this surface.", display="system"
+ )
+ try:
+ runs = [t for t in registry.all() if getattr(t, "type", None) == "local_workflow"]
+ except Exception:
+ runs = []
+ if not runs:
+ return InteractiveOutcome(
+ message="No workflow runs. Start one with /deep-research or by asking for a workflow.",
+ display="system",
+ )
+ from src.workflow.progress import render_run_lines
+
+ blocks: list[str] = []
+ for t in runs:
+ lines = render_run_lines(t)
+ run_id = getattr(t, "run_id", "") or ""
+ if run_id and lines:
+ lines[0] = f"{lines[0]} (run: {run_id})"
+ blocks.append("\n".join(lines))
+ return InteractiveOutcome(message="\n\n".join(blocks), display="system")
+
+
+WORKFLOWS_COMMAND = WorkflowsCommand(
+ name="workflows",
+ description="List running and recent dynamic workflows",
+ is_enabled=is_workflows_enabled,
+ kind="workflow",
+)
+
+
+__all__ = ["WORKFLOWS_COMMAND", "WorkflowsCommand"]
diff --git a/src/command_system/workflows_integration.py b/src/command_system/workflows_integration.py
new file mode 100644
index 000000000..759f6154b
--- /dev/null
+++ b/src/command_system/workflows_integration.py
@@ -0,0 +1,181 @@
+"""Contribute saved + bundled workflows as slash commands.
+
+Discovers ``.claude/workflows/*.py`` (project) and ``~/.claude/workflows/*.py``
+(personal), plus the bundled ``/deep-research``, and turns each into a
+``PromptCommand`` (``kind="workflow"``) that directs the model to launch it via
+the Workflow tool. Project workflows win over personal ones on a name clash.
+
+Mirrors the skills-integration pattern (``skills_integration.py``); failures
+degrade to fewer commands rather than breaking command listing.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING, Optional
+
+from src.workflow.bundled import bundled_workflow_path
+from src.workflow.gating import is_workflows_enabled
+from src.workflow.sandbox import extract_meta
+
+from .types import Command, PromptCommand
+
+if TYPE_CHECKING:
+ from .registry import CommandRegistry
+
+logger = logging.getLogger(__name__)
+
+
+def _directive(name: str, path: str) -> str:
+ return (
+ f'Launch the dynamic workflow "{name}" — do NOT do the work yourself. '
+ f"Call the Workflow tool with `script_path` set to \"{path}\" and pass the "
+ f"user's input as `args` (parse it as JSON if it looks structured, otherwise "
+ f"pass it as a string).\n\n"
+ f"The Workflow tool launches the run in the BACKGROUND and returns a `run_id` "
+ f"immediately. As soon as it returns, STOP: reply with one short sentence "
+ f"confirming the workflow started (mention the run_id) and END YOUR TURN. Do "
+ f"NOT wait for it, poll it, call any tool again, or write the report yourself — "
+ f"the finished report is delivered automatically when the run completes.\n\n"
+ f"$ARGUMENTS"
+ )
+
+
+def _workflow_to_command(path: Path, loaded_from: str) -> Optional[PromptCommand]:
+ try:
+ meta = extract_meta(path.read_text(encoding="utf-8"))
+ except Exception as exc: # noqa: BLE001 — a bad file shouldn't break listing
+ logger.debug("skipping invalid workflow %s: %s", path, exc)
+ return None
+ name = path.stem
+ return PromptCommand(
+ name=name,
+ description=meta.description,
+ kind="workflow",
+ loaded_from=loaded_from,
+ source=loaded_from,
+ is_enabled=is_workflows_enabled,
+ argument_hint="[args]",
+ when_to_use=meta.when_to_use or None,
+ markdown_content=_directive(name, str(path)),
+ )
+
+
+def _deep_research_command() -> Optional[PromptCommand]:
+ path = bundled_workflow_path("deep_research")
+ try:
+ meta = extract_meta(path.read_text(encoding="utf-8"))
+ except Exception as exc: # noqa: BLE001
+ logger.debug("bundled deep-research unavailable: %s", exc)
+ return None
+ return PromptCommand(
+ name="deep-research",
+ description=meta.description,
+ kind="workflow",
+ loaded_from="bundled",
+ source="bundled",
+ is_enabled=is_workflows_enabled,
+ argument_hint="",
+ markdown_content=_directive("deep-research", str(path)),
+ )
+
+
+def bundled_workflow_commands() -> list[Command]:
+ """The cwd-independent bundled workflow slash commands (currently
+ ``/deep-research``).
+
+ Surfaced via ``get_builtin_commands()`` so they register into the global
+ command registry that both command suggestions and dispatch read — the
+ aggregator's :func:`get_commands` that also lists them has no real consumers.
+ Project/personal workflows are cwd-dependent and remain the aggregator's job.
+
+ Includes the ``/workflows`` viewer so the Rich REPL can dispatch it (the TUI
+ has its own ``open_dialog`` fast-path, which runs before registry dispatch,
+ so registering here doesn't disturb it).
+ """
+ from .workflows_command import WORKFLOWS_COMMAND
+
+ out: list[Command] = [WORKFLOWS_COMMAND]
+ deep = _deep_research_command()
+ if deep is not None:
+ out.append(deep)
+ return out
+
+
+def _discover_dir(directory: Path, loaded_from: str) -> list[PromptCommand]:
+ commands: list[PromptCommand] = []
+ try:
+ if not directory.is_dir():
+ return commands
+ for path in sorted(directory.glob("*.py")):
+ cmd = _workflow_to_command(path, loaded_from)
+ if cmd is not None:
+ commands.append(cmd)
+ except OSError as exc:
+ logger.debug("workflow discovery failed for %s: %s", directory, exc)
+ return commands
+
+
+def load_workflow_commands(cwd: str) -> list[Command]:
+ """The ``/workflows`` view + bundled + project + personal workflow commands
+ (project wins over personal on a name clash)."""
+ from .workflows_command import WORKFLOWS_COMMAND
+
+ commands: list[Command] = [WORKFLOWS_COMMAND]
+ deep = _deep_research_command()
+ if deep is not None:
+ commands.append(deep)
+
+ # Project first so it reserves the name before the personal copy.
+ project = _discover_dir(Path(cwd) / ".claude" / "workflows", "project")
+ personal = _discover_dir(Path.home() / ".claude" / "workflows", "user")
+
+ seen = {c.name for c in commands}
+ for cmd in [*project, *personal]:
+ if cmd.name in seen:
+ continue
+ seen.add(cmd.name)
+ commands.append(cmd)
+ return commands
+
+
+def load_and_register_workflows(
+ project_root: str | Path | None = None,
+ registry: "CommandRegistry | None" = None,
+) -> list[PromptCommand]:
+ """Discover saved project/personal workflows and register them as ``/``
+ commands into the command registry (global if ``registry`` is ``None``).
+
+ This is the workflow analogue of :func:`load_and_register_skills`, and exists
+ for the same reason: the aggregator's ``get_commands()`` lists these but has
+ no real consumers, so dispatch + suggestions (which read the GLOBAL registry)
+ never saw saved workflows. The REPL/TUI call this at startup, right after
+ ``load_and_register_skills``.
+
+ Precedence, all via the shadowing guard (a name already in the target wins):
+ builtins/bundled (registered first) beat saved workflows; **project beats
+ personal** (project is enumerated first); workflow-vs-workflow ties are
+ first-wins. Only the discovered project/personal commands are registered —
+ bundled ``/deep-research`` + ``/workflows`` already come from
+ ``register_builtin_commands``.
+
+ Gated by :func:`is_workflows_enabled`; returns ``[]`` when workflows are off.
+ """
+ if not is_workflows_enabled():
+ return []
+
+ from .registry import get_command_registry
+
+ cwd = Path(project_root) if project_root is not None else Path.cwd()
+ project = _discover_dir(cwd / ".claude" / "workflows", "project")
+ personal = _discover_dir(Path.home() / ".claude" / "workflows", "user")
+
+ target = registry if registry is not None else get_command_registry()
+ registered: list[PromptCommand] = []
+ for cmd in [*project, *personal]: # project first → wins on a name clash
+ if target.get(cmd.name) is not None:
+ continue # builtin / bundled / earlier workflow already owns the name
+ target.register(cmd)
+ registered.append(cmd)
+ return registered
diff --git a/src/components/__init__.py b/src/components/__init__.py
index 68bd81d3c..637bfde33 100644
--- a/src/components/__init__.py
+++ b/src/components/__init__.py
@@ -1,4 +1,11 @@
-"""Python package placeholder for the archived `components` subsystem."""
+"""Placeholder for parity with ``typescript/src/components/``.
+
+The real Python implementation of the terminal-UI component tree lives at
+``src/tui/widgets/`` (Textual widgets) and ``src/tui/screens/`` (Textual
+screens). This module exists so the ``reference_data`` subsystem snapshots
+resolve and so the namespace is reserved; do not add behavior here. See
+``my-docs/ch13-terminal-ui-gap-analysis.md`` gap #11 for the rationale.
+"""
from __future__ import annotations
diff --git a/src/config.py b/src/config.py
index c2cf4b598..4bd0cf3a2 100644
--- a/src/config.py
+++ b/src/config.py
@@ -93,10 +93,17 @@ def _read_json(path: Path) -> dict[str, Any]:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
+ data = json.load(f)
except Exception as exc:
logger.debug("Failed to read config %s: %s", path, exc)
return {}
+ if not isinstance(data, dict):
+ # A non-object top level previously flowed into _deep_merge and
+ # raised AttributeError at the call site (C6 review M2) — treat
+ # it like any other unreadable config: ignored.
+ logger.debug("Ignoring non-object config %s", path)
+ return {}
+ return data
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
@@ -226,6 +233,76 @@ def set_project(self, key: str, value: Any) -> None:
self.save_project(cfg)
+# ---------------------------------------------------------------------------
+# Per-project state in the GLOBAL config (TS config.projects[path])
+# ---------------------------------------------------------------------------
+# TS keeps per-project security state (hasTrustDialogAccepted,
+# hasClaudeMdExternalIncludesApproved, ...) in the USER-owned global
+# config keyed by project path — deliberately NOT in the committable
+# project config, so a checked-out repo can never pre-accept its own
+# trust prompts. Same here: ``~/.clawcodex/config.json`` → "projects".
+
+_PROJECTS_KEY = "projects"
+
+
+def _global_config_file() -> Path:
+ # Resolve through the module attribute at call time so test
+ # isolation that re-points GLOBAL_CONFIG_DIR covers these helpers.
+ return Path(GLOBAL_CONFIG_DIR) / "config.json"
+
+
+def normalize_path_for_config_key(path: str | Path) -> str:
+ """Canonical absolute path used as a ``projects`` map key."""
+
+ return str(Path(path).resolve())
+
+
+def get_project_path_for_config(cwd: str | Path | None = None) -> str:
+ """Where per-project state is keyed: git root if in a repo, else cwd
+ (TS ``getProjectPathForConfig``)."""
+
+ root = _find_git_root(cwd)
+ if root is not None:
+ return normalize_path_for_config_key(root)
+ return normalize_path_for_config_key(cwd or Path.cwd())
+
+
+def get_project_entry(project_path: str | Path) -> dict[str, Any]:
+ """The global-config ``projects[path]`` entry ({} if absent)."""
+
+ config = _read_json(_global_config_file())
+ projects = config.get(_PROJECTS_KEY)
+ if not isinstance(projects, dict):
+ return {}
+ entry = projects.get(normalize_path_for_config_key(project_path))
+ return entry if isinstance(entry, dict) else {}
+
+
+def update_project_entry(
+ project_path: str | Path, updates: dict[str, Any]
+) -> bool:
+ """Merge *updates* into ``projects[path]`` in the global config."""
+
+ path = _global_config_file()
+ config = _read_json(path)
+ projects = config.get(_PROJECTS_KEY)
+ if not isinstance(projects, dict):
+ projects = {}
+ key = normalize_path_for_config_key(project_path)
+ entry = projects.get(key)
+ if not isinstance(entry, dict):
+ entry = {}
+ entry.update(updates)
+ projects[key] = entry
+ config[_PROJECTS_KEY] = projects
+ try:
+ _atomic_write_json(path, config)
+ except OSError:
+ return False
+ _get_default_manager().invalidate()
+ return True
+
+
# ---------------------------------------------------------------------------
# History (JSONL paste history)
# ---------------------------------------------------------------------------
@@ -327,3 +404,54 @@ def set_default_provider(provider: str) -> None:
def get_default_provider() -> str:
"""Get the default provider."""
return load_config().get("default_provider", "anthropic")
+
+
+def set_theme(name: str) -> None:
+ """Persist the selected theme to the global config.
+
+ Mirrors TS ``setThemeSetting`` (``components/design-system/ThemeProvider.tsx``:
+ ``saveGlobalConfig(c => ({...c, theme: setting}))``). Uses ``set_global``
+ (global-only) — **not** ``load_config()`` + ``save_config()``, which would
+ serialize the *merged* (global+project+local) config back into the global file.
+ Matches the ``set_api_key`` / ``set_default_provider`` convention above.
+ """
+ _get_default_manager().set_global("theme", name)
+
+
+def set_logo_color(name: str) -> None:
+ """Persist the startup logo color palette to the global config.
+
+ Mirrors TS ``/logo`` (``saveGlobalConfig(c => ({...c, logoColor: chosen}))``). A
+ top-level config key (like :func:`set_theme`), read via
+ ``load_config().get("logoColor")`` by the startup banners — NOT a nested settings
+ field.
+ """
+ _get_default_manager().set_global("logoColor", name)
+
+
+def set_effort(value: Optional[str]) -> None:
+ """Persist the reasoning-effort choice to user settings (``settings.effort``)
+ and invalidate the settings read-cache.
+
+ Mirrors TS ``updateSettingsForSource('userSettings', {effortLevel})``
+ (``commands/effort/effort.tsx``). A ``value`` of ``None``/``""`` clears the
+ setting (auto). Unlike :func:`set_theme` (a top-level config key), effort is a
+ *settings* field (``src/settings/types.py``), read via ``get_settings()``, so it
+ lives in the nested ``"settings"`` section of the global config. Pattern copied
+ from ``state/app_state._on_advisor_model_change``; the local import avoids a
+ config→settings import cycle. After writing, the settings cache is invalidated so
+ the next ``get_settings()`` reflects the new value immediately (mid-session).
+ """
+ from src.settings.settings import invalidate_settings_cache
+
+ mgr = _get_default_manager()
+ cfg = mgr.load_global()
+ section = cfg.get("settings")
+ if not isinstance(section, dict):
+ section = {}
+ # None / "" both map to "auto" — write empty string for round-trip fidelity
+ # with the SettingsSchema default (settings/types.py: effort: str = "").
+ section["effort"] = value or ""
+ cfg["settings"] = section
+ mgr.save_global(cfg)
+ invalidate_settings_cache()
diff --git a/src/constants/xml.py b/src/constants/xml.py
new file mode 100644
index 000000000..d576fff40
--- /dev/null
+++ b/src/constants/xml.py
@@ -0,0 +1,60 @@
+"""XML tag constants for chapter-10 task notifications.
+
+Mirrors ``typescript/src/constants/xml.ts``. Single source of truth for
+the tag names that appear in ```` envelopes the model
+sees in its conversation flow. Keep names byte-identical to the TS
+source so prompt-cache stability holds across TS↔Python interop and
+snapshot tests pin the surface.
+"""
+from __future__ import annotations
+
+from typing import Final
+
+TASK_NOTIFICATION_TAG: Final[str] = "task-notification"
+TASK_ID_TAG: Final[str] = "task-id"
+TOOL_USE_ID_TAG: Final[str] = "tool-use-id"
+OUTPUT_FILE_TAG: Final[str] = "output-file"
+STATUS_TAG: Final[str] = "status"
+SUMMARY_TAG: Final[str] = "summary"
+RESULT_TAG: Final[str] = "result"
+USAGE_TAG: Final[str] = "usage"
+TOTAL_TOKENS_TAG: Final[str] = "total_tokens"
+TOOL_USES_TAG: Final[str] = "tool_uses"
+DURATION_MS_TAG: Final[str] = "duration_ms"
+WORKTREE_TAG: Final[str] = "worktree"
+WORKTREE_PATH_TAG: Final[str] = "worktree-path"
+WORKTREE_BRANCH_TAG: Final[str] = "worktree-branch"
+
+
+# Common argument patterns for slash commands that request help.
+# Mirrors ``typescript/src/constants/xml.ts:69`` verbatim.
+COMMON_HELP_ARGS: Final[tuple[str, ...]] = ('help', '-h', '--help')
+
+# Common argument patterns for slash commands that request current state/info.
+# Mirrors ``typescript/src/constants/xml.ts:72-86`` verbatim. Notably ``?``
+# lives here, NOT in ``COMMON_HELP_ARGS`` — so ``/cmd ?`` means
+# "show status", not "help".
+COMMON_INFO_ARGS: Final[tuple[str, ...]] = (
+ 'list', 'show', 'display', 'current', 'view', 'get', 'check',
+ 'describe', 'print', 'version', 'about', 'status', '?',
+)
+
+
+__all__ = [
+ "TASK_NOTIFICATION_TAG",
+ "TASK_ID_TAG",
+ "TOOL_USE_ID_TAG",
+ "OUTPUT_FILE_TAG",
+ "STATUS_TAG",
+ "SUMMARY_TAG",
+ "RESULT_TAG",
+ "USAGE_TAG",
+ "TOTAL_TOKENS_TAG",
+ "TOOL_USES_TAG",
+ "DURATION_MS_TAG",
+ "WORKTREE_TAG",
+ "WORKTREE_PATH_TAG",
+ "WORKTREE_BRANCH_TAG",
+ "COMMON_HELP_ARGS",
+ "COMMON_INFO_ARGS",
+]
diff --git a/src/context_system/cache_boundary.py b/src/context_system/cache_boundary.py
new file mode 100644
index 000000000..8ec561b0f
--- /dev/null
+++ b/src/context_system/cache_boundary.py
@@ -0,0 +1,23 @@
+"""Prompt-cache dynamic-boundary marker.
+
+Mirrors TS ``constants/prompts.ts:114-115``::
+
+ export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
+ '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
+
+The literal is emitted as a system-prompt block separating globally-cacheable
+identity/policy/tool sections from per-session sections that vary by user.
+When ``shouldUseGlobalCacheScope()`` is true (firstParty provider, no MCP
+tools), blocks BEFORE this marker can use ``scope: 'global'`` so two users
+running the same Claude Code version share the prefix cache.
+
+The marker is its own block (so it shows up in the wire payload of any
+recorded request — making it easy to verify the boundary was emitted)
+rather than embedded inside another block.
+"""
+
+from __future__ import annotations
+
+__all__ = ["SYSTEM_PROMPT_DYNAMIC_BOUNDARY"]
+
+SYSTEM_PROMPT_DYNAMIC_BOUNDARY: str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
diff --git a/src/context_system/claude_md.py b/src/context_system/claude_md.py
index 6310bf249..e107261dc 100644
--- a/src/context_system/claude_md.py
+++ b/src/context_system/claude_md.py
@@ -41,6 +41,11 @@
_memory_files_cache: list[MemoryFileInfo] | None = None
_memory_files_cache_key: str | None = None
+# Per-cwd memo of the C8 external-includes approval. The lookup behind
+# it shells out (git rev-parse) and reads the global config — far too
+# heavy per get_memory_files call. record_external_includes_choice
+# clears all caches, so in-process flips are still picked up.
+_external_includes_approval_cache: dict[str, bool] = {}
def clear_memory_file_caches() -> None:
@@ -48,6 +53,7 @@ def clear_memory_file_caches() -> None:
global _memory_files_cache, _memory_files_cache_key
_memory_files_cache = None
_memory_files_cache_key = None
+ _external_includes_approval_cache.clear()
def reset_get_memory_files_cache() -> None:
@@ -336,6 +342,40 @@ def _path_in_working_path(path: str, working_path: str) -> bool:
return False
+def _external_includes_approved(original_cwd: str) -> bool:
+ """C8: ``projects[path].hasClaudeMdExternalIncludesApproved`` from
+ the user-owned global config (TS getCurrentProjectConfig)."""
+
+ cached = _external_includes_approval_cache.get(original_cwd)
+ if cached is not None:
+ return cached
+ try:
+ from src import config as config_mod
+
+ entry = config_mod.get_project_entry(
+ config_mod.get_project_path_for_config(original_cwd)
+ )
+ approved = bool(entry.get("hasClaudeMdExternalIncludesApproved"))
+ except Exception:
+ approved = False
+ _external_includes_approval_cache[original_cwd] = approved
+ return approved
+
+
+def is_external_memory_file(
+ info: MemoryFileInfo, cwd: str | None = None
+) -> bool:
+ """TS getExternalClaudeMdIncludes predicate (claudemd.ts:1427-1437):
+ a non-User-tier INCLUDED file (has a parent) living outside the
+ original cwd. The User tier is excluded — the user's own
+ ~/CLAUDE.md may include anything without triggering the project
+ gate."""
+
+ if info.type == "User" or not info.parent:
+ return False
+ return not _path_in_working_path(info.path, cwd or _get_original_cwd())
+
+
# ---------------------------------------------------------------------------
# get_memory_files — main entry point (mirrors TS getMemoryFiles)
# ---------------------------------------------------------------------------
@@ -356,14 +396,21 @@ async def get_memory_files(
global _memory_files_cache, _memory_files_cache_key
original_cwd = cwd or _get_original_cwd()
- cache_key = f"{original_cwd}:{force_include_external}"
+ # TS claudemd.ts:812-815: externals load when forced (the gate's
+ # detection pass) OR when this project approved them via the C8
+ # external-includes dialog. The cache keys on the EFFECTIVE value
+ # so an approval recorded mid-session isn't masked by a stale
+ # pre-approval entry.
+ include_external = force_include_external or _external_includes_approved(
+ original_cwd
+ )
+ cache_key = f"{original_cwd}:{include_external}"
if _memory_files_cache is not None and _memory_files_cache_key == cache_key:
return list(_memory_files_cache)
result: list[MemoryFileInfo] = []
processed_paths: set[str] = set()
- include_external = force_include_external
home = str(Path.home())
diff --git a/src/context_system/memory_prefetch.py b/src/context_system/memory_prefetch.py
index c402942c0..3ff789632 100644
--- a/src/context_system/memory_prefetch.py
+++ b/src/context_system/memory_prefetch.py
@@ -1,245 +1,91 @@
-"""
-Async memory pre-fetch — aligned with typescript/src/memdir/findRelevantMemories.ts.
-
-Scans memory file headers and selects the most relevant ones (up to 5)
-for progressive disclosure during conversation.
+"""Backward-compatible re-export shim — superseded by ``src.memdir``.
+
+Per the ch11 refactor plan (Slice B3), the recall pipeline moved into
+``src/memdir/`` to live next to the rest of the auto-memory subsystem.
+This module is preserved for one release as a thin shim so existing
+imports keep working; new code should import from ``src.memdir``
+directly.
+
+Notable behavior change worth flagging to consumers: the original
+implementation accepted ``provider=None`` and fell back to a keyword
+heuristic. The chapter explicitly rejects that (`Use an LLM for recall,
+not keywords or embeddings`), and the new pipeline returns an empty list
+on missing/failing provider rather than retrieving with keyword
+matching. Callers that previously relied on the heuristic will see no
+results until a real provider is wired in.
"""
from __future__ import annotations
-import asyncio
import logging
-import os
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any, Optional
+from collections.abc import Set as _AbstractSet
+from typing import Any
+
+from src.memdir.find_relevant_memories import (
+ MAX_RELEVANT_MEMORIES,
+ RelevantMemory,
+)
+from src.memdir.find_relevant_memories import (
+ find_relevant_memories as _find_relevant_memories_new,
+)
+from src.memdir.memory_scan import (
+ MemoryHeader,
+ format_memory_manifest,
+ scan_memory_files,
+)
logger = logging.getLogger(__name__)
-MAX_RELEVANT_MEMORIES = 5
-
-
-@dataclass
-class RelevantMemory:
- """A memory file selected as relevant to the current query."""
-
- path: str
- mtime_ms: float
-
-
-@dataclass
-class MemoryHeader:
- """Parsed header from a memory file."""
-
- filename: str
- file_path: str
- description: str
- mtime_ms: float
-
-
-# ---------------------------------------------------------------------------
-# Memory header scanning
-# ---------------------------------------------------------------------------
-
-def _parse_memory_header(file_path: str) -> MemoryHeader | None:
- """
- Read just the header/first few lines of a memory file.
-
- Extracts a description from the first non-empty, non-heading line
- or from YAML frontmatter 'description' field.
- """
- try:
- p = Path(file_path)
- if not p.is_file():
- return None
- mtime_ms = p.stat().st_mtime * 1000
-
- content = p.read_text(encoding="utf-8")
- if not content.strip():
- return None
-
- # Try frontmatter description
- description = ""
- lines = content.splitlines()
- if lines and lines[0].strip() == "---":
- for i, line in enumerate(lines[1:], 1):
- if line.strip() == "---":
- break
- if line.strip().startswith("description:"):
- description = line.split(":", 1)[1].strip().strip('"').strip("'")
- break
-
- # Fallback: first meaningful line
- if not description:
- for line in lines:
- stripped = line.strip()
- if not stripped:
- continue
- if stripped.startswith("#"):
- description = stripped.lstrip("#").strip()
- break
- if stripped != "---":
- description = stripped[:200]
- break
-
- return MemoryHeader(
- filename=p.name,
- file_path=file_path,
- description=description or p.name,
- mtime_ms=mtime_ms,
- )
- except Exception:
- return None
-
-
-async def scan_memory_files(
- memory_dir: str,
-) -> list[MemoryHeader]:
- """
- Scan a directory for memory files and parse their headers.
-
- Mirrors TS scanMemoryFiles from memoryScan.ts.
- Excludes MEMORY.md (already in system prompt).
- """
- dir_path = Path(memory_dir)
- if not dir_path.is_dir():
- return []
-
- headers: list[MemoryHeader] = []
- try:
- for entry in sorted(dir_path.iterdir()):
- if not entry.is_file():
- continue
- if entry.name == "MEMORY.md":
- continue
- if entry.suffix.lower() not in (".md", ".txt"):
- continue
- header = _parse_memory_header(str(entry))
- if header:
- headers.append(header)
- except PermissionError:
- pass
- except Exception:
- logger.debug("Error scanning memory dir %s", memory_dir, exc_info=True)
-
- return headers
-
-
-def format_memory_manifest(headers: list[MemoryHeader]) -> str:
- """Format memory headers into a manifest string for LLM selection."""
- lines: list[str] = []
- for h in headers:
- lines.append(f"- {h.filename}: {h.description}")
- return "\n".join(lines)
-
+__all__ = [
+ "MAX_RELEVANT_MEMORIES",
+ "MemoryHeader",
+ "RelevantMemory",
+ "find_relevant_memories",
+ "format_memory_manifest",
+ "scan_memory_files",
+]
+
+_DEPRECATION_LOGGED = False
+
+
+def _log_deprecation_once() -> None:
+ global _DEPRECATION_LOGGED
+ if _DEPRECATION_LOGGED:
+ return
+ _DEPRECATION_LOGGED = True
+ logger.debug(
+ "src.context_system.memory_prefetch is deprecated; "
+ "import from src.memdir instead."
+ )
-# ---------------------------------------------------------------------------
-# Relevance selection — simplified version of TS findRelevantMemories
-# ---------------------------------------------------------------------------
async def find_relevant_memories(
query: str,
memory_dir: str,
- already_surfaced: set[str] | None = None,
+ already_surfaced: _AbstractSet[str] | None = None,
provider: Any = None,
+ *,
+ cancel_event: Any = None,
+ recent_tools: Any = (),
) -> list[RelevantMemory]:
- """
- Find memory files relevant to a query.
-
- Mirrors TS findRelevantMemories from findRelevantMemories.ts.
-
- In the TS implementation, this uses a side query to Sonnet to select
- relevant memories. In our Python implementation, we support two modes:
-
- 1. If a provider is given, use it to select memories via LLM.
- 2. Otherwise, use keyword-based heuristic matching.
+ """Compatibility wrapper around :func:`src.memdir.find_relevant_memories`.
- Returns list of RelevantMemory (up to MAX_RELEVANT_MEMORIES).
+ The new pipeline requires both a ``provider`` and a ``cancel_event``
+ (kw-only). For callers that haven't migrated yet, this shim
+ synthesizes an empty ``cancel_event`` and refuses to run without a
+ provider — the keyword fallback is intentionally gone.
"""
- surfaced = already_surfaced or set()
- headers = await scan_memory_files(memory_dir)
- headers = [h for h in headers if h.file_path not in surfaced]
-
- if not headers:
+ _log_deprecation_once()
+ if provider is None:
return []
-
- if provider is not None:
- return await _select_with_provider(query, headers, provider)
-
- return _select_with_heuristic(query, headers)
-
-
-async def _select_with_provider(
- query: str,
- headers: list[MemoryHeader],
- provider: Any,
-) -> list[RelevantMemory]:
- """Use an LLM provider to select relevant memories."""
- import json
-
- manifest = format_memory_manifest(headers)
- system_prompt = (
- "You are selecting memories that will be useful to Claude Code as it "
- "processes a user's query. You will be given the user's query and a list "
- "of available memory files with their filenames and descriptions.\n\n"
- "Return a JSON object with a single key 'selected_memories' containing "
- "a list of filenames for the memories that will clearly be useful "
- f"(up to {MAX_RELEVANT_MEMORIES}). Only include memories that you are certain "
- "will be helpful. If unsure, return an empty list."
+ import asyncio
+
+ event = cancel_event if cancel_event is not None else asyncio.Event()
+ return await _find_relevant_memories_new(
+ query,
+ memory_dir,
+ cancel_event=event,
+ provider=provider,
+ recent_tools=recent_tools,
+ already_surfaced=already_surfaced or frozenset(),
)
-
- messages = [
- {"role": "user", "content": f"Query: {query}\n\nAvailable memories:\n{manifest}"},
- ]
-
- try:
- response = provider.chat(messages, system=system_prompt, max_tokens=256)
- if response and response.content:
- parsed = json.loads(response.content)
- selected_names = parsed.get("selected_memories", [])
- by_filename = {h.filename: h for h in headers}
- result: list[RelevantMemory] = []
- for name in selected_names[:MAX_RELEVANT_MEMORIES]:
- header = by_filename.get(name)
- if header:
- result.append(RelevantMemory(
- path=header.file_path,
- mtime_ms=header.mtime_ms,
- ))
- return result
- except Exception:
- logger.debug("LLM memory selection failed, falling back to heuristic", exc_info=True)
-
- return _select_with_heuristic(query, headers)
-
-
-def _select_with_heuristic(
- query: str,
- headers: list[MemoryHeader],
-) -> list[RelevantMemory]:
- """Simple keyword-matching heuristic for memory selection."""
- query_lower = query.lower()
- query_words = set(query_lower.split())
-
- scored: list[tuple[float, MemoryHeader]] = []
- for header in headers:
- desc_lower = header.description.lower()
- name_lower = header.filename.lower()
- combined = f"{name_lower} {desc_lower}"
-
- score = 0.0
- for word in query_words:
- if len(word) < 3:
- continue
- if word in combined:
- score += 1.0
-
- if score > 0:
- scored.append((score, header))
-
- scored.sort(key=lambda x: x[0], reverse=True)
-
- return [
- RelevantMemory(path=h.file_path, mtime_ms=h.mtime_ms)
- for _, h in scored[:MAX_RELEVANT_MEMORIES]
- ]
diff --git a/src/context_system/prompt_assembly.py b/src/context_system/prompt_assembly.py
index 32a11198f..06e583a51 100644
--- a/src/context_system/prompt_assembly.py
+++ b/src/context_system/prompt_assembly.py
@@ -12,11 +12,13 @@
from __future__ import annotations
+import functools
import os
from datetime import datetime
from typing import Any
from ..types.messages import Message, UserMessage
+from .cache_boundary import SYSTEM_PROMPT_DYNAMIC_BOUNDARY
from .claude_md import (
_should_disable_claude_md,
clear_memory_file_caches,
@@ -73,8 +75,13 @@ async def get_user_context(
context: dict[str, str] = {}
- # Current date in local ISO format
- context["currentDate"] = _get_local_iso_date()
+ # Date in cached prefix → memoized + date-only.
+ # Per WI-1.2: get_user_context() feeds prepend_user_context() at :200,
+ # which prepends a user message. If a future
+ # cache_control marker lands on a tool or message block, this date
+ # becomes part of the cached prefix; using sub-second precision here
+ # would bust the cache on every turn.
+ context["currentDate"] = _get_session_start_date_iso()
# CLAUDE.md content (skip in --bare mode unless --add-dir used)
if not _should_disable_claude_md():
@@ -193,6 +200,29 @@ def append_system_context(
return "\n\n".join(p for p in parts if p)
+def append_system_context_blocks(
+ blocks: list[dict[str, Any]],
+ context: dict[str, str],
+) -> list[dict[str, Any]]:
+ """Block-list version of ``append_system_context``.
+
+ Appends the system-context (git status, etc.) as a NEW trailing block
+ rather than joining it inline into an existing block. This preserves
+ cache_control markers on prior blocks — git status is volatile per
+ session and would bust the cached prefix if joined into a cached block.
+
+ The new block is uncached; it sits after the REQUEST-scope cache_control
+ marker so it doesn't extend the cached prefix.
+ """
+ result = list(blocks)
+ context_str = "\n".join(
+ f"{key}: {value}" for key, value in context.items() if value
+ )
+ if context_str:
+ result.append({"type": "text", "text": context_str})
+ return result
+
+
# ---------------------------------------------------------------------------
# prependUserContext — mirrors TS api.ts prependUserContext
# ---------------------------------------------------------------------------
@@ -233,10 +263,45 @@ def prepend_user_context(
# ---------------------------------------------------------------------------
def _get_local_iso_date() -> str:
- """Get current date in local ISO format."""
+ """Get current wall-clock datetime in local ISO format with hh:mm:ss.
+
+ DEPRECATED for use in any section that flows through the prompt cache —
+ once cache_control markers engage server-side caching (WI-1.1), the
+ sub-second precision in this string busts the cached prefix on every
+ turn. Prefer ``_get_session_start_date_iso()`` for cache-stable dates.
+
+ Retained for callers in REQUEST-scope sections (``:274``,
+ ``:775``) where per-call drift is harmless because the section sits
+ after ``__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__`` and is not part of the
+ cached prefix.
+ """
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S%z") or datetime.now().isoformat()
+@functools.lru_cache(maxsize=1)
+def _get_session_start_date_iso() -> str:
+ """Date frozen at first call, cached for the process lifetime.
+
+ Mirrors TS ``constants/common.ts:24``::
+
+ const getSessionStartDate = memoize(getLocalISODate)
+
+ Used in any prompt section that flows through the prompt cache
+ (system blocks before ``__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__``, OR user-
+ context messages — the latter would land in the cached prefix if a
+ future cache_control marker is placed on a tool/message block).
+
+ Returns date-only (``YYYY-MM-DD``) — no hh:mm:ss. The chapter's
+ rationale: a stale date in the prompt is cosmetic, but a per-call
+ cache bust reprocesses ~190K cached tokens.
+
+ The lru_cache lives at the module level — it is NOT cleared by
+ ``clear_context_caches()``. That function only resets the per-context
+ dicts; this date persists for the process lifetime by design.
+ """
+ return datetime.now().strftime("%Y-%m-%d")
+
+
def _should_include_git_instructions() -> bool:
"""Check if git instructions should be included in context."""
env_val = os.environ.get("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS", "")
@@ -335,6 +400,22 @@ def build_full_system_prompt(
"""
if custom_system_prompt:
base = custom_system_prompt
+ # SDK custom-prompt branch: when CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
+ # is set, the caller has explicitly opted into the auto-memory
+ # mechanics. Mirror QueryEngine.ts:317-320: append the memory
+ # prompt AFTER the custom prompt and BEFORE append_system_prompt.
+ try:
+ from src.memdir import (
+ has_auto_mem_path_override,
+ load_memory_prompt,
+ )
+ if has_auto_mem_path_override():
+ memory_prompt = load_memory_prompt()
+ if memory_prompt:
+ base += "\n\n" + memory_prompt
+ except Exception:
+ # Memory subsystem failures must never block prompt building.
+ pass
if append_system_prompt:
base += "\n\n" + append_system_prompt
return base
@@ -390,6 +471,11 @@ def build_full_system_prompt(
if env_section:
sections.append(env_section)
+ # 25. Auto-memory section (MEMORY.md + behavioral instructions)
+ memory_section = _build_memory_section()
+ if memory_section:
+ sections.append(memory_section)
+
# 30. MCP instructions
mcp_section = _build_mcp_section(mcp_servers, use_cache)
if mcp_section:
@@ -438,6 +524,230 @@ def build_full_system_prompt(
return prompt
+# ---------------------------------------------------------------------------
+# WI-1.1: Block-list assembly with cache_control markers
+#
+# Mirrors TS ``getSystemPrompt()`` in ``constants/prompts.ts`` returning a
+# list of blocks that the API receives as ``system: List[TextBlockParam]``.
+# Each scope-group's last block carries ``cache_control: {type:'ephemeral'}``,
+# turning Anthropic's prompt cache from "decorated but inactive" (the gap
+# analysis's centerpiece finding) into "active and observable via
+# ``cache_read_input_tokens``" (gap #18 / WI-0.2).
+#
+# Block-shape contract (per refactor plan §A2 — max 4 cache_control markers):
+# * Each section becomes one ``{"type": "text", "text": ...}`` block.
+# * The LAST block in each scope group (GLOBAL, SESSION, REQUEST) carries
+# ``cache_control: {"type": "ephemeral", "ttl": ...}``. The TTL is
+# "5m" today; WI-2.2 swaps in "1h" once ``promptCache1hEligible`` is
+# latched.
+# * The ``__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__`` literal sits as its own
+# block between the last GLOBAL block and the first SESSION block.
+#
+# Caveat: tool-block schemas (the ``tools=`` parameter) are NOT cache-marked
+# in this initial port. Adding/removing a single tool to a session WILL bust
+# the prompt cache because the tools array changes the prefix. Defer to a
+# follow-on if the cache-break detector flags tool churn.
+# ---------------------------------------------------------------------------
+
+
+def build_full_system_prompt_blocks(
+ *,
+ cwd: str | None = None,
+ tools: list[Any] | None = None,
+ tool_registry: Any | None = None,
+ agents: list[Any] | None = None,
+ skills: list[Any] | None = None,
+ mcp_servers: list[Any] | None = None,
+ output_style: str = "default",
+ plan_mode: bool = False,
+ non_interactive: bool = False,
+ tool_restrictions: list[str] | None = None,
+ custom_system_prompt: str | None = None,
+ append_system_prompt: str | None = None,
+ use_cache: bool = True,
+ query_source: str = "main",
+ provider: Any | None = None,
+) -> list[dict[str, Any]]:
+ """Block-list version of ``build_full_system_prompt``.
+
+ Returns a list of ``{"type": "text", "text": ..., "cache_control"?: ...}``
+ dicts suitable for passing as the ``system`` parameter to the Anthropic
+ Python SDK's ``messages.create()`` / ``messages.stream()``. Blocks are
+ grouped by ``CacheScope``:
+
+ [global blocks…, ⟨5m cache_control⟩,
+ __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__,
+ session blocks…, ⟨5m cache_control⟩,
+ request blocks…, ⟨5m cache_control⟩,
+ (optional append_system_prompt block)]
+
+ The ``custom_system_prompt`` branch produces a single uncached block —
+ SDK callers using a custom prompt opt out of the section taxonomy.
+
+ Same parameters as ``build_full_system_prompt``; the only difference is
+ return type. Ordering inside each scope group preserves the section
+ ``order`` attribute so two consecutive calls with identical inputs
+ produce byte-identical block lists (cache stability).
+ """
+ # Custom-prompt branch: same shape as the str-returning path, but
+ # wrapped as a single block. No cache_control marker — SDK callers
+ # using custom prompts opt out of the section taxonomy.
+ if custom_system_prompt:
+ base = custom_system_prompt
+ try:
+ from src.memdir import (
+ has_auto_mem_path_override,
+ load_memory_prompt,
+ )
+ if has_auto_mem_path_override():
+ memory_prompt = load_memory_prompt()
+ if memory_prompt:
+ base += "\n\n" + memory_prompt
+ except Exception:
+ pass
+ if append_system_prompt:
+ base += "\n\n" + append_system_prompt
+ return [{"type": "text", "text": base}]
+
+ sections: list[SystemPromptSection] = []
+
+ # Re-use the same section builders as the str path so content is
+ # bit-identical. Any divergence between the two paths would let one
+ # path cache while the other busts — explicitly avoided.
+ intro = _build_intro_section(use_cache)
+ if intro:
+ sections.append(intro)
+ system = _build_system_section(use_cache)
+ if system:
+ sections.append(system)
+ tasks = _build_doing_tasks_section(use_cache)
+ if tasks:
+ sections.append(tasks)
+ actions = _build_actions_section(use_cache)
+ if actions:
+ sections.append(actions)
+ using_tools = _build_using_tools_section(use_cache)
+ if using_tools:
+ sections.append(using_tools)
+ tone = _build_tone_style_section(use_cache)
+ if tone:
+ sections.append(tone)
+ efficiency = _build_output_efficiency_section(use_cache)
+ if efficiency:
+ sections.append(efficiency)
+ tool_docs = _build_tool_docs_section(tools, tool_registry, use_cache)
+ if tool_docs:
+ sections.append(tool_docs)
+ env_section = _build_env_section(cwd, use_cache)
+ if env_section:
+ sections.append(env_section)
+ memory_section = _build_memory_section()
+ if memory_section:
+ sections.append(memory_section)
+ mcp_section = _build_mcp_section(mcp_servers, use_cache)
+ if mcp_section:
+ sections.append(mcp_section)
+ agent_section = _build_agent_section(agents, use_cache)
+ if agent_section:
+ sections.append(agent_section)
+ skill_section = _build_skill_section(skills, use_cache)
+ if skill_section:
+ sections.append(skill_section)
+ style_section = _build_output_style_section(output_style, use_cache)
+ if style_section:
+ sections.append(style_section)
+ if plan_mode:
+ plan_section = _build_plan_mode_section(use_cache)
+ if plan_section:
+ sections.append(plan_section)
+ if non_interactive:
+ ni_section = _build_non_interactive_section(use_cache)
+ if ni_section:
+ sections.append(ni_section)
+ if tool_restrictions:
+ restrict_section = _build_tool_restrictions_section(tool_restrictions)
+ if restrict_section:
+ sections.append(restrict_section)
+
+ # Sort within scope groups so block ordering is deterministic across
+ # calls. The cache stability test relies on this.
+ sections.sort(key=lambda s: s.order)
+
+ # Partition by scope. We import CacheScope locally to avoid a top-level
+ # circular import (system_prompt_cache imports from this module's
+ # peers; a top-level import here closes the cycle).
+ from .system_prompt_cache import CacheScope
+
+ global_sections = [s for s in sections if s.cache_scope is CacheScope.GLOBAL and s.content]
+ session_sections = [s for s in sections if s.cache_scope is CacheScope.SESSION and s.content]
+ request_sections = [s for s in sections if s.cache_scope is CacheScope.REQUEST and s.content]
+
+ blocks: list[dict[str, Any]] = []
+
+ # WI-2.2: TTL selector. ``should_1h_cache_ttl(query_source)`` returns
+ # True only when (a) the user is 1h-eligible per the latched
+ # evaluation in cache_state.evaluate_prompt_cache_1h_eligibility, AND
+ # (b) the query source is in the GrowthBook-populated allowlist.
+ # When either condition is False, fall back to "5m" — the safe-default
+ # TTL that Phase 1 already engaged. The allowlist is empty by default
+ # (no GrowthBook port yet), so this defaults to "5m" universally until
+ # a future WI populates it.
+ from src.state.cache_state import should_1h_cache_ttl, should_use_global_cache_scope
+ ttl = "1h" if should_1h_cache_ttl(query_source) else "5m"
+
+ # WI-2.3: global-scope gate. Per chapter line 91, GLOBAL-tier blocks
+ # may emit ``scope: 'global'`` only when (firstParty provider) AND
+ # (no MCP tools) AND (env-gated opt-in flag set). Defaults to OFF for
+ # safety — see ``should_use_global_cache_scope`` docstring.
+ has_mcp_tools = bool(mcp_servers)
+ use_global_scope = (
+ provider is not None
+ and should_use_global_cache_scope(
+ provider=provider, has_mcp_tools=has_mcp_tools,
+ )
+ )
+
+ # Helper that appends a section's content as a block. The LAST block
+ # in each scope group carries cache_control; everything else is plain.
+ def _emit_group(
+ group: list[SystemPromptSection],
+ scope: CacheScope,
+ ) -> None:
+ for idx, section in enumerate(group):
+ block: dict[str, Any] = {"type": "text", "text": section.content}
+ if idx == len(group) - 1:
+ # Last block in this scope group → mark for caching.
+ cache_control: dict[str, Any] = {
+ "type": "ephemeral", "ttl": ttl,
+ }
+ # Only GLOBAL-tier blocks ever get scope='global'. SESSION
+ # and REQUEST tiers stay at the default org/session scope.
+ if scope is CacheScope.GLOBAL and use_global_scope:
+ cache_control["scope"] = "global"
+ block["cache_control"] = cache_control
+ blocks.append(block)
+
+ if global_sections:
+ _emit_group(global_sections, CacheScope.GLOBAL)
+
+ # The dynamic-boundary marker sits between GLOBAL and SESSION even when
+ # one of the two groups is empty — its presence in the wire payload is
+ # what request-recording tests assert on.
+ blocks.append({"type": "text", "text": SYSTEM_PROMPT_DYNAMIC_BOUNDARY})
+
+ if session_sections:
+ _emit_group(session_sections, CacheScope.SESSION)
+ if request_sections:
+ _emit_group(request_sections, CacheScope.REQUEST)
+
+ # SDK ``append_system_prompt`` is appended as a final uncached block —
+ # same semantics as the str path's trailing concatenation.
+ if append_system_prompt:
+ blocks.append({"type": "text", "text": append_system_prompt})
+
+ return blocks
+
+
# ---------------------------------------------------------------------------
# Section builders
# ---------------------------------------------------------------------------
@@ -764,6 +1074,31 @@ def _build_env_section(cwd: str | None, use_cache: bool) -> SystemPromptSection
return SystemPromptSection(id="environment", content=content, cache_scope=CacheScope.REQUEST, order=20)
+def _build_memory_section() -> SystemPromptSection | None:
+ """Build the auto-memory system-prompt section.
+
+ Mirrors TS ``constants/prompts.ts:495`` (``systemPromptSection('memory',
+ () => loadMemoryPrompt())``). Returned with ``REQUEST`` scope so any
+ upstream caching layer rebuilds the section per render — ``MEMORY.md``
+ can change mid-session as the model writes to it, and none of the
+ existing cache scopes invalidate on file mtime. The work is small
+ (one read of a ≤25KB file), so correctness over cache hit-rate.
+ """
+ try:
+ from src.memdir import load_memory_prompt
+ except Exception:
+ return None
+ content = load_memory_prompt()
+ if not content:
+ return None
+ return SystemPromptSection(
+ id="memory",
+ content=content,
+ cache_scope=CacheScope.REQUEST,
+ order=25,
+ )
+
+
def _build_mcp_section(
mcp_servers: list[Any] | None,
use_cache: bool,
diff --git a/src/context_system/system_prompt_cache.py b/src/context_system/system_prompt_cache.py
index 39489037c..8d8d78701 100644
--- a/src/context_system/system_prompt_cache.py
+++ b/src/context_system/system_prompt_cache.py
@@ -40,6 +40,67 @@ class SystemPromptSection:
content: str
cache_scope: CacheScope = CacheScope.SESSION
order: int = 0 # Lower = earlier in prompt
+ cache_break: bool = False
+ reason: str | None = None
+
+
+def system_prompt_section(
+ name: str,
+ *,
+ content: str,
+ cache_scope: CacheScope = CacheScope.SESSION,
+ order: int = 0,
+) -> SystemPromptSection:
+ """Build a cacheable prompt section.
+
+ Mirrors TS ``systemPromptSection`` from
+ ``constants/systemPromptSections.ts``. Sections built through this
+ factory have ``cache_break = False``; their content is memoized until
+ ``clear_system_prompt_sections()`` is called (on /clear or /compact).
+ """
+ return SystemPromptSection(
+ id=name,
+ content=content,
+ cache_scope=cache_scope,
+ order=order,
+ cache_break=False,
+ )
+
+
+def DANGEROUS_uncachedSystemPromptSection(
+ name: str,
+ *,
+ content: str,
+ reason: str,
+ cache_scope: CacheScope = CacheScope.REQUEST,
+ order: int = 0,
+) -> SystemPromptSection:
+ """Build a cache-breaking prompt section that recomputes every turn.
+
+ Mirrors TS ``DANGEROUS_uncachedSystemPromptSection`` from
+ ``constants/systemPromptSections.ts``. The loud name is deliberate (per
+ chapter §"The DANGEROUS Naming Convention"): cache-breaking sections
+ must surface in code review because each one risks invalidating ~50-70K
+ tokens of cached prefix.
+
+ ``reason`` is required — a blank reason raises ``ValueError``. The
+ parameter is unused at runtime, but enforcing non-empty content turns
+ code review from "remember to ask why" into a constructor-time invariant.
+ """
+ if not reason or not reason.strip():
+ raise ValueError(
+ "DANGEROUS_uncachedSystemPromptSection requires a non-empty "
+ "reason — explain why this section cannot be cached so future "
+ "readers can evaluate whether the trade-off is still warranted."
+ )
+ return SystemPromptSection(
+ id=name,
+ content=content,
+ cache_scope=cache_scope,
+ order=order,
+ cache_break=True,
+ reason=reason.strip(),
+ )
class SystemPromptCache:
@@ -102,3 +163,34 @@ def get_cached_section_ids(self) -> list[str]:
k for k, v in self._cache.items()
if not v.is_expired
]
+
+
+def clear_system_prompt_sections() -> None:
+ """Clear the section cache and reset sticky beta-header latches.
+
+ Mirrors TS ``clearSystemPromptSections()`` (constants/systemPromptSections.ts).
+ Called on ``/clear`` and ``/compact`` so a fresh conversation gets fresh
+ evaluation of AFK / fast-mode / cache-editing / thinking-clear / 1h-cache
+ eligibility *and* fresh content for memoized sections.
+
+ Importing ``cache_state`` lazily avoids a circular dependency: cache_state
+ does not import from this module, but a future change might, and the lazy
+ import keeps that risk contained.
+ """
+ # Lazy import to keep this file free of state dependencies at import time.
+ from src.state.cache_state import clear_beta_header_latches
+
+ # Clear the module-level prompt cache used by prompt_assembly.py.
+ # The cache lives on a singleton created in prompt_assembly; import
+ # lazily to avoid the prompt_assembly → system_prompt_cache circular
+ # dependency that the file's "from .system_prompt_cache import ..."
+ # block already comments on.
+ try:
+ from src.context_system.prompt_assembly import get_system_prompt_cache
+ get_system_prompt_cache().invalidate_all()
+ except ImportError:
+ # prompt_assembly isn't always importable in minimal test contexts;
+ # latch clearing still proceeds.
+ pass
+
+ clear_beta_header_latches()
diff --git a/src/coordinator/__init__.py b/src/coordinator/__init__.py
index 65a77d324..a20341c77 100644
--- a/src/coordinator/__init__.py
+++ b/src/coordinator/__init__.py
@@ -1,16 +1,60 @@
-"""Python package placeholder for the archived `coordinator` subsystem."""
+"""Coordinator subsystem — Chunk G / Phase 8.
+Replaces the prior placeholder module body that exposed only the
+parity-snapshot metadata. The chapter-10 coordinator pattern lives in:
+
+* ``src/coordinator/mode.py`` — ``is_coordinator_mode`` / ``match_session_mode``
+ / ``INTERNAL_WORKER_TOOLS`` / tool-set filters / ``get_coordinator_user_context``.
+* ``src/coordinator/prompt.py`` — verbatim port of the ~370-line
+ coordinator system prompt with two interpolation points
+ (tool-name constants + ``worker_capabilities`` env-flag branch).
+* ``src/coordinator/worker_agent.py`` — ``WORKER_AGENT`` definition
+ spread from ``GENERAL_PURPOSE_AGENT`` with ``INTERNAL_WORKER_TOOLS``
+ filtered out.
+
+Parity-snapshot metadata still exposed (the parity audit reads it).
+"""
from __future__ import annotations
import json
from pathlib import Path
+from src.coordinator.mode import (
+ INTERNAL_WORKER_TOOLS,
+ filter_coordinator_tools,
+ filter_worker_tools,
+ get_coordinator_user_context,
+ is_coordinator_mode,
+ match_session_mode,
+)
+from src.coordinator.prompt import get_coordinator_system_prompt
+from src.coordinator.worker_agent import WORKER_AGENT, get_coordinator_agents
+
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'coordinator.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 coordinator package for '{ARCHIVE_NAME}' "
+ f"({MODULE_COUNT} archived module reference(s)). "
+ f"Chapter-10 / Chunk G implements is_coordinator_mode + system prompt + "
+ f"INTERNAL_WORKER_TOOLS filter + WORKER agent + fork mutex."
+)
-__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
+__all__ = [
+ "ARCHIVE_NAME",
+ "MODULE_COUNT",
+ "PORTING_NOTE",
+ "SAMPLE_FILES",
+ "INTERNAL_WORKER_TOOLS",
+ "WORKER_AGENT",
+ "filter_coordinator_tools",
+ "filter_worker_tools",
+ "get_coordinator_agents",
+ "get_coordinator_system_prompt",
+ "get_coordinator_user_context",
+ "is_coordinator_mode",
+ "match_session_mode",
+]
diff --git a/src/coordinator/mode.py b/src/coordinator/mode.py
new file mode 100644
index 000000000..e97f8fd6a
--- /dev/null
+++ b/src/coordinator/mode.py
@@ -0,0 +1,222 @@
+"""Coordinator-mode gates and tool-set filters — Chunk G / WI-8.1-8.3.
+
+Mirrors ``typescript/src/coordinator/coordinatorMode.ts`` (excluding
+the ~370-line system prompt body which lives in
+``src/coordinator/prompt.py`` for SRP).
+
+* ``is_coordinator_mode`` — env-var gate over ``CLAUDE_CODE_COORDINATOR_MODE``.
+* ``match_session_mode`` — sync resumed-session mode with env var.
+* ``INTERNAL_WORKER_TOOLS`` — frozenset of tool names workers cannot
+ use (TeamCreate / TeamDelete / SendMessage / StructuredOutput).
+* ``filter_coordinator_tools`` — produce the coordinator's restricted
+ tool list (``Agent`` / ``SendMessage`` / ``TaskStop`` only).
+* ``filter_worker_tools`` — produce a worker's tool list (everything
+ the parent has, minus ``INTERNAL_WORKER_TOOLS``).
+* ``get_coordinator_user_context`` — produce the
+ ``workerToolsContext`` user-message block the chapter §"Worker
+ Context" describes.
+"""
+from __future__ import annotations
+
+import logging
+import os
+from typing import Final, Iterable, Literal, TYPE_CHECKING
+
+from src.agent.constants import ASYNC_AGENT_ALLOWED_TOOLS
+from src.utils.env import is_env_truthy
+
+if TYPE_CHECKING:
+ from src.tool_system.build_tool import Tool
+
+logger = logging.getLogger(__name__)
+
+
+# Env-var gate. Mirrors ``coordinatorMode.ts:36-41``.
+_COORDINATOR_MODE_ENV: Final[str] = "CLAUDE_CODE_COORDINATOR_MODE"
+
+
+def is_coordinator_mode() -> bool:
+ """Return True iff the active session is in coordinator mode.
+
+ The env var is the runtime signal; ``match_session_mode`` flips
+ it on resume so the resumed session's stored mode wins. Without
+ this flow, a coordinator session resumed as a regular agent
+ would lose awareness of its workers (and vice versa).
+ """
+ return is_env_truthy(_COORDINATOR_MODE_ENV)
+
+
+SessionMode = Literal["coordinator", "normal"]
+
+
+def match_session_mode(session_mode: SessionMode | None) -> str | None:
+ """Sync the env var with a resumed session's stored mode.
+
+ Mirrors ``coordinatorMode.ts:49-78``. Returns a banner string when
+ a flip happened (the caller surfaces it to the user) or ``None``
+ when no flip was needed.
+
+ Behavior:
+ * ``session_mode is None`` (sessions stored before mode tracking
+ existed) → no-op, return None.
+ * ``session_mode == current_is_coordinator`` → no-op, return None.
+ * Otherwise: flip the env var so ``is_coordinator_mode()`` returns
+ the right value for the resumed session, return a banner.
+ """
+ if session_mode is None:
+ return None
+
+ current_is_coordinator = is_coordinator_mode()
+ session_is_coordinator = session_mode == "coordinator"
+
+ if current_is_coordinator == session_is_coordinator:
+ return None
+
+ if session_is_coordinator:
+ os.environ[_COORDINATOR_MODE_ENV] = "1"
+ else:
+ # ``del`` rather than setting to "" so future reads return
+ # absent, not falsy-stringy.
+ os.environ.pop(_COORDINATOR_MODE_ENV, None)
+
+ return (
+ "Entered coordinator mode to match resumed session."
+ if session_is_coordinator
+ else "Exited coordinator mode to match resumed session."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Tool-set filters — coordinator gets 3 tools; workers lose 4
+# ---------------------------------------------------------------------------
+
+# Tools workers cannot use. ``"StructuredOutput"`` is the literal
+# string that TS's ``SYNTHETIC_OUTPUT_TOOL_NAME`` resolves to (per
+# ``SyntheticOutputTool.ts:20``); the apparent mismatch with the TS
+# constant name is intentional — pin the bytes the model sees.
+INTERNAL_WORKER_TOOLS: Final[frozenset[str]] = frozenset({
+ "TeamCreate",
+ "TeamDelete",
+ "SendMessage",
+ "StructuredOutput",
+})
+
+# The coordinator gets EXACTLY these three tools. The chapter calls
+# this out as core: "the coordinator's power comes not from having
+# more tools, but from having fewer." No Read, no Edit, no Bash.
+_COORDINATOR_ALLOWED_TOOLS: Final[frozenset[str]] = frozenset({
+ "Agent",
+ "SendMessage",
+ "TaskStop",
+})
+
+
+def filter_coordinator_tools(all_tools: Iterable["Tool"]) -> list["Tool"]:
+ """Return only the three tools the coordinator may use."""
+ return [t for t in all_tools if t.name in _COORDINATOR_ALLOWED_TOOLS]
+
+
+def filter_worker_tools(all_tools: Iterable["Tool"]) -> list["Tool"]:
+ """Return everything except ``INTERNAL_WORKER_TOOLS``.
+
+ Workers receive standard tools (Read / Edit / Bash / etc.) plus
+ any MCP tools the parent has registered; only the swarm-internal
+ coordination tools are excluded.
+ """
+ return [t for t in all_tools if t.name not in INTERNAL_WORKER_TOOLS]
+
+
+# ---------------------------------------------------------------------------
+# Worker-tools context block — surfaced to the coordinator's prompt
+# ---------------------------------------------------------------------------
+
+
+# Tools surfaced in SIMPLE mode. Mirrors the literal list at
+# ``coordinatorMode.ts:88-91`` (``[BASH_TOOL_NAME, FILE_READ_TOOL_NAME,
+# FILE_EDIT_TOOL_NAME]``). Kept as a module-level tuple so the
+# round-2 ch10 sort-and-render path has one source of truth.
+_SIMPLE_MODE_WORKER_TOOLS: Final[tuple[str, ...]] = ("Bash", "Read", "Edit")
+
+
+def _build_worker_tools_string() -> str:
+ """Build the comma-separated worker tools list for the coordinator
+ user context. Mirrors ``coordinatorMode.ts:88-95``.
+
+ SIMPLE branch (``CLAUDE_CODE_SIMPLE`` truthy): the literal three
+ tools ``[Bash, Read, Edit]``, sorted alphabetically. This matches
+ the SIMPLE worker-capabilities sentence in the coordinator system
+ prompt (``prompt.py:78-81``) so the coordinator's prompt and
+ user-context agree on what workers can do.
+
+ Default branch: ``ASYNC_AGENT_ALLOWED_TOOLS - INTERNAL_WORKER_TOOLS``
+ sorted alphabetically. Reading from ``ASYNC_AGENT_ALLOWED_TOOLS``
+ rather than hardcoding the list ties the rendered context to the
+ same source of truth as the actual runtime tool filter — adding
+ or removing a tool from the async-allowed set automatically
+ flows through to the coordinator's user context.
+ """
+ if is_env_truthy("CLAUDE_CODE_SIMPLE"):
+ return ", ".join(sorted(_SIMPLE_MODE_WORKER_TOOLS))
+ eligible = ASYNC_AGENT_ALLOWED_TOOLS - INTERNAL_WORKER_TOOLS
+ return ", ".join(sorted(eligible))
+
+
+def get_coordinator_user_context(
+ mcp_clients: Iterable[object] | None = None,
+ *,
+ scratchpad_dir: str | None = None,
+) -> dict[str, str]:
+ """Build the ``workerToolsContext`` user-message section for
+ coordinator mode.
+
+ Mirrors ``coordinatorMode.ts:80-109``. Returns an empty dict when
+ not in coordinator mode (the section is gated on mode, not on
+ tools-list shape — non-coordinator agents shouldn't see it). When
+ coordinator mode is active, returns ``{"workerToolsContext": "..."}``
+ with the appropriate tool names + optional MCP server names +
+ optional scratchpad note.
+
+ The worker tools list now branches on ``CLAUDE_CODE_SIMPLE`` to
+ match TS exactly (round-2 fix); see ``_build_worker_tools_string``.
+
+ The scratchpad block is gated by the ``tengu_scratch`` Statsig
+ feature flag in TS; Python has no Statsig infra yet (backlog
+ item #5 in ``ch10-phase11-backlog.md``), so for now we surface
+ the note unconditionally when ``scratchpad_dir`` is supplied.
+ The actual scratchpad creation is out-of-scope per plan §3.
+ """
+ if not is_coordinator_mode():
+ return {}
+
+ worker_tools = _build_worker_tools_string()
+
+ parts = [f"Workers spawned via Agent have access to these tools: {worker_tools}"]
+
+ mcp_server_names = sorted(
+ {getattr(c, "name", "") for c in (mcp_clients or [])} - {""}
+ )
+ if mcp_server_names:
+ parts.append(
+ "Workers also have access to MCP tools from connected MCP "
+ f"servers: {', '.join(mcp_server_names)}"
+ )
+
+ if scratchpad_dir:
+ parts.append(
+ f"Scratchpad directory: {scratchpad_dir}\n"
+ "Workers can read and write here without permission "
+ "prompts. Use this for durable cross-worker knowledge — "
+ "structure files however fits the work."
+ )
+
+ return {"workerToolsContext": "\n\n".join(parts)}
+
+
+__all__ = [
+ "is_coordinator_mode",
+ "match_session_mode",
+ "INTERNAL_WORKER_TOOLS",
+ "filter_coordinator_tools",
+ "filter_worker_tools",
+ "get_coordinator_user_context",
+]
diff --git a/src/coordinator/prompt.py b/src/coordinator/prompt.py
new file mode 100644
index 000000000..4f790c145
--- /dev/null
+++ b/src/coordinator/prompt.py
@@ -0,0 +1,369 @@
+"""Coordinator system prompt — Chunk G / WI-8.4.
+
+Verbatim port of ``typescript/src/coordinator/coordinatorMode.ts:111-369``.
+The chapter calls this prompt "the most instructive document in the
+codebase about how to use LLMs for orchestration"; it encodes
+hard-won lessons (never delegate understanding, parallelism is your
+superpower, the four-phase workflow, the continue-vs-spawn decision
+table). Mimicking TS Claude Code requires shipping it.
+
+Interpolation points
+--------------------
+
+Two — and only two — places where the prompt body is not byte-equal
+to the TS source:
+
+1. **Tool-name constants** (``{AGENT_TOOL_NAME}`` /
+ ``{SEND_MESSAGE_TOOL_NAME}`` / ``{TASK_STOP_TOOL_NAME}``) are
+ substituted via ``str.format``. Each TS f-string token
+ (``${TOKEN}``) maps to a Python f-string token (``{TOKEN}``).
+
+2. **``worker_capabilities``** is a runtime feature-flag-driven
+ branch on the ``CLAUDE_CODE_SIMPLE`` env var. The two branch
+ strings are byte-exact verbatim from
+ ``coordinatorMode.ts:112-114``. ``is_env_truthy`` mirrors TS
+ ``isEnvTruthy``.
+
+Snapshot tests
+--------------
+
+Two snapshot fixtures pin the rendered prompt:
+
+* ``tests/coordinator/__snapshots__/coordinator_prompt.simple.snap.txt``
+ — env ``CLAUDE_CODE_SIMPLE`` truthy.
+* ``tests/coordinator/__snapshots__/coordinator_prompt.default.snap.txt``
+ — env unset / falsy.
+
+Updating either is a deliberate review step.
+
+Note on rendered length
+-----------------------
+
+The TS source body is ~370 lines (``coordinatorMode.ts:111-369``);
+the rendered Python prompt is ~252 lines / ~14 KB. The discrepancy is
+expected — TS template literals expand to flat strings on render, and
+the snapshot pins the *rendered* output, not the source line count.
+A future reader spotting "the snapshot is shorter than the source"
+should not read that as content loss; the verbatim mandate applies to
+the *content*, not the line shape post-template-literal-expansion.
+
+Note on file extensions
+-----------------------
+
+The prompt's example file paths use ``.ts`` extensions
+(``src/auth/validate.ts``). Per the chapter, these are *teaching
+examples* — they exist to model the prompt-shape (file path + line
+number + change description), not to assert a specific file
+extension. Keeping ``.ts`` matches the verbatim-port mandate; the
+"hard-won lessons" the chapter cites are about prompt shape, not
+language artifacts.
+"""
+from __future__ import annotations
+
+from src.utils.env import is_env_truthy
+
+# Tool-name constants — match TS source. Pulled from the agent /
+# send-message / task-stop modules' canonical names so a future
+# rename ripples through the prompt automatically.
+from src.agent.constants import AGENT_TOOL_NAME
+
+# SendMessage and TaskStop are not import-stable from their tool
+# modules at this layer (would cycle), so we hardcode the literals
+# the registry uses. These are themselves source-of-truth in the
+# tool definitions; if those change, this constant moves with them.
+SEND_MESSAGE_TOOL_NAME = "SendMessage"
+TASK_STOP_TOOL_NAME = "TaskStop"
+
+
+_WORKER_CAPABILITIES_SIMPLE = (
+ "Workers have access to Bash, Read, and Edit tools, plus MCP tools "
+ "from configured MCP servers."
+)
+
+_WORKER_CAPABILITIES_DEFAULT = (
+ "Workers have access to standard tools, MCP tools from configured "
+ "MCP servers, and project skills via the Skill tool. Delegate skill "
+ "invocations (e.g. /commit or project workflow skills) to workers."
+)
+
+
+def _resolve_worker_capabilities() -> str:
+ """Match TS ``coordinatorMode.ts:112-114`` — branch on the
+ ``CLAUDE_CODE_SIMPLE`` env var."""
+ if is_env_truthy("CLAUDE_CODE_SIMPLE"):
+ return _WORKER_CAPABILITIES_SIMPLE
+ return _WORKER_CAPABILITIES_DEFAULT
+
+
+def get_coordinator_system_prompt() -> str:
+ """Return the system prompt delivered to a coordinator-mode agent.
+
+ Verbatim port of ``coordinatorMode.ts:111-369`` body. Two
+ interpolation points (tool-name constants and
+ ``worker_capabilities``); see module docstring.
+ """
+ worker_capabilities = _resolve_worker_capabilities()
+
+ # The body uses doubled-braces (``{{`` / ``}}``) to emit literal
+ # JS-style object syntax that the prompt teaches the model — that
+ # syntax is INSIDE the prompt, not interpreted by Python's
+ # f-string parser. The single-brace ``{TOKEN}`` slots are the
+ # actual interpolation points.
+ return f"""You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
+
+## 1. Your Role
+
+You are a **coordinator**. Your job is to:
+- Help the user achieve their goal
+- Direct workers to research, implement and verify code changes
+- Synthesize results and communicate with the user
+- Answer questions directly when possible — don't delegate work that you can handle without tools
+
+Every message you send is to the user. Worker results and system notifications are internal signals, not conversation partners — never thank or acknowledge them. Summarize new information for the user as it arrives.
+
+## 2. Your Tools
+
+- **{AGENT_TOOL_NAME}** - Spawn a new worker
+- **{SEND_MESSAGE_TOOL_NAME}** - Continue an existing worker (send a follow-up to its `to` agent ID)
+- **{TASK_STOP_TOOL_NAME}** - Stop a running worker
+- **subscribe_pr_activity / unsubscribe_pr_activity** (if available) - Subscribe to GitHub PR events (review comments, CI results). Events arrive as user messages. Merge conflict transitions do NOT arrive — GitHub doesn't webhook `mergeable_state` changes, so poll `gh pr view N --json mergeable` if tracking conflict status. Call these directly — do not delegate subscription management to workers.
+
+When calling {AGENT_TOOL_NAME}:
+- Do not use one worker to check on another. Workers will notify you when they are done.
+- Do not use workers to trivially report file contents or run commands. Give them higher-level tasks.
+- Do not set the model parameter. Workers need the default model for the substantive tasks you delegate.
+- Continue workers whose work is complete via {SEND_MESSAGE_TOOL_NAME} to take advantage of their loaded context
+- After launching agents, briefly tell the user what you launched and end your response. Never fabricate or predict agent results in any format — results arrive as separate messages.
+
+### {AGENT_TOOL_NAME} Results
+
+Worker results arrive as **user-role messages** containing `` XML. They look like user messages but are not. Distinguish them by the `` opening tag.
+
+Format:
+
+```xml
+
+{{agentId}}
+completed|failed|killed
+{{human-readable status summary}}
+{{agent's final text response}}
+
+ N
+ N
+ N
+
+
+```
+
+- `` and `` are optional sections
+- The `` describes the outcome: "completed", "failed: {{error}}", or "was stopped"
+- The `` value is the agent ID — use SendMessage with that ID as `to` to continue that worker
+
+### Example
+
+Each "You:" block is a separate coordinator turn. The "User:" block is a `` delivered between turns.
+
+You:
+ Let me start some research on that.
+
+ {AGENT_TOOL_NAME}({{ description: "Investigate auth bug", subagent_type: "worker", prompt: "..." }})
+ {AGENT_TOOL_NAME}({{ description: "Research secure token storage", subagent_type: "worker", prompt: "..." }})
+
+ Investigating both issues in parallel — I'll report back with findings.
+
+User:
+
+ agent-a1b
+ completed
+ Agent "Investigate auth bug" completed
+ Found null pointer in src/auth/validate.ts:42...
+
+
+You:
+ Found the bug — null pointer in confirmTokenExists in validate.ts. I'll fix it.
+ Still waiting on the token storage research.
+
+ {SEND_MESSAGE_TOOL_NAME}({{ to: "agent-a1b", message: "Fix the null pointer in src/auth/validate.ts:42..." }})
+
+## 3. Workers
+
+When calling {AGENT_TOOL_NAME}, use subagent_type `worker`. Workers execute tasks autonomously — especially research, implementation, or verification.
+
+{worker_capabilities}
+
+## 4. Task Workflow
+
+Most tasks can be broken down into the following phases:
+
+### Phases
+
+| Phase | Who | Purpose |
+|-------|-----|---------|
+| Research | Workers (parallel) | Investigate codebase, find files, understand problem |
+| Synthesis | **You** (coordinator) | Read findings, understand the problem, craft implementation specs (see Section 5) |
+| Implementation | Workers | Make targeted changes per spec, commit |
+| Verification | Workers | Test changes work |
+
+### Concurrency
+
+**Parallelism is your superpower. Workers are async. Launch independent workers concurrently whenever possible — don't serialize work that can run simultaneously and look for opportunities to fan out. When doing research, cover multiple angles. To launch workers in parallel, make multiple tool calls in a single message.**
+
+Manage concurrency:
+- **Read-only tasks** (research) — run in parallel freely
+- **Write-heavy tasks** (implementation) — one at a time per set of files
+- **Verification** can sometimes run alongside implementation on different file areas
+
+### What Real Verification Looks Like
+
+Verification means **proving the code works**, not confirming it exists. A verifier that rubber-stamps weak work undermines everything.
+
+- Run tests **with the feature enabled** — not just "tests pass"
+- Run typechecks and **investigate errors** — don't dismiss as "unrelated"
+- Be skeptical — if something looks off, dig in
+- **Test independently** — prove the change works, don't rubber-stamp
+
+### Handling Worker Failures
+
+When a worker reports failure (tests failed, build errors, file not found):
+- Continue the same worker with {SEND_MESSAGE_TOOL_NAME} — it has the full error context
+- If a correction attempt fails, try a different approach or report to the user
+
+### Stopping Workers
+
+Use {TASK_STOP_TOOL_NAME} to stop a worker you sent in the wrong direction — for example, when you realize mid-flight that the approach is wrong, or the user changes requirements after you launched the worker. Pass the `task_id` from the {AGENT_TOOL_NAME} tool's launch result. Stopped workers can be continued with {SEND_MESSAGE_TOOL_NAME}.
+
+```
+// Launched a worker to refactor auth to use JWT
+{AGENT_TOOL_NAME}({{ description: "Refactor auth to JWT", subagent_type: "worker", prompt: "Replace session-based auth with JWT..." }})
+// ... returns task_id: "agent-x7q" ...
+
+// User clarifies: "Actually, keep sessions — just fix the null pointer"
+{TASK_STOP_TOOL_NAME}({{ task_id: "agent-x7q" }})
+
+// Continue with corrected instructions
+{SEND_MESSAGE_TOOL_NAME}({{ to: "agent-x7q", message: "Stop the JWT refactor. Instead, fix the null pointer in src/auth/validate.ts:42..." }})
+```
+
+## 5. Writing Worker Prompts
+
+**Workers can't see your conversation.** Every prompt must be self-contained with everything the worker needs. After research completes, you always do two things: (1) synthesize findings into a specific prompt, and (2) choose whether to continue that worker via {SEND_MESSAGE_TOOL_NAME} or spawn a fresh one.
+
+### Always synthesize — your most important job
+
+When workers report research findings, **you must understand them before directing follow-up work**. Read the findings. Identify the approach. Then write a prompt that proves you understood by including specific file paths, line numbers, and exactly what to change.
+
+Never write "based on your findings" or "based on the research." These phrases delegate understanding to the worker instead of doing it yourself. You never hand off understanding to another worker.
+
+```
+// Anti-pattern — lazy delegation (bad whether continuing or spawning)
+{AGENT_TOOL_NAME}({{ prompt: "Based on your findings, fix the auth bug", ... }})
+{AGENT_TOOL_NAME}({{ prompt: "The worker found an issue in the auth module. Please fix it.", ... }})
+
+// Good — synthesized spec (works with either continue or spawn)
+{AGENT_TOOL_NAME}({{ prompt: "Fix the null pointer in src/auth/validate.ts:42. The user field on Session (src/auth/types.ts:15) is undefined when sessions expire but the token remains cached. Add a null check before user.id access — if null, return 401 with 'Session expired'. Commit and report the hash.", ... }})
+```
+
+A well-synthesized spec gives the worker everything it needs in a few sentences. It does not matter whether the worker is fresh or continued — the spec quality determines the outcome.
+
+### Add a purpose statement
+
+Include a brief purpose so workers can calibrate depth and emphasis:
+
+- "This research will inform a PR description — focus on user-facing changes."
+- "I need this to plan an implementation — report file paths, line numbers, and type signatures."
+- "This is a quick check before we merge — just verify the happy path."
+
+### Choose continue vs. spawn by context overlap
+
+After synthesizing, decide whether the worker's existing context helps or hurts:
+
+| Situation | Mechanism | Why |
+|-----------|-----------|-----|
+| Research explored exactly the files that need editing | **Continue** ({SEND_MESSAGE_TOOL_NAME}) with synthesized spec | Worker already has the files in context AND now gets a clear plan |
+| Research was broad but implementation is narrow | **Spawn fresh** ({AGENT_TOOL_NAME}) with synthesized spec | Avoid dragging along exploration noise; focused context is cleaner |
+| Correcting a failure or extending recent work | **Continue** | Worker has the error context and knows what it just tried |
+| Verifying code a different worker just wrote | **Spawn fresh** | Verifier should see the code with fresh eyes, not carry implementation assumptions |
+| First implementation attempt used the wrong approach entirely | **Spawn fresh** | Wrong-approach context pollutes the retry; clean slate avoids anchoring on the failed path |
+| Completely unrelated task | **Spawn fresh** | No useful context to reuse |
+
+There is no universal default. Think about how much of the worker's context overlaps with the next task. High overlap -> continue. Low overlap -> spawn fresh.
+
+### Continue mechanics
+
+When continuing a worker with {SEND_MESSAGE_TOOL_NAME}, it has full context from its previous run:
+```
+// Continuation — worker finished research, now give it a synthesized implementation spec
+{SEND_MESSAGE_TOOL_NAME}({{ to: "xyz-456", message: "Fix the null pointer in src/auth/validate.ts:42. The user field is undefined when Session.expired is true but the token is still cached. Add a null check before accessing user.id — if null, return 401 with 'Session expired'. Commit and report the hash." }})
+```
+
+```
+// Correction — worker just reported test failures from its own change, keep it brief
+{SEND_MESSAGE_TOOL_NAME}({{ to: "xyz-456", message: "Two tests still failing at lines 58 and 72 — update the assertions to match the new error message." }})
+```
+
+### Prompt tips
+
+**Good examples:**
+
+1. Implementation: "Fix the null pointer in src/auth/validate.ts:42. The user field can be undefined when the session expires. Add a null check and return early with an appropriate error. Commit and report the hash."
+
+2. Precise git operation: "Create a new branch from main called 'fix/session-expiry'. Cherry-pick only commit abc123 onto it. Push and create a draft PR targeting main. Add anthropics/claude-code as reviewer. Report the PR URL."
+
+3. Correction (continued worker, short): "The tests failed on the null check you added — validate.test.ts:58 expects 'Invalid session' but you changed it to 'Session expired'. Fix the assertion. Commit and report the hash."
+
+**Bad examples:**
+
+1. "Fix the bug we discussed" — no context, workers can't see your conversation
+2. "Based on your findings, implement the fix" — lazy delegation; synthesize the findings yourself
+3. "Create a PR for the recent changes" — ambiguous scope: which changes? which branch? draft?
+4. "Something went wrong with the tests, can you look?" — no error message, no file path, no direction
+
+Additional tips:
+- Include file paths, line numbers, error messages — workers start fresh and need complete context
+- State what "done" looks like
+- For implementation: "Run relevant tests and typecheck, then commit your changes and report the hash" — workers self-verify before reporting done. This is the first layer of QA; a separate verification worker is the second layer.
+- For research: "Report findings — do not modify files"
+- Be precise about git operations — specify branch names, commit hashes, draft vs ready, reviewers
+- When continuing for corrections: reference what the worker did ("the null check you added") not what you discussed with the user
+- For implementation: "Fix the root cause, not the symptom" — guide workers toward durable fixes
+- For verification: "Prove the code works, don't just confirm it exists"
+- For verification: "Try edge cases and error paths — don't just re-run what the implementation worker ran"
+- For verification: "Investigate failures — don't dismiss as unrelated without evidence"
+
+## 6. Example Session
+
+User: "There's a null pointer in the auth module. Can you fix it?"
+
+You:
+ Let me investigate first.
+
+ {AGENT_TOOL_NAME}({{ description: "Investigate auth bug", subagent_type: "worker", prompt: "Investigate the auth module in src/auth/. Find where null pointer exceptions could occur around session handling and token validation... Report specific file paths, line numbers, and types involved. Do not modify files." }})
+ {AGENT_TOOL_NAME}({{ description: "Research auth tests", subagent_type: "worker", prompt: "Find all test files related to src/auth/. Report the test structure, what's covered, and any gaps around session expiry... Do not modify files." }})
+
+ Investigating from two angles — I'll report back with findings.
+
+User:
+
+ agent-a1b
+ completed
+ Agent "Investigate auth bug" completed
+ Found null pointer in src/auth/validate.ts:42. The user field on Session is undefined when the session expires but ...
+
+
+You:
+ Found the bug — null pointer in validate.ts:42.
+
+ {SEND_MESSAGE_TOOL_NAME}({{ to: "agent-a1b", message: "Fix the null pointer in src/auth/validate.ts:42. Add a null check before accessing user.id — if null, ... Commit and report the hash." }})
+
+ Fix is in progress.
+
+User:
+ How's it going?
+
+You:
+ Fix for the new test is in progress. Still waiting to hear back about the test suite."""
+
+
+__all__ = [
+ "get_coordinator_system_prompt",
+]
diff --git a/src/coordinator/worker_agent.py b/src/coordinator/worker_agent.py
new file mode 100644
index 000000000..c7d9fa9e9
--- /dev/null
+++ b/src/coordinator/worker_agent.py
@@ -0,0 +1,60 @@
+"""WORKER agent definition — Chunk G / WI-8.5.
+
+Mirrors ``typescript/src/coordinator/workerAgent.ts``. Workers in
+coordinator mode are spread from ``GENERAL_PURPOSE_AGENT`` with two
+edits:
+
+* ``agent_type`` becomes ``"worker"`` so the coordinator system
+ prompt's ``subagent_type: "worker"`` calls resolve correctly.
+* ``when_to_use`` is rephrased for the coordinator-mode role.
+
+Workers do NOT lose tools at the agent-definition level — the
+``INTERNAL_WORKER_TOOLS`` filter (``coordinator/mode.py``) handles
+that at tool-set construction time. Keeping the filter at the
+mode-level (rather than baking it into the agent definition) means
+the same WORKER definition can be reused for non-coordinator paths
+in the future without re-editing.
+
+``get_coordinator_agents()`` returns the canonical agent list for
+coordinator-mode sessions (matches ``workerAgent.ts:16-18``).
+"""
+from __future__ import annotations
+
+from dataclasses import replace
+
+from src.agent.agent_definitions import (
+ EXPLORE_AGENT,
+ GENERAL_PURPOSE_AGENT,
+ PLAN_AGENT,
+ AgentDefinition,
+)
+
+
+# Spread GENERAL_PURPOSE → tweak agent_type + when_to_use. Mirrors
+# ``workerAgent.ts:9-14``.
+WORKER_AGENT: AgentDefinition = replace(
+ GENERAL_PURPOSE_AGENT,
+ agent_type="worker",
+ when_to_use=(
+ "Worker agent for coordinator mode. Executes tasks autonomously "
+ "— research, implementation, or verification."
+ ),
+)
+
+
+def get_coordinator_agents() -> list[AgentDefinition]:
+ """Return the canonical agent list for coordinator-mode sessions.
+
+ Mirrors ``workerAgent.ts:16-18``: ``[WORKER, GENERAL_PURPOSE,
+ EXPLORE, PLAN]``. ``WORKER`` first so the coordinator system
+ prompt's ``subagent_type: "worker"`` resolves to the right
+ definition; ``GENERAL_PURPOSE`` / ``EXPLORE`` / ``PLAN`` follow
+ so the legacy spawning paths still work in coordinator mode.
+ """
+ return [WORKER_AGENT, GENERAL_PURPOSE_AGENT, EXPLORE_AGENT, PLAN_AGENT]
+
+
+__all__ = [
+ "WORKER_AGENT",
+ "get_coordinator_agents",
+]
diff --git a/src/cost_tracker.py b/src/cost_tracker.py
index 6f95f50ce..d76ce0111 100644
--- a/src/cost_tracker.py
+++ b/src/cost_tracker.py
@@ -1,13 +1,95 @@
+"""Cost tracker — facade over ``src.bootstrap.state``.
+
+Phase 2.3 of the ch03 state refactor: the class shape is preserved for
+backward compatibility with existing callers (``costHook.py``,
+``repl/core.py``, ``tui/app.py``, ``command_system/builtins.py``), but
+all state now lives in the bootstrap singleton. Every ``CostTracker``
+instance is a view into the same state — there is no "two trackers
+disagreeing about cost" problem.
+
+Legacy ``record(label, units)`` API is retained for the existing
+``costHook.apply_cost_hook`` callers (which feed the /cost slash
+command's event log). The richer ``record_usage(model, usage)`` API
+records to the bootstrap singleton's per-model accumulators and
+also computes USD cost via ``src.services.pricing``.
+"""
+
from __future__ import annotations
from dataclasses import dataclass, field
+from typing import Any
+
+from src.bootstrap.state import (
+ ModelUsage,
+ add_to_total_cost_state,
+ get_model_usage,
+ get_total_cost_usd,
+)
+from src.services.pricing import compute_cost
@dataclass
class CostTracker:
+ """Facade over the bootstrap singleton.
+
+ Two distinct event streams co-exist for back-compat:
+
+ * ``events`` and ``total_units``: legacy free-form units that the
+ ``/cost`` slash command displays. These live on the instance (NOT
+ bootstrap) because they're informational — not part of cost
+ accounting. Multiple instances accumulate independently.
+
+ * ``record_usage(model, usage)``: routes through the bootstrap
+ singleton, so cost USD and per-model breakdown agree across
+ every consumer in the process.
+ """
+
total_units: int = 0
events: list[str] = field(default_factory=list)
+ last_usage: dict[str, Any] | None = None
def record(self, label: str, units: int) -> None:
+ """Legacy event recorder. Used by ``costHook.apply_cost_hook``."""
self.total_units += units
- self.events.append(f'{label}:{units}')
+ self.events.append(f"{label}:{units}")
+
+ def record_usage(self, model: str, usage: dict[str, Any]) -> float:
+ """Record a real API usage event into the bootstrap singleton.
+
+ Computes USD cost from per-model pricing, updates the
+ process-wide ``total_cost_usd`` accumulator, and stores a
+ ``last_usage`` snapshot for downstream consumers (e.g. the
+ ``/cost`` command's last-call summary).
+ """
+ cost = compute_cost(model, usage)
+ bootstrap_usage = ModelUsage(
+ input_tokens=int(usage.get("input_tokens", 0)),
+ output_tokens=int(usage.get("output_tokens", 0)),
+ cache_creation_input_tokens=int(usage.get("cache_creation_input_tokens", 0)),
+ cache_read_input_tokens=int(usage.get("cache_read_input_tokens", 0)),
+ cost_usd=cost,
+ )
+ # Merge with any existing per-model accumulator
+ existing = get_model_usage().get(model)
+ if existing is not None:
+ bootstrap_usage = ModelUsage(
+ input_tokens=existing.input_tokens + bootstrap_usage.input_tokens,
+ output_tokens=existing.output_tokens + bootstrap_usage.output_tokens,
+ cache_creation_input_tokens=(
+ existing.cache_creation_input_tokens
+ + bootstrap_usage.cache_creation_input_tokens
+ ),
+ cache_read_input_tokens=(
+ existing.cache_read_input_tokens + bootstrap_usage.cache_read_input_tokens
+ ),
+ cost_usd=existing.cost_usd + cost,
+ )
+ add_to_total_cost_state(cost, bootstrap_usage, model)
+ self.last_usage = dict(usage)
+ return cost
+
+ @property
+ def total_cost_usd(self) -> float:
+ """Read-through to the bootstrap singleton — every tracker
+ instance sees the same total."""
+ return get_total_cost_usd()
diff --git a/src/dialogLaunchers.py b/src/dialogLaunchers.py
deleted file mode 100644
index 78588c3b3..000000000
--- a/src/dialogLaunchers.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-
-@dataclass(frozen=True)
-class DialogLauncher:
- name: str
- description: str
-
-
-DEFAULT_DIALOGS = (
- DialogLauncher('summary', 'Launch the Markdown summary view'),
- DialogLauncher('parity_audit', 'Launch the parity audit view'),
-)
diff --git a/src/entrypoints/__init__.py b/src/entrypoints/__init__.py
index c1e9579ea..d97fc4f46 100644
--- a/src/entrypoints/__init__.py
+++ b/src/entrypoints/__init__.py
@@ -13,8 +13,33 @@
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
-from .headless import HeadlessOptions, run_headless # noqa: E402
-from .tui import TUIOptions, run_tui, should_use_tui # noqa: E402
+# WI-4.3: lazy-load the heavy entrypoints (headless + tui). Eagerly
+# importing them at package init pulls in Textual, prompt_toolkit, the
+# full tool registry, and ~150 transitive modules — defeating the
+# fast-path-dispatch acceptance contract for ``clawcodex mcp/doctor/daemon``.
+# PEP 562 ``__getattr__`` exposes the public names lazily so callers like
+# ``from src.entrypoints import run_tui`` keep working but pay the import
+# cost only when actually invoked.
+_LAZY_NAMES = {
+ 'HeadlessOptions': ('headless', 'HeadlessOptions'),
+ 'run_headless': ('headless', 'run_headless'),
+ 'TUIOptions': ('tui', 'TUIOptions'),
+ 'run_tui': ('tui', 'run_tui'),
+ 'should_use_tui': ('tui', 'should_use_tui'),
+}
+
+
+def __getattr__(name: str):
+ if name in _LAZY_NAMES:
+ module_name, attr_name = _LAZY_NAMES[name]
+ from importlib import import_module
+ module = import_module(f'.{module_name}', __name__)
+ value = getattr(module, attr_name)
+ # Cache so subsequent accesses bypass __getattr__.
+ globals()[name] = value
+ return value
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
__all__ = [
'ARCHIVE_NAME',
diff --git a/src/entrypoints/daemon.py b/src/entrypoints/daemon.py
new file mode 100644
index 000000000..21bf2fc3c
--- /dev/null
+++ b/src/entrypoints/daemon.py
@@ -0,0 +1,28 @@
+"""Fast-path handler for ``clawcodex daemon``.
+
+WI-4.3: ``daemon`` is a placeholder for a future background-runtime
+subcommand (the TS port has it for the long-running coordinator host).
+Today it's a stub that prints a "not implemented" message and exits.
+
+Importantly, the stub keeps the cold-start path light — it does NOT
+trigger the TUI/REPL imports.
+"""
+
+from __future__ import annotations
+
+import sys
+
+
+def run_daemon_subcommand(rest: list[str]) -> int:
+ """Print a "not yet implemented" message and exit cleanly.
+
+ The handler exists primarily so ``clawcodex daemon`` doesn't fall
+ through to the interactive REPL bootstrap (which would load Textual
+ and the full tool registry — wasteful for a non-interactive command).
+ """
+ print(
+ "clawcodex daemon: not yet implemented in this Python port. "
+ "See the TypeScript reference at typescript/src/coordinator/.",
+ file=sys.stderr,
+ )
+ return 1
diff --git a/src/entrypoints/doctor.py b/src/entrypoints/doctor.py
new file mode 100644
index 000000000..5a3ffa0aa
--- /dev/null
+++ b/src/entrypoints/doctor.py
@@ -0,0 +1,44 @@
+"""Fast-path handler for ``clawcodex doctor``.
+
+WI-4.3: skips the TUI/REPL load. Doctor is diagnostic-only, doesn't need
+the full tool registry or the prompt assembly. Mirrors TS ``main.tsx``
+``claude doctor`` early-return.
+"""
+
+from __future__ import annotations
+
+import sys
+
+
+def run_doctor() -> int:
+ """Print the basic environment + version sanity report.
+
+ Imports only the modules needed for the diagnostic surface — does NOT
+ load ``src.tui.app``, ``src.repl.core``, or the full tool registry.
+ MCP-side diagnostics are loaded lazily below only when ``--mcp`` is
+ present.
+ """
+ print("clawcodex doctor")
+ print("================")
+ try:
+ from src import __version__
+ print(f"version: {__version__}")
+ except Exception: # pragma: no cover
+ print("version: (unknown)")
+ print(f"python: {sys.version.split()[0]}")
+ print(f"platform: {sys.platform}")
+ # If `mcp` subflag is present, run MCP diagnostics; else skip.
+ if "--mcp" in sys.argv:
+ try:
+ import asyncio
+ from src.services.mcp.doctor import run_diagnostics
+ print("")
+ print("MCP diagnostics")
+ print("---------------")
+ result = asyncio.run(run_diagnostics())
+ for line in str(result).splitlines():
+ print(f" {line}")
+ except Exception as exc:
+ print(f"MCP diagnostics failed: {exc}", file=sys.stderr)
+ return 1
+ return 0
diff --git a/src/entrypoints/headless.py b/src/entrypoints/headless.py
index 3edf38eed..3c05d6114 100644
--- a/src/entrypoints/headless.py
+++ b/src/entrypoints/headless.py
@@ -4,10 +4,12 @@
Phase 1: run a single prompt (or a stream of prompts via stream-json stdin)
through the agent loop and emit the response in the requested output format.
-The heavy lifting lives in :mod:`src.tool_system.agent_loop` which already
-understands Anthropic + OpenAI-compatible providers and emits structured tool
-events. This module adapts those events to the CLI protocol in
-:mod:`src.cli_core`.
+The heavy lifting lives in :mod:`src.query.query` (the canonical agent
+loop), driven via the sync wrapper
+:func:`src.query.agent_loop_compat.run_query_as_agent_loop`. That loop
+already understands Anthropic + OpenAI-compatible providers and emits
+structured tool events; this module adapts those events to the CLI
+protocol in :mod:`src.cli_core`.
Design notes
------------
@@ -25,11 +27,12 @@
import io
import os
+import signal as _signal
import sys
import time
from dataclasses import dataclass
from pathlib import Path
-from typing import IO, Iterable, Optional
+from typing import IO, Callable, Iterable, Optional
from src.agent import Session
from src.cli_core import (
@@ -47,9 +50,14 @@
)
from src.config import get_default_provider, get_provider_config
from src.providers import get_provider_class
-from src.tool_system.agent_loop import ToolEvent, run_agent_loop
+from src.tool_system.renderers import AgentLoopResult, ToolEvent
+from src.query.agent_loop_compat import (
+ build_effective_system_prompt,
+ run_query_as_agent_loop,
+)
from src.tool_system.context import ToolContext
from src.tool_system.defaults import build_default_registry
+from src.utils.abort_controller import AbortController, AbortError
OUTPUT_FORMATS = ("text", "json", "stream-json")
@@ -70,7 +78,15 @@ class HeadlessOptions:
provider_name: str | None = None
model: str | None = None
max_turns: int = 20
+ # ``skip_permissions`` is a backward-compat alias for the boolean form
+ # of ``--dangerously-skip-permissions``. ``permission_mode`` and
+ # ``is_bypass_permissions_mode_available`` were added in round 5 to
+ # mirror the TS reference's resolved state. When ``skip_permissions``
+ # is True we treat it as ``permission_mode='bypassPermissions'`` and
+ # ``is_bypass_permissions_mode_available=True``.
skip_permissions: bool = False
+ permission_mode: str = "default"
+ is_bypass_permissions_mode_available: bool = False
allowed_tools: tuple[str, ...] = ()
disallowed_tools: tuple[str, ...] = ()
include_partial_messages: bool = False
@@ -137,9 +153,46 @@ def run_headless(options: HeadlessOptions) -> int:
_filter_registry(tool_registry, keep=lambda n: n.lower() not in deny)
workspace_root = options.workspace_root or Path.cwd()
- tool_context = ToolContext(workspace_root=workspace_root)
- tool_context.options.is_non_interactive_session = True
+
+ # Compute the effective permission context. ``skip_permissions=True`` is
+ # the legacy alias and means "user passed --dangerously-skip-permissions";
+ # ``permission_mode`` / ``is_bypass_permissions_mode_available`` are the
+ # round-5 fields. When skip_permissions wins, force bypass mode + bypass
+ # availability so the registry's ``has_permissions_to_use_tool`` check
+ # short-circuits to ``allow``.
if options.skip_permissions:
+ effective_mode: str = "bypassPermissions"
+ bypass_available = True
+ else:
+ effective_mode = options.permission_mode or "default"
+ bypass_available = bool(options.is_bypass_permissions_mode_available)
+
+ # Per-session abort controller. SIGINT trips this so the running
+ # tool (Bash supervisor, Agent subagent) unwinds immediately rather
+ # than waiting for the next safe interpreter bytecode boundary.
+ # Without this wiring, Ctrl-C only fires ``KeyboardInterrupt`` at
+ # the next safe boundary — which can be several minutes for a
+ # subprocess.wait() or an in-flight subagent.
+ abort_controller = AbortController()
+ # C1: load persisted permission rules (settings files) at startup so
+ # "always allow" rules saved in interactive sessions auto-allow here
+ # too. Setup warnings intentionally unsurfaced until phase C6.
+ from src.permissions.settings_paths import default_setup_paths
+ from src.permissions.setup import setup_permissions
+
+ _perm_setup = setup_permissions(
+ cwd=str(workspace_root),
+ mode=effective_mode, # type: ignore[arg-type]
+ is_bypass_available=bypass_available,
+ **default_setup_paths(str(workspace_root)),
+ )
+ tool_context = ToolContext(
+ workspace_root=workspace_root,
+ permission_context=_perm_setup.context,
+ abort_controller=abort_controller,
+ )
+ tool_context.options.is_non_interactive_session = True
+ if options.skip_permissions or effective_mode == "bypassPermissions":
tool_context.allow_docs = True
tool_context.permission_handler = None
else:
@@ -172,9 +225,7 @@ def run_headless(options: HeadlessOptions) -> int:
provider=provider_name,
cwd=str(workspace_root),
tools=tools,
- permission_mode="bypassPermissions"
- if options.skip_permissions
- else "default",
+ permission_mode=effective_mode,
)
)
@@ -185,58 +236,184 @@ def run_headless(options: HeadlessOptions) -> int:
exit_code = 0
start = time.monotonic()
- for user_msg in inputs:
- session.conversation.add_user_message(user_msg.text)
-
- on_event = _build_event_bridge(writer, aggregate_tool_events)
- on_text_chunk = None
- if writer is not None and options.include_partial_messages:
- def _emit_partial(chunk: str) -> None:
- writer.write(PartialTextEvent(text=chunk))
-
- on_text_chunk = _emit_partial
-
+ # Two-mode SIGINT handler:
+ # * Idle (waiting on stdin for the next stream-json input) → raise
+ # ``KeyboardInterrupt`` immediately so the blocking read returns.
+ # * In-flight ``run_agent_loop`` → first strike trips the controller
+ # (cooperative unwind), second strike force-quits via
+ # ``KeyboardInterrupt``. Both map to exit 130.
+ # See ``_install_sigint_handler`` for the full handler logic; the
+ # for-loop's outer ``except (AbortError, KeyboardInterrupt)`` is the
+ # single chokepoint that catches whatever the handler raises.
+ # ``restore_sigint`` runs in the ``finally`` so we don't leak global
+ # signal state to embedders.
+ in_agent_loop = _InAgentLoopFlag()
+ restore_sigint = _install_sigint_handler(
+ abort_controller, in_agent_loop, stderr
+ )
+ try:
+ # Cancellation is caught at the for-loop level (not per-iteration)
+ # so that a SIGINT landing on ANY cancellation point unwinds to one
+ # place that emits the cancelled ResultEvent: the iterator step
+ # (``StreamJsonReader``'s blocking stdin read in idle mode), the
+ # agent loop itself, or the post-success accounting between them.
+ # The inner per-iteration ``except Exception`` keeps per-turn
+ # tool/provider error handling local — it must NOT catch
+ # ``AbortError``/``KeyboardInterrupt`` (Python catches them via
+ # ``Exception`` only when they inherit from it; ``AbortError`` does
+ # but ``KeyboardInterrupt`` does not, so we exclude AbortError
+ # explicitly).
try:
- result = run_agent_loop(
- conversation=session.conversation,
- provider=provider,
- tool_registry=tool_registry,
- tool_context=tool_context,
- max_turns=options.max_turns,
- stream=bool(on_text_chunk),
- verbose=options.verbose,
- on_event=on_event,
- on_text_chunk=on_text_chunk,
- )
- except KeyboardInterrupt:
+ for user_msg in inputs:
+ session.conversation.add_user_message(user_msg.text)
+
+ on_event = _build_event_bridge(writer, aggregate_tool_events)
+ on_text_chunk = None
+ if writer is not None and options.include_partial_messages:
+ def _emit_partial(chunk: str) -> None:
+ writer.write(PartialTextEvent(text=chunk))
+
+ on_text_chunk = _emit_partial
+
+ try:
+ in_agent_loop.value = True
+ try:
+ # Ch5/F.2 cutover: route headless through the
+ # canonical query() loop via the F.1 adapter.
+ # Headless is single-shot per prompt and starts
+ # its own event loop, so ``asyncio.run`` is the
+ # right pattern. Pre-build the effective system
+ # prompt (CLAUDE.md + git status + style) so the
+ # cold-start context reaches query() unchanged
+ # — the legacy run_agent_loop did this inside
+ # the loop; the adapter doesn't.
+ import asyncio as _asyncio
+ from src.outputStyles import resolve_output_style
+ _style_prompt = resolve_output_style(
+ getattr(tool_context, "output_style_name", None),
+ getattr(tool_context, "output_style_dir", None),
+ ).prompt
+ effective_system_prompt = (
+ build_effective_system_prompt(_style_prompt, tool_context)
+ )
+
+ def _persist(msg: Any) -> None:
+ # BLOCKING #2 fix: persist FULL message
+ # (including tool_use/tool_result blocks)
+ # so the next turn can pair tool_use IDs to
+ # results. Plain add_assistant_message
+ # loses the structure.
+ # Critic S3: log + re-raise on failure
+ # rather than swallow; a persist error
+ # means the conversation is corrupted and
+ # the next API call will reject it. Better
+ # to surface now than to debug a 400 later.
+ try:
+ session.conversation.add_message(msg.role, msg.content)
+ except Exception:
+ import logging
+ logging.getLogger(__name__).exception(
+ "Failed to persist message into "
+ "conversation: role=%s",
+ getattr(msg, "role", "?"),
+ )
+ raise
+
+ compat_result = _asyncio.run(run_query_as_agent_loop(
+ initial_messages=list(session.conversation.messages),
+ provider=provider,
+ tool_registry=tool_registry,
+ tool_context=tool_context,
+ system_prompt=effective_system_prompt,
+ max_turns=options.max_turns,
+ on_event=on_event,
+ on_text_chunk=on_text_chunk,
+ on_message=_persist,
+ # Critic C2: pass the OWNING controller so
+ # the provider's chat_stream_response listens
+ # on the same signal the SIGINT handler trips.
+ # Passing only ``cancel_signal=signal`` would
+ # force the adapter to mint a fresh controller
+ # and break the mid-stream tear-down path.
+ abort_controller=abort_controller,
+ ))
+ # Re-wrap into legacy AgentLoopResult shape so
+ # downstream usage/num_turns/response_text code
+ # stays untouched. ``usage if num_turns > 0
+ # else None`` preserves the dict|None contract.
+ result = AgentLoopResult(
+ response_text=compat_result.response_text,
+ usage=(
+ compat_result.usage
+ if compat_result.num_turns > 0
+ else None
+ ),
+ num_turns=compat_result.num_turns,
+ )
+ finally:
+ # Flip BEFORE the outer except block can run so a
+ # SIGINT landing between ``run_agent_loop`` returning
+ # and the next iterator step is correctly classified
+ # as idle. (``AbortError`` is a subclass of
+ # ``Exception`` and would otherwise be re-raised
+ # through this finally too — so we set the flag
+ # back to False regardless of how we leave.)
+ in_agent_loop.value = False
+ except AbortError:
+ # Re-raise to the outer ``except`` so the cancelled
+ # ResultEvent is emitted in exactly one place.
+ raise
+ except Exception as exc:
+ exit_code = 1
+ if writer is not None:
+ writer.write(
+ ResultEvent(
+ subtype="error",
+ session_id=session.session_id,
+ num_turns=num_turns_total,
+ result=str(exc),
+ duration_ms=int((time.monotonic() - start) * 1000),
+ is_error=True,
+ error=str(exc),
+ )
+ )
+ else:
+ print(f"error: {exc}", file=stderr)
+ break
+
+ num_turns_total += result.num_turns
+ if result.usage:
+ for key, value in result.usage.items():
+ usage_total[key] = usage_total.get(key, 0) + int(value)
+
+ if writer is not None:
+ writer.write(AssistantEvent(text=result.response_text))
+ aggregate_text.append(result.response_text)
+ except (AbortError, KeyboardInterrupt):
+ # Cancellation from ANY point in the loop body lands here:
+ # * ``AbortError`` from a cooperative unwind inside
+ # ``run_agent_loop`` (first SIGINT, in-flight mode).
+ # * ``KeyboardInterrupt`` from the SIGINT handler's idle
+ # branch (raised mid-``inputs.__iter__()`` while blocked on
+ # stdin), or from the in-flight second-strike force-quit.
+ # All map to exit 130 for shell parity. ``error`` is left
+ # unset — ``subtype: "cancelled"`` already carries the
+ # signal, and pairing ``is_error=False`` with a populated
+ # ``error`` field would confuse consumers.
exit_code = 130
- break
- except Exception as exc:
- exit_code = 1
if writer is not None:
writer.write(
ResultEvent(
- subtype="error",
+ subtype="cancelled",
session_id=session.session_id,
num_turns=num_turns_total,
- result=str(exc),
+ result="",
duration_ms=int((time.monotonic() - start) * 1000),
- is_error=True,
- error=str(exc),
+ is_error=False,
)
)
- else:
- print(f"error: {exc}", file=stderr)
- break
-
- num_turns_total += result.num_turns
- if result.usage:
- for key, value in result.usage.items():
- usage_total[key] = usage_total.get(key, 0) + int(value)
-
- if writer is not None:
- writer.write(AssistantEvent(text=result.response_text))
- aggregate_text.append(result.response_text)
+ finally:
+ restore_sigint()
duration_ms = int((time.monotonic() - start) * 1000)
final_text = "\n\n".join(t for t in aggregate_text if t).strip()
@@ -246,9 +423,15 @@ def _emit_partial(chunk: str) -> None:
stdout.write(final_text + "\n")
stdout.flush()
elif options.output_format == "json":
+ if exit_code == 0:
+ json_subtype = "success"
+ elif exit_code == 130:
+ json_subtype = "cancelled"
+ else:
+ json_subtype = "error"
payload = {
"type": "result",
- "subtype": "error" if exit_code not in (0, 130) else "success",
+ "subtype": json_subtype,
"session_id": session.session_id,
"provider": provider_name,
"model": getattr(provider, "model", None),
@@ -280,6 +463,118 @@ def _emit_partial(chunk: str) -> None:
# Helpers
+class _InAgentLoopFlag:
+ """Mutable shared flag indicating whether ``run_agent_loop`` is in flight.
+
+ Read by the SIGINT handler to decide between cooperative abort
+ (in-flight: trip the controller, let the loop unwind at the next
+ safe boundary) and immediate raise (idle, e.g. blocked on
+ ``StreamJsonReader``'s stdin read: the only way to make the read
+ return is to actually raise ``KeyboardInterrupt`` on the same
+ thread — Python 3 auto-retries EINTR'd reads when the handler
+ didn't raise, per PEP 475).
+ """
+
+ __slots__ = ("value",)
+
+ def __init__(self) -> None:
+ self.value = False
+
+
+def _install_sigint_handler(
+ controller: AbortController,
+ in_agent_loop: _InAgentLoopFlag,
+ stderr: IO[str],
+) -> Callable[[], None]:
+ """Install a context-aware SIGINT handler.
+
+ - **Idle** (``in_agent_loop.value`` is False, e.g. blocked on
+ stdin reading the next stream-json input): raise
+ ``KeyboardInterrupt`` immediately. Python 3 PEP 475 retries
+ EINTR'd ``read()`` calls when the signal handler did NOT raise,
+ so a cooperative abort here would *hang the stdin read* until
+ the user hit Ctrl-C a second time — a UX regression vs. the
+ pre-fix behaviour where the first Ctrl-C raised at the next
+ bytecode boundary and exited the program. Raising on the first
+ strike restores parity with that pre-fix path.
+
+ - **Cooperative** (in-flight ``run_agent_loop``, first strike):
+ trip ``controller``. Every abort-aware site — the agent loop's
+ ``_check_cancel`` boundaries, the Bash supervisor's poll loop,
+ the subagent query loop, the streaming executor's per-tool
+ controller, hook gates — sees the signal and unwinds gracefully
+ with a partial result that's appended to the conversation. A
+ message is printed to stderr so the user knows the request was
+ received but unwind may take a moment.
+
+ - **Cooperative** (in-flight, second strike): re-install the
+ platform default handler (defense-in-depth against a possible
+ third strike landing during unwind) and raise
+ ``KeyboardInterrupt`` directly. This is the force-quit escape
+ hatch for the rare case where a tool doesn't honour the abort.
+
+ Returns a callable that restores whatever handler was installed
+ before us, so embedders that drive ``run_headless`` from inside a
+ larger program don't have their global signal state mutated.
+
+ ``signal.signal`` is only callable from the main thread; if we are
+ not the main thread (e.g. an SDK harness that runs headless in a
+ worker thread), the install is skipped and the returned restore is
+ a no-op. Cancellation in that case falls back to the agent loop's
+ natural turn-boundary checks via ``KeyboardInterrupt`` propagation
+ from whatever signal facility the embedder is using.
+ """
+
+ previous = _signal.getsignal(_signal.SIGINT)
+
+ def _handler(signum, frame):
+ if not in_agent_loop.value:
+ # Idle on input — raise so the blocking stdin read returns.
+ # No need to swap to ``default_int_handler``: there's no
+ # cooperative-unwind escalation state to escalate from, and
+ # ``restore_sigint()`` in the ``finally`` block will revert
+ # the user's pre-existing handler shortly after the raise
+ # unwinds out of ``run_headless``. A second SIGINT before
+ # that finally runs would just re-enter this handler and
+ # raise again — fine.
+ raise KeyboardInterrupt
+ if controller.signal.aborted:
+ # Second strike during cooperative unwind: re-arm the
+ # platform default handler (so any third strike terminates
+ # the process the usual way) and raise the force-quit.
+ _signal.signal(_signal.SIGINT, _signal.default_int_handler)
+ raise KeyboardInterrupt
+ controller.abort("user_interrupt")
+ try:
+ # Plain ASCII for portability — some legacy Windows code
+ # pages can't encode U+2026 and would silently drop the
+ # message via the outer except.
+ stderr.write("\nCancelling... (Ctrl-C again to force quit)\n")
+ stderr.flush()
+ except Exception:
+ # A broken stderr (closed pipe etc.) must not stop the
+ # cancellation from propagating — the controller is already
+ # tripped, the agent loop will unwind regardless.
+ pass
+
+ try:
+ _signal.signal(_signal.SIGINT, _handler)
+ except (ValueError, OSError):
+ # Not in main thread (ValueError) or SIGINT not supported on
+ # this platform (OSError on some Windows configurations).
+ # Fall back to the agent loop's natural turn-boundary cancel
+ # checks via ``KeyboardInterrupt`` — the pre-fix behaviour.
+ return lambda: None
+
+ def _restore() -> None:
+ try:
+ _signal.signal(_signal.SIGINT, previous)
+ except (ValueError, OSError):
+ pass
+
+ return _restore
+
+
def _filter_registry(registry, *, keep) -> None:
"""In-place best-effort filter of a ToolRegistry."""
@@ -299,16 +594,19 @@ def _filter_registry(registry, *, keep) -> None:
def _auto_deny_permission_handler(stderr: IO[str]):
- def handler(tool_name: str, message: str, suggestion: Optional[str]):
+ from src.permissions.types import PermissionAskReply, PermissionAskRequest
+
+ def handler(request: PermissionAskRequest) -> PermissionAskReply:
stderr.write(
- f"[headless] denying permission for {tool_name}: {message}"
+ f"[headless] denying permission for {request.tool_name}: "
+ f"{request.message}"
" (pass --dangerously-skip-permissions to bypass)\n"
)
try:
stderr.flush()
except Exception:
pass
- return False, False
+ return PermissionAskReply(behavior="deny")
return handler
diff --git a/src/entrypoints/mcp.py b/src/entrypoints/mcp.py
new file mode 100644
index 000000000..abc9eb214
--- /dev/null
+++ b/src/entrypoints/mcp.py
@@ -0,0 +1,72 @@
+"""Fast-path handler for ``clawcodex mcp ...`` subcommands.
+
+WI-4.3: invoked from ``src/cli.py``'s pre-argparse subcommand sieve so the
+MCP tooling path doesn't load the TUI/REPL/full tool registry. Imports
+only what it needs (the ``src.services.mcp`` package) and exits.
+
+Mirrors the chapter's "fast-path dispatch" pattern (TS ``main.tsx:914+``):
+specialized subcommands get an early-return that skips the React REPL.
+"""
+
+from __future__ import annotations
+
+import sys
+
+
+def run_mcp_subcommand(rest: list[str]) -> int:
+ """Handle ``clawcodex mcp [args...]``.
+
+ Verbs (initial set):
+ * ``list`` — list configured MCP servers (reads
+ ``~/.claude/settings.json``-style config and prints names).
+ * ``--help`` — print usage and exit 0.
+
+ Returns the process exit code.
+ """
+ if not rest or rest[0] in ("--help", "-h"):
+ _print_usage()
+ return 0
+
+ verb = rest[0]
+ if verb == "list":
+ return _list_servers()
+ print(f"clawcodex mcp: unknown verb {verb!r}", file=sys.stderr)
+ _print_usage()
+ return 2
+
+
+def _print_usage() -> None:
+ print("Usage: clawcodex mcp [args...]")
+ print("")
+ print("Verbs:")
+ print(" list List configured MCP servers")
+
+
+def _list_servers() -> int:
+ """Print each configured MCP server's name on its own line.
+
+ Avoids importing the TUI/REPL/full tool registry — only loads what
+ the MCP config layer needs. The chapter's fast-path test (skip the
+ React REPL on ``claude mcp``) is verified by ensuring this handler
+ does NOT trigger ``src.tui.app`` or ``src.repl.core`` imports.
+ """
+ try:
+ # Local imports keep the module-load cost off the hot cold-start
+ # path of the interactive CLI. ``get_all_mcp_configs`` returns
+ # ``(dict[str, ScopedMcpServerConfig], list[ValidationError])``.
+ from src.services.mcp.config import get_all_mcp_configs
+ except Exception as exc: # pragma: no cover
+ print(f"clawcodex mcp list: cannot load MCP config: {exc}", file=sys.stderr)
+ return 1
+ try:
+ configs, _errors = get_all_mcp_configs()
+ except Exception as exc:
+ print(f"clawcodex mcp list: error reading config: {exc}", file=sys.stderr)
+ return 1
+ names = sorted(configs.keys())
+ if not names:
+ print("(no MCP servers configured)")
+ return 0
+ for name in names:
+ print(name)
+ return 0
diff --git a/src/entrypoints/tui.py b/src/entrypoints/tui.py
index 31fa89b47..64cbdb50f 100644
--- a/src/entrypoints/tui.py
+++ b/src/entrypoints/tui.py
@@ -35,6 +35,12 @@ class TUIOptions:
disallowed_tools: tuple[str, ...] = ()
workspace_root: Path | None = None
stream: bool = True
+ # Resolved permission state from --dangerously-skip-permissions /
+ # --allow-dangerously-skip-permissions / --permission-mode. Threaded
+ # in by ``cli._run_tui_mode`` so the TUI tool context honors the same
+ # flags as the headless entrypoint.
+ permission_mode: str = "default"
+ is_bypass_permissions_mode_available: bool = False
# Test hook: replace the provider instance we'd otherwise build from config.
provider_factory: Callable[[], object] | None = None
@@ -52,13 +58,17 @@ def run_tui(options: TUIOptions) -> int:
2,
)
- # Match typescript/src/bootstrap/state.ts: tool gating like
- # ``isTodoV2Enabled()`` reads this flag, so set it BEFORE we build the
- # registry.
+ # ``is_interactive`` is normally set during bootstrap phase 2 by
+ # ``src.init.run_pre_action`` (called from ``cli.main``) before any
+ # entry point runs. C8 re-asserts it here defensively: the bootstrap
+ # DEFAULT is non-interactive, and since the .mcp.json approval gate
+ # auto-approves non-interactive sessions (TS utils.ts:399-403), a
+ # future caller booting run_tui without the CLI bootstrap would
+ # otherwise silently auto-approve repo MCP servers inside an
+ # interactive UI.
from src.bootstrap.state import set_is_interactive
set_is_interactive(True)
-
workspace_root = options.workspace_root or Path.cwd()
# Build provider ------------------------------------------------------
@@ -99,10 +109,31 @@ def run_tui(options: TUIOptions) -> int:
deny = {name.lower() for name in options.disallowed_tools}
_filter_registry(tool_registry, keep=lambda n: n.lower() not in deny)
- tool_context = ToolContext(workspace_root=workspace_root)
- # In the TUI we *do* want tool permission prompts to show up as dialogs,
- # but until we ship ``PermissionDialog`` we default to the same
- # bypassPermissions behavior the current Rich REPL uses.
+ # Apply the resolved permission state (from ``--dangerously-skip-permissions``
+ # or ``--permission-mode``). When bypass is in effect we also flip
+ # ``allow_docs`` so the doc-write gate in write.py / edit.py doesn't
+ # second-guess the user's explicit opt-in.
+ # C1: load persisted permission rules (settings files) at startup so
+ # "always allow" rules from prior sessions are live — the rule engine
+ # ran against empty rule sets before this. Setup warnings (dangerous /
+ # shadowed rules) surface as startup transcript rows via
+ # services/config_health.collect_rule_warnings (C6).
+ from src.permissions.settings_paths import default_setup_paths
+ from src.permissions.setup import setup_permissions
+
+ _perm_setup = setup_permissions(
+ cwd=str(workspace_root),
+ mode=options.permission_mode or "default", # type: ignore[arg-type]
+ is_bypass_available=bool(options.is_bypass_permissions_mode_available),
+ **default_setup_paths(str(workspace_root)),
+ )
+
+ tool_context = ToolContext(
+ workspace_root=workspace_root,
+ permission_context=_perm_setup.context,
+ )
+ if options.permission_mode == "bypassPermissions":
+ tool_context.allow_docs = True
tool_context.options.is_non_interactive_session = False
# Build and run app ---------------------------------------------------
@@ -130,7 +161,10 @@ def run_tui(options: TUIOptions) -> int:
app.run(inline=True, inline_no_clear=True, mouse=False)
except KeyboardInterrupt:
return 130
- return 0
+ # C8: startup gates exit via app.exit(return_code=...) — declining
+ # the trust or bypass dialog must propagate a non-zero exit code to
+ # the shell (TS gracefulShutdownSync(1)).
+ return app.return_code or 0
def _replay_transcript_to_host(app) -> None:
diff --git a/src/hooks/__init__.py b/src/hooks/__init__.py
index d12b7839d..ea99135e0 100644
--- a/src/hooks/__init__.py
+++ b/src/hooks/__init__.py
@@ -20,6 +20,7 @@
PostSamplingHookInput,
PostToolUseHookInput,
PreToolUseHookInput,
+ ShellType,
StopHookInput,
UserPromptSubmitHookInput,
)
@@ -30,6 +31,12 @@
get_global_hook_registry,
reset_global_hook_registry,
)
+from .shell_invocation import (
+ DEFAULT_HOOK_SHELL,
+ SHELL_TYPES,
+ build_powershell_args,
+ find_powershell_path,
+)
__all__ = [
"ALL_HOOK_EVENTS",
@@ -37,6 +44,7 @@
"HTTP_HOOK_TIMEOUT_MS",
"TOOL_HOOK_EXECUTION_TIMEOUT_MS",
"AsyncHookRegistry",
+ "DEFAULT_HOOK_SHELL",
"HookConfig",
"HookEvent",
"HookProgress",
@@ -48,8 +56,12 @@
"PostToolUseHookInput",
"PreToolUseHookInput",
"RegisteredHook",
+ "SHELL_TYPES",
+ "ShellType",
"StopHookInput",
"UserPromptSubmitHookInput",
+ "build_powershell_args",
+ "find_powershell_path",
"get_global_hook_registry",
"reset_global_hook_registry",
"HookConfigManager",
diff --git a/src/hooks/config_manager.py b/src/hooks/config_manager.py
index 29306d546..fecfc8644 100644
--- a/src/hooks/config_manager.py
+++ b/src/hooks/config_manager.py
@@ -11,6 +11,7 @@
from .hook_types import HookConfig, HookEvent, HookSource, ALL_HOOK_EVENTS
from .registry import AsyncHookRegistry
+from .shell_invocation import SHELL_TYPES, ShellType
logger = logging.getLogger(__name__)
@@ -42,8 +43,19 @@ def _get_settings_path() -> Path:
return Path.home() / ".claude" / "settings.json"
-def _parse_hook_config(raw: dict[str, Any]) -> HookConfig:
+def _parse_hook_config(raw: dict[str, Any], source: HookSource | None = None) -> HookConfig:
hook_type = raw.get("type", "command")
+
+ # Round-2 / Ch12 — per-hook shell selection. Only meaningful for
+ # ``type == "command"`` (matches TS schema where ``shell`` only appears on
+ # BashCommandHookSchema). Unknown values are dropped here and recorded as
+ # validator errors by ``validate_hook_configs``; the parser stays
+ # permissive so a single bad entry doesn't black-hole the whole snapshot.
+ shell_raw = raw.get("shell")
+ shell: ShellType | None = (
+ shell_raw if isinstance(shell_raw, str) and shell_raw in SHELL_TYPES else None # type: ignore[assignment]
+ )
+
return HookConfig(
type=hook_type,
command=raw.get("command", ""),
@@ -52,7 +64,15 @@ def _parse_hook_config(raw: dict[str, Any]) -> HookConfig:
url=raw.get("url"),
prompt_text=raw.get("promptText") or raw.get("prompt_text"),
agent_instructions=raw.get("agentInstructions") or raw.get("agent_instructions"),
- source=HookSource.SETTINGS,
+ # Phase-1 / WI-1.3 — new fields. Settings.json is permissive on key
+ # casing: accept both ``if_condition`` (snake_case, Python-native) and
+ # ``if`` (TS-native, matches schemas/hooks.ts). ``once`` stays as a
+ # bool. ``skill_root`` is NOT parsed from settings — only set at
+ # skill-hook registration time (Phase 3).
+ if_condition=raw.get("if_condition") or raw.get("if"),
+ once=bool(raw.get("once", False)),
+ shell=shell,
+ source=source if source is not None else HookSource.USER_SETTINGS,
)
@@ -72,6 +92,29 @@ def validate_hook_configs(
))
continue
+ # Per-hook field validation for the WI-1.3 additions:
+ # ``if`` / ``if_condition`` must be a string when present;
+ # ``once`` must be a bool.
+ if isinstance(hook_list, list):
+ for i, hook_raw in enumerate(hook_list):
+ if not isinstance(hook_raw, dict):
+ continue
+ if_value = hook_raw.get("if_condition", hook_raw.get("if"))
+ if if_value is not None and not isinstance(if_value, str):
+ errors.append(HookValidationError(
+ event=event_name,
+ index=i,
+ field="if",
+ message="`if` must be a string (permission-rule grammar)",
+ ))
+ if "once" in hook_raw and not isinstance(hook_raw["once"], bool):
+ errors.append(HookValidationError(
+ event=event_name,
+ index=i,
+ field="once",
+ message="`once` must be a boolean",
+ ))
+
if not isinstance(hook_list, list):
errors.append(HookValidationError(
event=event_name,
@@ -100,6 +143,24 @@ def validate_hook_configs(
field="command",
message="Command hook must have a 'command' field",
))
+ # Round-2 / Ch12 — validate ``shell`` for command hooks only.
+ # TS ``BashCommandHookSchema.shell`` enforces ``z.enum(SHELL_TYPES)``;
+ # we surface unknown values here so settings authors see them.
+ # Mirrors TS where ``parseSettingsFile`` would reject the entry
+ # whole; we keep the parser permissive (drop the bad value)
+ # but flag the error so the failure is visible in the
+ # ``/hooks`` UI / logs.
+ shell_raw = hook_raw.get("shell")
+ if shell_raw is not None and shell_raw not in SHELL_TYPES:
+ errors.append(HookValidationError(
+ event=event_name,
+ index=i,
+ field="shell",
+ message=(
+ f"Unknown shell type: {shell_raw!r}. "
+ f"Must be one of {SHELL_TYPES}."
+ ),
+ ))
elif hook_type == "http":
if not hook_raw.get("url"):
errors.append(HookValidationError(
@@ -149,6 +210,54 @@ def validate_hook_configs(
return errors
+# ---------------------------------------------------------------------------
+# Phase-1 / WI-1.1 — legacy back-compat reader for lifecycle events.
+# ---------------------------------------------------------------------------
+
+# Pre-Phase-1, ``SessionStart`` / ``SessionEnd`` / compaction events were
+# routed through the ``Notification`` event with a magic matcher string.
+# Phase 1 promotes them to first-class events; this map preserves the legacy
+# form for one CHANGELOG cycle. The matcher can be either ``onSessionStart``
+# (legacy code path in ``session_hooks.py`` pre-Phase-1) or the bare event
+# name (used in the regression test). Both translate to the same first-class
+# event with a DeprecationWarning.
+_LEGACY_NOTIFICATION_MATCHER_TO_EVENT: dict[str, HookEvent] = {
+ "onSessionStart": "SessionStart",
+ "onSessionEnd": "SessionEnd",
+ "onCompact": "PreCompact",
+ "SessionStart": "SessionStart",
+ "SessionEnd": "SessionEnd",
+ "PreCompact": "PreCompact",
+ "PostCompact": "PostCompact",
+}
+
+
+def _translate_legacy_notification_entry(
+ hook_raw: dict[str, Any],
+) -> HookEvent | None:
+ """If ``hook_raw`` carries a legacy lifecycle matcher under
+ ``Notification``, return the canonical first-class event name; else None.
+
+ Emits a DeprecationWarning at translation time so settings authors see
+ one warning per offending entry.
+ """
+ matcher = hook_raw.get("matcher")
+ if not isinstance(matcher, str):
+ return None
+ target = _LEGACY_NOTIFICATION_MATCHER_TO_EVENT.get(matcher)
+ if target is None:
+ return None
+ import warnings as _warnings
+ _warnings.warn(
+ f"Hook registered under 'Notification' with matcher={matcher!r} is "
+ f"deprecated; use the first-class event {target!r} directly. "
+ "Will be removed two CHANGELOG entries after the rename.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return target
+
+
def load_hooks_from_settings(
settings_path: str | Path | None = None,
) -> HookConfigSnapshot:
@@ -171,12 +280,17 @@ def load_hooks_from_settings(
for event_name, hook_list in hooks_raw.items():
if not isinstance(hook_list, list):
continue
- configs: list[HookConfig] = []
for hook_raw in hook_list:
- if isinstance(hook_raw, dict):
- configs.append(_parse_hook_config(hook_raw))
- if configs:
- hooks[event_name] = configs
+ if not isinstance(hook_raw, dict):
+ continue
+ # Phase-1 / WI-1.1 — legacy ``Notification + matcher`` form
+ # translates to first-class lifecycle events.
+ target_event: str = event_name
+ if event_name == "Notification":
+ translated = _translate_legacy_notification_entry(hook_raw)
+ if translated is not None:
+ target_event = translated
+ hooks.setdefault(target_event, []).append(_parse_hook_config(hook_raw))
return HookConfigSnapshot(
hooks=hooks,
@@ -204,7 +318,7 @@ async def load(self) -> HookConfigSnapshot:
snapshot = load_hooks_from_settings(self._settings_path)
self._snapshot = snapshot
- await self._registry.clear_source(HookSource.SETTINGS)
+ await self._registry.clear_source(HookSource.USER_SETTINGS)
for event_name, hook_configs in snapshot.hooks.items():
for config in hook_configs:
@@ -212,7 +326,7 @@ async def load(self) -> HookConfigSnapshot:
await self._registry.register(
event_name, # type: ignore[arg-type]
config,
- HookSource.SETTINGS,
+ HookSource.USER_SETTINGS,
)
try:
diff --git a/src/hooks/hook_executor.py b/src/hooks/hook_executor.py
index 6eaa615a3..a1e6a4552 100644
--- a/src/hooks/hook_executor.py
+++ b/src/hooks/hook_executor.py
@@ -19,6 +19,7 @@
import logging
import os
import time
+import warnings
from typing import Any, AsyncGenerator
from uuid import uuid4
@@ -28,6 +29,7 @@
HookEvent,
HookResult,
)
+from src.hooks.trust_gate import should_skip_hook_due_to_trust
from src.types.messages import (
create_attachment_message,
create_progress_message,
@@ -36,38 +38,87 @@
logger = logging.getLogger(__name__)
-def _get_hooks_from_settings(tool_use_context: Any) -> dict[str, list[HookConfig]]:
+def _get_hooks_from_snapshot(tool_use_context: Any) -> dict[str, list[HookConfig]]:
+ """Read hooks from the frozen snapshot held on the active HookConfigManager.
+
+ Mirrors typescript/src/utils/hooks/hooksConfigSnapshot.ts:119-124
+ (``getHooksConfigFromSnapshot``). settings.json is never re-read implicitly;
+ snapshot updates flow only through ``HookConfigManager.load()`` (startup) or
+ ``HookConfigManager.reload_if_changed()`` (explicit /hooks command).
+
+ **Back-compat:** if ``hook_config_manager`` is not set on the context but
+ ``options.hooks`` is, fall back to the legacy read path with a
+ ``DeprecationWarning``. This keeps existing callers / tests working during
+ one CHANGELOG cycle. After the deprecation cycle, the fallback is removed.
+ """
+ manager = getattr(tool_use_context, "hook_config_manager", None)
+ if manager is not None:
+ snapshot = getattr(manager, "snapshot", None)
+ if snapshot is None:
+ # Manager exists but hasn't been loaded — treat as empty.
+ return {}
+ # Defensive copy: callers MUST NOT mutate the returned dict.
+ return {ev: list(hooks) for ev, hooks in snapshot.hooks.items()}
+
+ # Legacy fallback — emit DeprecationWarning if options.hooks carries data.
+ return _get_hooks_from_options_legacy(tool_use_context)
+
+
+def _get_hooks_from_options_legacy(tool_use_context: Any) -> dict[str, list[HookConfig]]:
+ """Legacy read path: ``tool_use_context.options.hooks``.
+
+ Bypasses the snapshot freezing semantic introduced in WI-0.1. Preserved for
+ one CHANGELOG cycle so existing callers / tests do not break in lockstep
+ with the rewire. Emits a ``DeprecationWarning`` when it actually returns
+ data (silent no-op when options.hooks is empty/None).
+ """
try:
options = getattr(tool_use_context, "options", None)
if options is None:
return {}
hooks_config = getattr(options, "hooks", None)
- if hooks_config is None:
+ if hooks_config is None or not hooks_config:
+ return {}
+ if not isinstance(hooks_config, dict):
return {}
- if isinstance(hooks_config, dict):
- result: dict[str, list[HookConfig]] = {}
- for event_name, hook_list in hooks_config.items():
- if isinstance(hook_list, list):
- configs = []
- for h in hook_list:
- if isinstance(h, dict):
- configs.append(HookConfig(
- type=h.get("type", "command"),
- command=h.get("command", ""),
- timeout=h.get("timeout"),
- matcher=h.get("matcher"),
- ))
- elif isinstance(h, HookConfig):
- configs.append(h)
- result[event_name] = configs
- return result
+ result: dict[str, list[HookConfig]] = {}
+ for event_name, hook_list in hooks_config.items():
+ if isinstance(hook_list, list):
+ configs = []
+ for h in hook_list:
+ if isinstance(h, dict):
+ configs.append(HookConfig(
+ type=h.get("type", "command"),
+ command=h.get("command", ""),
+ timeout=h.get("timeout"),
+ matcher=h.get("matcher"),
+ ))
+ elif isinstance(h, HookConfig):
+ configs.append(h)
+ result[event_name] = configs
+ if result:
+ warnings.warn(
+ "Reading hooks from tool_use_context.options.hooks is deprecated; "
+ "wire a HookConfigManager onto tool_use_context.hook_config_manager "
+ "and call .load() at bootstrap. The legacy path bypasses the snapshot "
+ "security model (chapter §'The Snapshot Security Model'). Will be "
+ "removed two CHANGELOG entries after the rename.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return result
except Exception:
- pass
- return {}
+ return {}
+
+
+# Back-compat alias — preserved for any external test fixtures still importing
+# ``_get_hooks_from_settings`` directly. New code uses ``_get_hooks_from_snapshot``.
+def _get_hooks_from_settings(tool_use_context: Any) -> dict[str, list[HookConfig]]:
+ return _get_hooks_from_snapshot(tool_use_context)
def has_hook_for_event(event: str, tool_use_context: Any) -> bool:
- hooks = _get_hooks_from_settings(tool_use_context)
+ hooks = _get_hooks_from_snapshot(tool_use_context)
return bool(hooks.get(event))
@@ -85,11 +136,75 @@ def _matches_tool(matcher: str | None, tool_name: str) -> bool:
return matcher == tool_name
+def _build_hook_env(
+ hook: HookConfig,
+ stdin_data: dict[str, Any],
+ tool_use_context: Any | None,
+) -> dict[str, str]:
+ """Compute the env-var dict passed to a command-hook subprocess.
+
+ Phase-1 / WI-1.5 — adds three vars on top of inherited ``os.environ``:
+
+ * ``CLAUDE_HOOK_EVENT`` — the canonical event name (existing).
+ * ``CLAUDE_PROJECT_DIR`` — workspace root from the active context.
+ Empty string if the context doesn't carry a workspace_root.
+ * ``CLAUDE_PLUGIN_ROOT`` — set from ``hook.skill_root`` (populated only
+ for skill-declared hooks; empty for everything else).
+ * ``CLAUDE_ENV_FILE`` — per-fire ephemeral env file path. Set ONLY for
+ the three lifecycle events that benefit from env propagation
+ (``SessionStart``, ``Setup``, ``CwdChanged``). For other events:
+ empty string. Per N4: this WI sets the path; the
+ sourcing-and-applying loop (read the file back and apply exports to
+ subsequent shells in the session) is a separate follow-up ticket.
+ TODO(ch12-followup): ticket #