From 3fce9c009864824db4d18cf3994084cbc0f5255e Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 13 Oct 2026 13:42:20 +0100 Subject: [PATCH] feat: a structured claude agent opens in claude code's own terminal and comes back POST /v1/sessions/{name}/switch {"to":"terminal"|"chat"} (driver.switch) moves an idle structured session between its chat and Claude Code's own screen, one process at a time, on the same conversation: - to the terminal: the host stops Claude, and the pane is respawned as `claude --resume `, with berthd's hooks back on and the session's bypass carried over as --permission-mode where Claude allows it - back to the chat: Claude Code is told /exit, the turns typed there are copied from its transcript into berthd's log, and the host starts again with --resume on the same log and socket. It works from a terminal that was already left, too The pane's Terminal | Conversation switch does it for these sessions. Also: the Claude driver says what it reads in order, from one queue per session, and a prompt's start waits for the send that opened its turn. A write waits for a host that is still starting. Co-Authored-By: Claude Opus 5.5 --- app/src/components/workspace/pane.tsx | 23 +- app/src/lib/api.ts | 4 + design/structured-agents/plan.md | 2 +- docs/changelog.mdx | 7 + docs/guides/labs.mdx | 15 +- docs/reference/app-api.mdx | 6 +- internal/box/api.go | 1 + internal/box/claudedriver.go | 127 ++++++++--- internal/box/claudedriver_test.go | 144 ++++++++++++- internal/box/sessions.go | 4 + internal/box/switch.go | 295 ++++++++++++++++++++++++++ internal/box/tasks.go | 6 + internal/box/turns.go | 23 ++ internal/box/upgrade.go | 4 +- packages/plugin-sdk/src/types.ts | 4 + 15 files changed, 625 insertions(+), 40 deletions(-) create mode 100644 internal/box/switch.go diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 902666a0..5af326b9 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -11,7 +11,7 @@ import { openChatBackgroundSettings } from "@/components/conversation/chat-backg import { ConversationPane } from "@/components/conversation/conversation-pane"; import { HelperPane } from "@/components/conversation/helper-pane"; import { helperKey, useHelperInfo } from "@/components/conversation/subagent-view"; -import { ErrorText } from "@/components/error-note"; +import { ErrorText, toastError } from "@/components/error-note"; import { SessionActionItems } from "@/components/orchestrate/session-actions"; import { Button } from "@/components/ui/button"; import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "@/components/ui/menu"; @@ -25,6 +25,7 @@ import { TerminalView } from "@/components/workspace/terminal-view"; import { openWorktreePicker } from "@/components/workspace/worktree-picker"; import { useLabel, useTone, WtChip } from "@/components/workspace/worktree-tone"; import { agentPresets, closePane, openBrowserAt, openPreviewAt, startSession } from "@/lib/actions"; +import { boxApi } from "@/lib/api"; import { agentLabel, agentOf, restartCommand, sessionAgent, sessionName, sessionState } from "@/lib/derive"; import { nameFromKey } from "@/lib/groups"; import { type Leaf, leaves, paneWorktree } from "@/lib/layout"; @@ -239,6 +240,9 @@ export function usePaneView(pane: Leaf): "terminal" | "conversation" | undefined export function ViewSwitch({ wsKey, tab, pane }: { wsKey: string; tab: string; pane: Leaf }) { const view = usePaneView(pane); const c = pane.content; + // A structured session's terminal and chat are two ways of running it, + // not two views of one: the switch moves it (switch.go). + const moves = useStore((st) => (c.kind === "terminal" ? st.boxes[c.box]?.sessions?.find((x) => x.name === c.session)?.switch : undefined)); if (!view || c.kind !== "terminal") return null; return ( { const next = v[0] as "terminal" | "conversation" | undefined; if (!next) return; + if ((moves === "terminal" && next === "terminal") || (moves === "chat" && next === "conversation")) { + const client = useStore.getState().client; + if (!client) return; + const to = next === "terminal" ? "terminal" : "chat"; + void boxApi + .switchSession(client, c.box, c.session, to) + .then(() => { + setPaneContent(wsKey, tab, pane.id, { ...c, view: next }); + void useStore.getState().refreshBox(c.box, ["sessions"]); + }) + .catch((err) => toastError(err, { title: to === "terminal" ? "Couldn't open Claude Code's terminal" : "Couldn't go back to the chat", box: c.box })); + return; + } setPaneContent(wsKey, tab, pane.id, { ...c, view: next }); // The way you last chose to see an agent is how new ones open. usePrefs.setState({ agentView: next }); @@ -255,12 +272,12 @@ export function ViewSwitch({ wsKey, tab, pane }: { wsKey: string; tab: string; p aria-label="Show the agent as" className="mr-1" > - + - + diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index f314fd24..4ec85acc 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -265,6 +265,10 @@ export const boxApi = { startSession: (c: Client, box: string, req: ({ location: string } | { home: true }) & { name?: string; command?: string; agent?: string; prompt?: string; model?: string; effort?: string; title?: string; driver?: "terminal" | "claude"; mode?: string }) => c.box(box, "POST", "sessions", req), stopSession: (c: Client, box: string, name: string) => c.box(box, "DELETE", `sessions/${encodeURIComponent(name)}`), + // Takes a structured session to Claude Code's own terminal, or back to its + // chat (Session.switch). + switchSession: (c: Client, box: string, name: string, to: "terminal" | "chat") => + c.box(box, "POST", `sessions/${encodeURIComponent(name)}/switch`, { to }), // Names a session's work; "" clears its title. // renameWorktree gives a worktree a display name ("" clears it), on boxes // that list "worktree.titles" (lib/worktree-names). diff --git a/design/structured-agents/plan.md b/design/structured-agents/plan.md index 3f6ca42a..67f4832f 100644 --- a/design/structured-agents/plan.md +++ b/design/structured-agents/plan.md @@ -1,6 +1,6 @@ # Structured agents: plan -Status: 2026-10-13. Phase 0 (spike) done: go. Phases 1 (driver interface) and 2 (agent-host + log) merged. Phase 3 (Claude driver, behind Labs) in review. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. +Status: 2026-10-13. Phase 0 (spike) done: go. Phases 1 (driver interface) and 2 (agent-host + log) merged. Phase 3 (Claude driver, behind Labs) merged. Phase 4a (terminal and back) in review; 4b (resume, conversation index) next. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. ## Why diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 9a31d533..562b3449 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,13 @@ again. ## Unreleased +- **Labs: a structured Claude agent opens in Claude Code's own terminal and + comes back.** The Terminal switch on a structured agent's pane stops it and + opens the same conversation in Claude Code's screen, for `/login`, + `/memory`, its menus and anything else only it does; Conversation brings it + back to the chat, with what was said in the terminal in its place. Needs the + box upgraded. + - **Labs: structured Claude agents.** Turn on *Structured Claude agents* in Settings › Labs and new Claude Code agents run without their terminal: the box drives Claude Code through its stream and keeps its own diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index 543e1b3e..df4f7ec8 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -251,13 +251,18 @@ own record of the conversation. The box drives it from there: for you. The chat's mode menu switches between bypass, ask, accept edits, plan and auto. - **Stop** interrupts the turn, as Esc does in its terminal. -- **Its terminal** shows what streams by; it takes no typing. +- **Claude Code's own terminal** is one click away: the pane's Terminal + switch stops the structured agent and opens the same conversation in + Claude Code's screen (`claude --resume`), for what only it does + (`/login`, `/resume`, `/memory`, `/permissions`, its menus). Conversation + takes it back to the chat, with what you said in the terminal in its place. + Either way needs the agent to be idle. In a folder Claude Code hasn't + seen, its screen first asks whether you trust it. The agent keeps working while berthd restarts or upgrades, and the record -keeps every streamed word even if the agent is killed. Slash commands that -need Claude Code's own screen (`/login`, `/resume`, `/memory`, -`/permissions`) don't run here: start a terminal agent for those. The box -needs the `driver.claude` capability (upgrade it). +keeps every streamed word even if the agent is killed. The box needs the +`driver.claude` capability, and `driver.switch` for the terminal (upgrade +it). ## Zen diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 5ac8cdd8..9d69bb76 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -229,7 +229,11 @@ sessions` and `POST tasks` take `driver: "claude"` with `agent: "claude"`, and `mode`, the permission mode it starts in (`bypassPermissions` has the box allow what it asks; questions and plans still wait), on boxes that list `driver.claude`. A waiting structured session's `ask` carries `choices` -(`{ key, label }`): send the key to answer. `exited` is +(`{ key, label }`): send the key to answer. `POST sessions/{name}/switch` +with `{ to: "terminal" }` or `{ to: "chat" }` moves a structured session to +Claude Code's own terminal (`claude --resume`) and back, answering the +`Session`; its `switch` says where it can go, 409 while the agent works. +Boxes that take it list `driver.switch`. `exited` is true once the program has ended. `title` names the work: the first line of the prompt it started with, or of the first prompt it was sent or typed, cut to about 48 characters; `PATCH sessions/{name}` with `{ title }` renames it diff --git a/internal/box/api.go b/internal/box/api.go index d5a76f5b..469f4043 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -217,6 +217,7 @@ func (b *Box) Mount(s *wire.Server) { route("POST /v1/sessions/{name}/interrupt", b.interruptSession) route("GET /v1/sessions/{name}/controls", b.sessionControls) route("POST /v1/sessions/{name}/mode", b.setMode) + route("POST /v1/sessions/{name}/switch", b.switchSession) route("GET /v1/sessions/{name}/wait", b.waitForSession) route("GET /v1/sessions/{name}/turns", b.listTurns) route("GET /v1/sessions/{name}/queue", b.listQueue) diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 485ebf89..67fd7927 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -83,6 +83,24 @@ func (b *Box) launchClaude(p AgentPreset, model, effort, mode, resume string) (s default: return structuredLaunch{}, badRequest("unknown permission mode %q", mode) } + id := newConvID() + dir := filepath.Join(b.ConversationsDir, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + return structuredLaunch{}, err + } + sock, err := hostSocket(id) + if err != nil { + return structuredLaunch{}, err + } + return b.launchClaudeIn(p, model, effort, mode, resume, dir, sock) +} + +// launchClaudeIn is launchClaude keeping its log in dir and listening on +// sock: a new conversation's, or one carrying on (resume). +func (b *Box) launchClaudeIn(p AgentPreset, model, effort, mode, resume, dir, sock string) (structuredLaunch, error) { + if mode == "" { + mode = "default" + } base, err := AgentCommandWith(p, "", model, effort) if err != nil { return structuredLaunch{}, err @@ -95,15 +113,6 @@ func (b *Box) launchClaude(p AgentPreset, model, effort, mode, resume string) (s } host = []string{exe, "agent-host"} } - id := newConvID() - dir := filepath.Join(b.ConversationsDir, id) - if err := os.MkdirAll(dir, 0o700); err != nil { - return structuredLaunch{}, err - } - sock, err := hostSocket(id) - if err != nil { - return structuredLaunch{}, err - } claudeMode, bypass := mode, "0" if mode == "bypassPermissions" { claudeMode, bypass = "default", "1" @@ -189,6 +198,11 @@ type claudeConv struct { // after is new, and is said as it comes. caught chan struct{} caughtOnce sync.Once + // queue is what is to be said, in the order it happened, by speak; + // kick wakes it. gone stops it, once the session is let go. + queue []func() + kick chan struct{} + gone bool } // claudeDriver holds the structured sessions berthd follows. @@ -205,6 +219,36 @@ func (b *Box) claude() *claudeDriver { func (d *claudeDriver) Kind() string { return DriverClaude } +// forget lets a session's conversation go: it is followed afresh from its +// log when next asked for (a session switching to its terminal and back). +func (d *claudeDriver) forget(name string) { + d.mu.Lock() + c := d.convs[name] + delete(d.convs, name) + d.mu.Unlock() + if c != nil { + c.mu.Lock() + if !c.gone { + c.gone = true + close(c.kick) + } + c.mu.Unlock() + } +} + +// speak says what the log said, one thing after another, in its order. +func (c *claudeConv) speak() { + for range c.kick { + c.mu.Lock() + q := c.queue + c.queue = nil + c.mu.Unlock() + for _, f := range q { + f() + } + } +} + // conv is the session's, followed from its log from the first time it is // asked for. func (d *claudeDriver) conv(sess Session) (*claudeConv, error) { @@ -215,7 +259,9 @@ func (d *claudeDriver) conv(sess Session) (*claudeConv, error) { c := d.convs[sess.Name] if c == nil || c.conv != sess.conv { c = &claudeConv{b: d.b, name: sess.Name, cwd: sess.Dir, conv: sess.conv, sock: sess.sock, - log: filepath.Join(sess.conv, agenthost.LogName), replies: map[string]chan json.RawMessage{}, changed: make(chan struct{}), caught: make(chan struct{})} + log: filepath.Join(sess.conv, agenthost.LogName), replies: map[string]chan json.RawMessage{}, changed: make(chan struct{}), caught: make(chan struct{}), + kick: make(chan struct{}, 1)} + go c.speak() d.convs[sess.Name] = c } d.mu.Unlock() @@ -325,9 +371,11 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { var say []func() defer func() { c.signal() - if live { - for _, f := range say { - go f() + if live && len(say) > 0 && !c.gone { + c.queue = append(c.queue, say...) + select { + case c.kick <- struct{}{}: + default: } } }() @@ -335,7 +383,8 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { case "host": switch e.Event { case "started": - c.busy, c.pending, c.since = false, nil, time.Time{} + // A host started again (resumed, or back from its terminal). + c.busy, c.pending, c.since, c.exited = false, nil, time.Time{}, false say = append(say, func() { c.event(adapters.Ready, nil, nil) }) case "exited": c.exited, c.busy, c.pending = true, false, nil @@ -392,7 +441,13 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { if text, ok := promptText(l.Message); ok && !c.busy { c.busy, c.since = true, time.UnixMilli(e.At) title := adapters.Title(text) - say = append(say, func() { c.event(adapters.Started, map[string]any{"signal": "prompt"}, map[string]any{"title": title}) }) + say = append(say, func() { + // The send that started it is said first. + if c.b.Turns != nil { + c.b.Turns.AwaitSent(c.name, 2*time.Second) + } + c.event(adapters.Started, map[string]any{"signal": "prompt"}, map[string]any{"title": title}) + }) } } say = append(say, c.changedConversation) @@ -407,7 +462,7 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { } r.ID = l.RequestID if live && c.bypass && !interactiveTools[r.Tool] { - say = append(say, func() { c.answer(r, true, false, "") }) + go c.answer(r, true, false, "") return } c.pending = append(c.pending, r) @@ -589,13 +644,23 @@ func (c *claudeConv) write(ctx context.Context, v any) error { if err != nil { return err } - if _, err := agenthost.Write(ctx, c.sock, b); err != nil { - if errors.Is(err, agenthost.ErrNoHost) { + // A host starting (just launched, or back from its terminal) is + // waited for a moment. + deadline := time.Now().Add(10 * time.Second) + for { + _, err := agenthost.Write(ctx, c.sock, b) + if !errors.Is(err, agenthost.ErrNoHost) { + return err + } + if time.Now().After(deadline) { return ErrSessionExited } - return err + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } } - return nil } // control sends Claude a control request and waits for its answer. @@ -945,18 +1010,24 @@ func (d *claudeDriver) askChoices(sess Session) []AskChoice { return c.choices() } +// awaitHost waits up to d for a session's host to listen. +func awaitHost(sock string, d time.Duration) bool { + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if c, err := agenthost.Dial(context.Background(), sock); err == nil { + c.Close() + return true + } + time.Sleep(50 * time.Millisecond) + } + return false +} + // startStructured gives a new structured session its first prompt once its // host listens. func (b *Box) startStructured(origin, from string, sess Session, prompt string) { go func() { - deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { - if c, err := agenthost.Dial(context.Background(), sess.sock); err == nil { - c.Close() - break - } - time.Sleep(100 * time.Millisecond) - } + awaitHost(sess.sock, 30*time.Second) // Followed from its start, so its first turn is said as it goes. b.claude().conv(sess) if strings.TrimSpace(prompt) == "" { diff --git a/internal/box/claudedriver_test.go b/internal/box/claudedriver_test.go index e017c265..344ad541 100644 --- a/internal/box/claudedriver_test.go +++ b/internal/box/claudedriver_test.go @@ -2,6 +2,7 @@ package box import ( "encoding/json" + "net/http" "os" "os/exec" "path/filepath" @@ -18,8 +19,24 @@ import ( // tests", asks a question to "ask me", works until interrupted on "work", // and answers control requests. const fakeStreamClaude = `#!/usr/bin/env python3 -import json, sys +import json, os, re, sys S = "00000000-0000-4000-8000-0000000000c1" +def transcript(sid): + d = os.path.join(os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude"), "projects", re.sub(r"[^A-Za-z0-9]", "-", os.getcwd())) + os.makedirs(d, exist_ok=True) + return os.path.join(d, sid + ".jsonl") +if "-p" not in sys.argv: + # Claude Code's own screen, resumed: a turn typed there, then /exit. + sid = sys.argv[sys.argv.index("--resume") + 1] + with open(transcript(sid), "a") as f: + f.write(json.dumps({"type": "user", "message": {"role": "user", "content": "typed in the terminal"}, "uuid": "00000000-0000-4000-8000-0000000000f1", "sessionId": sid, "timestamp": "2026-10-13T12:00:00Z"}) + "\n") + f.write(json.dumps({"type": "assistant", "message": {"id": "msg_t", "role": "assistant", "content": [{"type": "text", "text": "answered in the terminal"}]}, "uuid": "00000000-0000-4000-8000-0000000000f2", "sessionId": sid, "timestamp": "2026-10-13T12:00:01Z"}) + "\n") + print("Claude Code, resumed " + sid, flush=True) + for line in sys.stdin: + if "/exit" in line: + sys.exit(0) + sys.exit(0) +open(transcript(S), "a").close() mode = sys.argv[sys.argv.index("--permission-mode") + 1] n = [0] def out(d): @@ -340,4 +357,129 @@ func TestALiveClaudeSessionWorks(t *testing.T) { // Bypass: the box allows the command it runs. call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": "Run `echo shipyard-live` with the Bash tool, then reply with just its output.", "when": "now"}, nil) wait("the command's output", said("shipyard-live")) + + // To Claude Code's own terminal and back, the same conversation. + if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/switch", "", map[string]string{"to": "terminal"}, nil); status != 200 { + t.Fatalf("to terminal: %d", status) + } + var screen struct{ Screen string } + deadline := time.Now().Add(60 * time.Second) + for !strings.Contains(screen.Screen, "shipyard-live") { + if time.Now().After(deadline) { + t.Fatalf("Claude Code's screen never showed the conversation:\n%s", screen.Screen) + } + time.Sleep(500 * time.Millisecond) + call(t, c, "GET", "/v1/sessions/"+sess.Name+"/screen", "", nil, &screen) + if strings.Contains(screen.Screen, "Yes, I trust this folder") { + // A folder it hasn't seen: answered as a person would. + call(t, c, "POST", "/v1/sessions/"+sess.Name+"/keys", "", map[string]any{"keys": []string{"down", "enter"}}, nil) + time.Sleep(time.Second) + } + } + if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/switch", "", map[string]string{"to": "chat"}, nil); status != 200 { + t.Fatalf("to chat: %d", status) + } + call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": "What word did you reply with first? Reply with just that word.", "when": "now"}, nil) + wait("a reply that remembers", func(items []transcript.Item, st SessionState) bool { + n := 0 + for _, it := range items { + if it.Kind == "text" && strings.Contains(strings.ToLower(it.Text), "harbour") { + n++ + } + } + return n >= 2 && st.State == "finished" + }) +} + +func TestAStructuredSessionGoesToItsTerminalAndBack(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("the stand-in Claude needs python3") + } + bin := t.TempDir() + os.WriteFile(filepath.Join(bin, "claude"), []byte(fakeStreamClaude), 0o755) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) + exe, _ := os.Executable() + states := &Turns{} + c, bus := servedBox(t, func(b *Box) { + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + var sess Session + call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "claude", "driver": "claude", "prompt": "hello"}, &sess) + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + name := sess.Name + until := func(what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for !ok() { + if time.Now().After(deadline) { + t.Fatalf("%s never happened", what) + } + time.Sleep(50 * time.Millisecond) + } + } + said := func(text string) func() bool { + return func() bool { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + for _, it := range res.Items { + if strings.Contains(it.Text, text) { + return true + } + } + return false + } + } + finished := func() bool { st, _ := states.State(name); return st.State == "finished" } + until("the first reply", said("echo: hello")) + until("the first turn's end", finished) + if sess.Switch != "terminal" { + t.Fatalf("a structured session can't go to its terminal: %+v", sess) + } + + var moved Session + if status := call(t, c, "POST", "/v1/sessions/"+name+"/switch", "", map[string]string{"to": "terminal"}, &moved); status != 200 { + t.Fatalf("to terminal: %d", status) + } + if moved.Driver != DriverTerminal || moved.Switch != "chat" || moved.Exited { + t.Fatalf("in its terminal it reads %+v", moved) + } + until("Claude Code's own screen", func() bool { + var screen struct{ Screen string } + call(t, c, "GET", "/v1/sessions/"+name+"/screen", "", nil, &screen) + return strings.Contains(screen.Screen, "resumed 00000000-0000-4000-8000-0000000000c1") + }) + if status := call(t, c, "POST", "/v1/sessions/"+name+"/switch", "", map[string]string{"to": "terminal"}, nil); status != http.StatusConflict { + t.Fatalf("switching to the terminal twice: %d", status) + } + + if status := call(t, c, "POST", "/v1/sessions/"+name+"/switch", "", map[string]string{"to": "chat"}, &moved); status != 200 { + t.Fatalf("to chat: %d", status) + } + if moved.Driver != DriverClaude || moved.Switch != "terminal" { + t.Fatalf("back in its chat it reads %+v", moved) + } + // What was said in the terminal reads in the chat, in its place. + until("the terminal's turn in the chat", said("answered in the terminal")) + if status := call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "back again", "when": "now"}, nil); status != 200 { + st, _ := states.State(name) + t.Fatalf("send after coming back: %d (%+v)", status, st) + } + until("a reply after coming back", said("echo: back again")) + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + var order []string + for _, it := range res.Items { + if it.Kind == "text" { + order = append(order, it.Text) + } + } + if strings.Join(order, " | ") != "echo: hello | answered in the terminal | echo: back again" { + t.Fatalf("the conversation reads %q", order) + } } diff --git a/internal/box/sessions.go b/internal/box/sessions.go index d18f8b26..78498d29 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -62,6 +62,10 @@ type Session struct { // "terminal": true): the service's name, kept as @berth_service. It is // never an agent, whatever it runs. Service string `json:"service,omitempty"` + // Switch is where a structured session can go: "terminal" (Claude + // Code's own screen), or "chat" for one in its terminal now + // (switch.go). + Switch string `json:"switch,omitempty"` // Driver is how berthd reaches the session's agent (driver.go): // "terminal" for every agent today. Empty when it runs no agent. Driver string `json:"driver,omitempty"` diff --git a/internal/box/switch.go b/internal/box/switch.go new file mode 100644 index 00000000..ee5562d6 --- /dev/null +++ b/internal/box/switch.go @@ -0,0 +1,295 @@ +package box + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "maps" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/groups" +) + +// Switching a structured Claude session between its chat and Claude Code's +// own terminal, and back (POST /v1/sessions/{name}/switch, "driver.switch" +// capability). It is one conversation throughout, and one process at a +// time: +// +// - To the terminal: the host stops Claude (it ends at its stdin's end), +// and the pane runs `claude --resume `: the full TUI, for +// what only it does (/login, /resume, /memory, its own screen). +// - Back to the chat: the TUI is told /exit, what was said in it is copied +// from Claude Code's transcript into berthd's log, and the host starts +// again with --resume. +// +// The session keeps its log folder and socket throughout (@berth_conv, +// @berth_sock): a session in its terminal with a log is one that can go +// back (Session.Switch "chat"). + +// labelHandoff is where Claude Code's transcript stood when the session +// went to its terminal: the file, then its size. What it says past that +// was said in the terminal. +const labelHandoff = "@berth_handoff" + +// SwitchRequest moves a session to its terminal or back to its chat. +type SwitchRequest struct { + To string `json:"to"` +} + +func (b *Box) switchSession(w http.ResponseWriter, r *http.Request) error { + var req SwitchRequest + if err := decode(r, &req); err != nil { + return err + } + name := r.PathValue("name") + sess, err := b.Sessions.Get(r.Context(), name) + if err != nil { + return err + } + switch req.To { + case "terminal", "chat": + default: + return badRequest("switch to terminal or chat") + } + // Claude Code's terminal may have been left (/exit, or "No, exit" at + // its trust question): its chat still takes it back. + if sess.Exited && !(req.To == "chat" && sess.Driver == "" && sess.conv != "") { + return ErrSessionExited + } + if b.Turns != nil { + if st, ok := b.Turns.State(name); ok && (st.State == "running" || st.State == "waiting") { + return httpError{http.StatusConflict, "the agent is " + st.State + ": stop it or let it finish first"} + } + } + if err := b.before(r, "session.switch", map[string]any{"name": name, "to": req.To}); err != nil { + return err + } + switch { + case req.To == "terminal" && sess.Driver == DriverClaude: + err = b.toTerminal(r, sess) + case req.To == "chat" && sess.Driver == "" && sess.conv != "": + err = b.toChat(r, sess) + case req.To == "terminal": + return httpError{http.StatusConflict, name + " is already in its terminal"} + default: + return httpError{http.StatusConflict, name + " has no chat to go back to: only a structured session's terminal does"} + } + if err != nil { + return err + } + b.publish(r, "session.switched", map[string]any{"name": name, "to": req.To}) + sess, err = b.Sessions.Get(r.Context(), name) + if err != nil { + return err + } + writeJSON(w, b.enrich(r.Context(), []Session{sess})[0]) + return nil +} + +// claudePreset is the Claude Code preset a session's location runs. +func (b *Box) claudePreset(ctx context.Context, sess Session) AgentPreset { + name, _, _ := strings.Cut(sess.Location, "/") + if loc, err := b.Locations.Get(ctx, name); err == nil { + if p, ok := presetFor(&loc, "claude"); ok { + return p + } + } + for _, p := range builtinAgents { + if p.ID == "claude" { + return p + } + } + return AgentPreset{ID: "claude", Name: "Claude Code", Command: "claude"} +} + +func (b *Box) toTerminal(r *http.Request, sess Session) error { + ctx := r.Context() + c, err := b.claude().conv(sess) + if err != nil { + return err + } + c.mu.Lock() + sid, mode, bypass := c.sessionID, firstNonEmpty(c.mode, "default"), c.bypass + c.mu.Unlock() + if sid == "" { + return httpError{http.StatusConflict, "Claude hasn't started its conversation yet: send it something first"} + } + // Where its transcript stands now: what the terminal adds is past it. + handoff := "" + if _, path, _ := b.transcriptFile(ctx, sess, nil); path != "" { + if st, err := os.Stat(path); err == nil { + handoff = path + "\x1f" + strconv.FormatInt(st.Size(), 10) + } + } + stopCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := agenthost.Stop(stopCtx, sess.sock); err != nil && !errors.Is(err, agenthost.ErrNoHost) { + return err + } + b.claude().forget(sess.Name) + command := b.claudePreset(ctx, sess).Command + " --resume " + shellQuote(sid) + if bypass && os.Getuid() != 0 { + command += " --permission-mode bypassPermissions" + } else if mode != "default" { + command += " --permission-mode " + shellQuote(mode) + } + err = b.respawnAgent(ctx, sess, command, []string{"BERTH_DRIVER="}, map[string]string{labelDriver: "", labelHandoff: handoff}) + if err != nil { + return err + } + // Claude Code's own screen may ask its startup questions. + b.beginStartup(origin(r), Session{Name: sess.Name, Agent: "claude", Dir: sess.Dir}) + return nil +} + +func (b *Box) toChat(r *http.Request, sess Session) error { + ctx := r.Context() + sid := "" + if b.Turns != nil { + if st, ok := b.Turns.State(sess.Name); ok { + sid = st.AgentSessionID + } + } + if sid == "" { + sid = lastSessionID(filepath.Join(sess.conv, agenthost.LogName)) + } + if sid == "" { + return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} + } + // Ask Claude Code to leave, as a person would, then make sure. + b.Sessions.tmux(ctx, "send-keys", "-t", "="+sess.Name+":", "-l", "/exit") + time.Sleep(150 * time.Millisecond) + b.Sessions.tmux(ctx, "send-keys", "-t", "="+sess.Name+":", "Enter") + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if s, err := b.Sessions.Get(ctx, sess.Name); err != nil || s.Exited { + break + } + time.Sleep(150 * time.Millisecond) + } + if err := b.importTerminalTurns(ctx, sess); err != nil { + return err + } + l, err := b.launchClaudeIn(b.claudePreset(ctx, sess), "", "", modeFor(sess), sid, sess.conv, sess.sock) + if err != nil { + return err + } + l.labels[labelHandoff] = "" + b.claude().forget(sess.Name) + if err := b.respawnAgent(ctx, sess, l.command, l.env, l.labels); err != nil { + return err + } + // Back when it listens, so what is sent next reaches it. + awaitHost(sess.sock, 15*time.Second) + if live, err := b.Sessions.Get(ctx, sess.Name); err == nil { + b.startStructured(origin(r), gateOrigin(r), live, "") + } + return nil +} + +// modeFor is the mode a structured session goes back to: bypass when +// berthd allowed its permissions before. +func modeFor(sess Session) string { + if sess.bypass { + return "bypassPermissions" + } + return "" +} + +// importTerminalTurns copies what was said in Claude Code's terminal from +// its transcript into berthd's log, so the chat reads on from where the +// terminal left it. +func (b *Box) importTerminalTurns(ctx context.Context, sess Session) error { + v, err := b.Sessions.tmux(ctx, "show-options", "-v", "-t", "="+sess.Name+":", labelHandoff) + if err != nil { + return nil + } + path, off, ok := strings.Cut(strings.TrimSpace(string(v)), "\x1f") + from, perr := strconv.ParseInt(off, 10, 64) + if !ok || perr != nil || path == "" { + return nil + } + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + if _, err := f.Seek(from, 0); err != nil { + return nil + } + log, err := agenthost.OpenLog(sess.conv) + if err != nil { + return err + } + defer log.Close() + rd := bufio.NewReaderSize(f, 256<<10) + for { + line, err := rd.ReadBytes('\n') + if len(line) > 0 && line[len(line)-1] == '\n' && json.Valid(line) { + log.Append(agenthost.Entry{Dir: "out", Event: "imported", Data: json.RawMessage(strings.TrimSpace(string(line)))}) + } + if err != nil { + return nil + } + } +} + +// lastSessionID is the Claude conversation a log last spoke in. +func lastSessionID(path string) string { + sid := "" + agenthost.ReadLog(path, 0, func(e agenthost.Entry) error { + var l struct { + SessionID string `json:"session_id"` + } + if e.Dir == "out" && json.Unmarshal(e.Data, &l) == nil && l.SessionID != "" { + sid = l.SessionID + } + return nil + }) + return sid +} + +// respawnAgent runs command in the session's pane in place of what ran +// there, with env added to the session's own and labels set, as create +// starts one (sessions.go). +func (b *Box) respawnAgent(ctx context.Context, sess Session, command string, env []string, labels map[string]string) error { + s := b.Sessions + extra, wrap := b.sessionEnv(ctx, sess.Dir) + file, err := s.writeCommand(sess.Name, command) + if err != nil { + return err + } + shell := sessionShell() + argv := []string{shell, "-lc", withPATH(shell, launchPATH(command, "claude"), sourceCommand(shell, file))} + target := "=" + sess.Name + ":" + args := []string{"respawn-pane", "-k", "-t", target, "-c", sess.Dir} + for _, kv := range append(append(withAgentBrowserIdle(extra), "BERTH_SESSION="+sess.Name, "BERTH_AGENT=claude"), env...) { + args = append(args, "-e", kv) + } + args = append(append(args, "--"), groups.Wrap(append(append([]string(nil), wrap...), argv...))...) + set := func(k, v string) { + args = append(args, ";", "set-option", "-t", target, k, v) + } + set("@berth_command", plainCommand(command)) + set("@berth_command_file", file) + for _, k := range slices.Sorted(maps.Keys(labels)) { + set(k, labels[k]) + } + for i, a := range args { + if a != ";" { + args[i] = tmuxArg(a) + } + } + if out, err := s.tmux(ctx, args...); err != nil { + return tmuxError("respawn-pane", out, err) + } + return nil +} diff --git a/internal/box/tasks.go b/internal/box/tasks.go index d3e76727..fe6c696f 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -399,6 +399,12 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { continue } s.Driver = b.driverFor(*s).Kind() + switch { + case s.Driver == DriverClaude: + s.Switch = "terminal" + case s.conv != "": + s.Switch = "chat" + } if s.Driver == DriverClaude && !s.Exited { // Followed from the first time it is seen (after a restart, // the first list), so what it does is said as it does it. diff --git a/internal/box/turns.go b/internal/box/turns.go index 432f2dda..7e5e7c8a 100644 --- a/internal/box/turns.go +++ b/internal/box/turns.go @@ -1250,6 +1250,29 @@ func (t *Turns) failQueued(it inboxItem, why string) { } // Changed returns a channel closed at the ledger's next change. +// AwaitSent waits up to d for a prompt sent to the session to be in the +// ledger: a turn pending, or begun. A structured agent's start can be read +// before berthd has said it sent the prompt that starts it. +func (t *Turns) AwaitSent(name string, d time.Duration) { + deadline := time.After(d) + for { + t.mu.Lock() + t.init() + s := t.sess[name] + ready := s != nil && (s.oldest("pending") != nil || (s.current() != nil && s.current().State == "running")) + changed := t.changed + t.mu.Unlock() + if ready { + return + } + select { + case <-changed: + case <-deadline: + return + } + } +} + func (t *Turns) Changed() <-chan struct{} { t.mu.Lock() defer t.mu.Unlock() diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index d41ad596..1a23bebf 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -165,7 +165,9 @@ func (b *Box) Capabilities() []string { if b.ConversationsDir != "" { // driver.claude: sessions and tasks take "driver": "claude", Claude // Code driven through its stream (claudedriver.go). - caps = append(caps, "driver.claude") + // driver.switch: POST .../switch takes a structured session to + // Claude Code's terminal and back (switch.go). + caps = append(caps, "driver.claude", "driver.switch") } if b.Artifacts != nil { // artifacts: berthd artifact add, a worktree's artifacts and their diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 92b067a3..df9370c3 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -216,6 +216,10 @@ export interface Session { // own screen in tmux, or "claude", Claude Code driven through its stream // ("driver.claude" capability). driver?: "terminal" | "claude" | string; + // Where a structured session can switch ("driver.switch" capability): + // "terminal", to Claude Code's own screen, or "chat", back from it. POST + // sessions/{name}/switch with { to } switches it. + switch?: "terminal" | "chat"; // The systemd scope its processes run in, on boxes where sessions get // one (Linux with systemd), and what they use: memory (bytes, page cache // included), its ceiling if the box sets one (Settings → Boxes), processor