}
>
-
+ {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;