Vectrum
Sign In
459 lines · 20.5 KB

Codey inside T3 Code — implementation plan

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.