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
23 changes: 20 additions & 3 deletions app/src/components/workspace/pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 (
<ToggleGroup
Expand All @@ -248,19 +252,32 @@ export function ViewSwitch({ wsKey, tab, pane }: { wsKey: string; tab: string; p
onValueChange={(v) => {
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 });
}}
aria-label="Show the agent as"
className="mr-1"
>
<Tip label="Terminal">
<Tip label={moves === "terminal" ? "Open in Claude Code's terminal" : "Terminal"}>
<ToggleGroupItem value="terminal" aria-label="Terminal" className="h-6! min-w-7! px-1!">
<SquareTerminalIcon className="size-3.5" />
</ToggleGroupItem>
</Tip>
<Tip label="Conversation">
<Tip label={moves === "chat" ? "Back to the chat" : "Conversation"}>
<ToggleGroupItem value="conversation" aria-label="Conversation" className="h-6! min-w-7! px-1!">
<MessagesSquareIcon className="size-3.5" />
</ToggleGroupItem>
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session>(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<Session>(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).
Expand Down
2 changes: 1 addition & 1 deletion 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) 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

Expand Down
7 changes: 7 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions docs/guides/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/reference/app-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/box/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
127 changes: 99 additions & 28 deletions internal/box/claudedriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -325,17 +371,20 @@ 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:
}
}
}()
switch e.Dir {
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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) == "" {
Expand Down
Loading
Loading