Status: phases 1 and 2 implemented, with six departures from what is written below.
Approvals moved from phase 2 into phase 1. A nil
Approvemeans the agent may do anything, so "text only, approvals later" was not a smaller first step — it was shipping the gate open.Event.IDmoved 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.Describewas 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.modelStatecarries the model list, because T3 Code's health check shakes hands and stops without opening a session, and a picker only offered atsession/newnever reaches the provider card. And images work:promptCapabilities.imageis 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,planupdates and slash commands.See the
## Inside another editorsection 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.
codey; import paths are codey/internal/....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.id key. Not null, absent.-32601. T3 Code answers our requests on its read loop, so
a hang on either side stalls the whole session.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.net.Pipe or an io.Pipe pair, no
subprocess, no network, no sleeping on wall-clock time.gofmt -w on every file you touch, and go test ./... before each task
is ticked off.internal/acp — types and the JSON-RPC peerFiles:
internal/acp/wire.go, internal/acp/types.go, internal/acp/conn.go, internal/acp/conn_test.goInterfaces:
Consumes: nothing.
Produces:
// 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
gofmt -w internal/acp && go test ./internal/acp/...
internal/serve — the session bridge, text onlyFiles:
internal/serve/serve.go, internal/serve/session.go, internal/serve/update.go, internal/serve/serve_test.goInterfaces:
Consumes: internal/acp, internal/agent, internal/tools, internal/metrics.
Produces:
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
gofmt -w internal/serve && go test ./internal/serve/...
cmd/codey — the agent stdio verb and the two probesFiles:
cmd/codey/main.gocmd/codey/agentcmd.go, cmd/codey/agentcmd_test.goBehaviour:
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
gofmt -w ./cmd/codey && go test ./... && go build -o /tmp/codey ./cmd/codey
/tmp/codey --version && /tmp/codey models
Files:
internal/serve/conformance_test.goSteps:
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):
"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.
Files:
internal/agent/loop.go (add ID to Event), internal/serve/update.gointernal/agent/loop_test.go, internal/serve/serve_test.goagent.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.
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.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.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.
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.
agent_thought_chunk. Needs a new event kind in internal/agent; today the
loop never reports them to observers.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.agentCapabilities.sessionCapabilities.resume and
implement session/load on top of agent.ResolveSession, replaying the
history as session/updates. Until we advertise it, T3 Code will not ask
(acp/AcpSessionRuntime.ts:726), so this is purely additive.plan update, or skip it —
it is optional and we have no plan object today.available_commands_update can expose /compact and
the skills list in T3 Code's composer.go test ./... green, including the conformance test.codey --version and codey models satisfy T3 Code's parsers — asserted in
Go, then observed on the provider card.~/.t3/userdata/logs/provider/events.<threadId>.log contains no decode
errors and no warning rows we did not intend.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.session/new; we ignore it.