From ae07b5c1793fe469cbc08e4656e06d72915cd2e3 Mon Sep 17 00:00:00 2001 From: tdwd Date: Wed, 9 Sep 2026 15:17:24 +0200 Subject: [PATCH] backend: switch the agent CLI without restarting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /backend swaps the subprocess in place. No re-exec: restartResuming exists for launch flags that cannot change any other way, and a backend switch replaces the process anyway, so using it would discard the scrollback to achieve what the running process can already do. Three things make the swap safe. Close runs on its own goroutine. It blocks on Wait, and doing that inline is the same deadlock main tears down after p.Run to avoid. The new engine is piped into the program that is already running, reached through progRef. The program is built from the model, so the model cannot be handed it at construction, and every later model is a value copy — a pointer to a holder survives that, the same trick as model.content. A failed spawn leaves the old engine untouched and running, so a missing CLI costs an error line rather than the session. The engine we switch away from keeps draining into the same program until it exits, so its trailing frames and its EOF are now ignored. Without that the old subprocess announces "session ended" into the session that just started. What does not survive is the conversation. A session id from one backend means nothing to the other, so the new one starts fresh and the divider says so rather than leaving it to be noticed. resetForBackend clears the rest: a model list from the wrong backend offers rows that cannot be selected, a carried-over session id would be filed under the new backend, and ctxLimit returns to the -ctx floor. The launch --resume is stripped too, or switching a resumed session hands one backend an id the other invented. --- README.md | 5 ++ backend_test.go | 13 +-- backendswitch.go | 183 ++++++++++++++++++++++++++++++++++++++++ backendswitch_test.go | 189 ++++++++++++++++++++++++++++++++++++++++++ commandlist.go | 11 +++ launch.go | 6 ++ main.go | 8 ++ model.go | 14 +++- pickerkeys.go | 14 ++++ progref.go | 47 +++++++++++ update.go | 15 ++++ 11 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 backendswitch.go create mode 100644 backendswitch_test.go create mode 100644 progref.go 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"