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" || d === "acp";
});
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" | "acp"; 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
20 changes: 13 additions & 7 deletions app/src/lib/start-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toastManager } from "@/components/ui/toast";
import { isMock } from "@/hooks/use-berth-connection";
import { agentPresets } from "@/lib/actions";
import { offerAgentHooks } from "@/lib/agent-hooks";
import type { Session, TaskResult, Worktree } from "@/lib/api";
import type { AgentPreset, Session, TaskResult, Worktree } from "@/lib/api";
import { ApiError, boxApi } from "@/lib/api";
import { startBroadcast } from "@/lib/broadcast";
import type { AgentPick, ComposerTarget } from "@/lib/composer";
Expand Down Expand Up @@ -96,13 +96,19 @@ 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 an agent starts when Labs runs it structured: Claude
// Code through its stream ("driver.claude"), Codex through its app server
// ("driver.codex"), and an agent whose preset speaks ACP through that
// ("driver.acp").
function structured(box: string, agent: string, preset?: AgentPreset): { driver?: "claude" | "codex" | "acp"; 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 (!p.labs || !p.structuredClaude) return {};
const driver = agent === "claude" || agent === "codex" ? agent : preset?.acp ? "acp" : undefined;
if (!driver || !caps.includes(`driver.${driver}`)) return {};
// ACP agents have modes of their own: bypass, or theirs as they start.
const mode = p.approvals === "bypass" ? "bypassPermissions" : driver === "acp" ? undefined : "default";
return { driver, mode };
}

export async function startWork(d: StartDraft): Promise<boolean> {
Expand Down Expand Up @@ -130,7 +136,7 @@ export async function startWork(d: StartDraft): Promise<boolean> {
toastManager.add({ type: "success", title: `Created ${wt.name}`, description: `${wt.branch ?? ""} on ${d.box}` });
return true;
}
const how = { agent: pick.agent, prompt: d.text || undefined, model: pick.model || undefined, effort: pick.effort || undefined, ...structured(d.box, pick.agent) };
const how = { agent: pick.agent, prompt: d.text || undefined, model: pick.model || undefined, effort: pick.effort || undefined, ...structured(d.box, pick.agent, presets.find((p) => p.id === pick.agent)) };
if (d.where === "new") {
const name = d.worktree?.name || freeName(d.box, d.location, d.text ? slug(d.text) : randomName());
const res = await client.box<TaskResult>(d.box, "POST", "tasks", {
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, Codex, OpenCode, Gemini CLI and Cursor Agent agents run without their terminal: the box drives them through their own protocol (Claude Code's stream, Codex's app server, ACP for the rest) 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), 5 (Codex) and 6 (ACP) in review, stacked. The spike's recordings join the repo as test fixtures in phase 3, once reviewed.

## Why

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

## Unreleased

- **Labs: OpenCode, Gemini CLI and Cursor Agent run structured too.** With
*Structured agents* on, they start through the Agent Client Protocol: their
replies stream into the chat, their permission requests come to it (or,
with Bypass, the box allows them), Stop cancels their turn, their own modes
are in the chat's mode menu, and they can be resumed. A repository's own
agent can join in by giving its preset an `acp`. Needs the box upgraded.

- **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 +38,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
23 changes: 19 additions & 4 deletions docs/guides/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,10 @@ 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
agent runs without its terminal. The box starts `claude -p` speaking
With *Structured agents* on in Settings › Labs, a new Claude Code, Codex,
OpenCode, Gemini CLI or Cursor Agent 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
own record of the conversation. The box drives it from there:
Expand All @@ -265,9 +265,24 @@ 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.
- **OpenCode, Gemini CLI and Cursor Agent** speak the Agent Client Protocol
(`opencode acp`, `gemini --acp`, `cursor-agent acp`), and so does any agent
whose preset in a repository's config has an [`acp`](/reference/config):
the box opens its session (with its own login, for Cursor Agent), streams
its reply, cancels its turn, switches its own modes, and brings its
permission requests to the chat; *Bypass* has the box allow them.

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, `driver.acp` for ACP
agents), and `driver.switch`
for the terminal (upgrade
it).

## Zen
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/app-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,10 @@ 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`, or `driver: "acp"` with an agent whose preset has
`acp`, on boxes listing `driver.acp`),
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
1 change: 1 addition & 0 deletions docs/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ guide is in [project config](/guides/project-config).
| `name` | string | Shown in the app. |
| `command` | string | What to run. |
| `prompt_flag` | string | How the agent takes a first prompt, when it needs a flag (`--prompt`). Empty: the prompt is the last argument. |
| `acp` | string | What follows `command` to start the agent's Agent Client Protocol server (`acp`, `--acp`). With it the agent can run as a [structured agent](/guides/labs#structured-agents). Given with an `id` and no `command`, it adds that to a built-in agent. |

The built-ins are offered when their command is installed on the box:
`claude`, `codex`, `opencode` (`--prompt`), `gemini` (`-i`) and `cursor`
Expand Down
Loading
Loading