diff --git a/README.md b/README.md index a812399..27d96ab 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ would divert billing to one. Codex needs `codex login` completed, the same way claude needs `claude login`. +`/backend` switches between them **inside a running session**, with no restart. +The transcript stays on screen; the conversation does not carry over, because a +session id from one backend means nothing to the other. The switch says so +rather than leaving you to notice. + The codex backend is newer and narrower than the claude one: - All four modes work. `build` and `bypass` run tools without asking; `ask` and diff --git a/backend_test.go b/backend_test.go index a7650a3..34f7ba1 100644 --- a/backend_test.go +++ b/backend_test.go @@ -15,20 +15,23 @@ import ( // there was no way to assert what actually reached the wire, only what the // helpers returned in isolation. type fakeEngine struct { - sent []string + sent []string + shutdown bool } func (f *fakeEngine) Send(text string) error { f.sent = append(f.sent, text); return nil } -// The rest of the seam, unrecorded: no test reads them yet, and a field nobody -// asserts is a field that can drift from what it claims to capture. Record one -// when a test needs it. +// Close is recorded because a declined backend switch must leave the old engine +// running, and that is only checkable by asking whether it was shut down +// (backendswitch_test.go). The rest stay unrecorded: a field nobody asserts is +// a field that can drift from what it claims to capture. Record one when a test +// needs it. +func (f *fakeEngine) Close() { f.shutdown = true } func (f *fakeEngine) Initialize() error { return nil } func (f *fakeEngine) Interrupt() error { return nil } func (f *fakeEngine) SetPermissionMode(mode string) error { return nil } func (f *fakeEngine) SetModel(m string) error { return nil } func (f *fakeEngine) Pipe(*tea.Program) {} -func (f *fakeEngine) Close() {} var _ Engine = (*fakeEngine)(nil) diff --git a/backendswitch.go b/backendswitch.go new file mode 100644 index 0000000..c92a7ca --- /dev/null +++ b/backendswitch.go @@ -0,0 +1,183 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// ---- switching backend without restarting (`/backend`) ---- +// +// Unlike /sysprompt, this does not re-exec. A backend switch replaces the +// subprocess anyway, and `restartResuming` exists for launch flags that cannot +// change any other way — using it here would throw away the scrollback to +// achieve something the running process can do itself. +// +// What does NOT survive is the conversation. A claude session id means nothing +// to codex, and there is no shared transcript format to hand over, so the new +// backend starts a fresh session. That is stated on screen rather than glossed: +// a switch that silently forgot the context would be worse than one that says +// so. + +// backendItems is the /backend picker. +func backendItems(current string) []pickerItem { + rows := []struct{ id, desc string }{ + {backendClaude, "Claude Code over stream-json"}, + {backendCodex, "codex over app-server JSON-RPC"}, + } + items := make([]pickerItem, 0, len(rows)) + for _, r := range rows { + desc := r.desc + if r.id == sessionBackend(current) { + desc = "current · " + desc + } + items = append(items, pickerItem{id: r.id, title: r.id, subtitle: desc}) + } + return items +} + +// commitBackend swaps the running subprocess for the other backend's. +// +// Every path that declines says why. A silent return reads as "switched" and is +// not — the same rule commitSysPrompt follows. +func (m *model) commitBackend(name string) tea.Cmd { + name = sessionBackend(name) + switch { + case name != backendClaude && name != backendCodex: + m.add(entError, "unknown backend "+name) + return nil + case name == sessionBackend(m.backend): + m.add(entInfo, "→ already on "+agentName(name)) + return nil + case m.busy: + // Mid-turn the old engine still owns a running turn and an approval may + // be waiting on a reply channel that is about to be dropped. + m.add(entInfo, "→ backend: busy — interrupt the turn first (esc)") + return nil + case m.prog.get() == nil: + // Nothing to pipe a new engine into. Only reachable before the program + // starts, but a nil dereference here would take the session with it. + m.add(entError, "cannot switch backend before the UI is running") + return nil + } + + eng, approvals, err := m.spawnBackend(name) + if err != nil { + // The old engine is untouched and still running, so the session goes on. + m.add(entError, "could not start "+agentName(name)+": "+err.Error()) + return nil + } + + old := m.engine + m.engine = eng + m.backend = name + m.approvals = approvals + m.resetForBackend() + + // Close off the update loop: Close blocks on Wait, and the whole reason + // main tears down after p.Run is that doing it inline deadlocks. + go old.Close() + go eng.Pipe(m.prog.get()) + + m.input.Placeholder = promptPlaceholder(name) + m.add(entInfo, fmt.Sprintf("— switched to %s · new session, the conversation does not carry over —", agentName(name))) + m.rerender() // the reply label and header name the backend (agentname.go) + return tea.Batch(requestModels(eng), waitApproval(approvals)) +} + +// spawnBackend starts the engine for name, and the approvals server it needs. +// +// claude gates tools through the in-process MCP permission server, so a switch +// to claude has to start one when the session launched on codex without it. +// codex needs none: it raises approvals as requests on its own connection. +func (m *model) spawnBackend(name string) (Engine, *Approvals, error) { + // A server started for claude is kept when switching to codex, and reused + // on the way back. codex never routes through it, and rebinding a port on + // every switch would be work for nothing. + approvals := m.approvals + cfg := m.engineCfg + cfg.PermissionMode = modeToPermission(m.mode) + + if name == backendClaude && approvals == nil && m.mode != "bypass" { + a, err := StartApprovals() + if err != nil { + // The pane is worth less than the session: report it and run + // ungated, the same trade main makes at launch. + m.add(entError, "approvals disabled: "+err.Error()) + } else { + approvals = a + } + } + if approvals != nil { + cfg.ApprovalsMCPConfig = approvals.mcpConfigJSON() + cfg.PermissionPromptTool = approvals.permissionToolName() + } + + // No resume, and no model. The other backend has never seen this session's + // id, and a model id resolved by one backend names nothing in the other's + // catalogue. The launch --resume has to be stripped from ExtraArgs too, or + // switching a resumed session hands claude an id from codex. + cfg.Model = "" + cfg.ExtraArgs = withoutResume(cfg.ExtraArgs) + eng, err := startEngine(name, cfg, m.mode, "", "") + if err != nil { + return nil, nil, err + } + return eng, approvals, nil +} + +// resetForBackend clears everything that described the old subprocess. +// +// The transcript stays — it is what the user read, and losing it would make a +// switch feel like a restart. Everything else belonged to a session that no +// longer exists, and a stale value here is worse than an empty one: a model +// list from the wrong backend offers rows that cannot be selected, and a +// carried-over session id would be written to the store under the new backend. +func (m *model) resetForBackend() { + m.session = "" + m.modelID = "" + m.agentCwd = "" + m.models = nil + m.commands = nil + m.agents = nil + m.mcpServers = nil + m.toolUses = nil + m.shownTools = nil + m.lastCost = 0 + m.outTokens = 0 + m.ctxTokens = 0 + m.busy = false + m.stopCompacting() + + // The context window belongs to the model that is gone. Back to the -ctx + // floor until the new backend reports or grows past it, rather than keeping + // a number the new session has no relation to. + m.ctxLimit = m.baseCtxLimit +} + +// withoutResume drops a --resume flag from a launch argv, in both the joined +// and the separated form. Returns a fresh slice: the caller's config is reused +// on the next switch and must not be edited underneath it. +func withoutResume(args []string) []string { + out := make([]string, 0, len(args)) + skip := false + for _, a := range args { + if skip { + skip = false + continue + } + if a == "--resume" || a == "-resume" { + skip = true + continue + } + if strings.HasPrefix(a, "--resume=") || strings.HasPrefix(a, "-resume=") { + continue + } + out = append(out, a) + } + return out +} diff --git a/backendswitch_test.go b/backendswitch_test.go new file mode 100644 index 0000000..d454a02 --- /dev/null +++ b/backendswitch_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// switchable builds a model that can actually perform a swap: a program handle +// to pipe into, and a codex stand-in so no real CLI is spawned. +func switchable(t *testing.T, backend string) (model, *fakeEngine) { + t.Helper() + fakeAppServer(t, echoServer) + m, f := newTestModel(t, "") + m.backend = backend + m.prog = &progRef{} + m.prog.set(tea.NewProgram(model{})) + return m, f +} + +// Every declined switch says why. A silent return reads as "switched" and is +// not — the same rule commitSysPrompt follows. +func TestBackendSwitchDeclinesWithAReason(t *testing.T) { + cases := []struct { + name, to, want string + busy bool + noProg bool + }{ + {"same backend", backendClaude, "already on claude", false, false}, + {"unknown", "gemini", "unknown backend", false, false}, + {"mid turn", backendCodex, "busy", true, false}, + {"before the UI runs", backendCodex, "before the UI is running", false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m, f := switchable(t, backendClaude) + m.busy = c.busy + if c.noProg { + m.prog = &progRef{} + } + before := m.engine + + if cmd := m.commitBackend(c.to); cmd != nil { + t.Error("a declined switch must not return a command") + } + if m.engine != before { + t.Error("the engine was swapped despite declining") + } + if m.backend != backendClaude { + t.Errorf("backend = %q, want it unchanged", m.backend) + } + if f.shutdown { + t.Error("the old engine was closed despite declining") + } + last := m.entries[len(m.entries)-1] + if !strings.Contains(last.text, c.want) { + t.Errorf("entry = %q, want it to mention %q", last.text, c.want) + } + }) + } +} + +// A successful switch replaces the engine, keeps the transcript, and clears +// everything that described the session that is gone. +func TestBackendSwitchResetsSessionStateButKeepsTheTranscript(t *testing.T) { + m, _ := switchable(t, backendClaude) + m.add(entUser, "something I asked earlier") + kept := len(m.entries) + + m.session = "claude-session" + m.modelID = "opus" + m.models = []ModelChoice{{Value: "opus"}} + m.mcpServers = []MCPServerInfo{{Name: "x"}} + m.ctxTokens = 1234 + m.ctxLimit = 1_000_000 + m.baseCtxLimit = 200_000 + before := m.engine + + if cmd := m.commitBackend(backendCodex); cmd == nil { + t.Fatal("a real switch should return the new engine's startup commands") + } + + if m.engine == before { + t.Error("the engine was not replaced") + } + if m.backend != backendCodex { + t.Errorf("backend = %q, want codex", m.backend) + } + if len(m.entries) <= kept { + t.Error("the transcript should survive a switch") + } + if m.entries[kept-1].text != "something I asked earlier" { + t.Error("an earlier entry was lost") + } + last := m.entries[len(m.entries)-1] + if !strings.Contains(last.text, "does not carry over") { + t.Errorf("the switch must say the conversation is not carried, got %q", last.text) + } + + // State belonging to the session that ended. + for name, got := range map[string]any{ + "session": m.session, "modelID": m.modelID, + "models": len(m.models), "mcpServers": len(m.mcpServers), + "ctxTokens": m.ctxTokens, + } { + switch v := got.(type) { + case string: + if v != "" { + t.Errorf("%s = %q, want it cleared", name, v) + } + case int: + if v != 0 { + t.Errorf("%s = %d, want it cleared", name, v) + } + } + } + if m.ctxLimit != 200_000 { + t.Errorf("ctxLimit = %d, want the -ctx floor back", m.ctxLimit) + } + if !strings.Contains(m.input.Placeholder, "codex") { + t.Errorf("placeholder = %q, want it to name the new backend", m.input.Placeholder) + } +} + +// A resumed session's id belongs to the backend that issued it. Carrying the +// launch --resume across would hand claude an id codex made up. +func TestWithoutResumeStripsBothFlagForms(t *testing.T) { + got := withoutResume([]string{"--settings", "{}", "--resume=abc", "-x"}) + if strings.Join(got, " ") != "--settings {} -x" { + t.Errorf("joined form: got %v", got) + } + got = withoutResume([]string{"--resume", "abc", "--verbose"}) + if strings.Join(got, " ") != "--verbose" { + t.Errorf("separated form: got %v", got) + } + orig := []string{"--resume=abc"} + _ = withoutResume(orig) + if len(orig) != 1 { + t.Error("the caller's slice must not be edited underneath it") + } +} + +// The engine we switched away from is still draining into the same program. +// Its trailing frames, and the EOF that follows, must not land in the new +// session — the EOF in particular would announce that the session had ended. +func TestStaleBackendFramesAreIgnoredAfterASwitch(t *testing.T) { + m, _ := newTestModel(t, "") + m.backend = backendCodex + before := len(m.entries) + + // claude traffic arriving after a switch to codex. + step := func(msg tea.Msg) { + t.Helper() + next, _ := m.Update(msg) + nm, ok := next.(model) + if !ok { + t.Fatalf("Update returned %T", next) + } + m = nm + } + step(streamMsg{env: Envelope{ + Type: "assistant", + Message: &APIMessage{Content: []ContentBlock{{Type: "text", Text: "from the old engine"}}}, + }}) + step(streamClosedMsg{}) + + if len(m.entries) != before { + t.Errorf("the old backend added %d entries after the switch", len(m.entries)-before) + } + for _, e := range m.entries { + if strings.Contains(e.text, "session ended") { + t.Error("the old engine's EOF announced the end of the new session") + } + } + + // The backend we are actually on still gets through. + step(codexMsg{frame: codexFrame{ + Method: "item/completed", + Params: []byte(`{"item":{"type":"agentMessage","id":"m1","text":"from the live engine"}}`), + }}) + last := m.entries[len(m.entries)-1] + if last.text != "from the live engine" { + t.Errorf("the live backend was filtered out too: %+v", last) + } +} diff --git a/commandlist.go b/commandlist.go index bd07990..dd4b3b5 100644 --- a/commandlist.go +++ b/commandlist.go @@ -57,6 +57,17 @@ func slashCommands() []slashCmd { return *m, nil }, }, + { + name: "backend", + desc: "switch the agent CLI without restarting (claude|codex)", + exec: func(m *model, arg string) (model, tea.Cmd) { + if id := strings.TrimSpace(strings.ToLower(arg)); id != "" { + return *m, m.commitBackend(id) + } + m.picker = newPicker("backend", "BACKEND", backendItems(m.backend), m.w, m.h) + return *m, nil + }, + }, { name: "mcp", desc: "manage MCP servers — status, reconnect/enable/disable", diff --git a/launch.go b/launch.go index f9a650a..8978c0f 100644 --- a/launch.go +++ b/launch.go @@ -24,4 +24,10 @@ type launchConfig struct { Spinner string // throbber style id ResumeID string // session to replay into the transcript, or "" SysPrompt string // standing instructions in force, or "" + + // EngineCfg is the claude launch config, kept so /backend can re-spawn a + // backend without a re-exec (backendswitch.go). Prog is the running program, + // filled by main once it exists (progref.go). + EngineCfg EngineConfig + Prog *progRef } diff --git a/main.go b/main.go index 1021b6c..f7fd972 100644 --- a/main.go +++ b/main.go @@ -131,9 +131,12 @@ func main() { os.Exit(1) } + prog := &progRef{} m := newModel(launchConfig{ Engine: engine, Backend: *backend, + EngineCfg: cfg, + Prog: prog, Approvals: approvals, Mode: *mode, Spinner: *spin, @@ -141,10 +144,15 @@ func main() { SysPrompt: sysPrompt, }) m.ctxLimit = parseTokenCount(*ctx) + m.baseCtxLimit = m.ctxLimit // A resumed session may already exceed the base limit; grow it now that the // -ctx flag has set the floor, so the gauge starts honest (see observeCtx). m.observeCtx(m.ctxTokens) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + // Set before the program runs, so /backend can pipe a swapped engine into + // it later (progref.go). The model was copied into p above, but it holds a + // pointer to this holder, so the copy sees the value too. + prog.set(p) go engine.Pipe(p) diff --git a/model.go b/model.go index 0e17c5f..3e41ca8 100644 --- a/model.go +++ b/model.go @@ -60,7 +60,9 @@ type bodyKey struct { // external services up top, modal flags, widgets, then session/turn state. type model struct { engine Engine - backend string // which agent CLI is being driven; see agentname.go + backend string // which agent CLI is being driven; see agentname.go + engineCfg EngineConfig // the launch config, reused when /backend re-spawns + prog *progRef // the running program, for piping a swapped engine approvals *Approvals md *glamour.TermRenderer hist *history @@ -178,9 +180,12 @@ type model struct { // is cumulative. ctxLimit defaults to 200K and auto-grows when observed // ctx exceeds it, so users on the 1M-context beta don't see a stuck ⚠. ctxTokens int - outTokens int - ctxLimit int - resumeID string // set via restartResuming (session picker, /sysprompt); main.go re-execs on it after p.Run() + // baseCtxLimit is the -ctx floor. A backend switch returns ctxLimit to it, + // because the window it grew to described a model that is gone. + baseCtxLimit int + outTokens int + ctxLimit int + resumeID string // set via restartResuming (session picker, /sysprompt); main.go re-execs on it after p.Run() // sysPromptSeen is the standing-instruction text as claude got it at launch, // and "" whenever no style reached claude: the toggle off, an empty prompt // file, or a style file that could not be written. The flag reads the file @@ -235,6 +240,7 @@ func newModel(cfg launchConfig) model { applyTheme(st.Theme) // re-skin all styles to the persisted theme before first paint m := model{ engine: cfg.Engine, backend: cfg.Backend, approvals: cfg.Approvals, + engineCfg: cfg.EngineCfg, prog: cfg.Prog, hist: openHistory(), sessions: openSessionStore(), input: ta, sp: sp, diff --git a/pickerkeys.go b/pickerkeys.go index bd8c526..5aeb16c 100644 --- a/pickerkeys.go +++ b/pickerkeys.go @@ -77,6 +77,13 @@ func (m model) handlePickerKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { m.commitBar(chosen) } return m, nil, true + case "backend": + // Swaps the subprocess in place — no re-exec, so the transcript stays + // (backendswitch.go). + if chosen != "" { + return m, m.commitBackend(chosen), true + } + return m, nil, true case "sysprompt": // May return tea.Quit: applying it restarts into a resumed session // (sysprompt.go). @@ -158,6 +165,13 @@ func (m model) handlePickerKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { p := newPicker("bar", "COMPACT BAR", barItems(), m.w, m.h) p.setCursorTo(m.settings.Bar) m.picker = p + case "backend": + // Swaps the subprocess in place — no re-exec, so the transcript stays + // (backendswitch.go). + if chosen != "" { + return m, m.commitBackend(chosen), true + } + return m, nil, true case "sysprompt": p := newPicker("sysprompt", "EXTRA SYSTEM PROMPT", sysPromptItems(m.sysPromptEdited()), m.w, m.h) p.setCursorTo(sysPromptLabel(m.settings.SysPrompt)) diff --git a/progref.go b/progref.go new file mode 100644 index 0000000..92a8d48 --- /dev/null +++ b/progref.go @@ -0,0 +1,47 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" +) + +// progRef is a shared handle on the running Bubble Tea program. +// +// It exists because of an ordering problem with no neat answer in Bubble Tea: +// the program is constructed *from* the model, so the model cannot be given the +// program at construction, and every later copy of the model is a value copy. +// A pointer to this holder survives those copies, and main fills it in once the +// program exists. +// +// One consumer: switching backend at runtime has to start piping a new engine +// into the program that is already running (backendswitch.go). Nothing else +// needs the program, and nothing else should — a model that can reach the +// program can bypass the update loop, which is how ordering bugs start. +// +// The same trick as model.content, which is a *strings.Builder for the same +// value-copy reason. +type progRef struct { + mu sync.Mutex + p *tea.Program +} + +// set is called once by main, after the program is constructed. +func (r *progRef) set(p *tea.Program) { + r.mu.Lock() + defer r.mu.Unlock() + r.p = p +} + +// get returns the program, or nil before main has set it. +func (r *progRef) get() *tea.Program { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + return r.p +} diff --git a/update.go b/update.go index 74c78bc..f2831dc 100644 --- a/update.go +++ b/update.go @@ -117,9 +117,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case streamMsg: + // Ignore a backend that is no longer ours. After /backend swaps engines + // the old subprocess is still draining into this same program, so its + // last frames — and the EOF that follows — would land in the new + // session's transcript and announce that it had ended (backendswitch.go). + if sessionBackend(m.backend) != backendClaude { + return m, nil + } m.handleEvent(msg.env) case codexMsg: + if sessionBackend(m.backend) != backendCodex { + return m, nil + } m.handleCodexEvent(msg.frame) case engineInitErrMsg: @@ -156,6 +166,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pending = &msg.req case streamClosedMsg: + // Same rule: the closing engine may be the one we just switched away + // from, and its EOF is expected rather than the end of this session. + if sessionBackend(m.backend) != backendClaude { + return m, nil + } m.busy = false m.stopCompacting() note := "session ended"