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
2 changes: 1 addition & 1 deletion app/src/components/workspace/pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export function usePaneView(pane: Leaf): "terminal" | "conversation" | undefined
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";
return d === "claude" || d === "codex" || d === "acp";
});
if (c.kind !== "terminal" || !labs || !runsAgent) return undefined;
return c.view ?? (structured ? "conversation" : fallback);
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" | "codex"; 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
21 changes: 13 additions & 8 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,14 +96,19 @@ const fail = (title: string, err: unknown, box?: string) => {
return false;
};

// 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 } {
// 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" && agent !== "codex") || !p.labs || !p.structuredClaude || !caps.includes(`driver.${agent}`)) return {};
return { driver: agent, 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 @@ -131,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
2 changes: 1 addition & 1 deletion app/src/views/settings/labs-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function LabsSection() {
{labs && (
<SettingsRow
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."
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) and 5 (Codex) in review, stacked. 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
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: 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
Expand Down
13 changes: 10 additions & 3 deletions docs/guides/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,8 @@ aren't listed: the agent reads them from each server once it starts.

## Structured agents

With *Structured agents* on in Settings › Labs, a new Claude Code or Codex
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 Down Expand Up @@ -271,10 +271,17 @@ own record of the conversation. The box drives it from there:
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 (`driver.codex` for Codex), and `driver.switch`
`driver.claude` capability (`driver.codex` for Codex, `driver.acp` for ACP
agents), and `driver.switch`
for the terminal (upgrade
it).

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/app-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ 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"`
(or `driver: "codex"` with `agent: "codex"`, Codex's app server, on boxes
listing `driver.codex`),
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