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/conversation/conversation-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
const state = gone ? "exited" : s ? sessionState(s, stats) : undefined;
const [attempt, setAttempt] = useState(0);
const feed = useTranscriptFeed(box, session, s?.dir, visible && !mock && !away, attempt);
const ask = useAsk(box, session, !mock && state === "waiting", s?.state_since);
const ask = useAsk(box, session, !mock && state === "waiting", s?.state_since, s?.ask);
const [answered, setAnswered] = useState<{ at?: string; key: string }>();
// A form of questions the agent asks (Claude Code's AskUserQuestion),
// from its record: the chat draws it whole and the box fills it in. stuck
Expand Down
4 changes: 3 additions & 1 deletion app/src/components/workspace/pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,10 @@ export function usePaneView(pane: Leaf): "terminal" | "conversation" | undefined
const s = st.boxes[c.box]?.sessions?.find((x) => x.name === c.session);
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");
if (c.kind !== "terminal" || !labs || !runsAgent) return undefined;
return c.view ?? fallback;
return c.view ?? (structured ? "conversation" : fallback);
}

// ViewSwitch flips an agent's pane between its terminal and its
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 @@ -262,7 +262,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 }) =>
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)}`),
// Names a session's work; "" clears its title.
Expand Down
9 changes: 9 additions & 0 deletions app/src/lib/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ 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").
structuredClaude: boolean;
// What a structured agent's permissions do: "bypass" has berthd allow
// them (questions and plans still ask), "ask" brings each to you.
approvals: "bypass" | "ask";
// Labs: zen (⌘.): no sidebar or status bar, a switcher for a tab strip,
// agents as conversations.
zen: boolean;
Expand Down Expand Up @@ -96,6 +103,8 @@ const DEFAULTS: Prefs = {
labs: true,
labsChosen: false,
agentView: "terminal",
structuredClaude: false,
approvals: "bypass",
zen: false,
autoUpdateBoxes: false,
chatBackground: DEFAULT_CHAT_BACKGROUND,
Expand Down
12 changes: 11 additions & 1 deletion app/src/lib/start-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { AgentPick, ComposerTarget } from "@/lib/composer";
import { agentLabel } from "@/lib/derive";
import { plainError } from "@/lib/errors";
import { handoff, loop, review } from "@/lib/orchestrate";
import { usePrefs } from "@/lib/prefs";
import { worktreeSlug } from "@/lib/projects";
import { usePrompts } from "@/lib/prompts";
import { boxHasRuns, runs, scheduleRuns } from "@/lib/runs";
Expand Down Expand Up @@ -95,6 +96,15 @@ 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 } {
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" };
}

export async function startWork(d: StartDraft): Promise<boolean> {
const client = useStore.getState().client;
if (!client) return false;
Expand All @@ -120,7 +130,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 };
const how = { agent: pick.agent, prompt: d.text || undefined, model: pick.model || undefined, effort: pick.effort || undefined, ...structured(d.box, 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
18 changes: 14 additions & 4 deletions app/src/lib/transcript-feed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";

import { boxApi, type Client, type QueuedPrompt } from "@/lib/api";
import { type Ask, boxApi, type Client, type QueuedPrompt } from "@/lib/api";
import type { ChatSignals } from "@/lib/chat-controls";
import { noteSignals } from "@/lib/chat-signals";
import { keyOf, offOf, useConversations } from "@/lib/conversation-store";
Expand Down Expand Up @@ -240,11 +240,19 @@ export function useTranscriptFeed(box: string, session: string, dir: string | un
// numbered options, and the line above them as the question; form says it
// shows a form of questions with steps, which no one number answers. A
// menu is drawn a moment after the agent says it waits, so a screen
// without one is read again a few times.
export function useAsk(box: string, session: string, waiting: boolean, since?: string): { detail: string; choices: Choice[]; form?: boolean } | undefined {
// without one is read again a few times. A structured session has no menu
// on a screen: given, its ask's own choices answer instead.
export function useAsk(box: string, session: string, waiting: boolean, since?: string, given?: Ask): { detail: string; choices: Choice[]; form?: boolean } | undefined {
const client = useStore((s) => s.client);
const [ask, setAsk] = useState<{ detail: string; choices: Choice[]; form?: boolean }>();
const offered = waiting && given?.choices?.length ? given : undefined;
const offeredKey = offered ? JSON.stringify(offered) : "";
useEffect(() => {
if (offered) {
const what = offered.input ? `${offered.tool ?? "A tool"}: ${offered.input}` : (offered.tool ?? "");
setAsk({ detail: offered.message || what, choices: offered.choices ?? [] });
return;
}
if (!client || !waiting) {
setAsk(undefined);
return;
Expand Down Expand Up @@ -281,7 +289,9 @@ export function useAsk(box: string, session: string, waiting: boolean, since?: s
alive = false;
window.clearTimeout(timer);
};
}, [client, box, session, waiting, since]);
// offeredKey stands for offered: a new list with the same choices
// reads the same.
}, [client, box, session, waiting, since, offeredKey]);
return ask;
}

Expand Down
6 changes: 3 additions & 3 deletions app/src/views/home/widgets/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ function WaitingRow({ r }: { r: AgentRow }) {
const s = r.session;
const tool = s.ask?.tool;
const question = !tool || QUESTION_TOOLS.test(tool);
// A permission's options are on the agent's screen: read them while the
// row can be seen, as the agent's own pane does.
const ask = useAsk(r.box, s.name, visible && !preview && !question, s.state_since);
// A permission's options are on the agent's screen (or, structured, on
// its ask): read them while the row can be seen, as its own pane does.
const ask = useAsk(r.box, s.name, visible && !preview && !question, s.state_since, s.ask);
const choices = ask && !ask.form ? permissionChoices(ask.choices) : undefined;
const allow = choices?.find((c) => c.label === "Allow");
const deny = choices?.find((c) => c.label === "Deny");
Expand Down
22 changes: 22 additions & 0 deletions app/src/views/settings/labs-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"
export function LabsSection() {
const labs = usePrefs((p) => p.labs);
const agentView = usePrefs((p) => p.agentView);
const structured = usePrefs((p) => p.structuredClaude);
const approvals = usePrefs((p) => p.approvals);
return (
<SettingsPage title="Labs" description="Newer ideas, on by default. They may change or go away.">
<SettingsGroup>
Expand All @@ -25,6 +27,26 @@ export function LabsSection() {
/>
</SettingsRow>
)}
{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."
>
<Switch checked={structured} onCheckedChange={(on) => setPrefs({ structuredClaude: on })} />
</SettingsRow>
)}
{labs && structured && (
<SettingsRow label="Permissions" description="Bypass lets the box allow what a structured agent asks to do; its questions and plans still come to you. Ask brings every permission to you.">
<Segmented
value={approvals}
options={[
{ value: "bypass", label: "Bypass" },
{ value: "ask", label: "Ask" },
]}
onChange={(v) => setPrefs({ approvals: v })}
/>
</SettingsRow>
)}
</SettingsGroup>
</SettingsPage>
);
Expand Down
26 changes: 14 additions & 12 deletions cmd/berthd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,18 +357,20 @@ func serve(b boxHome, args []string) error {
Watcher: watcher,
DaemonChecks: func() []doctor.Check { return daemonChecks(b, ln.Addr().String(), *listen) },
LogDir: filepath.Join(b.dir, "logs"),
Units: &box.Units{Dir: filepath.Join(b.dir, "units")},
Turns: turns,
Hooks: hookRunner,
Flows: box.FlowsAt(userDir, b.dir),
KitsDir: filepath.Join(b.dir, "kits"),
EnvFile: filepath.Join(userDir, "env.json"),
Paused: &box.PauseStore{Path: filepath.Join(b.dir, "paused.json")},
Secrets: b.boxSecrets(),
Socket: b.socket(),
Phone: &box.Phone{Path: filepath.Join(b.dir, "phone.json"), Addr: tailnetAddr, Log: logger},
Guard: &box.Guard{Path: filepath.Join(userDir, "guard.json")},
Invites: &box.Invites{Address: func() string { return pairAddress(b) }, TTL: defaultTTL},
// Structured agents' logs (claudedriver.go).
ConversationsDir: filepath.Join(b.dir, "conversations"),
Units: &box.Units{Dir: filepath.Join(b.dir, "units")},
Turns: turns,
Hooks: hookRunner,
Flows: box.FlowsAt(userDir, b.dir),
KitsDir: filepath.Join(b.dir, "kits"),
EnvFile: filepath.Join(userDir, "env.json"),
Paused: &box.PauseStore{Path: filepath.Join(b.dir, "paused.json")},
Secrets: b.boxSecrets(),
Socket: b.socket(),
Phone: &box.Phone{Path: filepath.Join(b.dir, "phone.json"), Addr: tailnetAddr, Log: logger},
Guard: &box.Guard{Path: filepath.Join(userDir, "guard.json")},
Invites: &box.Invites{Address: func() string { return pairAddress(b) }, TTL: defaultTTL},
Update: &box.SelfUpdate{
Executable: exe,
Fingerprint: id.Fingerprint().String(),
Expand Down
7 changes: 4 additions & 3 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. Phase 1 (driver interface) merged. Phase 2 (agent-host + log) 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) in review. The spike's recordings join the repo as test fixtures in phase 3, once reviewed.

## Why

Expand Down Expand Up @@ -91,8 +91,9 @@ Exit: a go/no-go note per item; any no-go changes the phases below.
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 the Claude driver
(phase 3), the first thing that has those to record.
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.
- 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
10 changes: 10 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ again.

## Unreleased

- **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
record of the conversation, so replies stream in as they are written,
nothing is read off a screen, and an agent keeps working while your
laptop is away or the box's berthd restarts. Permissions come to the chat
as Allow / Always allow / Deny, or with *Permissions: Bypass* (the
default) the box allows them for you; questions and plans always wait for
you. They open as conversations. Needs the box upgraded.

- **Doctor no longer says berthd runs "not as a service" when it does.**
`berthd install` puts your login shell's PATH in the service, and doctor
compared the service with one that had no PATH, so on a Mac it always
Expand Down
24 changes: 24 additions & 0 deletions docs/guides/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,30 @@ 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

With *Structured Claude agents* on in Settings › Labs, a new Claude Code
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:

- **The conversation** is read from that record, and the reply being written
streams into the chat as it is written.
- **Permissions** come to the chat with Allow, Always allow (for the rest of
the session) and Deny. *Permissions: Bypass*, the default, has the box allow
them for you instead; questions the agent asks and its plans always wait
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.

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).

## Zen

⌘. puts everything but the agents away: no sidebar, tab strip or status
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/app-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,13 @@ with), `turn` (its current or last turn), `state_seq` and `fidelity`
(`hooks`, `partial` or `screen`), `queued` (prompts held until it is idle)
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 today; boxes that say it list `driver` in `capabilities`. `exited` is
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"`,
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
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
3 changes: 3 additions & 0 deletions internal/agenthost/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
// is still there to read), or never started.
var ErrNoHost = errors.New("agent-host: no host is running")

// Dial connects to the host on sock: ErrNoHost when none answers.
func Dial(ctx context.Context, sock string) (net.Conn, error) { return dial(ctx, sock) }

func dial(ctx context.Context, sock string) (net.Conn, error) {
var d net.Dialer
c, err := d.DialContext(ctx, "unix", sock)
Expand Down
2 changes: 1 addition & 1 deletion internal/box/agentpaths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestAnNPMInstalledAgentIsOfferedAndStarts(t *testing.T) {
// The session's login shell (sh -l) knows nothing of nvm.
os.WriteFile(filepath.Join(home, ".profile"), []byte("PATH=/usr/bin:/bin:$PATH\n"), 0o644)
ctx := context.Background()
if _, err := s.create(ctx, "agent", "", home, "claude --model opus", "claude", nil, nil); err != nil {
if _, err := s.create(ctx, "agent", "", home, "claude --model opus", "claude", nil, nil, nil); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
Expand Down
Loading
Loading