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:
  ```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.