Vectrum
Sign In

acp: Codey as an agent another program drives

Codey speaks the Agent Client Protocol on stdin and stdout, so a control
plane that drives Codex or Claude Code can drive this instead. T3 Code
compiles its providers in and has no Codey entry, but every provider
takes a binary path and its Grok provider is a plain ACP agent — so
'codey agent stdio' occupies that slot with no change to their source.

internal/acp is the wire: JSON-RPC 2.0, one object per line, every
inbound message dispatched on its own goroutine because a permission
request answered on the read loop is a deadlock with a peer doing the
same. internal/serve is the bridge, and it adds no agent behaviour —
Observe becomes session/update, Approve becomes session/request_permission,
and a prompt becomes a turn. One process serves many sessions, each with
its own history, its own root and its own record of what was allowed.

Text is coalesced before it goes out: the client's inbound queue is 32
deep and drops the oldest, so a notification per token would lose the
middle of a sentence. Buffered text is flushed before any tool runs,
which is the case a timer would have been for.

Two invocations exist only for the provider card — 'codey --version' and
'codey models' — shaped for the regular expressions that read them. The
conformance test replays T3 Code's literal handshake, so a nightly of
theirs that breaks us says where.

Event grew an ID so a tool_start and its result pair by the model's own
tool_use id rather than by arrival order, and approval.Describe is
exported because a tool row and an approval box want the same sentence.
Jjonicommitted 1 parent c1086c0commitb811ab2

Showing 18 changed files with 3680 additions and 21 deletions.

