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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,14 @@ 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.
- All four modes work. `build` and `bypass` run tools without asking; `ask` and
`plan` raise the same approval pane claude uses, and your answer becomes the
decision codex is waiting on.
- File changes render as real diff cards, in both the unified and side-by-side
styles. Other tool calls render as plain cards rather than the typed ones
claude gets.
- Session replay, `/compact`, `/sysprompt` and the slash-command palette are
claude-only so far.
- `/sysprompt` and `/mcp` are claude-only and are hidden on codex rather than
offered and inert. Session replay and `/compact` are claude-only too.
- `@path` inserts a path but does not inject the file. Only claude expands an
`@` mention into file contents; codex reads the file itself with a tool.

Expand Down
56 changes: 56 additions & 0 deletions agentname_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,59 @@ func TestSplashDialsTheRunningBackend(t *testing.T) {
t.Errorf("codex splash still names claude:\n%s", out)
}
}

// A command that depends on one backend's features must not be offered on the
// other. Offered and selected, /sysprompt would restart the session and change
// nothing, and an unhandled slash line is forwarded to the agent as a prompt.
func TestClaudeOnlyCommandsAreHiddenOnCodex(t *testing.T) {
claudeOnly := map[string]bool{}
for _, c := range slashCommands() {
if !c.availableOn(backendCodex) {
claudeOnly[c.name] = true
}
}
for _, name := range []string{"sysprompt", "mcp"} {
if !claudeOnly[name] {
t.Errorf("/%s depends on claude and must not be offered on codex", name)
}
if !mustFind(t, name).availableOn(backendClaude) {
t.Errorf("/%s must still work on claude", name)
}
}

for _, it := range slashItems(backendCodex) {
if claudeOnly[it.id] {
t.Errorf("the codex palette still lists /%s", it.id)
}
}
if len(slashItems(backendClaude)) <= len(slashItems(backendCodex)) {
t.Error("claude should offer strictly more commands than codex right now")
}
}

// Selecting one anyway is handled here, not forwarded. Forwarding would send
// the literal "/sysprompt" to the agent as a prompt.
func TestUnavailableCommandIsHandledNotForwarded(t *testing.T) {
m, _ := newTestModel(t, "")
m.backend = backendCodex

_, _, handled := runSlash(&m, "/sysprompt on")
if !handled {
t.Fatal("an unavailable command must be handled, or it reaches the agent as a prompt")
}
last := m.entries[len(m.entries)-1]
if !strings.Contains(last.text, "not available") || !strings.Contains(last.text, "codex") {
t.Errorf("entry = %q, want it to say the command is unavailable on this backend", last.text)
}
}

func mustFind(t *testing.T, name string) slashCmd {
t.Helper()
for _, c := range slashCommands() {
if c.name == name {
return c
}
}
t.Fatalf("no /%s command", name)
return slashCmd{}
}
117 changes: 93 additions & 24 deletions codexapproval.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@

package main

import (
"encoding/json"
"strings"
)

// ---- 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.
// turn stops there and the session looks frozen with no error anywhere. So the
// rule here is that every server request gets a reply, always — either the
// user's decision, or a refusal, but never silence.
//
// 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.
// A gated action goes to the same pane claude's approvals use. That reuse is
// the point: the y/n bar, the diff card, the AskUserQuestion picker and the
// build-mode short-circuit are all shared, and the only codex-specific part is
// turning a decision back into a JSON-RPC response.

// 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
Expand All @@ -27,41 +33,104 @@ var codexApprovalMethods = map[string]bool{
"execCommandApproval": true,
}

// codexRefusal is the decision sent for a gated action.
// codexRefusal is the decision sent when the user declines, and when a request
// has to be refused without asking.
//
// "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"
const (
codexRefusal = "cancel"
codexApproval = "accept"
)

// codexApprovalParams is the part of a gating request this needs. The command
// approval carries the command; the file-change approval carries ids and
// nothing else, which is why the card is drawn from the earlier item/started
// and paired by itemId (toolcard.go).
type codexApprovalParams struct {
ItemID string `json:"itemId"`
Command string `json:"command"`
}

