Vectrum
Sign In
487 lines · 22.0 KB

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 remains open. 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:

    // 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/...

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:

    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.Eventsession/update
EventTextDeltaagent_message_chunk with content: {type: "text", text}
EventText (non-streaming)same, once
everything elsedropped 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/...

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 modeCodey behaviour
default (or absent)ask for every mutating tool
acceptEditsauto-approve edit/write, ask for bash
autoauto-approve all but ask for anything the policy calls non-rememberable
--always-approvethe 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

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):

    "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/updates. 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.