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
6 changes: 5 additions & 1 deletion app/src/components/workspace/pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,11 @@ export function usePaneView(pane: Leaf): "terminal" | "conversation" | undefined
return !!(s && agentOf(s));
});
// A structured agent has no screen of its own to read: its conversation.
const structured = useStore((st) => c.kind === "terminal" && st.boxes[c.box]?.sessions?.find((x) => x.name === c.session)?.driver === "claude");
const structured = useStore((st) => {
if (c.kind !== "terminal") return false;
const d = st.boxes[c.box]?.sessions?.find((x) => x.name === c.session)?.driver;
return d === "claude" || d === "codex";
});
if (c.kind !== "terminal" || !labs || !runsAgent) return undefined;
return c.view ?? (structured ? "conversation" : fallback);
}
Expand Down
4 changes: 2 additions & 2 deletions app/src/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ export async function startSession(command: string, target: Target = { kind: "ta
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);
const at = place({ kind: "starting", label: agentLabel(conv.agent) }, { 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" });
setPaneContent(at.key, at.tab, at.pane, { kind: "terminal", box, session: s.name, agent: conv.agent });
scheduleRefresh(box, ["sessions"]);
return s.name;
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export const boxApi = {
c.box(box, "DELETE", `locations/${encodeURIComponent(location)}/worktrees/${encodeURIComponent(worktree)}${force ? "?force=1" : ""}`),
// home: in the box user's home folder, tied to no worktree, in place of a
// location (boxes with the "session.home" capability).
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 }) =>
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" | "codex"; 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
Expand Down
6 changes: 3 additions & 3 deletions app/src/lib/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ export interface Prefs {
labsChosen: boolean;
// Labs: how an agent's pane opens, until switched.
agentView: "terminal" | "conversation";
// Labs: new Claude Code agents run structured, driven through their
// stream by berthd rather than typed into, and open as conversations
// (boxes with "driver.claude").
// Labs: new Claude Code and Codex agents run structured, driven through
// their stream or app server by berthd rather than typed into, and open as
// conversations (boxes with "driver.claude", "driver.codex").
structuredClaude: boolean;
// What a structured agent's permissions do: "bypass" has berthd allow
// them (questions and plans still ask), "ask" brings each to you.
Expand Down
11 changes: 6 additions & 5 deletions app/src/lib/start-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ const fail = (title: string, err: unknown, box?: string) => {
return false;
};

// structured is how a Claude agent starts when Labs runs it structured: on
// a box whose berthd drives Claude Code through its stream.
function structured(box: string, agent: string): { driver?: "claude"; mode?: string } {
// structured is how a Claude Code or Codex agent starts when Labs runs it
// structured: on a box whose berthd drives that agent through its stream
// ("driver.claude") or app server ("driver.codex").
function structured(box: string, agent: string): { driver?: "claude" | "codex"; mode?: string } {
const p = usePrefs.getState();
const caps = useStore.getState().boxes[box]?.info?.capabilities ?? [];
if (agent !== "claude" || !p.labs || !p.structuredClaude || !caps.includes("driver.claude")) return {};
return { driver: "claude", mode: p.approvals === "bypass" ? "bypassPermissions" : "default" };
if ((agent !== "claude" && agent !== "codex") || !p.labs || !p.structuredClaude || !caps.includes(`driver.${agent}`)) return {};
return { driver: agent, mode: p.approvals === "bypass" ? "bypassPermissions" : "default" };
}

export async function startWork(d: StartDraft): Promise<boolean> {
Expand Down
4 changes: 2 additions & 2 deletions app/src/views/settings/labs-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export function LabsSection() {
)}
{labs && (
<SettingsRow
label="Structured Claude agents"
description="New Claude Code agents run without their terminal: the box drives them through their stream and keeps its own record. They open as conversations, and their terminal shows what streams by."
label="Structured agents"
description="New Claude Code and Codex agents run without their terminal: the box drives them through their stream (Codex: its app server) and keeps its own record. They open as conversations; Claude Code's own terminal is one switch away."
>
<Switch checked={structured} onCheckedChange={(on) => setPrefs({ structuredClaude: on })} />
</SettingsRow>
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) 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.
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) and 5 (Codex) in review, stacked. The spike's recordings join the repo as test fixtures in phase 3, once reviewed.

## Why

Expand Down
9 changes: 8 additions & 1 deletion docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ again.

## Unreleased

- **Labs: Codex agents can run structured too.** With *Structured agents*
on (Settings › Labs), a new Codex agent
runs as Codex's app server, driven by the box: replies stream into the
chat, commands and file changes to approve come to it as Allow / Always
allow / Deny (or, with Bypass, run without asking), its questions are
answered there, and it can be stopped and resumed. Needs the box upgraded.

- **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
Expand All @@ -24,7 +31,7 @@ again.
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*
- **Labs: structured Claude agents.** Turn on *Structured 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
record of the conversation, so replies stream in as they are written,
Expand Down
14 changes: 11 additions & 3 deletions docs/guides/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,9 @@ box with the `commands` capability: `GET /v1/sessions/{name}/commands`
(cached a minute, capped at 600) and `GET …/files?q=`. MCP servers' prompts
aren't listed: the agent reads them from each server once it starts.

## Structured Claude agents
## Structured agents

With *Structured Claude agents* on in Settings › Labs, a new Claude Code
With *Structured agents* on in Settings › Labs, a new Claude Code or Codex
agent runs without its terminal. The box starts `claude -p` speaking
stream-json under `berthd agent-host`, which keeps the agent running in its
tmux session and writes everything that passes between them to the box's
Expand Down Expand Up @@ -265,9 +265,17 @@ own record of the conversation. The box drives it from there:
Claude Code's `--resume`, in its own record, so the chat shows its earlier
turns.

- **Codex** runs as `codex app-server` under the same host: the box opens its
thread, sends each prompt as a turn with the agent's mode, model and effort,
and brings its command and file-change approvals and its questions to the
chat. Its modes are default (asks to go past its workspace sandbox),
untrusted (asks before untrusted commands), read only, and bypass (no
sandbox, nothing asked). It has no terminal switch.

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
`driver.claude` capability (`driver.codex` for Codex), and `driver.switch`
for the terminal (upgrade
it).

## Zen
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/app-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ and, while it waits, `ask` (what its hooks said it asks for). `driver`
says how the box reaches the agent: `terminal` (its own screen in tmux) for
every agent but a structured one, or `claude` (Claude Code driven through
its stream); boxes that say it list `driver` in `capabilities`. `POST
sessions` and `POST tasks` take `driver: "claude"` with `agent: "claude"`,
sessions` and `POST tasks` take `driver: "claude"` with `agent: "claude"`
(or `driver: "codex"` with `agent: "codex"`, Codex's app server, on boxes
listing `driver.codex`),
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`
Expand Down
42 changes: 31 additions & 11 deletions internal/box/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ type Box struct {
AgentHost []string
claudeOnce sync.Once
claudeDrv *claudeDriver
codexOnce sync.Once
codexDrv *codexDriver
// Hooks, when set, may refuse actions through "before:" hooks.
Hooks *hooks.Runner
// Flows runs the box's and its repositories' automations.
Expand Down Expand Up @@ -707,10 +709,10 @@ func (b *Box) addSession(w http.ResponseWriter, r *http.Request) error {
if !ok {
return badRequest("unknown agent %q", req.Agent)
}
if structured, err := wantStructured(req.Driver); err != nil {
if kind, err := wantStructured(req.Driver); err != nil {
return err
} else if structured {
l, err := b.launchClaude(p, req.Model, req.Effort, req.Mode, "")
} else if kind != "" {
l, err := b.launchStructured(kind, p, req.Model, req.Effort, req.Mode)
if err != nil {
return err
}
Expand Down Expand Up @@ -812,15 +814,33 @@ func (b *Box) startSession(r *http.Request, name, location, dir, command, preset
return sess, nil
}

// wantStructured says whether a request's driver is the structured one.
func wantStructured(driver string) (bool, error) {
// wantStructured is the structured driver a request asks for, or "".
func wantStructured(driver string) (string, error) {
switch driver {
case "", DriverTerminal:
return false, nil
case DriverClaude:
return true, nil
return "", nil
case DriverClaude, DriverCodex:
return driver, nil
}
return "", badRequest("unknown driver %q: terminal, claude or codex", driver)
}

// launchStructured is what starts preset p under a structured driver, in a
// new conversation.
func (b *Box) launchStructured(kind string, p AgentPreset, model, effort, mode string) (structuredLaunch, error) {
if kind == DriverClaude {
return b.launchClaude(p, model, effort, mode, "")
}
if p.ID != "codex" {
return structuredLaunch{}, badRequest("the codex driver runs Codex; %s runs in its terminal", p.Name)
}
dir, sock, err := b.newConversation()
if err != nil {
return structuredLaunch{}, err
}
return false, badRequest("unknown driver %q: terminal or claude", driver)
l, err := b.launchCodexIn(p, mode, dir, sock)
l.model, l.effort = model, effort
return l, err
}

// startStructuredSession starts a structured agent (claudedriver.go) and
Expand All @@ -834,9 +854,9 @@ func (b *Box) startStructuredSession(r *http.Request, name, location, dir, prese
if err != nil {
return Session{}, err
}
data["agent"], data["driver"] = agentFor(sess), DriverClaude
data["agent"], data["driver"] = agentFor(sess), sess.Driver
b.publish(r, "session.started", data)
noteStarted(l.labels[labelConv], location, dir, sess.Name, l.mode, prompt)
noteStarted(l, location, dir, sess.Name, prompt)
b.setUpOnFirstOpen(origin(r), dir)
sess = b.enrich(r.Context(), []Session{sess})[0]
b.startStructured(origin(r), gateOrigin(r), sess, prompt)
Expand Down
68 changes: 51 additions & 17 deletions internal/box/claudedriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ type structuredLaunch struct {
command string
env []string
labels map[string]string
// mode is the permission mode asked for.
mode string
// mode, model and effort are what was asked for; agent the agent.
mode, model, effort, agent string
}

// launchClaude is the command, environment and labels that start the
Expand All @@ -85,12 +85,7 @@ 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)
dir, sock, err := b.newConversation()
if err != nil {
return structuredLaunch{}, err
}
Expand All @@ -107,13 +102,9 @@ func (b *Box) launchClaudeIn(p AgentPreset, model, effort, mode, resume, dir, so
if err != nil {
return structuredLaunch{}, err
}
host := b.AgentHost
if len(host) == 0 {
exe, err := os.Executable()
if err != nil {
return structuredLaunch{}, err
}
host = []string{exe, "agent-host"}
host, err := b.agentHostCommand()
if err != nil {
return structuredLaunch{}, err
}
claudeMode, bypass := mode, "0"
if mode == "bypassPermissions" {
Expand All @@ -131,7 +122,7 @@ func (b *Box) launchClaudeIn(p AgentPreset, model, effort, mode, resume, dir, so
words = append(words, "--resume", shellQuote(resume))
}
return structuredLaunch{
mode: mode,
mode: mode, model: model, effort: effort, agent: "claude",
command: strings.Join(words, " "),
env: []string{"BERTH_DRIVER=structured"},
labels: map[string]string{labelDriver: DriverClaude, labelConv: dir, labelSock: sock, labelBypass: bypass},
Expand All @@ -144,6 +135,32 @@ func ownedByMe(st os.FileInfo) bool {
return ok && int(sys.Uid) == os.Getuid()
}

// agentHostCommand is the command that runs `berthd agent-host`.
func (b *Box) agentHostCommand() ([]string, error) {
if len(b.AgentHost) > 0 {
return b.AgentHost, nil
}
exe, err := os.Executable()
if err != nil {
return nil, err
}
return []string{exe, "agent-host"}, nil
}

// newConversation makes a new conversation's folder and names its socket.
func (b *Box) newConversation() (dir, sock string, err error) {
if b.ConversationsDir == "" {
return "", "", errors.New("this box keeps no conversations")
}
id := newConvID()
dir = filepath.Join(b.ConversationsDir, id)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
sock, err = hostSocket(id)
return dir, sock, err
}

func newConvID() string {
var b [6]byte
rand.Read(b[:])
Expand Down Expand Up @@ -1020,6 +1037,23 @@ func (d *claudeDriver) askChoices(sess Session) []AskChoice {
return c.choices()
}

// follow starts following a structured session's log, for its driver.
func (b *Box) follow(sess Session) {
switch sess.Driver {
case DriverClaude:
b.claude().conv(sess)
case DriverCodex:
b.codex().conv(sess)
}
}

// forgetConversation lets a structured session's conversation go, for
// whichever driver follows it.
func (b *Box) forgetConversation(name string) {
b.claude().forget(name)
b.codex().forget(name)
}

// 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)
Expand All @@ -1039,7 +1073,7 @@ func (b *Box) startStructured(origin, from string, sess Session, prompt string)
go func() {
awaitHost(sess.sock, 30*time.Second)
// Followed from its start, so its first turn is said as it goes.
b.claude().conv(sess)
b.follow(sess)
if strings.TrimSpace(prompt) == "" {
return
}
Expand Down
Loading
Loading