// 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")
if !codexApprovalMethods[f.Method] {
// 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.replyError(*f.ID, -32601, "cathode does not implement "+f.Method)
e.emitError("unhandled request " + f.Method)
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.askUser(*f.ID, f)
}

// askUser puts a gated action in front of the user and answers with what they
// choose.
//
// All of it runs off the reader goroutine, for two separate reasons. The reader
// must keep draining while the pane is up, or the item events that draw the
// very card being approved never arrive. And approvals are admitted one at a
// time: the UI holds a single pending approval, so a second would overwrite the
// first and leave codex waiting on a reply that can no longer be given.
func (e *codexEngine) askUser(id int64, f codexFrame) {
var p codexApprovalParams
_ = json.Unmarshal(f.Params, &p)

go func() {
e.approvalSlot <- struct{}{}
defer func() { <-e.approvalSlot }()

reply := make(chan approvalReply, 1)
e.emitMsg(pendingApprovalMsg{req: approvalReq{
toolName: codexApprovalLabel(f.Method, p),
toolUseID: p.ItemID,
input: f.Params,
reply: reply,
}})

decision := codexRefusal
if (<-reply).allow {
decision = codexApproval
}
e.replyResult(id, map[string]any{"decision": decision})
}()
}

// codexApprovalLabel names the action on the approval bar. The command itself
// is far more use than the method name, so prefer it where the request carries
// one; the rest fall back to the item kind.
func codexApprovalLabel(method string, p codexApprovalParams) string {
if c := strings.TrimSpace(p.Command); c != "" {
return c
}
switch method {
case "item/fileChange/requestApproval":
return "file change"
case "item/permissions/requestApproval":
return "permission"
}
return method
}

// replyResult and replyError are the two shapes of answer, kept apart so a
// caller cannot half-fill one.
func (e *codexEngine) replyResult(id int64, result any) {
_ = e.write(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
}

func (e *codexEngine) replyError(id int64, code int, msg string) {
_ = e.write(map[string]any{
"jsonrpc": "2.0",
"id": *f.ID,
"error": map[string]any{
"code": -32601,
"message": "cathode does not implement " + f.Method,
},
"id": id,
"error": map[string]any{"code": code, "message": msg},
})
e.emitError("unhandled request " + f.Method)
}
40 changes: 25 additions & 15 deletions codexengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,29 @@ type codexEngine struct {
wmu sync.Mutex // serialises writes to stdin
pending *codexPending

// approvalSlot admits one approval to the pane at a time.
//
// The UI holds exactly one pending approval (update.go assigns m.pending),
// so a second arriving before the first is answered would overwrite it and
// the first request would never be replied to — and codex waits on a reply
// forever. claude cannot hit this: its approvals are pulled one at a time by
// waitApproval. codex pushes, so the serialising has to happen here.
approvalSlot chan struct{}

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
// sink is where anything bound for the UI goes. Pipe sets it; until then
// messages are held in backlog. It takes a tea.Msg rather than a codexFrame
// because an approval request carries a reply channel, which cannot be
// expressed as JSON — and reusing pendingApprovalMsg is what lets codex
// share the whole approval pane rather than growing a second one.
sink func(tea.Msg)
backlog []tea.Msg // messages that arrived before Pipe registered a sink
}

// codexEngineConfig is what main resolved for a codex session.
Expand Down Expand Up @@ -90,11 +100,12 @@ func newCodexEngine(cfg codexEngineConfig) (*codexEngine, error) {
}
e := &codexEngine{
cmd: cmd, stdin: stdin, stdout: stdout,
pending: newCodexPending(),
mode: cfg.Mode,
model: cfg.Model,
cwd: cfg.Cwd,
resumeID: cfg.ResumeID,
pending: newCodexPending(),
approvalSlot: make(chan struct{}, 1),
mode: cfg.Mode,
model: cfg.Model,
cwd: cfg.Cwd,
resumeID: cfg.ResumeID,
}
go e.read()
return e, nil
Expand All @@ -103,14 +114,13 @@ func newCodexEngine(cfg codexEngineConfig) (*codexEngine, error) {
// 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
e.sink = p.Send
held := e.backlog
e.backlog = nil
e.mu.Unlock()
for _, f := range held {
send(f)
for _, msg := range held {
p.Send(msg)
}
}

Expand Down
Loading
Loading