Vectrum
Sign In

plan: run Codey inside T3 Code through its Grok provider slot

T3 Code's drivers are compiled in and there is no plugin mechanism, but
every driver takes a user-set binary path and driver kinds are an open
slug. So the way in is to occupy a slot rather than add one.

Researched all four candidate surfaces against pingdotgg/t3code 9accc567.
Claude is driven through Anthropic's own SDK behind a version-gated model
manifest; Codex is a 99-method schema-pinned mirror of OpenAI's protocol;
OpenCode is a server, not a CLI. Grok is a plain ACP agent spawned as
'<binaryPath> agent stdio', gated only by two regex-loose shell probes,
and it does not fingerprint the agent the way Antigravity does.

The design doc carries the field-level contract; the plan builds
internal/acp and internal/serve on top of the Observe and Approve seams
the agent loop already has.
Jjonicommitted 1 parent aa469bacommitc1086c0

Showing 2 changed files with 765 additions and 0 deletions.

+459−0docs/superpowers/plans/2026-09-19-t3code-provider.mdaddedView File
@@ -0,0 +1,459 @@
1+ # Codey inside T3 Code — implementation plan
2+
3+ > **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.
4+
5+ **Goal:** Run Codey as an agent inside T3 Code without touching T3 Code's
6+ source. The user enables the Grok provider in **Settings → Providers**, sets
7+ **Binary path** to the `codey` binary, and gets Codey threads with streaming
8+ text, visible tool calls, working approvals and a stop button.
9+
10+ **Architecture:** Codey grows a third front end alongside the TUI and `-p`.
11+ `internal/acp` owns the wire — newline-delimited JSON-RPC 2.0 over stdin and
12+ stdout, with the handful of ACP methods T3 Code actually exercises.
13+ `internal/serve` is the bridge: one `*agent.Agent` per ACP session, its
14+ `Observe` callback translated into `session/update` notifications and its
15+ `Approve` gate translated into `session/request_permission` round trips.
16+ `cmd/codey` grows an `agent stdio` verb plus the two shell probes T3 Code runs
17+ before it will show the provider as ready. Nothing in `internal/agent`,
18+ `internal/llm` or `internal/tui` changes in phase 1.
19+
20+ **Tech Stack:** Go 1.26, stdlib only — `encoding/json`, `bufio`, `context`,
21+ `sync`. No ACP library exists for Go and the surface is five methods; hand-
22+ rolling it is smaller than a dependency.
23+
24+ **Spec:** `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` — read
25+ it first. It carries the evidence for why the Grok slot and not Codex's
26+ app-server, and the exact field-level contract quoted out of T3 Code's source.
27+
28+ ## Global Constraints
29+
30+ - Go module is `codey`; import paths are `codey/internal/...`.
31+ - **Stdout belongs to the protocol.** In `agent stdio` mode nothing may write
32+ to stdout except the JSON-RPC encoder — no `fmt.Println`, no metrics logger,
33+ no stray `log`. Diagnostics go to stderr, which T3 Code drains and (on the
34+ Grok path) discards. A single stray line desynchronises the parser and the
35+ thread dies with a decode error.
36+ - **`jsonrpc: "2.0"` is mandatory on every message we send.** T3 Code's ACP
37+ codec declares it `Schema.Literal("2.0")` on requests, notifications and
38+ responses (`packages/effect-acp/src/_internal/shared.ts:83-103`). This is the
39+ opposite of their Codex path, where the field must be absent — do not carry
40+ habits between the two.
41+ - **A notification must have no `id` key.** Not `null`, absent.
42+ - **Never leave an inbound request unanswered**, including methods we do not
43+ implement: answer `-32601`. T3 Code answers our requests on its read loop, so
44+ a hang on either side stalls the whole session.
45+ - T3 Code's inbound notification queue is `sliding(32)`
46+ (`packages/effect-acp/src/protocol.ts:89`). Coalesce text deltas rather than
47+ emitting one notification per token.
48+ - `internal/acp` imports no other `internal` package and knows nothing about
49+ agents: it is a JSON-RPC peer with ACP's types. `internal/serve` may import
50+ `internal/acp`, `internal/agent`, `internal/tools`, `internal/approval`;
51+ nothing imports `internal/serve` back except `cmd/codey`.
52+ - Every test is offline and in-process: a `net.Pipe` or an `io.Pipe` pair, no
53+ subprocess, no network, no sleeping on wall-clock time.
54+ - New exported identifiers get a doc comment in the existing house style.
55+ - Run `gofmt -w` on every file you touch, and `go test ./...` before each task
56+ is ticked off.
57+
58+ ---
59+
60+ ## Phase 1 — a text turn appears in T3 Code
61+
62+ ### Task 1: `internal/acp` — types and the JSON-RPC peer
63+
64+ **Files:**
65+ - Create: `internal/acp/wire.go`, `internal/acp/types.go`, `internal/acp/conn.go`, `internal/acp/conn_test.go`
66+
67+ **Interfaces:**
68+ - Consumes: nothing.
69+ - Produces:
70+ ```go
71+ // wire.go
72+ type Message struct {
73+ JSONRPC string `json:"jsonrpc"`
74+ ID *json.RawMessage `json:"id,omitempty"`
75+ Method string `json:"method,omitempty"`
76+ Params json.RawMessage `json:"params,omitempty"`
77+ Result json.RawMessage `json:"result,omitempty"`
78+ Error *Error `json:"error,omitempty"`
79+ }
80+ type Error struct { Code int `json:"code"`; Message string `json:"message"`; Data any `json:"data,omitempty"` }
81+
82+ const (
83+ CodeParse = -32700; CodeInvalidRequest = -32600
84+ CodeMethodNotFound = -32601; CodeInvalidParams = -32602; CodeInternal = -32603
85+ )
86+
87+ // conn.go
88+ type Handler interface {
89+ Initialize(context.Context, InitializeRequest) (InitializeResponse, error)
90+ Authenticate(context.Context, AuthenticateRequest) error
91+ NewSession(context.Context, NewSessionRequest) (NewSessionResponse, error)
92+ Prompt(context.Context, PromptRequest) (PromptResponse, error)
93+ Cancel(context.Context, CancelParams)
94+ }
95+ type Conn struct{ /* unexported */ }
96+ func NewConn(r io.Reader, w io.Writer, h Handler) *Conn
97+ func (c *Conn) Serve(ctx context.Context) error // returns nil on clean EOF
98+ func (c *Conn) Notify(ctx context.Context, method string, params any) error
99+ func (c *Conn) Request(ctx context.Context, method string, params any, out any) error
100+ ```
101+
102+ `types.go` holds the ACP structs, spelled exactly as the spec quotes them:
103+ `InitializeRequest{ProtocolVersion int, ClientCapabilities, ClientInfo}`,
104+ `InitializeResponse{ProtocolVersion int, AgentInfo, AgentCapabilities, AuthMethods}`,
105+ `AuthenticateRequest{MethodID string}`, `NewSessionRequest{Cwd string,
106+ McpServers []json.RawMessage, AdditionalDirectories []string}`,
107+ `NewSessionResponse{SessionID string, Models *ModelState}`,
108+ `PromptRequest{SessionID string, Prompt []ContentBlock}`,
109+ `PromptResponse{StopReason string}`, `SessionNotification{SessionID string,
110+ Update Update}`, `RequestPermissionParams/Result`.
111+ `ContentBlock` is a tagged struct (`Type`, `Text`, `Data`, `MimeType`, `Path`)
112+ — decode leniently, we only read `text`.
113+
114+ **Behaviour that the tests pin down:**
115+
116+ Reader goroutine owns the decoder; **each inbound request is dispatched on its
117+ own goroutine** so that a `session/cancel` notification arriving mid-prompt is
118+ seen immediately. A single mutex serialises writes. Outbound request ids are
119+ integers from a counter; replies are correlated through a `map[string]chan`
120+ keyed by `string(id)`.
121+
122+ - [ ] **Step 1: Write the failing tests**
123+
124+ `conn_test.go` drives a `Conn` over an `io.Pipe` pair with a stub handler
125+ and asserts: every outbound line carries `"jsonrpc":"2.0"`; a notification
126+ line has no `id` key at all (assert on the raw bytes, not on a decoded map);
127+ a request for an unknown method comes back as `-32601` with the same `id`; a
128+ handler returning an error becomes `-32603` and does not kill the connection;
129+ malformed JSON on a line is answered `-32700` where an id can be recovered
130+ and skipped otherwise, and the loop survives; `Request` blocks until the
131+ matching response arrives and returns the decoded result; `Request` with a
132+ cancelled context returns `ctx.Err()` and drops the pending entry; a
133+ `session/cancel` notification is delivered while a `session/prompt` handler
134+ is still running; clean EOF on the reader returns `nil` from `Serve`.
135+
136+ - [ ] **Step 2: Implement `wire.go` and `types.go`**
137+
138+ Encoding is one compact JSON object per line. `ID` is kept as
139+ `json.RawMessage` so a string id and a numeric id both round-trip byte-exact.
140+
141+ - [ ] **Step 3: Implement `conn.go`**
142+
143+ - [ ] **Step 4: Verify**
144+
145+ ```bash
146+ gofmt -w internal/acp && go test ./internal/acp/...
147+ ```
148+
149+ ---
150+
151+ ### Task 2: `internal/serve` — the session bridge, text only
152+
153+ **Files:**
154+ - Create: `internal/serve/serve.go`, `internal/serve/session.go`, `internal/serve/update.go`, `internal/serve/serve_test.go`
155+
156+ **Interfaces:**
157+ - Consumes: `internal/acp`, `internal/agent`, `internal/tools`, `internal/metrics`.
158+ - Produces:
159+ ```go
160+ type Options struct {
161+ NewAgent func(root string) (*agent.Agent, error) // one agent per session
162+ Log *metrics.Logger
163+ }
164+ type Server struct{ /* unexported */ }
165+ func New(opts Options) *Server
166+ func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error
167+ ```
168+
169+ `NewAgent` is a factory taking the session's `cwd`, mirroring the
170+ `toolsIn(root)` factory `cmd/codey/main.go:286` already uses to rebuild a
171+ tool set against a worktree. T3 Code hands us a cwd per `session/new` and it
172+ is not necessarily the process's own.
173+
174+ **Behaviour:**
175+
176+ - `initialize` → `{protocolVersion: 1, agentInfo: {name: "codey", version},
177+ agentCapabilities: {promptCapabilities: {image: true}}, authMethods: []}`.
178+ Accept any `protocolVersion` the client offers and answer with `1`; T3 Code
179+ sends `1` today (`acp/AcpSessionRuntime.ts:696-705`).
180+ - `authenticate` → `{}` for any `methodId`. T3 Code sends `cached_token` or
181+ `xai.api_key` and does not catch a failure here, so this must never error.
182+ - `session/new` → build an agent rooted at `params.cwd`, store it under a fresh
183+ session id (`codey-` + 16 random hex), reply `{sessionId}`. Ignore
184+ `mcpServers`.
185+ - `session/prompt` → concatenate the text blocks, run the agent, stream, reply
186+ `{stopReason: "end_turn"}`. `"cancelled"` if the session was cancelled,
187+ `"refusal"` on a hard agent error after emitting the error text as a chunk.
188+ - `session/cancel` → cancel that session's in-flight turn context. Idempotent,
189+ and harmless when nothing is running.
190+
191+ `update.go` owns the translation, and is the file worth unit-testing hardest:
192+
193+ | `agent.Event` | `session/update` |
194+ |---|---|
195+ | `EventTextDelta` | `agent_message_chunk` with `content: {type: "text", text}` |
196+ | `EventText` (non-streaming) | same, once |
197+ | everything else | dropped in phase 1 |
198+
199+ Deltas are coalesced: buffer until 50 ms have passed or 512 bytes have
200+ accumulated, then flush one notification. This is what keeps us clear of T3
201+ Code's `sliding(32)` queue on a fast model.
202+
203+ - [ ] **Step 1: Write the failing tests**
204+
205+ Drive `Server.Run` over pipes with a fake `NewAgent` whose `Run` emits a
206+ scripted `[]agent.Event`. Assert: the full handshake in order
207+ (`initialize` → `authenticate` → `session/new` → `session/prompt`) produces a
208+ session id and a `{stopReason:"end_turn"}`; the text arrives as
209+ `session/update` notifications carrying the session id and reassembling to
210+ the original string; deltas are coalesced rather than one-per-event; a
211+ `session/prompt` for an unknown session id is an error, not a panic; a
212+ `session/cancel` during a turn cancels the agent's context and the prompt
213+ replies `{stopReason:"cancelled"}`; two sessions run concurrently without
214+ their updates crossing over.
215+
216+ - [ ] **Step 2: Implement `session.go`** — the session table, per-session
217+ mutex, per-turn cancel func.
218+
219+ - [ ] **Step 3: Implement `update.go`** — the event translation and the
220+ coalescer. The coalescer takes a clock function so tests do not sleep.
221+
222+ - [ ] **Step 4: Implement `serve.go`** — wire the handler methods onto the
223+ session table.
224+
225+ - [ ] **Step 5: Verify**
226+
227+ ```bash
228+ gofmt -w internal/serve && go test ./internal/serve/...
229+ ```
230+
231+ ---
232+
233+ ### Task 3: `cmd/codey` — the `agent stdio` verb and the two probes
234+
235+ **Files:**
236+ - Modify: `cmd/codey/main.go`
237+ - Create: `cmd/codey/agentcmd.go`, `cmd/codey/agentcmd_test.go`
238+
239+ **Behaviour:**
240+
241+ Argument parsing has to be positional-agnostic, because T3 Code splits its
242+ flags around the subcommand: `--permission-mode default agent stdio`, but
243+ `agent --always-approve stdio` (`acp/GrokAcpSupport.ts:33-46`). Scan `os.Args`
244+ for the bare words `agent` and `stdio` and for the three flags anywhere in the
245+ line; ignore anything else rather than erroring, since `launchArgs` is a free
246+ text field the user can put anything in.
247+
248+ Three entry points:
249+
250+ - `codey --version` → one line, `codey <semver>`, exit 0. Must match
251+ `/\bv?(\d+\.\d+\.\d+)\b/` (`apps/server/src/provider/providerSnapshot.ts:114-119`).
252+ Keep the existing `-v` verbose flag working; `--version` is new and distinct.
253+ - `codey models` → exit 0, and print, in this order:
254+ ```
255+ You are logged in as <endpoint or account>.
256+ * codey-opus-5 (default)
257+ - codey-sonnet-5
258+ ```
259+ T3 Code reads login state off `/you are logged in/i` and models off
260+ `^\s*[*-]\s+(\S+)` bullet lines, with `(default)` marking the default
261+ (`Layers/GrokProvider.ts:258-286`). Source the list from the same
262+ `GET /v1/models` the TUI model picker uses; on failure print the configured
263+ model alone and still exit 0 — a non-zero exit makes T3 Code discard the
264+ whole listing.
265+ - `codey agent [--permission-mode X] [--always-approve] stdio` → build the
266+ `serve.Server` and run it on stdin/stdout.
267+
268+ Permission modes map onto the existing `approval.Policy`:
269+
270+ | T3 Code mode | Codey behaviour |
271+ |---|---|
272+ | `default` (or absent) | ask for every mutating tool |
273+ | `acceptEdits` | auto-approve `edit`/`write`, ask for `bash` |
274+ | `auto` | auto-approve all but ask for anything the policy calls non-rememberable |
275+ | `--always-approve` | the equivalent of `-yolo` |
276+
277+ - [ ] **Step 1: Write the failing tests**
278+
279+ `agentcmd_test.go` tests the argument scanner as a pure function over
280+ `[]string` — all five argv shapes T3 Code produces, plus junk from
281+ `launchArgs`, plus `--version` and `models`. Then a test that asserts the
282+ version line matches T3 Code's regex and the `models` output parses under a
283+ Go transliteration of their bullet parser.
284+
285+ - [ ] **Step 2: Implement the scanner and the three entry points**
286+
287+ Take the branch as early in `main` as possible — before the metrics logger
288+ opens, before the wizard, before anything can print to stdout.
289+
290+ - [ ] **Step 3: Verify**
291+
292+ ```bash
293+ gofmt -w ./cmd/codey && go test ./... && go build -o /tmp/codey ./cmd/codey
294+ /tmp/codey --version && /tmp/codey models
295+ ```
296+
297+ ---
298+
299+ ### Task 4: End-to-end against the real T3 Code
300+
301+ **Files:**
302+ - Create: `internal/serve/conformance_test.go`
303+
304+ **Steps:**
305+
306+ - [ ] **Step 1: A conformance test that replays T3 Code's exact handshake**
307+
308+ Hard-code the literal JSON T3 Code sends — `initialize` with
309+ `{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"t3-code","version":"0.0.0"}}`,
310+ then `authenticate` with `{"methodId":"cached_token"}`, then `session/new`,
311+ then `session/prompt` — and assert our replies decode against the shapes in
312+ the design doc. This file is the early-warning system for their churn: when
313+ a nightly breaks us, this is what tells us where.
314+
315+ - [ ] **Step 2: Wire it up by hand**
316+
317+ In T3 Code: Settings → Providers → Grok → enable, Binary path = the built
318+ `codey`, instance display name "Codey". Or edit `~/.t3/userdata/settings.json`
319+ directly (restart the app afterwards):
320+
321+ ```json
322+ "providerInstances": {
323+ "codey": {
324+ "driver": "grok",
325+ "displayName": "Codey",
326+ "enabled": true,
327+ "config": { "binaryPath": "/home/joni/Work/GO/coding-harness/codey" },
328+ "environment": [
329+ { "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true }
330+ ]
331+ }
332+ }
333+ ```
334+
335+ The per-instance `environment` array is how Codey gets its credential without
336+ inheriting whatever the desktop app was launched with.
337+
338+ - [ ] **Step 3: Confirm the provider card and one turn**
339+
340+ The card should read installed, authenticated, with our model list. Start a
341+ thread, ask something that needs no tools, and watch the text stream in.
342+
343+ - [ ] **Step 4: Capture the traffic for the next phase**
344+
345+ T3 Code logs every provider event to
346+ `~/.t3/userdata/logs/provider/events.<threadId>.log`, tagged `CANON:` for its
347+ normalised model and `NTIVE:` for the raw wire. That file is the ground truth
348+ for anything phase 2 gets wrong.
349+
350+ ---
351+
352+ ## Phase 2 — a first-class provider
353+
354+ ### Task 5: Tool calls become visible
355+
356+ **Files:**
357+ - Modify: `internal/agent/loop.go` (add `ID` to `Event`), `internal/serve/update.go`
358+ - Modify: `internal/agent/loop_test.go`, `internal/serve/serve_test.go`
359+
360+ `agent.Event` carries `Tool string` but nothing pairing a start with its result
361+ (`internal/agent/loop.go:180-191`). ACP keys `tool_call` and `tool_call_update`
362+ by `toolCallId`. Pairing by order would work today because dispatch is
363+ sequential, but it is a landmine the first time it is not.
364+
365+ - [ ] **Step 1:** Add `ID string` to `agent.Event`, populated from the
366+ `tool_use` block's id on `EventToolStart` and echoed on `EventToolResult`.
367+ Existing observers ignore it; the TUI is unaffected.
368+ - [ ] **Step 2:** Emit `tool_call` on start with
369+ `{toolCallId, title, kind, status: "pending", rawInput}` and
370+ `tool_call_update` on result with `status: "completed"|"failed"` and
371+ `content: [{type: "content", content: {type: "text", text}}]`.
372+ `kind` maps our tools onto ACP's vocabulary: `read`, `edit`, `execute`,
373+ `search`, `fetch`, `think`, `other`.
374+ - [ ] **Step 3:** Verify the tool rows render in T3 Code, and that a failed
375+ tool shows as failed rather than silently completing.
376+
377+ ### Task 6: Approvals
378+
379+ **Files:**
380+ - Modify: `internal/serve/serve.go`, `internal/serve/session.go`
381+
382+ - [ ] **Step 1:** Implement an `approval.Prompt` that sends
383+ `session/request_permission` with
384+ `options: [{optionId:"allow-once", name:"Allow", kind:"allow_once"},
385+ {optionId:"allow-always", name:"Always allow", kind:"allow_always"},
386+ {optionId:"reject-once", name:"Reject", kind:"reject_once"}]`
387+ and blocks on the reply. Use exactly those three ids: Grok echoes back one of
388+ ours, but Cursor's adapter hardcodes those literals
389+ (`acp/AcpAdapterSupport.ts:46-55`), so choosing them now means the same binary
390+ works in a Cursor instance too.
391+ - [ ] **Step 2:** Map the reply — `selected`/`allow-always` →
392+ `approval.AlwaysThisCommand`, `selected`/`allow-once` → `Once`,
393+ `selected`/`reject-once` → `Deny`, `cancelled` → abort the turn.
394+ - [ ] **Step 3:** Respect the launch permission mode: in `--always-approve` no
395+ request is sent at all.
396+ - [ ] **Step 4:** Verify against T3 Code's approval panel, including the
397+ interaction with its own permission-mode selector, and confirm that
398+ cancelling a pending approval does not wedge the session.
399+
400+ ### Task 7: Models, effort and the picker
401+
402+ **Files:**
403+ - Modify: `internal/serve/serve.go`, `cmd/codey/agentcmd.go`
404+
405+ - [ ] **Step 1:** Return the model list in `session/new` as
406+ `{models: {currentModelId, availableModels: [{modelId, name}]}}`. T3 Code's
407+ Grok path reads it (`acp/GrokAcpSupport.ts:124-154`) and prefers it over the
408+ `codey models` output.
409+ - [ ] **Step 2:** Handle `session/set_model` / `session/set_config_option` by
410+ switching the session's model, and reply `{}` to any config option we do not
411+ recognise rather than erroring — Cursor's adapter sends one unconditionally.
412+ - [ ] **Step 3:** Carry reasoning effort through `_meta.reasoningEffort` if it
413+ is present on the selected model.
414+
415+ ---
416+
417+ ## Phase 3 — polish, in rough order of value
418+
419+ - [ ] **Reasoning.** Surface thinking blocks from the loop as
420+ `agent_thought_chunk`. Needs a new event kind in `internal/agent`; today the
421+ loop never reports them to observers.
422+ - [ ] **Diffs.** Emit `tool_call` content of kind `diff` with `path`, `oldText`,
423+ `newText` from `edit`/`write`, so T3 Code's diff viewer lights up instead of
424+ showing our text summary.
425+ - [ ] **Resume.** Advertise `agentCapabilities.sessionCapabilities.resume` and
426+ implement `session/load` on top of `agent.ResolveSession`, replaying the
427+ history as `session/update`s. Until we advertise it, T3 Code will not ask
428+ (`acp/AcpSessionRuntime.ts:726`), so this is purely additive.
429+ - [ ] **Plans.** Map our todo-ish output onto the `plan` update, or skip it —
430+ it is optional and we have no plan object today.
431+ - [ ] **Slash commands.** `available_commands_update` can expose `/compact` and
432+ the skills list in T3 Code's composer.
433+ - [ ] **Usage.** Feed token counts somewhere visible; the Grok path has no
434+ usage channel, so this may be the first thing that argues for the fork.
435+ - [ ] **README.** A "Using Codey from T3 Code" section: the settings recipe,
436+ what the Grok branding means, and the fact that it says Grok on the tin.
437+
438+ ---
439+
440+ ## How we will know it works
441+
442+ 1. `go test ./...` green, including the conformance test.
443+ 2. `codey --version` and `codey models` satisfy T3 Code's parsers — asserted in
444+ Go, then observed on the provider card.
445+ 3. A thread in T3 Code: text streams, tool rows appear and complete, an
446+ approval prompt appears and is answerable, the stop button ends the turn,
447+ and the thread survives a second prompt in the same session.
448+ 4. `~/.t3/userdata/logs/provider/events.<threadId>.log` contains no decode
449+ errors and no `warning` rows we did not intend.
450+
451+ ## Out of scope
452+
453+ - Any change to T3 Code's source. The fork — a `codey` driver in
454+ `BUILT_IN_DRIVERS` — is discussed at the end of the design doc and stays a
455+ later decision. Nothing in this plan is wasted if we take it: the same ACP
456+ server is what such a driver would talk to.
457+ - The Codex app-server path. Specified in the design doc's appendix as the
458+ fallback, not built.
459+ - MCP. T3 Code offers us an MCP server on `session/new`; we ignore it.
+306−0docs/superpowers/specs/2026-09-19-t3code-provider-design.mdaddedView File
@@ -0,0 +1,306 @@
1+ # Codey as a T3 Code provider — design
2+
3+ **Question:** can T3 Code drive Codey without patching T3 Code? If so, through
4+ which of its provider slots, and what exactly does Codey have to say?
5+
6+ **Answer:** yes. Speak ACP over stdio, put the binary path in the Grok
7+ provider's settings, and T3 Code drives Codey with no source change on their
8+ side. Everything below is evidence for that choice and the contract it
9+ commits us to.
10+
11+ Researched against `github.com/pingdotgg/t3code` at commit `9accc567`
12+ (2026-09-19), which is the same generation as the 0.0.43 nightly installed on
13+ this machine. Every file reference is into that tree.
14+
15+ ## How T3 Code's provider layer is built
16+
17+ T3 Code is a control plane, not an agent: it spawns somebody else's CLI and
18+ renders what comes back. There are six drivers — Codex, Claude, Cursor, Grok,
19+ OpenCode, Antigravity (`apps/server/src/provider/builtInDrivers.ts:50-56`).
20+
21+ Three facts decide everything:
22+
23+ **Drivers are compiled in.** `BUILT_IN_DRIVERS` is "the static set of
24+ `ProviderDriver`s this build ships with"
25+ (`apps/server/src/provider/builtInDrivers.ts:1-22`). There is no plugin
26+ mechanism, no config-declared driver, no external process registry. Adding a
27+ seventh driver means editing their repo and rebuilding the Electron app.
28+
29+ **But driver kinds are an open slug, on purpose.** `ProviderDriverKind` is "an
30+ **open** branded slug, not a closed literal union … The server hosts forks,
31+ ships in PRs that add drivers, and users frequently roll between branches and
32+ forks" (`packages/contracts/src/providerInstance.ts:16-33`). Settings naming a
33+ driver this build does not know round-trip intact and surface as an
34+ "unavailable" card. So a fork that adds a `codey` driver is a supported shape —
35+ it is just not a shape that survives the auto-updater.
36+
37+ **Each instance carries user-editable config, and there can be several per
38+ driver.** `ProviderInstanceConfig` is
39+ `{driver, displayName?, accentColor?, environment?, enabled?, config?}`
40+ (`packages/contracts/src/providerInstance.ts:124-131`), keyed by a user-chosen
41+ instance id, and every built-in driver sets `supportsMultipleInstances: true`
42+ (e.g. `Drivers/GrokDriver.ts:60-61`). The per-driver `config` payload always
43+ contains `binaryPath`, and usually `launchArgs` and `customModels`
44+ (`packages/contracts/src/settings.ts:562-735`). This is the seam. On this
45+ machine it is already visible in `~/.t3/userdata/settings.json`:
46+
47+ ```json
48+ "providerInstances": {
49+ "codex": { "driver": "codex", "enabled": false,
50+ "config": { "binaryPath": "codex", "homePath": "", "shadowHomePath": "",
51+ "launchArgs": "", "customModels": [] } }
52+ }
53+ ```
54+
55+ `binaryPath` is used verbatim — nothing anywhere keys off the file's *name*.
56+ Per-instance environment variables are editable in the same UI
57+ (`docs/user/install.md:130-133`).
58+
59+ So the plan the user proposed is the right one: don't add a driver, occupy one.
60+
61+ ## The four candidate surfaces
62+
63+ ### Claude — rejected
64+
65+ T3 Code does not spawn `claude` itself. It hands the path to Anthropic's own
66+ SDK: `"@anthropic-ai/claude-agent-sdk": "^0.3.276"` (`apps/server/package.json:26`),
67+ `pathToClaudeCodeExecutable: claudeBinaryPath` (`Layers/ClaudeAdapter.ts:4916`).
68+ The contract is therefore the *SDK's* CLI contract, which lives in a node
69+ package T3 Code upgrades on its own schedule, and which we would have to
70+ re-derive by experiment rather than read.
71+
72+ Worse, the model picker is a baked-in manifest with version gates:
73+ `provider/model-manifest.json` gives `claude-opus-5` `"minVersion": "2.1.219"`,
74+ and `ClaudeModelCatalog.ts:148-158` refuses a model whose `minVersion` exceeds
75+ what `claude --version` printed. Our own model names would never appear;
76+ we would be advertising Anthropic slugs and lying about a Claude Code version.
77+
78+ ### Codex — possible, expensive
79+
80+ Spawned as `<binaryPath> app-server` plus the user's `launchArgs`
81+ (`Layers/codexLaunchArgs.ts:12-15`), newline-delimited JSON-RPC over stdio,
82+ no `jsonrpc` field, notifications must carry no `id` key at all
83+ (`packages/effect-codex-app-server/src/protocol.ts:82-95, 459-474`).
84+
85+ It has one genuinely attractive property the others lack: **models come from
86+ the binary at runtime**. The probe pages through `model/list` and shows what it
87+ is told (`Layers/CodexProvider.ts:325-341`), so Codey's own model names would
88+ appear in T3 Code's picker. There is no version gate; the version is scraped
89+ out of the `initialize` result's `userAgent` with `/\/([^\s]+)/`
90+ (`Layers/CodexProvider.ts:422-424`).
91+
92+ Against it: the protocol is enormous and strictly decoded. 99 client request
93+ methods, 70 server notifications (`_generated/meta.gen.ts:6-191`), generated
94+ from a pinned upstream ref and schema-checked per method, so a missing required
95+ field is a hard decode error rather than a shrug. The minimum is bigger than it
96+ looks — `thread/start` must return a fully populated `thread` object
97+ (`cliVersion`, `sessionId`, `preview`, `ephemeral`, `createdAt`, `updatedAt`,
98+ `source`, `status`, `turns`) of which T3 Code reads exactly three fields
99+ (`_generated/schema.gen.ts:31819-31855`, consumed at
100+ `Layers/CodexSessionRuntime.ts:2447-2453`). And `account/read` must answer
101+ `requiresOpenaiAuth: false` with an account object or the probe stops
102+ (`Layers/CodexProvider.ts:426-434`).
103+
104+ It is implementable — the full method-by-method spec is in the appendix — but
105+ it is a moving target owned by OpenAI and mirrored by T3 Code.
106+
107+ ### OpenCode — rejected
108+
109+ T3-managed OpenCode runs one HTTP server per thread with directory-scoped MCP
110+ registration and per-directory persistent approval grants
111+ (`docs/internals/providers.md:13-24`). We would be impersonating a server, not
112+ a CLI, and inheriting its grant semantics.
113+
114+ ### Grok — chosen
115+
116+ Grok is an ACP agent. T3 Code spawns:
117+
118+ ```ts
119+ { command: grokSettings?.binaryPath || "grok",
120+ args: [...grokAcpSpawnArgs(runtimeMode)],
121+ cwd,
122+ env: { ...environment, GROK_OAUTH2_REFERRER: "t3code" } }
123+ ```
124+ (`acp/GrokAcpSupport.ts:48-63`), where `grokAcpSpawnArgs` is
125+ `["agent", "stdio"]` by default and prefixes `["--permission-mode", "default"
126+ | "acceptEdits" | "auto"]` or appends `--always-approve` for the four runtime
127+ modes (`acp/GrokAcpSupport.ts:33-46`).
128+
129+ That is the whole launch contract. The ACP side is four methods and one
130+ notification, and ACP is an open, documented protocol rather than a vendor
131+ mirror. Crucially, the initialize *result* has exactly one required field:
132+ `protocolVersion`; `agentCapabilities`, `authMethods`, `agentInfo` and
133+ `promptCapabilities` are all `optionalKey`
134+ (`packages/effect-acp/src/_generated/schema.gen.ts:6574-6605`). Unknown
135+ `sessionUpdate` discriminators hit `default: break`
136+ (`acp/AcpRuntimeModel.ts:882-883`), so we can grow into the protocol instead of
137+ having to land on it.
138+
139+ The two shell probes that gate the provider card are regex-loose:
140+
141+ - `grok --version` must exit 0 and print something matching
142+ `/\bv?(\d+\.\d+\.\d+)\b/` (`Layers/GrokProvider.ts:394`, parser at
143+ `providerSnapshot.ts:114-119`).
144+ - `grok models` must exit 0. Login state is `/you are logged in/i` versus
145+ `/not authenticated|not logged in/i`; models are bullet lines
146+ `* slug (default)` or `- slug` (`Layers/GrokProvider.ts:258-286`).
147+
148+ Compare Antigravity, which fingerprints the agent properly —
149+ `agentInfo.name === "antigravity-acp"` plus four required capabilities
150+ (`AntigravityInstallation.ts:493-499`). Nothing of the sort exists on the Grok
151+ path: no filename check, no minimum version, no config file under `~/.grok`.
152+
153+ Cursor is nearly as cheap but adds three obligations: an `about --format json`
154+ probe that must report a `YYYY.MM.DD` `cliVersion` and a non-null `userEmail`
155+ or the card goes to `status: "error"` (`Layers/CursorProvider.ts:1086-1097,
156+ 932-952`); an unconditional `session/set_config_option` on model selection
157+ (`acp/CursorAcpSupport.ts:113`); and hardcoded permission option ids
158+ `"allow-always"` / `"allow-once"` / `"reject-once"` that the agent must accept
159+ verbatim (`acp/AcpAdapterSupport.ts:46-55`). Grok, by contrast, echoes back an
160+ option id chosen from the ones *we* advertised
161+ (`Layers/GrokAdapter.ts:1219-1237`).
162+
163+ ### Scoring
164+
165+ | | Claude | Codex | OpenCode | Cursor | **Grok** |
166+ |---|---|---|---|---|---|
167+ | Transport we must implement | SDK-private | JSON-RPC, 99+70 methods | HTTP+SSE server | ACP | **ACP** |
168+ | Minimum methods | n/a | 4 req + 4 notif, strict schemas | many | 4 + probe | **4 + 1 notif** |
169+ | Startup gates | version-gated model manifest | `account/read` auth shape | server lifecycle | `about` JSON + email | **two regexes** |
170+ | Our own model names show | no | yes | partly | via extension | via `models` output |
171+ | Protocol owner | Anthropic SDK | OpenAI, mirrored | SST | Cursor | **open standard** |
172+ | Fingerprints the agent | — | — | — | — | **no** |
173+
174+ ## The contract we are signing up to
175+
176+ ### Command line
177+
178+ | invocation | must do |
179+ |---|---|
180+ | `codey --version` | exit 0, print a line containing `x.y.z` |
181+ | `codey models` | exit 0, print `You are logged in as …` and one `* slug (default)` / `- slug` line per model |
182+ | `codey agent stdio` | speak ACP over stdin/stdout until EOF |
183+ | `codey agent --always-approve stdio` | same, with every tool auto-approved |
184+ | `codey --permission-mode default\|acceptEdits\|auto agent stdio` | same, with that approval policy |
185+
186+ `codey login`, `codey update` and any other subcommand may be absent; T3 Code
187+ only ever mentions them in error text. Note the flag placement: T3 Code puts
188+ `--permission-mode` *before* the `agent` subcommand and `--always-approve`
189+ *between* `agent` and `stdio`, so the argument parser has to be positional-
190+ agnostic rather than a strict `agent` subcommand with its own flag set.
191+
192+ ### ACP, client → us
193+
194+ 1. `initialize` — `{protocolVersion: 1, clientCapabilities: {fs: {readTextFile: false, writeTextFile: false}, terminal: false}, clientInfo: {name: "t3-code", version}}` (`acp/AcpSessionRuntime.ts:696-705, 566-578`). Minimum valid reply: `{"protocolVersion": 1}`. We will also send `agentInfo` and `agentCapabilities`.
195+ 2. `authenticate` — `{methodId: "cached_token"}`, or `"xai.api_key"` if `XAI_API_KEY` is set in the instance env (`acp/GrokAcpSupport.ts:65-69`). Sent unconditionally and *not* caught: an error here aborts session start (`acp/AcpSessionRuntime.ts:710-718`). Reply `{}` regardless of the id.
196+ 3. `session/new` — `{cwd, mcpServers: [], additionalDirectories?}`. Only `sessionId` is required back (`acp/AcpSessionRuntime.ts:836-860`).
197+ 4. `session/prompt` — `{sessionId, prompt: ContentBlock[]}`. Only `stopReason` is read from the result (`Layers/CursorAdapter.ts:1112`).
198+ 5. `session/cancel` — notification; T3 Code synthesises `{stopReason: "cancelled"}` locally (`acp/AcpSessionRuntime.ts:1027-1029`).
199+
200+ `session/load` / resume is only attempted when
201+ `agentCapabilities.sessionCapabilities.resume` is true
202+ (`acp/AcpSessionRuntime.ts:726`), so omitting it is a supported state, not a
203+ failure.
204+
205+ ### ACP, us → client
206+
207+ `session/update` notifications, switched on `sessionUpdate`
208+ (`acp/AcpRuntimeModel.ts:795-884`):
209+
210+ | variant | fields read | we need it |
211+ |---|---|---|
212+ | `agent_message_chunk` | `content.text` | **yes** — the visible answer |
213+ | `agent_thought_chunk` | `content.text` | phase 2 (we have no reasoning events yet) |
214+ | `tool_call` | `toolCallId`, `title`, `kind`, `status`, `content`, `rawInput` | **yes** — otherwise tools are invisible |
215+ | `tool_call_update` | same, merged by `toolCallId`; terminal on `completed`/`failed` | **yes** |
216+ | `plan` | `entries[].content`, `.status` | no |
217+ | `available_commands_update` | `availableCommands` | later, for slash commands |
218+ | `current_mode_update`, `config_option_update` | mode / config ids | no |
219+
220+ And `session/request_permission` — we send `{sessionId, toolCall: {kind, title, rawInput, locations}, options: [{optionId, name, kind}]}`; T3 Code replies `{outcome: {outcome: "selected", optionId}}` or `{outcome: {outcome: "cancelled"}}` (`Layers/CursorAdapter.ts:746-754`). On the Grok path the id comes from our own list (`Layers/GrokAdapter.ts:1219-1237`), so we choose the vocabulary — but we should still accept `allow-always`/`allow-once`/`reject-once` verbatim so that pointing the same binary at a Cursor instance also works.
221+
222+ ### What T3 Code will show
223+
224+ The card, icon and colour come from `GROK_PRESENTATION`, so out of the box it
225+ says Grok. The instance's `displayName` and `accentColor` are user-editable
226+ (`packages/contracts/src/providerInstance.ts:124-131`) and are stamped onto
227+ every event by `withInstanceIdentity`, so renaming the instance to "Codey" gets
228+ most of the way. The xAI glyph stays. That is the price of not forking.
229+
230+ Model slugs pass through `resolveGrokAcpBaseModelId` and get a display name
231+ from `displayNameFromGrokModelSlug`, which title-cases on `-`/`_`
232+ (`Layers/GrokProvider.ts:272-293`). `codey-opus-5` renders as "Codey Opus 5".
233+
234+ ## What this costs on our side
235+
236+ Codey today is a TUI and a `-p` one-shot. It already has the two seams an
237+ adapter needs — `Agent.Observe func(Event)` and `Agent.Approve Approver`
238+ (`internal/agent/loop.go:205-211`) — plus a tool registry, a session store and
239+ an approval policy that can remember decisions. The new work is a third front
240+ end: `internal/acp` (wire) + `internal/serve` (bridge), and an `agent stdio`
241+ verb in `cmd/codey`. Nothing in `internal/agent` needs to change for phase 1.
242+
243+ Three gaps show up when mapping our `Event` onto ACP:
244+
245+ - **`Event` has no tool call id.** `EventToolStart`/`EventToolResult` carry
246+ `Tool string` and nothing that pairs them
247+ (`internal/agent/loop.go:180-191`). ACP's `tool_call` /`tool_call_update`
248+ are keyed by `toolCallId`. Sequential dispatch means we can pair by order for
249+ now, but the honest fix is to add an id to the event.
250+ - **No reasoning events.** The loop never surfaces thinking blocks to
251+ observers, so `agent_thought_chunk` has nothing to carry.
252+ - **No diff events.** Our `edit`/`write` tools return text; T3 Code's diff view
253+ wants `tool_call` content of kind `diff` with old/new text.
254+
255+ None of these block a working turn; all three are what separate "it works"
256+ from "it looks like a first-class provider".
257+
258+ ## Risks
259+
260+ - **Their churn.** T3 Code ships nightly and the Grok path is explicitly young:
261+ the driver is disabled by default because "the binding is not yet stable
262+ enough to probe on every install" (`packages/contracts/src/settings.ts:713-716`).
263+ Mitigation: the contract surface we depend on is five methods, and a conformance
264+ test that replays T3 Code's exact handshake catches a break in seconds.
265+ - **Occupying a slot means losing it.** A user who wants both real Grok and
266+ Codey needs two instances of the `grok` driver. That works
267+ (`supportsMultipleInstances: true`) but it is a confusing settings page.
268+ - **It says Grok.** See above.
269+ - **Upstream ACP version.** T3 Code sends `protocolVersion: 1`. If they move to
270+ 2 we have to follow; the handshake is where we would find out.
271+
272+ ## The fork option, for the record
273+
274+ Because driver kinds are open and forks are anticipated, a `codey` driver is
275+ about one file (`Drivers/CodeyDriver.ts`), one line in `BUILT_IN_DRIVERS`, and
276+ a settings schema — additive, no rewriting, and it would get us our own name,
277+ icon and config fields. The cost is that it only exists in a build we compile
278+ and ship ourselves, and it has to be rebased onto a repo that moves daily.
279+
280+ The right sequencing is impersonation first: the ACP server we build for the
281+ Grok slot is exactly the server a `codey` driver would talk to, so nothing is
282+ thrown away if we later decide the fork is worth maintaining.
283+
284+ ## Appendix: the Codex app-server contract
285+
286+ Kept because it is the fallback if the Grok path is withdrawn, and because it
287+ is the only slot that lets our model names reach the picker honestly.
288+
289+ Launch: `<binaryPath> app-server [launchArgs]`, `CODEX_HOME` set in the child
290+ env, tilde-expanded (`Layers/CodexProvider.ts:368-378`).
291+
292+ Must answer:
293+ - `initialize` → `{codexHome, platformFamily, platformOs, userAgent}`, all four required; version is scraped from `userAgent` after the first `/`.
294+ - `thread/start` → `{approvalPolicy, approvalsReviewer, cwd, model, modelProvider, sandbox, thread{...}}` — the strictest decode in the flow.
295+ - `turn/start` → `{turn: {id, items: [], status: "inProgress"}}`.
296+ - `turn/interrupt` → `{}`.
297+ - probe path: `account/read` → `{requiresOpenaiAuth: false, account: {...}}`; `model/list` → `{data: [...]}`; `skills/list` (a failure here fails the whole probe, unlike `account/rateLimits/read`, which degrades).
298+
299+ Must emit: `turn/started`, `item/agentMessage/delta`, `item/completed`,
300+ `turn/completed` — the last is what ends the turn
301+ (`Layers/CodexAdapter.ts:1613`).
302+
303+ Traps: notifications must have no `id` key at all; inbound queues are
304+ `sliding(32)` so bursts drop oldest; a server→client request left unanswered
305+ blocks T3 Code's entire stdin loop (`Layers/CodexSessionRuntime.ts:2565-2571`);
306+ there is no shutdown RPC — exit on stdin EOF.