diff --git a/app/src/components/sidebar/actions.tsx b/app/src/components/sidebar/actions.tsx index 42a06e20..cdced9f9 100644 --- a/app/src/components/sidebar/actions.tsx +++ b/app/src/components/sidebar/actions.tsx @@ -1,44 +1,4 @@ -import { - ActivityIcon, - ListPlusIcon, - PaletteIcon, - ArchiveIcon, - ArrowUpCircleIcon, - ArrowUpRightIcon, - BotIcon, - CheckIcon, - FolderInputIcon, - MergeIcon, - PackageIcon, - PencilIcon, - PlusIcon, - ServerIcon, - SplitIcon, - CodeXmlIcon, - CopyIcon, - EllipsisIcon, - ExternalLinkIcon, - FolderOpenIcon, - GitBranchIcon, - GitCompareArrowsIcon, - GitBranchPlusIcon, - GitPullRequestIcon, - GlobeIcon, - HomeIcon, - LinkIcon, - PlayIcon, - RotateCwIcon, - Settings2Icon, - SquareIcon, - SquareTerminalIcon, - StethoscopeIcon, - Trash2Icon, - UnplugIcon, - WorkflowIcon, - WrenchIcon, - BellOffIcon, - UsersRoundIcon, -} from "lucide-react"; +import { ActivityIcon, ListPlusIcon, PaletteIcon, ArchiveIcon, ArrowUpCircleIcon, ArrowUpRightIcon, BotIcon, CheckIcon, FolderInputIcon, MergeIcon, PackageIcon, PencilIcon, PlusIcon, ServerIcon, SplitIcon, CodeXmlIcon, CopyIcon, EllipsisIcon, ExternalLinkIcon, FolderOpenIcon, GitBranchIcon, GitCompareArrowsIcon, GitBranchPlusIcon, GitPullRequestIcon, GlobeIcon, HomeIcon, LinkIcon, PlayIcon, RotateCwIcon, Settings2Icon, SquareIcon, SquareTerminalIcon, StethoscopeIcon, Trash2Icon, UnplugIcon, WorkflowIcon, WrenchIcon, BellOffIcon, UsersRoundIcon, HistoryIcon } from "lucide-react"; import { type ComponentProps, createContext, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type TouchEvent, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AgentIcon } from "@/components/agent-glyph"; @@ -57,11 +17,11 @@ import { Kbd } from "@/components/ui/kbd"; import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger } from "@/components/ui/menu"; import { Spinner } from "@/components/ui/spinner"; import { toastManager } from "@/components/ui/toast"; -import { agentPresets, openBrowserAt, startSession, stopSession } from "@/lib/actions"; -import { boxApi, type BoxStatus, laptopApi, type Location, type Worktree, type WorktreeService } from "@/lib/api"; +import { agentPresets, openBrowserAt, resumeConversation, startSession, stopSession } from "@/lib/actions"; +import { boxApi, type BoxStatus, type Conversation, laptopApi, type Location, type Worktree, type WorktreeService } from "@/lib/api"; import { portUrl } from "@/lib/browser-url"; import { agentOf, worktreeSessions } from "@/lib/derive"; -import { errorMessage } from "@/lib/format"; +import { ago, errorMessage } from "@/lib/format"; import { plainError } from "@/lib/errors"; import { scheduleRefresh, useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; @@ -373,6 +333,9 @@ export function worktreeActions(box: string, loc: Location, wt: Worktree): Actio }), ), }, + ...(st.boxes[box]?.info?.capabilities?.includes("conversations") + ? [{ type: "sub" as const, label: "Resume a conversation", icon: , items: () => }] + : []), { type: "sub", label: "Run", icon: , items: () => }, item(url ? "Open dev server in browser tab" : "New browser tab", , () => { select(); @@ -504,6 +467,39 @@ function ReviewLinkAsItems({ box, loc, wt }: { box: string; loc: Location; wt: W return item(loginUserLabel(u), , () => void copyReviewLink(box, loc, wt, { as: u.email })))} />; } +// ResumeItems are the worktree's structured conversations whose agent isn't +// running, newest first: each carries on where it left off. +function ResumeItems({ box, loc, wt }: { box: string; loc: Location; wt: Worktree }) { + const [list, setList] = useState(); + const [error, setError] = useState(); + useEffect(() => { + const client = useStore.getState().client; + if (!client) return; + boxApi.conversations(client, box, wt.path).then( + (all) => setList(all.filter((c) => !c.live)), + (e) => { + setList([]); + setError(plainError(e)); + }, + ); + }, [box, wt.path]); + if (!list) { + return ( +
+ Loading… +
+ ); + } + if (!list.length) return
{error ?? "No structured conversations to resume here."}
; + const items: Action[] = list.slice(0, 12).map((c) => + item(`${c.title || "Untitled"} · ${ago(c.updated ?? c.created)}`, slot(), () => { + selectWorktree(refOf(box, loc, wt)); + void resumeConversation(box, c, wsKey(box, wt.path)); + }), + ); + return ; +} + function RunItems({ box, loc, wt }: { box: string; loc: Location; wt: Worktree }) { const [list, setList] = useState(); const [error, setError] = useState(); diff --git a/app/src/components/workspace/pane-state.tsx b/app/src/components/workspace/pane-state.tsx index 367b2436..5f134295 100644 --- a/app/src/components/workspace/pane-state.tsx +++ b/app/src/components/workspace/pane-state.tsx @@ -3,7 +3,7 @@ import { RotateCwIcon, ServerIcon } from "lucide-react"; import { Scene } from "@/components/art/scenes"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; -import { startSession } from "@/lib/actions"; +import { resumeSession, startSession } from "@/lib/actions"; import { agentLabel, restartCommand } from "@/lib/derive"; import { retryLine } from "@/lib/net"; import { tryNow, useAway } from "@/lib/reconnect"; @@ -35,6 +35,8 @@ export function SessionEnded({ box, session, agent, command, wsKey, tab, pane, o const ref = useWorkspaces((s) => s.spaces[wsKey]?.ref); const label = agent ? agentLabel(agent) : "Shell"; const named = useTitleAt(ref?.box, ref?.path); + // A structured agent's conversation carries on, rather than a new one. + const resumable = useStore((st) => !!st.boxes[box]?.sessions?.find((x) => x.name === session)?.switch); const where = ref ? (named ?? (ref.main ? ref.location : ref.worktree)) : undefined; return ( } > - + {resumable ? ( + + ) : ( + + )} diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 5af326b9..cc37a88c 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -24,7 +24,7 @@ import { armDrag, useTabDrag } from "@/components/workspace/tab-drag"; 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 { agentPresets, closePane, openBrowserAt, openPreviewAt, resumeSession, startSession } from "@/lib/actions"; import { boxApi } from "@/lib/api"; import { agentLabel, agentOf, restartCommand, sessionAgent, sessionName, sessionState } from "@/lib/derive"; import { nameFromKey } from "@/lib/groups"; @@ -141,7 +141,11 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare {/* The terminal stays connected underneath, so switching back is instant. */} {c.kind === "terminal" && view === "conversation" && (
- void startSession(restartCommand(c.command) ?? c.agent ?? "", { kind: "replace", tab, pane: pane.id }, c.agent ? agentLabel(c.agent) : "Agent")} onShowTerminal={() => setPaneContent(wsKey, tab, pane.id, { ...c, view: "terminal" })} /> + + useStore.getState().boxes[c.box]?.sessions?.find((x) => x.name === c.session)?.switch + ? void resumeSession(c.box, c.session) + : void startSession(restartCommand(c.command) ?? c.agent ?? "", { kind: "replace", tab, pane: pane.id }, c.agent ? agentLabel(c.agent) : "Agent") + } onShowTerminal={() => setPaneContent(wsKey, tab, pane.id, { ...c, view: "terminal" })} />
)} {c.kind === "browser" && ( diff --git a/app/src/lib/actions.ts b/app/src/lib/actions.ts index 4336b5c4..ab969b1b 100644 --- a/app/src/lib/actions.ts +++ b/app/src/lib/actions.ts @@ -5,7 +5,7 @@ import { serviceKeepsRunning } from "@/components/workspace/service-terminal"; import { toastManager } from "@/components/ui/toast"; import { usePrefs } from "@/lib/prefs"; import { offerAgentHooks } from "@/lib/agent-hooks"; -import { type AgentPreset, boxApi, type Location, type Worktree } from "@/lib/api"; +import { type AgentPreset, boxApi, type Conversation, type Location, type Worktree } from "@/lib/api"; import { agentLabel, agentOf } from "@/lib/derive"; import { errorMessage } from "@/lib/format"; import { plainError } from "@/lib/errors"; @@ -100,6 +100,36 @@ export async function startSession(command: string, target: Target = { kind: "ta } } +// resumeConversation carries a structured agent's conversation on in a tab +// of its worktree: in its own session when that still runs (or only +// ended), else in a new one. +export async function resumeConversation(box: string, conv: Conversation, worktree: string): Promise { + const client = useStore.getState().client; + if (!client) return; + const at = place({ kind: "starting", label: agentLabel("claude") }, { kind: "tab" }, worktree); + if (!at) return; + try { + const s = await boxApi.resumeConversation(client, box, conv.id); + setPaneContent(at.key, at.tab, at.pane, { kind: "terminal", box, session: s.name, agent: "claude" }); + scheduleRefresh(box, ["sessions"]); + return s.name; + } catch (err) { + setPaneContent(at.key, at.tab, at.pane, { kind: "error", message: plainError(err) }); + } +} + +// resumeSession starts a structured agent that ended again, in its pane. +export async function resumeSession(box: string, session: string): Promise { + const client = useStore.getState().client; + if (!client) return; + try { + await boxApi.resumeSession(client, box, session); + scheduleRefresh(box, ["sessions"]); + } catch (err) { + toastManager.add({ type: "error", title: "Couldn't resume it", description: errorMessage(err) }); + } +} + export function openBrowserAt(url = "", target: Target = { kind: "tab" }, worktree?: string) { const wt = worktree ?? targetWorktree(target); if (wt) place({ kind: "browser", url }, target, wt); diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 4ec85acc..523387ae 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -8,6 +8,7 @@ import type { BerthEvent, BoxInfo, BoxRoute, + Conversation, ExecResult, QueuedPrompt, SendResult, @@ -265,6 +266,15 @@ 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)}`), + // A structured agent's conversations, those that worked in path + // ("conversations" capability). + conversations: async (c: Client, box: string, path?: string) => + (await c.box(box, "GET", `conversations${path ? `?path=${encodeURIComponent(path)}` : ""}`)) ?? [], + // Carries a conversation on: in its session, started again if it ended, + // or in a new one when that is gone. + resumeConversation: (c: Client, box: string, id: string) => c.box(box, "POST", `conversations/${encodeURIComponent(id)}/resume`), + // Starts a structured session whose agent ended again, on its own record. + resumeSession: (c: Client, box: string, name: string) => c.box(box, "POST", `sessions/${encodeURIComponent(name)}/resume`), // 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") => diff --git a/design/structured-agents/plan.md b/design/structured-agents/plan.md index 67f4832f..10245163 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) 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. +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) merged; 4b (resume, conversation index) in review. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. ## Why @@ -90,10 +90,10 @@ Exit: a go/no-go note per item; any no-go changes the phases below. - Keep streamed partial text in the log (a killed agent loses it). Stop = interrupt, then SIGTERM, then kill the process tree (SIGKILL leaves Bash children running). -- The SQLite index (conversation id, location/worktree, agent, driver, agent - session id, title, status, cost, timestamps) comes with resume and the - terminal hand-off (phase 4), the first thing that looks conversations up - after their session is gone. +- The index is a meta.json per conversation folder (where it works, its + session, Claude's session id, mode, title, times), listed by scanning the + folder: at a box's scale that needs no database or migrations, and the + folder describes itself. GET /v1/conversations reads it (phase 4b). - Push to the app via the events bus (items in the event, not a "changed" ping); no stat-polling for driver agents. - Attaching to the tmux session shows a readable live render, so `berth attach` keeps working. diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 562b3449..825bfd01 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,12 @@ again. ## Unreleased +- **Labs: a structured Claude agent's conversation can be resumed.** When + one ends, its pane offers *Resume the conversation* rather than a fresh + start, and once its session is closed a worktree's menu lists its + conversations under *Resume a conversation*: each carries on where it left + off, earlier turns and permission mode included. Needs the box upgraded. + - **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`, diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index df4f7ec8..0bb107af 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -259,6 +259,12 @@ own record of the conversation. The box drives it from there: Either way needs the agent to be idle. In a folder Claude Code hasn't seen, its screen first asks whether you trust it. +- **Resume.** A structured agent that ended offers *Resume the conversation* + in its pane, and a worktree's menu lists its conversations whose agents + aren't running under *Resume a conversation*: each carries on, with + Claude Code's `--resume`, in its own record, so the chat shows its earlier + turns. + The agent keeps working while berthd restarts or upgrades, and the record keeps every streamed word even if the agent is killed. The box needs the `driver.claude` capability, and `driver.switch` for the terminal (upgrade diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 9d69bb76..59d3ee8e 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -233,7 +233,14 @@ box allow what it asks; questions and plans still wait), on boxes that list 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 +Boxes that take it list `driver.switch`. A structured conversation outlives +its session: `GET conversations?path=P` lists those that worked in `P`, +newest first (`{ id, agent, location?, dir, session?, live?, +agent_session_id?, mode?, title?, created, updated? }`); `POST +conversations/{id}/resume` carries one on, in its session (started again if +it ended) or a new one, answering the `Session`; `POST +sessions/{name}/resume` starts an ended structured session again on its own +record. Boxes that take them list `conversations`. `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 469f4043..b1f49e86 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -261,6 +261,7 @@ func (b *Box) Mount(s *wire.Server) { b.mountRuns(route) b.mountHistory(route) b.mountAnswer(route) + b.mountConversations(route) b.mountBrowser(route) b.mountNotify(route) b.mountArtifacts(route) @@ -835,6 +836,7 @@ func (b *Box) startStructuredSession(r *http.Request, name, location, dir, prese } data["agent"], data["driver"] = agentFor(sess), DriverClaude b.publish(r, "session.started", data) + noteStarted(l.labels[labelConv], location, dir, sess.Name, l.mode, prompt) b.setUpOnFirstOpen(origin(r), dir) sess = b.enrich(r.Context(), []Session{sess})[0] b.startStructured(origin(r), gateOrigin(r), sess, prompt) diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 67fd7927..8d2ead9b 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -64,6 +64,8 @@ type structuredLaunch struct { command string env []string labels map[string]string + // mode is the permission mode asked for. + mode string } // launchClaude is the command, environment and labels that start the @@ -129,6 +131,7 @@ func (b *Box) launchClaudeIn(p AgentPreset, model, effort, mode, resume, dir, so words = append(words, "--resume", shellQuote(resume)) } return structuredLaunch{ + mode: mode, command: strings.Join(words, " "), env: []string{"BERTH_DRIVER=structured"}, labels: map[string]string{labelDriver: DriverClaude, labelConv: dir, labelSock: sock, labelBypass: bypass}, @@ -405,8 +408,12 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { if len(e.Data) == 0 || json.Unmarshal(e.Data, &l) != nil { return } - if l.SessionID != "" { + if l.SessionID != "" && l.SessionID != c.sessionID { c.sessionID = l.SessionID + if live { + sid, dir := l.SessionID, c.conv + say = append(say, func() { noteConversation(dir, func(m *Conversation) { m.AgentSessionID = sid }) }) + } } helper := l.Parent != nil && *l.Parent != "" switch l.Type { @@ -497,7 +504,9 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { data["status"] = "interrupted" } } - say = append(say, func() { c.event(adapters.Finished, data, nil) }, c.changedConversation) + dir := c.conv + say = append(say, func() { c.event(adapters.Finished, data, nil) }, c.changedConversation, + func() { noteConversation(dir, func(m *Conversation) { m.Updated = time.Now().UTC() }) }) } } @@ -908,6 +917,7 @@ func (d *claudeDriver) SetMode(ctx context.Context, sess Session, mode string) ( v = "1" } d.b.Sessions.tmux(ctx, "set-option", "-t", "="+sess.Name+":", labelBypass, v) + noteConversation(c.conv, func(m *Conversation) { m.Mode = mode }) for _, r := range allow { c.answer(r, true, false, "") } diff --git a/internal/box/claudedriver_test.go b/internal/box/claudedriver_test.go index 344ad541..9fae356a 100644 --- a/internal/box/claudedriver_test.go +++ b/internal/box/claudedriver_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/cosscom/shipyard/internal/agenthost" "github.com/cosscom/shipyard/internal/transcript" ) @@ -483,3 +484,126 @@ func TestAStructuredSessionGoesToItsTerminalAndBack(t *testing.T) { t.Fatalf("the conversation reads %q", order) } } + +func TestAStructuredConversationIsResumedWhenItsAgentEndsAndWhenItsSessionIsGone(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{} + var bx *Box + c, bus := servedBox(t, func(b *Box) { + bx = b + 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", "mode": "bypassPermissions", "prompt": "hello"}, &sess) + names := []string{sess.Name} + t.Cleanup(func() { + for _, n := range names { + call(t, c, "DELETE", "/v1/sessions/"+n, "", nil, nil) + } + }) + 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(name, 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(name string) func() bool { + return func() bool { st, _ := states.State(name); return st.State == "finished" } + } + until("the first reply", said(sess.Name, "echo: hello")) + until("the first turn's end", finished(sess.Name)) + + var convs []Conversation + call(t, c, "GET", "/v1/conversations?path="+repo, "", nil, &convs) + if len(convs) != 1 || !convs[0].Live || convs[0].Session != sess.Name || convs[0].Title != "hello" || + convs[0].AgentSessionID != "00000000-0000-4000-8000-0000000000c1" || convs[0].Mode != "bypassPermissions" { + t.Fatalf("conversations = %+v", convs) + } + id := convs[0].ID + + // Its agent ends (stopped here; a crash or a /exit alike): the session + // starts it again on its own log. + live, err := bx.Sessions.Get(t.Context(), sess.Name) + if err != nil { + t.Fatal(err) + } + if err := agenthost.Stop(t.Context(), live.sock); err != nil { + t.Fatal(err) + } + until("the session's end", func() bool { + var list []Session + call(t, c, "GET", "/v1/sessions", "", nil, &list) + return len(list) == 1 && list[0].Exited + }) + var resumed Session + if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/resume", "", nil, &resumed); status != 200 || resumed.Exited || resumed.Driver != DriverClaude { + t.Fatalf("resume: %d %+v", status, resumed) + } + call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": "again", "when": "now"}, nil) + until("a reply once resumed", said(sess.Name, "echo: again")) + until("its turn's end", finished(sess.Name)) + + // Its session closed altogether: the conversation is found and carried + // on in a new one, its earlier turns with it. + if status := call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil); status != 200 { + t.Fatalf("delete: %d", status) + } + deadline := time.Now().Add(10 * time.Second) + for { + convs = nil + call(t, c, "GET", "/v1/conversations", "", nil, &convs) + if len(convs) == 1 && !convs[0].Live { + break + } + if time.Now().After(deadline) { + var list []Session + call(t, c, "GET", "/v1/sessions", "", nil, &list) + t.Fatalf("the conversation is still %+v; sessions %+v", convs, list) + } + time.Sleep(100 * time.Millisecond) + } + var again Session + if status := call(t, c, "POST", "/v1/conversations/"+id+"/resume", "", nil, &again); status != 200 || again.Driver != DriverClaude || again.Title != "hello" { + t.Fatalf("resume the conversation: %d %+v", status, again) + } + names = append(names, again.Name) + call(t, c, "POST", "/v1/sessions/"+again.Name+"/send", "", map[string]any{"text": "third", "when": "now"}, nil) + until("a reply in the new session", said(again.Name, "echo: third")) + until("the earlier turns in it", said(again.Name, "echo: again")) + var controls Controls + call(t, c, "GET", "/v1/sessions/"+again.Name+"/controls", "", nil, &controls) + if controls.Mode != "bypassPermissions" { + t.Fatalf("it came back in %q", controls.Mode) + } + if status := call(t, c, "POST", "/v1/conversations/nope/resume", "", nil, nil); status != http.StatusNotFound { + t.Fatalf("an unknown conversation: %d", status) + } +} diff --git a/internal/box/conversations.go b/internal/box/conversations.go new file mode 100644 index 00000000..4f233b1a --- /dev/null +++ b/internal/box/conversations.go @@ -0,0 +1,317 @@ +package box + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/integrations/adapters" + "github.com/cosscom/shipyard/internal/statefile" +) + +// A structured agent's conversation outlives its session: its folder under +// ConversationsDir holds its log and meta.json, what it is. A session that +// ended is started again on its own log (POST /v1/sessions/{name}/resume), +// and one that is gone altogether is found again (GET /v1/conversations) +// and resumed in a new session (POST /v1/conversations/{id}/resume), with +// Claude Code's --resume: the same conversation, carried on. + +// Conversation is what a structured conversation is. +type Conversation struct { + ID string `json:"id"` + Agent string `json:"agent"` + // Location and Dir are where it works; Session the session it last ran + // in, and Live that one is running. + Location string `json:"location,omitempty"` + Dir string `json:"dir"` + Session string `json:"session,omitempty"` + Live bool `json:"live,omitempty"` + // AgentSessionID is Claude Code's own name for it; Mode the + // permission mode it ran in ("bypassPermissions": berthd allowed). + AgentSessionID string `json:"agent_session_id,omitempty"` + Mode string `json:"mode,omitempty"` + // Title is its first prompt's first line. + Title string `json:"title,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated,omitzero"` +} + +const metaName = "meta.json" + +func (b *Box) mountConversations(route func(string, func(http.ResponseWriter, *http.Request) error)) { + route("GET /v1/conversations", b.listConversations) + route("POST /v1/conversations/{id}/resume", b.resumeConversation) + route("POST /v1/sessions/{name}/resume", b.resumeSession) +} + +// readConversation reads a conversation's meta.json. +func readConversation(dir string) (Conversation, bool) { + raw, err := os.ReadFile(filepath.Join(dir, metaName)) + if err != nil { + return Conversation{}, false + } + var c Conversation + if json.Unmarshal(raw, &c) != nil { + return Conversation{}, false + } + c.ID = filepath.Base(dir) + return c, true +} + +// noteConversation changes a conversation's meta.json. +func noteConversation(dir string, change func(*Conversation)) { + c, _ := readConversation(dir) + change(&c) + c.ID, c.Live = "", false + if raw, err := json.Marshal(c); err == nil { + statefile.Write(filepath.Join(dir, metaName), raw) + } +} + +// conversationDir is the folder of conversation id, refusing anything that +// isn't one of the box's. +func (b *Box) conversationDir(id string) (string, error) { + if b.ConversationsDir == "" || id == "" || strings.ContainsAny(id, `/\`) || strings.HasPrefix(id, ".") { + return "", httpError{http.StatusNotFound, "no conversation " + strconv.Quote(id)} + } + dir := filepath.Join(b.ConversationsDir, id) + if _, ok := readConversation(dir); !ok { + return "", httpError{http.StatusNotFound, "no conversation " + strconv.Quote(id)} + } + return dir, nil +} + +// listConversations answers GET /v1/conversations?path=P&location=L&limit=N: +// the structured conversations kept on the box, newest first, those that +// worked in P (or in location L, or its worktrees). +func (b *Box) listConversations(w http.ResponseWriter, r *http.Request) error { + out := []Conversation{} + if b.ConversationsDir == "" { + writeJSON(w, out) + return nil + } + q := r.URL.Query() + path, location := q.Get("path"), q.Get("location") + limit, _ := strconv.Atoi(q.Get("limit")) + if limit <= 0 || limit > 200 { + limit = 50 + } + live := map[string]string{} + if sessions, err := b.Sessions.List(r.Context()); err == nil { + for _, s := range sessions { + if s.conv != "" && !s.Exited { + live[s.conv] = s.Name + } + } + } + entries, _ := os.ReadDir(b.ConversationsDir) + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(b.ConversationsDir, e.Name()) + c, ok := readConversation(dir) + if !ok { + continue + } + if path != "" && !sameDir(c.Dir, filepath.Clean(path)) { + continue + } + if location != "" && c.Location != location && !strings.HasPrefix(c.Location, location+"/") { + continue + } + if name, ok := live[dir]; ok { + c.Live, c.Session = true, name + } + out = append(out, c) + } + slices.SortFunc(out, func(a, b Conversation) int { return lastTime(b).Compare(lastTime(a)) }) + if len(out) > limit { + out = out[:limit] + } + writeJSON(w, out) + return nil +} + +func lastTime(c Conversation) time.Time { + if c.Updated.After(c.Created) { + return c.Updated + } + return c.Created +} + +// resumeSession answers POST /v1/sessions/{name}/resume: a structured +// session whose agent ended starts again on its own log, carrying its +// conversation on. +func (b *Box) resumeSession(w http.ResponseWriter, r *http.Request) error { + name := r.PathValue("name") + sess, err := b.Sessions.Get(r.Context(), name) + if err != nil { + return err + } + if sess.conv == "" { + return httpError{http.StatusConflict, name + " isn't a structured session: start its agent again instead"} + } + if !sess.Exited { + return httpError{http.StatusConflict, name + " is still running"} + } + if err := b.before(r, "session.start", map[string]any{"name": name, "location": sess.Location, "path": sess.Dir, "resume": true}); err != nil { + return err + } + if sess.Driver == "" { + // Left in Claude Code's terminal: back to its chat, with what was + // said there. + err = b.toChat(r, sess) + } else { + err = b.resumeInPlace(r, sess, "") + } + if err != nil { + return err + } + b.publish(r, "session.resumed", map[string]any{"name": name}) + sess, err = b.Sessions.Get(r.Context(), name) + if err != nil { + return err + } + writeJSON(w, b.enrich(r.Context(), []Session{sess})[0]) + return nil +} + +// resumeInPlace starts a structured session's host again in its own pane, +// on its log and socket, with --resume (sid, or the one its log names). +func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { + ctx := r.Context() + if sid == "" { + sid = conversationSession(sess.conv) + } + if sid == "" { + return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} + } + 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 + } + noteResumed(sess.conv, sess.Name) + // 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 +} + +// conversationSession is the Claude conversation a structured +// conversation's folder carries on: from its meta, else its log. +func conversationSession(dir string) string { + if c, ok := readConversation(dir); ok && c.AgentSessionID != "" { + return c.AgentSessionID + } + return lastSessionID(filepath.Join(dir, agenthost.LogName)) +} + +// resumeConversation answers POST /v1/conversations/{id}/resume {open?}: +// the session it runs in, started again when it ended, or a new one when +// it is gone. +func (b *Box) resumeConversation(w http.ResponseWriter, r *http.Request) error { + var req struct { + Open string `json:"open,omitempty"` + } + if r.ContentLength > 0 { + if err := decode(r, &req); err != nil { + return err + } + } + dir, err := b.conversationDir(r.PathValue("id")) + if err != nil { + return err + } + conv, _ := readConversation(dir) + sessions, err := b.Sessions.List(r.Context()) + if err != nil { + return err + } + for _, s := range sessions { + if s.conv != dir { + continue + } + if s.Exited { + if s.Driver == "" { + err = b.toChat(r, s) + } else { + err = b.resumeInPlace(r, s, "") + } + if err != nil { + return err + } + if s, err = b.Sessions.Get(r.Context(), s.Name); err != nil { + return err + } + } + s = b.enrich(r.Context(), []Session{s})[0] + b.announceOpen(r, s, req.Open) + writeJSON(w, s) + return nil + } + if st, err := os.Stat(conv.Dir); err != nil || !st.IsDir() { + return httpError{http.StatusGone, "the folder it worked in, " + conv.Dir + ", is gone"} + } + sid := conversationSession(dir) + if sid == "" { + return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} + } + sock, err := hostSocket(filepath.Base(dir)) + if err != nil { + return err + } + p := b.claudePreset(r.Context(), Session{Location: conv.Location}) + l, err := b.launchClaudeIn(p, "", "", conv.Mode, sid, dir, sock) + if err != nil { + return err + } + name := defaultSessionName(firstNonEmpty(conv.Location, filepath.Base(conv.Dir)), p.Command) + sess, err := b.startStructuredSession(r, name, conv.Location, conv.Dir, "claude", l, "") + if err != nil { + return err + } + if conv.Title != "" { + b.nameAfter(r.Context(), sess.Name, conv.Title) + sess.Title = conv.Title + } + b.announceOpen(r, sess, req.Open) + writeJSON(w, sess) + return nil +} + +// noteStarted keeps what a new structured conversation is. +func noteStarted(dir, location, cwd, session, mode, prompt string) { + noteConversation(dir, func(c *Conversation) { + c.Agent, c.Location, c.Dir, c.Session = "claude", location, cwd, session + if mode != "" { + c.Mode = mode + } + if c.Created.IsZero() { + c.Created = time.Now().UTC() + } + if c.Title == "" && strings.TrimSpace(prompt) != "" { + c.Title = adapters.Clip(adapters.Title(prompt), adapters.TitleMax) + } + }) +} + +// noteResumed marks a conversation as running in session now. +func noteResumed(dir, session string) { + noteConversation(dir, func(c *Conversation) { + c.Session, c.Updated = session, time.Now().UTC() + }) +} diff --git a/internal/box/switch.go b/internal/box/switch.go index ee5562d6..dbec759e 100644 --- a/internal/box/switch.go +++ b/internal/box/switch.go @@ -178,21 +178,7 @@ func (b *Box) toChat(r *http.Request, sess Session) error { 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 + return b.resumeInPlace(r, sess, sid) } // modeFor is the mode a structured session goes back to: bypass when diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index 1a23bebf..5b38fc16 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -167,7 +167,9 @@ func (b *Box) Capabilities() []string { // Code driven through its stream (claudedriver.go). // driver.switch: POST .../switch takes a structured session to // Claude Code's terminal and back (switch.go). - caps = append(caps, "driver.claude", "driver.switch") + // conversations: GET /v1/conversations, POST .../{id}/resume and + // POST /v1/sessions/{name}/resume (conversations.go). + caps = append(caps, "driver.claude", "driver.switch", "conversations") } 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 df9370c3..d2813cb5 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -233,6 +233,24 @@ export interface Session { // tool, a short summary of its input (the command, or the file's path; // never what it would write), its reason, and the message it showed. Each // is at most 300 characters. +// A structured agent's conversation, kept on the box past its session +// ("conversations" capability): GET conversations lists them, newest first. +export interface Conversation { + id: string; + agent: string; + location?: string; + dir: string; + // The session it last ran in, and whether that one is running. + session?: string; + live?: boolean; + agent_session_id?: string; + mode?: string; + // Its first prompt's first line. + title?: string; + created: string; + updated?: string; +} + export interface Ask { tool?: string; input?: string;