diff --git a/README.md b/README.md index 0467521..4c66c86 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,34 @@ make clean # remove ./cathode | flag | default | meaning | |----------|---------|---------------------------------------------------------------------------| +| `-backend`| `claude`| agent CLI to drive: `claude` or `codex` | | `-mode` | `build` | `ask` (gated, shows approval pane) | `plan` (read-only) | `build` (auto-accept edits) | `bypass` | | `-mcp` | `""` | path to a `.mcp.json` that wires your internal tools | | `-model` | `""` | pin a model (e.g. `sonnet`); empty uses the account default | | `-spinner`| `bar` | working throbber: `bar` | `shade` | `block` | `arrow` | `scan` | -| `-resume`| `""` | claude session id to resume (set automatically when picking via `ctrl+r`) | +| `-resume`| `""` | session id to resume (set automatically when picking via `ctrl+r`) | | `-ctx` | `200k` | context-gauge window: `200k` / `500k` / `1m` or a raw count; auto-grows | + +## Backends + +Cathode drives `claude` by default. `-backend codex` drives OpenAI's `codex` +CLI instead, over its `app-server` JSON-RPC protocol. Both run on a +subscription: cathode never sets an API key, and strips the variables that +would divert billing to one. + +Codex needs `codex login` completed, the same way claude needs `claude login`. + +The codex backend is newer and narrower than the claude one: + +- `build` and `bypass` work fully. Tools run, and codex asks for nothing. +- `ask` and `plan` refuse gated actions rather than granting them, because the + approval pane is not wired to codex yet. +- Tool calls and file changes render as cards. Side-by-side diffs, session + replay and the slash-command palette are claude-only so far. + +`CATHODE_CODEX_LIVE=1 go test -run TestCodexLive ./...` exercises the backend +against the real CLI. It spends a turn on your subscription, so it is off by +default. | `-debug` | `""` | tee raw stream-json + MCP traffic to this logfile | ## Themes diff --git a/backend.go b/backend.go index 25bf7c7..3d96eb3 100644 --- a/backend.go +++ b/backend.go @@ -26,7 +26,11 @@ type Engine interface { Initialize() error // Interrupt asks the subprocess to abort the turn in flight. Interrupt() error - // SetPermissionMode switches permission mode without a restart. + // SetPermissionMode switches permission mode without a restart. mode is a + // *cathode* mode (ask | plan | build | bypass), not a backend's own + // vocabulary: each implementation translates. The seam described claude's + // --permission-mode values at first, which meant a second backend had to + // reverse that translation before doing its own. SetPermissionMode(mode string) error // SetModel switches the model for subsequent turns. SetModel(model string) error @@ -36,8 +40,19 @@ type Engine interface { Close() } -// Compile-time proof that the claude backend satisfies the seam. main already +// How cathode identifies itself to a backend that asks. codex's initialize +// handshake wants both, and the name reaches its user-agent string, so this is +// the plain name rather than the wordmark appName renders on screen. +const ( + clientName = "cathode" + clientVersion = "0.1.0" +) + +// Compile-time proof that each backend satisfies the seam. main already // forces this by passing one to newModel, but stating it here keeps the check // attached to the interface rather than to whichever call site happens to // exist. -var _ Engine = (*claudeEngine)(nil) +var ( + _ Engine = (*claudeEngine)(nil) + _ Engine = (*codexEngine)(nil) +) diff --git a/backendpick.go b/backendpick.go new file mode 100644 index 0000000..aaf025c --- /dev/null +++ b/backendpick.go @@ -0,0 +1,51 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" +) + +// The backends -backend accepts. Named constants because the value is compared +// in more than one place (main gates the approvals server on it too), and a +// typo in a string literal there fails open: the server starts, nothing routes +// through it, and every gated tool waits for an approval that never arrives. +const ( + backendClaude = "claude" + backendCodex = "codex" +) + +// startEngine spawns the backend the user asked for. +// +// The two are not symmetric, and the asymmetry is all in this function so the +// rest of the program does not carry it. claude takes its whole session shape +// as launch flags, which is why EngineConfig is already assembled by the time +// we get here. codex takes almost none as flags: the mode, model, working root +// and resumed thread are parameters of thread/start and turn/start, so they are +// handed to the engine instead and applied per call (codexcalls.go). +func startEngine(backend string, cfg EngineConfig, mode, resume, model string) (Engine, error) { + switch backend { + case backendClaude: + e, err := newClaudeEngine(cfg) + if err != nil { + return nil, fmt.Errorf("failed to start claude: %w\nis the `claude` CLI installed and on PATH, and have you run `claude login`?", err) + } + return e, nil + + case backendCodex: + cwd, _ := os.Getwd() + e, err := newCodexEngine(codexEngineConfig{ + Model: model, + Mode: mode, + Cwd: cwd, + ResumeID: resume, + }) + if err != nil { + return nil, fmt.Errorf("failed to start codex: %w\nis the `codex` CLI installed and on PATH, and have you run `codex login`?", err) + } + return e, nil + } + return nil, fmt.Errorf("unknown -backend %q: use %s or %s", backend, backendClaude, backendCodex) +} diff --git a/codexapproval.go b/codexapproval.go new file mode 100644 index 0000000..4470a4a --- /dev/null +++ b/codexapproval.go @@ -0,0 +1,67 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +// ---- answering the requests codex makes of us ---- +// +// The app-server sends requests in the other direction, and every one of them +// blocks until answered. An unanswered request is not a dropped message: the +// turn stops there and the session looks frozen with no error anywhere. So this +// file's rule is that every server request gets a reply, always. +// +// The approval pane is not wired to codex yet. Until it is, a gated action is +// refused rather than granted, because the alternative is a backend that +// silently runs whatever it likes in the mode whose entire purpose is asking +// first. + +// codexApprovalMethods are the server requests that gate an action, and so can +// be answered with a decision. Anything not on this list is answered with a +// JSON-RPC error instead — inventing a reply for a request whose semantics we +// have not established is worse than declining it plainly. +var codexApprovalMethods = map[string]bool{ + "item/commandExecution/requestApproval": true, + "item/fileChange/requestApproval": true, + "item/permissions/requestApproval": true, + "applyPatchApproval": true, + "execCommandApproval": true, +} + +// codexRefusal is the decision sent for a gated action. +// +// "cancel" and not "decline", and deliberately not read from the request's +// availableDecisions: a file-change approval offers only ["accept"], so there is +// no refusal in the offered set at all. Probing the live app-server showed +// cancel is accepted anyway and ends the turn cleanly with TurnAborted, rather +// than being rejected as an unknown variant. That makes it the one refusal that +// works for every request shape. +const codexRefusal = "cancel" + +// answerServerRequest replies to a request from the app-server. It never +// declines to answer: see the file comment for what silence costs. +func (e *codexEngine) answerServerRequest(f codexFrame) { + if f.ID == nil { + return + } + if codexApprovalMethods[f.Method] { + _ = e.write(map[string]any{ + "jsonrpc": "2.0", + "id": *f.ID, + "result": map[string]any{"decision": codexRefusal}, + }) + e.emitError("refused " + f.Method + " — approvals are not wired to this backend yet") + return + } + // Not an approval. Answer with the JSON-RPC "method not found" code, which + // is a well-defined way to say "this client cannot do that" and leaves the + // server to decide what happens next. + _ = e.write(map[string]any{ + "jsonrpc": "2.0", + "id": *f.ID, + "error": map[string]any{ + "code": -32601, + "message": "cathode does not implement " + f.Method, + }, + }) + e.emitError("unhandled request " + f.Method) +} diff --git a/codexcalls.go b/codexcalls.go new file mode 100644 index 0000000..df16880 --- /dev/null +++ b/codexcalls.go @@ -0,0 +1,104 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +// codexMsg carries one inbound frame into the Bubble Tea Update loop, the way +// streamMsg does for claude. +type codexMsg struct{ frame codexFrame } + +// Synthetic methods cathode raises itself. Namespaced under "cathode/" so they +// can never collide with something the app-server adds later: every real method +// is under a codex-owned prefix, and this makes the distinction checkable +// rather than a matter of memory. +const ( + codexClosedMethod = "cathode/closed" // the subprocess exited + codexErrorMethod = "cathode/error" // a call failed, and nobody was waiting +) + +// codexCallTimeout bounds a request that gets no reply. It is generous because +// the only blocking caller is the opening handshake, which starts a subprocess, +// reads config and may refresh an auth token. +const codexCallTimeout = 60 * time.Second + +// write marshals one JSON-RPC message and writes the line. +func (e *codexEngine) write(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + e.wmu.Lock() + defer e.wmu.Unlock() + if _, err := e.stdin.Write(append(b, '\n')); err != nil { + return err + } + debug.Logf("stdin", "%s", b) + return nil +} + +// notify sends a fire-and-forget notification (no id, no reply). +func (e *codexEngine) notify(method string, params any) error { + return e.write(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) +} + +// call sends a request and waits for its reply. Blocking, so it belongs in a +// tea.Cmd goroutine or in startup code — never directly in Update. +func (e *codexEngine) call(method string, params any) (json.RawMessage, error) { + id, ch := e.pending.begin() + msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params} + if err := e.write(msg); err != nil { + e.pending.abandon(id) + return nil, err + } + select { + case f := <-ch: + if f.Error != nil { + return nil, f.Error + } + return f.Result, nil + case <-time.After(codexCallTimeout): + e.pending.abandon(id) + return nil, fmt.Errorf("codex: no reply to %s within %s", method, codexCallTimeout) + } +} + +// fire sends a request without blocking the caller, and surfaces a failure as a +// UI frame instead of returning it. Update runs on the UI timeline, so nothing +// called from it may wait on a round trip. +func (e *codexEngine) fire(method string, params any, onResult func(json.RawMessage)) error { + id, ch := e.pending.begin() + msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params} + if err := e.write(msg); err != nil { + e.pending.abandon(id) + return err + } + go func() { + select { + case f := <-ch: + if f.Error != nil { + e.emitError(method + ": " + f.Error.Error()) + return + } + if onResult != nil { + onResult(f.Result) + } + case <-time.After(codexCallTimeout): + e.pending.abandon(id) + e.emitError(fmt.Sprintf("%s: no reply within %s", method, codexCallTimeout)) + } + }() + return nil +} + +// emitError raises a synthetic frame so a failed background call is visible in +// the transcript rather than only in a -debug log. +func (e *codexEngine) emitError(msg string) { + b, _ := json.Marshal(map[string]string{"message": msg}) + e.emit(codexFrame{Method: codexErrorMethod, Params: b}) +} diff --git a/codexengine.go b/codexengine.go new file mode 100644 index 0000000..bd039cf --- /dev/null +++ b/codexengine.go @@ -0,0 +1,134 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io" + "os" + "os/exec" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// codexEngine drives a long-lived `codex app-server` subprocess over +// stdio JSON-RPC. It is the codex half of the Engine seam (backend.go). +// +// Two differences from claudeEngine shape everything here. +// +// A turn needs a thread first. claude opens a session implicitly on the first +// turn; codex requires thread/start (or thread/resume) and hands back an id +// that every later call carries. Initialize does that, so a Send always has one. +// +// Mode and model are per-turn, not session-level. claude takes control requests +// mid-session (set_permission_mode, set_model); codex has no equivalent request +// — turn/start takes approvalPolicy, sandbox and model as parameters instead. So +// the setters here record the value and the next turn applies it, which is why +// they cannot fail and never touch the wire. +type codexEngine struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + + wmu sync.Mutex // serialises writes to stdin + pending *codexPending + + mu sync.Mutex // guards everything below + cwd string // working root the thread runs in + resumeID string // thread to resume on Initialize, or "" + threadID string + turnID string // live turn, learned from turn/started; interrupt needs it + mode string // cathode mode, applied at the next turn/start + model string + // sink is where a non-reply frame goes. Pipe sets it; until then frames + // are held in backlog. A function rather than the *tea.Program itself + // keeps the emit path independent of Bubble Tea, which is what lets a test + // collect frames directly. + sink func(codexFrame) + backlog []codexFrame // frames that arrived before Pipe registered a sink +} + +// codexEngineConfig is what main resolved for a codex session. +type codexEngineConfig struct { + Model string // "" lets codex pick + Mode string // cathode mode: ask | plan | build | bypass + Cwd string + ResumeID string // thread to resume, or "" for a fresh one +} + +// codexCommand is the binary spawned for a codex session. A package var, not a +// literal, so a test can point it at a scripted stand-in and exercise the real +// reader loop, id correlation and handshake parsing without a network call or a +// billed turn. Same stubbing convention as loadRepoFiles. +var codexCommand = "codex" + +// newCodexEngine spawns the app-server and starts reading it. +// +// The reader starts here rather than in Pipe, because Initialize has to +// complete a request/response round trip before the Bubble Tea program exists. +// Frames that arrive in that window are held in backlog and flushed when Pipe +// registers, so nothing from the opening handshake is lost. +func newCodexEngine(cfg codexEngineConfig) (*codexEngine, error) { + debug.Logf("spawn", "%s app-server", codexCommand) + + cmd := exec.Command(codexCommand, "app-server") + cmd.Env = codexEnv() + cmd.Stderr = os.Stderr // surface auth and spawn failures directly + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + e := &codexEngine{ + cmd: cmd, stdin: stdin, stdout: stdout, + pending: newCodexPending(), + mode: cfg.Mode, + model: cfg.Model, + cwd: cfg.Cwd, + resumeID: cfg.ResumeID, + } + go e.read() + return e, nil +} + +// Pipe registers the program and flushes anything the handshake produced. +// Unlike claudeEngine.Pipe this does not block: reading started at construction. +func (e *codexEngine) Pipe(p *tea.Program) { + send := func(f codexFrame) { p.Send(codexMsg{frame: f}) } + e.mu.Lock() + e.sink = send + held := e.backlog + e.backlog = nil + e.mu.Unlock() + for _, f := range held { + send(f) + } +} + +// Close ends the session. Same ordering rule as claudeEngine.Close: main calls +// it after the Bubble Tea program returns, never from the Update loop. +func (e *codexEngine) Close() { + if e.stdin != nil { + _ = e.stdin.Close() + } + if e.cmd == nil || e.cmd.Process == nil { + return + } + done := make(chan struct{}) + go func() { _ = e.cmd.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = e.cmd.Process.Kill() + <-done // reap, so no zombie is left behind + } +} diff --git a/codexengine_test.go b/codexengine_test.go new file mode 100644 index 0000000..816ccbc --- /dev/null +++ b/codexengine_test.go @@ -0,0 +1,216 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// fakeAppServer writes a stand-in for `codex app-server` and points +// codexCommand at it. The script speaks the real frame shapes, taken from a +// recorded session: a reply carries an id and no method, a notification carries +// a method and no id. +// +// This exercises the parts a unit test on the codec cannot reach — the reader +// goroutine, id correlation across concurrent calls, and the handshake's +// result.thread.id nesting — without a billed turn or a network call. +func fakeAppServer(t *testing.T, body string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the stand-in is a shell script") + } + dir := t.TempDir() + path := filepath.Join(dir, "fake-codex") + script := "#!/bin/sh\n" + body + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + orig := codexCommand + t.Cleanup(func() { codexCommand = orig }) + codexCommand = path +} + +// readLoop reads one request per line and answers from a case statement. Using +// the method name rather than the id keeps the script independent of how many +// calls the engine makes. +const echoServer = ` +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"initialize"'*) printf '{"id":%s,"result":{"userAgent":"fake"}}\n' "$id" ;; + *'"thread/start"'*) printf '{"method":"thread/started","params":{"thread":{"id":"th-1"}}}\n' + printf '{"id":%s,"result":{"thread":{"id":"th-1"},"model":"fake-model"}}\n' "$id" ;; + *'"turn/start"'*) printf '{"id":%s,"result":{"turn":{"id":"tu-1"}}}\n' "$id" ;; + esac +done +` + +func TestCodexInitializeOpensAThread(t *testing.T) { + fakeAppServer(t, echoServer) + + e, err := newCodexEngine(codexEngineConfig{Mode: "ask", Cwd: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + if err := e.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + e.mu.Lock() + got := e.threadID + e.mu.Unlock() + if got != "th-1" { + t.Errorf("threadID = %q, want th-1 read from result.thread.id", got) + } +} + +// A turn cannot open before a thread exists, and saying so beats a wire error. +func TestCodexSendRefusesBeforeInitialize(t *testing.T) { + fakeAppServer(t, echoServer) + + e, err := newCodexEngine(codexEngineConfig{Mode: "ask"}) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + if err := e.Send("hello"); err == nil { + t.Error("Send with no thread should report why, not reach the wire") + } +} + +// The turn id comes back on the turn/start reply, and Interrupt needs it +// alongside the thread id. +func TestCodexSendRecordsTheTurnID(t *testing.T) { + fakeAppServer(t, echoServer) + + e, err := newCodexEngine(codexEngineConfig{Mode: "ask"}) + if err != nil { + t.Fatal(err) + } + defer e.Close() + if err := e.Initialize(); err != nil { + t.Fatal(err) + } + if err := e.Send("hello"); err != nil { + t.Fatal(err) + } + + // Send is deliberately non-blocking, so the id lands on the reply goroutine. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + e.mu.Lock() + got := e.turnID + e.mu.Unlock() + if got == "tu-1" { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Error("turn id was never recorded from the turn/start reply") +} + +// A backend that dies must not leave the UI waiting out a 60s timeout per call. +func TestCodexCallFailsWhenTheSubprocessExits(t *testing.T) { + fakeAppServer(t, "exit 0\n") + + e, err := newCodexEngine(codexEngineConfig{Mode: "ask"}) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + done := make(chan error, 1) + go func() { _, err := e.call("initialize", map[string]any{}); done <- err }() + select { + case err := <-done: + if err == nil { + t.Error("a call to a dead subprocess should fail, not succeed") + } + case <-time.After(5 * time.Second): + t.Fatal("call hung after the subprocess exited; failAll did not fire") + } +} + +// Mode and model are per-turn on codex, so the setters record rather than send. +// They must not fail, and the next turn must carry the new value. +func TestCodexSettersRecordForTheNextTurn(t *testing.T) { + fakeAppServer(t, echoServer) + + e, err := newCodexEngine(codexEngineConfig{Mode: "ask"}) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + if err := e.SetPermissionMode("plan"); err != nil { + t.Errorf("SetPermissionMode: %v", err) + } + if err := e.SetModel("gpt-x"); err != nil { + t.Errorf("SetModel: %v", err) + } + e.mu.Lock() + mode, model := e.mode, e.model + e.mu.Unlock() + if mode != "plan" { + t.Errorf("mode = %q, want the cathode mode stored verbatim", mode) + } + if model != "gpt-x" { + t.Errorf("model = %q", model) + } +} + +// The adapter turns frames into entries. thread/started names the session, and +// a token update sets the gauge from the window codex states outright. +func TestCodexAdapterMapsFramesToEntries(t *testing.T) { + m, _ := newTestModel(t, "") + + m.handleCodexEvent(codexFrame{ + Method: "thread/started", + Params: json.RawMessage(`{"thread":{"id":"th-abc","model":"gpt-test"}}`), + }) + if m.session != "th-abc" { + t.Errorf("session = %q, want the thread id", m.session) + } + // The model rides on the thread object; claude announces it separately. + if m.modelID != "gpt-test" { + t.Errorf("modelID = %q, want the model named on the thread", m.modelID) + } + + m.handleCodexEvent(codexFrame{ + Method: "thread/tokenUsage/updated", + Params: json.RawMessage(`{"tokenUsage":{"total":{"inputTokens":1000,"outputTokens":50},"modelContextWindow":258400}}`), + }) + if m.ctxLimit != 258400 { + t.Errorf("ctxLimit = %d, want the reported window rather than a guess", m.ctxLimit) + } + if m.ctxTokens != 1000 { + t.Errorf("ctxTokens = %d", m.ctxTokens) + } + + m.handleCodexEvent(codexFrame{ + Method: "item/completed", + Params: json.RawMessage(`{"item":{"type":"agentMessage","id":"m1","text":"hello there"}}`), + }) + last := m.entries[len(m.entries)-1] + if last.kind != entClaude || last.text != "hello there" { + t.Errorf("agent message = %+v, want entClaude", last) + } + + // The user's own turn is already in the transcript; echoing it would double it. + before := len(m.entries) + m.handleCodexEvent(codexFrame{ + Method: "item/completed", + Params: json.RawMessage(`{"item":{"type":"userMessage","id":"u1"}}`), + }) + if len(m.entries) != before { + t.Error("the echoed user message must not be added again") + } +} diff --git a/codexenv.go b/codexenv.go new file mode 100644 index 0000000..8a8d6a9 --- /dev/null +++ b/codexenv.go @@ -0,0 +1,40 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "strings" +) + +// codexDropFromEnv is what the spawned codex must not inherit. +// +// Unlike claude, an OPENAI_API_KEY in the environment does NOT divert billing: +// codex reads its credential from ~/.codex/auth.json, and a probe confirmed it +// still resolves auth mode "chatgpt" with the variable set, and even with +// preferred_auth_method forced to apikey. Only `codex login --with-api-key` +// puts a key where it counts. It is dropped anyway, because the cost is nothing +// and the claim above is a fact about one version. +// +// OPENAI_BASE_URL is the one that matters here. It is not a billing lever, it +// is a routing one: it decides which host the conversation is sent to. +var codexDropFromEnv = map[string]bool{ + "OPENAI_API_KEY": true, + "OPENAI_BASE_URL": true, +} + +// codexEnv returns the environment to spawn codex with. Whole-name matching, +// for the reason scrubbedEnv documents: a substring test also drops any +// variable whose name merely ends with one of these. +func codexEnv() []string { + env := os.Environ() + out := make([]string, 0, len(env)) + for _, kv := range env { + if name, _, ok := strings.Cut(kv, "="); ok && codexDropFromEnv[name] { + continue + } + out = append(out, kv) + } + return out +} diff --git a/codexitems.go b/codexitems.go new file mode 100644 index 0000000..aa18533 --- /dev/null +++ b/codexitems.go @@ -0,0 +1,90 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "strings" +) + +// codexItem renders one conversation item. started says whether this is the +// opening event, which is the one that carries a tool item's content. +func (m *model) codexItem(f codexFrame, started bool) { + var p struct { + Item json.RawMessage `json:"item"` + } + if json.Unmarshal(f.Params, &p) != nil || len(p.Item) == 0 { + return + } + var head struct { + Type string `json:"type"` + ID string `json:"id"` + Text string `json:"text"` + } + if json.Unmarshal(p.Item, &head) != nil { + return + } + + switch head.Type { + case "userMessage": + // Already in the transcript: sendTurn adds the typed text before the + // turn opens, the same reason stream.go hides claude's echo. + return + case "agentMessage": + if started { + return // text arrives on completion + } + if t := strings.TrimSpace(head.Text); t != "" { + m.add(entClaude, t) + } + case "reasoning": + if started { + return + } + if t := codexReasoningText(p.Item); t != "" { + m.add(entThinking, t) + } + default: + // A tool item. Its content is on the opening event, and the id pairs it + // with the approval request that may follow, so noteToolCard keeps the + // two from both drawing (toolcard.go). + if !started { + return + } + if head.ID != "" && !m.noteToolCard(head.ID) { + return + } + m.addTool(head.Type, p.Item) + } +} + +// codexReasoningText flattens a reasoning item. The summary is what the +// interactive UI shows, so prefer it and fall back to the full content. +func codexReasoningText(raw json.RawMessage) string { + var r struct { + Summary []struct { + Text string `json:"text"` + } `json:"summary"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &r) != nil { + return "" + } + var b []string + for _, s := range r.Summary { + if t := strings.TrimSpace(s.Text); t != "" { + b = append(b, t) + } + } + if len(b) == 0 { + for _, c := range r.Content { + if t := strings.TrimSpace(c.Text); t != "" { + b = append(b, t) + } + } + } + return strings.Join(b, "\n\n") +} diff --git a/codexlive_test.go b/codexlive_test.go new file mode 100644 index 0000000..137cd73 --- /dev/null +++ b/codexlive_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "testing" + "time" +) + +// A live check against the real `codex` CLI, off by default because it spends a +// turn on the user's subscription and needs `codex login` to have been run: +// +// CATHODE_CODEX_LIVE=1 go test -run TestCodexLive ./... +// +// Same opt-in shape as the asset generators. It exists because the scripted +// stand-in in codexengine_test.go proves the framing cathode expects, not the +// framing codex actually sends, and those are different claims. This one asks +// the second question. +func TestCodexLiveRoundTrip(t *testing.T) { + if os.Getenv("CATHODE_CODEX_LIVE") == "" { + t.Skip("set CATHODE_CODEX_LIVE=1 to run against the real codex CLI") + } + + e, err := newCodexEngine(codexEngineConfig{Mode: "plan", Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer e.Close() + + frames := make(chan codexFrame, 256) + e.mu.Lock() + e.sink = func(f codexFrame) { frames <- f } + e.mu.Unlock() + + if err := e.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + e.mu.Lock() + thread := e.threadID + e.mu.Unlock() + if thread == "" { + t.Fatal("no thread id after Initialize") + } + t.Logf("thread %s", thread) + + if err := e.Send("Reply with exactly: OK. Do not use any tools."); err != nil { + t.Fatalf("Send: %v", err) + } + + // Drain until the turn ends, collecting what the adapter would render. + m, _ := newTestModel(t, "") + deadline := time.After(3 * time.Minute) + for { + select { + case f := <-frames: + m.handleCodexEvent(f) + if f.Method == codexErrorMethod || f.Method == "error" { + t.Fatalf("codex reported an error: %s", f.Params) + } + if f.Method == "turn/completed" || f.Method == "turn/failed" { + for _, e := range m.entries { + t.Logf("entry kind=%d text=%q", e.kind, e.text) + } + if m.busy { + t.Error("busy should be cleared when the turn ends") + } + return + } + case <-deadline: + t.Fatal("no turn/completed within the deadline") + } + } +} + +// What each mode actually does with a gated action, against the real CLI. +// +// The property that matters is that the turn always ENDS. codex blocks until a +// server request is answered, so the failure guarded against is not a wrong +// answer, it is no answer — which presents as a frozen UI with nothing in the +// log. +// +// The two rows also pin the current limit of this backend. In build mode codex +// asks for nothing and the action runs, so the backend is usable today. In ask +// mode every gated action is refused, because the approval pane is not wired to +// codex yet and granting silently would defeat the mode. +func TestCodexLiveGatedActionsAlwaysEndTheTurn(t *testing.T) { + if os.Getenv("CATHODE_CODEX_LIVE") == "" { + t.Skip("set CATHODE_CODEX_LIVE=1 to run against the real codex CLI") + } + for _, c := range []struct { + mode string + wantRefusal bool + }{ + {"build", false}, + {"ask", true}, + } { + t.Run(c.mode, func(t *testing.T) { + e, err := newCodexEngine(codexEngineConfig{Mode: c.mode, Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer e.Close() + + frames := make(chan codexFrame, 256) + e.mu.Lock() + e.sink = func(f codexFrame) { frames <- f } + e.mu.Unlock() + + if err := e.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := e.Send("Create a file called probe.txt containing the word hello."); err != nil { + t.Fatalf("Send: %v", err) + } + + var refused bool + deadline := time.After(3 * time.Minute) + for { + select { + case f := <-frames: + if f.Method == codexErrorMethod { + t.Logf("notice: %s", f.Params) + refused = true + } + if f.Method == "turn/completed" || f.Method == "turn/failed" { + if refused != c.wantRefusal { + t.Errorf("%s mode: refused=%v, want %v", c.mode, refused, c.wantRefusal) + } + return + } + case <-deadline: + t.Fatal("the turn never ended — a server request went unanswered") + } + } + }) + } +} diff --git a/codexmode.go b/codexmode.go new file mode 100644 index 0000000..d48f0b5 --- /dev/null +++ b/codexmode.go @@ -0,0 +1,69 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +// codexPolicyForMode maps a cathode mode onto the two knobs codex actually has: +// an approval policy and a sandbox. +// +// claude expresses this as one value (--permission-mode); codex splits it in +// two, and the split is the interesting part. "What may run without asking" and +// "what may be touched at all" are independent there, so a mode has to state +// both or it inherits a default nobody chose. +// +// The pairs, and why: +// +// plan read-only + untrusted nothing is written, and anything that would +// run is asked about first. +// ask workspace-write + untrusted +// edits are allowed but every gated action +// surfaces in the approval pane. This is the +// mode the pane exists for. +// build workspace-write + on-request +// the agent proceeds and asks only when it +// judges it needs to — the closest thing codex +// has to acceptEdits. +// bypass danger-full-access + never +// nothing is gated. Named to match what it +// does, and deliberately off the Shift+Tab +// wheel (see nextMode). +// +// Anything unrecognised gets the ask pair. An unknown mode must not be quieter +// than the modes we know: defaulting to the gated one fails safe. +// +// The sandbox returned here is thread/start's `sandbox`, a SandboxMode string. +// turn/start takes the same concept as `sandboxPolicy`, a tagged object with a +// DIFFERENT spelling — codexSandboxPolicy converts. Do not pass one where the +// other belongs: the app-server rejects the frame, turn/start never replies +// with a turn, and the session simply never starts one. +func codexPolicyForMode(mode string) (policy, sandbox string) { + switch mode { + case "plan": + return "untrusted", "read-only" + case "build": + return "on-request", "workspace-write" + case "bypass": + return "never", "danger-full-access" + default: // "ask", and anything unknown + return "untrusted", "workspace-write" + } +} + +// codexSandboxPolicy converts a SandboxMode string into the tagged object +// turn/start wants. +// +// The two vocabularies are genuinely different, not merely styled differently: +// thread/start says "read-only", turn/start says {"type":"readOnly"}. Writing +// the conversion down once, here, is what stops the next caller guessing — the +// first version of Send guessed {"mode": "read-only"} and every turn silently +// failed to start. +func codexSandboxPolicy(sandbox string) map[string]any { + switch sandbox { + case "read-only": + return map[string]any{"type": "readOnly"} + case "danger-full-access": + return map[string]any{"type": "dangerFullAccess"} + default: // "workspace-write" + return map[string]any{"type": "workspaceWrite"} + } +} diff --git a/codexreader.go b/codexreader.go new file mode 100644 index 0000000..24d3917 --- /dev/null +++ b/codexreader.go @@ -0,0 +1,85 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "encoding/json" + "strings" +) + +// The stdout side of the codex connection: one goroutine consuming frames and +// deciding where each one goes. Kept apart from the engine's lifecycle so the +// routing rule stays readable on its own. + +// read is the single consumer of stdout. It sorts each frame and never blocks +// on the UI: a reply goes to its waiter, everything else goes to the program. +func (e *codexEngine) read() { + sc := bufio.NewScanner(e.stdout) + sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // tool output can be large + for sc.Scan() { + line := sc.Bytes() + if len(strings.TrimSpace(string(line))) == 0 { + continue + } + debug.Logf("stdout", "%s", line) + var f codexFrame + if err := json.Unmarshal(line, &f); err != nil { + continue // not a frame; skip rather than kill the session + } + switch f.classify() { + case frameResponse: + e.pending.deliver(f) + continue + case frameServerReq: + // Answered here, not in the UI: an unanswered request stops the + // turn dead with no error shown anywhere (codexapproval.go). + e.answerServerRequest(f) + continue + } + e.noteTurn(f) + e.emit(f) + } + // The subprocess is gone. Wake every in-flight call now: left alone each + // one waits out its own timeout and the UI sits frozen meanwhile. + e.pending.failAll(errEngineClosed) + e.emit(codexFrame{Method: codexClosedMethod}) +} + +// noteTurn keeps the live turn id current. turn/interrupt needs it alongside +// the thread id, and the notification stream is the only place it appears. +func (e *codexEngine) noteTurn(f codexFrame) { + switch f.Method { + case "turn/started": + var p struct { + Turn struct { + ID string `json:"id"` + } `json:"turn"` + } + if json.Unmarshal(f.Params, &p) == nil { + e.mu.Lock() + e.turnID = p.Turn.ID + e.mu.Unlock() + } + case "turn/completed", "turn/failed": + e.mu.Lock() + e.turnID = "" + e.mu.Unlock() + } +} + +// emit forwards one frame to the sink, or holds it until one is registered. +// The send happens outside the lock: a slow consumer must not block the reader, +// which would stall every later frame behind it. +func (e *codexEngine) emit(f codexFrame) { + e.mu.Lock() + sink := e.sink + if sink == nil { + e.backlog = append(e.backlog, f) + e.mu.Unlock() + return + } + e.mu.Unlock() + sink(f) +} diff --git a/codexrpc.go b/codexrpc.go new file mode 100644 index 0000000..a5cec14 --- /dev/null +++ b/codexrpc.go @@ -0,0 +1,142 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "sync" +) + +// ---- JSON-RPC framing for `codex app-server` ---- +// +// One JSON object per line, in both directions, but the traffic is three +// different things and telling them apart is the whole job of this file: +// +// {"id":1,"result":{…}} a reply to something we asked +// {"method":"item/started","params":{…}} a notification, no reply wanted +// {"method":"…/requestApproval","id":0,…} a request TO us, reply required +// +// Two shapes on the wire decide the code below. The server does not echo +// `jsonrpc` on anything it sends, so presence of that field cannot classify a +// frame. And server request ids start at **0**, so an `int` field cannot tell +// "no id" from "id zero" — every id here is a pointer for that reason. Getting +// it wrong turns the first approval request of every session into a response +// nobody is waiting for, and the turn hangs. + +// codexFrame is one decoded line. Which fields are populated says what it is; +// see classify. +type codexFrame struct { + ID *int64 `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error *codexRPCError `json:"error"` +} + +// codexRPCError is the error member of a failed response. +type codexRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func (e *codexRPCError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("codex rpc error %d: %s", e.Code, e.Message) +} + +// frameKind is what one inbound line turned out to be. +type frameKind int + +const ( + frameResponse frameKind = iota // a reply to one of our calls + frameServerReq // a request to us; we must answer it + frameNotification // fire-and-forget +) + +// classify sorts an inbound frame. The id pointer is load-bearing: a server +// request carries a method AND an id, a notification carries a method and no +// id, and a response carries an id and no method. +func (f codexFrame) classify() frameKind { + switch { + case f.Method == "": + return frameResponse + case f.ID != nil: + return frameServerReq + default: + return frameNotification + } +} + +// codexPending tracks the calls waiting for a reply, keyed by the id we sent. +// +// Separate from any id the server chooses for its own requests: the two id +// spaces are independent, and only direction tells them apart. +type codexPending struct { + mu sync.Mutex + next int64 + wait map[int64]chan codexFrame +} + +func newCodexPending() *codexPending { + // Start at 1. Nothing depends on it, but leaving 0 unused keeps our ids + // visually distinct from the server's in a -debug log, which is the first + // place anyone looks when a reply goes missing. + return &codexPending{next: 1, wait: map[int64]chan codexFrame{}} +} + +// begin reserves an id and the channel its reply will arrive on. +func (p *codexPending) begin() (int64, chan codexFrame) { + p.mu.Lock() + defer p.mu.Unlock() + id := p.next + p.next++ + ch := make(chan codexFrame, 1) + p.wait[id] = ch + return id, ch +} + +// deliver hands a reply to whoever is waiting for it. An id nobody is waiting +// for is dropped: it means a call already gave up, and there is no one to tell. +func (p *codexPending) deliver(f codexFrame) { + if f.ID == nil { + return + } + p.mu.Lock() + ch, ok := p.wait[*f.ID] + delete(p.wait, *f.ID) + p.mu.Unlock() + if ok { + ch <- f + } +} + +// abandon releases a waiter without a reply, so a caller that timed out or a +// subprocess that died does not leak an entry. +func (p *codexPending) abandon(id int64) { + p.mu.Lock() + delete(p.wait, id) + p.mu.Unlock() +} + +// failAll wakes every waiter with the same error. Called when the subprocess +// exits: without it, each in-flight call blocks until its own timeout, and the +// UI freezes for as long as the longest one. +func (p *codexPending) failAll(err error) { + p.mu.Lock() + waiters := p.wait + p.wait = map[int64]chan codexFrame{} + p.mu.Unlock() + msg := err.Error() + for _, ch := range waiters { + ch <- codexFrame{Error: &codexRPCError{Message: msg}} + } +} + +// errEngineClosed is what an in-flight call sees when the subprocess exits. +var errEngineClosed = errors.New("codex app-server exited") diff --git a/codexrpc_test.go b/codexrpc_test.go new file mode 100644 index 0000000..62d4d8f --- /dev/null +++ b/codexrpc_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// decode is what the reader does to one line of stdout. +func decode(t *testing.T, line string) codexFrame { + t.Helper() + var f codexFrame + if err := json.Unmarshal([]byte(line), &f); err != nil { + t.Fatalf("decode %q: %v", line, err) + } + return f +} + +// The three inbound shapes, taken from a recorded app-server session. +// +// The id:0 case is the one that matters. codex numbers its own requests from +// zero, so a plain int field cannot tell "no id" from "id zero" — every id is a +// pointer for exactly this line. Misread, the first approval of every session +// is filed as a reply nobody waits for, no answer is ever sent, and the turn +// hangs with the pane never opening. +func TestClassifyTellsTheThreeInboundShapesApart(t *testing.T) { + cases := []struct { + name string + line string + want frameKind + }{ + {"response", `{"id":1,"result":{"userAgent":"x"}}`, frameResponse}, + {"notification", `{"method":"item/started","params":{}}`, frameNotification}, + {"server request", `{"method":"item/fileChange/requestApproval","id":7,"params":{}}`, frameServerReq}, + {"server request with id zero", `{"method":"item/commandExecution/requestApproval","id":0,"params":{}}`, frameServerReq}, + {"error response", `{"id":2,"error":{"code":-1,"message":"nope"}}`, frameResponse}, + } + for _, c := range cases { + if got := decode(t, c.line).classify(); got != c.want { + t.Errorf("%s: classify = %v, want %v", c.name, got, c.want) + } + } +} + +// A reply reaches the caller that asked, and only that caller. +func TestPendingDeliversToTheRightWaiter(t *testing.T) { + p := newCodexPending() + id1, ch1 := p.begin() + id2, ch2 := p.begin() + if id1 == id2 { + t.Fatalf("ids must be distinct, both were %d", id1) + } + + p.deliver(codexFrame{ID: &id2, Result: json.RawMessage(`{"ok":true}`)}) + select { + case f := <-ch2: + if string(f.Result) != `{"ok":true}` { + t.Errorf("waiter 2 got %s", f.Result) + } + default: + t.Fatal("waiter 2 got nothing") + } + select { + case <-ch1: + t.Error("waiter 1 must not receive another call's reply") + default: + } +} + +// An id nobody waits for is dropped rather than blocking the reader. A call +// that timed out has already gone, and the reader must not stall on it — that +// would freeze every later frame behind one abandoned reply. +func TestPendingDropsRepliesNobodyIsWaitingFor(t *testing.T) { + p := newCodexPending() + id, _ := p.begin() + p.abandon(id) + + done := make(chan struct{}) + go func() { p.deliver(codexFrame{ID: &id}); close(done) }() + <-done // a stall here means the reader would stall too +} + +// When the subprocess dies, every in-flight call is woken with an error. Left +// alone each one waits out its own timeout, and the UI sits frozen meanwhile. +func TestPendingFailAllWakesEveryCaller(t *testing.T) { + p := newCodexPending() + _, ch1 := p.begin() + _, ch2 := p.begin() + + p.failAll(errEngineClosed) + + for i, ch := range []chan codexFrame{ch1, ch2} { + select { + case f := <-ch: + if f.Error == nil { + t.Errorf("waiter %d woke with no error", i+1) + } + default: + t.Errorf("waiter %d was left hanging", i+1) + } + } +} + +// The whole-name rule, the same one TestScrubbedEnvDropsWhatClaudeMustNotInherit +// pins for claude: a substring test also drops any variable whose name merely +// ends with one of these. +func TestCodexEnvDropsWholeNamesOnly(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "sk-not-real") + t.Setenv("OPENAI_BASE_URL", "http://example.invalid") + t.Setenv("API_KEY", "keep-me") + t.Setenv("BASE_URL", "keep-me-too") + t.Setenv("OPENAI_API_KEY_BACKUP", "keep-me-three") + + got := map[string]bool{} + for _, kv := range codexEnv() { + if name, _, ok := strings.Cut(kv, "="); ok { + got[name] = true + } + } + for _, name := range []string{"OPENAI_API_KEY", "OPENAI_BASE_URL"} { + if got[name] { + t.Errorf("%s must not reach codex", name) + } + } + for _, name := range []string{"API_KEY", "BASE_URL", "OPENAI_API_KEY_BACKUP"} { + if !got[name] { + t.Errorf("%s is not one of the dropped names and must survive", name) + } + } + if len(got) == 0 { + t.Fatal("codexEnv returned nothing") + } + if _, ok := os.LookupEnv("PATH"); ok && !got["PATH"] { + t.Error("PATH must survive, or the subprocess cannot find its own tools") + } +} + +// Each cathode mode states both codex knobs. An unknown mode must land on the +// gated pair, never a quieter one. +func TestCodexPolicyForModeStatesBothKnobs(t *testing.T) { + cases := []struct{ mode, policy, sandbox string }{ + {"plan", "untrusted", "read-only"}, + {"ask", "untrusted", "workspace-write"}, + {"build", "on-request", "workspace-write"}, + {"bypass", "never", "danger-full-access"}, + {"", "untrusted", "workspace-write"}, + {"nonsense", "untrusted", "workspace-write"}, + } + for _, c := range cases { + policy, sandbox := codexPolicyForMode(c.mode) + if policy != c.policy || sandbox != c.sandbox { + t.Errorf("%q: got %s/%s, want %s/%s", c.mode, policy, sandbox, c.policy, c.sandbox) + } + } +} diff --git a/codexsession.go b/codexsession.go new file mode 100644 index 0000000..c82c001 --- /dev/null +++ b/codexsession.go @@ -0,0 +1,140 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "errors" +) + +// ---- the Engine seam ---- + +// Initialize runs the handshake and opens the thread every later call needs. +// +// Three steps, in order, because codex requires all of them before a turn: +// the initialize request, the initialized notification that completes it, and +// thread/start (or thread/resume). The thread id lives at result.thread.id — +// not result.threadId, which is the shape a reader would assume. +func (e *codexEngine) Initialize() error { + if _, err := e.call("initialize", map[string]any{ + "clientInfo": map[string]string{"name": clientName, "version": clientVersion}, + "capabilities": map[string]any{"experimentalApi": true}, + }); err != nil { + return err + } + if err := e.notify("initialized", map[string]any{}); err != nil { + return err + } + return e.openThread() +} + +// openThread starts a fresh thread, or resumes one when main was given an id. +func (e *codexEngine) openThread() error { + e.mu.Lock() + resume, mode, cwd := e.resumeID, e.mode, e.cwd + e.mu.Unlock() + + policy, sandbox := codexPolicyForMode(mode) + params := map[string]any{ + "approvalPolicy": policy, + "sandbox": sandbox, + } + // State the working root rather than relying on the inherited one. diff.go + // reads a file's "before" off disk on the assumption that the agent runs in + // the TUI's cwd, so the two must not be free to differ. + if cwd != "" { + params["cwd"] = cwd + } + method := "thread/start" + if resume != "" { + method, params["threadId"] = "thread/resume", resume + } + res, err := e.call(method, params) + if err != nil { + return err + } + var out struct { + Thread struct { + ID string `json:"id"` + } `json:"thread"` + } + if err := json.Unmarshal(res, &out); err != nil { + return err + } + if out.Thread.ID == "" { + return errors.New("codex: " + method + " returned no thread id") + } + e.mu.Lock() + e.threadID = out.Thread.ID + e.mu.Unlock() + return nil +} + +// Send opens one turn. Non-blocking: turn/start replies as soon as the turn is +// accepted, long before it finishes, and the reply carries the turn id that +// Interrupt needs. +func (e *codexEngine) Send(text string) error { + e.mu.Lock() + thread, mode, model := e.threadID, e.mode, e.model + e.mu.Unlock() + if thread == "" { + return errors.New("codex: no thread yet — Initialize first") + } + + policy, sandbox := codexPolicyForMode(mode) + params := map[string]any{ + "threadId": thread, + "input": []map[string]any{{"type": "text", "text": text}}, + "approvalPolicy": policy, + "sandboxPolicy": codexSandboxPolicy(sandbox), + } + if model != "" { + params["model"] = model + } + return e.fire("turn/start", params, func(res json.RawMessage) { + var out struct { + Turn struct { + ID string `json:"id"` + } `json:"turn"` + } + if json.Unmarshal(res, &out) == nil && out.Turn.ID != "" { + e.mu.Lock() + e.turnID = out.Turn.ID + e.mu.Unlock() + } + }) +} + +// Interrupt aborts the running turn. codex needs the turn id as well as the +// thread id, so there is nothing to do when no turn is in flight. +func (e *codexEngine) Interrupt() error { + e.mu.Lock() + thread, turn := e.threadID, e.turnID + e.mu.Unlock() + if thread == "" || turn == "" { + return nil // no turn running; the UI clears busy either way + } + return e.fire("turn/interrupt", map[string]any{"threadId": thread, "turnId": turn}, nil) +} + +// SetPermissionMode records the mode for the next turn. +// +// It sends nothing. codex has no mid-session equivalent of claude's +// set_permission_mode control request — approvalPolicy and sandbox are +// parameters of turn/start — so the change lands when the next turn opens. +// Shift+Tab therefore takes effect on the next turn, not the running one. +func (e *codexEngine) SetPermissionMode(mode string) error { + e.mu.Lock() + e.mode = mode + e.mu.Unlock() + return nil +} + +// SetModel records the model for the next turn, for the same reason. +func (e *codexEngine) SetModel(model string) error { + e.mu.Lock() + e.model = model + e.mu.Unlock() + return nil +} diff --git a/codexstream.go b/codexstream.go new file mode 100644 index 0000000..18e217f --- /dev/null +++ b/codexstream.go @@ -0,0 +1,130 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" +) + +// handleCodexEvent routes one app-server frame into the model, the way +// stream.go:handleEvent does for claude. Everything below `entry` is shared, so +// this file only decides which entry kind a frame becomes. +// +// Where a payload lives differs from claude in one way worth stating: for a +// tool item the content is on `item/started`, not on the completion and not on +// the approval request. So a command and a file change are drawn when they +// start, and their completion only updates status. Drawing on completion +// instead would leave a long command invisible while it runs, and drawing on +// the approval is not possible at all — that request carries ids and nothing +// else (see the approval work). +func (m *model) handleCodexEvent(f codexFrame) { + switch f.Method { + case "thread/started": + m.noteCodexThread(f) + case "turn/started": + m.busy = true + case "turn/completed", "turn/failed": + m.noteCodexTurnEnd(f) + case "item/started": + m.codexItem(f, true) + case "item/completed": + m.codexItem(f, false) + case "thread/tokenUsage/updated": + m.noteCodexTokens(f) + case "error", codexErrorMethod: + var p struct { + Message string `json:"message"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(f.Params, &p) + msg := p.Message + if msg == "" { + msg = p.Error.Message + } + if msg == "" { + msg = "codex reported an error" + } + m.add(entError, "✗ "+msg) + case codexClosedMethod: + m.busy = false + m.add(entInfo, "— session ended —") + } +} + +// noteCodexThread records the thread id. codex calls it a thread; cathode's +// session field holds it, so ctrl+r and the status row keep working unchanged. +func (m *model) noteCodexThread(f codexFrame) { + var p struct { + Thread struct { + ID string `json:"id"` + Model string `json:"model"` + } `json:"thread"` + } + if json.Unmarshal(f.Params, &p) != nil || p.Thread.ID == "" { + return + } + m.session = p.Thread.ID + // The resolved model is on the thread, not announced separately the way + // claude's system/init reports it. Without this the session line renders a + // bare separator where the model name belongs. + if p.Thread.Model != "" { + m.modelID = p.Thread.Model + } + m.add(entInfo, fmt.Sprintf("— thread %s · %s —", short(p.Thread.ID), m.modelID)) +} + +// noteCodexTurnEnd closes out a turn. There is no USD figure to report on a +// subscription, so the line carries what codex does report: how long it took. +func (m *model) noteCodexTurnEnd(f codexFrame) { + m.busy = false + var p struct { + Turn struct { + DurationMS int `json:"durationMs"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } `json:"turn"` + } + _ = json.Unmarshal(f.Params, &p) + if p.Turn.Error != nil && p.Turn.Error.Message != "" { + m.add(entError, "✗ "+p.Turn.Error.Message) + } + if p.Turn.DurationMS > 0 { + m.add(entInfo, fmt.Sprintf("— done · %dms —", p.Turn.DurationMS)) + return + } + m.add(entInfo, "— done —") +} + +// noteCodexTokens drives the context gauge. +// +// codex states modelContextWindow outright, which claude never does. cathode's +// -ctx flag and the auto-grow in observeCtx exist only because that number has +// to be guessed on claude; here the gauge can be exact, so the reported window +// wins over the flag. +func (m *model) noteCodexTokens(f codexFrame) { + var p struct { + TokenUsage struct { + // inputTokens already counts the cached portion — the sibling + // cachedInputTokens is a subset of it, not an addition, so adding + // the two would roughly double the reported context. + Total struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + } `json:"total"` + ModelContextWindow int `json:"modelContextWindow"` + } `json:"tokenUsage"` + } + if json.Unmarshal(f.Params, &p) != nil { + return + } + if w := p.TokenUsage.ModelContextWindow; w > 0 { + m.ctxLimit = w + } + m.outTokens = p.TokenUsage.Total.OutputTokens + m.observeCtx(p.TokenUsage.Total.InputTokens) +} diff --git a/commandlist.go b/commandlist.go index 07e66af..088db0c 100644 --- a/commandlist.go +++ b/commandlist.go @@ -35,7 +35,7 @@ func slashCommands() []slashCmd { m.add(entInfo, "bypass mode: restart with -mode to switch") } else { m.mode = nextMode(m.mode) - if err := m.engine.SetPermissionMode(modeToPermission(m.mode)); err != nil { + if err := m.engine.SetPermissionMode(m.mode); err != nil { m.add(entError, "mode toggle failed: "+err.Error()) } else { m.add(entInfo, "→ mode: "+modeLabel(m.mode)) @@ -46,7 +46,7 @@ func slashCommands() []slashCmd { switch arg { case "plan", "ask", "build": m.mode = arg - if err := m.engine.SetPermissionMode(modeToPermission(arg)); err != nil { + if err := m.engine.SetPermissionMode(arg); err != nil { m.add(entError, "mode set failed: "+err.Error()) } else { m.add(entInfo, "→ mode: "+modeLabel(arg)) diff --git a/control.go b/control.go index f5bc714..58f6e0c 100644 --- a/control.go +++ b/control.go @@ -61,11 +61,14 @@ func (e *claudeEngine) Interrupt() error { return e.sendControl("int", map[string]string{"subtype": "interrupt"}) } -// SetPermissionMode switches permission mode mid-session. mode is one of -// "default" | "plan" | "acceptEdits" | "bypassPermissions" — the same values -// --permission-mode takes. +// SetPermissionMode switches permission mode mid-session. mode is a cathode +// mode (see the Engine interface); modeToPermission turns it into the value +// --permission-mode takes, so claude's vocabulary stops at this line. func (e *claudeEngine) SetPermissionMode(mode string) error { - return e.sendControl("ctrl", map[string]string{"subtype": "set_permission_mode", "mode": mode}) + return e.sendControl("ctrl", map[string]string{ + "subtype": "set_permission_mode", + "mode": modeToPermission(mode), + }) } // SetModel switches the model for subsequent turns. model is a CLI alias diff --git a/keys.go b/keys.go index c859b79..f10136f 100644 --- a/keys.go +++ b/keys.go @@ -118,7 +118,7 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { m.add(entInfo, "bypass mode: restart with -mode to switch") } else { m.mode = nextMode(m.mode) - if err := m.engine.SetPermissionMode(modeToPermission(m.mode)); err != nil { + if err := m.engine.SetPermissionMode(m.mode); err != nil { m.add(entError, "mode toggle failed: "+err.Error()) } else { m.add(entInfo, "→ mode: "+modeLabel(m.mode)) diff --git a/main.go b/main.go index 24617a6..6fcd919 100644 --- a/main.go +++ b/main.go @@ -66,6 +66,7 @@ func nextMode(cur string) string { } func main() { + backend := flag.String("backend", "claude", "agent CLI to drive: claude | codex") mode := flag.String("mode", "build", "ask | plan | build | bypass") mcp := flag.String("mcp", "", "path to a .mcp.json that wires your internal tools") modelID := flag.String("model", "", "pin a model (e.g. sonnet); empty uses account default") @@ -86,9 +87,11 @@ func main() { } // Start the in-process approval server unless we're in bypass mode (where - // nothing is gated, so there's nothing to approve). + // nothing is gated, so there's nothing to approve), or on codex, which + // raises approvals as JSON-RPC requests on its own connection and has no use + // for a localhost MCP server. var approvals *Approvals - if *mode != "bypass" { + if *mode != "bypass" && *backend != backendCodex { if a, err := StartApprovals(); err == nil { approvals = a } else { @@ -122,10 +125,9 @@ func main() { cfg.PermissionPromptTool = approvals.permissionToolName() } - engine, err := newClaudeEngine(cfg) + engine, err := startEngine(*backend, cfg, *mode, *resume, *modelID) if err != nil { - fmt.Fprintln(os.Stderr, "failed to start claude:", err) - fmt.Fprintln(os.Stderr, "is the `claude` CLI installed and on PATH, and have you run `claude login`?") + fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/models.go b/models.go index c63b558..5f96303 100644 --- a/models.go +++ b/models.go @@ -35,12 +35,22 @@ func fallbackModelItems() []pickerItem { } } +// engineInitErrMsg reports a handshake that failed. It used to be discarded, +// which was harmless while claude was the only backend: there the handshake is +// a fire-and-forget control request and a session runs fine without it. It is +// not harmless generally — a backend that opens its conversation during the +// handshake has no conversation if it fails, and every later turn fails with a +// message about internal state instead of the reason. +type engineInitErrMsg struct{ err error } + // requestModels runs the initialize handshake so the model list is cached // before the user opens /model. The reply arrives via the stream as a // control_response (see handleEvent). Wired into model.Init(). func requestModels(e Engine) tea.Cmd { return func() tea.Msg { - _ = e.Initialize() + if err := e.Initialize(); err != nil { + return engineInitErrMsg{err: err} + } return nil } } diff --git a/update.go b/update.go index 803df54..74c78bc 100644 --- a/update.go +++ b/update.go @@ -119,6 +119,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case streamMsg: m.handleEvent(msg.env) + case codexMsg: + m.handleCodexEvent(msg.frame) + + case engineInitErrMsg: + m.add(entError, "handshake failed: "+msg.err.Error()) + case pendingApprovalMsg: // AskUserQuestion is a question, not a permission. Always present it — even // in build/bypass, and never auto-approve — and answer it via the picker