Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
)
51 changes: 51 additions & 0 deletions backendpick.go
Original file line number Diff line number Diff line change
@@ -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)
}
67 changes: 67 additions & 0 deletions codexapproval.go
Original file line number Diff line number Diff line change
@@ -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)
}
104 changes: 104 additions & 0 deletions codexcalls.go
Original file line number Diff line number Diff line change
@@ -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})
}
Loading
Loading