Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add native ACP wrappers for codex and claude

Lyric e33e74a2 bf5ef6c4

+2871 -11
+23
assets/config/config.example.yaml
··· 273 273 # - name: "codex" 274 274 # enable: true 275 275 # type: "stdio" 276 + # # Native wrapper kept in this repository: 277 + # command: "node" 278 + # args: ["./wrappers/acp/codex/src/index.mjs"] 279 + # # Or use a third-party ACP adapter: 276 280 # command: "codex-acp" 277 281 # args: [] 278 282 # # Or install nothing and run through npx: ··· 287 291 # # session_options: 288 292 # # mode: "auto" 289 293 # # reasoning_effort: "low" 294 + # # Example for the native Codex wrapper: 295 + # # session_options: 296 + # # approval_policy: "never" 297 + # # reasoning_effort: "low" 298 + # - name: "claude" 299 + # enable: true 300 + # type: "stdio" 301 + # command: "node" 302 + # args: ["./wrappers/acp/claude/src/index.mjs"] 303 + # env: {} 304 + # cwd: "." 305 + # read_roots: ["."] 306 + # write_roots: ["."] 307 + # session_options: 308 + # permission_mode: "dontAsk" 309 + # allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] 310 + # max_turns: 8 311 + # # Set bare: true only when you authenticate with API keys or explicit settings. 312 + # # Claude Code bare mode skips OAuth and keychain reads. 290 313 291 314 # Markdown-based memory 292 315 memory:
+78 -4
docs/acp.md
··· 139 139 140 140 So ACP support should be treated as controlled delegation, not a hard sandbox. 141 141 142 - ## Codex Adapter Notes 142 + ## Codex Paths 143 + 144 + There are now two Codex paths. 145 + 146 + ### External Adapter 143 147 144 - Current Codex integration is based on an external ACP adapter such as `codex-acp`. 148 + You can still point ACP at an external adapter such as `codex-acp`. 145 149 146 150 Practical checks: 147 151 ··· 149 153 2. `mistermorph tools` should show `acp_spawn`. 150 154 3. the ACP profile should point to `codex-acp`. 151 155 152 - The repository also includes an opt-in live integration test: 156 + ### Native Wrapper in This Repository 157 + 158 + The repository now also includes a Codex wrapper owned by Mistermorph: 159 + 160 + ```yaml 161 + acp: 162 + agents: 163 + - name: "codex" 164 + enable: true 165 + type: "stdio" 166 + command: "node" 167 + args: ["./wrappers/acp/codex/src/index.mjs"] 168 + env: {} 169 + cwd: "." 170 + read_roots: ["."] 171 + write_roots: ["."] 172 + session_options: 173 + approval_policy: "never" 174 + ``` 175 + 176 + Current scope of the native wrapper: 177 + 178 + - backend is `codex app-server` 179 + - no third-party ACP adapter is required 180 + - no interactive approval flow yet 181 + - default `approval_policy` is `never` 182 + 183 + The existing opt-in live integration test can target this wrapper too: 153 184 154 185 ```bash 155 - MISTERMORPH_ACP_CODEX_INTEGRATION=1 go test ./internal/acpclient -run TestRunPrompt_CodexACPIntegration -v 186 + MISTERMORPH_ACP_CODEX_INTEGRATION=1 \ 187 + MISTERMORPH_ACP_CODEX_COMMAND=node \ 188 + MISTERMORPH_ACP_CODEX_ARGS="./wrappers/acp/codex/src/index.mjs" \ 189 + go test ./internal/acpclient -run TestRunPrompt_CodexACPIntegration -v 190 + ``` 191 + 192 + ## Claude Paths 193 + 194 + Claude now also has a native wrapper in this repository. 195 + 196 + ```yaml 197 + acp: 198 + agents: 199 + - name: "claude" 200 + enable: true 201 + type: "stdio" 202 + command: "node" 203 + args: ["./wrappers/acp/claude/src/index.mjs"] 204 + env: {} 205 + cwd: "." 206 + read_roots: ["."] 207 + write_roots: ["."] 208 + session_options: 209 + permission_mode: "dontAsk" 210 + allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] 211 + ``` 212 + 213 + Current scope of the native Claude wrapper: 214 + 215 + - backend is `claude -p --output-format stream-json` 216 + - no third-party ACP adapter is required 217 + - no interactive approval flow yet 218 + - Claude internal tools are not bridged back into ACP file or terminal callbacks 219 + 220 + Two practical notes: 221 + 222 + - `bare: true` is optional, not the default 223 + - if you rely on Claude.ai login, keep `bare: false` because bare mode skips OAuth and keychain reads 224 + 225 + There is also an opt-in live integration test: 226 + 227 + ```bash 228 + MISTERMORPH_ACP_CLAUDE_INTEGRATION=1 \ 229 + go test ./internal/acpclient -run TestRunPrompt_ClaudeNativeWrapperIntegration -v 156 230 ``` 157 231 158 232 ## See Also
+238
docs/feat/feat_20260411_acp_native_wrappers.md
··· 1 + --- 2 + date: 2026-04-11 3 + title: 自研 ACP Wrapper 设计 4 + status: draft 5 + --- 6 + 7 + # 自研 ACP Wrapper 设计 8 + 9 + ## 1) 目标 10 + 11 + 当前 ACP client 已经能跑通。 12 + 13 + 下一步要解决的是另一个问题: 14 + 15 + - 不依赖第三方 ACP adapter。 16 + - 继续保留 ACP 作为 MisterMorph 和外部 agent 之间的统一边界。 17 + - 让 MisterMorph 自己提供 `codex` 和 `claude` 的 wrapper。 18 + 19 + 这样做的直接好处是: 20 + 21 + - 少一层外部依赖。 22 + - 问题定位更短。 23 + - 配置和行为可以按 MisterMorph 自己的需求收敛。 24 + 25 + ## 2) 基本判断 26 + 27 + 这里要把两层事情分开。 28 + 29 + 第一层是 ACP 协议本身。 30 + 31 + - MisterMorph 已经是 ACP client。 32 + - 这层先不重写。 33 + 34 + 第二层是目标 agent 的桥接层。 35 + 36 + - 这层以前依赖 `codex-acp` 之类的 adapter。 37 + - 现在改成我们自己写。 38 + 39 + 所以这轮不是“重做 ACP”,而是“自己实现 wrapper”。 40 + 41 + ## 3) 总体结构 42 + 43 + 结构保持简单: 44 + 45 + ```text 46 + MisterMorph (Go, ACP client) 47 + <-> self-owned ACP wrapper 48 + <-> target agent native interface 49 + ``` 50 + 51 + 当前规划: 52 + 53 + - `codex` wrapper 54 + - 后端接 `codex app-server` 55 + - `claude` wrapper 56 + - 后端接 `claude -p --output-format stream-json` 57 + 58 + 这两个 wrapper 都单独跑成子进程,通过 `stdio` 讲 ACP。 59 + 60 + 原因很直接: 61 + 62 + - MisterMorph 当前已经是 `stdio` ACP client。 63 + - `pi-acp` 这类 adapter 也是这个形状。 64 + - 子进程边界最清楚,调试也简单。 65 + 66 + ## 4) 语言选择 67 + 68 + wrapper 先用 Node.js 写。 69 + 70 + 原因是: 71 + 72 + - `codex app-server` 官方直接给了 Node / TypeScript 示例。 73 + - `claude -p` 也是 Claude Code 官方的程序化入口。 74 + - 用 Node 内置模块就能先把第一版做出来,不必先引入额外构建链。 75 + 76 + 这期先写成不依赖第三方包的 ESM 脚本。 77 + 78 + 后面如果类型复杂度上来,再收敛成 TypeScript 编译产物。 79 + 80 + ## 5) Codex Wrapper 一期范围 81 + 82 + 第一版先只做 `codex`。 83 + 84 + 目录建议: 85 + 86 + ```text 87 + wrappers/acp/codex/ 88 + ``` 89 + 90 + 协议面只做最小可用集合: 91 + 92 + - `initialize` 93 + - `authenticate` 94 + - 先做 no-op 95 + - `session/new` 96 + - `session/set_config_option` 97 + - 只支持一小部分 option id 98 + - `session/prompt` 99 + - `session/cancel` 100 + 101 + 事件先做: 102 + 103 + - `agent_message_chunk` 104 + - 基础 `tool_call` 105 + - 基础 `tool_call_update` 106 + 107 + 后端桥接关系: 108 + 109 + - ACP `session/new` 110 + - 对应 `codex app-server` 的 `thread/start` 111 + - ACP `session/prompt` 112 + - 对应 `turn/start` 113 + - ACP `session/cancel` 114 + - 对应 `turn/interrupt` 115 + - Codex `item/agentMessage/delta` 116 + - 映射成 ACP `agent_message_chunk` 117 + - Codex 命令执行 / 文件变更通知 118 + - 映射成 ACP `tool_call` / `tool_call_update` 119 + 120 + ## 6) Codex Wrapper 的刻意收缩 121 + 122 + 这期不追求把 Codex 全部能力都桥接出来。 123 + 124 + 先明确不做: 125 + 126 + - 会话持久化 127 + - slash commands 128 + - MCP passthrough 129 + - 动态 tool call 130 + - review mode 131 + - 图像输入 132 + - 复杂 approvals 133 + - 多 session 并发优化 134 + 135 + 第一版默认策略也先写死: 136 + 137 + - 每个 ACP session 对应一个 Codex thread 138 + - `approval_policy` 默认用 `never` 139 + - wrapper 不做交互式用户确认 140 + 141 + 这样做不是最终形态,但能先把“自研 wrapper 能跑起来”这件事做实。 142 + 143 + ## 7) 为什么先做 Codex 144 + 145 + Codex 更适合作为第一个目标。 146 + 147 + 原因不是主观偏好,而是接口形状更合适: 148 + 149 + - `codex app-server` 本身就是 `JSON-RPC` over `stdio` 150 + - 它已经明确提供 thread / turn / event stream 151 + - 这和 ACP 的 session / prompt / update 非常接近 152 + 153 + 所以 `codex` 这条桥接更像协议映射问题,而不是黑盒驱动问题。 154 + 155 + ## 8) Claude Wrapper 规划 156 + 157 + `claude` wrapper 放在第二步。 158 + 159 + 方向先定成: 160 + 161 + - wrapper 继续讲 ACP 162 + - 后端接 `claude -p --output-format stream-json` 163 + 164 + 这里直接桥接 Claude Code CLI,而不是先引 SDK 包。 165 + 166 + 原因很直接: 167 + 168 + - 这就是直接操控本机 Claude Code。 169 + - `claude -p` 本身就是官方程序化入口。 170 + - `stream-json` 已经提供了可解析事件流。 171 + - 第一版不需要额外依赖。 172 + 173 + 当前桥接关系: 174 + 175 + - ACP `session/new` 176 + - 建立本地 wrapper session,并保存默认 CLI 选项 177 + - ACP `session/prompt` 178 + - 启动一次 `claude -p` 179 + - ACP `session/cancel` 180 + - 终止当前 `claude` 子进程 181 + - Claude `stream-json` 182 + - 文本增量映射成 `agent_message_chunk` 183 + - 结果事件映射成 ACP `stopReason` 184 + 185 + 有一个边界要单独写明: 186 + 187 + - `bare: true` 不能作为默认值 188 + - Claude Code 文档明确说明 bare mode 会跳过 OAuth 和 keychain 读取 189 + - 如果用户依赖 Claude.ai 登录态,通常必须保持 `bare: false` 190 + 191 + ## 9) 配置入口 192 + 193 + MisterMorph 主体配置暂时不变。 194 + 195 + 仍然用: 196 + 197 + - `tools.acp_spawn.enabled` 198 + - `acp.agents` 199 + 200 + 只是 `acp.agents[].command` 不再必须指向第三方 adapter,也可以指向仓库自带 wrapper。 201 + 202 + 例如 `codex`: 203 + 204 + ```yaml 205 + acp: 206 + agents: 207 + - name: "codex" 208 + enable: true 209 + type: "stdio" 210 + command: "node" 211 + args: ["./wrappers/acp/codex/src/index.mjs"] 212 + env: {} 213 + cwd: "." 214 + read_roots: ["."] 215 + write_roots: ["."] 216 + session_options: 217 + approval_policy: "never" 218 + ``` 219 + 220 + ## 10) 交付顺序 221 + 222 + 按返工成本,顺序定成: 223 + 224 + 1. 写设计文档和实现跟踪文档。 225 + 2. 落 `codex` wrapper 最小骨架。 226 + 3. 用现有 Go ACP client 跑 live smoke test。 227 + 4. 补基本文档和配置示例。 228 + 5. 再开始 `claude` wrapper。 229 + 230 + ## 11) 当前验收标准 231 + 232 + `codex` wrapper 第一版达到下面几条就算过线: 233 + 234 + - `acp_spawn` 可以直接调用仓库内 wrapper 235 + - `Say exactly: Hello` 能稳定返回 236 + - 读取本地文件并总结能稳定返回 237 + - 命令不会再因为未处理的协议收尾而挂死 238 + - 文档里明确写清范围和限制
+108
docs/feat/feat_20260411_acp_native_wrappers_impl.md
··· 1 + --- 2 + date: 2026-04-11 3 + title: 自研 ACP Wrapper 实现进度 4 + status: in_progress 5 + --- 6 + 7 + # 自研 ACP Wrapper 实现进度 8 + 9 + ## 当前范围 10 + 11 + 本轮先做最小可用版本: 12 + 13 + - 新增自研 `codex` ACP wrapper 14 + - 位置放在仓库内 15 + - 不依赖第三方 npm 包 16 + - 通过 `stdio` 讲 ACP 17 + - 后端桥接 `codex app-server` 18 + 19 + 当前不做: 20 + 21 + - wrapper 安装发布流程 22 + - MCP passthrough 23 + - session 持久化 24 + - 复杂 approval 桥接 25 + 26 + ## 任务清单 27 + 28 + - [x] 明确“继续走 ACP,但改成自研 wrapper” 29 + - [x] 写设计文档 30 + - [x] 建 `codex` wrapper 目录和脚手架 31 + - [x] 接 `codex app-server` 32 + - [x] 跑通 `initialize -> session/new -> session/prompt -> session/cancel` 33 + - [x] 补最小单测 34 + - [x] 补配置示例 35 + - [x] 补用户文档 36 + - [x] 跑 live smoke test 37 + 38 + ## 进度记录 39 + 40 + ### 2026-04-11 41 + 42 + - 已确认继续保留 ACP,改成自研 wrapper。 43 + - 已确认参考方向是 `pi-acp` 这类独立 adapter 进程,而不是把桥接逻辑塞回 Go 主体。 44 + - 已确认 `codex` 适合作为第一个目标: 45 + - `codex app-server` 是官方接口 46 + - 传输是 `stdio` 47 + - 协议是 `JSON-RPC` 48 + - 有清晰的 thread / turn / event stream 49 + - 已调整 `claude` 路线: 50 + - 第一版不接 SDK 包 51 + - 直接桥接 `claude -p --output-format stream-json` 52 + - 这样更接近“直接操控 Claude Code” 53 + - 也避免先引额外依赖 54 + - 已落第一版 `codex` wrapper: 55 + - 目录:`wrappers/acp/codex/` 56 + - 运行形态:Node.js `stdio` ACP agent 57 + - 后端:`codex app-server` 58 + - 当前支持: 59 + - `initialize` 60 + - `authenticate`(no-op) 61 + - `session/new` 62 + - `session/set_config_option` 63 + - `session/prompt` 64 + - `session/cancel` 65 + - 文本 `agent_message_chunk` 66 + - 基础 `tool_call` / `tool_call_update` 67 + - 已补最小单测: 68 + - `node --test wrappers/acp/codex/test/*.test.mjs` 69 + - 已在用户机环境跑通 live 集成测试: 70 + - `MISTERMORPH_ACP_CODEX_INTEGRATION=1 MISTERMORPH_ACP_CODEX_COMMAND=node MISTERMORPH_ACP_CODEX_ARGS="./wrappers/acp/codex/src/index.mjs" go test ./internal/acpclient -run TestRunPrompt_CodexACPIntegration -v` 71 + - 这轮修了一个 wrapper 自己的协议问题: 72 + - `codex app-server` 的 turn 状态会返回 `inProgress` 73 + - 第一版状态映射没有统一转小写,导致 `session/prompt` 提前报错 74 + - 当前结论: 75 + - 仓库内自带 wrapper 已经能替代第三方 `codex-acp` 跑最小主线 76 + - 复杂 approval 和更细的能力映射仍然留在后续迭代 77 + - 已落第一版 `claude` wrapper: 78 + - 目录:`wrappers/acp/claude/` 79 + - 运行形态:Node.js `stdio` ACP agent 80 + - 后端:`claude -p --output-format stream-json` 81 + - 当前支持: 82 + - `initialize` 83 + - `authenticate`(no-op) 84 + - `session/new` 85 + - `session/set_config_option` 86 + - `session/prompt` 87 + - `session/cancel` 88 + - 文本 `agent_message_chunk` 89 + - 已补两层测试: 90 + - Node 单测:`node --test wrappers/acp/claude/test/*.test.mjs` 91 + - Go 端到端假后端集成测试:`internal/acpclient/claude_wrapper_integration_test.go` 92 + - 已新增 opt-in live 集成测试: 93 + - `internal/acpclient/claude_integration_test.go` 94 + - 依赖本机 `claude` 和可用认证 95 + - 当前已确认一个 Claude 侧边界: 96 + - `claude auth status` 成功,不等于当前组织一定能实际执行 Claude 请求 97 + - live 测试仍以真实 `claude -p` 结果为准 98 + - 当前已确认一个配置边界: 99 + - bare mode 不能默认打开 100 + - 文档明确说明 bare mode 会跳过 OAuth / keychain 101 + - 对依赖 Claude.ai 登录态的用户,这会直接影响可用性 102 + 103 + ## 当前风险 104 + 105 + - `codex app-server` 的审批和权限请求面比第一版 wrapper 想做的范围更宽。 106 + - 如果某些任务必须经过 approval request,第一版可能只能通过默认 `approval_policy: never` 先避开。 107 + - `claude` 这条线的 live 可用性依赖真实账号权限。 108 + - 当前 wrapper 只桥接 Claude 的输出流,不把 Claude 内部工具行为再拆回 ACP 的 file / terminal callback。
+115
internal/acpclient/claude_integration_test.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + const ( 15 + claudeACPIntegrationEnv = "MISTERMORPH_ACP_CLAUDE_INTEGRATION" 16 + claudeACPSessionOptionsEnv = "MISTERMORPH_ACP_CLAUDE_SESSION_OPTIONS" 17 + ) 18 + 19 + func TestRunPrompt_ClaudeNativeWrapperIntegration(t *testing.T) { 20 + if testing.Short() { 21 + t.Skip("skipping live Claude ACP integration test in short mode") 22 + } 23 + if strings.TrimSpace(os.Getenv(claudeACPIntegrationEnv)) != "1" { 24 + t.Skipf("set %s=1 to run the live Claude ACP integration test", claudeACPIntegrationEnv) 25 + } 26 + if _, err := exec.LookPath("node"); err != nil { 27 + t.Skip("node is required for the live Claude ACP integration test") 28 + } 29 + if _, err := exec.LookPath("claude"); err != nil { 30 + t.Skip("claude is required for the live Claude ACP integration test") 31 + } 32 + authStatus := exec.Command("claude", "auth", "status") 33 + if err := authStatus.Run(); err != nil { 34 + t.Skipf("claude auth status failed: %v", err) 35 + } 36 + 37 + sessionOptions, err := parseClaudeACPSessionOptions(os.Getenv(claudeACPSessionOptionsEnv)) 38 + if err != nil { 39 + t.Fatalf("parse %s: %v", claudeACPSessionOptionsEnv, err) 40 + } 41 + if sessionOptions == nil { 42 + sessionOptions = map[string]any{ 43 + "permission_mode": "dontAsk", 44 + "allowed_tools": []string{"Read"}, 45 + } 46 + } 47 + 48 + repoRoot := repoRootFromTestFile(t) 49 + wrapperPath := filepath.Join(repoRoot, "wrappers", "acp", "claude", "src", "index.mjs") 50 + node, err := exec.LookPath("node") 51 + if err != nil { 52 + t.Skip("node is required for the live Claude ACP integration test") 53 + } 54 + 55 + dir := t.TempDir() 56 + probePath := filepath.Join(dir, "acp_probe.txt") 57 + if err := os.WriteFile(probePath, []byte("ACP_CLAUDE_SMOKE_TOKEN_20260411\n"), 0o644); err != nil { 58 + t.Fatalf("WriteFile(probePath) error = %v", err) 59 + } 60 + 61 + prepared, err := PrepareAgentConfig(AgentConfig{ 62 + Name: "claude", 63 + Enable: true, 64 + Type: "stdio", 65 + Command: node, 66 + Args: []string{wrapperPath}, 67 + CWD: dir, 68 + ReadRoots: []string{dir}, 69 + WriteRoots: []string{dir}, 70 + Env: map[string]string{ 71 + "MISTERMORPH_CLAUDE_COMMAND": "claude", 72 + }, 73 + SessionOptions: sessionOptions, 74 + }, "") 75 + if err != nil { 76 + t.Fatalf("PrepareAgentConfig() error = %v", err) 77 + } 78 + 79 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 80 + defer cancel() 81 + 82 + var events []Event 83 + result, err := RunPrompt(ctx, prepared, RunRequest{ 84 + Prompt: "Read ./acp_probe.txt and reply with exactly its full contents. " + 85 + "Do not add quotes, labels, explanations, or any extra text.", 86 + Observer: ObserverFunc(func(_ context.Context, event Event) { 87 + events = append(events, event) 88 + }), 89 + }) 90 + if err != nil { 91 + t.Fatalf("RunPrompt() error = %v", err) 92 + } 93 + if result.StopReason != "end_turn" { 94 + t.Fatalf("StopReason = %q, want %q", result.StopReason, "end_turn") 95 + } 96 + output := strings.ReplaceAll(result.Output, "\r\n", "\n") 97 + if strings.TrimSpace(output) != "ACP_CLAUDE_SMOKE_TOKEN_20260411" { 98 + t.Fatalf("Output = %q, want %q", result.Output, "ACP_CLAUDE_SMOKE_TOKEN_20260411") 99 + } 100 + if len(events) == 0 { 101 + t.Fatal("expected at least one ACP event from the Claude wrapper") 102 + } 103 + } 104 + 105 + func parseClaudeACPSessionOptions(raw string) (map[string]any, error) { 106 + raw = strings.TrimSpace(raw) 107 + if raw == "" { 108 + return nil, nil 109 + } 110 + var options map[string]any 111 + if err := json.Unmarshal([]byte(raw), &options); err != nil { 112 + return nil, err 113 + } 114 + return options, nil 115 + }
+77
internal/acpclient/claude_wrapper_integration_test.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "context" 5 + "os/exec" 6 + "path/filepath" 7 + "runtime" 8 + "strings" 9 + "testing" 10 + "time" 11 + ) 12 + 13 + func TestRunPrompt_ClaudeNativeWrapperFakeBackend(t *testing.T) { 14 + node, err := exec.LookPath("node") 15 + if err != nil { 16 + t.Skip("node is required for the Claude wrapper integration test") 17 + } 18 + 19 + repoRoot := repoRootFromTestFile(t) 20 + wrapperPath := filepath.Join(repoRoot, "wrappers", "acp", "claude", "src", "index.mjs") 21 + backendPath := filepath.Join(repoRoot, "wrappers", "acp", "claude", "test", "fixtures", "fake-claude-success.mjs") 22 + 23 + prepared, err := PrepareAgentConfig(AgentConfig{ 24 + Name: "claude", 25 + Enable: true, 26 + Type: "stdio", 27 + Command: node, 28 + Args: []string{wrapperPath}, 29 + CWD: repoRoot, 30 + ReadRoots: []string{repoRoot}, 31 + WriteRoots: []string{repoRoot}, 32 + Env: map[string]string{ 33 + "MISTERMORPH_CLAUDE_COMMAND": node, 34 + "MISTERMORPH_CLAUDE_ARGS": backendPath, 35 + }, 36 + SessionOptions: map[string]any{ 37 + "permission_mode": "dontAsk", 38 + "allowed_tools": []string{"Read"}, 39 + "max_turns": 2, 40 + }, 41 + }, "") 42 + if err != nil { 43 + t.Fatalf("PrepareAgentConfig() error = %v", err) 44 + } 45 + 46 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 47 + defer cancel() 48 + 49 + var events []Event 50 + result, err := RunPrompt(ctx, prepared, RunRequest{ 51 + Prompt: "Say exactly: Hello", 52 + Observer: ObserverFunc(func(_ context.Context, event Event) { 53 + events = append(events, event) 54 + }), 55 + }) 56 + if err != nil { 57 + t.Fatalf("RunPrompt() error = %v", err) 58 + } 59 + if result.StopReason != "end_turn" { 60 + t.Fatalf("StopReason = %q, want %q", result.StopReason, "end_turn") 61 + } 62 + if strings.TrimSpace(result.Output) != "Hello" { 63 + t.Fatalf("Output = %q, want %q", result.Output, "Hello") 64 + } 65 + if len(events) == 0 { 66 + t.Fatal("expected at least one ACP event from the Claude wrapper") 67 + } 68 + } 69 + 70 + func repoRootFromTestFile(t *testing.T) string { 71 + t.Helper() 72 + _, file, _, ok := runtime.Caller(0) 73 + if !ok { 74 + t.Fatal("runtime.Caller(0) failed") 75 + } 76 + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) 77 + }
+83 -3
web/vitepress/docs/guide/acp.md
··· 124 124 125 125 Also, the wrapper itself is still a local child process. ACP callback limits do not automatically sandbox arbitrary direct behavior inside that wrapper. 126 126 127 - ## Codex via Adapter 127 + ## Codex Paths 128 + 129 + There are now two Codex paths. 130 + 131 + ### External Adapter 128 132 129 - Current Codex support is meant to work through an ACP adapter such as `codex-acp`. 133 + You can still use an ACP adapter such as `codex-acp`. 130 134 131 - Practical checks: 135 + Checks: 132 136 133 137 1. `codex` itself should already work. 134 138 2. `mistermorph tools` should list `acp_spawn`. 135 139 3. the ACP profile should point to `codex-acp`. 140 + 141 + ### Native Wrapper in This Repository 142 + 143 + The repository also includes a Codex wrapper owned by Mister Morph: 144 + 145 + ```yaml 146 + acp: 147 + agents: 148 + - name: "codex" 149 + enable: true 150 + type: "stdio" 151 + command: "node" 152 + args: ["./wrappers/acp/codex/src/index.mjs"] 153 + env: {} 154 + cwd: "." 155 + read_roots: ["."] 156 + write_roots: ["."] 157 + session_options: 158 + approval_policy: "never" 159 + ``` 160 + 161 + Current scope of the native wrapper: 162 + 163 + - backend is `codex app-server` 164 + - no third-party ACP adapter is required 165 + - no interactive approval flow yet 166 + - default `approval_policy` is `never` 167 + 168 + The existing opt-in live integration test can target this wrapper too: 169 + 170 + ```bash 171 + MISTERMORPH_ACP_CODEX_INTEGRATION=1 \ 172 + MISTERMORPH_ACP_CODEX_COMMAND=node \ 173 + MISTERMORPH_ACP_CODEX_ARGS="./wrappers/acp/codex/src/index.mjs" \ 174 + go test ./internal/acpclient -run TestRunPrompt_CodexACPIntegration -v 175 + ``` 176 + 177 + ## Claude Native Wrapper 178 + 179 + The repository also includes a native Claude wrapper: 180 + 181 + ```yaml 182 + acp: 183 + agents: 184 + - name: "claude" 185 + enable: true 186 + type: "stdio" 187 + command: "node" 188 + args: ["./wrappers/acp/claude/src/index.mjs"] 189 + env: {} 190 + cwd: "." 191 + read_roots: ["."] 192 + write_roots: ["."] 193 + session_options: 194 + permission_mode: "dontAsk" 195 + allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] 196 + ``` 197 + 198 + Current scope: 199 + 200 + - backend is `claude -p --output-format stream-json` 201 + - no third-party ACP adapter is required 202 + - no interactive approval flow yet 203 + - Claude internal tools are not bridged back into ACP file or terminal callbacks 204 + 205 + Notes: 206 + 207 + - `bare: true` is optional, not the default 208 + - if you rely on Claude.ai login, keep `bare: false` because bare mode skips OAuth and keychain reads 209 + 210 + The repository also includes an opt-in live integration test: 211 + 212 + ```bash 213 + MISTERMORPH_ACP_CLAUDE_INTEGRATION=1 \ 214 + go test ./internal/acpclient -run TestRunPrompt_ClaudeNativeWrapperIntegration -v 215 + ``` 136 216 137 217 See also: 138 218
+82 -2
web/vitepress/docs/ja/guide/acp.md
··· 122 122 123 123 もう 1 点重要なのは、wrapper 自体もローカル子プロセスだということです。ACP callback の制限は、wrapper 自身の任意の直接動作まで自動的にサンドボックス化しません。 124 124 125 - ## Adapter 経由の Codex 125 + ## Codex への 2 つの経路 126 + 127 + 現在、Codex には 2 つの経路があります。 128 + 129 + ### 外部 adapter 126 130 127 - 現在の Codex 連携は `codex-acp` のような ACP adapter を前提にしています。 131 + `codex-acp` のような外部 ACP adapter は引き続き使えます。 128 132 129 133 確認ポイント: 130 134 131 135 1. まず `codex` 単体で動くこと 132 136 2. `mistermorph tools` に `acp_spawn` が出ること 133 137 3. ACP profile の `command` が `codex-acp` を指していること 138 + 139 + ### このリポジトリ内の native wrapper 140 + 141 + このリポジトリには、Mister Morph 自身が管理する Codex wrapper もあります。 142 + 143 + ```yaml 144 + acp: 145 + agents: 146 + - name: "codex" 147 + enable: true 148 + type: "stdio" 149 + command: "node" 150 + args: ["./wrappers/acp/codex/src/index.mjs"] 151 + env: {} 152 + cwd: "." 153 + read_roots: ["."] 154 + write_roots: ["."] 155 + session_options: 156 + approval_policy: "never" 157 + ``` 158 + 159 + この native wrapper の現在の範囲: 160 + 161 + - backend は `codex app-server` 162 + - サードパーティの ACP adapter は不要 163 + - まだ対話式 approval はありません 164 + - 既定の `approval_policy` は `never` 165 + 166 + 既存の opt-in live integration test でもこの wrapper を検証できます。 167 + 168 + ```bash 169 + MISTERMORPH_ACP_CODEX_INTEGRATION=1 \ 170 + MISTERMORPH_ACP_CODEX_COMMAND=node \ 171 + MISTERMORPH_ACP_CODEX_ARGS="./wrappers/acp/codex/src/index.mjs" \ 172 + go test ./internal/acpclient -run TestRunPrompt_CodexACPIntegration -v 173 + ``` 174 + 175 + ## Claude の native wrapper 176 + 177 + このリポジトリには Claude 用の native wrapper もあります。 178 + 179 + ```yaml 180 + acp: 181 + agents: 182 + - name: "claude" 183 + enable: true 184 + type: "stdio" 185 + command: "node" 186 + args: ["./wrappers/acp/claude/src/index.mjs"] 187 + env: {} 188 + cwd: "." 189 + read_roots: ["."] 190 + write_roots: ["."] 191 + session_options: 192 + permission_mode: "dontAsk" 193 + allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] 194 + ``` 195 + 196 + 現在の範囲: 197 + 198 + - backend は `claude -p --output-format stream-json` 199 + - サードパーティの ACP adapter は不要 200 + - まだ対話式 approval はありません 201 + - Claude 内部ツールは ACP の file / terminal callback に戻していません 202 + 203 + 注意点: 204 + 205 + - `bare: true` は任意ですが、既定値にはしません 206 + - Claude.ai のログイン状態に依存するなら、bare mode は OAuth と keychain を読まないので `bare: false` を維持してください 207 + 208 + opt-in の live integration test も追加しています。 209 + 210 + ```bash 211 + MISTERMORPH_ACP_CLAUDE_INTEGRATION=1 \ 212 + go test ./internal/acpclient -run TestRunPrompt_ClaudeNativeWrapperIntegration -v 213 + ``` 134 214 135 215 関連ページ: 136 216
+82 -2
web/vitepress/docs/zh/guide/acp.md
··· 124 124 125 125 还有一点要看清:wrapper 本身仍是本地子进程。ACP 回调层的约束,不会自动把 wrapper 自己的直接行为也沙箱化。 126 126 127 - ## 通过适配层接 Codex 127 + ## Codex 的两条接法 128 + 129 + 现在 Codex 有两条接法。 130 + 131 + ### 外部适配层 128 132 129 - 当前 Codex 的用法是通过 ACP 适配层,比如 `codex-acp`。 133 + 你仍然可以继续用 `codex-acp` 这类外部 ACP 适配层。 130 134 131 135 联调前先检查: 132 136 133 137 1. `codex` 自己先能正常工作 134 138 2. `mistermorph tools` 里能看到 `acp_spawn` 135 139 3. ACP profile 的 `command` 指向 `codex-acp` 140 + 141 + ### 仓库内自带 wrapper 142 + 143 + 仓库里现在也有一个 MisterMorph 自己维护的 Codex wrapper: 144 + 145 + ```yaml 146 + acp: 147 + agents: 148 + - name: "codex" 149 + enable: true 150 + type: "stdio" 151 + command: "node" 152 + args: ["./wrappers/acp/codex/src/index.mjs"] 153 + env: {} 154 + cwd: "." 155 + read_roots: ["."] 156 + write_roots: ["."] 157 + session_options: 158 + approval_policy: "never" 159 + ``` 160 + 161 + 这个 native wrapper 当前的范围: 162 + 163 + - 后端直接接 `codex app-server` 164 + - 不依赖第三方 ACP adapter 165 + - 还没有交互式 approval 流程 166 + - 默认 `approval_policy` 是 `never` 167 + 168 + 现有的 opt-in live 集成测试也能直接打这个 wrapper: 169 + 170 + ```bash 171 + MISTERMORPH_ACP_CODEX_INTEGRATION=1 \ 172 + MISTERMORPH_ACP_CODEX_COMMAND=node \ 173 + MISTERMORPH_ACP_CODEX_ARGS="./wrappers/acp/codex/src/index.mjs" \ 174 + go test ./internal/acpclient -run TestRunPrompt_CodexACPIntegration -v 175 + ``` 176 + 177 + ## Claude 的 native wrapper 178 + 179 + 仓库里现在也有一个 Claude 的 native wrapper: 180 + 181 + ```yaml 182 + acp: 183 + agents: 184 + - name: "claude" 185 + enable: true 186 + type: "stdio" 187 + command: "node" 188 + args: ["./wrappers/acp/claude/src/index.mjs"] 189 + env: {} 190 + cwd: "." 191 + read_roots: ["."] 192 + write_roots: ["."] 193 + session_options: 194 + permission_mode: "dontAsk" 195 + allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] 196 + ``` 197 + 198 + 这个 wrapper 当前的范围: 199 + 200 + - 后端直接接 `claude -p --output-format stream-json` 201 + - 不依赖第三方 ACP adapter 202 + - 还没有交互式 approval 流程 203 + - Claude 内部工具不会再拆回 ACP 的文件或终端回调 204 + 205 + 注意两点: 206 + 207 + - `bare: true` 只是可选项,不该默认打开 208 + - 如果你依赖 Claude.ai 登录态,通常要保持 `bare: false`,因为 bare mode 会跳过 OAuth 和 keychain 读取 209 + 210 + 仓库里也加了 opt-in 的 live 集成测试: 211 + 212 + ```bash 213 + MISTERMORPH_ACP_CLAUDE_INTEGRATION=1 \ 214 + go test ./internal/acpclient -run TestRunPrompt_ClaudeNativeWrapperIntegration -v 215 + ``` 136 216 137 217 另见: 138 218
+59
wrappers/acp/claude/README.md
··· 1 + # MisterMorph ACP Claude Wrapper 2 + 3 + This wrapper lets MisterMorph talk ACP to Claude Code without depending on a third-party ACP adapter. 4 + 5 + Current shape: 6 + 7 + - transport: `stdio` 8 + - ACP methods: 9 + - `initialize` 10 + - `authenticate` (no-op) 11 + - `session/new` 12 + - `session/set_config_option` 13 + - `session/prompt` 14 + - `session/cancel` 15 + - backend: `claude -p --output-format stream-json` 16 + 17 + Current limits: 18 + 19 + - no session persistence 20 + - no MCP passthrough 21 + - no interactive approval flow 22 + - the wrapper does not bridge Claude tool calls back into ACP file or terminal callbacks 23 + 24 + Run it directly: 25 + 26 + ```bash 27 + node ./wrappers/acp/claude/src/index.mjs 28 + ``` 29 + 30 + Example ACP profile: 31 + 32 + ```yaml 33 + acp: 34 + agents: 35 + - name: "claude" 36 + enable: true 37 + type: "stdio" 38 + command: "node" 39 + args: ["./wrappers/acp/claude/src/index.mjs"] 40 + env: {} 41 + cwd: "." 42 + read_roots: ["."] 43 + write_roots: ["."] 44 + session_options: 45 + permission_mode: "dontAsk" 46 + allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] 47 + ``` 48 + 49 + Notes: 50 + 51 + - `bare: true` is optional, but it is not safe as a default when you rely on Claude.ai login. 52 + - Claude Code bare mode skips OAuth and keychain reads, so Claude.ai login usually requires `bare: false`. 53 + 54 + Optional environment variables: 55 + 56 + - `MISTERMORPH_CLAUDE_COMMAND` 57 + - override backend executable, default `claude` 58 + - `MISTERMORPH_CLAUDE_ARGS` 59 + - extra backend args, whitespace-split, inserted before print-mode flags
+16
wrappers/acp/claude/package.json
··· 1 + { 2 + "name": "@mistermorph/acp-claude", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "description": "MisterMorph-owned ACP wrapper for Claude Code print mode", 7 + "bin": { 8 + "mistermorph-acp-claude": "./src/index.mjs" 9 + }, 10 + "scripts": { 11 + "test": "node --test" 12 + }, 13 + "engines": { 14 + "node": ">=20" 15 + } 16 + }
+5
wrappers/acp/claude/src/index.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + import { main } from "./lib.mjs"; 4 + 5 + main();
+677
wrappers/acp/claude/src/lib.mjs
··· 1 + import { spawn } from "node:child_process"; 2 + import crypto from "node:crypto"; 3 + import process from "node:process"; 4 + import readline from "node:readline"; 5 + 6 + export const WRAPPER_VERSION = "0.1.0"; 7 + export const ACP_PROTOCOL_VERSION = 1; 8 + export const SUPPORTED_CONFIG_OPTIONS = [ 9 + "model", 10 + "permission_mode", 11 + "allowed_tools", 12 + "append_system_prompt", 13 + "max_turns", 14 + "bare" 15 + ]; 16 + 17 + const JSONRPC_VERSION = "2.0"; 18 + const RPC_METHOD_NOT_FOUND = -32601; 19 + const RPC_INVALID_PARAMS = -32602; 20 + const RPC_INTERNAL_ERROR = -32603; 21 + const DEFAULT_PERMISSION_MODE = "dontAsk"; 22 + const ACP_METHOD_INITIALIZE = "initialize"; 23 + const ACP_METHOD_AUTHENTICATE = "authenticate"; 24 + const ACP_METHOD_SESSION_NEW = "session/new"; 25 + const ACP_METHOD_SESSION_SET_CONFIG = "session/set_config_option"; 26 + const ACP_METHOD_SESSION_PROMPT = "session/prompt"; 27 + const ACP_METHOD_SESSION_CANCEL = "session/cancel"; 28 + const ACP_METHOD_SESSION_UPDATE = "session/update"; 29 + 30 + export function normalizeSessionOptions(raw = {}) { 31 + const source = isRecord(raw) ? raw : {}; 32 + return { 33 + model: pickString(source, "model"), 34 + permissionMode: 35 + pickString(source, "permission_mode", "permissionMode") ?? 36 + DEFAULT_PERMISSION_MODE, 37 + allowedTools: normalizeToolList( 38 + pickValue(source, "allowed_tools", "allowedTools") 39 + ), 40 + appendSystemPrompt: pickString( 41 + source, 42 + "append_system_prompt", 43 + "appendSystemPrompt" 44 + ), 45 + maxTurns: pickPositiveInt(source, "max_turns", "maxTurns"), 46 + bare: pickBoolean(source, "bare", false) 47 + }; 48 + } 49 + 50 + export function collectACPText(content) { 51 + if (!Array.isArray(content)) { 52 + return ""; 53 + } 54 + const parts = []; 55 + for (const item of content) { 56 + if (!isRecord(item)) { 57 + continue; 58 + } 59 + if (item.type !== "text" || typeof item.text !== "string") { 60 + continue; 61 + } 62 + const text = item.text.trim(); 63 + if (text !== "") { 64 + parts.push(text); 65 + } 66 + } 67 + return parts.join("\n").trim(); 68 + } 69 + 70 + export function buildBackendArgs() { 71 + return normalizeString(process.env.MISTERMORPH_CLAUDE_ARGS) 72 + .split(/\s+/) 73 + .filter(Boolean); 74 + } 75 + 76 + export function buildClaudePromptFlags(prompt, options = {}) { 77 + const args = []; 78 + if (options.bare) { 79 + args.push("--bare"); 80 + } 81 + args.push( 82 + "-p", 83 + prompt, 84 + "--output-format", 85 + "stream-json", 86 + "--verbose", 87 + "--include-partial-messages", 88 + "--no-session-persistence" 89 + ); 90 + if (options.permissionMode) { 91 + args.push("--permission-mode", options.permissionMode); 92 + } 93 + if (Array.isArray(options.allowedTools) && options.allowedTools.length > 0) { 94 + args.push("--allowedTools", ...options.allowedTools); 95 + } 96 + if (options.model) { 97 + args.push("--model", options.model); 98 + } 99 + if (options.appendSystemPrompt) { 100 + args.push("--append-system-prompt", options.appendSystemPrompt); 101 + } 102 + if (Number.isInteger(options.maxTurns) && options.maxTurns > 0) { 103 + args.push("--max-turns", String(options.maxTurns)); 104 + } 105 + return args; 106 + } 107 + 108 + export function buildClaudeArgs(prompt, options = {}) { 109 + return [...buildBackendArgs(), ...buildClaudePromptFlags(prompt, options)]; 110 + } 111 + 112 + export function createPromptState() { 113 + return { emittedText: "" }; 114 + } 115 + 116 + export function processClaudeEvent(rawEvent, state) { 117 + const event = isRecord(rawEvent) ? rawEvent : {}; 118 + const updates = []; 119 + 120 + if (normalizeString(event.type) === "stream_event") { 121 + const delta = extractStreamTextDelta(event.event); 122 + if (delta !== "") { 123 + state.emittedText += delta; 124 + updates.push(agentMessageChunk(delta)); 125 + } 126 + return { updates }; 127 + } 128 + 129 + if (normalizeString(event.type) === "assistant") { 130 + const assistantText = extractAssistantText(event.message); 131 + const delta = computeTextDelta(state.emittedText, assistantText); 132 + if (delta !== "") { 133 + state.emittedText += delta; 134 + updates.push(agentMessageChunk(delta)); 135 + } 136 + return { updates }; 137 + } 138 + 139 + if (normalizeString(event.type) === "result") { 140 + const resultText = normalizeString(event.result); 141 + const delta = computeTextDelta(state.emittedText, resultText); 142 + if (delta !== "") { 143 + state.emittedText += delta; 144 + updates.push(agentMessageChunk(delta)); 145 + } 146 + if (event.is_error === true) { 147 + return { 148 + updates, 149 + error: new Error(resultText || "claude print mode failed") 150 + }; 151 + } 152 + return { 153 + updates, 154 + final: { 155 + stopReason: mapClaudeStopReason(event.stop_reason, event.subtype) 156 + } 157 + }; 158 + } 159 + 160 + return { updates }; 161 + } 162 + 163 + export class ClaudePromptRun { 164 + constructor(options = {}) { 165 + this.command = 166 + normalizeString(options.command) || 167 + normalizeString(process.env.MISTERMORPH_CLAUDE_COMMAND) || 168 + "claude"; 169 + this.baseArgs = Array.isArray(options.args) ? options.args : buildBackendArgs(); 170 + this.cwd = normalizeString(options.cwd) || process.cwd(); 171 + this.env = { ...process.env, ...(isRecord(options.env) ? options.env : {}) }; 172 + this.onUpdate = 173 + typeof options.onUpdate === "function" ? options.onUpdate : () => {}; 174 + this.proc = null; 175 + this.cancelRequested = false; 176 + } 177 + 178 + async run(prompt, sessionOptions) { 179 + const args = [...this.baseArgs, ...buildClaudePromptFlags(prompt, sessionOptions)]; 180 + const state = createPromptState(); 181 + return new Promise((resolve, reject) => { 182 + const proc = spawn(this.command, args, { 183 + cwd: this.cwd, 184 + env: this.env, 185 + stdio: ["ignore", "pipe", "pipe"] 186 + }); 187 + this.proc = proc; 188 + const stdout = readline.createInterface({ input: proc.stdout }); 189 + let stderrText = ""; 190 + let settled = false; 191 + 192 + const finish = (fn, value) => { 193 + if (settled) { 194 + return; 195 + } 196 + settled = true; 197 + stdout.close(); 198 + fn(value); 199 + }; 200 + 201 + proc.stderr.on("data", (chunk) => { 202 + stderrText += chunk.toString(); 203 + }); 204 + 205 + stdout.on("line", (line) => { 206 + if (settled || line.trim() === "") { 207 + return; 208 + } 209 + let event; 210 + try { 211 + event = JSON.parse(line); 212 + } catch (error) { 213 + this.cancel(); 214 + finish(reject, new Error(`invalid claude stream-json line: ${String(error)}`)); 215 + return; 216 + } 217 + 218 + const outcome = processClaudeEvent(event, state); 219 + for (const update of outcome.updates) { 220 + this.onUpdate(update); 221 + } 222 + if (outcome.error) { 223 + finish(reject, outcome.error); 224 + return; 225 + } 226 + if (outcome.final) { 227 + finish(resolve, outcome.final); 228 + } 229 + }); 230 + 231 + proc.on("exit", (code, signal) => { 232 + if (settled) { 233 + return; 234 + } 235 + if (this.cancelRequested) { 236 + finish(resolve, { stopReason: "cancelled" }); 237 + return; 238 + } 239 + const detail = normalizeString(stderrText); 240 + const suffix = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`; 241 + finish( 242 + reject, 243 + new Error(detail || `claude print mode exited with ${suffix}`) 244 + ); 245 + }); 246 + }); 247 + } 248 + 249 + cancel() { 250 + this.cancelRequested = true; 251 + if (this.proc && !this.proc.killed) { 252 + this.proc.kill("SIGTERM"); 253 + } 254 + } 255 + } 256 + 257 + export class ClaudeACPServer { 258 + constructor(options = {}) { 259 + this.stdin = options.stdin ?? process.stdin; 260 + this.stdout = options.stdout ?? process.stdout; 261 + this.readline = readline.createInterface({ input: this.stdin }); 262 + this.command = 263 + normalizeString(options.command) || 264 + normalizeString(process.env.MISTERMORPH_CLAUDE_COMMAND) || 265 + "claude"; 266 + this.env = isRecord(options.env) ? options.env : {}; 267 + this.sessions = new Map(); 268 + } 269 + 270 + start() { 271 + this.readline.on("line", (line) => { 272 + void this.#handleACPLine(line); 273 + }); 274 + } 275 + 276 + async #handleACPLine(line) { 277 + if (line.trim() === "") { 278 + return; 279 + } 280 + let message; 281 + try { 282 + message = JSON.parse(line); 283 + } catch (error) { 284 + this.#writeError(null, RPC_INVALID_PARAMS, `invalid json: ${String(error)}`); 285 + return; 286 + } 287 + if (!isRecord(message)) { 288 + this.#writeError(null, RPC_INVALID_PARAMS, "invalid request"); 289 + return; 290 + } 291 + const method = normalizeString(message.method); 292 + if (method === "") { 293 + this.#writeError(message.id ?? null, RPC_INVALID_PARAMS, "missing method"); 294 + return; 295 + } 296 + 297 + try { 298 + const result = await this.#handleACPRequest(method, message.params); 299 + this.#write({ 300 + jsonrpc: JSONRPC_VERSION, 301 + id: message.id ?? null, 302 + result 303 + }); 304 + } catch (error) { 305 + const code = error instanceof JsonRpcFailure ? error.code : RPC_INTERNAL_ERROR; 306 + const messageText = error instanceof Error ? error.message : String(error); 307 + this.#writeError(message.id ?? null, code, messageText); 308 + } 309 + } 310 + 311 + async #handleACPRequest(method, params) { 312 + switch (method) { 313 + case ACP_METHOD_INITIALIZE: 314 + return { 315 + protocolVersion: ACP_PROTOCOL_VERSION, 316 + authMethods: [] 317 + }; 318 + case ACP_METHOD_AUTHENTICATE: 319 + return {}; 320 + case ACP_METHOD_SESSION_NEW: 321 + return this.#createSession(params); 322 + case ACP_METHOD_SESSION_SET_CONFIG: 323 + return this.#setSessionConfig(params); 324 + case ACP_METHOD_SESSION_PROMPT: 325 + return this.#runPrompt(params); 326 + case ACP_METHOD_SESSION_CANCEL: 327 + return this.#cancelPrompt(params); 328 + default: 329 + throw new JsonRpcFailure( 330 + RPC_METHOD_NOT_FOUND, 331 + `unsupported ACP method: ${method}` 332 + ); 333 + } 334 + } 335 + 336 + #createSession(params) { 337 + const payload = asObject(params, "session/new params"); 338 + const cwd = normalizeString(payload.cwd) || process.cwd(); 339 + const options = normalizeSessionOptions( 340 + isRecord(payload._meta) ? payload._meta : {} 341 + ); 342 + const sessionId = crypto.randomUUID(); 343 + this.sessions.set(sessionId, { 344 + sessionId, 345 + cwd, 346 + options, 347 + pendingRun: null 348 + }); 349 + return { 350 + sessionId, 351 + configOptions: SUPPORTED_CONFIG_OPTIONS.map((id) => ({ id })) 352 + }; 353 + } 354 + 355 + #setSessionConfig(params) { 356 + const payload = asObject(params, "session/set_config_option params"); 357 + const session = this.#getSession(payload.sessionId); 358 + const configId = normalizeString(payload.configId); 359 + if (!SUPPORTED_CONFIG_OPTIONS.includes(configId)) { 360 + return {}; 361 + } 362 + session.options = applyConfigOption(session.options, configId, payload.value); 363 + return {}; 364 + } 365 + 366 + async #runPrompt(params) { 367 + const payload = asObject(params, "session/prompt params"); 368 + const session = this.#getSession(payload.sessionId); 369 + if (session.pendingRun) { 370 + throw new JsonRpcFailure( 371 + RPC_INVALID_PARAMS, 372 + `session ${session.sessionId} already has an active prompt` 373 + ); 374 + } 375 + const prompt = collectACPText(payload.prompt); 376 + if (prompt === "") { 377 + throw new JsonRpcFailure( 378 + RPC_INVALID_PARAMS, 379 + "session/prompt requires text content" 380 + ); 381 + } 382 + const run = new ClaudePromptRun({ 383 + command: this.command, 384 + cwd: session.cwd, 385 + env: this.env, 386 + onUpdate: (update) => { 387 + this.#notifySessionUpdate(session.sessionId, update); 388 + } 389 + }); 390 + session.pendingRun = run; 391 + try { 392 + return await run.run(prompt, session.options); 393 + } finally { 394 + if (session.pendingRun === run) { 395 + session.pendingRun = null; 396 + } 397 + } 398 + } 399 + 400 + #cancelPrompt(params) { 401 + const payload = asObject(params, "session/cancel params"); 402 + const session = this.#getSession(payload.sessionId); 403 + if (session.pendingRun) { 404 + session.pendingRun.cancel(); 405 + } 406 + return {}; 407 + } 408 + 409 + #notifySessionUpdate(sessionId, update) { 410 + this.#write({ 411 + jsonrpc: JSONRPC_VERSION, 412 + method: ACP_METHOD_SESSION_UPDATE, 413 + params: { 414 + sessionId, 415 + update 416 + } 417 + }); 418 + } 419 + 420 + #getSession(sessionId) { 421 + const key = normalizeString(sessionId); 422 + const session = this.sessions.get(key); 423 + if (!session) { 424 + throw new JsonRpcFailure( 425 + RPC_INVALID_PARAMS, 426 + `unknown sessionId: ${sessionId}` 427 + ); 428 + } 429 + return session; 430 + } 431 + 432 + #write(message) { 433 + this.stdout.write(`${JSON.stringify(message)}\n`); 434 + } 435 + 436 + #writeError(id, code, message) { 437 + this.#write({ 438 + jsonrpc: JSONRPC_VERSION, 439 + id, 440 + error: { 441 + code, 442 + message 443 + } 444 + }); 445 + } 446 + } 447 + 448 + export function main(argv = process.argv.slice(2)) { 449 + if (argv.includes("--help") || argv.includes("-h")) { 450 + printHelp(); 451 + return; 452 + } 453 + const server = new ClaudeACPServer(); 454 + server.start(); 455 + } 456 + 457 + export function printHelp(stream = process.stdout) { 458 + stream.write( 459 + [ 460 + "MisterMorph ACP Claude Wrapper", 461 + "", 462 + "Starts an ACP agent over stdio and bridges it to Claude Code print mode.", 463 + "", 464 + "Usage:", 465 + " node ./wrappers/acp/claude/src/index.mjs", 466 + "", 467 + "Environment:", 468 + " MISTERMORPH_CLAUDE_COMMAND backend executable, default: claude", 469 + " MISTERMORPH_CLAUDE_ARGS extra backend args inserted before print-mode flags", 470 + "" 471 + ].join("\n") 472 + ); 473 + } 474 + 475 + function agentMessageChunk(text) { 476 + return { 477 + sessionUpdate: "agent_message_chunk", 478 + content: [{ type: "text", text }] 479 + }; 480 + } 481 + 482 + function applyConfigOption(options, configId, value) { 483 + const next = { ...options }; 484 + switch (configId) { 485 + case "model": 486 + next.model = typeof value === "string" ? value.trim() || null : null; 487 + break; 488 + case "permission_mode": 489 + next.permissionMode = 490 + typeof value === "string" 491 + ? value.trim() || DEFAULT_PERMISSION_MODE 492 + : DEFAULT_PERMISSION_MODE; 493 + break; 494 + case "allowed_tools": 495 + next.allowedTools = normalizeToolList(value); 496 + break; 497 + case "append_system_prompt": 498 + next.appendSystemPrompt = 499 + typeof value === "string" ? value.trim() || null : null; 500 + break; 501 + case "max_turns": 502 + next.maxTurns = normalizePositiveInt(value); 503 + break; 504 + case "bare": 505 + next.bare = normalizeBoolean(value, false); 506 + break; 507 + default: 508 + break; 509 + } 510 + return next; 511 + } 512 + 513 + function asObject(value, label) { 514 + if (!isRecord(value)) { 515 + throw new JsonRpcFailure(RPC_INVALID_PARAMS, `invalid ${label}`); 516 + } 517 + return value; 518 + } 519 + 520 + function computeTextDelta(previousText, nextText) { 521 + const prev = typeof previousText === "string" ? previousText : ""; 522 + const next = typeof nextText === "string" ? nextText : ""; 523 + if (next === "" || next === prev) { 524 + return ""; 525 + } 526 + if (next.startsWith(prev)) { 527 + return next.slice(prev.length); 528 + } 529 + if (prev.startsWith(next)) { 530 + return ""; 531 + } 532 + return next; 533 + } 534 + 535 + function extractAssistantText(message) { 536 + if (!isRecord(message) || !Array.isArray(message.content)) { 537 + return ""; 538 + } 539 + const parts = []; 540 + for (const item of message.content) { 541 + if (!isRecord(item)) { 542 + continue; 543 + } 544 + if (item.type !== "text" || typeof item.text !== "string") { 545 + continue; 546 + } 547 + const text = item.text.trim(); 548 + if (text !== "") { 549 + parts.push(text); 550 + } 551 + } 552 + return parts.join("\n").trim(); 553 + } 554 + 555 + function extractStreamTextDelta(event) { 556 + if (!isRecord(event)) { 557 + return ""; 558 + } 559 + const delta = isRecord(event.delta) ? event.delta : null; 560 + if (delta && normalizeString(delta.type).toLowerCase() === "text_delta") { 561 + return normalizeString(delta.text); 562 + } 563 + return ""; 564 + } 565 + 566 + function isRecord(value) { 567 + return value !== null && typeof value === "object" && !Array.isArray(value); 568 + } 569 + 570 + class JsonRpcFailure extends Error { 571 + constructor(code, message) { 572 + super(message); 573 + this.code = code; 574 + } 575 + } 576 + 577 + function mapClaudeStopReason(stopReason, subtype) { 578 + const reason = normalizeString(stopReason).toLowerCase(); 579 + if (reason === "" && normalizeString(subtype).toLowerCase() === "success") { 580 + return "end_turn"; 581 + } 582 + switch (reason) { 583 + case "": 584 + case "stop_sequence": 585 + case "end_turn": 586 + return "end_turn"; 587 + case "max_turns": 588 + return "max_turns"; 589 + default: 590 + return reason; 591 + } 592 + } 593 + 594 + function normalizeBoolean(value, fallback) { 595 + if (typeof value === "boolean") { 596 + return value; 597 + } 598 + if (typeof value === "string") { 599 + const normalized = value.trim().toLowerCase(); 600 + if (normalized === "true") { 601 + return true; 602 + } 603 + if (normalized === "false") { 604 + return false; 605 + } 606 + } 607 + return fallback; 608 + } 609 + 610 + function normalizePositiveInt(value) { 611 + if (typeof value === "number" && Number.isInteger(value) && value > 0) { 612 + return value; 613 + } 614 + if (typeof value === "string") { 615 + const parsed = Number.parseInt(value.trim(), 10); 616 + if (Number.isInteger(parsed) && parsed > 0) { 617 + return parsed; 618 + } 619 + } 620 + return null; 621 + } 622 + 623 + function normalizeString(value) { 624 + return typeof value === "string" ? value.trim() : ""; 625 + } 626 + 627 + function normalizeToolList(value) { 628 + if (typeof value === "string") { 629 + return value 630 + .split(",") 631 + .map((item) => item.trim()) 632 + .filter(Boolean); 633 + } 634 + if (!Array.isArray(value)) { 635 + return []; 636 + } 637 + return value 638 + .map((item) => (typeof item === "string" ? item.trim() : "")) 639 + .filter(Boolean); 640 + } 641 + 642 + function pickBoolean(source, key, fallback) { 643 + return normalizeBoolean(source[key], fallback); 644 + } 645 + 646 + function pickPositiveInt(source, ...keys) { 647 + for (const key of keys) { 648 + if (!Object.prototype.hasOwnProperty.call(source, key)) { 649 + continue; 650 + } 651 + return normalizePositiveInt(source[key]); 652 + } 653 + return null; 654 + } 655 + 656 + function pickString(source, ...keys) { 657 + for (const key of keys) { 658 + const value = source[key]; 659 + if (typeof value !== "string") { 660 + continue; 661 + } 662 + const text = value.trim(); 663 + if (text !== "") { 664 + return text; 665 + } 666 + } 667 + return null; 668 + } 669 + 670 + function pickValue(source, ...keys) { 671 + for (const key of keys) { 672 + if (Object.prototype.hasOwnProperty.call(source, key)) { 673 + return source[key]; 674 + } 675 + } 676 + return null; 677 + }
+52
wrappers/acp/claude/test/fixtures/fake-claude-success.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + import process from "node:process"; 4 + 5 + const args = process.argv.slice(2); 6 + const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--print"); 7 + const prompt = promptIndex >= 0 ? args[promptIndex + 1] ?? "" : ""; 8 + 9 + let result = "Hello"; 10 + if (prompt.includes("exactly")) { 11 + const marker = prompt.split("exactly").pop()?.trim() ?? ""; 12 + result = marker.replace(/^[:\s"]+/, "").replace(/[".\s]+$/, "") || result; 13 + } 14 + 15 + console.log( 16 + JSON.stringify({ 17 + type: "system", 18 + subtype: "init", 19 + session_id: "fake-claude-session", 20 + tools: ["Read", "Edit", "Write", "Bash"], 21 + permissionMode: "dontAsk" 22 + }) 23 + ); 24 + console.log( 25 + JSON.stringify({ 26 + type: "stream_event", 27 + event: { 28 + delta: { 29 + type: "text_delta", 30 + text: result.slice(0, Math.max(1, Math.floor(result.length / 2))) 31 + } 32 + } 33 + }) 34 + ); 35 + console.log( 36 + JSON.stringify({ 37 + type: "assistant", 38 + message: { 39 + content: [{ type: "text", text: result }] 40 + } 41 + }) 42 + ); 43 + console.log( 44 + JSON.stringify({ 45 + type: "result", 46 + subtype: "success", 47 + is_error: false, 48 + result, 49 + stop_reason: "stop_sequence", 50 + session_id: "fake-claude-session" 51 + }) 52 + );
+121
wrappers/acp/claude/test/lib.test.mjs
··· 1 + import test from "node:test"; 2 + import assert from "node:assert/strict"; 3 + 4 + import { 5 + buildClaudePromptFlags, 6 + collectACPText, 7 + createPromptState, 8 + normalizeSessionOptions, 9 + processClaudeEvent 10 + } from "../src/lib.mjs"; 11 + 12 + test("normalizeSessionOptions supports defaults and arrays", () => { 13 + const options = normalizeSessionOptions({ 14 + model: "claude-opus-4-6", 15 + permission_mode: "dontAsk", 16 + allowed_tools: ["Read", "Edit"], 17 + max_turns: 3 18 + }); 19 + 20 + assert.equal(options.model, "claude-opus-4-6"); 21 + assert.equal(options.permissionMode, "dontAsk"); 22 + assert.deepEqual(options.allowedTools, ["Read", "Edit"]); 23 + assert.equal(options.maxTurns, 3); 24 + assert.equal(options.bare, false); 25 + }); 26 + 27 + test("collectACPText joins ACP text items", () => { 28 + const text = collectACPText([ 29 + { type: "text", text: "hello" }, 30 + { type: "image", url: "ignored" }, 31 + { type: "text", text: "world" } 32 + ]); 33 + 34 + assert.equal(text, "hello\nworld"); 35 + }); 36 + 37 + test("buildClaudePromptFlags includes streaming and permission flags", () => { 38 + const args = buildClaudePromptFlags("Say Hello", { 39 + permissionMode: "dontAsk", 40 + allowedTools: ["Read", "Edit"], 41 + model: "opus", 42 + appendSystemPrompt: "Be precise.", 43 + maxTurns: 2, 44 + bare: true 45 + }); 46 + 47 + assert.deepEqual(args.slice(0, 9), [ 48 + "--bare", 49 + "-p", 50 + "Say Hello", 51 + "--output-format", 52 + "stream-json", 53 + "--verbose", 54 + "--include-partial-messages", 55 + "--no-session-persistence", 56 + "--permission-mode" 57 + ]); 58 + assert.ok(args.includes("--allowedTools")); 59 + assert.ok(args.includes("Read")); 60 + assert.ok(args.includes("Edit")); 61 + assert.ok(args.includes("--append-system-prompt")); 62 + assert.ok(args.includes("--max-turns")); 63 + }); 64 + 65 + test("processClaudeEvent emits stream deltas and final stop reason", () => { 66 + const state = createPromptState(); 67 + 68 + const partial = processClaudeEvent( 69 + { 70 + type: "stream_event", 71 + event: { 72 + delta: { type: "text_delta", text: "Hel" } 73 + } 74 + }, 75 + state 76 + ); 77 + assert.equal(partial.updates.length, 1); 78 + assert.equal(partial.updates[0].content[0].text, "Hel"); 79 + 80 + const assistant = processClaudeEvent( 81 + { 82 + type: "assistant", 83 + message: { 84 + content: [{ type: "text", text: "Hello" }] 85 + } 86 + }, 87 + state 88 + ); 89 + assert.equal(assistant.updates.length, 1); 90 + assert.equal(assistant.updates[0].content[0].text, "lo"); 91 + 92 + const result = processClaudeEvent( 93 + { 94 + type: "result", 95 + subtype: "success", 96 + is_error: false, 97 + result: "Hello", 98 + stop_reason: "stop_sequence" 99 + }, 100 + state 101 + ); 102 + assert.equal(result.updates.length, 0); 103 + assert.deepEqual(result.final, { stopReason: "end_turn" }); 104 + }); 105 + 106 + test("processClaudeEvent turns result errors into failures", () => { 107 + const state = createPromptState(); 108 + const outcome = processClaudeEvent( 109 + { 110 + type: "result", 111 + subtype: "success", 112 + is_error: true, 113 + result: "authentication failed", 114 + stop_reason: "stop_sequence" 115 + }, 116 + state 117 + ); 118 + 119 + assert.equal(outcome.updates.length, 1); 120 + assert.match(outcome.error.message, /authentication failed/); 121 + });
+55
wrappers/acp/codex/README.md
··· 1 + # MisterMorph ACP Codex Wrapper 2 + 3 + This wrapper lets MisterMorph talk ACP to Codex without depending on a third-party ACP adapter. 4 + 5 + Current shape: 6 + 7 + - transport: `stdio` 8 + - ACP methods: 9 + - `initialize` 10 + - `authenticate` (no-op) 11 + - `session/new` 12 + - `session/set_config_option` 13 + - `session/prompt` 14 + - `session/cancel` 15 + - backend: `codex app-server` 16 + 17 + Current limits: 18 + 19 + - no session persistence 20 + - no MCP passthrough 21 + - no interactive approval flow 22 + - default `approval_policy` is `never` 23 + 24 + Run it directly: 25 + 26 + ```bash 27 + node ./wrappers/acp/codex/src/index.mjs 28 + ``` 29 + 30 + Example ACP profile: 31 + 32 + ```yaml 33 + acp: 34 + agents: 35 + - name: "codex" 36 + enable: true 37 + type: "stdio" 38 + command: "node" 39 + args: ["./wrappers/acp/codex/src/index.mjs"] 40 + env: {} 41 + cwd: "." 42 + read_roots: ["."] 43 + write_roots: ["."] 44 + session_options: 45 + approval_policy: "never" 46 + ``` 47 + 48 + Optional environment variables: 49 + 50 + - `MISTERMORPH_CODEX_COMMAND` 51 + - override backend executable, default `codex` 52 + - `MISTERMORPH_CODEX_ARGS` 53 + - extra backend args, whitespace-split, appended after `app-server` 54 + - `MISTERMORPH_CODEX_AUTO_APPROVE` 55 + - when set to `1`, auto-accept Codex command/file approval requests for the session
+16
wrappers/acp/codex/package.json
··· 1 + { 2 + "name": "@mistermorph/acp-codex", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "description": "MisterMorph-owned ACP wrapper for Codex app-server", 7 + "bin": { 8 + "mistermorph-acp-codex": "./src/index.mjs" 9 + }, 10 + "scripts": { 11 + "test": "node --test" 12 + }, 13 + "engines": { 14 + "node": ">=20" 15 + } 16 + }
+5
wrappers/acp/codex/src/index.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + import { main } from "./lib.mjs"; 4 + 5 + main();
+894
wrappers/acp/codex/src/lib.mjs
··· 1 + import { spawn } from "node:child_process"; 2 + import crypto from "node:crypto"; 3 + import readline from "node:readline"; 4 + import process from "node:process"; 5 + 6 + export const WRAPPER_VERSION = "0.1.0"; 7 + export const ACP_PROTOCOL_VERSION = 1; 8 + export const SUPPORTED_CONFIG_OPTIONS = [ 9 + "model", 10 + "service_tier", 11 + "approval_policy", 12 + "reasoning_effort", 13 + ]; 14 + 15 + const JSONRPC_VERSION = "2.0"; 16 + const RPC_METHOD_NOT_FOUND = -32601; 17 + const RPC_INVALID_PARAMS = -32602; 18 + const RPC_INTERNAL_ERROR = -32603; 19 + const DEFAULT_APPROVAL_POLICY = "never"; 20 + const ACP_METHOD_INITIALIZE = "initialize"; 21 + const ACP_METHOD_AUTHENTICATE = "authenticate"; 22 + const ACP_METHOD_SESSION_NEW = "session/new"; 23 + const ACP_METHOD_SESSION_SET_CONFIG = "session/set_config_option"; 24 + const ACP_METHOD_SESSION_PROMPT = "session/prompt"; 25 + const ACP_METHOD_SESSION_CANCEL = "session/cancel"; 26 + const ACP_METHOD_SESSION_UPDATE = "session/update"; 27 + 28 + export function normalizeSessionOptions(raw = {}) { 29 + const source = isRecord(raw) ? raw : {}; 30 + return { 31 + model: pickString(source, "model"), 32 + serviceTier: pickString(source, "service_tier", "serviceTier"), 33 + approvalPolicy: 34 + pickValue(source, "approval_policy", "approvalPolicy") ?? 35 + DEFAULT_APPROVAL_POLICY, 36 + reasoningEffort: pickString( 37 + source, 38 + "reasoning_effort", 39 + "reasoningEffort", 40 + ), 41 + sandbox: pickValue(source, "sandbox"), 42 + baseInstructions: pickString( 43 + source, 44 + "base_instructions", 45 + "baseInstructions", 46 + ), 47 + developerInstructions: pickString( 48 + source, 49 + "developer_instructions", 50 + "developerInstructions", 51 + ), 52 + ephemeral: pickBoolean(source, "ephemeral", true), 53 + }; 54 + } 55 + 56 + export function collectACPText(content) { 57 + if (!Array.isArray(content)) { 58 + return ""; 59 + } 60 + const parts = []; 61 + for (const item of content) { 62 + if (!isRecord(item)) { 63 + continue; 64 + } 65 + if (item.type !== "text") { 66 + continue; 67 + } 68 + if (typeof item.text !== "string") { 69 + continue; 70 + } 71 + const text = item.text.trim(); 72 + if (text !== "") { 73 + parts.push(text); 74 + } 75 + } 76 + return parts.join("\n").trim(); 77 + } 78 + 79 + export function mapTurnOutcome(turn) { 80 + if (!isRecord(turn)) { 81 + throw new Error("codex turn response missing turn payload"); 82 + } 83 + const status = normalizeString(turn.status).toLowerCase(); 84 + switch (status) { 85 + case "completed": 86 + return { stopReason: "end_turn" }; 87 + case "interrupted": 88 + return { stopReason: "cancelled" }; 89 + case "failed": { 90 + const error = isRecord(turn.error) ? turn.error : {}; 91 + const message = 92 + normalizeString(error.message) || 93 + normalizeString(error.additionalDetails) || 94 + "codex turn failed"; 95 + throw new Error(message); 96 + } 97 + case "inprogress": 98 + return null; 99 + default: 100 + throw new Error(`unsupported codex turn status: ${turn.status}`); 101 + } 102 + } 103 + 104 + export function buildToolStartUpdate(item) { 105 + if (!isRecord(item) || typeof item.id !== "string") { 106 + return null; 107 + } 108 + if (item.type === "commandExecution") { 109 + return { 110 + sessionUpdate: "tool_call", 111 + toolCallId: item.id, 112 + title: typeof item.command === "string" ? item.command : "command", 113 + kind: "command_execution", 114 + status: mapItemStatus(item.status), 115 + content: textContent(typeof item.command === "string" ? item.command : ""), 116 + }; 117 + } 118 + if (item.type === "fileChange") { 119 + return { 120 + sessionUpdate: "tool_call", 121 + toolCallId: item.id, 122 + title: "file change", 123 + kind: "file_change", 124 + status: mapItemStatus(item.status), 125 + content: textContent(summarizeFileChanges(item.changes)), 126 + }; 127 + } 128 + return null; 129 + } 130 + 131 + export function buildToolProgressUpdate(method, params) { 132 + if (!isRecord(params) || typeof params.itemId !== "string") { 133 + return null; 134 + } 135 + if ( 136 + method === "item/commandExecution/outputDelta" || 137 + method === "command/exec/outputDelta" 138 + ) { 139 + return { 140 + sessionUpdate: "tool_call_update", 141 + toolCallId: params.itemId, 142 + kind: "command_execution", 143 + status: "in_progress", 144 + content: textContent(typeof params.delta === "string" ? params.delta : ""), 145 + }; 146 + } 147 + if (method === "item/fileChange/outputDelta") { 148 + return { 149 + sessionUpdate: "tool_call_update", 150 + toolCallId: params.itemId, 151 + kind: "file_change", 152 + status: "in_progress", 153 + content: textContent(typeof params.delta === "string" ? params.delta : ""), 154 + }; 155 + } 156 + return null; 157 + } 158 + 159 + export function buildToolDoneUpdate(item) { 160 + const started = buildToolStartUpdate(item); 161 + if (!started) { 162 + return null; 163 + } 164 + return { 165 + ...started, 166 + sessionUpdate: "tool_call_update", 167 + status: mapItemStatus(item.status), 168 + content: textContent(extractToolOutput(item) || summarizeFileChanges(item.changes)), 169 + }; 170 + } 171 + 172 + export class CodexAppServerClient { 173 + constructor(options = {}) { 174 + this.command = 175 + normalizeString(options.command) || 176 + normalizeString(process.env.MISTERMORPH_CODEX_COMMAND) || 177 + "codex"; 178 + this.args = buildBackendArgs(options.args); 179 + this.cwd = normalizeString(options.cwd) || process.cwd(); 180 + this.env = { ...process.env, ...(isRecord(options.env) ? options.env : {}) }; 181 + this.proc = null; 182 + this.rl = null; 183 + this.nextID = 1; 184 + this.pending = new Map(); 185 + this.ready = null; 186 + this.notificationHandler = null; 187 + this.requestHandler = null; 188 + this.closed = false; 189 + this.starting = false; 190 + } 191 + 192 + async ensureStarted() { 193 + if (this.ready) { 194 + return this.ready; 195 + } 196 + this.ready = this.#start(); 197 + return this.ready; 198 + } 199 + 200 + async #start() { 201 + this.starting = true; 202 + this.proc = spawn(this.command, this.args, { 203 + cwd: this.cwd, 204 + env: this.env, 205 + stdio: ["pipe", "pipe", "pipe"], 206 + }); 207 + this.proc.stderr.on("data", (chunk) => { 208 + process.stderr.write(chunk); 209 + }); 210 + this.proc.on("exit", (code, signal) => { 211 + const suffix = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`; 212 + const error = new Error(`codex app-server exited with ${suffix}`); 213 + for (const pending of this.pending.values()) { 214 + pending.reject(error); 215 + } 216 + this.pending.clear(); 217 + }); 218 + 219 + this.rl = readline.createInterface({ input: this.proc.stdout }); 220 + this.rl.on("line", (line) => { 221 + void this.#handleLine(line); 222 + }); 223 + 224 + const initializeResult = await this.#sendRequest("initialize", { 225 + clientInfo: { 226 + name: "mistermorph-acp-codex", 227 + version: WRAPPER_VERSION, 228 + }, 229 + capabilities: { 230 + experimentalApi: false, 231 + }, 232 + }); 233 + await this.#sendNotification("initialized", undefined); 234 + this.starting = false; 235 + return initializeResult; 236 + } 237 + 238 + async close() { 239 + this.closed = true; 240 + if (this.rl) { 241 + this.rl.close(); 242 + } 243 + if (this.proc?.stdin && !this.proc.stdin.destroyed) { 244 + this.proc.stdin.end(); 245 + } 246 + if (this.proc && !this.proc.killed) { 247 + this.proc.kill(); 248 + } 249 + } 250 + 251 + async call(method, params) { 252 + await this.ensureStarted(); 253 + return this.#sendRequest(method, params); 254 + } 255 + 256 + async notify(method, params) { 257 + await this.ensureStarted(); 258 + this.#sendNotification(method, params); 259 + } 260 + 261 + #send(message) { 262 + if (!this.proc?.stdin || this.proc.stdin.destroyed) { 263 + throw new Error("codex app-server stdin is not available"); 264 + } 265 + this.proc.stdin.write(`${JSON.stringify(message)}\n`); 266 + } 267 + 268 + async #sendRequest(method, params) { 269 + const id = this.nextID++; 270 + const response = new Promise((resolve, reject) => { 271 + this.pending.set(String(id), { resolve, reject }); 272 + }); 273 + this.#send({ 274 + jsonrpc: JSONRPC_VERSION, 275 + id, 276 + method, 277 + params, 278 + }); 279 + return response; 280 + } 281 + 282 + #sendNotification(method, params) { 283 + this.#send({ 284 + jsonrpc: JSONRPC_VERSION, 285 + method, 286 + params, 287 + }); 288 + } 289 + 290 + async #handleLine(line) { 291 + if (line.trim() === "") { 292 + return; 293 + } 294 + let message; 295 + try { 296 + message = JSON.parse(line); 297 + } catch (error) { 298 + process.stderr.write(`invalid codex app-server json: ${String(error)}\n`); 299 + return; 300 + } 301 + if (!isRecord(message)) { 302 + return; 303 + } 304 + if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) { 305 + const key = String(message.id); 306 + const pending = this.pending.get(key); 307 + if (!pending) { 308 + return; 309 + } 310 + this.pending.delete(key); 311 + if (message.error) { 312 + pending.reject(new Error(message.error.message || "codex app-server error")); 313 + return; 314 + } 315 + pending.resolve(message.result); 316 + return; 317 + } 318 + if (Object.prototype.hasOwnProperty.call(message, "id") && message.method) { 319 + try { 320 + const result = await this.#handleServerRequest(message); 321 + this.#send({ 322 + jsonrpc: JSONRPC_VERSION, 323 + id: message.id, 324 + result, 325 + }); 326 + } catch (error) { 327 + this.#send({ 328 + jsonrpc: JSONRPC_VERSION, 329 + id: message.id, 330 + error: { 331 + code: RPC_INTERNAL_ERROR, 332 + message: error instanceof Error ? error.message : String(error), 333 + }, 334 + }); 335 + } 336 + return; 337 + } 338 + if (message.method && this.notificationHandler) { 339 + await this.notificationHandler(message); 340 + } 341 + } 342 + 343 + async #handleServerRequest(message) { 344 + if (!this.requestHandler) { 345 + throw new Error(`unhandled codex server request: ${message.method}`); 346 + } 347 + return this.requestHandler(message); 348 + } 349 + } 350 + 351 + export class CodexACPServer { 352 + constructor(options = {}) { 353 + this.stdin = options.stdin ?? process.stdin; 354 + this.stdout = options.stdout ?? process.stdout; 355 + this.stderr = options.stderr ?? process.stderr; 356 + this.readline = readline.createInterface({ input: this.stdin }); 357 + this.codex = new CodexAppServerClient({ 358 + command: options.codexCommand, 359 + args: options.codexArgs, 360 + cwd: options.cwd, 361 + env: options.env, 362 + }); 363 + this.sessions = new Map(); 364 + this.codex.notificationHandler = async (message) => { 365 + await this.#handleCodexNotification(message); 366 + }; 367 + this.codex.requestHandler = async (message) => { 368 + return this.#handleCodexRequest(message); 369 + }; 370 + } 371 + 372 + start() { 373 + this.readline.on("line", (line) => { 374 + void this.#handleACPLine(line); 375 + }); 376 + this.readline.on("close", () => { 377 + void this.codex.close(); 378 + }); 379 + } 380 + 381 + async #handleACPLine(line) { 382 + if (line.trim() === "") { 383 + return; 384 + } 385 + let message; 386 + try { 387 + message = JSON.parse(line); 388 + } catch (error) { 389 + this.#writeError(null, RPC_INVALID_PARAMS, `invalid json: ${String(error)}`); 390 + return; 391 + } 392 + if (!isRecord(message)) { 393 + this.#writeError(null, RPC_INVALID_PARAMS, "invalid request"); 394 + return; 395 + } 396 + const method = normalizeString(message.method); 397 + if (method === "") { 398 + this.#writeError(message.id ?? null, RPC_INVALID_PARAMS, "missing method"); 399 + return; 400 + } 401 + try { 402 + const result = await this.#handleACPRequest(method, message.params); 403 + this.#write({ 404 + jsonrpc: JSONRPC_VERSION, 405 + id: message.id ?? null, 406 + result, 407 + }); 408 + } catch (error) { 409 + if (error instanceof JsonRpcFailure) { 410 + this.#writeError(message.id ?? null, error.code, error.message); 411 + return; 412 + } 413 + this.#writeError( 414 + message.id ?? null, 415 + RPC_INTERNAL_ERROR, 416 + error instanceof Error ? error.message : String(error), 417 + ); 418 + } 419 + } 420 + 421 + async #handleACPRequest(method, params) { 422 + switch (method) { 423 + case ACP_METHOD_INITIALIZE: 424 + return { 425 + protocolVersion: ACP_PROTOCOL_VERSION, 426 + authMethods: [], 427 + }; 428 + case ACP_METHOD_AUTHENTICATE: 429 + return {}; 430 + case ACP_METHOD_SESSION_NEW: 431 + return this.#createSession(params); 432 + case ACP_METHOD_SESSION_SET_CONFIG: 433 + return this.#setSessionConfig(params); 434 + case ACP_METHOD_SESSION_PROMPT: 435 + return this.#runPrompt(params); 436 + case ACP_METHOD_SESSION_CANCEL: 437 + return this.#cancelPrompt(params); 438 + default: 439 + throw new JsonRpcFailure(RPC_METHOD_NOT_FOUND, `unsupported ACP method: ${method}`); 440 + } 441 + } 442 + 443 + async #createSession(params) { 444 + const payload = asObject(params, "session/new params"); 445 + const cwd = normalizeString(payload.cwd) || process.cwd(); 446 + const meta = isRecord(payload._meta) ? payload._meta : {}; 447 + const options = normalizeSessionOptions(meta); 448 + 449 + const result = await this.codex.call("thread/start", cleanObject({ 450 + cwd, 451 + model: options.model ?? null, 452 + serviceTier: options.serviceTier ?? null, 453 + approvalPolicy: options.approvalPolicy, 454 + sandbox: options.sandbox ?? null, 455 + baseInstructions: options.baseInstructions ?? null, 456 + developerInstructions: options.developerInstructions ?? null, 457 + ephemeral: options.ephemeral, 458 + experimentalRawEvents: false, 459 + persistExtendedHistory: false, 460 + })); 461 + 462 + const thread = asObject(result?.thread, "codex thread/start result.thread"); 463 + const sessionId = normalizeString(thread.id) || crypto.randomUUID(); 464 + this.sessions.set(sessionId, { 465 + sessionId, 466 + threadId: normalizeString(thread.id) || sessionId, 467 + cwd, 468 + options, 469 + pendingTurn: null, 470 + }); 471 + 472 + return { 473 + sessionId, 474 + configOptions: SUPPORTED_CONFIG_OPTIONS.map((id) => ({ id })), 475 + }; 476 + } 477 + 478 + async #setSessionConfig(params) { 479 + const payload = asObject(params, "session/set_config_option params"); 480 + const session = this.#getSession(payload.sessionId); 481 + const configId = normalizeString(payload.configId); 482 + if (!SUPPORTED_CONFIG_OPTIONS.includes(configId)) { 483 + return {}; 484 + } 485 + session.options = applyConfigOption(session.options, configId, payload.value); 486 + return {}; 487 + } 488 + 489 + async #runPrompt(params) { 490 + const payload = asObject(params, "session/prompt params"); 491 + const session = this.#getSession(payload.sessionId); 492 + if (session.pendingTurn) { 493 + throw new JsonRpcFailure( 494 + RPC_INVALID_PARAMS, 495 + `session ${session.sessionId} already has an active turn`, 496 + ); 497 + } 498 + const prompt = collectACPText(payload.prompt); 499 + if (prompt === "") { 500 + throw new JsonRpcFailure(RPC_INVALID_PARAMS, "session/prompt requires text content"); 501 + } 502 + 503 + const deferred = createDeferred(); 504 + session.pendingTurn = { 505 + turnId: "", 506 + resolve: deferred.resolve, 507 + reject: deferred.reject, 508 + }; 509 + 510 + try { 511 + const result = await this.codex.call("turn/start", cleanObject({ 512 + threadId: session.threadId, 513 + input: [ 514 + { 515 + type: "text", 516 + text: prompt, 517 + text_elements: [], 518 + }, 519 + ], 520 + cwd: session.cwd, 521 + model: session.options.model ?? null, 522 + serviceTier: session.options.serviceTier ?? null, 523 + approvalPolicy: session.options.approvalPolicy ?? null, 524 + effort: session.options.reasoningEffort ?? null, 525 + })); 526 + 527 + const turn = asObject(result?.turn, "codex turn/start result.turn"); 528 + session.pendingTurn.turnId = normalizeString(turn.id); 529 + const immediate = mapTurnOutcome(turn); 530 + if (!immediate) { 531 + return deferred.promise; 532 + } 533 + session.pendingTurn = null; 534 + return immediate; 535 + } catch (error) { 536 + session.pendingTurn = null; 537 + throw error; 538 + } 539 + } 540 + 541 + async #cancelPrompt(params) { 542 + const payload = asObject(params, "session/cancel params"); 543 + const session = this.#getSession(payload.sessionId); 544 + if (!session.pendingTurn || normalizeString(session.pendingTurn.turnId) === "") { 545 + return {}; 546 + } 547 + await this.codex.call("turn/interrupt", { 548 + threadId: session.threadId, 549 + turnId: session.pendingTurn.turnId, 550 + }); 551 + return {}; 552 + } 553 + 554 + async #handleCodexNotification(message) { 555 + const method = normalizeString(message.method); 556 + const params = isRecord(message.params) ? message.params : {}; 557 + const session = this.#findSessionByThreadId(params.threadId); 558 + if (!session) { 559 + return; 560 + } 561 + 562 + if (method === "item/agentMessage/delta") { 563 + const delta = normalizeString(params.delta); 564 + if (delta !== "") { 565 + this.#notifySessionUpdate(session.sessionId, { 566 + sessionUpdate: "agent_message_chunk", 567 + content: textContent(delta), 568 + }); 569 + } 570 + return; 571 + } 572 + 573 + if (method === "item/started") { 574 + const update = buildToolStartUpdate(params.item); 575 + if (update) { 576 + this.#notifySessionUpdate(session.sessionId, update); 577 + } 578 + return; 579 + } 580 + 581 + if ( 582 + method === "item/commandExecution/outputDelta" || 583 + method === "command/exec/outputDelta" || 584 + method === "item/fileChange/outputDelta" 585 + ) { 586 + const update = buildToolProgressUpdate(method, params); 587 + if (update) { 588 + this.#notifySessionUpdate(session.sessionId, update); 589 + } 590 + return; 591 + } 592 + 593 + if (method === "item/completed") { 594 + const update = buildToolDoneUpdate(params.item); 595 + if (update) { 596 + this.#notifySessionUpdate(session.sessionId, update); 597 + } 598 + return; 599 + } 600 + 601 + if (method === "turn/completed") { 602 + if (!session.pendingTurn) { 603 + return; 604 + } 605 + if (!turnMatches(session.pendingTurn.turnId, params.turn?.id)) { 606 + return; 607 + } 608 + const pending = session.pendingTurn; 609 + session.pendingTurn = null; 610 + try { 611 + pending.resolve(mapTurnOutcome(params.turn)); 612 + } catch (error) { 613 + pending.reject(error); 614 + } 615 + return; 616 + } 617 + 618 + if (method === "error") { 619 + if (!session.pendingTurn) { 620 + return; 621 + } 622 + if (!turnMatches(session.pendingTurn.turnId, params.turnId)) { 623 + return; 624 + } 625 + const pending = session.pendingTurn; 626 + session.pendingTurn = null; 627 + const error = isRecord(params.error) ? params.error : {}; 628 + const messageText = 629 + normalizeString(error.message) || 630 + normalizeString(error.additionalDetails) || 631 + "codex turn failed"; 632 + pending.reject(new Error(messageText)); 633 + } 634 + } 635 + 636 + async #handleCodexRequest(message) { 637 + const method = normalizeString(message.method); 638 + const params = isRecord(message.params) ? message.params : {}; 639 + const session = this.#findSessionByThreadId(params.threadId); 640 + const autoApprove = 641 + session?.options?.approvalPolicy === "never" 642 + ? false 643 + : normalizeString(process.env.MISTERMORPH_CODEX_AUTO_APPROVE) === "1"; 644 + 645 + if (method === "item/commandExecution/requestApproval") { 646 + return { 647 + decision: autoApprove ? "acceptForSession" : "decline", 648 + }; 649 + } 650 + if (method === "item/fileChange/requestApproval") { 651 + return { 652 + decision: autoApprove ? "acceptForSession" : "decline", 653 + }; 654 + } 655 + throw new Error(`unsupported codex server request: ${method}`); 656 + } 657 + 658 + #notifySessionUpdate(sessionId, update) { 659 + this.#write({ 660 + jsonrpc: JSONRPC_VERSION, 661 + method: ACP_METHOD_SESSION_UPDATE, 662 + params: { 663 + sessionId, 664 + update, 665 + }, 666 + }); 667 + } 668 + 669 + #getSession(sessionId) { 670 + const key = normalizeString(sessionId); 671 + const session = this.sessions.get(key); 672 + if (!session) { 673 + throw new JsonRpcFailure(RPC_INVALID_PARAMS, `unknown sessionId: ${sessionId}`); 674 + } 675 + return session; 676 + } 677 + 678 + #findSessionByThreadId(threadId) { 679 + const key = normalizeString(threadId); 680 + if (key === "") { 681 + return null; 682 + } 683 + for (const session of this.sessions.values()) { 684 + if (session.threadId === key) { 685 + return session; 686 + } 687 + } 688 + return null; 689 + } 690 + 691 + #write(message) { 692 + this.stdout.write(`${JSON.stringify(message)}\n`); 693 + } 694 + 695 + #writeError(id, code, message) { 696 + this.#write({ 697 + jsonrpc: JSONRPC_VERSION, 698 + id, 699 + error: { 700 + code, 701 + message, 702 + }, 703 + }); 704 + } 705 + } 706 + 707 + export function buildBackendArgs(rawArgs) { 708 + const extraArgs = Array.isArray(rawArgs) 709 + ? rawArgs 710 + : normalizeString(process.env.MISTERMORPH_CODEX_ARGS) 711 + .split(/\s+/) 712 + .filter(Boolean); 713 + return ["app-server", ...extraArgs]; 714 + } 715 + 716 + export function printHelp(stream = process.stdout) { 717 + stream.write( 718 + [ 719 + "MisterMorph ACP Codex Wrapper", 720 + "", 721 + "Starts an ACP agent over stdio and bridges it to codex app-server.", 722 + "", 723 + "Usage:", 724 + " node ./wrappers/acp/codex/src/index.mjs", 725 + "", 726 + "Environment:", 727 + " MISTERMORPH_CODEX_COMMAND backend executable, default: codex", 728 + " MISTERMORPH_CODEX_ARGS extra backend args appended after app-server", 729 + " MISTERMORPH_CODEX_AUTO_APPROVE set to 1 to auto-accept command/file approvals", 730 + "", 731 + ].join("\n"), 732 + ); 733 + } 734 + 735 + export function main(argv = process.argv.slice(2)) { 736 + if (argv.includes("--help") || argv.includes("-h")) { 737 + printHelp(); 738 + return; 739 + } 740 + const server = new CodexACPServer(); 741 + server.start(); 742 + } 743 + 744 + class JsonRpcFailure extends Error { 745 + constructor(code, message) { 746 + super(message); 747 + this.code = code; 748 + } 749 + } 750 + 751 + function createDeferred() { 752 + let resolve; 753 + let reject; 754 + const promise = new Promise((res, rej) => { 755 + resolve = res; 756 + reject = rej; 757 + }); 758 + return { promise, resolve, reject }; 759 + } 760 + 761 + function asObject(value, label) { 762 + if (!isRecord(value)) { 763 + throw new JsonRpcFailure(RPC_INVALID_PARAMS, `invalid ${label}`); 764 + } 765 + return value; 766 + } 767 + 768 + function applyConfigOption(options, configId, value) { 769 + const next = { ...options }; 770 + switch (configId) { 771 + case "model": 772 + next.model = typeof value === "string" ? value.trim() || null : null; 773 + break; 774 + case "service_tier": 775 + next.serviceTier = typeof value === "string" ? value.trim() || null : null; 776 + break; 777 + case "approval_policy": 778 + next.approvalPolicy = value ?? DEFAULT_APPROVAL_POLICY; 779 + break; 780 + case "reasoning_effort": 781 + next.reasoningEffort = 782 + typeof value === "string" ? value.trim() || null : null; 783 + break; 784 + default: 785 + break; 786 + } 787 + return next; 788 + } 789 + 790 + function cleanObject(value) { 791 + const out = {}; 792 + for (const [key, item] of Object.entries(value)) { 793 + if (item === undefined) { 794 + continue; 795 + } 796 + out[key] = item; 797 + } 798 + return out; 799 + } 800 + 801 + function extractToolOutput(item) { 802 + if (!isRecord(item)) { 803 + return ""; 804 + } 805 + if (item.type === "commandExecution") { 806 + return typeof item.aggregatedOutput === "string" ? item.aggregatedOutput : ""; 807 + } 808 + return ""; 809 + } 810 + 811 + function isRecord(value) { 812 + return value !== null && typeof value === "object" && !Array.isArray(value); 813 + } 814 + 815 + function mapItemStatus(value) { 816 + switch (normalizeString(value).toLowerCase()) { 817 + case "inprogress": 818 + return "in_progress"; 819 + case "completed": 820 + return "completed"; 821 + case "failed": 822 + return "failed"; 823 + default: 824 + return normalizeString(value) || "unknown"; 825 + } 826 + } 827 + 828 + function normalizeString(value) { 829 + return typeof value === "string" ? value.trim() : ""; 830 + } 831 + 832 + function pickBoolean(source, key, fallback) { 833 + const value = source[key]; 834 + return typeof value === "boolean" ? value : fallback; 835 + } 836 + 837 + function pickString(source, ...keys) { 838 + for (const key of keys) { 839 + const value = source[key]; 840 + if (typeof value !== "string") { 841 + continue; 842 + } 843 + const text = value.trim(); 844 + if (text !== "") { 845 + return text; 846 + } 847 + } 848 + return null; 849 + } 850 + 851 + function pickValue(source, ...keys) { 852 + for (const key of keys) { 853 + if (Object.prototype.hasOwnProperty.call(source, key)) { 854 + return source[key]; 855 + } 856 + } 857 + return null; 858 + } 859 + 860 + function summarizeFileChanges(changes) { 861 + if (!Array.isArray(changes)) { 862 + return ""; 863 + } 864 + const paths = []; 865 + for (const change of changes) { 866 + if (!isRecord(change)) { 867 + continue; 868 + } 869 + const path = 870 + pickString(change, "path", "filePath", "relativePath") || 871 + pickString(change.target ?? {}, "path"); 872 + if (path) { 873 + paths.push(path); 874 + } 875 + } 876 + return paths.join("\n"); 877 + } 878 + 879 + function textContent(text) { 880 + const clean = typeof text === "string" ? text.trim() : ""; 881 + if (clean === "") { 882 + return []; 883 + } 884 + return [{ type: "text", text: clean }]; 885 + } 886 + 887 + function turnMatches(expected, actual) { 888 + const want = normalizeString(expected); 889 + const got = normalizeString(actual); 890 + if (want === "") { 891 + return got !== ""; 892 + } 893 + return want === got; 894 + }
+85
wrappers/acp/codex/test/lib.test.mjs
··· 1 + import test from "node:test"; 2 + import assert from "node:assert/strict"; 3 + 4 + import { 5 + buildToolDoneUpdate, 6 + buildToolProgressUpdate, 7 + buildToolStartUpdate, 8 + collectACPText, 9 + mapTurnOutcome, 10 + normalizeSessionOptions, 11 + } from "../src/lib.mjs"; 12 + 13 + test("normalizeSessionOptions supports snake_case keys and defaults", () => { 14 + const options = normalizeSessionOptions({ 15 + model: "gpt-5-codex", 16 + service_tier: "flex", 17 + reasoning_effort: "low", 18 + }); 19 + 20 + assert.equal(options.model, "gpt-5-codex"); 21 + assert.equal(options.serviceTier, "flex"); 22 + assert.equal(options.reasoningEffort, "low"); 23 + assert.equal(options.approvalPolicy, "never"); 24 + assert.equal(options.ephemeral, true); 25 + }); 26 + 27 + test("collectACPText joins ACP text items", () => { 28 + const text = collectACPText([ 29 + { type: "text", text: "hello" }, 30 + { type: "image", url: "ignored" }, 31 + { type: "text", text: "world" }, 32 + ]); 33 + 34 + assert.equal(text, "hello\nworld"); 35 + }); 36 + 37 + test("mapTurnOutcome maps completed and interrupted turns", () => { 38 + assert.deepEqual(mapTurnOutcome({ status: "completed" }), { 39 + stopReason: "end_turn", 40 + }); 41 + assert.deepEqual(mapTurnOutcome({ status: "interrupted" }), { 42 + stopReason: "cancelled", 43 + }); 44 + }); 45 + 46 + test("mapTurnOutcome throws on failed turns", () => { 47 + assert.throws( 48 + () => 49 + mapTurnOutcome({ 50 + status: "failed", 51 + error: { message: "boom" }, 52 + }), 53 + /boom/, 54 + ); 55 + }); 56 + 57 + test("tool update builders map command execution events", () => { 58 + const started = buildToolStartUpdate({ 59 + type: "commandExecution", 60 + id: "cmd-1", 61 + command: "go test ./...", 62 + status: "inProgress", 63 + }); 64 + assert.equal(started.sessionUpdate, "tool_call"); 65 + assert.equal(started.toolCallId, "cmd-1"); 66 + assert.equal(started.kind, "command_execution"); 67 + assert.equal(started.status, "in_progress"); 68 + 69 + const progress = buildToolProgressUpdate("item/commandExecution/outputDelta", { 70 + itemId: "cmd-1", 71 + delta: "ok", 72 + }); 73 + assert.equal(progress.sessionUpdate, "tool_call_update"); 74 + assert.equal(progress.toolCallId, "cmd-1"); 75 + 76 + const done = buildToolDoneUpdate({ 77 + type: "commandExecution", 78 + id: "cmd-1", 79 + command: "go test ./...", 80 + status: "completed", 81 + aggregatedOutput: "ok", 82 + }); 83 + assert.equal(done.sessionUpdate, "tool_call_update"); 84 + assert.equal(done.status, "completed"); 85 + });