+64−1README.mdView File
@@ -999,6 +999,67 @@ set is for. The query and the URL are the detail on the prompt, so what is
999999 leaving is on screen before it leaves. "Always this tool" is the answer most
10001000 people give once.
10011001
1002+ ## Inside another editor
1003+
1004+ Codey can be the agent behind somebody else's UI. It speaks the
1005+ [Agent Client Protocol](https://agentclientprotocol.com) — JSON-RPC over stdin
1006+ and stdout — so a control plane that drives Codex, Claude Code or Cursor can
1007+ drive this instead:
1008+
1009+ ```bash
1010+ codey agent stdio
1011+ ```
1012+
1013+ Nothing but protocol frames goes to stdout in that mode; diagnostics go to
1014+ stderr. The connection ends when stdin does.
1015+
1016+ ### T3 Code
1017+
1018+ [T3 Code](https://t3.codes) compiles its providers in, so there is no Codey
1019+ entry to select. There is, however, a binary path on every provider, and its
1020+ Grok provider is a plain ACP agent — which means Codey can take that slot with
1021+ no change to their source. In **Settings → Providers**, enable Grok, set
1022+ **Binary path** to your `codey`, and rename the instance:
1023+
1024+ ```json
1025+ "providerInstances": {
1026+ "codey": {
1027+ "driver": "grok",
1028+ "displayName": "Codey",
1029+ "enabled": true,
1030+ "config": { "binaryPath": "/path/to/codey" },
1031+ "environment": [
1032+ { "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true }
1033+ ]
1034+ }
1035+ }
1036+ ```
1037+
1038+ That file is `~/.t3/userdata/settings.json`; the settings panel writes the same
1039+ thing. The per-instance environment is how Codey gets a credential without
1040+ inheriting whatever the desktop app was launched with — and without it, the
1041+ provider card will say Codey is installed and not logged in, which is true.
1042+
1043+ Threads stream text, show tool calls as they run, raise an approval prompt you
1044+ answer in T3 Code's own panel, and stop when you press stop. The model picker
1045+ is filled from the endpoint Codey is pointed at. What does not work: resuming a
1046+ thread after a restart (Codey does not advertise session loading, so T3 Code
1047+ will not offer it), and the reasoning view, because the turn loop does not
1048+ report thinking to a front end yet.
1049+
1050+ The card will say Grok and show the xAI glyph. The display name and accent
1051+ colour are yours to set; the icon is not. That is the price of occupying a slot
1052+ instead of adding one.
1053+
1054+ Two invocations exist only for that provider card: `codey --version`, and
1055+ `codey models`, which prints whether there is a credential and what the
1056+ endpoint serves. Both are shaped for the regular expressions on the other end
1057+ rather than for a person.
1058+
1059+ `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` is the research
1060+ this is built on: what every T3 Code provider requires of a binary, and why the
1061+ Grok slot is the one that fits.
1062+
10021063 ## Evals
10031064
10041065 ```bash
@@ -1071,12 +1132,14 @@ internal/ask questions the agent puts to you mid-task, and the tool for it
10711132 internal/attach pictures pasted into a prompt: clipboard, files, markers
10721133 internal/metrics JSONL event log
10731134 internal/ui line-based terminal rendering
1135+ internal/acp Agent Client Protocol: the wire, and its vocabulary
1136+ internal/serve Codey as an agent another program drives, over ACP
10741137 internal/tui full-screen Bubble Tea front end, and the setup wizard
10751138 evals task suite and runner
10761139 ```
10771140
10781141 Two rules hold the shape: `agent` never imports `ui` or `tui`, so the eval
1079- runner can drive the loop headless; and every tool returns `(string, error)`
1142+ runner and the ACP front end can drive the loop headless; and every tool returns `(string, error)`
10801143 where the error is text the model reads, not an exception Codey swallows.
10811144 A failed edit is a normal event in the transcript.
10821145
+261−0cmd/codey/agentcmd.goaddedView File
@@ -0,0 +1,261 @@
1+ package main
2+
3+ // The command line another program drives Codey through.
4+ //
5+ // Three invocations here are not for a person. A control plane that runs
6+ // coding agents — T3 Code is the one this was built against — spawns the
7+ // binary three ways: once to ask its version, once to ask what models it has
8+ // and whether it is logged in, and once to run the agent itself over a pipe.
9+ // Only the third does any work; the first two exist so the provider card can
10+ // say "installed" and "authenticated" before anyone starts a thread.
11+ //
12+ // The shapes are not ours to choose. They are what the client already sends to
13+ // the binary whose slot Codey is occupying, which is why the flags arrive
14+ // around the subcommand rather than before it and why the two probes print
15+ // prose a regular expression reads.
16+
17+ import (
18+ "context"
19+ "fmt"
20+ "io"
21+ "os"
22+ "sort"
23+ "strings"
24+ "time"
25+
26+ "codey/internal/acp"
27+ "codey/internal/agent"
28+ "codey/internal/llm"
29+ "codey/internal/metrics"
30+ "codey/internal/serve"
31+ "codey/internal/tools"
32+ )
33+
34+ // invKind is which of the four things this command line is.
35+ type invKind int
36+
37+ const (
38+ invNormal invKind = iota
39+ invVersion
40+ invModels
41+ invAgent
42+ )
43+
44+ // invocation is the command line, read.
45+ type invocation struct {
46+ Kind invKind
47+ // Permission is the approval floor an agent run was launched with.
48+ Permission serve.PermissionMode
49+ // Rest is what is left for ordinary flag parsing once the words that
50+ // belong to the subcommand are taken out.
51+ Rest []string
52+ }
53+
54+ // scanArgs reads the command line before the flag package sees it.
55+ //
56+ // It has to, for two reasons. `--version` is not one of our flags, and flag
57+ // parsing would exit(2) on it rather than answer. And the agent invocation
58+ // splits its flags around the subcommand — `--permission-mode default agent
59+ // stdio`, but `agent --always-approve stdio` — which no flag package parses,
60+ // because it is not one grammar but a client's habit.
61+ //
62+ // Everything unrecognised is handed on rather than rejected. The launch
63+ // arguments are a free-text field in somebody's settings panel, and the flags
64+ // Codey does understand should keep working when they arrive through it.
65+ func scanArgs(args []string) invocation {
66+ // The probes are exact: the client runs the binary with this one word and
67+ // nothing else. Requiring that keeps `-C models` from being mistaken for
68+ // a request to list them.
69+ if len(args) == 1 {
70+ switch args[0] {
71+ case "--version", "-version":
72+ return invocation{Kind: invVersion}
73+ case "models":
74+ return invocation{Kind: invModels}
75+ }
76+ }
77+
78+ var (
79+ sawAgent, sawStdio, always bool
80+ mode string
81+ rest []string
82+ )
83+ for i := 0; i < len(args); i++ {
84+ a := args[i]
85+ switch {
86+ case a == "agent":
87+ sawAgent = true
88+ case a == "stdio":
89+ sawStdio = true
90+ case a == "--always-approve", a == "-always-approve":
91+ always = true
92+ case a == "--permission-mode", a == "-permission-mode":
93+ if i+1 < len(args) {
94+ mode = args[i+1]
95+ i++
96+ }
97+ case strings.HasPrefix(a, "--permission-mode="):
98+ mode = strings.TrimPrefix(a, "--permission-mode=")
99+ case strings.HasPrefix(a, "-permission-mode="):
100+ mode = strings.TrimPrefix(a, "-permission-mode=")
101+ default:
102+ rest = append(rest, a)
103+ }
104+ }
105+
106+ if !sawAgent || !sawStdio {
107+ // Not an agent run after all, so nothing was ours to consume: hand the
108+ // line back exactly as it came.
109+ return invocation{Kind: invNormal, Rest: args}
110+ }
111+ return invocation{Kind: invAgent, Permission: permissionMode(mode, always), Rest: rest}
112+ }
113+
114+ // permissionMode maps the client's vocabulary onto ours. An unrecognised mode
115+ // is the strictest one: a name we do not know is not a reason to stop asking.
116+ func permissionMode(mode string, always bool) serve.PermissionMode {
117+ if always {
118+ return serve.ModeFullAccess
119+ }
120+ switch mode {
121+ case "acceptEdits":
122+ return serve.ModeAcceptEdits
123+ case "auto":
124+ return serve.ModeAuto
125+ case "bypassPermissions", "full-access":
126+ return serve.ModeFullAccess
127+ default:
128+ return serve.ModeAsk
129+ }
130+ }
131+
132+ // printVersion answers the version probe.
133+ //
134+ // The line has to contain something matching \bv?(\d+\.\d+\.\d+)\b, because
135+ // that is the regular expression on the other end. Anything else reads as an
136+ // installed binary that cannot run.
137+ func printVersion(w io.Writer) {
138+ fmt.Fprintf(w, "codey %s\n", version)
139+ }
140+
141+ // modelsTimeout bounds the listing. The probe that runs this has its own,
142+ // shorter patience, and a listing that has not arrived by then is better
143+ // reported as the one model we know about than not reported at all.
144+ const modelsTimeout = 8 * time.Second
145+
146+ // printModels answers the model-and-login probe.
147+ //
148+ // The format is prose because the reader is a regular expression: a line
149+ // saying whether we are logged in, then one bullet per model with the default
150+ // marked. It exits zero even when it has nothing good to say — a non-zero exit
151+ // makes the client discard the whole listing, including the part that was
152+ // true.
153+ func printModels(w io.Writer, client *llm.Client) {
154+ if !client.HasCredentials() {
155+ fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY, or log in with Claude Code.")
156+ return
157+ }
158+ fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL)
159+
160+ ctx, cancel := context.WithTimeout(context.Background(), modelsTimeout)
161+ defer cancel()
162+
163+ models, err := client.ListModels(ctx)
164+ if err != nil || len(models) == 0 {
165+ // An endpoint that does not answer still serves the model we were
166+ // configured with, and offering that beats offering nothing.
167+ fmt.Fprintf(w, "* %s (default)\n", client.Model())
168+ return
169+ }
170+
171+ for _, m := range sortedModels(models) {
172+ if m.ID == client.Model() {
173+ fmt.Fprintf(w, "* %s (default)\n", m.ID)
174+ continue
175+ }
176+ fmt.Fprintf(w, "- %s\n", m.ID)
177+ }
178+ }
179+
180+ // sortedModels puts the newest first, which is the order a picker should show
181+ // and not the order every endpoint answers in.
182+ func sortedModels(models []llm.Model) []llm.Model {
183+ out := append([]llm.Model(nil), models...)
184+ sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
185+ return out
186+ }
187+
188+ // acpDeps is what an agent run borrows from the ordinary startup path. Nothing
189+ // here is built for this mode: it is the same client, the same event log, the
190+ // same options and the same tool factory the TUI gets, which is the point —
191+ // an agent driven through a pipe should be able to do exactly what an agent
192+ // driven through a keyboard can.
193+ type acpDeps struct {
194+ Permission serve.PermissionMode
195+ Client *llm.Client
196+ // ClientOpts rebuilds the client for another model. The model lives on the
197+ // client rather than on the agent, so a session that selects one needs a
198+ // client of its own.
199+ ClientOpts []llm.Option
200+ Log *metrics.Logger
201+ Base agent.Options
202+ // Tools is main's factory: a root in, a tool-set builder out.
203+ Tools func(root string) func(*agent.State, *metrics.Logger) *tools.Registry
204+ }
205+
206+ // serveACP runs the agent over stdin and stdout until the client hangs up.
207+ //
208+ // Nothing else in this process may write to stdout from here on. The stream is
209+ // the protocol, and one stray line desynchronises a parser that has no way to
210+ // resynchronise.
211+ func serveACP(ctx context.Context, deps acpDeps) error {
212+ models, current := offeredModels(ctx, deps.Client)
213+
214+ srv := serve.New(serve.Options{
215+ Version: version,
216+ Permission: deps.Permission,
217+ Models: models,
218+ DefaultModel: current,
219+
220+ NewAgent: func(root, model string, state *agent.State) (*agent.Agent, error) {
221+ client := deps.Client
222+ if model != "" && model != client.Model() {
223+ client = llm.New(append(deps.ClientOpts, llm.WithModel(model))...)
224+ }
225+ reg := deps.Tools(root)(state, deps.Log)
226+ return agent.New(client, reg, state, deps.Log, deps.Base), nil
227+ },
228+ })
229+
230+ deps.Log.Log(metrics.Event{Type: "acp_start", Extra: map[string]any{
231+ "permission": string(deps.Permission), "model": deps.Client.Model(),
232+ }})
233+
234+ return srv.Run(ctx, os.Stdin, os.Stdout)
235+ }
236+
237+ // offeredModels is the picker a session is given. An endpoint that will not
238+ // list them leaves the picker empty, and the client then shows whatever it
239+ // learned from the `models` probe instead.
240+ func offeredModels(ctx context.Context, client *llm.Client) ([]acp.ModelInfo, string) {
241+ listCtx, cancel := context.WithTimeout(ctx, modelsTimeout)
242+ defer cancel()
243+
244+ found, err := client.ListModels(listCtx)
245+ if err != nil || len(found) == 0 {
246+ return nil, client.Model()
247+ }
248+
249+ out := make([]acp.ModelInfo, 0, len(found))
250+ for _, m := range sortedModels(found) {
251+ out = append(out, acp.ModelInfo{ModelID: m.ID, Name: displayName(m)})
252+ }
253+ return out, client.Model()
254+ }
255+
256+ func displayName(m llm.Model) string {
257+ if m.DisplayName != "" {
258+ return m.DisplayName
259+ }
260+ return m.ID
261+ }
+185−0cmd/codey/agentcmd_test.goaddedView File
@@ -0,0 +1,185 @@
1+ package main
2+
3+ import (
4+ "bytes"
5+ "net/http"
6+ "net/http/httptest"
7+ "regexp"
8+ "slices"
9+ "strings"
10+ "testing"
11+
12+ "codey/internal/llm"
13+ "codey/internal/serve"
14+ )
15+
16+ // t3VersionPattern is the regular expression T3 Code parses `--version` with.
17+ // It is copied here rather than described, because a version line that does
18+ // not match it reads as a binary that is installed and broken.
19+ var t3VersionPattern = regexp.MustCompile(`\bv?(\d+\.\d+\.\d+)\b`)
20+
21+ func TestVersionMatchesWhatTheClientLooksFor(t *testing.T) {
22+ var out bytes.Buffer
23+ printVersion(&out)
24+
25+ m := t3VersionPattern.FindStringSubmatch(out.String())
26+ if m == nil {
27+ t.Fatalf("no version in %q", out.String())
28+ }
29+ if !strings.Contains(out.String(), "codey") {
30+ t.Errorf("the line should say whose version it is: %q", out.String())
31+ }
32+ }
33+
34+ func TestScanArgsReadsEveryShapeTheClientSends(t *testing.T) {
35+ // The five launches T3 Code's Grok driver produces, one per runtime mode.
36+ cases := []struct {
37+ name string
38+ args []string
39+ want serve.PermissionMode
40+ }{
41+ {"default", []string{"agent", "stdio"}, serve.ModeAsk},
42+ {"approval required", []string{"--permission-mode", "default", "agent", "stdio"}, serve.ModeAsk},
43+ {"accept edits", []string{"--permission-mode", "acceptEdits", "agent", "stdio"}, serve.ModeAcceptEdits},
44+ {"auto", []string{"--permission-mode", "auto", "agent", "stdio"}, serve.ModeAuto},
45+ {"full access", []string{"agent", "--always-approve", "stdio"}, serve.ModeFullAccess},
46+ }
47+
48+ for _, tc := range cases {
49+ t.Run(tc.name, func(t *testing.T) {
50+ inv := scanArgs(tc.args)
51+ if inv.Kind != invAgent {
52+ t.Fatalf("kind = %v, want an agent run", inv.Kind)
53+ }
54+ if inv.Permission != tc.want {
55+ t.Errorf("permission = %q, want %q", inv.Permission, tc.want)
56+ }
57+ if len(inv.Rest) != 0 {
58+ t.Errorf("leftover args %v — flag parsing would choke on them", inv.Rest)
59+ }
60+ })
61+ }
62+ }
63+
64+ func TestScanArgsKeepsOurOwnFlagsFromTheLaunchArguments(t *testing.T) {
65+ // The launch arguments are a free-text box in a settings panel: whatever
66+ // the user typed arrives mixed in with the subcommand.
67+ inv := scanArgs([]string{"--permission-mode", "auto", "agent", "stdio", "-effort", "high", "-no-web"})
68+
69+ if inv.Kind != invAgent || inv.Permission != serve.ModeAuto {
70+ t.Fatalf("invocation = %+v", inv)
71+ }
72+ want := []string{"-effort", "high", "-no-web"}
73+ if !slices.Equal(inv.Rest, want) {
74+ t.Errorf("rest = %v, want %v", inv.Rest, want)
75+ }
76+ }
77+
78+ func TestScanArgsTakesThePermissionModeJoinedByAnEquals(t *testing.T) {
79+ if got := scanArgs([]string{"--permission-mode=acceptEdits", "agent", "stdio"}); got.Permission != serve.ModeAcceptEdits {
80+ t.Errorf("permission = %q", got.Permission)
81+ }
82+ }
83+
84+ func TestAnUnknownPermissionModeIsTheStrictOne(t *testing.T) {
85+ // A mode we have never heard of is not a reason to stop asking.
86+ if got := scanArgs([]string{"--permission-mode", "yolo-supreme", "agent", "stdio"}); got.Permission != serve.ModeAsk {
87+ t.Errorf("permission = %q, want %q", got.Permission, serve.ModeAsk)
88+ }
89+ }
90+
91+ func TestTheProbesAreRecognisedOnlyOnTheirOwn(t *testing.T) {
92+ if got := scanArgs([]string{"--version"}).Kind; got != invVersion {
93+ t.Errorf("--version kind = %v", got)
94+ }
95+ if got := scanArgs([]string{"models"}).Kind; got != invModels {
96+ t.Errorf("models kind = %v", got)
97+ }
98+ // A directory that happens to be called models is a directory.
99+ inv := scanArgs([]string{"-C", "models"})
100+ if inv.Kind != invNormal {
101+ t.Errorf("kind = %v, want a normal run", inv.Kind)
102+ }
103+ if !slices.Equal(inv.Rest, []string{"-C", "models"}) {
104+ t.Errorf("a normal run must get its line back untouched: %v", inv.Rest)
105+ }
106+ }
107+
108+ func TestHalfAnAgentInvocationIsNotOne(t *testing.T) {
109+ // "agent" without "stdio" is somebody's file name, not a subcommand.
110+ for _, args := range [][]string{{"agent"}, {"stdio"}, {"-p", "explain the agent"}} {
111+ if got := scanArgs(args).Kind; got != invNormal {
112+ t.Errorf("scanArgs(%v) = %v, want a normal run", args, got)
113+ }
114+ }
115+ }
116+
117+ // testClient has a credential and an endpoint that refuses to list anything,
118+ // which is the interesting case: the probe still has to answer.
119+ func testClient(t *testing.T) *llm.Client {
120+ t.Helper()
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
122+ w.WriteHeader(http.StatusInternalServerError)
123+ }))
124+ t.Cleanup(srv.Close)
125+ return llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"), llm.WithModel("codey-test-model"))
126+ }
127+
128+ func noCredentialClient(t *testing.T) *llm.Client {
129+ t.Helper()
130+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
131+ c.ClearCredentialsForTest()
132+ return c
133+ }
134+
135+ // t3ModelBullet is how T3 Code reads a line of the model listing, and
136+ // t3LoggedIn is how it reads the login state off the same output.
137+ var (
138+ t3ModelBullet = regexp.MustCompile(`^\s*[*-]\s+(\S+)(.*)$`)
139+ t3LoggedIn = regexp.MustCompile(`(?i)you are logged in`)
140+ t3LoggedOut = regexp.MustCompile(`(?i)not authenticated|not logged in`)
141+ )
142+
143+ // parseLikeT3 is a transliteration of the client's parser, so the test fails
144+ // for the same reason the provider card would.
145+ func parseLikeT3(out string) (loggedIn bool, models []string, defaultModel string) {
146+ loggedIn = t3LoggedIn.MatchString(out) && !t3LoggedOut.MatchString(out)
147+ for _, line := range strings.Split(out, "\n") {
148+ m := t3ModelBullet.FindStringSubmatch(line)
149+ if m == nil {
150+ continue
151+ }
152+ models = append(models, m[1])
153+ if strings.Contains(strings.ToLower(m[2]), "(default)") {
154+ defaultModel = m[1]
155+ }
156+ }
157+ return
158+ }
159+
160+ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) {
161+ // No endpoint is reachable here, so this exercises the fallback: the one
162+ // model we were configured with, and a login state that is still true.
163+ var out bytes.Buffer
164+ printModels(&out, testClient(t))
165+
166+ loggedIn, models, def := parseLikeT3(out.String())
167+ if !loggedIn {
168+ t.Errorf("the client would read this as logged out:\n%s", out.String())
169+ }
170+ if len(models) != 1 || def != models[0] {
171+ t.Errorf("models = %v, default = %q, from:\n%s", models, def, out.String())
172+ }
173+ }
174+
175+ func TestLoggedOutSaysSoRatherThanFailing(t *testing.T) {
176+ var out bytes.Buffer
177+ printModels(&out, noCredentialClient(t))
178+
179+ if loggedIn, _, _ := parseLikeT3(out.String()); loggedIn {
180+ t.Errorf("a client with no credential reported itself logged in:\n%s", out.String())
181+ }
182+ if !t3LoggedOut.MatchString(out.String()) {
183+ t.Errorf("the client would read this as unknown rather than logged out:\n%s", out.String())
184+ }
185+ }
+50−2cmd/codey/main.goView File
@@ -21,6 +21,7 @@ import (
2121 "codey/internal/config"
2222 "codey/internal/llm"
2323 "codey/internal/metrics"
24+ "codey/internal/serve"
2425 "codey/internal/skills"
2526 "codey/internal/statedir"
2627 "codey/internal/subagent"
@@ -85,7 +86,18 @@ func run() error {
8586 noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname)
8687 noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone")
8788 noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
88- flag.Parse()
89+
90+ // Read the line before the flag package does. Three invocations belong to
91+ // a program driving Codey rather than to a person — see agentcmd.go — and
92+ // two of them are not spelled in a grammar flag parsing can read.
93+ inv := scanArgs(os.Args[1:])
94+ if inv.Kind == invVersion {
95+ printVersion(os.Stdout)
96+ return nil
97+ }
98+ if err := flag.CommandLine.Parse(inv.Rest); err != nil {
99+ return err
100+ }
89101
90102 root, err := filepath.Abs(*workdir)
91103 if err != nil {
@@ -114,6 +126,10 @@ func run() error {
114126 llm.WithAuthToken(*authToken),
115127 }
116128 client := llm.New(opts...)
129+ // clientOpts is the list the live client was actually built from, kept so
130+ // a second client on another model can be built the same way. The Claude
131+ // Code branch below is the only thing that adds to it.
132+ clientOpts := opts
117133
118134 home, err := os.UserHomeDir()
119135 if err != nil {
@@ -136,7 +152,8 @@ func run() error {
136152 }
137153 if src != nil {
138154 ccSource = src
139- client = llm.New(append(opts, llm.WithTokenSource(src.Token))...)
155+ clientOpts = append(opts, llm.WithTokenSource(src.Token))
156+ client = llm.New(clientOpts...)
140157 // Not expected to ever trigger: resolveClaudeCode already checked
141158 // the endpoint before we agreed to borrow this credential, but
142159 // that check ran against the client we had before this rebuild.
@@ -150,6 +167,15 @@ func run() error {
150167 systemPrefix = agent.ClaudeCodeIdentity
151168 }
152169 }
170+ // The login-and-models probe answers here, where a borrowed Claude Code
171+ // credential already counts as being logged in, and before the check
172+ // below: "not logged in" is the useful answer to that question, not an
173+ // error exit that tells the asker nothing.
174+ if inv.Kind == invModels {
175+ printModels(os.Stdout, client)
176+ return nil
177+ }
178+
153179 if !client.HasCredentials() {
154180 return errNoCredentials
155181 }
@@ -343,6 +369,28 @@ func run() error {
343369 SystemPrefix: systemPrefix,
344370 }
345371
372+ // An agent run hands the whole thing over to the protocol and never comes
373+ // back. It goes here, after everything a session needs has been built and
374+ // before the front ends that assume a person: no subagent supervisor,
375+ // because their reports need somewhere to be shown, and no question tool,
376+ // because the protocol has no box to put a question in.
377+ //
378+ // Nothing above this line has written to stdout. Nothing below it may.
379+ if inv.Kind == invAgent {
380+ if acceptAll || inv.Permission == serve.ModeFullAccess {
381+ inv.Permission = serve.ModeFullAccess
382+ notice("no approval prompts. Use a throwaway clone or a container.")
383+ }
384+ return serveACP(context.Background(), acpDeps{
385+ Permission: inv.Permission,
386+ Client: client,
387+ ClientOpts: clientOpts,
388+ Log: log,
389+ Base: base,
390+ Tools: toolsIn,
391+ })
392+ }
393+
346394 // Subagents are a TUI feature: their reports come back as messages the
347395 // front end puts into the conversation, and the line-based REPL has
348396 // nowhere to show four agents working at once. Offering the tools where
+28−0docs/superpowers/plans/2026-09-19-t3code-provider.mdView File
@@ -1,5 +1,33 @@
11 # Codey inside T3 Code — implementation plan
22
3+ > **Status: phases 1 and 2 implemented**, with six departures from what is
4+ > written below.
5+ >
6+ > Approvals moved from phase 2 into phase 1. A nil `Approve` means the agent
7+ > may do anything, so "text only, approvals later" was not a smaller first
8+ > step — it was shipping the gate open. `Event.ID` moved forward for the same
9+ > kind of reason: the translator needed tool call ids from its first line, so
10+ > pairing by arrival order was never written.
11+ >
12+ > `approval.Describe` was exported rather than duplicated. The tool-call title
13+ > a client shows and the summary an approval box shows are the same sentence,
14+ > and two vocabularies for one call would be two things to keep in step.
15+ >
16+ > The coalescer has no timer. Text goes out on size, on elapsed time checked at
17+ > the next delta, and on any event that is not text — which covers the case the
18+ > timer was for, a sentence written just before a long command, and leaves the
19+ > tests free of sleeping.
20+ >
21+ > Two things were added that the plan did not ask for. `initialize._meta.modelState`
22+ > carries the model list, because T3 Code's health check shakes hands and stops
23+ > without opening a session, and a picker only offered at `session/new` never
24+ > reaches the provider card. And images work: `promptCapabilities.image` is
25+ > advertised and image blocks are converted, which was phase 3 in the plan and
26+ > turned out to be ten lines.
27+ >
28+ > Phase 3 remains open. See the `## Inside another editor` section of README.md
29+ > for what shipped.
30+
331 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
432
533 **Goal:** Run Codey as an agent inside T3 Code without touching T3 Code's
+348−0internal/acp/conn.goaddedView File
@@ -0,0 +1,348 @@
1+ package acp
2+
3+ import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "strconv"
11+ "sync"
12+ "sync/atomic"
13+ )
14+
15+ // Handler is the meaning behind the methods: one implementation per agent.
16+ //
17+ // Every method may be called concurrently, and Prompt in particular is
18+ // expected to run for minutes while Cancel arrives on another goroutine. A
19+ // handler that serializes its own state is a handler that works; this package
20+ // deliberately does no locking on its behalf, because only the handler knows
21+ // what a session is.
22+ type Handler interface {
23+ Initialize(context.Context, InitializeRequest) (InitializeResponse, error)
24+ // Authenticate must not fail for an unknown method id. Clients send one
25+ // unconditionally and do not catch the error, so a refusal here is a
26+ // session that never starts rather than a login prompt.
27+ Authenticate(context.Context, AuthenticateRequest) error
28+ NewSession(context.Context, NewSessionRequest) (NewSessionResponse, error)
29+ Prompt(context.Context, PromptRequest) (PromptResponse, error)
30+ // Cancel is a notification: there is nothing to answer and nothing to
31+ // fail. It must return promptly — the turn it cancels reports the outcome
32+ // itself, by ending with StopCancelled.
33+ Cancel(context.Context, CancelParams)
34+ }
35+
36+ // Extender is the optional half of [Handler]: methods outside the core set.
37+ // A handler that does not implement it answers CodeMethodNotFound to anything
38+ // unrecognised, which is the correct answer and not an error.
39+ type Extender interface {
40+ Extra(ctx context.Context, method string, params json.RawMessage) (any, error)
41+ }
42+
43+ // ErrClosed is what a pending [Conn.Request] fails with when the connection
44+ // ends before the answer arrives.
45+ var ErrClosed = errors.New("acp: connection closed")
46+
47+ // Conn is one ACP connection.
48+ type Conn struct {
49+ h Handler
50+ r io.Reader
51+ w io.Writer
52+
53+ // wmu serializes writes. Every frame is marshalled whole and written in
54+ // one call, so interleaving is the only thing to guard against.
55+ wmu sync.Mutex
56+
57+ nextID atomic.Int64
58+
59+ pmu sync.Mutex
60+ pending map[string]chan *Message
61+
62+ closeOnce sync.Once
63+ done chan struct{}
64+
65+ // wg counts handler goroutines, so Serve can wait for them to finish
66+ // writing before it returns and the streams go away underneath them.
67+ wg sync.WaitGroup
68+ }
69+
70+ // NewConn wires a handler to a pair of streams. Nothing is read or written
71+ // until [Conn.Serve] runs.
72+ func NewConn(r io.Reader, w io.Writer, h Handler) *Conn {
73+ return &Conn{
74+ h: h,
75+ r: r,
76+ w: w,
77+ pending: make(map[string]chan *Message),
78+ done: make(chan struct{}),
79+ }
80+ }
81+
82+ // Serve reads until the stream ends, dispatching as it goes. A clean EOF is
83+ // how a client says goodbye, so it returns nil; anything else is the error
84+ // that stopped it.
85+ //
86+ // Cancelling ctx stops the handlers but not the read itself: a blocking read
87+ // on an arbitrary io.Reader cannot be interrupted portably. Closing the reader
88+ // is what ends Serve.
89+ func (c *Conn) Serve(ctx context.Context) error {
90+ ctx, cancel := context.WithCancel(ctx)
91+
92+ err := c.loop(ctx)
93+
94+ // Order matters. Cancel first so a turn in flight stops, then release
95+ // anyone blocked waiting for an answer that is no longer coming, then wait
96+ // for the handlers to finish with the streams.
97+ cancel()
98+ c.close()
99+ c.wg.Wait()
100+ return err
101+ }
102+
103+ func (c *Conn) loop(ctx context.Context) error {
104+ // ReadBytes rather than a Scanner: a prompt can carry a base64 image, and
105+ // a line-length limit chosen here would be a decode failure nobody could
106+ // explain.
107+ br := bufio.NewReaderSize(c.r, 64*1024)
108+ for {
109+ line, err := br.ReadBytes('\n')
110+ if len(bytes.TrimSpace(line)) > 0 {
111+ c.handleLine(ctx, line)
112+ }
113+ switch {
114+ case errors.Is(err, io.EOF):
115+ return nil
116+ case err != nil:
117+ return err
118+ }
119+ }
120+ }
121+
122+ func (c *Conn) handleLine(ctx context.Context, line []byte) {
123+ var m Message
124+ if err := json.Unmarshal(line, &m); err != nil {
125+ // A frame we cannot parse might still have a recoverable id, in which
126+ // case the peer is waiting and deserves an answer. If not, there is
127+ // nobody to tell and the loop carries on: one bad line is not a reason
128+ // to end a session.
129+ if id := salvageID(line); id != nil {
130+ c.respondError(id, Errorf(CodeParse, "invalid JSON"))
131+ }
132+ return
133+ }
134+
135+ switch {
136+ case m.ID != nil && m.Method == "":
137+ c.deliver(&m)
138+ case m.ID != nil:
139+ c.wg.Add(1)
140+ go func() {
141+ defer c.wg.Done()
142+ c.serveRequest(ctx, &m)
143+ }()
144+ case m.Method != "":
145+ c.wg.Add(1)
146+ go func() {
147+ defer c.wg.Done()
148+ c.serveNotification(ctx, &m)
149+ }()
150+ }
151+ }
152+
153+ // salvageID pulls the id out of a line that did not parse as a whole, so a
154+ // peer blocked on a request gets an error instead of a hang.
155+ //
156+ // It streams tokens rather than unmarshalling, because the interesting case is
157+ // a frame that was cut off: the id is usually intact and everything after it
158+ // is not, and a whole-value decode fails on exactly those lines.
159+ func salvageID(line []byte) *json.RawMessage {
160+ dec := json.NewDecoder(bytes.NewReader(line))
161+ if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
162+ return nil
163+ }
164+ for {
165+ tok, err := dec.Token()
166+ if err != nil {
167+ return nil
168+ }
169+ key, ok := tok.(string)
170+ if !ok {
171+ return nil // the closing brace, or something that is not an object
172+ }
173+ var raw json.RawMessage
174+ if err := dec.Decode(&raw); err != nil {
175+ return nil
176+ }
177+ if key == "id" {
178+ return &raw
179+ }
180+ }
181+ }
182+
183+ func (c *Conn) serveRequest(ctx context.Context, m *Message) {
184+ result, err := c.call(ctx, m.Method, m.Params)
185+ if err != nil {
186+ c.respondError(m.ID, asError(err))
187+ return
188+ }
189+ c.respond(m.ID, result)
190+ }
191+
192+ func (c *Conn) serveNotification(ctx context.Context, m *Message) {
193+ if m.Method != MethodCancel {
194+ // Unknown notifications are ignored by design: that is what makes it
195+ // safe for a client to send one we have not heard of.
196+ return
197+ }
198+ var params CancelParams
199+ if err := decodeParams(m.Params, &params); err != nil {
200+ return
201+ }
202+ c.h.Cancel(ctx, params)
203+ }
204+
205+ // call dispatches one request to the handler. The returned value is whatever
206+ // goes in the result field; nil becomes an empty object, because a JSON-RPC
207+ // response with no result at all is not a success.
208+ func (c *Conn) call(ctx context.Context, method string, params json.RawMessage) (any, error) {
209+ switch method {
210+ case MethodInitialize:
211+ var req InitializeRequest
212+ if err := decodeParams(params, &req); err != nil {
213+ return nil, err
214+ }
215+ return c.h.Initialize(ctx, req)
216+
217+ case MethodAuthenticate:
218+ var req AuthenticateRequest
219+ if err := decodeParams(params, &req); err != nil {
220+ return nil, err
221+ }
222+ return nil, c.h.Authenticate(ctx, req)
223+
224+ case MethodNewSession:
225+ var req NewSessionRequest
226+ if err := decodeParams(params, &req); err != nil {
227+ return nil, err
228+ }
229+ return c.h.NewSession(ctx, req)
230+
231+ case MethodPrompt:
232+ var req PromptRequest
233+ if err := decodeParams(params, &req); err != nil {
234+ return nil, err
235+ }
236+ return c.h.Prompt(ctx, req)
237+
238+ default:
239+ if ex, ok := c.h.(Extender); ok {
240+ return ex.Extra(ctx, method, params)
241+ }
242+ return nil, Errorf(CodeMethodNotFound, "unknown method %q", method)
243+ }
244+ }
245+
246+ // Notify sends a notification. It never has an id, which is what stops the
247+ // peer from waiting for an answer to it.
248+ func (c *Conn) Notify(ctx context.Context, method string, params any) error {
249+ raw, err := json.Marshal(params)
250+ if err != nil {
251+ return err
252+ }
253+ return c.write(&Message{JSONRPC: jsonrpcVersion, Method: method, Params: raw})
254+ }
255+
256+ // Request calls the peer and waits for the answer, decoding it into out when
257+ // out is non-nil. It returns ctx.Err() if the caller gives up first and
258+ // [ErrClosed] if the connection ends before the peer replies — a permission
259+ // request outliving its connection has to fail rather than hang the turn.
260+ func (c *Conn) Request(ctx context.Context, method string, params, out any) error {
261+ raw, err := json.Marshal(params)
262+ if err != nil {
263+ return err
264+ }
265+
266+ id := json.RawMessage(strconv.FormatInt(c.nextID.Add(1), 10))
267+ key := string(id)
268+ ch := make(chan *Message, 1)
269+
270+ c.pmu.Lock()
271+ c.pending[key] = ch
272+ c.pmu.Unlock()
273+ defer func() {
274+ c.pmu.Lock()
275+ delete(c.pending, key)
276+ c.pmu.Unlock()
277+ }()
278+
279+ if err := c.write(&Message{JSONRPC: jsonrpcVersion, ID: &id, Method: method, Params: raw}); err != nil {
280+ return err
281+ }
282+
283+ select {
284+ case <-ctx.Done():
285+ return ctx.Err()
286+ case <-c.done:
287+ return ErrClosed
288+ case reply := <-ch:
289+ if reply.Error != nil {
290+ return reply.Error
291+ }
292+ if out == nil || len(reply.Result) == 0 {
293+ return nil
294+ }
295+ return json.Unmarshal(reply.Result, out)
296+ }
297+ }
298+
299+ func (c *Conn) deliver(m *Message) {
300+ c.pmu.Lock()
301+ ch := c.pending[string(*m.ID)]
302+ c.pmu.Unlock()
303+ if ch != nil {
304+ // Buffered, and the entry is removed by the waiter, so this cannot
305+ // block and cannot be delivered twice.
306+ select {
307+ case ch <- m:
308+ default:
309+ }
310+ }
311+ }
312+
313+ func (c *Conn) respond(id *json.RawMessage, result any) {
314+ if result == nil {
315+ result = struct{}{}
316+ }
317+ raw, err := json.Marshal(result)
318+ if err != nil {
319+ c.respondError(id, asError(err))
320+ return
321+ }
322+ _ = c.write(&Message{JSONRPC: jsonrpcVersion, ID: id, Result: raw})
323+ }
324+
325+ func (c *Conn) respondError(id *json.RawMessage, e *Error) {
326+ if id == nil {
327+ null := json.RawMessage("null")
328+ id = &null
329+ }
330+ _ = c.write(&Message{JSONRPC: jsonrpcVersion, ID: id, Error: e})
331+ }
332+
333+ func (c *Conn) write(m *Message) error {
334+ b, err := json.Marshal(m)
335+ if err != nil {
336+ return err
337+ }
338+ b = append(b, '\n')
339+
340+ c.wmu.Lock()
341+ defer c.wmu.Unlock()
342+ _, err = c.w.Write(b)
343+ return err
344+ }
345+
346+ func (c *Conn) close() {
347+ c.closeOnce.Do(func() { close(c.done) })
348+ }
+382−0internal/acp/conn_test.goaddedView File
@@ -0,0 +1,382 @@
1+ package acp
2+
3+ import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+ )
15+
16+ // stubHandler answers the core methods with whatever the test set on it.
17+ type stubHandler struct {
18+ initialize func(InitializeRequest) (InitializeResponse, error)
19+ authFail error
20+ newSession func(NewSessionRequest) (NewSessionResponse, error)
21+ prompt func(context.Context, PromptRequest) (PromptResponse, error)
22+
23+ mu sync.Mutex
24+ cancelled []string
25+ }
26+
27+ func (s *stubHandler) Initialize(_ context.Context, req InitializeRequest) (InitializeResponse, error) {
28+ if s.initialize != nil {
29+ return s.initialize(req)
30+ }
31+ return InitializeResponse{ProtocolVersion: ProtocolVersion, AuthMethods: []AuthMethod{}}, nil
32+ }
33+
34+ func (s *stubHandler) Authenticate(context.Context, AuthenticateRequest) error { return s.authFail }
35+
36+ func (s *stubHandler) NewSession(_ context.Context, req NewSessionRequest) (NewSessionResponse, error) {
37+ if s.newSession != nil {
38+ return s.newSession(req)
39+ }
40+ return NewSessionResponse{SessionID: "s1"}, nil
41+ }
42+
43+ func (s *stubHandler) Prompt(ctx context.Context, req PromptRequest) (PromptResponse, error) {
44+ if s.prompt != nil {
45+ return s.prompt(ctx, req)
46+ }
47+ return PromptResponse{StopReason: StopEndTurn}, nil
48+ }
49+
50+ func (s *stubHandler) Cancel(_ context.Context, p CancelParams) {
51+ s.mu.Lock()
52+ defer s.mu.Unlock()
53+ s.cancelled = append(s.cancelled, p.SessionID)
54+ }
55+
56+ // peer is the far end of a connection: a pipe to write requests into and a
57+ // reader that hands back one decoded frame at a time.
58+ type peer struct {
59+ t *testing.T
60+ in *io.PipeWriter
61+ out *bufio.Reader
62+ conn *Conn
63+ done chan error
64+ }
65+
66+ func newPeer(t *testing.T, h Handler) *peer {
67+ t.Helper()
68+ toAgent, fromPeer := io.Pipe()
69+ fromAgent, toPeer := io.Pipe()
70+
71+ c := NewConn(toAgent, toPeer, h)
72+ p := &peer{t: t, in: fromPeer, out: bufio.NewReader(fromAgent), conn: c, done: make(chan error, 1)}
73+
74+ go func() { p.done <- c.Serve(context.Background()) }()
75+ t.Cleanup(func() {
76+ _ = fromPeer.Close()
77+ select {
78+ case <-p.done:
79+ case <-time.After(2 * time.Second):
80+ t.Error("Serve did not return after the stream closed")
81+ }
82+ })
83+ return p
84+ }
85+
86+ func (p *peer) send(line string) {
87+ p.t.Helper()
88+ if _, err := io.WriteString(p.in, line+"\n"); err != nil {
89+ p.t.Fatalf("writing to the agent: %v", err)
90+ }
91+ }
92+
93+ // readRaw returns the next line exactly as it went over the wire, because some
94+ // of what matters here — an absent key — does not survive decoding.
95+ func (p *peer) readRaw() string {
96+ p.t.Helper()
97+ type result struct {
98+ line string
99+ err error
100+ }
101+ ch := make(chan result, 1)
102+ go func() {
103+ line, err := p.out.ReadString('\n')
104+ ch <- result{line, err}
105+ }()
106+ select {
107+ case r := <-ch:
108+ if r.err != nil {
109+ p.t.Fatalf("reading from the agent: %v", r.err)
110+ }
111+ return strings.TrimSuffix(r.line, "\n")
112+ case <-time.After(2 * time.Second):
113+ p.t.Fatal("timed out waiting for a frame from the agent")
114+ return ""
115+ }
116+ }
117+
118+ func (p *peer) read() Message {
119+ p.t.Helper()
120+ var m Message
121+ line := p.readRaw()
122+ if err := json.Unmarshal([]byte(line), &m); err != nil {
123+ p.t.Fatalf("undecodable frame %q: %v", line, err)
124+ }
125+ return m
126+ }
127+
128+ func TestEveryFrameCarriesTheProtocolVersion(t *testing.T) {
129+ p := newPeer(t, &stubHandler{})
130+
131+ p.send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}`)
132+ if got := p.read().JSONRPC; got != "2.0" {
133+ t.Errorf("response jsonrpc = %q, want 2.0", got)
134+ }
135+
136+ go func() {
137+ _ = p.conn.Notify(context.Background(), MethodSessionUpdate, SessionNotification{SessionID: "s1"})
138+ }()
139+ if got := p.read().JSONRPC; got != "2.0" {
140+ t.Errorf("notification jsonrpc = %q, want 2.0", got)
141+ }
142+ }
143+
144+ func TestANotificationHasNoIDKeyAtAll(t *testing.T) {
145+ p := newPeer(t, &stubHandler{})
146+
147+ go func() {
148+ _ = p.conn.Notify(context.Background(), MethodSessionUpdate, SessionNotification{
149+ SessionID: "s1",
150+ Update: Update{SessionUpdate: UpdateAgentMessageChunk, Content: TextContent("hi")},
151+ })
152+ }()
153+
154+ // A decoded map would show the difference between absent and null as the
155+ // same missing key, and null is the thing that breaks the client.
156+ line := p.readRaw()
157+ if strings.Contains(line, `"id"`) {
158+ t.Errorf("notification carries an id: %s", line)
159+ }
160+ if !strings.Contains(line, `"sessionUpdate":"agent_message_chunk"`) {
161+ t.Errorf("notification lost its tag: %s", line)
162+ }
163+ }
164+
165+ func TestUnknownMethodIsMethodNotFound(t *testing.T) {
166+ p := newPeer(t, &stubHandler{})
167+
168+ p.send(`{"jsonrpc":"2.0","id":"abc","method":"session/set_model","params":{}}`)
169+ m := p.read()
170+
171+ if m.ID == nil || string(*m.ID) != `"abc"` {
172+ t.Errorf("id did not come back byte for byte: %v", m.ID)
173+ }
174+ if m.Error == nil || m.Error.Code != CodeMethodNotFound {
175+ t.Fatalf("want a method-not-found error, got %+v", m.Error)
176+ }
177+ }
178+
179+ // extendedHandler answers anything the core set does not.
180+ type extendedHandler struct {
181+ stubHandler
182+ seen string
183+ }
184+
185+ func (e *extendedHandler) Extra(_ context.Context, method string, _ json.RawMessage) (any, error) {
186+ e.seen = method
187+ return map[string]any{}, nil
188+ }
189+
190+ func TestAnExtenderAnswersTheRest(t *testing.T) {
191+ h := &extendedHandler{}
192+ p := newPeer(t, h)
193+
194+ p.send(`{"jsonrpc":"2.0","id":7,"method":"session/set_model","params":{"modelId":"x"}}`)
195+ m := p.read()
196+
197+ if m.Error != nil {
198+ t.Fatalf("unexpected error: %v", m.Error)
199+ }
200+ if h.seen != "session/set_model" {
201+ t.Errorf("Extra saw %q", h.seen)
202+ }
203+ if string(m.Result) != "{}" {
204+ t.Errorf("result = %s, want {}", m.Result)
205+ }
206+ }
207+
208+ func TestAHandlerErrorBecomesInternalAndTheLoopSurvives(t *testing.T) {
209+ h := &stubHandler{initialize: func(InitializeRequest) (InitializeResponse, error) {
210+ return InitializeResponse{}, errors.New("no")
211+ }}
212+ p := newPeer(t, h)
213+
214+ p.send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
215+ if m := p.read(); m.Error == nil || m.Error.Code != CodeInternal {
216+ t.Fatalf("want an internal error, got %+v", m.Error)
217+ }
218+
219+ // The connection is still usable, which is the point.
220+ p.send(`{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}}`)
221+ if m := p.read(); m.Error != nil {
222+ t.Fatalf("the connection did not survive the error: %v", m.Error)
223+ }
224+ }
225+
226+ func TestBadParamsAreInvalidParamsNotInternal(t *testing.T) {
227+ p := newPeer(t, &stubHandler{})
228+
229+ p.send(`{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":42}}`)
230+ if m := p.read(); m.Error == nil || m.Error.Code != CodeInvalidParams {
231+ t.Fatalf("want invalid params, got %+v", m.Error)
232+ }
233+ }
234+
235+ func TestMalformedLinesAreAnsweredWhenTheyCanBeAndSkippedWhenTheyCannot(t *testing.T) {
236+ p := newPeer(t, &stubHandler{})
237+
238+ // Unparseable as a whole, but the id is recoverable: the peer is waiting.
239+ p.send(`{"jsonrpc":"2.0","id":3,"method":"initialize","params":{`)
240+ if m := p.read(); m.Error == nil || m.Error.Code != CodeParse {
241+ t.Fatalf("want a parse error, got %+v", m.Error)
242+ }
243+
244+ // Nothing to answer, and nothing to do but carry on.
245+ p.send(`not json at all`)
246+ p.send(`{"jsonrpc":"2.0","id":4,"method":"initialize","params":{}}`)
247+ if m := p.read(); m.Error != nil {
248+ t.Fatalf("the loop did not survive a junk line: %v", m.Error)
249+ }
250+ }
251+
252+ func TestRequestWaitsForTheMatchingResponse(t *testing.T) {
253+ p := newPeer(t, &stubHandler{})
254+
255+ var out RequestPermissionResult
256+ errc := make(chan error, 1)
257+ go func() {
258+ errc <- p.conn.Request(context.Background(), MethodRequestPermission,
259+ RequestPermissionParams{SessionID: "s1"}, &out)
260+ }()
261+
262+ req := p.read()
263+ if req.Method != MethodRequestPermission {
264+ t.Fatalf("method = %q", req.Method)
265+ }
266+ if req.ID == nil {
267+ t.Fatal("an outbound request must carry an id")
268+ }
269+ p.send(`{"jsonrpc":"2.0","id":` + string(*req.ID) + `,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}`)
270+
271+ if err := <-errc; err != nil {
272+ t.Fatalf("Request: %v", err)
273+ }
274+ if out.Outcome.OptionID != OptAllowOnce {
275+ t.Errorf("outcome = %+v", out.Outcome)
276+ }
277+ }
278+
279+ func TestRequestGivesUpWithItsContext(t *testing.T) {
280+ p := newPeer(t, &stubHandler{})
281+
282+ ctx, cancel := context.WithCancel(context.Background())
283+ errc := make(chan error, 1)
284+ go func() {
285+ errc <- p.conn.Request(ctx, MethodRequestPermission, RequestPermissionParams{}, nil)
286+ }()
287+ p.read() // let it go out before cancelling
288+ cancel()
289+
290+ select {
291+ case err := <-errc:
292+ if !errors.Is(err, context.Canceled) {
293+ t.Fatalf("want context.Canceled, got %v", err)
294+ }
295+ case <-time.After(2 * time.Second):
296+ t.Fatal("Request did not return when its context was cancelled")
297+ }
298+ }
299+
300+ func TestRequestFailsWhenTheConnectionEnds(t *testing.T) {
301+ toAgent, fromPeer := io.Pipe()
302+ fromAgent, toPeer := io.Pipe()
303+ // Drained so the outbound request cannot block on an unread pipe.
304+ go func() { _, _ = io.Copy(io.Discard, fromAgent) }()
305+
306+ c := NewConn(toAgent, toPeer, &stubHandler{})
307+ served := make(chan error, 1)
308+ go func() { served <- c.Serve(context.Background()) }()
309+
310+ errc := make(chan error, 1)
311+ go func() {
312+ errc <- c.Request(context.Background(), MethodRequestPermission, RequestPermissionParams{}, nil)
313+ }()
314+
315+ time.Sleep(20 * time.Millisecond)
316+ _ = fromPeer.Close()
317+
318+ select {
319+ case err := <-errc:
320+ if !errors.Is(err, ErrClosed) {
321+ t.Fatalf("want ErrClosed, got %v", err)
322+ }
323+ case <-time.After(2 * time.Second):
324+ t.Fatal("a pending request outlived its connection")
325+ }
326+ if err := <-served; err != nil {
327+ t.Errorf("Serve after a clean close: %v", err)
328+ }
329+ }
330+
331+ func TestCancelArrivesWhileAPromptIsStillRunning(t *testing.T) {
332+ release := make(chan struct{})
333+ h := &stubHandler{}
334+ h.prompt = func(ctx context.Context, _ PromptRequest) (PromptResponse, error) {
335+ <-release
336+ return PromptResponse{StopReason: StopCancelled}, nil
337+ }
338+ p := newPeer(t, h)
339+
340+ p.send(`{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"sessionId":"s1","prompt":[]}}`)
341+ p.send(`{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"s1"}}`)
342+
343+ // The cancel must land while the prompt is still blocked. Polling rather
344+ // than sleeping a fixed time keeps the test honest on a loaded machine.
345+ deadline := time.Now().Add(2 * time.Second)
346+ for {
347+ h.mu.Lock()
348+ got := len(h.cancelled)
349+ h.mu.Unlock()
350+ if got == 1 {
351+ break
352+ }
353+ if time.Now().After(deadline) {
354+ t.Fatal("the cancel was queued behind the prompt")
355+ }
356+ time.Sleep(time.Millisecond)
357+ }
358+
359+ close(release)
360+ if m := p.read(); m.Error != nil {
361+ t.Fatalf("prompt failed: %v", m.Error)
362+ }
363+ }
364+
365+ func TestCleanEOFIsNotAnError(t *testing.T) {
366+ c := NewConn(strings.NewReader(""), &bytes.Buffer{}, &stubHandler{})
367+ if err := c.Serve(context.Background()); err != nil {
368+ t.Errorf("Serve on an empty stream: %v", err)
369+ }
370+ }
371+
372+ func TestATrailingLineWithoutANewlineIsStillHandled(t *testing.T) {
373+ // A peer that exits right after writing may not terminate its last line.
374+ var out bytes.Buffer
375+ c := NewConn(strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), &out, &stubHandler{})
376+ if err := c.Serve(context.Background()); err != nil {
377+ t.Fatalf("Serve: %v", err)
378+ }
379+ if !strings.Contains(out.String(), `"protocolVersion":1`) {
380+ t.Errorf("the last line was dropped: %q", out.String())
381+ }
382+ }
+308−0internal/acp/types.goaddedView File
@@ -0,0 +1,308 @@
1+ package acp
2+
3+ import "encoding/json"
4+
5+ // ProtocolVersion is the ACP revision this package speaks. T3 Code sends 1 in
6+ // its initialize request and every other client we know of does too; a client
7+ // that asks for something else still gets 1 back, because answering with a
8+ // version we do not implement would be a lie the handshake cannot recover
9+ // from.
10+ const ProtocolVersion = 1
11+
12+ // The method names, spelled once. A typo in a string literal is a method the
13+ // peer never hears about and an error nobody can grep for.
14+ const (
15+ MethodInitialize = "initialize"
16+ MethodAuthenticate = "authenticate"
17+ MethodNewSession = "session/new"
18+ MethodPrompt = "session/prompt"
19+ MethodCancel = "session/cancel"
20+ MethodSessionUpdate = "session/update"
21+ MethodRequestPermission = "session/request_permission"
22+ )
23+
24+ // Implementation names a peer: the agent to the client, or the client to the
25+ // agent.
26+ type Implementation struct {
27+ Name string `json:"name"`
28+ Title string `json:"title,omitempty"`
29+ Version string `json:"version"`
30+ }
31+
32+ // InitializeRequest is the first thing a client sends.
33+ //
34+ // ClientCapabilities is kept raw. We advertise no filesystem or terminal
35+ // capability of our own, so there is nothing in there we act on, and decoding
36+ // a structure we ignore is a decode that can fail for no reason.
37+ type InitializeRequest struct {
38+ ProtocolVersion int `json:"protocolVersion"`
39+ ClientCapabilities json.RawMessage `json:"clientCapabilities,omitempty"`
40+ ClientInfo *Implementation `json:"clientInfo,omitempty"`
41+ }
42+
43+ // InitializeResponse answers it. Only ProtocolVersion is required by the
44+ // protocol; everything else is how the client decides what to ask us for
45+ // later, so leaving a field out is the way to say "do not ask".
46+ type InitializeResponse struct {
47+ ProtocolVersion int `json:"protocolVersion"`
48+ AgentInfo *Implementation `json:"agentInfo,omitempty"`
49+ AgentCapabilities *AgentCapabilities `json:"agentCapabilities,omitempty"`
50+ // AuthMethods is serialized even when empty: a client that sees the key
51+ // knows we answered the question, where a missing key means "unstated".
52+ AuthMethods []AuthMethod `json:"authMethods"`
53+ // Meta is the protocol's escape hatch, and the one thing we put in it is
54+ // ModelStateMeta. A client probing which models an agent has does not want
55+ // to open a session to find out, and answering here is what lets its model
56+ // picker be filled from the endpoint rather than from parsing our own
57+ // command-line output.
58+ Meta map[string]any `json:"_meta,omitempty"`
59+ }
60+
61+ // ModelStateMeta is the key under InitializeResponse.Meta that carries a
62+ // [SessionModelState].
63+ const ModelStateMeta = "modelState"
64+
65+ // AgentCapabilities is what the agent can be asked to do beyond one prompt.
66+ type AgentCapabilities struct {
67+ LoadSession bool `json:"loadSession,omitempty"`
68+ PromptCapabilities *PromptCapabilities `json:"promptCapabilities,omitempty"`
69+ }
70+
71+ // PromptCapabilities is what a prompt may contain.
72+ type PromptCapabilities struct {
73+ Image bool `json:"image,omitempty"`
74+ Audio bool `json:"audio,omitempty"`
75+ EmbeddedContext bool `json:"embeddedContext,omitempty"`
76+ }
77+
78+ // AuthMethod is one way of logging in that the agent offers. We offer none —
79+ // Codey takes its credential from the environment — so this exists to make the
80+ // empty list well typed rather than to be constructed.
81+ type AuthMethod struct {
82+ ID string `json:"id"`
83+ Name string `json:"name"`
84+ Description string `json:"description,omitempty"`
85+ }
86+
87+ // AuthenticateRequest asks the agent to log in with one of the methods it
88+ // advertised. Clients send it unconditionally, including a method id we never
89+ // offered, so the id is informational.
90+ type AuthenticateRequest struct {
91+ MethodID string `json:"methodId"`
92+ }
93+
94+ // NewSessionRequest opens a conversation rooted at a directory.
95+ //
96+ // AdditionalDirectories is not in every published copy of the schema but T3
97+ // Code sends it, and McpServers is accepted and ignored: an agent that refuses
98+ // to decode a field it does not use is an agent that breaks on the client's
99+ // next release.
100+ type NewSessionRequest struct {
101+ Cwd string `json:"cwd"`
102+ McpServers []json.RawMessage `json:"mcpServers,omitempty"`
103+ AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
104+ }
105+
106+ // NewSessionResponse hands back the id every later message carries.
107+ type NewSessionResponse struct {
108+ SessionID string `json:"sessionId"`
109+ Models *SessionModelState `json:"models,omitempty"`
110+ }
111+
112+ // SessionModelState is the model picker: what is selected, and what else there
113+ // is. A client that gets this uses it in preference to anything the agent
114+ // printed on a command line.
115+ type SessionModelState struct {
116+ CurrentModelID string `json:"currentModelId"`
117+ AvailableModels []ModelInfo `json:"availableModels"`
118+ }
119+
120+ // ModelInfo is one entry in that picker.
121+ type ModelInfo struct {
122+ ModelID string `json:"modelId"`
123+ Name string `json:"name"`
124+ Description string `json:"description,omitempty"`
125+ }
126+
127+ // SetModelRequest selects a model mid-session.
128+ type SetModelRequest struct {
129+ SessionID string `json:"sessionId"`
130+ ModelID string `json:"modelId"`
131+ }
132+
133+ // PromptRequest is one user turn.
134+ type PromptRequest struct {
135+ SessionID string `json:"sessionId"`
136+ Prompt []ContentBlock `json:"prompt"`
137+ }
138+
139+ // ContentBlock is a piece of a message. The union is decoded into one struct
140+ // because Go has no sum types and the alternative — a custom unmarshaller per
141+ // variant — buys nothing when the only variant we read is text.
142+ type ContentBlock struct {
143+ Type string `json:"type"`
144+ Text string `json:"text,omitempty"`
145+ Data string `json:"data,omitempty"`
146+ MimeType string `json:"mimeType,omitempty"`
147+ URI string `json:"uri,omitempty"`
148+ Name string `json:"name,omitempty"`
149+ }
150+
151+ // The stop reasons a prompt can end with.
152+ const (
153+ StopEndTurn = "end_turn"
154+ StopCancelled = "cancelled"
155+ StopRefusal = "refusal"
156+ StopMaxTokens = "max_tokens"
157+ StopMaxTurnRequests = "max_turn_requests"
158+ )
159+
160+ // PromptResponse ends the turn. StopReason is the only field the clients we
161+ // know of read.
162+ type PromptResponse struct {
163+ StopReason string `json:"stopReason"`
164+ }
165+
166+ // CancelParams asks for the turn in flight to stop. It is a notification, so
167+ // there is nothing to answer: the acknowledgement is the prompt coming back
168+ // with StopCancelled.
169+ type CancelParams struct {
170+ SessionID string `json:"sessionId"`
171+ }
172+
173+ // SessionNotification is everything the agent says while a turn runs. One
174+ // struct with a tagged Update, because that is how the wire is shaped.
175+ type SessionNotification struct {
176+ SessionID string `json:"sessionId"`
177+ Update Update `json:"update"`
178+ }
179+
180+ // The sessionUpdate discriminators we emit.
181+ const (
182+ UpdateAgentMessageChunk = "agent_message_chunk"
183+ UpdateAgentThoughtChunk = "agent_thought_chunk"
184+ UpdateToolCall = "tool_call"
185+ UpdateToolCallUpdate = "tool_call_update"
186+ )
187+
188+ // Update is the tagged body of a session notification. Every field beyond
189+ // SessionUpdate is optional, and which ones are read depends on the tag.
190+ //
191+ // Content is `any` because the wire reuses one key for two shapes: on a chunk
192+ // it is a single content block, on a tool call it is a list of tool call
193+ // content. Updates are only ever written, never decoded, so carrying the union
194+ // as an interface costs nothing and spells out that the shape follows the tag.
195+ type Update struct {
196+ SessionUpdate string `json:"sessionUpdate"`
197+ Content any `json:"content,omitempty"`
198+
199+ // Set on the tool-call variants. ToolCallID is what pairs an update with
200+ // the call it belongs to, so it is the one field that is never optional
201+ // there.
202+ ToolCallID string `json:"toolCallId,omitempty"`
203+ Title string `json:"title,omitempty"`
204+ Kind string `json:"kind,omitempty"`
205+ Status string `json:"status,omitempty"`
206+ RawInput json.RawMessage `json:"rawInput,omitempty"`
207+ }
208+
209+ // The tool kinds ACP defines. A client uses them to pick an icon and to decide
210+ // what to fold away, so a wrong one is cosmetic and a missing one is fine.
211+ const (
212+ KindRead = "read"
213+ KindEdit = "edit"
214+ KindDelete = "delete"
215+ KindMove = "move"
216+ KindSearch = "search"
217+ KindExecute = "execute"
218+ KindThink = "think"
219+ KindFetch = "fetch"
220+ KindOther = "other"
221+ )
222+
223+ // The tool call statuses.
224+ const (
225+ StatusPending = "pending"
226+ StatusInProgress = "in_progress"
227+ StatusCompleted = "completed"
228+ StatusFailed = "failed"
229+ )
230+
231+ // ToolCallContent is what a tool produced: text, or a diff a client can render
232+ // side by side.
233+ type ToolCallContent struct {
234+ Type string `json:"type"`
235+ Content *ContentBlock `json:"content,omitempty"`
236+
237+ // Set when Type is "diff".
238+ Path string `json:"path,omitempty"`
239+ OldText string `json:"oldText,omitempty"`
240+ NewText string `json:"newText,omitempty"`
241+ }
242+
243+ // TextContent is the content block a chunk carries, which is always text in
244+ // what Codey emits.
245+ func TextContent(text string) *ContentBlock {
246+ return &ContentBlock{Type: "text", Text: text}
247+ }
248+
249+ // ToolCallText wraps tool output as tool call content.
250+ func ToolCallText(text string) ToolCallContent {
251+ return ToolCallContent{Type: "content", Content: TextContent(text)}
252+ }
253+
254+ // RequestPermissionParams asks the client to decide about one tool call.
255+ type RequestPermissionParams struct {
256+ SessionID string `json:"sessionId"`
257+ ToolCall PermissionToolCall `json:"toolCall"`
258+ Options []PermissionOption `json:"options"`
259+ }
260+
261+ // PermissionToolCall describes what is waiting on the answer.
262+ type PermissionToolCall struct {
263+ ToolCallID string `json:"toolCallId"`
264+ Title string `json:"title,omitempty"`
265+ Kind string `json:"kind,omitempty"`
266+ Status string `json:"status,omitempty"`
267+ RawInput json.RawMessage `json:"rawInput,omitempty"`
268+ }
269+
270+ // PermissionOption is one answer the client may give.
271+ type PermissionOption struct {
272+ OptionID string `json:"optionId"`
273+ Name string `json:"name"`
274+ Kind string `json:"kind"`
275+ }
276+
277+ // The option kinds, and the option ids we use.
278+ //
279+ // The ids are not ours to invent freely. Cursor's adapter in T3 Code replies
280+ // with these three literals regardless of what the agent offered, so choosing
281+ // them here is what lets the same binary sit in a Cursor-shaped slot as well
282+ // as a Grok-shaped one.
283+ const (
284+ KindAllowOnce = "allow_once"
285+ KindAllowAlways = "allow_always"
286+ KindRejectOnce = "reject_once"
287+
288+ OptAllowOnce = "allow-once"
289+ OptAllowAlways = "allow-always"
290+ OptRejectOnce = "reject-once"
291+ )
292+
293+ // RequestPermissionResult is the answer.
294+ type RequestPermissionResult struct {
295+ Outcome PermissionOutcome `json:"outcome"`
296+ }
297+
298+ // PermissionOutcome is either a selection or a cancellation.
299+ type PermissionOutcome struct {
300+ Outcome string `json:"outcome"`
301+ OptionID string `json:"optionId,omitempty"`
302+ }
303+
304+ // The outcome tags.
305+ const (
306+ OutcomeSelected = "selected"
307+ OutcomeCancelled = "cancelled"
308+ )
+87−0internal/acp/wire.goaddedView File
@@ -0,0 +1,87 @@
1+ // Package acp speaks the Agent Client Protocol: JSON-RPC 2.0 framed as one
2+ // compact object per line, over a pair of streams that are usually this
3+ // process's stdin and stdout.
4+ //
5+ // It is a peer, not a client or a server. The other end calls initialize,
6+ // opens sessions and sends prompts; this end calls back to ask permission for
7+ // a tool. Both directions run over the same connection at the same time, which
8+ // is why the read loop dispatches every inbound message onto its own goroutine
9+ // — a permission request answered on the read loop would be a deadlock with
10+ // the peer doing the same thing.
11+ //
12+ // The package knows nothing about agents, models or tools. It is the wire and
13+ // the vocabulary, and [Handler] is where somebody else's meaning attaches.
14+ package acp
15+
16+ import (
17+ "encoding/json"
18+ "fmt"
19+ )
20+
21+ // jsonrpcVersion is required on every frame in both directions. The clients
22+ // that matter validate it as a literal, so omitting it is not a shortcut, it
23+ // is a decode error at the far end.
24+ const jsonrpcVersion = "2.0"
25+
26+ // Message is one frame. The four payload fields are mutually exclusive in
27+ // practice — a request has Method, a response has Result or Error — but they
28+ // share a struct because that is how they share a line.
29+ type Message struct {
30+ JSONRPC string `json:"jsonrpc"`
31+ // ID is kept raw so a string id and a numeric one both come back exactly
32+ // as they were sent. A response whose id does not match byte for byte is a
33+ // response the peer cannot pair up.
34+ ID *json.RawMessage `json:"id,omitempty"`
35+ Method string `json:"method,omitempty"`
36+ Params json.RawMessage `json:"params,omitempty"`
37+ Result json.RawMessage `json:"result,omitempty"`
38+ Error *Error `json:"error,omitempty"`
39+ }
40+
41+ // The JSON-RPC error codes this package produces.
42+ const (
43+ CodeParse = -32700
44+ CodeInvalidRequest = -32600
45+ CodeMethodNotFound = -32601
46+ CodeInvalidParams = -32602
47+ CodeInternal = -32603
48+ )
49+
50+ // Error is a JSON-RPC error object, and also a Go error: a [Handler] that
51+ // returns one chooses the code the peer sees, and anything else becomes
52+ // CodeInternal.
53+ type Error struct {
54+ Code int `json:"code"`
55+ Message string `json:"message"`
56+ Data any `json:"data,omitempty"`
57+ }
58+
59+ func (e *Error) Error() string { return fmt.Sprintf("acp: %s (%d)", e.Message, e.Code) }
60+
61+ // Errorf builds an [Error] with a formatted message.
62+ func Errorf(code int, format string, args ...any) *Error {
63+ return &Error{Code: code, Message: fmt.Sprintf(format, args...)}
64+ }
65+
66+ // asError turns whatever a handler returned into something the peer can be
67+ // told. An error that already chose its code keeps it.
68+ func asError(err error) *Error {
69+ if e, ok := err.(*Error); ok {
70+ return e
71+ }
72+ return &Error{Code: CodeInternal, Message: err.Error()}
73+ }
74+
75+ // decodeParams unmarshals a request's params, reporting a bad shape as
76+ // CodeInvalidParams rather than as a server fault. Absent params decode as the
77+ // zero value: a method whose arguments are all optional may be called with
78+ // none.
79+ func decodeParams(raw json.RawMessage, v any) error {
80+ if len(raw) == 0 || string(raw) == "null" {
81+ return nil
82+ }
83+ if err := json.Unmarshal(raw, v); err != nil {
84+ return Errorf(CodeInvalidParams, "cannot decode params: %v", err)
85+ }
86+ return nil
87+ }
+14−8internal/agent/loop.goView File
@@ -178,10 +178,16 @@ const (
178178 // Event is what the loop reports to an observer. It carries plain data only —
179179 // no terminal concerns — so the eval runner can consume the same stream.
180180 type Event struct {
181- Kind EventKind
182- Turn int
183- Text string
184- Tool string
181+ Kind EventKind
182+ Turn int
183+ Text string
184+ Tool string
185+ // ID pairs a tool_start with the tool_result that answers it: the model's
186+ // own tool_use id, echoed on both. Dispatch is sequential, so a front end
187+ // can get away with pairing by arrival order — but a protocol that keys
188+ // tool calls by id, as ACP does, needs the id rather than a promise about
189+ // the order things arrive in.
190+ ID string
185191 Input json.RawMessage
186192 Result string
187193 IsError bool
@@ -488,7 +494,7 @@ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) {
488494 func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock) ([]llm.ContentBlock, error) {
489495 results := make([]llm.ContentBlock, 0, len(calls))
490496 for _, call := range calls {
491- a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, Input: call.Input})
497+ a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, ID: call.ID, Input: call.Input})
492498
493499 out, err := a.authorizedExecute(ctx, call)
494500 if ctx.Err() != nil {
@@ -513,7 +519,7 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock
513519 }
514520
515521 results = append(results, llm.ToolResultBlock(call.ID, payload, isErr))
516- a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr})
522+ a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, ID: call.ID, Result: payload, IsError: isErr})
517523 }
518524 return results, nil
519525 }
@@ -533,7 +539,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) {
533539 switch b.Type {
534540 case llm.BlockServerToolUse:
535541 names[b.ID] = b.Name
536- a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, Input: b.Input})
542+ a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, ID: b.ID, Input: b.Input})
537543 case llm.BlockWebSearchToolResult:
538544 name, ok := names[b.ToolUseID]
539545 if !ok {
@@ -542,7 +548,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) {
542548 name = llm.WebSearchToolName
543549 }
544550 summary, isErr := summarizeWebSearchResult(b)
545- a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, Result: summary, IsError: isErr})
551+ a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, ID: b.ToolUseID, Result: summary, IsError: isErr})
546552 }
547553 }
548554 }
+8−3internal/approval/policy.goView File
@@ -90,7 +90,7 @@ func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.R
9090 if p == nil || p.YOLO || readOnly[tool] {
9191 return nil
9292 }
93- req := describe(tool, input)
93+ req := Describe(tool, input)
9494 req.Agent = agent
9595
9696 p.mu.Lock()
@@ -159,8 +159,13 @@ func article(word string) string {
159159 return "a"
160160 }
161161
162- // describe turns a tool call into something a human can decide on in one read.
163- func describe(tool string, input json.RawMessage) Request {
162+ // Describe turns a tool call into something a human can decide on in one read.
163+ //
164+ // It is exported because the summary is not only for a prompt box: a front end
165+ // that shows the agent's tool calls as they happen wants the same one-line
166+ // description, and two vocabularies for the same call would be two things to
167+ // keep in step.
168+ func Describe(tool string, input json.RawMessage) Request {
164169 var args struct {
165170 Command string `json:"command"`
166171 Path string `json:"path"`
+7−7internal/approval/policy_test.goView File
@@ -139,7 +139,7 @@ func TestRequestSummaries(t *testing.T) {
139139 }
140140 for _, tc := range tests {
141141 t.Run(tc.tool, func(t *testing.T) {
142- got := describe(tc.tool, json.RawMessage(tc.input))
142+ got := Describe(tc.tool, json.RawMessage(tc.input))
143143 for _, w := range tc.want {
144144 if !strings.Contains(got.Summary, w) {
145145 t.Errorf("summary %q missing %q", got.Summary, w)
@@ -189,7 +189,7 @@ func TestApproveAsTagsTheRequestWithTheAgent(t *testing.T) {
189189 // every time — so "always allow this exact one" would be a promise about a
190190 // string nobody will ever repeat. The tool-wide answer is the one on offer.
191191 func TestSpawnAgentIsDescribedByPresetAndIsNotRememberedByTask(t *testing.T) {
192- req := describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find the approval policy","model":"claude-haiku-4-5"}`))
192+ req := Describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find the approval policy","model":"claude-haiku-4-5"}`))
193193
194194 if !strings.Contains(req.Summary, "an explore agent") {
195195 t.Errorf("summary %q does not name the preset, with the article that fits it", req.Summary)
@@ -208,7 +208,7 @@ func TestSpawnAgentIsDescribedByPresetAndIsNotRememberedByTask(t *testing.T) {
208208 // A spawn with no model named runs on the session's, and the prompt should say
209209 // that rather than invent an id.
210210 func TestSpawnAgentWithoutAModelSaysSo(t *testing.T) {
211- req := describe("spawn_agent", json.RawMessage(`{"preset":"plan","task":"design it"}`))
211+ req := Describe("spawn_agent", json.RawMessage(`{"preset":"plan","task":"design it"}`))
212212 if !strings.Contains(req.Summary, "a plan agent") {
213213 t.Errorf("summary %q does not name the preset", req.Summary)
214214 }
@@ -220,13 +220,13 @@ func TestSpawnAgentWithoutAModelSaysSo(t *testing.T) {
220220 // What a spawn will spend is the model and the effort together, so the box
221221 // that asks about it names both.
222222 func TestSpawnApprovalNamesTheEffortWhenThereIsOne(t *testing.T) {
223- with := describe("spawn_agent", json.RawMessage(
223+ with := Describe("spawn_agent", json.RawMessage(
224224 `{"preset":"explore","task":"find it","model":"claude-opus-5","effort":"low"}`))
225225 if !strings.Contains(with.Summary, "low effort") {
226226 t.Errorf("summary %q does not say what effort it will run at", with.Summary)
227227 }
228228
229- without := describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find it"}`))
229+ without := Describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find it"}`))
230230 if strings.Contains(without.Summary, "effort") {
231231 t.Errorf("summary %q invents an effort level nobody asked for", without.Summary)
232232 }
@@ -242,7 +242,7 @@ func TestWebToolsPromptAndShowWhatLeaves(t *testing.T) {
242242 }
243243 }
244244
245- search := describe("web_search", json.RawMessage(`{"query":"go 1.26 release notes"}`))
245+ search := Describe("web_search", json.RawMessage(`{"query":"go 1.26 release notes"}`))
246246 if search.Detail != "go 1.26 release notes" {
247247 t.Errorf("the query is not the detail: %q", search.Detail)
248248 }
@@ -253,7 +253,7 @@ func TestWebToolsPromptAndShowWhatLeaves(t *testing.T) {
253253 t.Error("a search that differs every call should not be rememberable")
254254 }
255255
256- fetch := describe("web_fetch", json.RawMessage(`{"url":"https://go.dev/doc/go1.26"}`))
256+ fetch := Describe("web_fetch", json.RawMessage(`{"url":"https://go.dev/doc/go1.26"}`))
257257 if fetch.Detail != "https://go.dev/doc/go1.26" {
258258 t.Errorf("the URL is not the detail: %q", fetch.Detail)
259259 }
+289−0internal/serve/conformance_test.goaddedView File
@@ -0,0 +1,289 @@
1+ package serve
2+
3+ import (
4+ "encoding/json"
5+ "slices"
6+ "strings"
7+ "testing"
8+
9+ "codey/internal/acp"
10+ )
11+
12+ // The handshake T3 Code actually sends, byte for byte.
13+ //
14+ // These are not paraphrases of the protocol. They are the literal payloads
15+ // built in T3 Code's ACP session runtime, and the assertions below are its
16+ // schema's required fields. When a nightly of theirs breaks us, this is the
17+ // test that says where — which is worth more than a test written against what
18+ // the specification says ought to happen.
19+ //
20+ // Source: apps/server/src/provider/acp/AcpSessionRuntime.ts, at t3code
21+ // 9accc567. initialize at :696-705 with capabilities from :566-578,
22+ // authenticate at :710-718, session/new at :836-849, session/prompt at
23+ // :1005-1014.
24+ const (
25+ t3Initialize = `{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"t3-code","version":"0.0.0"}}`
26+ t3Authenticate = `{"methodId":"cached_token"}`
27+ )
28+
29+ // raw sends a method with params already encoded, so nothing between the test
30+ // and the wire can quietly fix a payload up.
31+ func (c *testClient) raw(method, params string) json.RawMessage {
32+ c.t.Helper()
33+ c.nextID++
34+ id := json.RawMessage(itoa(c.nextID))
35+ c.write(acpMessage{JSONRPC: "2.0", ID: &id, Method: method, Params: json.RawMessage(params)})
36+
37+ m := c.pump(string(id))
38+ if m.Error != nil {
39+ c.t.Fatalf("%s: %v", method, m.Error)
40+ }
41+ return m.Result
42+ }
43+
44+ // acpMessage mirrors the wire frame so this file can write one by hand.
45+ type acpMessage struct {
46+ JSONRPC string `json:"jsonrpc"`
47+ ID *json.RawMessage `json:"id,omitempty"`
48+ Method string `json:"method,omitempty"`
49+ Params json.RawMessage `json:"params,omitempty"`
50+ }
51+
52+ func field(t *testing.T, raw json.RawMessage, name string) any {
53+ t.Helper()
54+ var m map[string]any
55+ if err := json.Unmarshal(raw, &m); err != nil {
56+ t.Fatalf("result is not an object: %s", raw)
57+ }
58+ v, ok := m[name]
59+ if !ok {
60+ t.Fatalf("result has no %q: %s", name, raw)
61+ }
62+ return v
63+ }
64+
65+ func TestTheHandshakeT3CodeActuallySends(t *testing.T) {
66+ f := newFakeAPI(t, msg("end_turn", textBlock("hello from codey")))
67+ c := newTestServer(t, f, Options{})
68+
69+ // initialize — the client requires protocolVersion, a number.
70+ init := c.raw("initialize", t3Initialize)
71+ if v, ok := field(t, init, "protocolVersion").(float64); !ok || int(v) != 1 {
72+ t.Fatalf("protocolVersion = %v, want 1", field(t, init, "protocolVersion"))
73+ }
74+
75+ // authenticate — sent unconditionally with a method id we never offered,
76+ // and its failure is not caught on the other end.
77+ c.raw("authenticate", t3Authenticate)
78+
79+ // session/new — only sessionId is consumed, and it must be a string.
80+ root := t.TempDir()
81+ newSession := c.raw("session/new", `{"cwd":`+quote(root)+`,"mcpServers":[]}`)
82+ id, ok := field(t, newSession, "sessionId").(string)
83+ if !ok || id == "" {
84+ t.Fatalf("sessionId = %v", field(t, newSession, "sessionId"))
85+ }
86+
87+ // session/prompt — only stopReason is read, and it has to be one of five.
88+ prompt := c.raw("session/prompt", `{"sessionId":`+quote(id)+`,"prompt":[{"type":"text","text":"say hello"}]}`)
89+ stop, ok := field(t, prompt, "stopReason").(string)
90+ if !ok {
91+ t.Fatalf("stopReason = %v", field(t, prompt, "stopReason"))
92+ }
93+ valid := []string{"end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"}
94+ if !slices.Contains(valid, stop) {
95+ t.Fatalf("stopReason %q is not one the client's schema allows", stop)
96+ }
97+
98+ // session/cancel is a notification: no id, nothing to answer.
99+ c.write(acpMessage{JSONRPC: "2.0", Method: "session/cancel", Params: json.RawMessage(`{"sessionId":` + quote(id) + `}`)})
100+ }
101+
102+ func TestEveryNotificationDecodesAgainstTheClientSchema(t *testing.T) {
103+ f := newFakeAPI(t,
104+ msg("tool_use", toolUseBlock("t1", "write_file", `{"path":"note.txt","content":"hi"}`)),
105+ msg("end_turn", textBlock("done")),
106+ )
107+ c := newTestServer(t, f, Options{Permission: ModeFullAccess})
108+
109+ id := c.handshake(t.TempDir())
110+ c.prompt(id, "write a note")
111+
112+ updates := c.sessionUpdates(id)
113+ if len(updates) == 0 {
114+ t.Fatal("the turn produced no updates at all")
115+ }
116+
117+ // The variants the client switches on. Anything else it drops silently,
118+ // which is a worse failure than a loud one: the row simply never appears.
119+ known := []string{
120+ "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update",
121+ "plan", "current_mode_update", "config_option_update", "available_commands_update",
122+ }
123+
124+ var sawToolCall, sawToolUpdate bool
125+ for _, u := range updates {
126+ if !slices.Contains(known, u.SessionUpdate) {
127+ t.Errorf("sessionUpdate %q is not one the client handles", u.SessionUpdate)
128+ }
129+ switch u.SessionUpdate {
130+ case "agent_message_chunk", "agent_thought_chunk":
131+ // The client reads content.type == "text" and content.text.
132+ raw, _ := json.Marshal(u.Content)
133+ var content struct {
134+ Type string `json:"type"`
135+ Text string `json:"text"`
136+ }
137+ if err := json.Unmarshal(raw, &content); err != nil || content.Type != "text" {
138+ t.Errorf("chunk content is not a text block: %s", raw)
139+ }
140+ case "tool_call":
141+ sawToolCall = true
142+ // title and toolCallId are the two required fields.
143+ if u.ToolCallID == "" || u.Title == "" {
144+ t.Errorf("tool_call is missing a required field: %+v", u)
145+ }
146+ if !slices.Contains([]string{"read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"}, u.Kind) {
147+ t.Errorf("tool kind %q is not in the client's vocabulary", u.Kind)
148+ }
149+ if !slices.Contains([]string{"pending", "in_progress", "completed", "failed"}, u.Status) {
150+ t.Errorf("tool status %q is not in the client's vocabulary", u.Status)
151+ }
152+ case "tool_call_update":
153+ sawToolUpdate = true
154+ if u.ToolCallID == "" {
155+ t.Error("tool_call_update with no toolCallId cannot be matched to its call")
156+ }
157+ }
158+ }
159+
160+ if !sawToolCall || !sawToolUpdate {
161+ t.Errorf("a turn that ran a tool produced no tool rows: %+v", updates)
162+ }
163+ }
164+
165+ func TestAPermissionRequestMatchesTheClientSchema(t *testing.T) {
166+ f := newFakeAPI(t,
167+ msg("tool_use", toolUseBlock("t1", "write_file", `{"path":"note.txt","content":"hi"}`)),
168+ msg("end_turn", textBlock("done")),
169+ )
170+ c := newTestServer(t, f, Options{Permission: ModeAsk})
171+
172+ c.onPermission = func(acp.RequestPermissionParams) acp.RequestPermissionResult {
173+ return acp.RequestPermissionResult{
174+ Outcome: acp.PermissionOutcome{Outcome: acp.OutcomeSelected, OptionID: acp.OptAllowOnce},
175+ }
176+ }
177+
178+ id := c.handshake(t.TempDir())
179+ c.prompt(id, "write a note")
180+
181+ c.mu.Lock()
182+ seen := c.permission
183+ c.mu.Unlock()
184+ if seen == nil {
185+ t.Fatal("no permission request was sent")
186+ }
187+
188+ var params struct {
189+ SessionID string `json:"sessionId"`
190+ ToolCall struct {
191+ ToolCallID string `json:"toolCallId"`
192+ } `json:"toolCall"`
193+ Options []struct {
194+ OptionID string `json:"optionId"`
195+ Name string `json:"name"`
196+ Kind string `json:"kind"`
197+ } `json:"options"`
198+ }
199+ if err := json.Unmarshal(seen, &params); err != nil {
200+ t.Fatalf("the request does not decode: %v\n%s", err, seen)
201+ }
202+ if params.SessionID != id || params.ToolCall.ToolCallID == "" {
203+ t.Errorf("params = %s", seen)
204+ }
205+ if len(params.Options) == 0 {
206+ t.Fatal("a permission request with no options cannot be answered")
207+ }
208+
209+ // The option kinds are a closed set, and the ids have to include the ones
210+ // a client may reply with regardless of what we offered.
211+ var ids []string
212+ for _, o := range params.Options {
213+ if o.Name == "" {
214+ t.Errorf("an option with no name cannot be shown: %+v", o)
215+ }
216+ if !slices.Contains([]string{"allow_once", "allow_always", "reject_once", "reject_always"}, o.Kind) {
217+ t.Errorf("option kind %q is not in the client's vocabulary", o.Kind)
218+ }
219+ ids = append(ids, o.OptionID)
220+ }
221+ for _, want := range []string{"allow-once", "allow-always", "reject-once"} {
222+ if !slices.Contains(ids, want) {
223+ t.Errorf("option id %q is missing; a client that replies with it would not be understood. got %v", want, ids)
224+ }
225+ }
226+ }
227+
228+ // quote is strconv.Quote for the handful of JSON strings built by hand here.
229+ func quote(s string) string {
230+ b, _ := json.Marshal(s)
231+ return string(b)
232+ }
233+
234+ // stray is a guard: nothing but protocol frames may reach the stream.
235+ func TestNothingButFramesReachTheStream(t *testing.T) {
236+ f := newFakeAPI(t, msg("end_turn", textBlock("quiet")))
237+ c := newTestServer(t, f, Options{})
238+
239+ id := c.handshake(t.TempDir())
240+ c.prompt(id, "say something")
241+
242+ for _, line := range c.lines() {
243+ if strings.TrimSpace(line) == "" {
244+ continue
245+ }
246+ var probe map[string]any
247+ if err := json.Unmarshal([]byte(line), &probe); err != nil {
248+ t.Fatalf("a non-JSON line reached the stream and would desynchronise the parser: %q", line)
249+ }
250+ if probe["jsonrpc"] != "2.0" {
251+ t.Errorf("a frame without jsonrpc 2.0: %q", line)
252+ }
253+ }
254+ }
255+
256+ func TestTheProbeCanReadTheModelsFromTheHandshakeAlone(t *testing.T) {
257+ // A health check spawns the agent, shakes hands and stops. T3 Code reads
258+ // initialize._meta.modelState on exactly that path, so a model list only
259+ // offered at session/new never reaches the provider card.
260+ c := newTestServer(t, newFakeAPI(t), Options{
261+ Models: []acp.ModelInfo{{ModelID: "codey-opus-5", Name: "Codey Opus 5"}},
262+ DefaultModel: "codey-opus-5",
263+ })
264+
265+ init := c.raw("initialize", t3Initialize)
266+
267+ var resp struct {
268+ Meta struct {
269+ ModelState struct {
270+ CurrentModelID string `json:"currentModelId"`
271+ AvailableModels []struct {
272+ ModelID string `json:"modelId"`
273+ Name string `json:"name"`
274+ } `json:"availableModels"`
275+ } `json:"modelState"`
276+ } `json:"_meta"`
277+ }
278+ if err := json.Unmarshal(init, &resp); err != nil {
279+ t.Fatalf("initialize result: %v", err)
280+ }
281+
282+ state := resp.Meta.ModelState
283+ if state.CurrentModelID != "codey-opus-5" || len(state.AvailableModels) != 1 {
284+ t.Fatalf("_meta.modelState = %+v, from %s", state, init)
285+ }
286+ if state.AvailableModels[0].Name != "Codey Opus 5" {
287+ t.Errorf("model name = %q", state.AvailableModels[0].Name)
288+ }
289+ }
+359−0internal/serve/serve.goaddedView File
@@ -0,0 +1,359 @@
1+ // Package serve runs Codey as an agent another program drives, speaking the
2+ // Agent Client Protocol over stdin and stdout.
3+ //
4+ // It is a front end, the same way the TUI and the one-shot `-p` mode are: it
5+ // owns no agent behaviour and makes no decisions the other two do not. What it
6+ // adds is a translation. The agent reports what it is doing through
7+ // [agent.Agent.Observe] and asks permission through [agent.Agent.Approve];
8+ // this package turns the first into `session/update` notifications and the
9+ // second into a `session/request_permission` round trip, and turns the
10+ // client's prompts back into turns.
11+ //
12+ // One process serves many sessions. A client that opens three threads gets
13+ // three conversations here, each with its own history, its own tool set rooted
14+ // where it asked, and its own record of what the user has permanently allowed
15+ // — because "always allow this" is a statement about one conversation, not
16+ // about the process.
17+ package serve
18+
19+ import (
20+ "context"
21+ "encoding/json"
22+ "errors"
23+ "fmt"
24+ "io"
25+ "strings"
26+ "sync"
27+
28+ "codey/internal/acp"
29+ "codey/internal/agent"
30+ "codey/internal/approval"
31+ "codey/internal/llm"
32+ )
33+
34+ // PermissionMode is how tool calls are gated, chosen on the command line by
35+ // whoever launched us. It is a floor, not a policy: the client can still be
36+ // asked about anything the mode does not settle.
37+ type PermissionMode string
38+
39+ const (
40+ // ModeAsk asks about every mutating call. The default, and the only mode
41+ // that is safe against a tree you care about.
42+ ModeAsk PermissionMode = "default"
43+ // ModeAcceptEdits lets file edits through and still asks before a command
44+ // runs. An edit is reviewable after the fact; a command is not.
45+ ModeAcceptEdits PermissionMode = "acceptEdits"
46+ // ModeAuto asks only about calls that cannot be pinned down — an opaque
47+ // shell command, a search whose query is different every time. Everything
48+ // whose exact shape could have been remembered is allowed.
49+ ModeAuto PermissionMode = "auto"
50+ // ModeFullAccess never asks. Only against a throwaway clone or inside a
51+ // container.
52+ ModeFullAccess PermissionMode = "full-access"
53+ )
54+
55+ // allows reports whether the mode settles this call without asking.
56+ func (m PermissionMode) allows(req approval.Request) bool {
57+ switch m {
58+ case ModeFullAccess:
59+ return true
60+ case ModeAuto:
61+ return req.Rememberable
62+ case ModeAcceptEdits:
63+ return req.Tool == "edit_file" || req.Tool == "write_file"
64+ default:
65+ return false
66+ }
67+ }
68+
69+ // NewAgentFunc builds the agent for one session.
70+ //
71+ // root is the directory the client asked to work in, which is not necessarily
72+ // the one this process was started in — a client that manages worktrees hands
73+ // out a different one per thread. model is empty for the default. state is the
74+ // conversation, passed in rather than created here so that selecting another
75+ // model can rebuild the agent around a history that survives the switch.
76+ type NewAgentFunc func(root, model string, state *agent.State) (*agent.Agent, error)
77+
78+ // Options configures a [Server].
79+ type Options struct {
80+ // NewAgent is required.
81+ NewAgent NewAgentFunc
82+ // Version is reported in the initialize handshake.
83+ Version string
84+ // Permission is the approval floor for every session.
85+ Permission PermissionMode
86+ // Models is the picker the client shows, and DefaultModel is what is
87+ // selected in it. Both may be empty, in which case the client is told
88+ // nothing and uses whatever the agent was configured with.
89+ Models []acp.ModelInfo
90+ DefaultModel string
91+ }
92+
93+ // Server is the ACP end of the process.
94+ type Server struct {
95+ opts Options
96+ conn *acp.Conn
97+
98+ mu sync.Mutex
99+ sessions map[string]*session
100+ }
101+
102+ // New builds a server. Nothing runs until [Server.Run].
103+ func New(opts Options) *Server {
104+ return &Server{opts: opts, sessions: make(map[string]*session)}
105+ }
106+
107+ // Run serves one connection until the stream ends. A clean end of input is how
108+ // a client says goodbye, and it returns nil.
109+ func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error {
110+ s.conn = acp.NewConn(r, w, s)
111+ return s.conn.Serve(ctx)
112+ }
113+
114+ // Initialize answers the handshake.
115+ //
116+ // The protocol version we answer with is ours, not the client's: agreeing to a
117+ // revision we do not implement would be a handshake that succeeds and a
118+ // session that misbehaves later, which is harder to diagnose than a refusal.
119+ func (s *Server) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error) {
120+ resp := acp.InitializeResponse{
121+ ProtocolVersion: acp.ProtocolVersion,
122+ AgentInfo: &acp.Implementation{Name: "codey", Title: "Codey", Version: s.opts.Version},
123+ AgentCapabilities: &acp.AgentCapabilities{
124+ // Images go through: a screenshot is the fastest way to say what
125+ // is wrong with a layout. Session loading does not, yet — and not
126+ // advertising it is what stops a client from asking.
127+ PromptCapabilities: &acp.PromptCapabilities{Image: true},
128+ },
129+ AuthMethods: []acp.AuthMethod{},
130+ }
131+
132+ // The model list, for a client that is only probing. A health check
133+ // spawns the agent, shakes hands and stops; answering here is what puts
134+ // the models on its provider card without it having to open a session it
135+ // will not use.
136+ if len(s.opts.Models) > 0 {
137+ resp.Meta = map[string]any{acp.ModelStateMeta: s.modelState()}
138+ }
139+ return resp, nil
140+ }
141+
142+ func (s *Server) modelState() *acp.SessionModelState {
143+ if len(s.opts.Models) == 0 {
144+ return nil
145+ }
146+ return &acp.SessionModelState{
147+ CurrentModelID: s.opts.DefaultModel,
148+ AvailableModels: s.opts.Models,
149+ }
150+ }
151+
152+ // Authenticate always succeeds.
153+ //
154+ // Codey takes its credential from the environment before this process starts,
155+ // so there is nothing to log in to and no method id worth checking. Clients
156+ // send this unconditionally and do not catch a failure, so an error here would
157+ // be a session that never opens, over a question we were never asking.
158+ func (s *Server) Authenticate(context.Context, acp.AuthenticateRequest) error { return nil }
159+
160+ // NewSession opens a conversation rooted where the client asked.
161+ func (s *Server) NewSession(_ context.Context, req acp.NewSessionRequest) (acp.NewSessionResponse, error) {
162+ if req.Cwd == "" {
163+ return acp.NewSessionResponse{}, acp.Errorf(acp.CodeInvalidParams, "session/new needs a cwd")
164+ }
165+
166+ sess := &session{
167+ id: newSessionID(),
168+ root: req.Cwd,
169+ state: agent.NewState(),
170+ model: s.opts.DefaultModel,
171+ }
172+ sess.emit = newEmitter(notifier(s.conn, sess.id))
173+ sess.policy = approval.New(sess.promptFor(s.conn, s.opts.Permission))
174+ sess.policy.YOLO = s.opts.Permission == ModeFullAccess
175+
176+ if err := s.build(sess); err != nil {
177+ return acp.NewSessionResponse{}, err
178+ }
179+
180+ s.mu.Lock()
181+ s.sessions[sess.id] = sess
182+ s.mu.Unlock()
183+
184+ resp := acp.NewSessionResponse{SessionID: sess.id}
185+ if state := s.modelState(); state != nil {
186+ state.CurrentModelID = sess.model
187+ resp.Models = state
188+ }
189+ return resp, nil
190+ }
191+
192+ // build attaches an agent to a session, or replaces the one it has. The caller
193+ // holds whatever lock the session needs.
194+ func (s *Server) build(sess *session) error {
195+ a, err := s.opts.NewAgent(sess.root, sess.model, sess.state)
196+ if err != nil {
197+ return acp.Errorf(acp.CodeInternal, "cannot start an agent in %s: %v", sess.root, err)
198+ }
199+ a.Approve = sess.policy
200+ a.Observe = sess.emit.handle
201+ sess.agent = a
202+ return nil
203+ }
204+
205+ // Prompt runs one turn.
206+ func (s *Server) Prompt(ctx context.Context, req acp.PromptRequest) (acp.PromptResponse, error) {
207+ sess, err := s.lookup(req.SessionID)
208+ if err != nil {
209+ return acp.PromptResponse{}, err
210+ }
211+
212+ blocks := promptBlocks(req.Prompt)
213+ if len(blocks) == 0 {
214+ return acp.PromptResponse{StopReason: acp.StopEndTurn}, nil
215+ }
216+
217+ sess.mu.Lock()
218+ defer sess.mu.Unlock()
219+
220+ turnCtx := sess.begin(ctx)
221+ defer sess.end()
222+
223+ _, runErr := sess.agent.RunBlocks(turnCtx, blocks)
224+
225+ // Whatever happened, say the last of what was written before reporting how
226+ // the turn ended. A buffer dropped on the way out is a sentence the user
227+ // never sees.
228+ sess.emit.Flush()
229+
230+ switch {
231+ case sess.wasCancelled(), errors.Is(runErr, context.Canceled):
232+ // Not an error: the session is intact and the next message can correct
233+ // course. That is the whole point of a stop button.
234+ return acp.PromptResponse{StopReason: acp.StopCancelled}, nil
235+
236+ case errors.Is(runErr, agent.ErrMaxTurns):
237+ sess.emit.chunk(acp.UpdateAgentMessageChunk, runErr.Error())
238+ return acp.PromptResponse{StopReason: acp.StopMaxTurnRequests}, nil
239+
240+ case runErr != nil:
241+ // The failure goes in the transcript rather than into a protocol error
242+ // alone, because a protocol error is a line in the client's log and
243+ // this is something the user has to read to know what to do next.
244+ sess.emit.chunk(acp.UpdateAgentMessageChunk, "codey: "+runErr.Error())
245+ return acp.PromptResponse{StopReason: acp.StopRefusal}, nil
246+ }
247+
248+ return acp.PromptResponse{StopReason: acp.StopEndTurn}, nil
249+ }
250+
251+ // Cancel stops the turn in flight. An unknown session is not an error worth
252+ // reporting: a stop arriving after the session closed asks for something that
253+ // already happened.
254+ func (s *Server) Cancel(_ context.Context, params acp.CancelParams) {
255+ if sess, err := s.lookup(params.SessionID); err == nil {
256+ sess.stop()
257+ }
258+ }
259+
260+ // Extra answers the methods outside the core set.
261+ func (s *Server) Extra(_ context.Context, method string, params json.RawMessage) (any, error) {
262+ switch method {
263+ case "session/set_model":
264+ var req acp.SetModelRequest
265+ if err := json.Unmarshal(params, &req); err != nil {
266+ return nil, acp.Errorf(acp.CodeInvalidParams, "cannot decode params: %v", err)
267+ }
268+ sess, err := s.lookup(req.SessionID)
269+ if err != nil {
270+ return nil, err
271+ }
272+ sess.mu.Lock()
273+ defer sess.mu.Unlock()
274+ if req.ModelID == sess.model {
275+ return struct{}{}, nil
276+ }
277+ sess.model = req.ModelID
278+ // The conversation comes along: only the agent around it is replaced.
279+ if err := s.build(sess); err != nil {
280+ return nil, err
281+ }
282+ return struct{}{}, nil
283+
284+ case "session/set_config_option":
285+ // Accepted and ignored. Some clients send one unconditionally on every
286+ // model selection, and refusing an option we never advertised would
287+ // fail a turn over a setting we do not have.
288+ return struct{}{}, nil
289+
290+ default:
291+ return nil, acp.Errorf(acp.CodeMethodNotFound, "unknown method %q", method)
292+ }
293+ }
294+
295+ func (s *Server) lookup(id string) (*session, error) {
296+ s.mu.Lock()
297+ defer s.mu.Unlock()
298+ sess, ok := s.sessions[id]
299+ if !ok {
300+ return nil, acp.Errorf(acp.CodeInvalidParams, "no such session %q", id)
301+ }
302+ return sess, nil
303+ }
304+
305+ // promptBlocks turns the client's content blocks into the agent's.
306+ //
307+ // Text and images pass through. Anything else is named rather than dropped: a
308+ // client that attached a file we cannot decode has told the user it sent
309+ // something, and a model that never hears about it will answer a question
310+ // nobody asked.
311+ func promptBlocks(blocks []acp.ContentBlock) []llm.ContentBlock {
312+ var out []llm.ContentBlock
313+ var text strings.Builder
314+
315+ for _, b := range blocks {
316+ switch b.Type {
317+ case "text":
318+ if b.Text != "" {
319+ if text.Len() > 0 {
320+ text.WriteString("\n")
321+ }
322+ text.WriteString(b.Text)
323+ }
324+ case "image":
325+ if b.Data != "" && b.MimeType != "" {
326+ // Text so far goes in first, so the image lands where it was
327+ // written rather than at the end of the message.
328+ if t := strings.TrimSpace(text.String()); t != "" {
329+ out = append(out, llm.TextBlock(t))
330+ text.Reset()
331+ }
332+ out = append(out, llm.ImageBlock(b.MimeType, b.Data))
333+ }
334+ default:
335+ if name := attachmentName(b); name != "" {
336+ if text.Len() > 0 {
337+ text.WriteString("\n")
338+ }
339+ fmt.Fprintf(&text, "[attached %s: %s]", b.Type, name)
340+ }
341+ }
342+ }
343+
344+ if t := strings.TrimSpace(text.String()); t != "" {
345+ out = append(out, llm.TextBlock(t))
346+ }
347+ return out
348+ }
349+
350+ func attachmentName(b acp.ContentBlock) string {
351+ switch {
352+ case b.Name != "":
353+ return b.Name
354+ case b.URI != "":
355+ return b.URI
356+ default:
357+ return ""
358+ }
359+ }
+662−0internal/serve/serve_test.goaddedView File

Large diffs are not rendered by default.

+157−0internal/serve/session.goaddedView File

Large diffs are not rendered by default.

+252−0internal/serve/update.goaddedView File

Large diffs are not rendered by default.

+219−0internal/serve/update_test.goaddedView File

Large diffs are not rendered by default.