Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 40 additions & 44 deletions app/src/components/sidebar/actions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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: <HistoryIcon />, items: () => <ResumeItems box={box} loc={loc} wt={wt} /> }]
: []),
{ type: "sub", label: "Run", icon: <PlayIcon />, items: () => <RunItems box={box} loc={loc} wt={wt} /> },
item(url ? "Open dev server in browser tab" : "New browser tab", <GlobeIcon />, () => {
select();
Expand Down Expand Up @@ -504,6 +467,39 @@ function ReviewLinkAsItems({ box, loc, wt }: { box: string; loc: Location; wt: W
return <ActionItems items={login.users.map((u) => item(loginUserLabel(u), <span className="size-4" />, () => 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<Conversation[]>();
const [error, setError] = useState<string>();
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 (
<div className="flex items-center gap-2 px-2 py-1.5 text-muted-foreground text-xs">
<Spinner className="size-3" /> Loading…
</div>
);
}
if (!list.length) return <div className="max-w-56 px-2 py-1.5 text-muted-foreground text-xs">{error ?? "No structured conversations to resume here."}</div>;
const items: Action[] = list.slice(0, 12).map((c) =>
item(`${c.title || "Untitled"} · ${ago(c.updated ?? c.created)}`, slot(<AgentIcon agent={c.agent} />), () => {
selectWorktree(refOf(box, loc, wt));
void resumeConversation(box, c, wsKey(box, wt.path));
}),
);
return <ActionItems items={items} />;
}

function RunItems({ box, loc, wt }: { box: string; loc: Location; wt: Worktree }) {
const [list, setList] = useState<WorktreeService[]>();
const [error, setError] = useState<string>();
Expand Down
19 changes: 14 additions & 5 deletions app/src/components/workspace/pane-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<PaneState
Expand All @@ -57,10 +59,17 @@ export function SessionEnded({ box, session, agent, command, wsKey, tab, pane, o
</p>
}
>
<Button size="sm" onClick={() => void startSession(restartCommand(command) ?? "", { kind: "replace", tab, pane }, label)}>
<RotateCwIcon />
Start {agent ? label : "a shell"} again
</Button>
{resumable ? (
<Button size="sm" onClick={() => void resumeSession(box, session)}>
<RotateCwIcon />
Resume the conversation
</Button>
) : (
<Button size="sm" onClick={() => void startSession(restartCommand(command) ?? "", { kind: "replace", tab, pane }, label)}>
<RotateCwIcon />
Start {agent ? label : "a shell"} again
</Button>
)}
<Button size="sm" variant="ghost" onClick={onClose}>
Close pane
</Button>
Expand Down
8 changes: 6 additions & 2 deletions app/src/components/workspace/pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" && (
<div className="absolute inset-0 z-10 flex flex-col">
<ConversationPane box={c.box} session={c.session} agent={c.agent} visible={visible} onStartAgain={() => 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" })} />
<ConversationPane box={c.box} session={c.session} agent={c.agent} visible={visible} onStartAgain={() =>
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" })} />
</div>
)}
{c.kind === "browser" && (
Expand Down
32 changes: 31 additions & 1 deletion app/src/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | undefined> {
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<void> {
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);
Expand Down
10 changes: 10 additions & 0 deletions app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
BerthEvent,
BoxInfo,
BoxRoute,
Conversation,
ExecResult,
QueuedPrompt,
SendResult,
Expand Down Expand Up @@ -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<Session>(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<Conversation[] | null>(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<Session>(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<Session>(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") =>
Expand Down
10 changes: 5 additions & 5 deletions design/structured-agents/plan.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/reference/app-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/box/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions internal/box/claudedriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() }) })
}
}

Expand Down Expand Up @@ -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, "")
}
Expand Down
Loading
Loading