From 3a90d3ac0201d65df20d06dd82b5f5d9125006c1 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 17:41:34 +0000 Subject: [PATCH 1/2] spike(runtime): drive opencode and Claude Code over ACP (finding 13) A spike-grade ACP adapter behind the existing AgentAdapter seam, a runner script that drives the real runtime with it, and the finding. Bumps @tanstack/ai to 0.64 and adds @tanstack/ai-acp, @agentclientprotocol/sdk and @agentclientprotocol/claude-agent-acp. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + bun.lock | 75 ++++++-- docs/findings/13-acp-agents.md | 164 ++++++++++++++++ docs/findings/README.md | 6 + package.json | 11 +- spikes/13-acp-agents.ts | 247 ++++++++++++++++++++++++ src/runtime/acp-adapter.ts | 338 +++++++++++++++++++++++++++++++++ 7 files changed, 825 insertions(+), 17 deletions(-) create mode 100644 docs/findings/13-acp-agents.md create mode 100644 spikes/13-acp-agents.ts create mode 100644 src/runtime/acp-adapter.ts diff --git a/.gitignore b/.gitignore index 4beb66e..c010faa 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ playwright-report/ # tanstack sandbox projected state .tanstack-projected-* +spikes/out/ diff --git a/bun.lock b/bun.lock index a9be481..0fd3882 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,17 @@ "": { "name": "factory", "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.85.1", + "@agentclientprotocol/sdk": "^1.7.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@radix-ui/react-slot": "^1.3.3", "@shadcn/react": "^0.3.1", - "@tanstack/ai": "^0.54.0", - "@tanstack/ai-opencode": "^0.4.5", - "@tanstack/ai-sandbox": "^0.5.7", - "@tanstack/ai-sandbox-local-process": "^0.2.5", + "@tanstack/ai": "^0.64.0", + "@tanstack/ai-acp": "^0.3.20", + "@tanstack/ai-opencode": "^0.4.15", + "@tanstack/ai-sandbox": "^0.5.18", + "@tanstack/ai-sandbox-local-process": "^0.2.6", "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.36", "bun-plugin-tailwind": "^0.1.2", @@ -61,12 +64,38 @@ "@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="], - "@ag-ui/core": ["@ag-ui/core@0.1.1-canary.beta.0", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-zcP3RH5p3HJTZ0kyQxNOL1blnVFTDpaShitR7UMS/thwFdl48V6CAG+WQBcYEu/dfa8mKonRez88N0Ok+nEI+w=="], + "@ag-ui/core": ["@ag-ui/core@1.0.0", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-yCRhsQvb4+lmGMrDsi4TOJxqKpm/kb3H64wwL+Z90jNVrkhy17RErfzsNgybfbjNKLM0nenEDYDVJsZHJJSigQ=="], + + "@agentclientprotocol/claude-agent-acp": ["@agentclientprotocol/claude-agent-acp@0.85.1", "", { "dependencies": { "@agentclientprotocol/sdk": "1.6.0", "@anthropic-ai/claude-agent-sdk": "0.3.286", "diff": "9.0.0", "zod": "4.6.5" }, "bin": { "claude-agent-acp": "dist/index.js" } }, "sha512-XpBAh6m98ib0R1yu0fC7OOZNk2n/KxA6ykH1QXSr/dYJGDgm4ILr4lprlvEBDxFytzVEpup24Mjkv0qZPmMsMQ=="], + + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.7.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ZORuxsuEnLjRly7t6Ad7zAsWitjWsyprswyPUHz/yycPb+tTm3DGa8XheaHhRXqX8LS9snuxaYavXSRA50TlHw=="], + + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.286", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.286", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.286" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-InL/UNmRGSwBM/81PME0J0TZDsDBBlweWqRZgq2XSViSIg2hBi8nIL8j9Hm6MHRH85wgDJQE5n6Vo/r9hIO0NQ=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.286", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gkxWcJ+Z23UxwghI1V3dL09PkELIZmB2vPelR8XsdfhS+yP1KvoW7FThvRLojcxXb3fj0ddYawjQSQYIkXFbxw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.286", "", { "os": "darwin", "cpu": "x64" }, "sha512-eMdni7sy1ud2IISI4QSsfVBCxotzSe62zCGdXISejL9MxDIwcRgEGZpO5OV+j7t8mNGMTe6Opl1t/3Z/1RUJaQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.286", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h+WWGek9beZ6i1qbIjjwYEFbs46D6qumjSBc6cLsEvnufcoi7mc0iBwbFSh9yRb3upxoi4ZTJdl53T3gLTdUQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.286", "", { "os": "linux", "cpu": "arm64" }, "sha512-fG8Cqx53jkFyEL86ETA0tdLH3p06yFQKHcoxnMpiU+VmJ5g6uGFhsUZbJ6gFvuT3EgyMcNI2kZwhCCDF/DTnpQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.286", "", { "os": "linux", "cpu": "x64" }, "sha512-kNczbWhWJ1G8sPRZd4Nsx/Ozr/kx16lT2NGp/EEmTV4Hn732xfLkUOlg+tFcS78i6lOPBknjDB/cz/J5Ha4wDQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.286", "", { "os": "linux", "cpu": "x64" }, "sha512-WeO/wG2uPh95BhOQi1IsIECdW5gJgC1Q9xtGw4RjV04it1fBMAbUe7aVwP4DQgH8PHnmduA7dRnLtbnZnrGsjg=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.286", "", { "os": "win32", "cpu": "arm64" }, "sha512-N4p7Gw5Qg3q9+Y5sEht1cIHHZkmBAUfWXJnqfmDD7N8twqF3XNVK3w65hDLqli4i0ttuZu79bGmRGkgNGm9Imw=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.286", "", { "os": "win32", "cpu": "x64" }, "sha512-pg35GRPBKyviod0i8Z3EVMzDnTiiiucuWUbyH1bVIFFN0UWCQQ+PRUJ15qrPKjL6+vlFxOX2ei9FfsWYjGYZwA=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.131.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], "@conventional-changelog/template": ["@conventional-changelog/template@1.4.0", "", {}, "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg=="], @@ -309,6 +338,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -341,19 +372,21 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], - "@tanstack/ai": ["@tanstack/ai@0.54.0", "", { "dependencies": { "@ag-ui/core": "0.1.1-canary.beta.0", "@standard-schema/spec": "^1.1.0", "@tanstack/ai-event-client": "^0.11.3", "@tanstack/ai-utils": "^0.4.0", "partial-json": "^0.1.7" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-Hc7Yc7L+2n/dr11GwAT/QSbGwIxy5bhRe99yu1jAhGaBx28Ul3AaTLcGTyEj2aNeih00AUtF0KG+IOqJBSsj8A=="], + "@tanstack/ai": ["@tanstack/ai@0.64.0", "", { "dependencies": { "@ag-ui/core": "1.0.0", "@standard-schema/spec": "^1.1.0", "@tanstack/ai-event-client": "^0.13.0", "@tanstack/ai-utils": "^0.4.1", "partial-json": "^0.1.7" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-8/ambmqJIGgwTkNad5/y4R5s9kwAr4ylDVeQVLDxSvkzp9FydL/QkVKsQuLWbuG9wu66UQ0GkNWmlWwxQojObg=="], + + "@tanstack/ai-acp": ["@tanstack/ai-acp@0.3.20", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.25.0" }, "peerDependencies": { "@tanstack/ai": "^0.64.0", "@tanstack/ai-sandbox": "^0.5.18" } }, "sha512-Sxt9VJjrFahUp/MNsgP3xoDEest8jn8MrEJ+gAvgzBeVjjjPE+zFOtpu0yLgkg1XaP9ssAU3XTh7niT2sdoLiQ=="], - "@tanstack/ai-event-client": ["@tanstack/ai-event-client@0.11.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1" } }, "sha512-K5Nw05TG2NPJNunV7LI/5m7S97XHHdxN7vwTYcbKbmA5txlToaiF4wCfYBT406m7BOeGlLHZJpr3m1M6Vaai4g=="], + "@tanstack/ai-event-client": ["@tanstack/ai-event-client@0.13.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1" } }, "sha512-qGN7saScQHqDd06DE5CNG+wl1O9v6anwsGCCeP4N7zFbZd5k9/2C1u4cdJ2+Qxl2aA8drusLFZMhY/ljhBJqLQ=="], - "@tanstack/ai-opencode": ["@tanstack/ai-opencode@0.4.5", "", { "dependencies": { "@opencode-ai/sdk": "^1.17.4" }, "peerDependencies": { "@tanstack/ai": "^0.54.0", "@tanstack/ai-sandbox": "^0.5.7" } }, "sha512-RhS/o89BbF0rCCytJ9xa1dMUUXNSpqekc5JWhog+a6mqpdDswB2XqPfG1FG6wA2maL3w4A8ojnUjl9jQ1VBF+A=="], + "@tanstack/ai-opencode": ["@tanstack/ai-opencode@0.4.15", "", { "dependencies": { "@opencode-ai/sdk": "^1.17.4" }, "peerDependencies": { "@tanstack/ai": "^0.64.0", "@tanstack/ai-sandbox": "^0.5.18" } }, "sha512-rJji7c03ab82JIhgK3JS21Fsik6WEsG+JmqXGCB+IEzc5TdJXssnanD5aSXO4qkQZKdajyAUnk41BY6x3laEzg=="], - "@tanstack/ai-sandbox": ["@tanstack/ai-sandbox@0.5.7", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/ai-skills": "^0.1.3" }, "peerDependencies": { "@ngrok/ngrok": "^1.0.0", "@tanstack/ai": "^0.54.0", "@tanstack/ai-persistence": "^0.5.7", "vitest": "^4.1.10" }, "optionalPeers": ["@ngrok/ngrok", "@tanstack/ai-persistence", "vitest"] }, "sha512-lQlDLSYKCkW6Cq7dOtPU8jBlSIG6ApPySbo8NTZajWBQSS1kDX9HJwOUjIKWC3aHGa90fPv4CaR1xzvTKmUj4w=="], + "@tanstack/ai-sandbox": ["@tanstack/ai-sandbox@0.5.18", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/ai-skills": "^0.1.14" }, "peerDependencies": { "@ngrok/ngrok": "^1.0.0", "@tanstack/ai": "^0.64.0", "@tanstack/ai-persistence": "^0.7.2", "vitest": "^4.1.10" }, "optionalPeers": ["@ngrok/ngrok", "@tanstack/ai-persistence", "vitest"] }, "sha512-42geKy+uURfKEOZ57rps5/7Lp4Y53mAIwcdBC4PtIyEZuL0EnKb0Q6fKa+7SvbLcw8nURCtn2DJARktyrf4fBw=="], - "@tanstack/ai-sandbox-local-process": ["@tanstack/ai-sandbox-local-process@0.2.5", "", { "peerDependencies": { "@tanstack/ai-sandbox": "^0.5.6" } }, "sha512-3ke53I2EJ2RKVCOmxtOiV6V7zRglvB+uRxrNXm/VMrCiI81pq6D+/EDxmy38UpfnxjRPPxinom2D8r8d16Svtw=="], + "@tanstack/ai-sandbox-local-process": ["@tanstack/ai-sandbox-local-process@0.2.6", "", { "peerDependencies": { "@tanstack/ai-sandbox": "^0.5.15" } }, "sha512-RID+S1STNDdP8icFUBCrdFqZDNk6ett/JGIwNmsSUwCnYYTMM4toxWynoUKwmVuI0CUprZBInof+f4ylAiDIiw=="], - "@tanstack/ai-skills": ["@tanstack/ai-skills@0.1.3", "", { "dependencies": { "@tanstack/ai": "^0.54.0" }, "peerDependencies": { "vitest": "^4.1.10", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["vitest"] }, "sha512-Hxg6Rbkqt8u7bvL3hh6oEzDAtAR7TA+y7VJe/Jp2lZUnPJYQnZZR8tTzyzpDzCuT2eH1frceXNNx2hn1enbKjQ=="], + "@tanstack/ai-skills": ["@tanstack/ai-skills@0.1.14", "", { "dependencies": { "@tanstack/ai": "^0.64.0" }, "peerDependencies": { "vitest": "^4.1.10", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["vitest"] }, "sha512-yRGJC7kUb1QZPQ5AutivhASyW7oCAlcbi6FqYTvn7N+Izlkr3kZmmGUjbiLYn7/9rENWDH1bI/cOhJlUH2XULA=="], - "@tanstack/ai-utils": ["@tanstack/ai-utils@0.4.0", "", {}, "sha512-xqvAgPJkIuGk1m+jZKyb7vBTQIaBmUD9EVzlPkDWWMp80n69uwL0TnBZCVk+OwL9goTQiFy648dnBfLsiJgl6A=="], + "@tanstack/ai-utils": ["@tanstack/ai-utils@0.4.1", "", {}, "sha512-B3PGn2WYiRtivZCt7MUN2iXeoK9fwZhgdbrPIz5hPqVR8r8sNDZMmMqtiOkp7sxzPvVlCBItFVHw7QpZ9g6e9w=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.4", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw=="], @@ -537,6 +570,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], @@ -591,6 +626,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -717,6 +754,8 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -993,6 +1032,8 @@ "split2": ["split2@1.0.0", "", { "dependencies": { "through2": "~2.0.0" } }, "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stream-combiner2": ["stream-combiner2@1.1.1", "", { "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw=="], @@ -1045,6 +1086,8 @@ "traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -1103,12 +1146,16 @@ "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], - "zod": ["zod@4.6.4", "", {}, "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "@actions/http-client/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], + "@agentclientprotocol/claude-agent-acp/@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.6.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.6.4", "", {}, "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA=="], + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], "@octokit/plugin-retry/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], @@ -1137,6 +1184,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/ai-acp/@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.25.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jx2rF3bdpGwZ75Q/meyEDLLbYmbtxk82Uh9hDCdxDvcEedBnNSF5hZAnL/kJR5VNz56JqwOmqnAqasC84MwwkQ=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/docs/findings/13-acp-agents.md b/docs/findings/13-acp-agents.md new file mode 100644 index 0000000..1db7a9c --- /dev/null +++ b/docs/findings/13-acp-agents.md @@ -0,0 +1,164 @@ +# 13 — Driving opencode and Claude Code over ACP + +2026-10-02. Script: `spikes/13-acp-agents.ts`, adapter: `src/runtime/acp-adapter.ts` (spike +grade). Versions: `@agentclientprotocol/sdk` 1.7.0, `@agentclientprotocol/claude-agent-acp` +0.85.1 (Claude Code 2.1.280), opencode 1.18.31, `@tanstack/ai` 0.64.0, `@tanstack/ai-acp` +0.3.20. Raw evidence in `spikes/out/` (ignored; regenerate with the script). + +## Question + +Can factory replace `@tanstack/ai-opencode` with one ACP adapter — the approach the sibling +project canvas proved (canvas findings 01, 04) — and run workflows on opencode **and** Claude +Code, with the model chosen by the workflow? Before an ADR, four things had to be answered: +model selection, token usage, structured output, and headless permission and cancel. + +## Setup + +The adapter is canvas's `AgentManager.open()` cut down to one session per `stream()` call: +spawn the agent over stdio, `initialize`, `session/new`, set the model with +`session/set_config_option`, `session/prompt`, kill the process. ACP updates become AG-UI +chunks through `@tanstack/ai-acp`'s `translateAcpStream`. It implements the existing +`AgentAdapter` (ADR 0012), so the spike runs the **real** runtime: `startRun`, +`AgentRuntime`, `agent-step.ts`, `resolveOutput`. Nothing in `runtime/` changed. + +The workflow (`acp-hello`) runs in a scratch git repo with one `math.ts`: + +1. `implement`: "add `multiply(a, b)` to math.ts" (unstructured, edits a file). +2. `ctx.exec(git status --short)`. +3. `report`: "summarise the change", with `output: Struct({summary, changedFiles})`. + +Bumping `@tanstack/ai` 0.54 → 0.64 (plus `ai-opencode` 0.4.15 and `ai-sandbox` 0.5.18, which +`ai-acp` requires) left typecheck clean and all 347 `bun test` green, before any adapter code. + +## Results + +| run | model | outcome | implement | report | structured output | +|---|---|---|---|---|---| +| opencode | `opencode/big-pickle` (its default) | completed | 13.4 s | 7.5 s | tier 1 | +| opencode | `opencode-go/glm-5.3-flash` (switched) | completed | 12.1 s | 23.0 s | tier 1 | +| Claude Code | `sonnet` (switched from `haiku`) | completed | 5.8 s | 6.3 s | tier 1 | +| Claude Code | `no-such-model` | step failed, before any prompt | — | — | — | + +Every run changed only `math.ts` (`git status --short --ignored` shows ` M math.ts` and +nothing else: no `opencode.json`, no `.claude/`, no `.tanstack-projected-*`). + +### 1. Model selection works, and model ids are per agent + +Both agents expose the model as an ACP config option (`category: "model"`), and setting it +takes effect: the option's `currentValue` in the `set_config_option` reply is the new model, +and Claude's `usage_update._meta["_claude/model"]` reports `claude-sonnet-5-5` for a `sonnet` +step. + +- **Ids are the agent's own.** Claude offers 12 (`default`, `opus`, `sonnet`, `haiku`, + `claude-fable-5-1`, `claude-sonnet-5`, …); opencode offers 701. +- **The default comes from the host.** Claude started on `haiku` (the host's + `~/.claude/settings.json`), opencode on `opencode/big-pickle`. So a workflow that sets no + model runs on whatever the operator's machine says. +- **opencode's ids are not the ones factory uses.** The first run asked for + `opencode-go/big-pickle`, the id AGENTS.md recommends, and failed: under ACP the model is + `opencode/big-pickle`. The adapter refuses an unoffered id, so this surfaced as + `opencode has no model "opencode-go/big-pickle" (701 offered, e.g. …)` at +1.9 s, not as + a silent run on the default. The same check failed Claude on `no-such-model`. +- **The other options depend on the model**, as canvas found. With `big-pickle`, opencode + offers `model` and `mode` only. After the switch to `glm-5.3-flash` it adds `effort` + (`thought_level`, `low`). Claude adds `effort` once `sonnet` is set. + +### 2. Usage: same fields, different meaning per agent + +`PromptResponse.usage` (still marked UNSTABLE in the ACP spec) was present on every completed +turn from both agents. `translateAcpStream` maps it onto `RUN_FINISHED.usage`, so +`AgentStepFinished.usage` is filled with no runtime change. But the numbers mean different +things: + +| | opencode | Claude Code | +|---|---|---| +| `PromptResponse.usage` covers | the **last** assistant message (`input 139 + cached 13871 ≈ usage_update.used 14010`) | the **whole turn**, summed (`cachedRead 36386` over a ~18k context = two model calls) | +| `cachedWriteTokens` | absent | present (18499); factory's schema drops it | +| `usage_update.used / size` | context in use / window (14010 / 200000) | same (18551 / 1000000) | +| `usage_update.cost` | `0 USD` (free model) | per-session USD (`0.085`, `0.045`) | + +opencode under ACP keeps the semantics `AgentStepFinished.usage` already documents (last +message only). Claude reports more: the turn's true spend. A single field now means two +things depending on the agent. `usage_update` brings two things factory does not have yet: +the context in use, measured rather than derived (`agentStepContextTokens`), and a real cost +figure for Claude. + +### 3. Structured output: prompt injection is enough for both agents + +The adapter appends `appendOutputSchemaInstruction` (from `@tanstack/ai/adapter-internals`, +the same text `acpCompatible` uses) and parses the last assistant message with +`parseJsonFromAssistantText`. It emits a `structured-output.complete` CUSTOM chunk and a +`structuredOutput` signal. All three `report` steps answered with bare JSON that decoded +against the Effect Schema on tier 1. The runtime's tier-2 fallback was never needed. This is +a small sample: three structured steps, all with an easy schema. + +### 4. Headless permission: answered by the client, nothing written + +- **Claude Code asked once per run**, for `Edit math.ts`, offering `allow-once` + (`allow_once`), `allow-with-updates` (`allow_always`) and `reject`. The adapter picked + `allow_always`, and the edit landed. +- **"Allow always" did not touch the workspace.** No `.claude/settings.local.json` was + written; the tree stayed clean. +- **opencode asked nothing.** Its default policy asks only outside the project, which no step + needed. + +So #24's policy ("never ask, allow") moves into the `requestPermission` callback, where it +applies to every agent. `opencode.json` and `prepareWorkspace`'s only job go away. One +untested case: an opencode ask for a path outside the project (`external_directory`) should +now be answered by the callback, where it used to deadlock. The spike never triggered one. + +### 5. Cancel is immediate and leaves no process behind + +`cancel` mode starts a long task, cancels after 20 s, and walks the agent's process tree +(`pgrep -P`) beforehand: + +| | time to `RunCancelled` | `settled` | agent `stopReason` | processes alive 3 s later | +|---|---|---|---|---| +| opencode | 2 ms | 70 ms | `cancelled` | 0 of 1 | +| Claude Code | 3 ms | 13 ms | `cancelled` | 0 of 2 (`claude-agent-acp` and its `claude` child) | + +The adapter sends ACP `session/cancel` on abort and kills the child after a 2 s grace (and in +`finally`). Both agents settled the turn as `cancelled` first. This replaces the +abort → HTTP `session.abort()` → generator `finally` chain described in `agent-step.ts`'s +module comment. `AGENT_TEARDOWN_GRACE_MS` (1 s) was not reached. + +### 6. Startup cost per step + +| | spawn → `initialize` | → `session/new` | → model set | +|---|---|---|---| +| opencode | 1.2–1.5 s | 1.7–1.9 s | +6–13 ms | +| Claude Code | 0.14 s | 0.8 s | +0.7 s | + +About 2 s per step on opencode and 1.5 s on Claude, small next to the 6–23 s steps measured +here. ADR 0007's free-port probe and the `opencode serve` readiness wait are gone. + +### 7. Chunks: same AG-UI types, coarser tool names + +Both agents produced only AG-UI types the SPA already folds (`RUN_*`, `TEXT_MESSAGE_*`, +`TOOL_CALL_*`, `CUSTOM`). The SPA has no tool-name special-casing (no match for `toolName`, +`bash` or `execute` in `src/web`). Two differences from the opencode corpus: + +- **Tool names are ACP tool kinds**: `read`, `edit`, `execute` for both agents, where opencode + used its own names (`read`, `bash`, …). The descriptive title (`Edit math.ts`) does not + reach `TOOL_CALL_START`. +- **CUSTOM names change**: `acp.session-id` replaces `opencode.session-id`, and the + `sandbox.file` chunks are gone, so their back-dated timestamps (finding 1) go with them. + Factory itself no longer matches either name (ADR 0012 §2), but the replay corpus holds the + old ones. + +**Not checked:** how these transcripts look in the browser. + +## What this does not settle + +- **Usage semantics.** Should `AgentStepFinished.usage` stay "last message", or become "turn + total" (which opencode cannot give over ACP)? Should `cachedWriteTokens`, context and cost + get fields? This is a schema decision for the ADR. +- **Host configuration leaking into runs.** Claude loaded the host's settings (default model + `haiku`). Whether runs should use `settingSources: ["project"]` (via + `_meta.claudeCode.options`) was not tried. +- **Permission `mode`.** `permissionMode` (#39) maps to the `mode` option (Claude `default / + acceptEdits / plan / auto / bypassPermissions`, opencode `build / plan`). The spike left it + at the default and relied on the callback, so `bypassPermissions` was not run. +- **Session resume** (`session/load`) was not exercised; every step is a fresh session. +- **Long or messy runs.** All steps were small. Tier-2 output, `RUN_ERROR` from an agent, and + an agent crashing mid-turn (the `exited` race is wired but was not triggered) are untested. diff --git a/docs/findings/README.md b/docs/findings/README.md index ef817ef..36ed33f 100644 --- a/docs/findings/README.md +++ b/docs/findings/README.md @@ -79,3 +79,9 @@ Conclusions → [`adr/0006-raw-typescript-distribution.md`](../adr/0006-raw-type | [`12-ready-sweep-live-leg.md`](./12-ready-sweep-live-leg.md) | Issue #18: the Ready sweep as a scheduled wrapper workflow, proven against the real project board before the legacy dispatcher was deleted | Real board query + real at dispatch, per-issue `issue:1` dedupe key, `DispatchCollision` + a visible failed run on a second consecutive tick; the agent child's round trip itself was blocked by a provider-gateway opt-in wall out of session's control, and the leg surfaced a genuinely unresolvable cancel on a wedged stream (phase 6 candidate). | Conclusions → D43 ([`decisions.md`](../decisions.md)); ADR 0004 carries a supersession note. + +## Agent runtime — ACP + +| Document | Subtask | Headline finding | +|---|---|---| +| [`13-acp-agents.md`](./13-acp-agents.md) | Spike: one ACP adapter for opencode and Claude Code, through the real runtime | Both agents ran the hello-shaped workflow end to end with a workflow-chosen model, tier-1 structured output, permission answered client-side and a 2–3 ms clean cancel; model ids are per agent (opencode's big-pickle is `opencode/big-pickle`), and usage means "last message" on opencode but "whole turn" on Claude. | diff --git a/package.json b/package.json index d345968..6c95040 100644 --- a/package.json +++ b/package.json @@ -28,14 +28,17 @@ "check": "bun run format:check && bun run lint && bun run typecheck && bun run test" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.85.1", + "@agentclientprotocol/sdk": "^1.7.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@radix-ui/react-slot": "^1.3.3", "@shadcn/react": "^0.3.1", - "@tanstack/ai": "^0.54.0", - "@tanstack/ai-opencode": "^0.4.5", - "@tanstack/ai-sandbox": "^0.5.7", - "@tanstack/ai-sandbox-local-process": "^0.2.5", + "@tanstack/ai": "^0.64.0", + "@tanstack/ai-acp": "^0.3.20", + "@tanstack/ai-opencode": "^0.4.15", + "@tanstack/ai-sandbox": "^0.5.18", + "@tanstack/ai-sandbox-local-process": "^0.2.6", "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.36", "bun-plugin-tailwind": "^0.1.2", diff --git a/spikes/13-acp-agents.ts b/spikes/13-acp-agents.ts new file mode 100644 index 0000000..422a962 --- /dev/null +++ b/spikes/13-acp-agents.ts @@ -0,0 +1,247 @@ +/** + * SPIKE (finding 13): run a hello-shaped workflow through factory's real + * runtime (`startRun`, `AgentRuntime`, `agent-step.ts`) on the ACP adapter, + * against opencode and Claude Code, and measure what the ADR needs to know: + * model selection, usage, structured output, permission asks, cancel. + * + * bun spikes/13-acp-agents.ts run + * bun spikes/13-acp-agents.ts cancel [afterMs] + * bun spikes/13-acp-agents.ts bad-model + * + * Writes `spikes/out/--/{events,diagnostics}.ndjson`. + */ + +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RunEvent } from "../src/events"; +import { + ACP_AGENTS, + acpAdapter, + type AcpAgentKind, + type AcpDiagnostic, +} from "../src/runtime/acp-adapter"; +import { makeAgentRuntime } from "../src/runtime/agent-runtime"; +import { startRun } from "../src/runtime/run"; +import { defineWorkflow, Schema } from "../src/workflow"; + +const [mode = "run", agentArg = "opencode", modelArg, afterArg] = process.argv.slice(2); +const agent = agentArg as AcpAgentKind; +if (!(agent in ACP_AGENTS)) throw new Error(`unknown agent ${agentArg}`); +const model = mode === "bad-model" ? "no-such-model" : modelArg; +if (model === undefined) throw new Error("model required"); + +const sh = async (cwd: string, ...cmd: string[]) => { + const p = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" }); + const out = await new Response(p.stdout).text(); + if ((await p.exited) !== 0) throw new Error(`${cmd.join(" ")} failed`); + return out; +}; + +/** A throwaway git repo with one small file to change. */ +async function scratchRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), `factory-acp-${agent}-`)); + await writeFile( + join(dir, "math.ts"), + "export function add(a: number, b: number): number {\n return a + b;\n}\n", + ); + await writeFile(join(dir, "README.md"), "# math\n\nTiny arithmetic helpers.\n"); + await sh(dir, "git", "init", "-q", "-b", "main"); + await sh(dir, "git", "add", "."); + await sh( + dir, + "git", + "-c", + "user.name=spike", + "-c", + "user.email=spike@local", + "commit", + "-qm", + "init", + ); + return dir; +} + +const Report = Schema.Struct({ + summary: Schema.String, + changedFiles: Schema.Array(Schema.String), +}); + +const hello = defineWorkflow("acp-hello", { + input: Schema.Struct({ long: Schema.Boolean }), + output: Schema.Struct({ + changedFiles: Schema.Int, + report: Schema.NullOr(Report), + outputTier: Schema.String, + }), + agent: { model }, + run: async (ctx, input) => { + await ctx.agent( + "implement", + input.long + ? `Write a file primes.ts with a function that returns the first 2000 primes, then a file primes.test.ts with 50 separate bun tests for it, one per prime index, each written out by hand. Then run the tests with bun test. Then explain every prime below 500 in prose.` + : `You are working in a git checkout of this project. + +Task: add a function \`multiply(a, b)\` to math.ts next to \`add\`, in the same style. + +Make the change. When you are done, stop — do not run git commands and do not commit.`, + ); + const status = await ctx.exec(["git", "status", "--short"]); + const changedFiles = status.stdout.split("\n").filter((l) => l.trim() !== "").length; + + const report = await ctx.agent( + "report", + `Summarise the change you can see in this repository (run \`git status\` and \`git diff\` to look). List the paths of the changed files.`, + { output: Report }, + ); + const outputTier = + report.output === undefined + ? "none" + : report.finalText.trim().startsWith("{") + ? "1-or-2" + : "1"; + return { changedFiles, report: report.output ?? null, outputTier }; + }, +}); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const out = join(import.meta.dir, "out", `${mode}-${agent}-${stamp}`); +await mkdir(out, { recursive: true }); +const events: RunEvent[] = []; +const diagnostics: Array = []; +const pids: number[] = []; +let step = -1; + +const adapter = acpAdapter(ACP_AGENTS[agent], (event) => { + if (event.kind === "spawned") { + step += 1; + pids.push(event.pid); + } + diagnostics.push({ ...event, step }); +}); + +const dir = await scratchRepo(); +console.log(`${mode} ${agent} model=${model} dir=${dir}`); +const runtime = makeAgentRuntime(adapter); +const t0 = Date.now(); +const handle = startRun(hello, runtime, { + runId: `spike-${stamp}`, + dir, + input: { long: mode === "cancel" }, + onEvent: (event) => { + events.push(event); + const p = event.payload; + if (p._tag !== "AgentChunk") console.log(`+${Date.now() - t0}ms ${p._tag}`); + }, +}); + +/** The agent process and every descendant of it. */ +async function processTree(root: number): Promise { + const kids = (await sh("/", "sh", "-c", `pgrep -P ${root} || true`)) + .split("\n") + .filter(Boolean) + .map(Number); + return [root, ...(await Promise.all(kids.map(processTree))).flat()]; +} +const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +let cancelReport: unknown; +if (mode === "cancel") { + await Bun.sleep(Number(afterArg ?? 15000)); + const tree = await processTree(pids.at(-1)!); + const before = Date.now(); + await handle.cancel(); + const outcome = await handle.result; + const cancelledMs = Date.now() - before; + await handle.settled; + const settledMs = Date.now() - before; + await Bun.sleep(3000); + cancelReport = { + outcome: outcome.outcome, + tree, + cancelledMs, + settledMs, + aliveAfter3s: tree.filter(alive), + }; + console.log("cancel:", cancelReport); +} else { + const outcome = await handle.result; + console.log("outcome:", JSON.stringify(outcome, null, 2)); +} +await runtime.dispose(); + +await writeFile(join(out, "events.ndjson"), events.map((e) => JSON.stringify(e)).join("\n") + "\n"); +await writeFile( + join(out, "diagnostics.ndjson"), + diagnostics.map((d) => JSON.stringify(d)).join("\n") + "\n", +); + +// --- summary --------------------------------------------------------------- +for (let s = 0; s <= step; s++) { + const d = diagnostics.filter((x) => x.step === s); + const at = (kind: string) => d.find((x) => x.kind === kind)?.at; + const spawned = at("spawned")!; + const firstUpdate = at("update"); + const rel = (t?: number) => (t === undefined ? "-" : `${t - spawned}ms`); + const session = d.find((x) => x.kind === "session"); + const model0 = + session?.kind === "session" + ? session.configOptions.find((o) => o.category === "model")?.currentValue + : undefined; + const configured = d.find((x) => x.kind === "configured"); + const modelAfter = + configured?.kind === "configured" + ? configured.configOptions.find((o) => o.category === "model")?.currentValue + : model0; + const done = d.find((x) => x.kind === "done"); + const usageUpdates = d.flatMap((x) => + x.kind === "update" && x.update.sessionUpdate === "usage_update" ? [x.update] : [], + ); + const updateKinds: Record = {}; + for (const x of d) + if (x.kind === "update") + updateKinds[x.update.sessionUpdate] = (updateKinds[x.update.sessionUpdate] ?? 0) + 1; + console.log(`\n== step ${s}`); + console.log( + ` init ${rel(at("initialized"))} · session ${rel(at("session"))} · configured ${rel(at("configured"))} · first update ${rel(firstUpdate)} · done ${rel(at("done"))}`, + ); + console.log(` model: default=${String(model0)} → ${String(modelAfter)}`); + console.log( + ` config options: ${session?.kind === "session" ? session.configOptions.map((o) => `${o.id}(${o.category ?? "-"})`).join(", ") : "-"}`, + ); + console.log( + ` permission asks:`, + d + .filter((x) => x.kind === "permission") + .map((x) => x.kind === "permission" && `${x.title} → ${x.chosen}`), + ); + console.log( + ` done:`, + done?.kind === "done" ? { stopReason: done.stopReason, usage: done.usage } : "-", + ); + console.log( + ` usage_update (last):`, + usageUpdates.at(-1) ?? "-", + `(${usageUpdates.length} total)`, + ); + console.log(` update kinds:`, updateKinds); +} +const finished = events.flatMap((e) => (e.payload._tag === "AgentStepFinished" ? [e.payload] : [])); +for (const f of finished) + console.log( + `\nAgentStepFinished ${f.name}: ${f.outcome} ${f.durationMs}ms chunks=${f.chunkCount} session=${f.sessionId} usage=${JSON.stringify(f.usage)} error=${f.error ?? "-"}`, + ); +const chunkTypes: Record = {}; +for (const e of events) + if (e.payload._tag === "AgentChunk") + chunkTypes[e.payload.chunkType] = (chunkTypes[e.payload.chunkType] ?? 0) + 1; +console.log("chunk types:", chunkTypes); +console.log(`\nwritten to ${out}`); +process.exit(0); diff --git a/src/runtime/acp-adapter.ts b/src/runtime/acp-adapter.ts new file mode 100644 index 0000000..e54e577 --- /dev/null +++ b/src/runtime/acp-adapter.ts @@ -0,0 +1,338 @@ +/** + * SPIKE (finding 13): an `AgentAdapter` that drives any ACP-speaking coding + * agent over stdio — opencode (`opencode acp`) and Claude Code + * (`@agentclientprotocol/claude-agent-acp`) — in place of `@tanstack/ai-opencode`. + * + * Ported from canvas's `src/server/agents.ts` (canvas findings 01 and 04): + * `@agentclientprotocol/sdk` speaks ACP directly, because `chat()` + + * `acpCompatible` drops `session/new`'s `configOptions` and cannot call + * `session/set_config_option` — and that is the only way to pick a model in + * either agent. AG-UI chunks still come from `@tanstack/ai-acp`'s + * `translateAcpStream`, so stored chunks and the SPA stay AG-UI (ADR 0003 §2). + * + * One agent process per `stream()` call: a factory step is one fresh session + * and one prompt, so canvas's idle timer and reconnect logic have no job here. + * Headless permission is answered here, by the client, for either agent: + * every ask is allowed (the #24 policy, without writing `opencode.json`). + */ + +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type PlanEntry, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionUpdate, +} from "@agentclientprotocol/sdk"; +import { + appendOutputSchemaInstruction, + parseJsonFromAssistantText, +} from "@tanstack/ai/adapter-internals"; +import { + AsyncQueue, + translateAcpStream, + type AcpSessionUpdate, + type AcpStreamEvent, +} from "@tanstack/ai-acp"; +import type { AgentAdapter, AgentAdapterOptions, AgentAdapterYield } from "./agent-adapter"; + +export type AcpAgentKind = "claude" | "opencode"; + +export interface AcpAgentDefinition { + readonly kind: AcpAgentKind; + readonly command: ReadonlyArray; + readonly env?: Record; + /** Agent-specific `_meta` for `session/new`. */ + readonly sessionMeta?: Record; +} + +/** What the spike measures; a production adapter would not have this. */ +export type AcpDiagnostic = + | { readonly kind: "spawned"; readonly pid: number; readonly at: number } + | { readonly kind: "initialized"; readonly at: number; readonly agentInfo: unknown } + | { + readonly kind: "session"; + readonly at: number; + readonly sessionId: string; + readonly configOptions: ReadonlyArray; + } + | { + readonly kind: "configured"; + readonly at: number; + readonly configId: string; + readonly value: string; + readonly configOptions: ReadonlyArray; + } + | { + readonly kind: "permission"; + readonly at: number; + readonly title: string; + readonly options: ReadonlyArray<{ optionId: string; kind: string }>; + readonly chosen: string | null; + } + | { readonly kind: "update"; readonly at: number; readonly update: SessionUpdate } + | { + readonly kind: "done"; + readonly at: number; + readonly stopReason: string; + readonly usage: unknown; + } + | { readonly kind: "exited"; readonly at: number; readonly code: number | null }; + +const claudeAcpBin = Bun.resolveSync( + "@agentclientprotocol/claude-agent-acp/dist/index.js", + import.meta.dir, +); + +export const ACP_AGENTS: Record = { + claude: { kind: "claude", command: ["bun", claudeAcpBin] }, + opencode: { kind: "opencode", command: ["opencode", "acp"] }, +}; + +/** Model choices an option offers, flattened out of its groups. */ +function choicesOf(option: SessionConfigOption): string[] { + if (option.type !== "select") return []; + return option.options.flatMap((entry) => + "group" in entry ? entry.options.map((c) => c.value) : [entry.value], + ); +} + +/** Allow every ask, preferring "always" so the agent stops asking (#24). */ +function headlessAllow(request: RequestPermissionRequest): string | null { + const pick = + request.options.find((o) => o.kind === "allow_always") ?? + request.options.find((o) => o.kind === "allow_once"); + return pick?.optionId ?? null; +} + +/** + * opencode keeps its todo list in `todowrite`'s input and sends no ACP + * `plan` update; read it as one, so every agent has one kind of plan + * (canvas `todo-plan.ts`). + */ +function todoPlan(update: SessionUpdate): SessionUpdate | null { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") + return null; + const input = update.rawInput as { todos?: unknown } | null | undefined; + if (!Array.isArray(input?.todos)) return null; + const entries = input.todos.flatMap((todo: unknown): PlanEntry[] => { + const { content, status } = (todo ?? {}) as Record; + if (typeof content !== "string") return []; + if (status !== "pending" && status !== "in_progress" && status !== "completed") return []; + return [{ content, status, priority: "medium" }]; + }); + return entries.length ? { sessionUpdate: "plan", entries } : null; +} + +export function acpAdapter( + definition: AcpAgentDefinition, + observe: (event: AcpDiagnostic) => void = () => {}, +): AgentAdapter { + return { + // Permission is answered by the client (`headlessAllow`); nothing to write. + async prepareWorkspace(): Promise {}, + + async *stream(options: AgentAdapterOptions): AsyncIterable { + const child = Bun.spawn([...definition.command], { + cwd: options.dir, + env: { ...process.env, ...definition.env }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + observe({ kind: "spawned", pid: child.pid, at: Date.now() }); + + let stderr = ""; + void (async () => { + for await (const chunk of child.stderr.pipeThrough(new TextDecoderStream())) + stderr = (stderr + chunk).slice(-4096); + })().catch(() => undefined); + + const queue = new AsyncQueue(); + const exited = child.exited.then((code) => { + observe({ kind: "exited", at: Date.now(), code }); + const tail = stderr.trim().split("\n").at(-1); + throw new Error(`${definition.kind} exited (code ${code})${tail ? `: ${tail}` : ""}`); + }); + exited.catch((cause: unknown) => queue.fail(cause)); + const race = (work: Promise) => Promise.race([work, exited]); + + const acp = new ClientSideConnection( + () => ({ + requestPermission: async (request): Promise => { + const chosen = headlessAllow(request); + observe({ + kind: "permission", + at: Date.now(), + title: request.toolCall.title ?? "tool call", + options: request.options.map((o) => ({ optionId: o.optionId, kind: o.kind })), + chosen, + }); + return { + outcome: + chosen === null + ? { outcome: "cancelled" } + : { outcome: "selected", optionId: chosen }, + }; + }, + sessionUpdate: async ({ update }) => { + observe({ kind: "update", at: Date.now(), update }); + if (update.sessionUpdate === "config_option_update") return; + if (update.sessionUpdate === "usage_update") return; + queue.push({ kind: "update", update: update as AcpSessionUpdate }); + const plan = todoPlan(update); + if (plan) queue.push({ kind: "update", update: plan as AcpSessionUpdate }); + }, + }), + ndJsonStream( + new WritableStream({ + write: (chunk) => { + void child.stdin.write(chunk); + void child.stdin.flush(); + }, + close: () => void child.stdin.end(), + }), + child.stdout, + ), + ); + + let sessionId: string | undefined; + const onAbort = () => { + if (sessionId !== undefined) void acp.cancel({ sessionId }).catch(() => undefined); + // Give the agent a moment to settle the turn as `cancelled`, then make sure. + setTimeout(() => child.kill(), 2000).unref(); + }; + options.abortController.signal.addEventListener("abort", onAbort, { once: true }); + + try { + const init = await race( + acp.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "factory", version: "0.0.0" }, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }), + ); + observe({ kind: "initialized", at: Date.now(), agentInfo: init.agentInfo }); + + const created = await race( + acp.newSession({ + cwd: options.dir, + mcpServers: [], + ...(definition.sessionMeta && { _meta: definition.sessionMeta }), + }), + ); + sessionId = created.sessionId; + let configOptions = created.configOptions ?? []; + observe({ kind: "session", at: Date.now(), sessionId, configOptions }); + + // The model decides which other options exist, so it goes first and + // fails loudly: a workflow asked for it, and running on another + // model silently is worse than not running. + const modelOption = configOptions.find((o) => o.category === "model" || o.id === "model"); + if (modelOption === undefined) throw new Error(`${definition.kind} offers no model option`); + if (modelOption.currentValue !== options.model) { + const choices = choicesOf(modelOption); + if (!choices.includes(options.model)) + throw new Error( + `${definition.kind} has no model "${options.model}" (${choices.length} offered, e.g. ${choices.slice(0, 8).join(", ")})`, + ); + const response = await race( + acp.setSessionConfigOption({ + sessionId, + configId: modelOption.id, + value: options.model, + }), + ); + configOptions = response.configOptions; + observe({ + kind: "configured", + at: Date.now(), + configId: modelOption.id, + value: options.model, + configOptions, + }); + } + + const prompt = + options.outputSchema !== undefined + ? appendOutputSchemaInstruction(options.prompt, options.outputSchema) + : options.prompt; + queue.push({ kind: "session", sessionId }); + void acp + .prompt({ sessionId, prompt: [{ type: "text", text: prompt }] }) + .then((response) => { + observe({ + kind: "done", + at: Date.now(), + stopReason: response.stopReason, + usage: response.usage, + }); + queue.push({ + kind: "done", + stopReason: response.stopReason, + ...(response.usage && { usage: response.usage }), + }); + queue.end(); + }) + .catch((cause: unknown) => queue.fail(cause)); + + const runId = crypto.randomUUID(); + const chunks = translateAcpStream(queue, { + model: options.model, + runId, + threadId: options.threadId, + genId: () => crypto.randomUUID(), + labels: { + sessionIdEvent: "acp.session-id", + contentEvent: "acp.message-content", + planEvent: "acp.plan", + }, + }); + + let lastText = ""; + let finished: unknown; + for await (const chunk of chunks) { + const record = chunk as { type: string; delta?: unknown; message?: unknown }; + if (record.type === "TEXT_MESSAGE_START") lastText = ""; + else if (record.type === "TEXT_MESSAGE_CONTENT" && typeof record.delta === "string") + lastText += record.delta; + if (record.type === "RUN_FINISHED" && options.outputSchema !== undefined) { + // Hold it: the structured output goes before the run's end. + finished = chunk; + continue; + } + if (record.type === "CUSTOM" && (chunk as { name?: string }).name === "acp.session-id") { + yield { chunk, signal: { _tag: "sessionId", value: sessionId } }; + } else if (record.type === "RUN_ERROR") { + const message = typeof record.message === "string" ? record.message : "agent error"; + yield { chunk, signal: { _tag: "runError", value: message } }; + } else yield { chunk }; + } + + if (options.outputSchema !== undefined) { + try { + const object = parseJsonFromAssistantText(lastText); + yield { + chunk: { + type: "CUSTOM", + name: "structured-output.complete", + value: { object, raw: lastText }, + timestamp: Date.now(), + }, + signal: { _tag: "structuredOutput", value: object }, + }; + } catch { + // No JSON in the last message: the runtime's tier-2 re-parse of + // `finalText` gets the same text and reports the miss. + } + if (finished !== undefined) yield { chunk: finished }; + } + } finally { + options.abortController.signal.removeEventListener("abort", onAbort); + child.kill(); + } + }, + }; +} From 8c4cc3aecb8a97de21bfae8024a04ef3ce2d3abb Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 17:45:03 +0000 Subject: [PATCH 2/2] docs(adr): propose 0013, agents over ACP One ACP adapter for opencode and Claude Code. Agent and model become separate fields and factory always sends the model. Runs ignore host agent settings by default (configurable per agent). Headless permission is answered by the client, and context and cost get their own fields on AgentStepFinished. Adds the settings-isolation probe to finding 13. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0013-agents-over-acp.md | 232 +++++++++++++++++++++++++++++++ docs/findings/13-acp-agents.md | 29 ++++ docs/findings/README.md | 2 + 3 files changed, 263 insertions(+) create mode 100644 docs/adr/0013-agents-over-acp.md diff --git a/docs/adr/0013-agents-over-acp.md b/docs/adr/0013-agents-over-acp.md new file mode 100644 index 0000000..d074095 --- /dev/null +++ b/docs/adr/0013-agents-over-acp.md @@ -0,0 +1,232 @@ +# 0013. Agents over ACP — one adapter for opencode and Claude Code + +## Status + +Proposed, 2026-10-02. Based on the spike in finding 13 (`docs/findings/13-acp-agents.md`), +which ran a two-step workflow through the real runtime on both agents. The approach comes from +the sibling project canvas, where it runs in production use (canvas findings 01, 04, 06, 12). + +Supersedes ADR 0007 (per-run free port). Amends ADR 0012: §3 (workspace preparation) loses its +only job and §6 (sandbox) is overtaken. §1, §2, §4 and §5 stand. Closes #39 and #40 once +implemented. + +## Context + +Factory drives exactly one agent, opencode, through `@tanstack/ai-opencode`: `chat()` boots +`opencode serve` inside a `localProcessSandbox` on a free host port, and the adapter talks to it +over HTTP. ADR 0012 made the adapter a seam so a second agent would be "a normal amount of work", +but the opencode adapter has costs that a second adapter would not fix: + +- **#24's permission deadlock** is patched by writing `opencode.json` into every run tree, so + the workspace carries an agent-specific file. +- **ADR 0007's port race** is patched with a bind-then-close probe. +- **Cancel goes through a long chain**: abort → HTTP `session.abort()` → generator `finally` → + process kill. `agent-step.ts` needs a paragraph and `abortableIterable` to get through it. +- **The model is a provider-qualified constant** (`DEFAULT_MODEL`) with no config level (#40). +- **`permissionMode` is read by nobody** (#39). + +The Agent Client Protocol (ACP) is the open, stdio JSON-RPC protocol coding agents speak to +editors. opencode speaks it natively (`opencode acp`). Claude Code speaks it through Anthropic's +`@agentclientprotocol/claude-agent-acp`. canvas drives both through one generic client. +Finding 13 showed that the same client, behind factory's existing `AgentAdapter` interface: + +- runs both agents end to end; +- selects the model per step; +- gets structured output on the first parse; +- answers permission asks without writing a file; +- cancels in 2–3 ms with no process left behind. + +The finding also surfaced three things this ADR has to decide: + +- **Model ids belong to the agent.** opencode's big-pickle is `opencode/big-pickle`; Claude's + are `sonnet`, `opus`, `claude-sonnet-5`, … +- **An agent left on its own default runs on the host's configuration.** Claude started on the + `haiku` from `~/.claude/settings.json`. +- **Usage means different things per agent.** opencode reports the last message, Claude the + whole turn. Both report context in use, and Claude reports cost. + +## Decision + +### 1. One ACP adapter replaces the opencode adapter + +`runtime/acp-adapter.ts` speaks ACP with `@agentclientprotocol/sdk` directly, as canvas's +`agents.ts` does. It does **not** use `chat()` with `@tanstack/ai-acp`'s `acpCompatible`: that +adapter discards `session/new`'s config options and cannot call `session/set_config_option`, +which is the only way to choose a model in either agent (canvas finding 04). Updates become +AG-UI chunks through `@tanstack/ai-acp`'s `translateAcpStream`, so ADR 0003 §2 and ADR 0012 §1 +hold unchanged: chunks stay opaque AG-UI, and the SPA keeps folding them with `StreamProcessor`. + +The adapter is generic. An agent is a **definition**, and every agent-specific detail lives in +it: + +- the launch command; +- the environment; +- the `_meta` sent with `session/new`; +- how host settings are excluded (§3). + +Factory ships two definitions, `claude` and `opencode`. A third ACP agent is a third definition. +`@tanstack/ai-opencode`, `@tanstack/ai-sandbox` and `-local-process` are removed, together with +the opencode adapter. The opencode adapter is not kept as an alternative: one implementation, +per ADR 0012 §6's own rule. + +### 2. Agent and model are separate fields, and factory always sends the model + +The authoring surface gains `agent` next to `model`, at every level of the existing precedence +chain: + +```ts +ctx.agent("implement", prompt, { agent: "claude", model: "sonnet" }); // call +defineWorkflow("x", { agent: { agent: "opencode", model: "opencode/big-pickle" }, … }); +defineSchedule({ …, agent: { agent: "claude" } }); // schedule +defineConfig({ agent: { default: "claude", models: { claude: "sonnet", opencode: "opencode/big-pickle" } } }); +``` + +**Resolution:** + +- **Agent:** the most specific level that names one, else `config.agent.default`. +- **Model:** the most specific level that names one, counting only the level that chose the + agent and the levels more specific than it. If none of those names a model, it is + `config.agent.models[agent]`. + +In short, a model never carries across a change of agent. A workflow pinned to `sonnet` and +called once with `{ agent: "opencode" }` runs on opencode's configured default model, not on an +invalid `sonnet`. + +**Factory always sends a model.** It never leaves a session on the agent's own default, because +that default comes from the host's settings (finding 13 §1). For the same reason it is not +enough to switch the host's settings off (§3): claude-agent-acp reads the user's model setting +regardless. `DEFAULT_MODEL` is deleted. `factory init` writes `agent.default` and +`agent.models`. An unresolvable model fails the run at start. + +**A model the agent does not offer fails the step before the prompt is sent.** The error names +the agent and lists some of the models it does offer (finding 13 §1). Model ids are passed +verbatim; factory keeps no catalogue of them. + +`AgentStepStarted` records `agent` beside `model`. The field is optional: logs written before +this change have no `agent`. + +### 3. Runs ignore the host's agent settings by default + +A run reads the **project's** agent configuration and not the operator's: the repository's +`CLAUDE.md` / `AGENTS.md`, `.claude/settings.json` and `opencode.json` apply, while `~/.claude` +and `~/.config/opencode` do not. A run should not depend on whose machine it runs on. + +Credentials are not settings: the host's Claude login and opencode's provider auth stay +available. + +This is configurable per agent in `factory.config.ts`: + +```ts +agent: { hostSettings: { claude: "ignore" | "include", opencode: "ignore" | "include" } } +``` + +`"ignore"` is the default. `"include"` is the opt-in for an operator who wants their own skills, +plugins or providers in runs. The settings themselves stay in the agents' own files; factory +does not copy them into its config. + +**Per agent:** + +- **Claude:** `_meta.claudeCode.options.settingSources: ["project", "local"]` (the default is + `["user", "project", "local"]`). Known residue: claude-agent-acp's own settings reader still + applies the user's `model`, `permissions.defaultMode` and `availableModels` (finding 13 + addendum). §2 overrides the model and §4 answers every ask. Only an `availableModels` + allowlist can still narrow what a run may pick, and §2's error message makes that visible. +- **opencode:** the global config directory must be hidden from the opencode process. + `XDG_CONFIG_HOME` pointed at an empty directory works: the global plugins and the custom + `omniroute` provider disappear, while the auth-based `opencode` and `opencode-go` providers + remain. But the variable is inherited by every tool the agent runs, which would hide + `~/.config/gh` (gh's auth) and git's XDG config too. **The exact mechanism is left to the + implementation ticket**: an opencode-specific variable if one exists, otherwise a directory + that leaves those tools' configs in place. +- **Both:** the agent's environment is scrubbed of the parent's `CLAUDE_*` / `CLAUDECODE` + variables. A daemon started from inside a Claude Code session otherwise passes its + effort, subagent model and session ids down to the agents it runs. + +### 4. Headless permission is the client's answer + +The adapter answers every ACP `session/request_permission` with an allow option, preferring +`allow_always` over `allow_once`. This is #24's policy ("a run never waits on a question nobody +can answer") moved from a file opencode reads into a callback that covers every agent. The +following go away: + +- `lib/sandbox-config.ts` (`writeHeadlessPermissions`); +- the `opencode.json` write; +- the `.git/info/exclude` entry; +- `AgentAdapter.prepareWorkspace` and `StartRunOptions.prepareWorkspace`. + +Finding 13 confirmed that Claude's "allow always" leaves the tree untouched. + +`permissionMode` (#39) is **removed** from `AgentCallOptions`. The ACP `mode` options differ per +agent (Claude: `default / acceptEdits / plan / auto / bypassPermissions`; opencode: +`build / plan`), and with every ask answered the remaining difference is behavioural, such as a +read-only plan mode. When a workflow needs that, it can arrive as an explicit `mode` passthrough +validated like the model. It is not added now. + +### 5. Context and cost get their own fields + +`AgentStepFinished` gains two optional fields, filled from ACP `usage_update` (the last one of the +step): + +- `context: { used, size }` — the context in use and the window size, as the agent measured them; +- `cost: { amount, currency }` — what the agent says the step cost. + +opencode reports `cost` as `0 USD` for a free model; it is recorded as reported. + +`usage` stays the four token components from `PromptResponse.usage`. Its meaning is now "as the +agent reports it": the last message on opencode, the whole turn on Claude. The step's `agent` +(§2) says which, and its doc comment says so. Nothing should bill off `usage`; `cost` exists for +that. `agentStepContextTokens` gives way to `context.used` where present, and stays as the +fallback for logs written before this change. + +`usage_update` is not an AG-UI event, so the adapter forwards it as a `CUSTOM` chunk (`acp.usage`) +carrying a new signal, `usage`, with `context` and `cost`. This is the ADR 0012 §2 rule working +as intended: the union grows because factory now has fields for these. The chunk also lets the +SPA show context filling up live. + +### 6. One process per step, over stdio + +Each `ctx.agent` call spawns the agent with `cwd` set to the run's tree and holds one session for +one prompt. The process ends with the step. + +- **Startup:** about 1.5–2 s per step (finding 13 §6), accepted. +- **No session pool:** canvas keeps sessions open because people return to them; a step never + does. +- **Cancel:** sends ACP `session/cancel`, then kills the process after a 2 s grace and in + `finally`. Both agents settle the turn as `cancelled` in milliseconds. +- **Port race:** gone (ADR 0007), since stdio needs no port. +- **Sandbox:** the agent runs on the host in the tree, exactly as under `localProcessSandbox`. + A future container sandbox wraps the command (`docker exec -i …`), which stdio makes simpler + than published ports. + +### 7. Structured output stays prompt-injected + +The adapter appends `appendOutputSchemaInstruction` and parses the last assistant message, then +emits the `structuredOutput` signal. The runtime's tier-2 re-parse (ADR 0002 §3) is unchanged. +An MCP `submit_output` tool would give schema-validated output from both agents (canvas finding +06 shows the per-session MCP server). It is deferred until tier 1 is seen failing in practice. + +## Consequences + +- **Workflows can use Claude Code.** Choosing the agent is a field, and a project can set its + defaults in config. That was the point of the change. +- **The replay corpus must be re-recorded.** The `test/corpus` traces are `@tanstack/ai-opencode` + chunks (`opencode.session-id`, `sandbox.file`, opencode tool names), and the replay adapter + reads them with `extractOpencodeSignal`. New corpora come from the ACP adapter for both agents. + Old run logs stay readable, because chunks are opaque. +- **Transcripts show coarser tool names**: ACP tool kinds (`read`, `edit`, `execute`) instead of + opencode's own. The tool's title (`Edit math.ts`) is in the ACP update but does not reach + `TOOL_CALL_START`; the SPA may want to read it from the chunk's raw event. +- **`agent-step.ts` gets simpler.** The abort-chain commentary and the teardown grace were about + `@tanstack/ai`'s chat engine sitting on an aborted opencode stream. `abortableIterable` stays + until the replay and fake adapters are checked against the simpler contract. +- **New dependencies:** `@agentclientprotocol/sdk`, `@agentclientprotocol/claude-agent-acp` + (bundles the Claude Agent SDK) and `@tanstack/ai-acp`. `@tanstack/ai` moves to 0.64. +- **Claude runs need a Claude login (or API key) on the daemon's host**, and opencode runs need + `opencode` on `PATH`. The daemon checks the configured agents at start and names what is + missing, rather than failing the first step. +- **AGENTS.md's model advice changes**: the preferred test model is `opencode/big-pickle`. +- **Untested until the implementation:** + - an opencode ask outside the project (`external_directory`), now answered by the callback; + - an agent crashing mid-turn (the exit race is wired, never triggered); + - `RUN_ERROR` from an agent; + - whether Claude's `settingSources` also keeps the user's skills and `CLAUDE.md` out. diff --git a/docs/findings/13-acp-agents.md b/docs/findings/13-acp-agents.md index 1db7a9c..9e1f13d 100644 --- a/docs/findings/13-acp-agents.md +++ b/docs/findings/13-acp-agents.md @@ -162,3 +162,32 @@ Both agents produced only AG-UI types the SPA already folds (`RUN_*`, `TEXT_MESS - **Session resume** (`session/load`) was not exercised; every step is a fresh session. - **Long or messy runs.** All steps were small. Tier-2 output, `RUN_ERROR` from an agent, and an agent crashing mid-turn (the `exited` race is wired but was not triggered) are untested. + +## Addendum: switching off the host's settings (probe, same day) + +`session/new` only, with no prompt, comparing the config options each agent reports. + +| | setting | starting model | models offered | +|---|---|---|---| +| Claude Code | default (`settingSources: user, project, local`) | `haiku` | 12 | +| Claude Code | `_meta.claudeCode.options.settingSources: ["project", "local"]` | `haiku` | 12 | +| Claude Code | `settingSources: []` | `haiku` | 12 | +| opencode | default | `opencode/big-pickle` | 715 (providers `opencode-go`, `opencode`, `omniroute`) | +| opencode | `XDG_CONFIG_HOME` → empty dir | `opencode/big-pickle` | 39 (`opencode-go`, `opencode`) | + +- **Claude's starting model ignores `settingSources`.** claude-agent-acp builds its own + `SettingsManager` (`dist/acp-agent.js`, `new SettingsManager(params.cwd, …)`) and takes the + user's `model`, `permissions.defaultMode` and `availableModels` from it, whatever the SDK is + told. `settingSources` still reaches the SDK (the adapter spreads the client's options over its + own `["user", "project", "local"]`); its effect on skills and `CLAUDE.md` needs a prompt to + observe and was not checked. Practical consequence: the only reliable way off the host's model + is to always set one. +- **opencode's global config is `~/.config/opencode/opencode.jsonc`.** Hiding it removes its + plugins and the custom `omniroute` provider. The auth-based providers stay, because their + credentials live in the data dir, not the config dir. `XDG_CONFIG_HOME` is a blunt way to hide + it: every tool the agent runs inherits the variable (gh keeps its auth in `~/.config/gh`). +- **The parent's environment leaks into the agent.** When run from inside a Claude Code session, + the agent inherits `CLAUDECODE=1`, `CLAUDE_EFFORT`, `CLAUDE_CODE_SUBAGENT_MODEL`, + `CLAUDE_CODE_SESSION_ID`, … + +Conclusions → [`adr/0013-agents-over-acp.md`](../adr/0013-agents-over-acp.md). diff --git a/docs/findings/README.md b/docs/findings/README.md index 36ed33f..db49475 100644 --- a/docs/findings/README.md +++ b/docs/findings/README.md @@ -85,3 +85,5 @@ Conclusions → D43 ([`decisions.md`](../decisions.md)); ADR 0004 carries a supe | Document | Subtask | Headline finding | |---|---|---| | [`13-acp-agents.md`](./13-acp-agents.md) | Spike: one ACP adapter for opencode and Claude Code, through the real runtime | Both agents ran the hello-shaped workflow end to end with a workflow-chosen model, tier-1 structured output, permission answered client-side and a 2–3 ms clean cancel; model ids are per agent (opencode's big-pickle is `opencode/big-pickle`), and usage means "last message" on opencode but "whole turn" on Claude. | + +Conclusions → [`adr/0013-agents-over-acp.md`](../adr/0013-agents-over-acp.md) (proposed).