# Codey inside T3 Code — implementation plan
> **Status: phases 1 and 2 implemented**, with six departures from what is
> written below.
>
> Approvals moved from phase 2 into phase 1. A nil `Approve` means the agent
> may do anything, so "text only, approvals later" was not a smaller first
> step — it was shipping the gate open. `Event.ID` moved forward for the same
> kind of reason: the translator needed tool call ids from its first line, so
> pairing by arrival order was never written.
>
> `approval.Describe` was exported rather than duplicated. The tool-call title
> a client shows and the summary an approval box shows are the same sentence,
> and two vocabularies for one call would be two things to keep in step.
>
> The coalescer has no timer. Text goes out on size, on elapsed time checked at
> the next delta, and on any event that is not text — which covers the case the
> timer was for, a sentence written just before a long command, and leaves the
> tests free of sleeping.
>
> Two things were added that the plan did not ask for. `initialize._meta.modelState`
> carries the model list, because T3 Code's health check shakes hands and stops
> without opening a session, and a picker only offered at `session/new` never
> reaches the provider card. And images work: `promptCapabilities.image` is
> advertised and image blocks are converted, which was phase 3 in the plan and
> turned out to be ten lines.
>
> Phase 3 is now mostly done as well: reasoning streams as `agent_thought_chunk`,
> a tool's output streams into its row while the command is still running
> (which the plan did not list at all, and is the gap you notice first), and
> subagents each get a row that opens, fills and closes with their report.
> What is left of it is session resume, `plan` updates and slash commands.
>
> See the `## Inside another editor` section of README.md for what shipped.
> **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.
**Goal:** Run Codey as an agent inside T3 Code without touching T3 Code's
source. The user enables the Grok provider in **Settings → Providers**, sets
**Binary path** to the `codey` binary, and gets Codey threads with streaming
text, visible tool calls, working approvals and a stop button.
**Architecture:** Codey grows a third front end alongside the TUI and `-p`.
`internal/acp` owns the wire — newline-delimited JSON-RPC 2.0 over stdin and
stdout, with the handful of ACP methods T3 Code actually exercises.
`internal/serve` is the bridge: one `*agent.Agent` per ACP session, its
`Observe` callback translated into `session/update` notifications and its
`Approve` gate translated into `session/request_permission` round trips.
`cmd/codey` grows an `agent stdio` verb plus the two shell probes T3 Code runs
before it will show the provider as ready. Nothing in `internal/agent`,
`internal/llm` or `internal/tui` changes in phase 1.
**Tech Stack:** Go 1.26, stdlib only — `encoding/json`, `bufio`, `context`,
`sync`. No ACP library exists for Go and the surface is five methods; hand-
rolling it is smaller than a dependency.
**Spec:** `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` — read
it first. It carries the evidence for why the Grok slot and not Codex's
app-server, and the exact field-level contract quoted out of T3 Code's source.
## Global Constraints
- Go module is `codey`; import paths are `codey/internal/...`.
- **Stdout belongs to the protocol.** In `agent stdio` mode nothing may write
to stdout except the JSON-RPC encoder — no `fmt.Println`, no metrics logger,
no stray `log`. Diagnostics go to stderr, which T3 Code drains and (on the
Grok path) discards. A single stray line desynchronises the parser and the
thread dies with a decode error.
- **`jsonrpc: "2.0"` is mandatory on every message we send.** T3 Code's ACP
codec declares it `Schema.Literal("2.0")` on requests, notifications and
responses (`packages/effect-acp/src/_internal/shared.ts:83-103`). This is the
opposite of their Codex path, where the field must be absent — do not carry
habits between the two.
- **A notification must have no `id` key.** Not `null`, absent.
- **Never leave an inbound request unanswered**, including methods we do not
implement: answer `-32601`. T3 Code answers our requests on its read loop, so
a hang on either side stalls the whole session.
- T3 Code's inbound notification queue is `sliding(32)`
(`packages/effect-acp/src/protocol.ts:89`). Coalesce text deltas rather than
emitting one notification per token.
- `internal/acp` imports no other `internal` package and knows nothing about
agents: it is a JSON-RPC peer with ACP's types. `internal/serve` may import
`internal/acp`, `internal/agent`, `internal/tools`, `internal/approval`;
nothing imports `internal/serve` back except `cmd/codey`.
- Every test is offline and in-process: a `net.Pipe` or an `io.Pipe` pair, no
subprocess, no network, no sleeping on wall-clock time.
- New exported identifiers get a doc comment in the existing house style.
- Run `gofmt -w` on every file you touch, and `go test ./...` before each task
is ticked off.
---
## Phase 1 — a text turn appears in T3 Code
### Task 1: `internal/acp` — types and the JSON-RPC peer
**Files:**
- Create: `internal/acp/wire.go`, `internal/acp/types.go`, `internal/acp/conn.go`, `internal/acp/conn_test.go`
**Interfaces:**
- Consumes: nothing.
- Produces:
```go
// wire.go
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
type Error struct { Code int `json:"code"`; Message string `json:"message"`; Data any `json:"data,omitempty"` }
const (
CodeParse = -32700; CodeInvalidRequest = -32600
CodeMethodNotFound = -32601; CodeInvalidParams = -32602; CodeInternal = -32603
)
// conn.go
type Handler interface {
Initialize(context.Context, InitializeRequest) (InitializeResponse, error)
Authenticate(context.Context, AuthenticateRequest) error
NewSession(context.Context, NewSessionRequest) (NewSessionResponse, error)
Prompt(context.Context, PromptRequest) (PromptResponse, error)
Cancel(context.Context, CancelParams)
}
type Conn struct{ /* unexported */ }
func NewConn(r io.Reader, w io.Writer, h Handler) *Conn
func (c *Conn) Serve(ctx context.Context) error // returns nil on clean EOF
func (c *Conn) Notify(ctx context.Context, method string, params any) error
func (c *Conn) Request(ctx context.Context, method string, params any, out any) error
```
`types.go` holds the ACP structs, spelled exactly as the spec quotes them:
`InitializeRequest{ProtocolVersion int, ClientCapabilities, ClientInfo}`,
`InitializeResponse{ProtocolVersion int, AgentInfo, AgentCapabilities, AuthMethods}`,
`AuthenticateRequest{MethodID string}`, `NewSessionRequest{Cwd string,
McpServers []json.RawMessage, AdditionalDirectories []string}`,
`NewSessionResponse{SessionID string, Models *ModelState}`,
`PromptRequest{SessionID string, Prompt []ContentBlock}`,
`PromptResponse{StopReason string}`, `SessionNotification{SessionID string,
Update Update}`, `RequestPermissionParams/Result`.
`ContentBlock` is a tagged struct (`Type`, `Text`, `Data`, `MimeType`, `Path`)
— decode leniently, we only read `text`.
**Behaviour that the tests pin down:**
Reader goroutine owns the decoder; **each inbound request is dispatched on its
own goroutine** so that a `session/cancel` notification arriving mid-prompt is
seen immediately. A single mutex serialises writes. Outbound request ids are
integers from a counter; replies are correlated through a `map[string]chan`
keyed by `string(id)`.
- [ ] **Step 1: Write the failing tests**
`conn_test.go` drives a `Conn` over an `io.Pipe` pair with a stub handler
and asserts: every outbound line carries `"jsonrpc":"2.0"`; a notification
line has no `id` key at all (assert on the raw bytes, not on a decoded map);
a request for an unknown method comes back as `-32601` with the same `id`; a
handler returning an error becomes `-32603` and does not kill the connection;
malformed JSON on a line is answered `-32700` where an id can be recovered
and skipped otherwise, and the loop survives; `Request` blocks until the
matching response arrives and returns the decoded result; `Request` with a
cancelled context returns `ctx.Err()` and drops the pending entry; a
`session/cancel` notification is delivered while a `session/prompt` handler
is still running; clean EOF on the reader returns `nil` from `Serve`.
- [ ] **Step 2: Implement `wire.go` and `types.go`**
Encoding is one compact JSON object per line. `ID` is kept as
`json.RawMessage` so a string id and a numeric id both round-trip byte-exact.
- [ ] **Step 3: Implement `conn.go`**
- [ ] **Step 4: Verify**
```bash
gofmt -w internal/acp && go test ./internal/acp/...
```
---
### Task 2: `internal/serve` — the session bridge, text only
**Files:**
- Create: `internal/serve/serve.go`, `internal/serve/session.go`, `internal/serve/update.go`, `internal/serve/serve_test.go`
**Interfaces:**
- Consumes: `internal/acp`, `internal/agent`, `internal/tools`, `internal/metrics`.
- Produces:
```go
type Options struct {
NewAgent func(root string) (*agent.Agent, error) // one agent per session
Log *metrics.Logger
}
type Server struct{ /* unexported */ }
func New(opts Options) *Server
func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error
```
`NewAgent` is a factory taking the session's `cwd`, mirroring the
`toolsIn(root)` factory `cmd/codey/main.go:286` already uses to rebuild a
tool set against a worktree. T3 Code hands us a cwd per `session/new` and it
is not necessarily the process's own.
**Behaviour:**
- `initialize` → `{protocolVersion: 1, agentInfo: {name: "codey", version},
agentCapabilities: {promptCapabilities: {image: true}}, authMethods: []}`.
Accept any `protocolVersion` the client offers and answer with `1`; T3 Code
sends `1` today (`acp/AcpSessionRuntime.ts:696-705`).
- `authenticate` → `{}` for any `methodId`. T3 Code sends `cached_token` or
`xai.api_key` and does not catch a failure here, so this must never error.
- `session/new` → build an agent rooted at `params.cwd`, store it under a fresh
session id (`codey-` + 16 random hex), reply `{sessionId}`. Ignore
`mcpServers`.
- `session/prompt` → concatenate the text blocks, run the agent, stream, reply
`{stopReason: "end_turn"}`. `"cancelled"` if the session was cancelled,
`"refusal"` on a hard agent error after emitting the error text as a chunk.
- `session/cancel` → cancel that session's in-flight turn context. Idempotent,
and harmless when nothing is running.
`update.go` owns the translation, and is the file worth unit-testing hardest:
| `agent.Event` | `session/update` |
|---|---|
| `EventTextDelta` | `agent_message_chunk` with `content: {type: "text", text}` |
| `EventText` (non-streaming) | same, once |
| everything else | dropped in phase 1 |
Deltas are coalesced: buffer until 50 ms have passed or 512 bytes have
accumulated, then flush one notification. This is what keeps us clear of T3
Code's `sliding(32)` queue on a fast model.
- [ ] **Step 1: Write the failing tests**
Drive `Server.Run` over pipes with a fake `NewAgent` whose `Run` emits a
scripted `[]agent.Event`. Assert: the full handshake in order
(`initialize` → `authenticate` → `session/new` → `session/prompt`) produces a
session id and a `{stopReason:"end_turn"}`; the text arrives as
`session/update` notifications carrying the session id and reassembling to
the original string; deltas are coalesced rather than one-per-event; a
`session/prompt` for an unknown session id is an error, not a panic; a
`session/cancel` during a turn cancels the agent's context and the prompt
replies `{stopReason:"cancelled"}`; two sessions run concurrently without
their updates crossing over.
- [ ] **Step 2: Implement `session.go`** — the session table, per-session
mutex, per-turn cancel func.
- [ ] **Step 3: Implement `update.go`** — the event translation and the
coalescer. The coalescer takes a clock function so tests do not sleep.
- [ ] **Step 4: Implement `serve.go`** — wire the handler methods onto the
session table.
- [ ] **Step 5: Verify**
```bash
gofmt -w internal/serve && go test ./internal/serve/...
```
---
### Task 3: `cmd/codey` — the `agent stdio` verb and the two probes
**Files:**
- Modify: `cmd/codey/main.go`
- Create: `cmd/codey/agentcmd.go`, `cmd/codey/agentcmd_test.go`
**Behaviour:**
Argument parsing has to be positional-agnostic, because T3 Code splits its
flags around the subcommand: `--permission-mode default agent stdio`, but
`agent --always-approve stdio` (`acp/GrokAcpSupport.ts:33-46`). Scan `os.Args`
for the bare words `agent` and `stdio` and for the three flags anywhere in the
line; ignore anything else rather than erroring, since `launchArgs` is a free
text field the user can put anything in.
Three entry points:
- `codey --version` → one line, `codey <semver>`, exit 0. Must match
`/\bv?(\d+\.\d+\.\d+)\b/` (`apps/server/src/provider/providerSnapshot.ts:114-119`).
Keep the existing `-v` verbose flag working; `--version` is new and distinct.
- `codey models` → exit 0, and print, in this order:
```
You are logged in as <endpoint or account>.
* codey-opus-5 (default)
- codey-sonnet-5
```
T3 Code reads login state off `/you are logged in/i` and models off
`^\s*[*-]\s+(\S+)` bullet lines, with `(default)` marking the default
(`Layers/GrokProvider.ts:258-286`). Source the list from the same
`GET /v1/models` the TUI model picker uses; on failure print the configured
model alone and still exit 0 — a non-zero exit makes T3 Code discard the
whole listing.
- `codey agent [--permission-mode X] [--always-approve] stdio` → build the
`serve.Server` and run it on stdin/stdout.
Permission modes map onto the existing `approval.Policy`:
| T3 Code mode | Codey behaviour |
|---|---|
| `default` (or absent) | ask for every mutating tool |
| `acceptEdits` | auto-approve `edit`/`write`, ask for `bash` |
| `auto` | auto-approve all but ask for anything the policy calls non-rememberable |
| `--always-approve` | the equivalent of `-yolo` |
- [ ] **Step 1: Write the failing tests**
`agentcmd_test.go` tests the argument scanner as a pure function over
`[]string` — all five argv shapes T3 Code produces, plus junk from
`launchArgs`, plus `--version` and `models`. Then a test that asserts the
version line matches T3 Code's regex and the `models` output parses under a
Go transliteration of their bullet parser.
- [ ] **Step 2: Implement the scanner and the three entry points**
Take the branch as early in `main` as possible — before the metrics logger
opens, before the wizard, before anything can print to stdout.
- [ ] **Step 3: Verify**
```bash
gofmt -w ./cmd/codey && go test ./... && go build -o /tmp/codey ./cmd/codey
/tmp/codey --version && /tmp/codey models
```
---
### Task 4: End-to-end against the real T3 Code
**Files:**
- Create: `internal/serve/conformance_test.go`
**Steps:**
- [ ] **Step 1: A conformance test that replays T3 Code's exact handshake**
Hard-code the literal JSON T3 Code sends — `initialize` with
`{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"t3-code","version":"0.0.0"}}`,
then `authenticate` with `{"methodId":"cached_token"}`, then `session/new`,
then `session/prompt` — and assert our replies decode against the shapes in
the design doc. This file is the early-warning system for their churn: when
a nightly breaks us, this is what tells us where.
- [ ] **Step 2: Wire it up by hand**
In T3 Code: Settings → Providers → Grok → enable, Binary path = the built
`codey`, instance display name "Codey". Or edit `~/.t3/userdata/settings.json`
directly (restart the app afterwards):
```json
"providerInstances": {
"codey": {
"driver": "grok",
"displayName": "Codey",
"enabled": true,
"config": { "binaryPath": "/home/joni/Work/GO/coding-harness/codey" },
"environment": [
{ "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true }
]
}
}
```
The per-instance `environment` array is how Codey gets its credential without
inheriting whatever the desktop app was launched with.
- [ ] **Step 3: Confirm the provider card and one turn**
The card should read installed, authenticated, with our model list. Start a
thread, ask something that needs no tools, and watch the text stream in.
- [ ] **Step 4: Capture the traffic for the next phase**
T3 Code logs every provider event to
`~/.t3/userdata/logs/provider/events.<threadId>.log`, tagged `CANON:` for its
normalised model and `NTIVE:` for the raw wire. That file is the ground truth
for anything phase 2 gets wrong.
---
## Phase 2 — a first-class provider
### Task 5: Tool calls become visible
**Files:**
- Modify: `internal/agent/loop.go` (add `ID` to `Event`), `internal/serve/update.go`
- Modify: `internal/agent/loop_test.go`, `internal/serve/serve_test.go`
`agent.Event` carries `Tool string` but nothing pairing a start with its result
(`internal/agent/loop.go:180-191`). ACP keys `tool_call` and `tool_call_update`
by `toolCallId`. Pairing by order would work today because dispatch is
sequential, but it is a landmine the first time it is not.
- [ ] **Step 1:** Add `ID string` to `agent.Event`, populated from the
`tool_use` block's id on `EventToolStart` and echoed on `EventToolResult`.
Existing observers ignore it; the TUI is unaffected.
- [ ] **Step 2:** Emit `tool_call` on start with
`{toolCallId, title, kind, status: "pending", rawInput}` and
`tool_call_update` on result with `status: "completed"|"failed"` and
`content: [{type: "content", content: {type: "text", text}}]`.
`kind` maps our tools onto ACP's vocabulary: `read`, `edit`, `execute`,
`search`, `fetch`, `think`, `other`.
- [ ] **Step 3:** Verify the tool rows render in T3 Code, and that a failed
tool shows as failed rather than silently completing.
### Task 6: Approvals
**Files:**
- Modify: `internal/serve/serve.go`, `internal/serve/session.go`
- [ ] **Step 1:** Implement an `approval.Prompt` that sends
`session/request_permission` with
`options: [{optionId:"allow-once", name:"Allow", kind:"allow_once"},
{optionId:"allow-always", name:"Always allow", kind:"allow_always"},
{optionId:"reject-once", name:"Reject", kind:"reject_once"}]`
and blocks on the reply. Use exactly those three ids: Grok echoes back one of
ours, but Cursor's adapter hardcodes those literals
(`acp/AcpAdapterSupport.ts:46-55`), so choosing them now means the same binary
works in a Cursor instance too.
- [ ] **Step 2:** Map the reply — `selected`/`allow-always` →
`approval.AlwaysThisCommand`, `selected`/`allow-once` → `Once`,
`selected`/`reject-once` → `Deny`, `cancelled` → abort the turn.
- [ ] **Step 3:** Respect the launch permission mode: in `--always-approve` no
request is sent at all.
- [ ] **Step 4:** Verify against T3 Code's approval panel, including the
interaction with its own permission-mode selector, and confirm that
cancelling a pending approval does not wedge the session.
### Task 7: Models, effort and the picker
**Files:**
- Modify: `internal/serve/serve.go`, `cmd/codey/agentcmd.go`
- [ ] **Step 1:** Return the model list in `session/new` as
`{models: {currentModelId, availableModels: [{modelId, name}]}}`. T3 Code's
Grok path reads it (`acp/GrokAcpSupport.ts:124-154`) and prefers it over the
`codey models` output.
- [ ] **Step 2:** Handle `session/set_model` / `session/set_config_option` by
switching the session's model, and reply `{}` to any config option we do not
recognise rather than erroring — Cursor's adapter sends one unconditionally.
- [ ] **Step 3:** Carry reasoning effort through `_meta.reasoningEffort` if it
is present on the selected model.
---
## Phase 3 — polish, in rough order of value
- [ ] **Reasoning.** Surface thinking blocks from the loop as
`agent_thought_chunk`. Needs a new event kind in `internal/agent`; today the
loop never reports them to observers.
- [ ] **Diffs.** Emit `tool_call` content of kind `diff` with `path`, `oldText`,
`newText` from `edit`/`write`, so T3 Code's diff viewer lights up instead of
showing our text summary.
- [ ] **Resume.** Advertise `agentCapabilities.sessionCapabilities.resume` and
implement `session/load` on top of `agent.ResolveSession`, replaying the
history as `session/update`s. Until we advertise it, T3 Code will not ask
(`acp/AcpSessionRuntime.ts:726`), so this is purely additive.
- [ ] **Plans.** Map our todo-ish output onto the `plan` update, or skip it —
it is optional and we have no plan object today.
- [ ] **Slash commands.** `available_commands_update` can expose `/compact` and
the skills list in T3 Code's composer.
- [ ] **Usage.** Feed token counts somewhere visible; the Grok path has no
usage channel, so this may be the first thing that argues for the fork.
- [ ] **README.** A "Using Codey from T3 Code" section: the settings recipe,
what the Grok branding means, and the fact that it says Grok on the tin.
---
## How we will know it works
1. `go test ./...` green, including the conformance test.
2. `codey --version` and `codey models` satisfy T3 Code's parsers — asserted in
Go, then observed on the provider card.
3. A thread in T3 Code: text streams, tool rows appear and complete, an
approval prompt appears and is answerable, the stop button ends the turn,
and the thread survives a second prompt in the same session.
4. `~/.t3/userdata/logs/provider/events.<threadId>.log` contains no decode
errors and no `warning` rows we did not intend.
## Out of scope
- Any change to T3 Code's source. The fork — a `codey` driver in
`BUILT_IN_DRIVERS` — is discussed at the end of the design doc and stays a
later decision. Nothing in this plan is wasted if we take it: the same ACP
server is what such a driver would talk to.
- The Codex app-server path. Specified in the design doc's appendix as the
fallback, not built.
- MCP. T3 Code offers us an MCP server on `session/new`; we ignore it.