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.