From b75e73a560bb8798713df1166399b32c4760ad52 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 13 Oct 2026 14:09:13 +0100 Subject: [PATCH 1/2] feat: structured codex agents in labs, driven through codex's app server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Labs › Structured agents on (it covered Claude Code alone before), a new Codex agent runs as `codex app-server` under `berthd agent-host`, and the new Codex driver (internal/box/codexdriver.go) speaks its JSON-RPC: - it opens the thread (initialize, thread/start, or thread/resume with the conversation's thread id when it is carried on) - prompts go in as turn/start, carrying the session's mode (approval policy and sandbox), model and effort; interrupt is turn/interrupt - command and file-change approvals come to the chat as Allow / Always allow / Deny, Codex's questions as question cards, and anything else Codex asks gets an error so no turn waits for ever - turn state comes from turn and item notifications, the draft from agentMessage deltas, and berthd's own Codex hooks stay quiet - the conversation is read from the host's log (internal/transcript/codexhost.go), tool details included - resume and the conversation list work for Codex conversations as for Claude's Boxes list driver.codex; sessions and tasks take driver: "codex". Co-Authored-By: Claude Opus 5.5 --- app/src/components/workspace/pane.tsx | 6 +- app/src/lib/actions.ts | 4 +- app/src/lib/api.ts | 2 +- app/src/lib/prefs.ts | 6 +- app/src/lib/start-work.ts | 11 +- app/src/views/settings/labs-section.tsx | 4 +- design/structured-agents/plan.md | 2 +- docs/changelog.mdx | 9 +- docs/guides/labs.mdx | 14 +- docs/reference/app-api.mdx | 4 +- internal/box/api.go | 42 +- internal/box/claudedriver.go | 68 +- internal/box/codexdriver.go | 902 ++++++++++++++++++ internal/box/codexdriver_test.go | 300 ++++++ internal/box/conversations.go | 68 +- internal/box/driver.go | 5 +- internal/box/tasks.go | 23 +- internal/box/upgrade.go | 3 +- internal/transcript/codexhost.go | 238 +++++ internal/transcript/codexhost_test.go | 49 + internal/transcript/detail.go | 2 + internal/transcript/history.go | 2 + internal/transcript/host.go | 5 +- .../testdata/codex_host_approve.jsonl | 25 + packages/plugin-sdk/src/types.ts | 4 +- 25 files changed, 1724 insertions(+), 74 deletions(-) create mode 100644 internal/box/codexdriver.go create mode 100644 internal/box/codexdriver_test.go create mode 100644 internal/transcript/codexhost.go create mode 100644 internal/transcript/codexhost_test.go create mode 100644 internal/transcript/testdata/codex_host_approve.jsonl diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index cc37a88c..5434a3c7 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -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); } diff --git a/app/src/lib/actions.ts b/app/src/lib/actions.ts index ab969b1b..4784b559 100644 --- a/app/src/lib/actions.ts +++ b/app/src/lib/actions.ts @@ -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 { 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) { diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 523387ae..1aeef5c9 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -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(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 diff --git a/app/src/lib/prefs.ts b/app/src/lib/prefs.ts index 8c85aeec..ead6a049 100644 --- a/app/src/lib/prefs.ts +++ b/app/src/lib/prefs.ts @@ -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. diff --git a/app/src/lib/start-work.ts b/app/src/lib/start-work.ts index 6bf524dc..bf4cd3ba 100644 --- a/app/src/lib/start-work.ts +++ b/app/src/lib/start-work.ts @@ -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 { diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx index 38077675..ef29443d 100644 --- a/app/src/views/settings/labs-section.tsx +++ b/app/src/views/settings/labs-section.tsx @@ -29,8 +29,8 @@ export function LabsSection() { )} {labs && ( setPrefs({ structuredClaude: on })} /> diff --git a/design/structured-agents/plan.md b/design/structured-agents/plan.md index 10245163..76f07bc9 100644 --- a/design/structured-agents/plan.md +++ b/design/structured-agents/plan.md @@ -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 diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 825bfd01..57e1dfbc 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -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 @@ -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, diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index 0bb107af..d663b2ad 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -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 @@ -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 diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 59d3ee8e..a9d3751f 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -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` diff --git a/internal/box/api.go b/internal/box/api.go index b1f49e86..573213d6 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -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. @@ -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 } @@ -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 @@ -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) diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 8d2ead9b..06fb0ebf 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -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 @@ -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 } @@ -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" { @@ -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}, @@ -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[:]) @@ -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) @@ -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 } diff --git a/internal/box/codexdriver.go b/internal/box/codexdriver.go new file mode 100644 index 00000000..c76a3521 --- /dev/null +++ b/internal/box/codexdriver.go @@ -0,0 +1,902 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/integrations/adapters" + "github.com/cosscom/shipyard/internal/transcript" +) + +// The Codex driver: `codex app-server` (JSON-RPC over stdio) hosted by +// `berthd agent-host`, as the Claude driver hosts `claude -p`. berthd opens +// its thread (initialize, then thread/start, or thread/resume to carry one +// on), sends each prompt as turn/start with the session's mode, model and +// effort, and answers what Codex asks: commands and file changes to +// approve, and its questions. Everything else it asks gets an error, so a +// turn never waits on something nobody will answer. + +// DriverCodex is a structured Codex session. +const DriverCodex = "codex" + +// codexModes are the modes a structured Codex session offers, each an +// approval policy and a sandbox for its turns. +var codexModes = []string{"default", "untrusted", "readOnly", "bypassPermissions"} + +func codexPolicy(mode string) (approval string, sandbox map[string]any) { + workspace := map[string]any{"type": "workspaceWrite", "writableRoots": []string{}, "networkAccess": false, "excludeTmpdirEnvVar": false, "excludeSlashTmp": false} + switch mode { + case "untrusted": + return "untrusted", workspace + case "readOnly": + return "on-request", map[string]any{"type": "readOnly", "networkAccess": false} + case "bypassPermissions": + return "never", map[string]any{"type": "dangerFullAccess"} + } + return "on-request", workspace +} + +// threadSandbox is a mode's sandbox as thread/start names it. +func threadSandbox(mode string) string { + switch mode { + case "readOnly": + return "read-only" + case "bypassPermissions": + return "danger-full-access" + } + return "workspace-write" +} + +// launchCodexIn is what starts the preset's Codex as an app server under +// an agent host, keeping its log in dir and listening on sock. +func (b *Box) launchCodexIn(p AgentPreset, mode, dir, sock string) (structuredLaunch, error) { + if p.ID != "codex" { + return structuredLaunch{}, badRequest("the codex driver runs Codex; %s runs in its terminal", p.Name) + } + known := mode == "" + for _, m := range codexModes { + known = known || m == mode + } + if !known { + return structuredLaunch{}, badRequest("unknown permission mode %q", mode) + } + host, err := b.agentHostCommand() + if err != nil { + return structuredLaunch{}, err + } + words := make([]string, 0, len(host)+6) + for _, w := range host { + words = append(words, shellQuote(w)) + } + words = append(words, "--dir", shellQuote(dir), "--socket", shellQuote(sock), "--", p.Command, "app-server") + return structuredLaunch{ + mode: mode, agent: "codex", + command: strings.Join(words, " "), + env: []string{"BERTH_DRIVER=structured"}, + labels: map[string]string{labelDriver: DriverCodex, labelConv: dir, labelSock: sock, labelBypass: "0"}, + }, nil +} + +// codexRequest is something Codex asks berthd: an approval, a question. +type codexRequest struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +func (r codexRequest) key() string { return string(r.ID) } + +// codexConv is what berthd knows of one structured Codex session. +type codexConv struct { + b *Box + name string + cwd string + conv string + sock string + log string + + mu sync.Mutex + threadID string + turnID string + mode string + model string + effort string + busy bool + since time.Time + draft strings.Builder + pending []codexRequest + replies map[string]chan json.RawMessage + sent map[string]string // our request ids → their methods + next int + lastSeq int64 + exited bool + initialized bool + changed chan struct{} + following bool + caught chan struct{} + caughtOnce sync.Once + queue []func() + kick chan struct{} + gone bool +} + +type codexDriver struct { + b *Box + mu sync.Mutex + convs map[string]*codexConv +} + +func (b *Box) codex() *codexDriver { + b.codexOnce.Do(func() { b.codexDrv = &codexDriver{b: b, convs: map[string]*codexConv{}} }) + return b.codexDrv +} + +func (d *codexDriver) Kind() string { return DriverCodex } + +func (d *codexDriver) 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() + } +} + +func (d *codexDriver) conv(sess Session) (*codexConv, error) { + if sess.conv == "" || sess.sock == "" { + return nil, errors.New(sess.Name + " has no structured conversation") + } + d.mu.Lock() + c := d.convs[sess.Name] + if c == nil || c.conv != sess.conv { + c = &codexConv{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{}, sent: map[string]string{}, + changed: make(chan struct{}), caught: make(chan struct{}), kick: make(chan struct{}, 1)} + if meta, ok := readConversation(sess.conv); ok { + c.mode, c.model, c.effort = meta.Mode, meta.Model, meta.Effort + } + d.convs[sess.Name] = c + go c.speak() + } + d.mu.Unlock() + c.mu.Lock() + if !c.following { + c.following = true + go c.follow() + } + c.mu.Unlock() + return c, nil +} + +func (c *codexConv) speak() { + for range c.kick { + c.mu.Lock() + q := c.queue + c.queue = nil + c.mu.Unlock() + for _, f := range q { + f() + } + } +} + +func (c *codexConv) signal() { + close(c.changed) + c.changed = make(chan struct{}) +} + +// follow is the Claude driver's: the log read back quietly, then followed +// live, with a host that is starting waited for. +func (c *codexConv) follow() { + agenthost.ReadLog(c.log, 0, func(e agenthost.Entry) error { + c.apply(e, false) + return nil + }) + c.reconcile() + c.caughtOnce.Do(func() { close(c.caught) }) + var noHost time.Time + for { + c.mu.Lock() + from, done := c.lastSeq, c.exited + c.mu.Unlock() + if done { + return + } + err := agenthost.Attach(context.Background(), c.sock, from, func(e agenthost.Entry) error { + c.apply(e, true) + return nil + }) + if !errors.Is(err, agenthost.ErrNoHost) { + noHost = time.Time{} + time.Sleep(100 * time.Millisecond) + continue + } + agenthost.ReadLog(c.log, from, func(e agenthost.Entry) error { + c.apply(e, true) + return nil + }) + if noHost.IsZero() { + noHost = time.Now() + } + if time.Since(noHost) > hostGone { + c.mu.Lock() + c.following = false + c.mu.Unlock() + return + } + time.Sleep(200 * time.Millisecond) + } +} + +// reconcile tells the ledger what the log says now, and opens the thread +// of a host berthd hasn't greeted yet. +func (c *codexConv) reconcile() { + c.mu.Lock() + busy, pending, exited, greeted, lastSeq := c.busy, len(c.pending), c.exited, c.initialized, c.lastSeq + c.mu.Unlock() + if exited { + return + } + if !greeted && lastSeq > 0 { + go c.handshake() + } + if c.b.Turns == nil { + return + } + st, _ := c.b.Turns.State(c.name) + switch { + case pending > 0 && st.State != "waiting": + c.event(adapters.Waiting, map[string]any{"reason": "permission"}, nil) + case busy && pending == 0 && st.State != "running": + c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) + case !busy && (st.State == "running" || st.State == "waiting"): + c.event(adapters.Finished, nil, nil) + } +} + +type codexMessage struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *codexConv) apply(e agenthost.Entry, live bool) { + c.mu.Lock() + defer c.mu.Unlock() + if e.Seq <= c.lastSeq { + return + } + c.lastSeq = e.Seq + var say []func() + defer func() { + c.signal() + 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, c.exited, c.initialized, c.turnID = false, nil, time.Time{}, false, false, "" + if live { + say = append(say, func() { go c.handshake() }) + } + case "exited": + c.exited, c.busy, c.pending = true, false, nil + say = append(say, func() { c.event(adapters.Exited, nil, nil) }) + } + return + case "in": + var m codexMessage + if json.Unmarshal(e.Data, &m) != nil { + return + } + switch { + case m.Method == "initialize": + c.initialized = true + c.sent[string(m.ID)] = m.Method + case m.Method != "" && len(m.ID) > 0: + c.sent[string(m.ID)] = m.Method + case m.Method == "" && len(m.ID) > 0: + // berthd answered one of Codex's requests. + c.drop(string(m.ID)) + } + return + case "out": + default: + return + } + var m codexMessage + if len(e.Data) == 0 || json.Unmarshal(e.Data, &m) != nil { + return + } + if m.Method == "" && len(m.ID) > 0 { + // The answer to one of berthd's requests. + id := string(m.ID) + method := c.sent[id] + delete(c.sent, id) + if method == "thread/start" || method == "thread/resume" { + var r struct { + Thread struct { + ID string `json:"id"` + } `json:"thread"` + } + if json.Unmarshal(m.Result, &r) == nil && r.Thread.ID != "" && r.Thread.ID != c.threadID { + c.threadID = r.Thread.ID + if live { + tid, dir := r.Thread.ID, c.conv + say = append(say, func() { noteConversation(dir, func(x *Conversation) { x.AgentSessionID = tid }) }, func() { c.event(adapters.Ready, nil, nil) }) + } + } + } + if ch := c.replies[id]; ch != nil { + delete(c.replies, id) + if m.Error != nil { + msg, _ := json.Marshal(map[string]string{"error": m.Error.Message}) + ch <- msg + } else { + ch <- m.Result + } + } + return + } + if len(m.ID) > 0 && m.Method != "" { + // Codex asks berthd something. + r := codexRequest{ID: m.ID, Method: m.Method, Params: m.Params} + switch m.Method { + case "item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/tool/requestUserInput": + c.pending = append(c.pending, r) + if live { + ask, reason := r.ask(c.cwd) + say = append(say, func() { + c.event(adapters.Waiting, map[string]any{"reason": reason}, map[string]any{adapters.AskKey: ask}) + }) + } + default: + if live { + say = append(say, func() { + c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": r.ID, "error": map[string]any{"code": -32601, "message": "Shipyard doesn't answer " + r.Method}}) + }) + } + } + return + } + var p struct { + ThreadID string `json:"threadId"` + Turn struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"turn"` + Item struct { + Type string `json:"type"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"item"` + Delta string `json:"delta"` + RequestID json.RawMessage `json:"requestId"` + } + json.Unmarshal(m.Params, &p) + switch m.Method { + case "turn/started": + c.turnID = p.Turn.ID + case "item/started": + if p.Item.Type == "userMessage" && !c.busy { + c.busy, c.since = true, time.UnixMilli(e.At) + var text []string + for _, t := range p.Item.Content { + text = append(text, t.Text) + } + title := adapters.Title(strings.Join(text, "\n")) + say = append(say, func() { + 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}) + }) + } + if p.Item.Type == "agentMessage" { + c.draft.Reset() + } + say = append(say, c.changedConversation) + case "item/agentMessage/delta": + if c.draft.Len() < maxDraft { + c.draft.WriteString(p.Delta) + } + case "item/completed": + if p.Item.Type == "agentMessage" { + c.draft.Reset() + } + say = append(say, c.changedConversation) + case "serverRequest/resolved": + before := len(c.pending) + c.drop(string(p.RequestID)) + if live && len(c.pending) < before { + say = append(say, func() { c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) }) + } + case "turn/completed": + c.busy, c.pending, c.since, c.turnID = false, nil, time.Time{}, "" + c.draft.Reset() + data := map[string]any{} + switch p.Turn.Status { + case "interrupted": + data["status"] = "interrupted" + case "failed": + data["status"] = "error" + } + dir := c.conv + say = append(say, func() { c.event(adapters.Finished, data, nil) }, c.changedConversation, + func() { noteConversation(dir, func(x *Conversation) { x.Updated = time.Now().UTC() }) }) + } +} + +func (c *codexConv) drop(id string) { + for i, r := range c.pending { + if r.key() == id { + c.pending = append(c.pending[:i], c.pending[i+1:]...) + return + } + } +} + +// ask is what a request asks, as a hook's ask is. +func (r codexRequest) ask(cwd string) (map[string]any, string) { + var p struct { + Command string `json:"command"` + Reason string `json:"reason"` + ItemID string `json:"itemId"` + GrantRoot string `json:"grantRoot"` + Questions []struct { + Question string `json:"question"` + } `json:"questions"` + } + json.Unmarshal(r.Params, &p) + switch r.Method { + case "item/commandExecution/requestApproval": + cmd := p.Command + if m := shellWrappedCmd(cmd); m != "" { + cmd = m + } + ask := map[string]any{"tool": "Bash", "input": adapters.Clip(cmd, adapters.AskLimit)} + if p.Reason != "" { + ask["why"] = adapters.Clip(p.Reason, adapters.AskLimit) + } + return ask, "permission" + case "item/fileChange/requestApproval": + ask := map[string]any{"tool": "Edit", "message": "Codex wants to change files"} + if p.Reason != "" { + ask["why"] = adapters.Clip(p.Reason, adapters.AskLimit) + } + return ask, "permission" + } + msg := "Codex asks you something" + if len(p.Questions) > 0 { + msg = adapters.Clip(p.Questions[0].Question, adapters.AskLimit) + } + return map[string]any{"tool": "request_user_input", "message": msg}, "question" +} + +// shellWrappedCmd is the command inside `/bin/zsh -lc '…'`, or "". +func shellWrappedCmd(cmd string) string { + if i := strings.Index(cmd, " -lc '"); i >= 0 && strings.HasSuffix(cmd, "'") { + return strings.ReplaceAll(cmd[i+len(" -lc '"):len(cmd)-1], `'\''`, `'`) + } + return "" +} + +func (c *codexConv) event(typ string, data, private map[string]any) { + c.mu.Lock() + tid := c.threadID + c.mu.Unlock() + if data == nil { + data = map[string]any{} + } + data["session"], data["path"] = c.name, c.cwd + if tid != "" { + data["agent_session_id"], data["session_id"] = tid, tid + } + c.b.agentEvent(context.Background(), "codex", typ, data, private) +} + +func (c *codexConv) changedConversation() { + c.b.Events.Publish(events.Event{Type: events.TranscriptChanged, Box: c.b.Name, Data: map[string]any{"session": c.name, "name": c.name, "path": c.cwd}}) +} + +// write gives Codex one JSON-RPC message. +func (c *codexConv) write(ctx context.Context, v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + 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 + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +// request sends Codex a request and waits for its answer. A request before +// berthd has read the log back waits for that first. +func (c *codexConv) request(ctx context.Context, method string, params any, wait time.Duration) (json.RawMessage, error) { + select { + case <-c.caught: + case <-time.After(10 * time.Second): + return nil, httpError{http.StatusServiceUnavailable, "still reading " + c.name + "'s log back"} + } + c.mu.Lock() + c.next++ + id := "berth-" + strconv.Itoa(c.next) + "-" + newConvID() + ch := make(chan json.RawMessage, 1) + c.replies[`"`+id+`"`] = ch + c.mu.Unlock() + defer func() { + c.mu.Lock() + delete(c.replies, `"`+id+`"`) + c.mu.Unlock() + }() + msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method} + if params != nil { + msg["params"] = params + } + if err := c.write(ctx, msg); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + select { + case r := <-ch: + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(r, &e) == nil && e.Error != "" { + return nil, httpError{http.StatusConflict, "Codex refused: " + e.Error} + } + return r, nil + case <-ctx.Done(): + return nil, httpError{http.StatusGatewayTimeout, "Codex didn't answer " + method} + } +} + +// handshake greets a newly started app server and opens its thread: the +// conversation's own, carried on, when it has one. +func (c *codexConv) handshake() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + c.mu.Lock() + if c.initialized { + c.mu.Unlock() + return + } + c.initialized = true + thread, mode, model, cwd := c.threadID, c.mode, c.model, c.cwd + c.mu.Unlock() + if thread == "" { + if meta, ok := readConversation(c.conv); ok { + thread = meta.AgentSessionID + } + } + if _, err := c.request(ctx, "initialize", map[string]any{ + "clientInfo": map[string]any{"name": "shipyard", "title": "Shipyard", "version": "1"}, + "capabilities": map[string]any{"experimentalApi": true}, + }, 30*time.Second); err != nil { + c.mu.Lock() + c.initialized = false + c.mu.Unlock() + return + } + c.write(ctx, map[string]any{"jsonrpc": "2.0", "method": "initialized"}) + approval, _ := codexPolicy(mode) + params := map[string]any{"cwd": cwd, "approvalPolicy": approval, "sandbox": threadSandbox(mode)} + if model != "" { + params["model"] = model + } + if thread != "" { + params["threadId"] = thread + params["excludeTurns"] = true + if _, err := c.request(ctx, "thread/resume", params, time.Minute); err == nil { + return + } + delete(params, "threadId") + delete(params, "excludeTurns") + } + c.request(ctx, "thread/start", params, time.Minute) +} + +// awaitThread waits for the thread to be open. +func (c *codexConv) awaitThread(ctx context.Context) (string, error) { + deadline := time.After(time.Minute) + for { + c.mu.Lock() + tid, changed := c.threadID, c.changed + c.mu.Unlock() + if tid != "" { + return tid, nil + } + select { + case <-changed: + case <-deadline: + return "", httpError{http.StatusServiceUnavailable, "Codex hasn't opened its thread"} + case <-ctx.Done(): + return "", ctx.Err() + } + } +} + +func (c *codexConv) first() (codexRequest, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.pending) == 0 { + return codexRequest{}, false + } + return c.pending[0], true +} + +// respond answers one of Codex's requests. +func (c *codexConv) respond(r codexRequest, result any) error { + return c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": result}) +} + +// pick answers the oldest approval with the choice its key names. +func (c *codexConv) pick(key string) error { + r, ok := c.first() + if !ok { + return httpError{http.StatusConflict, "Codex isn't asking for anything"} + } + if r.Method == "item/tool/requestUserInput" { + return httpError{http.StatusConflict, "Codex asked questions: answer them in the chat"} + } + switch key { + case "1", "y", "Y": + return c.respond(r, map[string]any{"decision": "accept"}) + case "2": + return c.respond(r, map[string]any{"decision": "acceptForSession"}) + case "3", "n", "N": + return c.respond(r, map[string]any{"decision": "decline"}) + } + return badRequest("pick 1, 2 or 3") +} + +func (c *codexConv) choices() []AskChoice { + r, ok := c.first() + if !ok || r.Method == "item/tool/requestUserInput" { + return nil + } + return permissionChoices +} + +func (d *codexDriver) Send(ctx context.Context, sess Session, text string, enter bool) error { + c, err := d.conv(sess) + if err != nil { + return err + } + if r, waiting := c.first(); waiting { + if isKey(text) && !enter { + return c.pick(text) + } + if strings.TrimSpace(text) != "" && r.Method != "item/tool/requestUserInput" { + // Typed at an approval (sent with force): no, and this instead. + if err := c.respond(r, map[string]any{"decision": "decline"}); err != nil { + return err + } + } + } + if !enter { + return badRequest("a structured session takes whole messages") + } + if strings.TrimSpace(text) == "" { + return nil + } + tid, err := c.awaitThread(ctx) + if err != nil { + return err + } + c.mu.Lock() + mode, model, effort := c.mode, c.model, c.effort + c.mu.Unlock() + approval, sandbox := codexPolicy(mode) + params := map[string]any{"threadId": tid, "input": []any{map[string]any{"type": "text", "text": text, "text_elements": []any{}}}, + "approvalPolicy": approval, "sandboxPolicy": sandbox} + if model != "" { + params["model"] = model + } + if effort != "" { + params["effort"] = effort + } + _, err = c.request(ctx, "turn/start", params, 30*time.Second) + return err +} + +func (d *codexDriver) Interrupt(ctx context.Context, sess Session) (bool, error) { + c, err := d.conv(sess) + if err != nil { + return false, err + } + c.mu.Lock() + tid, turn := c.threadID, c.turnID + c.mu.Unlock() + if turn == "" { + return true, nil + } + if _, err := c.request(ctx, "turn/interrupt", map[string]any{"threadId": tid, "turnId": turn}, 10*time.Second); err != nil { + return false, err + } + deadline := time.After(4 * time.Second) + for { + c.mu.Lock() + busy, changed := c.busy, c.changed + c.mu.Unlock() + if !busy { + return true, nil + } + select { + case <-changed: + case <-deadline: + return false, nil + case <-ctx.Done(): + return false, ctx.Err() + } + } +} + +func (d *codexDriver) Controls(ctx context.Context, sess Session) (Controls, error) { + c, err := d.conv(sess) + if err != nil { + return Controls{Agent: "codex"}, err + } + c.mu.Lock() + defer c.mu.Unlock() + return Controls{Agent: "codex", Mode: firstNonEmpty(c.mode, "default"), Modes: codexModes}, nil +} + +// SetMode takes effect from the next turn, which carries it. +func (d *codexDriver) SetMode(ctx context.Context, sess Session, mode string) (string, error) { + c, err := d.conv(sess) + if err != nil { + return "", err + } + known := false + for _, m := range codexModes { + known = known || m == mode + } + if !known { + return "", badRequest("unknown permission mode %q", mode) + } + c.mu.Lock() + c.mode = mode + c.mu.Unlock() + noteConversation(c.conv, func(m *Conversation) { m.Mode = mode }) + return mode, nil +} + +func (d *codexDriver) Answer(ctx context.Context, sess Session, req AnswerRequest) ([]string, error) { + c, err := d.conv(sess) + if err != nil { + return nil, err + } + c.mu.Lock() + var r codexRequest + found := false + for _, p := range c.pending { + if p.Method != "item/tool/requestUserInput" { + continue + } + var params struct { + ItemID string `json:"itemId"` + } + json.Unmarshal(p.Params, ¶ms) + if firstNonEmpty(params.ItemID, "ask-"+p.key()) == req.Tool { + r, found = p, true + } + } + c.mu.Unlock() + if !found { + return nil, httpError{http.StatusConflict, "Codex isn't waiting for those answers"} + } + var in struct { + Questions []struct { + ID string `json:"id"` + Header string `json:"header"` + Question string `json:"question"` + Options []struct { + Label string `json:"label"` + Description string `json:"description"` + } `json:"options"` + } `json:"questions"` + } + if err := json.Unmarshal(r.Params, &in); err != nil { + return nil, err + } + qs := make([]transcript.Question, len(in.Questions)) + for i, q := range in.Questions { + qs[i] = transcript.Question{Question: q.Question, Header: q.Header, ID: q.ID} + for _, o := range q.Options { + qs[i].Options = append(qs[i].Options, transcript.Option{Label: o.Label, Description: o.Description}) + } + } + shown, err := checkAnswers(qs, req.Answers) + if err != nil { + return nil, err + } + answers := map[string]any{} + for i, q := range qs { + picks := append([]string{}, req.Answers[i].Picks...) + if req.Answers[i].Other != "" { + picks = append(picks, req.Answers[i].Other) + } + answers[q.ID] = map[string]any{"answers": picks} + } + if err := c.respond(r, map[string]any{"answers": answers}); err != nil { + return nil, err + } + return shown, nil +} + +func (d *codexDriver) Conversation(ctx context.Context, sess Session, _ []Session, since int, gen string) (transcript.Result, error) { + if _, err := d.conv(sess); err != nil { + return transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: err.Error()}, nil + } + return reader().Follow(transcript.SourceCodexHost, filepath.Join(sess.conv, agenthost.LogName), sess.Dir, max(since, 0), gen) +} + +func (d *codexDriver) Record(ctx context.Context, sess Session, _ []Session) (string, string) { + return transcript.SourceCodexHost, filepath.Join(sess.conv, agenthost.LogName) +} + +func (d *codexDriver) Draft(ctx context.Context, sess Session) (Draft, error) { + c, err := d.conv(sess) + if err != nil { + return Draft{Agent: "codex"}, nil + } + c.mu.Lock() + defer c.mu.Unlock() + out := Draft{Agent: "codex", Text: c.draft.String()} + if c.busy && len(c.pending) == 0 { + out.Status = &DraftStatus{Word: "Working…"} + if !c.since.IsZero() { + out.Status.Elapsed = time.Since(c.since).Round(time.Second).String() + } + } + return out, nil +} + +func (d *codexDriver) askChoices(sess Session) []AskChoice { + c, err := d.conv(sess) + if err != nil { + return nil + } + return c.choices() +} diff --git a/internal/box/codexdriver_test.go b/internal/box/codexdriver_test.go new file mode 100644 index 00000000..df722b11 --- /dev/null +++ b/internal/box/codexdriver_test.go @@ -0,0 +1,300 @@ +package box + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/transcript" +) + +// fakeCodexAppServer stands in for `codex app-server` with the messages the +// spike recorded (design/structured-agents): it opens a thread, echoes a +// prompt, asks to approve "run the tests" (unless its turn's policy is +// never), asks a question to "ask me", works until interrupted on "work", +// and resumes a thread by its id. +const fakeCodexAppServer = `#!/usr/bin/env python3 +import json, sys +T = "00000000-0000-7000-8000-0000000000d1" +n = [0] +srv = [0] +state = {"turn": None, "thread": None} +def out(d): + print(json.dumps(d), flush=True) +def note(m, p): + p.setdefault("threadId", state["thread"]) + out({"method": m, "params": p, "emittedAtMs": 0}) +def uid(): + n[0] += 1 + return "item-%d" % n[0] +def say(text): + i = uid() + note("item/started", {"item": {"type": "agentMessage", "id": i, "text": ""}}) + note("item/agentMessage/delta", {"itemId": i, "delta": text}) + note("item/completed", {"item": {"type": "agentMessage", "id": i, "text": text}}) +def finish(status="completed"): + note("thread/status/changed", {"status": {"type": "idle"}}) + note("turn/completed", {"turn": {"id": state["turn"], "status": status, "items": []}}) + state["turn"] = None +def ask(method, params): + rid = srv[0] + srv[0] += 1 + out({"id": rid, "method": method, "params": params}) + for line in sys.stdin: + m = json.loads(line) + if m.get("id") == rid and "method" not in m: + note("serverRequest/resolved", {"requestId": rid}) + return m.get("result") or {} + if handle(m) == "interrupted": + return None +def handle(m): + method, rid, p = m.get("method"), m.get("id"), m.get("params") or {} + if method == "initialize": + out({"id": rid, "result": {"userAgent": "fake", "platformFamily": "unix"}}) + elif method == "thread/start": + state["thread"] = T + out({"id": rid, "result": {"thread": {"id": T}}}) + note("thread/started", {"thread": {"id": T}}) + elif method == "thread/resume": + state["thread"] = p["threadId"] + out({"id": rid, "result": {"thread": {"id": p["threadId"]}}}) + elif method == "turn/interrupt": + out({"id": rid, "result": {}}) + if state["turn"]: + finish("interrupted") + return "interrupted" + elif method == "turn/start": + state["turn"] = "turn-" + uid() + out({"id": rid, "result": {"turn": {"id": state["turn"], "status": "inProgress", "items": []}}}) + note("turn/started", {"turn": {"id": state["turn"], "status": "inProgress"}}) + text = p["input"][0]["text"] + note("item/started", {"item": {"type": "userMessage", "id": uid(), "content": [{"type": "text", "text": text}]}}) + if text == "run the tests": + i = uid() + note("item/started", {"item": {"type": "commandExecution", "id": i, "command": "/bin/zsh -lc 'pnpm test'", "commandActions": [{"type": "unknown", "command": "pnpm test"}], "status": "inProgress"}}) + ok = True + if p.get("approvalPolicy") != "never": + r = ask("item/commandExecution/requestApproval", {"turnId": state["turn"], "itemId": i, "command": "/bin/zsh -lc 'pnpm test'", "reason": "Run the tests"}) + ok = bool(r) and r.get("decision") in ("accept", "acceptForSession") + note("item/completed", {"item": {"type": "commandExecution", "id": i, "command": "/bin/zsh -lc 'pnpm test'", "status": "completed" if ok else "declined", "exitCode": 0 if ok else None, "aggregatedOutput": "12 passed" if ok else None}}) + say("The tests pass." if ok else "I didn't run them.") + finish() + elif text == "ask me": + r = ask("item/tool/requestUserInput", {"turnId": state["turn"], "itemId": "ask-item", "questions": [{"id": "drink", "header": "Drink", "question": "Tea or coffee?", "isOther": False, "isSecret": False, "options": [{"label": "Tea", "description": ""}, {"label": "Coffee", "description": ""}]}]}) + say("You chose " + r["answers"]["drink"]["answers"][0] + ".") + finish() + elif text == "work": + i = uid() + note("item/started", {"item": {"type": "agentMessage", "id": i, "text": ""}}) + note("item/agentMessage/delta", {"itemId": i, "delta": "Working through it"}) + for line in sys.stdin: + if handle(json.loads(line)) == "interrupted": + break + else: + say("echo: " + text) + finish() +for line in sys.stdin: + handle(json.loads(line)) +` + +func TestAStructuredCodexSessionIsDrivenThroughItsAppServer(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("the stand-in Codex needs python3") + } + bin := t.TempDir() + os.WriteFile(filepath.Join(bin, "codex"), []byte(fakeCodexAppServer), 0o755) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + exe, _ := os.Executable() + states := &Turns{} + var bx *Box + c, bus := servedBox(t, func(b *Box) { + bx = b + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + var sess Session + if status := call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "codex", "driver": "codex", "prompt": "hello there"}, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + if sess.Driver != DriverCodex || sess.Switch != "" { + t.Fatalf("session = %+v", sess) + } + name := sess.Name + until := func(what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for !ok() { + if time.Now().After(deadline) { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + st, _ := states.State(name) + t.Fatalf("%s never happened: %+v / %+v", what, res.Items, st) + } + time.Sleep(50 * time.Millisecond) + } + } + said := func(text string) func() bool { + return func() bool { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + n := 0 + for _, it := range res.Items { + if strings.Contains(it.Text, text) { + n++ + } + } + return n > 0 + } + } + state := func(want string) func() bool { + return func() bool { st, _ := states.State(name); return st.State == want } + } + + until("the first reply", said("echo: hello there")) + until("the first turn's end", state("finished")) + if st, _ := states.State(name); st.AgentSessionID != "00000000-0000-7000-8000-0000000000d1" { + t.Fatalf("the thread = %q", st.AgentSessionID) + } + + // A command waits for approval, with choices. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "run the tests", "when": "now"}, nil) + until("the approval", state("waiting")) + if st, _ := states.State(name); st.Ask == nil || st.Ask.Tool != "Bash" || st.Ask.Input != "pnpm test" { + t.Fatalf("ask = %+v", st.Ask) + } + var list []Session + call(t, c, "GET", "/v1/sessions", "", nil, &list) + if len(list) != 1 || list[0].Ask == nil || len(list[0].Ask.Choices) != 3 { + t.Fatalf("no choices: %+v", list) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "1", "enter": false, "when": "now", "force": true}, nil) + until("the approved command's reply", said("The tests pass.")) + until("its turn's end", state("finished")) + + // Bypass: the turn says so, and Codex asks nothing. + var mode map[string]string + if status := call(t, c, "POST", "/v1/sessions/"+name+"/mode", "", map[string]string{"mode": "bypassPermissions"}, &mode); status != 200 || mode["mode"] != "bypassPermissions" { + t.Fatalf("mode: %d %v", status, mode) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "run the tests", "when": "now"}, nil) + until("a second run with no approval", func() bool { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + n := 0 + for _, it := range res.Items { + if it.Text == "The tests pass." { + n++ + } + } + return n == 2 + }) + until("its turn's end", state("finished")) + + // Its question, answered from the chat. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "ask me", "when": "now"}, nil) + until("the question", state("waiting")) + var ans AnswerResult + if status := call(t, c, "POST", "/v1/sessions/"+name+"/answer", "", map[string]any{"tool": "ask-item", "answers": []any{map[string]any{"picks": []string{"Coffee"}}}}, &ans); status != 200 { + t.Fatalf("answer: %d", status) + } + until("the answer's reply", said("You chose Coffee.")) + until("its turn's end", state("finished")) + + // The reply being written, then an interrupt. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "work", "when": "now"}, nil) + until("the draft", func() bool { + var d Draft + call(t, c, "GET", "/v1/sessions/"+name+"/draft", "", nil, &d) + return d.Text == "Working through it" + }) + var stopped map[string]bool + call(t, c, "POST", "/v1/sessions/"+name+"/interrupt", "", nil, &stopped) + if !stopped["stopped"] { + t.Fatalf("interrupt: %v", stopped) + } + until("the interrupted turn's end", state("finished")) + + // Its app server ends; resumed, it opens the same thread again. + live, err := bx.Sessions.Get(t.Context(), name) + if err != nil { + t.Fatal(err) + } + if err := agenthost.Stop(t.Context(), live.sock); err != nil { + t.Fatal(err) + } + until("the session's end", func() bool { + s, err := bx.Sessions.Get(t.Context(), name) + return err == nil && s.Exited + }) + if status := call(t, c, "POST", "/v1/sessions/"+name+"/resume", "", nil, nil); status != 200 { + t.Fatalf("resume: %d", status) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "again", "when": "now"}, nil) + until("a reply once resumed", said("echo: again")) + var controls Controls + call(t, c, "GET", "/v1/sessions/"+name+"/controls", "", nil, &controls) + if controls.Agent != "codex" || controls.Mode != "bypassPermissions" { + t.Fatalf("controls = %+v", controls) + } +} + +// TestALiveCodexSessionWorks drives the real Codex: run it with +// BERTH_LIVE_CODEX=1 on a machine logged in to Codex (BERTH_LIVE_CODEX_MODEL +// picks the model). +func TestALiveCodexSessionWorks(t *testing.T) { + if os.Getenv("BERTH_LIVE_CODEX") != "1" { + t.Skip("set BERTH_LIVE_CODEX=1 to drive the real Codex") + } + if _, err := exec.LookPath("codex"); err != nil { + t.Skip("no codex on PATH") + } + exe, _ := os.Executable() + states := &Turns{} + c, bus := servedBox(t, func(b *Box) { + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + req := map[string]any{"location": "acme", "agent": "codex", "driver": "codex", "mode": "bypassPermissions", "effort": "low", "prompt": "Reply with just the word: harbour"} + if m := os.Getenv("BERTH_LIVE_CODEX_MODEL"); m != "" { + req["model"] = m + } + var sess Session + if status := call(t, c, "POST", "/v1/sessions", "", req, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + wait := func(what, word string) { + t.Helper() + deadline := time.Now().Add(3 * time.Minute) + for { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+sess.Name+"/transcript?since=0", "", nil, &res) + st, _ := states.State(sess.Name) + for _, it := range res.Items { + if it.Kind == "text" && strings.Contains(strings.ToLower(it.Text), word) && st.State == "finished" { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("%s never happened: %+v / %+v", what, res.Items, st) + } + time.Sleep(500 * time.Millisecond) + } + } + wait("the first reply", "harbour") + call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": "Run `echo shipyard-live` in the shell, then reply with just its output.", "when": "now"}, nil) + wait("the command's output", "shipyard-live") +} diff --git a/internal/box/conversations.go b/internal/box/conversations.go index 4f233b1a..8d7131c9 100644 --- a/internal/box/conversations.go +++ b/internal/box/conversations.go @@ -1,6 +1,7 @@ package box import ( + "context" "encoding/json" "net/http" "os" @@ -36,6 +37,9 @@ type Conversation struct { // permission mode it ran in ("bypassPermissions": berthd allowed). AgentSessionID string `json:"agent_session_id,omitempty"` Mode string `json:"mode,omitempty"` + // Model and Effort, when asked for: a Codex thread's turns carry them. + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` // Title is its first prompt's first line. Title string `json:"title,omitempty"` Created time.Time `json:"created"` @@ -190,15 +194,17 @@ func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { if sid == "" { sid = conversationSession(sess.conv) } - if sid == "" { + if meta, _ := readConversation(sess.conv); sid == "" && meta.Agent != "codex" { + // Codex opens its thread again itself (codexdriver.go); Claude needs + // its conversation named. return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} } - l, err := b.launchClaudeIn(b.claudePreset(ctx, sess), "", "", modeFor(sess), sid, sess.conv, sess.sock) + l, err := b.relaunch(ctx, sess.Location, sess.conv, sess.sock, sid, modeFor(sess)) if err != nil { return err } l.labels[labelHandoff] = "" - b.claude().forget(sess.Name) + b.forgetConversation(sess.Name) if err := b.respawnAgent(ctx, sess, l.command, l.env, l.labels); err != nil { return err } @@ -211,6 +217,37 @@ func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { return nil } +// relaunch is what starts a structured conversation's agent again, in its +// folder and on sock: Claude Code with --resume sid, or Codex's app +// server, which opens its thread again itself. +func (b *Box) relaunch(ctx context.Context, location, dir, sock, sid, mode string) (structuredLaunch, error) { + meta, _ := readConversation(dir) + if meta.Agent == "codex" { + p, ok := b.presetAt(ctx, location, "codex") + if !ok { + return structuredLaunch{}, badRequest("this box has no Codex preset") + } + return b.launchCodexIn(p, firstNonEmpty(mode, meta.Mode), dir, sock) + } + return b.launchClaudeIn(b.claudePreset(ctx, Session{Location: location}), "", "", mode, sid, dir, sock) +} + +// presetAt is an agent preset as a location has it. +func (b *Box) presetAt(ctx context.Context, location, agent string) (AgentPreset, bool) { + name, _, _ := strings.Cut(location, "/") + if loc, err := b.Locations.Get(ctx, name); err == nil { + if p, ok := presetFor(&loc, agent); ok { + return p, true + } + } + for _, p := range builtinAgents { + if p.ID == agent { + return p, true + } + } + return AgentPreset{}, false +} + // conversationSession is the Claude conversation a structured // conversation's folder carries on: from its meta, else its log. func conversationSession(dir string) string { @@ -267,20 +304,19 @@ func (b *Box) resumeConversation(w http.ResponseWriter, r *http.Request) error { return httpError{http.StatusGone, "the folder it worked in, " + conv.Dir + ", is gone"} } sid := conversationSession(dir) - if sid == "" { + if sid == "" && conv.Agent != "codex" { return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} } sock, err := hostSocket(filepath.Base(dir)) if err != nil { return err } - p := b.claudePreset(r.Context(), Session{Location: conv.Location}) - l, err := b.launchClaudeIn(p, "", "", conv.Mode, sid, dir, sock) + l, err := b.relaunch(r.Context(), conv.Location, dir, sock, sid, conv.Mode) if err != nil { return err } - name := defaultSessionName(firstNonEmpty(conv.Location, filepath.Base(conv.Dir)), p.Command) - sess, err := b.startStructuredSession(r, name, conv.Location, conv.Dir, "claude", l, "") + name := defaultSessionName(firstNonEmpty(conv.Location, filepath.Base(conv.Dir)), firstNonEmpty(conv.Agent, "claude")) + sess, err := b.startStructuredSession(r, name, conv.Location, conv.Dir, firstNonEmpty(conv.Agent, "claude"), l, "") if err != nil { return err } @@ -294,11 +330,17 @@ func (b *Box) resumeConversation(w http.ResponseWriter, r *http.Request) error { } // noteStarted keeps what a new structured conversation is. -func noteStarted(dir, location, cwd, session, mode, prompt string) { - noteConversation(dir, func(c *Conversation) { - c.Agent, c.Location, c.Dir, c.Session = "claude", location, cwd, session - if mode != "" { - c.Mode = mode +func noteStarted(l structuredLaunch, location, cwd, session, prompt string) { + noteConversation(l.labels[labelConv], func(c *Conversation) { + c.Agent, c.Location, c.Dir, c.Session = firstNonEmpty(l.agent, "claude"), location, cwd, session + if l.mode != "" { + c.Mode = l.mode + } + if l.model != "" { + c.Model = l.model + } + if l.effort != "" { + c.Effort = l.effort } if c.Created.IsZero() { c.Created = time.Now().UTC() diff --git a/internal/box/driver.go b/internal/box/driver.go index f0e21fde..6932dc96 100644 --- a/internal/box/driver.go +++ b/internal/box/driver.go @@ -60,8 +60,11 @@ func (b *Box) driverFor(sess Session) Driver { if d := b.Drivers[kind]; d != nil { return d } - if kind == DriverClaude { + switch kind { + case DriverClaude: return b.claude() + case DriverCodex: + return b.codex() } return terminalDriver{b} } diff --git a/internal/box/tasks.go b/internal/box/tasks.go index fe6c696f..7438b3c1 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -299,10 +299,10 @@ func (b *Box) addTask(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 } @@ -402,13 +402,13 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { switch { case s.Driver == DriverClaude: s.Switch = "terminal" - case s.conv != "": + case s.Driver == DriverTerminal && s.conv != "": s.Switch = "chat" } - if s.Driver == DriverClaude && !s.Exited { + if !s.Exited { // Followed from the first time it is seen (after a restart, // the first list), so what it does is said as it does it. - b.claude().conv(*s) + b.follow(*s) } if s.Exited { if b.Turns != nil { @@ -426,8 +426,15 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { s.Queued = st.Queued if s.AgentState == "waiting" { s.Ask = st.Ask - if s.Driver == DriverClaude { - if choices := b.claude().askChoices(*s); len(choices) > 0 { + var choices []AskChoice + switch s.Driver { + case DriverClaude: + choices = b.claude().askChoices(*s) + case DriverCodex: + choices = b.codex().askChoices(*s) + } + { + if len(choices) > 0 { ask := Ask{Choices: choices} if s.Ask != nil { ask = *s.Ask diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index 5b38fc16..5dde0609 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -169,7 +169,8 @@ func (b *Box) Capabilities() []string { // Claude Code's terminal and back (switch.go). // conversations: GET /v1/conversations, POST .../{id}/resume and // POST /v1/sessions/{name}/resume (conversations.go). - caps = append(caps, "driver.claude", "driver.switch", "conversations") + // driver.codex: "driver": "codex", Codex's app server (codexdriver.go). + caps = append(caps, "driver.claude", "driver.codex", "driver.switch", "conversations") } if b.Artifacts != nil { // artifacts: berthd artifact add, a worktree's artifacts and their diff --git a/internal/transcript/codexhost.go b/internal/transcript/codexhost.go new file mode 100644 index 00000000..276da6de --- /dev/null +++ b/internal/transcript/codexhost.go @@ -0,0 +1,238 @@ +package transcript + +import ( + "encoding/json" + "path/filepath" + "regexp" + "strings" +) + +// SourceCodexHost is a structured Codex session's own log, kept by berthd +// agent-host: each line an entry whose data is one JSON-RPC message of +// `codex app-server` (its notifications and requests out, berthd's +// requests and answers in). Its items read as a Codex session file's. +const SourceCodexHost = "codex-host" + +// codexRPC is one JSON-RPC message of the app server. +type codexRPC struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` +} + +// codexThreadItem is the part of an app-server item read here. +type codexThreadItem struct { + Type string `json:"type"` + ID string `json:"id"` + Text string `json:"text"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Command string `json:"command"` + CommandActions []struct { + Type string `json:"type"` + Command string `json:"command"` + Path string `json:"path"` + Name string `json:"name"` + } `json:"commandActions"` + Changes []struct { + Path string `json:"path"` + Diff string `json:"diff"` + } `json:"changes"` + Server string `json:"server"` + Tool string `json:"tool"` +} + +// codexHostParser reads an agent-host log of `codex app-server`. +type codexHostParser struct { + // asks are the questions asked by request id, while unanswered. + asks map[string]string +} + +// shellWrapped is a command as Codex runs it: `/bin/zsh -lc 'cmd'`. +var shellWrapped = regexp.MustCompile(`^\S*sh -l?c '(.*)'$`) + +func (p *codexHostParser) line(c *conv, b []byte) { + var e hostEntry + if json.Unmarshal(b, &e) != nil || len(e.Data) == 0 || e.Data[0] != '{' { + return + } + var m codexRPC + if json.Unmarshal(e.Data, &m) != nil { + return + } + at := e.At + if at != 0 { + c.lineAt = at + } + if e.Dir == "in" { + // berthd's answer to a question Codex asked. + if m.Method == "" && len(m.ID) > 0 && len(m.Result) > 0 { + if item, ok := p.asks[string(m.ID)]; ok { + delete(p.asks, string(m.ID)) + c.codexAnswered(item, string(m.Result)) + } + } + return + } + if e.Dir != "out" { + return + } + switch m.Method { + case "item/started", "item/completed": + var params struct { + Item codexThreadItem `json:"item"` + } + if json.Unmarshal(m.Params, ¶ms) != nil { + return + } + codexHostItem(c, params.Item, m.Method == "item/completed", at) + case "item/tool/requestUserInput": + var params struct { + ItemID string `json:"itemId"` + Questions json.RawMessage `json:"questions"` + } + if json.Unmarshal(m.Params, ¶ms) != nil { + return + } + var qs any + json.Unmarshal(params.Questions, &qs) + id := firstNonEmpty(params.ItemID, "ask-"+string(m.ID)) + if c.asked(id, qs) { + if p.asks == nil { + p.asks = map[string]string{} + } + p.asks[string(m.ID)] = id + } + } +} + +// codexHostItem reads one item as it starts or completes. +func codexHostItem(c *conv, it codexThreadItem, done bool, at int64) { + switch it.Type { + case "userMessage": + if done { + return // read as it started + } + var parts []string + for _, p := range it.Content { + if p.Text != "" { + parts = append(parts, p.Text) + } + } + if text := strings.TrimSpace(strings.Join(parts, "\n")); text != "" { + userText(c, text) + } + case "agentMessage": + if done && strings.TrimSpace(it.Text) != "" { + c.add(Item{Kind: "text", ID: c.id(), Text: clip(strings.TrimSpace(it.Text), maxText)}) + } + case "commandExecution": + if done { + c.result(it.ID, at) + return + } + cmd := it.Command + if m := shellWrapped.FindStringSubmatch(cmd); m != nil { + cmd = strings.ReplaceAll(m[1], `'\''`, `'`) + } + if len(it.CommandActions) == 1 { + a := it.CommandActions[0] + switch a.Type { + case "read": + c.call(it.ID, ToolCall{Verb: "Read", Target: filepath.Base(firstNonEmpty(a.Path, a.Name)), File: true}) + return + case "search", "listFiles": + c.call(it.ID, ToolCall{Verb: "Search", Target: clip(firstLine(firstNonEmpty(a.Command, cmd)), 80)}) + return + } + } + c.call(it.ID, ToolCall{Verb: "Run", Target: clip(firstLine(cmd), 80)}) + case "fileChange": + if !done { + return + } + for _, ch := range it.Changes { + added, removed := diffCounts(ch.Diff) + c.add(Item{Kind: "edit", ID: c.id(), File: rel(c.dir, ch.Path), Added: added, Removed: removed, Tool: it.ID}) + } + case "mcpToolCall", "dynamicToolCall": + if done { + c.result(it.ID, at) + return + } + c.call(it.ID, ToolCall{Verb: "Run", Target: clip(strings.Trim(it.Server+"."+it.Tool, "."), 80)}) + } +} + +// diffCounts counts a unified diff's added and removed lines. +func diffCounts(diff string) (added, removed int) { + for _, l := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"): + case strings.HasPrefix(l, "+"): + added++ + case strings.HasPrefix(l, "-"): + removed++ + } + } + return added, removed +} + +// codexHostDetail reads one of the app server's items for its details: a +// command and its output, or a file change's diff. +func codexHostDetail(line []byte, id, dir string, d *ToolDetail) (call, result bool) { + var e hostEntry + if json.Unmarshal(line, &e) != nil || e.Dir != "out" { + return + } + var m codexRPC + if json.Unmarshal(e.Data, &m) != nil || (m.Method != "item/started" && m.Method != "item/completed") { + return + } + var params struct { + Item struct { + codexThreadItem + AggregatedOutput *string `json:"aggregatedOutput"` + ExitCode *int `json:"exitCode"` + Status string `json:"status"` + } `json:"item"` + } + if json.Unmarshal(m.Params, ¶ms) != nil || params.Item.ID != id { + return + } + it := params.Item + switch it.Type { + case "commandExecution": + call = true + d.Name = "shell" + d.Command = it.Command + if mm := shellWrapped.FindStringSubmatch(it.Command); mm != nil { + d.Command = strings.ReplaceAll(mm[1], `'\''`, `'`) + } + if m.Method == "item/completed" { + result = true + if it.AggregatedOutput != nil { + d.Output = *it.AggregatedOutput + } + d.Error = (it.ExitCode != nil && *it.ExitCode != 0) || it.Status == "declined" || it.Status == "failed" + } + case "fileChange": + call, result = true, m.Method == "item/completed" + d.Name = "apply_patch" + var diffs []string + for _, ch := range it.Changes { + if d.File == "" { + d.File = rel(dir, ch.Path) + } + diffs = append(diffs, ch.Diff) + } + d.New = strings.Join(diffs, "\n") + case "mcpToolCall", "dynamicToolCall": + call, result = true, m.Method == "item/completed" + d.Name = strings.Trim(it.Server+"."+it.Tool, ".") + } + return +} diff --git a/internal/transcript/codexhost_test.go b/internal/transcript/codexhost_test.go new file mode 100644 index 00000000..a8a83c31 --- /dev/null +++ b/internal/transcript/codexhost_test.go @@ -0,0 +1,49 @@ +package transcript + +import ( + "path/filepath" + "strings" + "testing" +) + +// codex_host_approve.jsonl is an agent-host log of `codex app-server` +// (recorded, then cut down and made anonymous): a turn whose command was +// approved, then one whose command was declined. +func TestAStructuredCodexSessionsLogReadsAsItsConversation(t *testing.T) { + path := filepath.Join("testdata", "codex_host_approve.jsonl") + res, err := NewReader().Read(SourceCodexHost, path, "/home/dev/spike/repo-codex", 0) + if err != nil { + t.Fatal(err) + } + var got []string + for _, it := range res.Items { + s := it.Kind + switch it.Kind { + case "text", "user": + s += ":" + it.Text + case "tools": + for _, c := range it.Items { + s += ":" + c.Verb + " " + c.Target + } + } + got = append(got, s) + } + want := []string{ + "user:Run the shell command `touch approved.txt` in the current directory (exactly that, once). Then say DONE.", + "tools:Run touch approved.txt", + "text:DONE", + "user:Now run the shell command `touch declined.txt` (exactly that, once). If it is refused, do not retry; just say REFUSED.", + "text:I’ll run the exact command once.", + "tools:Run touch declined.txt", + "text:REFUSED", + } + if res.Source != "codex" || strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("source %q, items:\n%s", res.Source, strings.Join(got, "\n")) + } + // A command's details: what ran, and that the declined one failed. + declined := res.Items[5].Items[0].ID + d, err := Detail(SourceCodexHost, path, "/home/dev/spike/repo-codex", declined) + if err != nil || d.Command != "touch declined.txt" || !d.Error { + t.Fatalf("detail = %+v, %v", d, err) + } +} diff --git a/internal/transcript/detail.go b/internal/transcript/detail.go index c3d528f0..f2fc7b17 100644 --- a/internal/transcript/detail.go +++ b/internal/transcript/detail.go @@ -91,6 +91,8 @@ func Detail(source, path, dir, id string) (ToolDetail, error) { var call, result bool if source == "codex" { call, result = codexDetail(line, id, dir, &d) + } else if source == SourceCodexHost { + call, result = codexHostDetail(line, id, dir, &d) } else if source == SourceClaudeHost { if inner, ok := unwrapHost(line); ok { call, result = claudeDetail(inner, id, dir, &d) diff --git a/internal/transcript/history.go b/internal/transcript/history.go index 9d7ceb69..512a5be3 100644 --- a/internal/transcript/history.go +++ b/internal/transcript/history.go @@ -33,6 +33,8 @@ func parserFor(source string) parser { return &codexParser{} case SourceClaudeHost: return &hostParser{} + case SourceCodexHost: + return &codexHostParser{} } return &claudeParser{} } diff --git a/internal/transcript/host.go b/internal/transcript/host.go index 90034ecb..4dcc6312 100644 --- a/internal/transcript/host.go +++ b/internal/transcript/host.go @@ -67,8 +67,11 @@ func unwrapHost(b []byte) ([]byte, bool) { // shownSource is the agent a conversation's source is: a structured Claude // session's log is Claude's conversation. func shownSource(source string) string { - if source == SourceClaudeHost { + switch source { + case SourceClaudeHost: return "claude" + case SourceCodexHost: + return "codex" } return source } diff --git a/internal/transcript/testdata/codex_host_approve.jsonl b/internal/transcript/testdata/codex_host_approve.jsonl new file mode 100644 index 00000000..2388788a --- /dev/null +++ b/internal/transcript/testdata/codex_host_approve.jsonl @@ -0,0 +1,25 @@ +{"seq":1,"at":1791900001000,"dir":"out","data":{"method":"item/started","params":{"item":{"type":"userMessage","id":"00000000-0000-4000-8000-000000000008","clientId":null,"content":[{"type":"text","text":"Run the shell command `touch approved.txt` in the current directory (exactly that, once). Then say DONE.","text_elements":[]}]},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","startedAtMs":1791886706699}}} +{"seq":2,"at":1791900002000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"00000000-0000-4000-8000-000000000008","clientId":null,"content":[{"type":"text","text":"Run the shell command `touch approved.txt` in the current directory (exactly that, once). Then say DONE.","text_elements":[]}]},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","completedAtMs":1791886706699}}} +{"seq":3,"at":1791900003000,"dir":"out","data":{"method":"thread/status/changed","params":{"threadId":"00000000-0000-4000-8000-000000000006","status":{"type":"active","activeFlags":["waitingOnApproval"]}}}} +{"seq":4,"at":1791900004000,"dir":"out","data":{"method":"item/started","params":{"item":{"type":"commandExecution","id":"exec-00000000-0000-4000-8000-000000000009","pluginId":null,"scriptPath":null,"command":"/bin/zsh -lc 'touch approved.txt'","cwd":"/home/dev/spike/repo-codex","processId":null,"source":"agent","status":"inProgress","commandActions":[{"type":"unknown","command":"touch approved.txt"}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","startedAtMs":1791886709778}}} +{"seq":5,"at":1791900005000,"dir":"out","data":{"method":"item/commandExecution/requestApproval","id":0,"params":{"kind":"command","threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","itemId":"exec-00000000-0000-4000-8000-000000000009","startedAtMs":1791886709776,"environmentId":"local","command":"/bin/zsh -lc 'touch approved.txt'","cwd":"/home/dev/spike/repo-codex","commandActions":[{"type":"unknown","command":"touch approved.txt"}],"proposedExecpolicyAmendment":["touch","approved.txt"],"availableDecisions":["accept",{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["touch","approved.txt"]}},"cancel"]}}} +{"seq":6,"at":1791900006000,"dir":"out","data":{"method":"serverRequest/resolved","params":{"threadId":"00000000-0000-4000-8000-000000000006","requestId":0}}} +{"seq":7,"at":1791900007000,"dir":"out","data":{"method":"thread/status/changed","params":{"threadId":"00000000-0000-4000-8000-000000000006","status":{"type":"active","activeFlags":[]}}}} +{"seq":8,"at":1791900008000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"commandExecution","id":"exec-00000000-0000-4000-8000-000000000009","pluginId":null,"scriptPath":null,"command":"/bin/zsh -lc 'touch approved.txt'","cwd":"/home/dev/spike/repo-codex","processId":"4677","source":"unifiedExecStartup","status":"completed","commandActions":[{"type":"unknown","command":"touch approved.txt"}],"aggregatedOutput":null,"exitCode":0,"durationMs":0},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","completedAtMs":1791886709862}}} +{"seq":9,"at":1791900009000,"dir":"out","data":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_030885820632c20d016ace0576a80c8191b1f1df4469be307d","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","startedAtMs":1791886710684}}} +{"seq":10,"at":1791900010000,"dir":"out","data":{"method":"item/agentMessage/delta","params":{"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","itemId":"msg_030885820632c20d016ace0576a80c8191b1f1df4469be307d","delta":"DONE"}}} +{"seq":11,"at":1791900011000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_030885820632c20d016ace0576a80c8191b1f1df4469be307d","text":"DONE","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000007","completedAtMs":1791886710686}}} +{"seq":12,"at":1791900012000,"dir":"out","data":{"method":"thread/status/changed","params":{"threadId":"00000000-0000-4000-8000-000000000006","status":{"type":"idle"}}}} +{"seq":13,"at":1791900013000,"dir":"out","data":{"method":"turn/completed","params":{"threadId":"00000000-0000-4000-8000-000000000006","turn":{"id":"00000000-0000-4000-8000-000000000007","items":[{"type":"agentMessage","id":"msg_030885820632c20d016ace0576a80c8191b1f1df4469be307d","text":"DONE","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1791886703,"completedAt":1791886710,"durationMs":7181}}}} +{"seq":14,"at":1791900014000,"dir":"out","data":{"method":"item/started","params":{"item":{"type":"userMessage","id":"00000000-0000-4000-8000-000000000011","clientId":null,"content":[{"type":"text","text":"Now run the shell command `touch declined.txt` (exactly that, once). If it is refused, do not retry; just say REFUSED.","text_elements":[]}]},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","startedAtMs":1791886711009}}} +{"seq":15,"at":1791900015000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"00000000-0000-4000-8000-000000000011","clientId":null,"content":[{"type":"text","text":"Now run the shell command `touch declined.txt` (exactly that, once). If it is refused, do not retry; just say REFUSED.","text_elements":[]}]},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","completedAtMs":1791886711009}}} +{"seq":16,"at":1791900016000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_030885820632c20d016ace0577d35c8191abecdcfe7beb1caa","text":"I\u2019ll run the exact command once.","phase":"commentary","memoryCitation":null,"delivery":null,"questions":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","completedAtMs":1791886711919}}} +{"seq":17,"at":1791900017000,"dir":"out","data":{"method":"thread/status/changed","params":{"threadId":"00000000-0000-4000-8000-000000000006","status":{"type":"active","activeFlags":["waitingOnApproval"]}}}} +{"seq":18,"at":1791900018000,"dir":"out","data":{"method":"item/started","params":{"item":{"type":"commandExecution","id":"exec-00000000-0000-4000-8000-000000000012","pluginId":null,"scriptPath":null,"command":"/bin/zsh -lc 'touch declined.txt'","cwd":"/home/dev/spike/repo-codex","processId":null,"source":"agent","status":"inProgress","commandActions":[{"type":"unknown","command":"touch declined.txt"}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","startedAtMs":1791886712388}}} +{"seq":19,"at":1791900019000,"dir":"out","data":{"method":"item/commandExecution/requestApproval","id":1,"params":{"kind":"command","threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","itemId":"exec-00000000-0000-4000-8000-000000000012","startedAtMs":1791886712388,"environmentId":"local","command":"/bin/zsh -lc 'touch declined.txt'","cwd":"/home/dev/spike/repo-codex","commandActions":[{"type":"unknown","command":"touch declined.txt"}],"proposedExecpolicyAmendment":["touch","declined.txt"],"availableDecisions":["accept",{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["touch","declined.txt"]}},"cancel"]}}} +{"seq":20,"at":1791900020000,"dir":"out","data":{"method":"serverRequest/resolved","params":{"threadId":"00000000-0000-4000-8000-000000000006","requestId":1}}} +{"seq":21,"at":1791900021000,"dir":"out","data":{"method":"thread/status/changed","params":{"threadId":"00000000-0000-4000-8000-000000000006","status":{"type":"active","activeFlags":[]}}}} +{"seq":22,"at":1791900022000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"commandExecution","id":"exec-00000000-0000-4000-8000-000000000012","pluginId":null,"scriptPath":null,"command":"/bin/zsh -lc 'touch declined.txt'","cwd":"/home/dev/spike/repo-codex","processId":null,"source":"agent","status":"declined","commandActions":[{"type":"unknown","command":"touch declined.txt"}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","completedAtMs":1791886712389}}} +{"seq":23,"at":1791900023000,"dir":"out","data":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_030885820632c20d016ace0579465881919877c52e4059e321","text":"REFUSED","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"00000000-0000-4000-8000-000000000006","turnId":"00000000-0000-4000-8000-000000000010","completedAtMs":1791886713311}}} +{"seq":24,"at":1791900024000,"dir":"out","data":{"method":"thread/status/changed","params":{"threadId":"00000000-0000-4000-8000-000000000006","status":{"type":"idle"}}}} +{"seq":25,"at":1791900025000,"dir":"out","data":{"method":"turn/completed","params":{"threadId":"00000000-0000-4000-8000-000000000006","turn":{"id":"00000000-0000-4000-8000-000000000010","items":[{"type":"agentMessage","id":"msg_030885820632c20d016ace0579465881919877c52e4059e321","text":"REFUSED","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1791886710,"completedAt":1791886713,"durationMs":2645}}}} diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index d2813cb5..20187506 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -215,7 +215,7 @@ export interface Session { // How the box reaches the agent ("driver" capability): "terminal", its // own screen in tmux, or "claude", Claude Code driven through its stream // ("driver.claude" capability). - driver?: "terminal" | "claude" | string; + driver?: "terminal" | "claude" | "codex" | string; // Where a structured session can switch ("driver.switch" capability): // "terminal", to Claude Code's own screen, or "chat", back from it. POST // sessions/{name}/switch with { to } switches it. @@ -478,7 +478,7 @@ export interface TaskRequest { // Code structured ("driver.claude" capability); mode is a structured // agent's permission mode to start in ("bypassPermissions": berthd allows // what it asks, questions and plans aside). - driver?: "terminal" | "claude"; + driver?: "terminal" | "claude" | "codex"; mode?: string; } From d7f96d59c99da7f4e8a80093328ee3718066215f Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 13 Oct 2026 14:22:09 +0100 Subject: [PATCH 2/2] feat: opencode, gemini cli and cursor agent run structured too, through the agent client protocol A generic ACP driver (internal/box/acpdriver.go) runs any agent whose preset says how it speaks the Agent Client Protocol (AgentPreset.ACP: opencode "acp", gemini "--acp", cursor-agent "acp"; a repository's own agent can have one) under `berthd agent-host`: - it opens the session: initialize, then session/resume or session/load with the conversation's id when it is carried on, else session/new, calling authenticate first when the agent asks for it (Cursor Agent) - prompts are session/prompt, whose answer is the turn's end; Stop is session/cancel - the agent's own modes (its mode config option, or session/set_mode) are offered with bypass beside them - permission requests come to the chat as Allow / Always allow / Deny, picked by option kind since each agent names them its own way; bypass has berthd allow them, and opencode is started asking so that it does - what else it asks of its client gets an error; streamed chunks make the draft and, gathered, the reply in the conversation (internal/transcript/acphost.go), with a tool's label updated once the agent says what it runs Boxes list driver.acp; sessions and tasks take driver: "acp". The Labs switch starts these agents structured too. Co-Authored-By: Claude Opus 5.5 --- app/src/components/workspace/pane.tsx | 2 +- app/src/lib/api.ts | 2 +- app/src/lib/start-work.ts | 21 +- app/src/views/settings/labs-section.tsx | 2 +- design/structured-agents/plan.md | 2 +- docs/changelog.mdx | 7 + docs/guides/labs.mdx | 13 +- docs/reference/app-api.mdx | 3 +- docs/reference/config.mdx | 1 + internal/box/acpdriver.go | 961 ++++++++++++++++++ internal/box/acpdriver_test.go | 271 +++++ internal/box/api.go | 15 +- internal/box/claudedriver.go | 3 + internal/box/conversations.go | 15 +- internal/box/driver.go | 2 + internal/box/tasks.go | 16 +- internal/box/upgrade.go | 4 +- internal/transcript/acphost.go | 171 ++++ internal/transcript/acphost_test.go | 40 + internal/transcript/history.go | 2 + internal/transcript/host.go | 2 + .../testdata/acp_host_opencode.jsonl | 35 + internal/transcript/transcript.go | 15 + packages/plugin-sdk/src/types.ts | 7 +- 24 files changed, 1584 insertions(+), 28 deletions(-) create mode 100644 internal/box/acpdriver.go create mode 100644 internal/box/acpdriver_test.go create mode 100644 internal/transcript/acphost.go create mode 100644 internal/transcript/acphost_test.go create mode 100644 internal/transcript/testdata/acp_host_opencode.jsonl diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 5434a3c7..6b12756f 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -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); diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 1aeef5c9..5f681fad 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -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(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 diff --git a/app/src/lib/start-work.ts b/app/src/lib/start-work.ts index bf4cd3ba..2eaa6ff7 100644 --- a/app/src/lib/start-work.ts +++ b/app/src/lib/start-work.ts @@ -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"; @@ -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 { @@ -131,7 +136,7 @@ export async function startWork(d: StartDraft): Promise { 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(d.box, "POST", "tasks", { diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx index ef29443d..cb2424d9 100644 --- a/app/src/views/settings/labs-section.tsx +++ b/app/src/views/settings/labs-section.tsx @@ -30,7 +30,7 @@ export function LabsSection() { {labs && ( setPrefs({ structuredClaude: on })} /> diff --git a/design/structured-agents/plan.md b/design/structured-agents/plan.md index 76f07bc9..458d17a8 100644 --- a/design/structured-agents/plan.md +++ b/design/structured-agents/plan.md @@ -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 diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 57e1dfbc..e800cfe1 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -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 diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index d663b2ad..d5f1a8b8 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -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: @@ -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). diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index a9d3751f..c625c63c 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -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` diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 848ed96d..9ac60aaa 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -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` diff --git a/internal/box/acpdriver.go b/internal/box/acpdriver.go new file mode 100644 index 00000000..81d1ee9a --- /dev/null +++ b/internal/box/acpdriver.go @@ -0,0 +1,961 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/integrations/adapters" + "github.com/cosscom/shipyard/internal/transcript" +) + +// The ACP driver: any agent that speaks the Agent Client Protocol +// (opencode, Gemini CLI, Cursor Agent, or a repository's own agent whose +// preset says how: AgentPreset.ACP), hosted by `berthd agent-host`. berthd +// opens its session (authenticating first when the agent asks for it), +// sends prompts, cancels turns, switches the agent's own modes, and brings +// its permission requests to the chat, picking among their options by kind, +// since each agent names them its own way. What else an agent asks of its +// client (files, terminals, its own extensions) gets an error: berthd +// offers none of those, so the agent uses its own tools. + +// DriverACP is a structured session of an ACP agent. +const DriverACP = "acp" + +// acpEnv is what an agent's ACP server needs to put its permissions to +// berthd: opencode allows everything unless told to ask. +var acpEnv = map[string][]string{ + "opencode": {`OPENCODE_CONFIG_CONTENT={"permission":{"bash":"ask","edit":"ask","webfetch":"ask"}}`}, +} + +// launchACPIn is what starts preset p's ACP server under an agent host. +func (b *Box) launchACPIn(p AgentPreset, mode, dir, sock string) (structuredLaunch, error) { + if p.ACP == "" { + return structuredLaunch{}, badRequest("berth doesn't know how to run %s over ACP: give its preset an acp", p.Name) + } + host, err := b.agentHostCommand() + if err != nil { + return structuredLaunch{}, err + } + words := make([]string, 0, len(host)+6) + for _, w := range host { + words = append(words, shellQuote(w)) + } + words = append(words, "--dir", shellQuote(dir), "--socket", shellQuote(sock), "--", p.Command, p.ACP) + bypass := "0" + if mode == "bypassPermissions" { + bypass = "1" + } + return structuredLaunch{ + mode: mode, agent: p.ID, + command: strings.Join(words, " "), + env: append([]string{"BERTH_DRIVER=structured"}, acpEnv[p.ID]...), + labels: map[string]string{labelDriver: DriverACP, labelConv: dir, labelSock: sock, labelBypass: bypass}, + }, nil +} + +// acpRequest is a permission an ACP agent asks for. +type acpRequest struct { + ID json.RawMessage + ToolCall struct { + Title string `json:"title"` + Kind string `json:"kind"` + RawInput struct { + Command string `json:"command"` + FilePath string `json:"filePath"` + Path string `json:"path"` + } `json:"rawInput"` + } `json:"toolCall"` + Options []struct { + OptionID string `json:"optionId"` + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"options"` +} + +// option is the id of the option of kind, or "". +func (r acpRequest) option(kinds ...string) string { + for _, k := range kinds { + for _, o := range r.Options { + if o.Kind == k { + return o.OptionID + } + } + } + return "" +} + +type acpConv struct { + b *Box + name string + agent string + cwd string + conv string + sock string + log string + + mu sync.Mutex + sessionID string + mode string // the agent's own mode + modes []string + modeConfig string // its mode's config option id, when it has one + canResume bool + canLoad bool + authMethod string + bypass bool + busy bool + since time.Time + draft strings.Builder + pending []acpRequest + replies map[string]chan json.RawMessage + sent map[string]string + prompts map[string]bool + next int + lastSeq int64 + exited bool + initialized bool + changed chan struct{} + following bool + caught chan struct{} + caughtOnce sync.Once + queue []func() + kick chan struct{} + gone bool +} + +type acpDriver struct { + b *Box + mu sync.Mutex + convs map[string]*acpConv +} + +func (b *Box) acp() *acpDriver { + b.acpOnce.Do(func() { b.acpDrv = &acpDriver{b: b, convs: map[string]*acpConv{}} }) + return b.acpDrv +} + +func (d *acpDriver) Kind() string { return DriverACP } + +func (d *acpDriver) 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() + } +} + +func (d *acpDriver) conv(sess Session) (*acpConv, error) { + if sess.conv == "" || sess.sock == "" { + return nil, errors.New(sess.Name + " has no structured conversation") + } + d.mu.Lock() + c := d.convs[sess.Name] + if c == nil || c.conv != sess.conv { + c = &acpConv{b: d.b, name: sess.Name, agent: agentFor(sess), cwd: sess.Dir, conv: sess.conv, sock: sess.sock, + log: filepath.Join(sess.conv, agenthost.LogName), replies: map[string]chan json.RawMessage{}, sent: map[string]string{}, + prompts: map[string]bool{}, changed: make(chan struct{}), caught: make(chan struct{}), kick: make(chan struct{}, 1), bypass: sess.bypass} + if meta, ok := readConversation(sess.conv); ok && c.agent == "" { + c.agent = meta.Agent + } + d.convs[sess.Name] = c + go c.speak() + } + d.mu.Unlock() + c.mu.Lock() + if !c.following { + c.following = true + go c.follow() + } + c.mu.Unlock() + return c, nil +} + +func (c *acpConv) speak() { + for range c.kick { + c.mu.Lock() + q := c.queue + c.queue = nil + c.mu.Unlock() + for _, f := range q { + f() + } + } +} + +func (c *acpConv) signal() { + close(c.changed) + c.changed = make(chan struct{}) +} + +func (c *acpConv) follow() { + agenthost.ReadLog(c.log, 0, func(e agenthost.Entry) error { + c.apply(e, false) + return nil + }) + c.reconcile() + c.caughtOnce.Do(func() { close(c.caught) }) + var noHost time.Time + for { + c.mu.Lock() + from, done := c.lastSeq, c.exited + c.mu.Unlock() + if done { + return + } + err := agenthost.Attach(context.Background(), c.sock, from, func(e agenthost.Entry) error { + c.apply(e, true) + return nil + }) + if !errors.Is(err, agenthost.ErrNoHost) { + noHost = time.Time{} + time.Sleep(100 * time.Millisecond) + continue + } + agenthost.ReadLog(c.log, from, func(e agenthost.Entry) error { + c.apply(e, true) + return nil + }) + if noHost.IsZero() { + noHost = time.Now() + } + if time.Since(noHost) > hostGone { + c.mu.Lock() + c.following = false + c.mu.Unlock() + return + } + time.Sleep(200 * time.Millisecond) + } +} + +func (c *acpConv) reconcile() { + c.mu.Lock() + busy, pending, exited, greeted, lastSeq, bypass := c.busy, append([]acpRequest(nil), c.pending...), c.exited, c.initialized, c.lastSeq, c.bypass + c.mu.Unlock() + if exited { + return + } + if !greeted && lastSeq > 0 { + go c.handshake() + } + if bypass { + for _, r := range pending { + go c.allow(r, false) + } + pending = nil + } + if c.b.Turns == nil { + return + } + st, _ := c.b.Turns.State(c.name) + switch { + case len(pending) > 0 && st.State != "waiting": + c.event(adapters.Waiting, map[string]any{"reason": "permission"}, map[string]any{adapters.AskKey: pending[0].ask()}) + case busy && len(pending) == 0 && st.State != "running": + c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) + case !busy && (st.State == "running" || st.State == "waiting"): + c.event(adapters.Finished, nil, nil) + } +} + +func (c *acpConv) apply(e agenthost.Entry, live bool) { + c.mu.Lock() + defer c.mu.Unlock() + if e.Seq <= c.lastSeq { + return + } + c.lastSeq = e.Seq + var say []func() + defer func() { + c.signal() + 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, c.exited, c.initialized = false, nil, time.Time{}, false, false + c.prompts = map[string]bool{} + if live { + say = append(say, func() { go c.handshake() }) + } + case "exited": + c.exited, c.busy, c.pending = true, false, nil + say = append(say, func() { c.event(adapters.Exited, nil, nil) }) + } + return + case "in": + var m codexMessage + if json.Unmarshal(e.Data, &m) != nil { + return + } + switch { + case m.Method == "initialize": + c.initialized = true + c.sent[string(m.ID)] = m.Method + case m.Method == "session/prompt": + c.sent[string(m.ID)] = m.Method + c.prompts[string(m.ID)] = true + if !c.busy { + c.busy, c.since = true, time.UnixMilli(e.At) + c.draft.Reset() + var p struct { + Prompt []struct { + Text string `json:"text"` + } `json:"prompt"` + } + json.Unmarshal(m.Params, &p) + var text []string + for _, b := range p.Prompt { + text = append(text, b.Text) + } + title := adapters.Title(strings.Join(text, "\n")) + say = append(say, func() { + 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}) + }, c.changedConversation) + } + case m.Method != "" && len(m.ID) > 0: + c.sent[string(m.ID)] = m.Method + case m.Method == "" && len(m.ID) > 0: + c.drop(string(m.ID)) + } + return + case "out": + default: + return + } + var m codexMessage + if len(e.Data) == 0 || json.Unmarshal(e.Data, &m) != nil { + return + } + if m.Method == "" && len(m.ID) > 0 { + id := string(m.ID) + method := c.sent[id] + delete(c.sent, id) + switch method { + case "initialize": + var r struct { + AgentCapabilities struct { + LoadSession bool `json:"loadSession"` + SessionCapabilities struct { + Resume json.RawMessage `json:"resume"` + } `json:"sessionCapabilities"` + } `json:"agentCapabilities"` + AuthMethods []struct { + ID string `json:"id"` + } `json:"authMethods"` + } + if json.Unmarshal(m.Result, &r) == nil { + c.canLoad = r.AgentCapabilities.LoadSession + c.canResume = len(r.AgentCapabilities.SessionCapabilities.Resume) > 0 && string(r.AgentCapabilities.SessionCapabilities.Resume) != "null" + if len(r.AuthMethods) > 0 { + c.authMethod = r.AuthMethods[0].ID + } + } + case "session/new", "session/load", "session/resume": + c.readSession(m.Result) + var r struct { + SessionID string `json:"sessionId"` + } + json.Unmarshal(m.Result, &r) + if r.SessionID != "" { + c.sessionID = r.SessionID + } + if live && m.Error == nil && c.sessionID != "" { + sid, dir := c.sessionID, c.conv + say = append(say, func() { noteConversation(dir, func(x *Conversation) { x.AgentSessionID = sid }) }, func() { c.event(adapters.Ready, nil, nil) }) + } + case "session/set_config_option", "session/set_mode": + c.readSession(m.Result) + case "session/prompt": + delete(c.prompts, id) + if len(c.prompts) == 0 { + c.busy, c.pending, c.since = false, nil, time.Time{} + c.draft.Reset() + data := map[string]any{} + var r struct { + StopReason string `json:"stopReason"` + } + json.Unmarshal(m.Result, &r) + switch { + case m.Error != nil: + data["status"] = "error" + case r.StopReason == "cancelled": + data["status"] = "interrupted" + } + dir := c.conv + say = append(say, func() { c.event(adapters.Finished, data, nil) }, c.changedConversation, + func() { noteConversation(dir, func(x *Conversation) { x.Updated = time.Now().UTC() }) }) + } + } + if ch := c.replies[id]; ch != nil { + delete(c.replies, id) + if m.Error != nil { + msg, _ := json.Marshal(map[string]string{"error": m.Error.Message}) + ch <- msg + } else { + ch <- m.Result + } + } + return + } + if len(m.ID) > 0 && m.Method != "" { + if m.Method == "session/request_permission" { + var r acpRequest + if json.Unmarshal(m.Params, &r) == nil { + r.ID = m.ID + if live && c.bypass { + say = append(say, func() { c.allow(r, false) }) + return + } + c.pending = append(c.pending, r) + if live { + ask := r.ask() + say = append(say, func() { + c.event(adapters.Waiting, map[string]any{"reason": "permission"}, map[string]any{adapters.AskKey: ask}) + }) + } + } + return + } + if live { + id, method := m.ID, m.Method + say = append(say, func() { + c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32601, "message": "Shipyard doesn't answer " + method}}) + }) + } + return + } + if m.Method == "session/update" { + var p struct { + Update struct { + SessionUpdate string `json:"sessionUpdate"` + Content struct { + Text string `json:"text"` + } `json:"content"` + CurrentModeID string `json:"currentModeId"` + } `json:"update"` + } + json.Unmarshal(m.Params, &p) + switch p.Update.SessionUpdate { + case "agent_message_chunk": + if c.busy && c.draft.Len() < maxDraft { + c.draft.WriteString(p.Update.Content.Text) + } + case "tool_call": + c.draft.Reset() + say = append(say, c.changedConversation) + case "tool_call_update": + say = append(say, c.changedConversation) + case "current_mode_update": + if p.Update.CurrentModeID != "" { + c.mode = p.Update.CurrentModeID + } + } + } +} + +// readSession reads a session's modes from session/new (or a mode change's +// answer): its modes, or its mode config option. Called with c.mu held. +func (c *acpConv) readSession(raw json.RawMessage) { + var r struct { + Modes *struct { + CurrentModeID string `json:"currentModeId"` + AvailableModes []struct { + ID string `json:"id"` + } `json:"availableModes"` + } `json:"modes"` + ConfigOptions []struct { + ID string `json:"id"` + Category string `json:"category"` + CurrentValue string `json:"currentValue"` + Options []struct { + Value string `json:"value"` + } `json:"options"` + } `json:"configOptions"` + } + if json.Unmarshal(raw, &r) != nil { + return + } + for _, o := range r.ConfigOptions { + if o.Category != "mode" { + continue + } + c.modeConfig, c.mode, c.modes = o.ID, o.CurrentValue, nil + for _, v := range o.Options { + c.modes = append(c.modes, v.Value) + } + return + } + if r.Modes != nil { + c.mode, c.modes = r.Modes.CurrentModeID, nil + for _, m := range r.Modes.AvailableModes { + c.modes = append(c.modes, m.ID) + } + } +} + +func (c *acpConv) drop(id string) { + for i, r := range c.pending { + if string(r.ID) == id { + c.pending = append(c.pending[:i], c.pending[i+1:]...) + return + } + } +} + +// ask is what a permission asks, as a hook's ask is. +func (r acpRequest) ask() map[string]any { + t := r.ToolCall + tool := map[string]string{"execute": "Bash", "edit": "Edit", "delete": "Edit", "move": "Edit", "read": "Read", "fetch": "WebFetch"}[t.Kind] + input := firstNonEmpty(t.RawInput.Command, firstNonEmpty(t.RawInput.FilePath, t.RawInput.Path)) + ask := map[string]any{"tool": firstNonEmpty(tool, adapters.Clip(t.Title, adapters.AskLimit))} + if input != "" { + ask["input"] = adapters.Clip(input, adapters.AskLimit) + } else if t.Title != "" { + ask["message"] = adapters.Clip(t.Title, adapters.AskLimit) + } + return ask +} + +func (c *acpConv) event(typ string, data, private map[string]any) { + c.mu.Lock() + sid := c.sessionID + c.mu.Unlock() + if data == nil { + data = map[string]any{} + } + data["session"], data["path"] = c.name, c.cwd + if sid != "" { + data["agent_session_id"], data["session_id"] = sid, sid + } + c.b.agentEvent(context.Background(), c.agent, typ, data, private) +} + +func (c *acpConv) changedConversation() { + c.b.Events.Publish(events.Event{Type: events.TranscriptChanged, Box: c.b.Name, Data: map[string]any{"session": c.name, "name": c.name, "path": c.cwd}}) +} + +func (c *acpConv) write(ctx context.Context, v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + 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 + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +// call sends the agent a request; with wait 0 it doesn't wait for the +// answer (a prompt's comes at its turn's end), and returns its id. +func (c *acpConv) call(ctx context.Context, method string, params any, wait time.Duration) (json.RawMessage, error) { + select { + case <-c.caught: + case <-time.After(10 * time.Second): + return nil, httpError{http.StatusServiceUnavailable, "still reading " + c.name + "'s log back"} + } + c.mu.Lock() + c.next++ + id := "berth-" + strconv.Itoa(c.next) + "-" + newConvID() + ch := make(chan json.RawMessage, 1) + if wait > 0 { + c.replies[`"`+id+`"`] = ch + } + c.mu.Unlock() + defer func() { + c.mu.Lock() + delete(c.replies, `"`+id+`"`) + c.mu.Unlock() + }() + if err := c.write(ctx, map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { + return nil, err + } + if wait == 0 { + return nil, nil + } + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + select { + case r := <-ch: + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(r, &e) == nil && e.Error != "" { + return nil, httpError{http.StatusConflict, e.Error} + } + return r, nil + case <-ctx.Done(): + return nil, httpError{http.StatusGatewayTimeout, "the agent didn't answer " + method} + } +} + +// handshake greets a newly started agent and opens its session: the +// conversation's own, carried on, when it has one and the agent can. +func (c *acpConv) handshake() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + c.mu.Lock() + if c.initialized { + c.mu.Unlock() + return + } + c.initialized = true + sid, cwd := c.sessionID, c.cwd + c.mu.Unlock() + meta, _ := readConversation(c.conv) + if sid == "" { + sid = meta.AgentSessionID + } + if _, err := c.call(ctx, "initialize", map[string]any{ + "protocolVersion": 1, + "clientCapabilities": map[string]any{"fs": map[string]any{"readTextFile": false, "writeTextFile": false}, "terminal": false}, + "clientInfo": map[string]any{"name": "shipyard", "version": "1"}, + }, 60*time.Second); err != nil { + c.mu.Lock() + c.initialized = false + c.mu.Unlock() + return + } + open := func(method string, params map[string]any) error { + _, err := c.call(ctx, method, params, time.Minute) + c.mu.Lock() + auth := c.authMethod + c.mu.Unlock() + if err != nil && auth != "" && strings.Contains(strings.ToLower(err.Error()), "auth") { + // It uses its own login, once asked to (Cursor Agent). + if _, aerr := c.call(ctx, "authenticate", map[string]any{"methodId": auth}, time.Minute); aerr == nil { + _, err = c.call(ctx, method, params, time.Minute) + } + } + return err + } + base := map[string]any{"cwd": cwd, "mcpServers": []any{}} + c.mu.Lock() + canResume, canLoad := c.canResume, c.canLoad + c.mu.Unlock() + opened := false + if sid != "" && (canResume || canLoad) { + method := "session/load" + if canResume { + method = "session/resume" + } + params := map[string]any{"sessionId": sid} + for k, v := range base { + params[k] = v + } + c.mu.Lock() + c.sessionID = sid + c.mu.Unlock() + opened = open(method, params) == nil + if !opened { + c.mu.Lock() + c.sessionID = "" + c.mu.Unlock() + } + } + if !opened { + open("session/new", base) + } + if meta.Model != "" { + c.mu.Lock() + s := c.sessionID + c.mu.Unlock() + c.call(ctx, "session/set_config_option", map[string]any{"sessionId": s, "configId": "model", "value": meta.Model}, 30*time.Second) + } + if mode := meta.Mode; mode != "" && mode != "bypassPermissions" { + c.setMode(ctx, mode) + } +} + +func (c *acpConv) awaitSession(ctx context.Context) (string, error) { + deadline := time.After(time.Minute) + for { + c.mu.Lock() + sid, changed := c.sessionID, c.changed + c.mu.Unlock() + if sid != "" { + return sid, nil + } + select { + case <-changed: + case <-deadline: + return "", httpError{http.StatusServiceUnavailable, "the agent hasn't opened its session"} + case <-ctx.Done(): + return "", ctx.Err() + } + } +} + +func (c *acpConv) first() (acpRequest, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.pending) == 0 { + return acpRequest{}, false + } + return c.pending[0], true +} + +func (c *acpConv) choose(r acpRequest, optionID string) error { + result := map[string]any{"outcome": map[string]any{"outcome": "selected", "optionId": optionID}} + if optionID == "" { + result = map[string]any{"outcome": map[string]any{"outcome": "cancelled"}} + } + err := c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": result}) + if err == nil { + c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) + } + return err +} + +// allow says yes to a permission, for this time or always. +func (c *acpConv) allow(r acpRequest, always bool) error { + if always { + return c.choose(r, r.option("allow_always", "allow_once")) + } + return c.choose(r, r.option("allow_once", "allow_always")) +} + +func (c *acpConv) pick(key string) error { + r, ok := c.first() + if !ok { + return httpError{http.StatusConflict, "the agent isn't asking for anything"} + } + switch key { + case "1", "y", "Y": + return c.allow(r, false) + case "2": + return c.allow(r, true) + case "3", "n", "N": + return c.choose(r, r.option("reject_once", "reject_always")) + } + return badRequest("pick 1, 2 or 3") +} + +func (c *acpConv) choices() []AskChoice { + r, ok := c.first() + if !ok { + return nil + } + var out []AskChoice + if r.option("allow_once") != "" { + out = append(out, AskChoice{Key: "1", Label: "Yes"}) + } + if r.option("allow_always") != "" { + out = append(out, AskChoice{Key: "2", Label: "Yes, and don't ask again this session"}) + } + if r.option("reject_once", "reject_always") != "" { + out = append(out, AskChoice{Key: "3", Label: "No"}) + } + return out +} + +func (c *acpConv) setMode(ctx context.Context, mode string) error { + sid, err := c.awaitSession(ctx) + if err != nil { + return err + } + c.mu.Lock() + config := c.modeConfig + c.mu.Unlock() + if config != "" { + _, err = c.call(ctx, "session/set_config_option", map[string]any{"sessionId": sid, "configId": config, "value": mode}, 30*time.Second) + } else { + _, err = c.call(ctx, "session/set_mode", map[string]any{"sessionId": sid, "modeId": mode}, 30*time.Second) + } + if err == nil { + c.mu.Lock() + c.mode = mode + c.mu.Unlock() + } + return err +} + +func (d *acpDriver) Send(ctx context.Context, sess Session, text string, enter bool) error { + c, err := d.conv(sess) + if err != nil { + return err + } + if r, waiting := c.first(); waiting { + if isKey(text) && !enter { + return c.pick(text) + } + if strings.TrimSpace(text) != "" { + if err := c.choose(r, r.option("reject_once", "reject_always")); err != nil { + return err + } + } + } + if !enter { + return badRequest("a structured session takes whole messages") + } + if strings.TrimSpace(text) == "" { + return nil + } + sid, err := c.awaitSession(ctx) + if err != nil { + return err + } + _, err = c.call(ctx, "session/prompt", map[string]any{"sessionId": sid, "prompt": []any{map[string]any{"type": "text", "text": text}}}, 0) + return err +} + +func (d *acpDriver) Interrupt(ctx context.Context, sess Session) (bool, error) { + c, err := d.conv(sess) + if err != nil { + return false, err + } + c.mu.Lock() + sid, busy := c.sessionID, c.busy + c.mu.Unlock() + if !busy || sid == "" { + return true, nil + } + if err := c.write(ctx, map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]any{"sessionId": sid}}); err != nil { + return false, err + } + deadline := time.After(4 * time.Second) + for { + c.mu.Lock() + busy, changed := c.busy, c.changed + c.mu.Unlock() + if !busy { + return true, nil + } + select { + case <-changed: + case <-deadline: + return false, nil + case <-ctx.Done(): + return false, ctx.Err() + } + } +} + +func (d *acpDriver) Controls(ctx context.Context, sess Session) (Controls, error) { + c, err := d.conv(sess) + if err != nil { + return Controls{Agent: agentFor(sess)}, err + } + c.mu.Lock() + defer c.mu.Unlock() + mode := c.mode + if c.bypass { + mode = "bypassPermissions" + } + return Controls{Agent: c.agent, Mode: mode, Modes: append(append([]string{}, c.modes...), "bypassPermissions")}, nil +} + +func (d *acpDriver) SetMode(ctx context.Context, sess Session, mode string) (string, error) { + c, err := d.conv(sess) + if err != nil { + return "", err + } + bypass := mode == "bypassPermissions" + if !bypass { + c.mu.Lock() + known := false + for _, m := range c.modes { + known = known || m == mode + } + c.mu.Unlock() + if !known { + return "", badRequest("unknown mode %q", mode) + } + if err := c.setMode(ctx, mode); err != nil { + return "", err + } + } + c.mu.Lock() + c.bypass = bypass + var allow []acpRequest + if bypass { + allow = append(allow, c.pending...) + } + c.mu.Unlock() + v := "0" + if bypass { + v = "1" + } + d.b.Sessions.tmux(ctx, "set-option", "-t", "="+sess.Name+":", labelBypass, v) + noteConversation(c.conv, func(m *Conversation) { m.Mode = mode }) + for _, r := range allow { + c.allow(r, false) + } + return mode, nil +} + +func (d *acpDriver) Answer(ctx context.Context, sess Session, req AnswerRequest) ([]string, error) { + return nil, httpError{http.StatusConflict, "this agent asks no questions in forms"} +} + +func (d *acpDriver) Conversation(ctx context.Context, sess Session, _ []Session, since int, gen string) (transcript.Result, error) { + if _, err := d.conv(sess); err != nil { + return transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: err.Error()}, nil + } + return reader().Follow(transcript.SourceACPHost, filepath.Join(sess.conv, agenthost.LogName), sess.Dir, max(since, 0), gen) +} + +func (d *acpDriver) Record(ctx context.Context, sess Session, _ []Session) (string, string) { + return transcript.SourceACPHost, filepath.Join(sess.conv, agenthost.LogName) +} + +func (d *acpDriver) Draft(ctx context.Context, sess Session) (Draft, error) { + c, err := d.conv(sess) + if err != nil { + return Draft{Agent: agentFor(sess)}, nil + } + c.mu.Lock() + defer c.mu.Unlock() + out := Draft{Agent: c.agent, Text: c.draft.String()} + if c.busy && len(c.pending) == 0 { + out.Status = &DraftStatus{Word: "Working…"} + if !c.since.IsZero() { + out.Status.Elapsed = time.Since(c.since).Round(time.Second).String() + } + } + return out, nil +} + +func (d *acpDriver) askChoices(sess Session) []AskChoice { + c, err := d.conv(sess) + if err != nil { + return nil + } + return c.choices() +} diff --git a/internal/box/acpdriver_test.go b/internal/box/acpdriver_test.go new file mode 100644 index 00000000..a52d551a --- /dev/null +++ b/internal/box/acpdriver_test.go @@ -0,0 +1,271 @@ +package box + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/transcript" +) + +// fakeACPAgent stands in for an agent speaking ACP, with the messages the +// spike recorded from opencode and Cursor Agent (design/structured-agents): +// it wants authenticate before a session, streams its replies as chunks, +// asks permission to "run the tests", works until cancelled on "work", has +// build and plan modes, and resumes a session by its id. +const fakeACPAgent = `#!/usr/bin/env python3 +import json, sys +state = {"authed": False, "session": None, "mode": "build", "cancelled": False} +srv = [0] +def out(d): + print(json.dumps(d), flush=True) +def update(u): + out({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": state["session"], "update": u}}) +def chunks(text): + for w in text.split(" "): + update({"sessionUpdate": "agent_message_chunk", "messageId": "m", "content": {"type": "text", "text": w + " "}}) +modes = {"currentModeId": "build", "availableModes": [{"id": "build", "name": "Build"}, {"id": "plan", "name": "Plan"}]} +def handle(m): + method, rid, p = m.get("method"), m.get("id"), m.get("params") or {} + if method == "initialize": + out({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": True, "sessionCapabilities": {"resume": {}}}, "authMethods": [{"id": "fake_login", "name": "Login"}]}}) + elif method == "authenticate": + state["authed"] = True + out({"jsonrpc": "2.0", "id": rid, "result": {}}) + elif method in ("session/new", "session/resume", "session/load"): + if not state["authed"]: + out({"jsonrpc": "2.0", "id": rid, "error": {"code": -32000, "message": "Authentication required"}}) + return + state["session"] = p.get("sessionId") or "ses_fake1" + res = {"modes": modes} if method == "session/new" else {} + if method == "session/new": + res["sessionId"] = state["session"] + out({"jsonrpc": "2.0", "id": rid, "result": res}) + elif method == "session/set_mode": + state["mode"] = p["modeId"] + out({"jsonrpc": "2.0", "id": rid, "result": {}}) + update({"sessionUpdate": "current_mode_update", "currentModeId": p["modeId"]}) + elif method == "session/cancel": + state["cancelled"] = True + elif method == "session/prompt": + text = p["prompt"][0]["text"] + if text == "run the tests": + update({"sessionUpdate": "tool_call", "toolCallId": "call_1", "title": "bash", "kind": "execute", "status": "pending", "rawInput": {}}) + update({"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "title": "pnpm test", "kind": "execute", "status": "in_progress", "rawInput": {"command": "pnpm test"}}) + rid0 = srv[0] + srv[0] += 1 + out({"jsonrpc": "2.0", "id": rid0, "method": "session/request_permission", "params": {"sessionId": state["session"], "toolCall": {"toolCallId": "call_1", "title": "pnpm test", "kind": "execute", "rawInput": {"command": "pnpm test"}}, "options": [{"optionId": "allow-once", "kind": "allow_once", "name": "Allow once"}, {"optionId": "allow-always", "kind": "allow_always", "name": "Always"}, {"optionId": "reject-once", "kind": "reject_once", "name": "Reject"}]}}) + r = None + for line in sys.stdin: + x = json.loads(line) + if x.get("id") == rid0 and "method" not in x: + r = x["result"]["outcome"] + break + handle(x) + ok = r.get("outcome") == "selected" and r.get("optionId", "").startswith("allow") + update({"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "status": "completed" if ok else "failed"}) + chunks("The tests pass." if ok else "I didn't run them.") + out({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "end_turn"}}) + elif text == "work": + chunks("Working through it") + for line in sys.stdin: + handle(json.loads(line)) + if state["cancelled"]: + break + state["cancelled"] = False + out({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "cancelled"}}) + else: + chunks("echo: " + text + " (" + state["mode"] + ")") + out({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "end_turn"}}) + elif rid is not None and method: + out({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "unknown"}}) +for line in sys.stdin: + handle(json.loads(line)) +` + +func TestAStructuredACPSessionIsDrivenThroughItsProtocol(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("the stand-in agent needs python3") + } + bin := t.TempDir() + os.WriteFile(filepath.Join(bin, "opencode"), []byte(fakeACPAgent), 0o755) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + exe, _ := os.Executable() + states := &Turns{} + var bx *Box + c, bus := servedBox(t, func(b *Box) { + bx = b + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + var sess Session + if status := call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "opencode", "driver": "acp", "prompt": "hello there"}, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + if sess.Driver != DriverACP { + t.Fatalf("session = %+v", sess) + } + name := sess.Name + until := func(what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for !ok() { + if time.Now().After(deadline) { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + st, _ := states.State(name) + t.Fatalf("%s never happened: %+v / %+v", what, res.Items, st) + } + time.Sleep(50 * time.Millisecond) + } + } + count := func(text string) int { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + n := 0 + for _, it := range res.Items { + if strings.Contains(it.Text, text) { + n++ + } + } + return n + } + state := func(want string) func() bool { + return func() bool { st, _ := states.State(name); return st.State == want } + } + + // It authenticated, opened its session and answered. + until("the first reply", func() bool { return count("echo: hello there (build)") == 1 }) + until("the first turn's end", state("finished")) + if st, _ := states.State(name); st.AgentSessionID != "ses_fake1" { + t.Fatalf("its session = %q", st.AgentSessionID) + } + + // Its own modes, and bypass beside them. + var controls Controls + call(t, c, "GET", "/v1/sessions/"+name+"/controls", "", nil, &controls) + if controls.Mode != "build" || strings.Join(controls.Modes, ",") != "build,plan,bypassPermissions" { + t.Fatalf("controls = %+v", controls) + } + call(t, c, "POST", "/v1/sessions/"+name+"/mode", "", map[string]string{"mode": "plan"}, nil) + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "and now", "when": "now"}, nil) + until("a reply in plan mode", func() bool { return count("echo: and now (plan)") == 1 }) + until("its turn's end", state("finished")) + + // A permission, picked by its kind. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "run the tests", "when": "now"}, nil) + until("the permission", state("waiting")) + if st, _ := states.State(name); st.Ask == nil || st.Ask.Tool != "Bash" || st.Ask.Input != "pnpm test" { + t.Fatalf("ask = %+v", st.Ask) + } + var list []Session + call(t, c, "GET", "/v1/sessions", "", nil, &list) + if len(list) != 1 || list[0].Ask == nil || len(list[0].Ask.Choices) != 3 { + t.Fatalf("choices: %+v", list) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "3", "enter": false, "when": "now", "force": true}, nil) + until("the refusal's reply", func() bool { return count("I didn't run them.") == 1 }) + until("its turn's end", state("finished")) + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + ran := false + for _, it := range res.Items { + for _, tc := range it.Items { + ran = ran || (tc.Verb == "Run" && tc.Target == "pnpm test") + } + } + if !ran { + t.Fatalf("the command isn't a step: %+v", res.Items) + } + + // Bypass: berthd allows it. + call(t, c, "POST", "/v1/sessions/"+name+"/mode", "", map[string]string{"mode": "bypassPermissions"}, nil) + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "run the tests", "when": "now"}, nil) + until("the bypassed run's reply", func() bool { return count("The tests pass.") == 1 }) + until("its turn's end", state("finished")) + + // The reply being written, then a cancel. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "work", "when": "now"}, nil) + until("the draft", func() bool { + var d Draft + call(t, c, "GET", "/v1/sessions/"+name+"/draft", "", nil, &d) + return strings.Contains(d.Text, "Working through it") + }) + var stopped map[string]bool + call(t, c, "POST", "/v1/sessions/"+name+"/interrupt", "", nil, &stopped) + if !stopped["stopped"] { + t.Fatalf("interrupt: %v", stopped) + } + until("the cancelled turn's end", state("finished")) + + // Ended and resumed: the same session, picked up by its id. + live, err := bx.Sessions.Get(t.Context(), name) + if err != nil { + t.Fatal(err) + } + agenthost.Stop(t.Context(), live.sock) + until("the session's end", func() bool { + s, err := bx.Sessions.Get(t.Context(), name) + return err == nil && s.Exited + }) + if status := call(t, c, "POST", "/v1/sessions/"+name+"/resume", "", nil, nil); status != 200 { + t.Fatalf("resume: %d", status) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "again", "when": "now"}, nil) + until("a reply once resumed", func() bool { return count("echo: again") == 1 }) + if st, _ := states.State(name); st.AgentSessionID != "ses_fake1" { + t.Fatalf("resumed into %q", st.AgentSessionID) + } +} + +// TestALiveACPSessionWorks drives a real ACP agent: BERTH_LIVE_ACP names +// its preset (opencode, cursor, gemini), BERTH_LIVE_ACP_MODEL its model. +func TestALiveACPSessionWorks(t *testing.T) { + agent := os.Getenv("BERTH_LIVE_ACP") + if agent == "" { + t.Skip("set BERTH_LIVE_ACP=opencode (or cursor, gemini) to drive a real ACP agent") + } + exe, _ := os.Executable() + states := &Turns{} + c, bus := servedBox(t, func(b *Box) { + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + req := map[string]any{"location": "acme", "agent": agent, "driver": "acp", "mode": "bypassPermissions", "prompt": "Reply with just the word: harbour. Use no tools."} + if m := os.Getenv("BERTH_LIVE_ACP_MODEL"); m != "" { + req["model"] = m + } + var sess Session + if status := call(t, c, "POST", "/v1/sessions", "", req, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + deadline := time.Now().Add(4 * time.Minute) + for { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+sess.Name+"/transcript?since=0", "", nil, &res) + st, _ := states.State(sess.Name) + for _, it := range res.Items { + if it.Kind == "text" && strings.Contains(strings.ToLower(it.Text), "harbour") && st.State == "finished" { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("no reply: %+v / %+v", res.Items, st) + } + time.Sleep(time.Second) + } +} diff --git a/internal/box/api.go b/internal/box/api.go index 573213d6..951983b6 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -68,6 +68,8 @@ type Box struct { claudeDrv *claudeDriver codexOnce sync.Once codexDrv *codexDriver + acpOnce sync.Once + acpDrv *acpDriver // Hooks, when set, may refuse actions through "before:" hooks. Hooks *hooks.Runner // Flows runs the box's and its repositories' automations. @@ -819,10 +821,10 @@ func wantStructured(driver string) (string, error) { switch driver { case "", DriverTerminal: return "", nil - case DriverClaude, DriverCodex: + case DriverClaude, DriverCodex, DriverACP: return driver, nil } - return "", badRequest("unknown driver %q: terminal, claude or codex", driver) + return "", badRequest("unknown driver %q: terminal, claude, codex or acp", driver) } // launchStructured is what starts preset p under a structured driver, in a @@ -831,6 +833,15 @@ func (b *Box) launchStructured(kind string, p AgentPreset, model, effort, mode s if kind == DriverClaude { return b.launchClaude(p, model, effort, mode, "") } + if kind == DriverACP { + dir, sock, err := b.newConversation() + if err != nil { + return structuredLaunch{}, err + } + l, err := b.launchACPIn(p, mode, dir, sock) + l.model = model + return l, err + } if p.ID != "codex" { return structuredLaunch{}, badRequest("the codex driver runs Codex; %s runs in its terminal", p.Name) } diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 06fb0ebf..788a17aa 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -1044,6 +1044,8 @@ func (b *Box) follow(sess Session) { b.claude().conv(sess) case DriverCodex: b.codex().conv(sess) + case DriverACP: + b.acp().conv(sess) } } @@ -1052,6 +1054,7 @@ func (b *Box) follow(sess Session) { func (b *Box) forgetConversation(name string) { b.claude().forget(name) b.codex().forget(name) + b.acp().forget(name) } // awaitHost waits up to d for a session's host to listen. diff --git a/internal/box/conversations.go b/internal/box/conversations.go index 8d7131c9..60c9ef7a 100644 --- a/internal/box/conversations.go +++ b/internal/box/conversations.go @@ -194,9 +194,9 @@ func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { if sid == "" { sid = conversationSession(sess.conv) } - if meta, _ := readConversation(sess.conv); sid == "" && meta.Agent != "codex" { - // Codex opens its thread again itself (codexdriver.go); Claude needs - // its conversation named. + if meta, _ := readConversation(sess.conv); sid == "" && meta.Agent == "claude" { + // Codex and ACP agents open their sessions again themselves; Claude + // needs its conversation named. return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} } l, err := b.relaunch(ctx, sess.Location, sess.conv, sess.sock, sid, modeFor(sess)) @@ -222,6 +222,13 @@ func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { // server, which opens its thread again itself. func (b *Box) relaunch(ctx context.Context, location, dir, sock, sid, mode string) (structuredLaunch, error) { meta, _ := readConversation(dir) + if meta.Agent != "claude" && meta.Agent != "codex" && meta.Agent != "" { + p, ok := b.presetAt(ctx, location, meta.Agent) + if !ok { + return structuredLaunch{}, badRequest("this box has no %s preset", meta.Agent) + } + return b.launchACPIn(p, firstNonEmpty(mode, meta.Mode), dir, sock) + } if meta.Agent == "codex" { p, ok := b.presetAt(ctx, location, "codex") if !ok { @@ -304,7 +311,7 @@ func (b *Box) resumeConversation(w http.ResponseWriter, r *http.Request) error { return httpError{http.StatusGone, "the folder it worked in, " + conv.Dir + ", is gone"} } sid := conversationSession(dir) - if sid == "" && conv.Agent != "codex" { + if sid == "" && conv.Agent == "claude" { return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} } sock, err := hostSocket(filepath.Base(dir)) diff --git a/internal/box/driver.go b/internal/box/driver.go index 6932dc96..9eae1a25 100644 --- a/internal/box/driver.go +++ b/internal/box/driver.go @@ -65,6 +65,8 @@ func (b *Box) driverFor(sess Session) Driver { return b.claude() case DriverCodex: return b.codex() + case DriverACP: + return b.acp() } return terminalDriver{b} } diff --git a/internal/box/tasks.go b/internal/box/tasks.go index 7438b3c1..1512f8f5 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -33,6 +33,11 @@ type AgentPreset struct { // them for a built-in agent by giving its id and no command. Models []string `json:"models,omitempty"` Efforts []string `json:"efforts,omitempty"` + // ACP is what follows the command to start the agent's Agent Client + // Protocol server ("acp", "--acp"): with it, the agent can run + // structured (acpdriver.go). A repository can give an agent of its own + // one, or a built-in one by its id. + ACP string `json:"acp,omitempty"` } // builtinAgents are the agent CLIs berth knows how to start, by binary. @@ -45,9 +50,9 @@ var builtinAgents = []AgentPreset{ Models: []string{"opus", "sonnet", "haiku"}, Efforts: []string{"low", "medium", "high", "xhigh", "max"}}, {ID: "codex", Name: "Codex", Command: "codex", ModelFlag: "--model", EffortFlag: "-c model_reasoning_effort=", Efforts: []string{"minimal", "low", "medium", "high"}}, - {ID: "opencode", Name: "OpenCode", Command: "opencode", PromptFlag: "--prompt", ModelFlag: "--model"}, - {ID: "gemini", Name: "Gemini CLI", Command: "gemini", PromptFlag: "-i"}, - {ID: "cursor", Name: "Cursor Agent", Command: "cursor-agent", ModelFlag: "--model"}, + {ID: "opencode", Name: "OpenCode", Command: "opencode", PromptFlag: "--prompt", ModelFlag: "--model", ACP: "acp"}, + {ID: "gemini", Name: "Gemini CLI", Command: "gemini", PromptFlag: "-i", ACP: "--acp"}, + {ID: "cursor", Name: "Cursor Agent", Command: "cursor-agent", ModelFlag: "--model", ACP: "acp"}, } // Presets are the built-in agents this box has, then the location's own from @@ -77,6 +82,9 @@ func Presets(loc *Location) []AgentPreset { if own.Efforts != nil { out[i].Efforts = own.Efforts } + if own.ACP != "" { + out[i].ACP = own.ACP + } continue } out[i] = own @@ -432,6 +440,8 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { choices = b.claude().askChoices(*s) case DriverCodex: choices = b.codex().askChoices(*s) + case DriverACP: + choices = b.acp().askChoices(*s) } { if len(choices) > 0 { diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index 5dde0609..96a844a6 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -170,7 +170,9 @@ func (b *Box) Capabilities() []string { // conversations: GET /v1/conversations, POST .../{id}/resume and // POST /v1/sessions/{name}/resume (conversations.go). // driver.codex: "driver": "codex", Codex's app server (codexdriver.go). - caps = append(caps, "driver.claude", "driver.codex", "driver.switch", "conversations") + // driver.acp: "driver": "acp", an agent whose preset says how it + // speaks ACP (acpdriver.go). + caps = append(caps, "driver.claude", "driver.codex", "driver.acp", "driver.switch", "conversations") } if b.Artifacts != nil { // artifacts: berthd artifact add, a worktree's artifacts and their diff --git a/internal/transcript/acphost.go b/internal/transcript/acphost.go new file mode 100644 index 00000000..5024f035 --- /dev/null +++ b/internal/transcript/acphost.go @@ -0,0 +1,171 @@ +package transcript + +import ( + "encoding/json" + "strings" +) + +// SourceACPHost is a structured agent's own log when the agent speaks the +// Agent Client Protocol (opencode, Gemini CLI, Cursor Agent…), kept by +// berthd agent-host: berthd's prompts in, the agent's session updates out. +const SourceACPHost = "acp-host" + +// acpHostParser reads an agent-host log of an ACP agent. A reply streams +// as chunks: they are gathered and kept as one item once something else +// happens (a tool, the turn's end, the next prompt). +type acpHostParser struct { + text strings.Builder + textOff int64 + // loading is the session/load being answered: what the agent replays + // for it is already in the log. + loading string + prompts map[string]bool +} + +type acpUpdate struct { + SessionUpdate string `json:"sessionUpdate"` + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + ToolCallID string `json:"toolCallId"` + Title string `json:"title"` + Kind string `json:"kind"` + Status string `json:"status"` + RawInput struct { + Command string `json:"command"` + FilePath string `json:"filePath"` + Path string `json:"path"` + } `json:"rawInput"` + Locations []struct { + Path string `json:"path"` + } `json:"locations"` +} + +func (p *acpHostParser) flush(c *conv) { + if t := strings.TrimSpace(p.text.String()); t != "" { + off := c.lineOff + c.lineOff = p.textOff + c.add(Item{Kind: "text", ID: c.id(), Text: clip(t, maxText)}) + c.lineOff = off + } + p.text.Reset() +} + +func (p *acpHostParser) line(c *conv, b []byte) { + var e hostEntry + if json.Unmarshal(b, &e) != nil || len(e.Data) == 0 || e.Data[0] != '{' { + return + } + var m codexRPC + if json.Unmarshal(e.Data, &m) != nil { + return + } + if e.At != 0 { + c.lineAt = e.At + } + id := string(m.ID) + switch e.Dir { + case "in": + switch m.Method { + case "session/prompt": + var params struct { + Prompt []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"prompt"` + } + json.Unmarshal(m.Params, ¶ms) + var parts []string + for _, b := range params.Prompt { + if b.Text != "" { + parts = append(parts, b.Text) + } + } + p.flush(c) + if text := strings.TrimSpace(strings.Join(parts, "\n")); text != "" { + userText(c, text) + } + if p.prompts == nil { + p.prompts = map[string]bool{} + } + p.prompts[id] = true + case "session/load": + p.loading = id + } + return + case "out": + default: + return + } + if m.Method == "" { + // An answer: a prompt's is its turn's end; a load's ends its replay. + if id == p.loading { + p.loading = "" + } + if p.prompts[id] { + delete(p.prompts, id) + p.flush(c) + } + return + } + if m.Method != "session/update" || p.loading != "" { + return + } + var params struct { + Update acpUpdate `json:"update"` + } + if json.Unmarshal(m.Params, ¶ms) != nil { + return + } + u := params.Update + switch u.SessionUpdate { + case "agent_message_chunk": + if p.text.Len() == 0 { + p.textOff = c.lineOff + } + if p.text.Len() < maxText { + p.text.WriteString(u.Content.Text) + } + case "tool_call": + p.flush(c) + c.call(u.ToolCallID, acpToolCall(c, u)) + if u.Status == "completed" || u.Status == "failed" { + c.result(u.ToolCallID, e.At) + } + case "tool_call_update": + // A call announced bare ("bash") says what it runs as it goes. + if u.RawInput.Command != "" || u.RawInput.FilePath != "" || u.RawInput.Path != "" || len(u.Locations) > 0 { + c.retitle(u.ToolCallID, acpToolCall(c, u)) + } + if u.Status == "completed" || u.Status == "failed" { + c.result(u.ToolCallID, e.At) + } + } +} + +// acpToolCall is a tool call as a step: its kind says what it does. +func acpToolCall(c *conv, u acpUpdate) ToolCall { + target := firstNonEmpty(u.RawInput.Command, u.Title) + path := firstNonEmpty(u.RawInput.FilePath, u.RawInput.Path) + if path == "" && len(u.Locations) > 0 { + path = u.Locations[0].Path + } + switch u.Kind { + case "read": + if path != "" { + return ToolCall{Verb: "Read", Target: rel(c.dir, path), File: true} + } + return ToolCall{Verb: "Read", Target: clip(firstLine(target), 80)} + case "edit", "delete", "move": + if path != "" { + return ToolCall{Verb: "Edit", Target: rel(c.dir, path), File: true} + } + return ToolCall{Verb: "Edit", Target: clip(firstLine(target), 80)} + case "search": + return ToolCall{Verb: "Search", Target: clip(firstLine(target), 80)} + case "fetch": + return ToolCall{Verb: "Fetch", Target: clip(firstLine(target), 80)} + } + return ToolCall{Verb: "Run", Target: clip(firstLine(target), 80)} +} diff --git a/internal/transcript/acphost_test.go b/internal/transcript/acphost_test.go new file mode 100644 index 00000000..e0a06fde --- /dev/null +++ b/internal/transcript/acphost_test.go @@ -0,0 +1,40 @@ +package transcript + +import ( + "path/filepath" + "strings" + "testing" +) + +// acp_host_opencode.jsonl is an agent-host log of opencode speaking ACP +// (recorded, then cut down and made anonymous): a prompt, a shell command +// it asked permission for and ran, and its streamed reply. +func TestAStructuredACPSessionsLogReadsAsItsConversation(t *testing.T) { + path := filepath.Join("testdata", "acp_host_opencode.jsonl") + res, err := NewReader().Read(SourceACPHost, path, "/home/dev/spike/repo-acp", 0) + if err != nil { + t.Fatal(err) + } + var got []string + for _, it := range res.Items { + s := it.Kind + switch it.Kind { + case "text", "user": + s += ":" + it.Text + case "tools": + for _, c := range it.Items { + s += ":" + c.Verb + " " + c.Target + } + if !it.Done { + s += " (running)" + } + } + got = append(got, s) + } + want := "user:Run the shell command `echo spike > made_by_agent.txt` in the current directory, then reply DONE. Do not ask me questions.\n" + + "tools:Run echo spike > made_by_agent.txt\n" + + "text:DONE" + if res.Source != "acp" || strings.Join(got, "\n") != want { + t.Fatalf("source %q, items:\n%s", res.Source, strings.Join(got, "\n")) + } +} diff --git a/internal/transcript/history.go b/internal/transcript/history.go index 512a5be3..c6ab15d4 100644 --- a/internal/transcript/history.go +++ b/internal/transcript/history.go @@ -35,6 +35,8 @@ func parserFor(source string) parser { return &hostParser{} case SourceCodexHost: return &codexHostParser{} + case SourceACPHost: + return &acpHostParser{} } return &claudeParser{} } diff --git a/internal/transcript/host.go b/internal/transcript/host.go index 4dcc6312..5497bff1 100644 --- a/internal/transcript/host.go +++ b/internal/transcript/host.go @@ -72,6 +72,8 @@ func shownSource(source string) string { return "claude" case SourceCodexHost: return "codex" + case SourceACPHost: + return "acp" } return source } diff --git a/internal/transcript/testdata/acp_host_opencode.jsonl b/internal/transcript/testdata/acp_host_opencode.jsonl new file mode 100644 index 00000000..e43ba06c --- /dev/null +++ b/internal/transcript/testdata/acp_host_opencode.jsonl @@ -0,0 +1,35 @@ +{"seq":1,"at":1791900001000,"dir":"in","data":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":false},"clientInfo":{"name":"berth-spike","version":"0.0.0"}}}} +{"seq":2,"at":1791900002000,"dir":"out","data":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"sessionCapabilities":{"close":{},"fork":{},"list":{},"resume":{}}},"authMethods":[{"description":"Run `opencode auth login` in the terminal","name":"Login with opencode","id":"opencode-login"}],"agentInfo":{"name":"OpenCode","version":"1.18.21"}}}} +{"seq":3,"at":1791900003000,"dir":"in","data":{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/home/dev/spike/repo-acp","mcpServers":[]}}} +{"seq":4,"at":1791900004000,"dir":"out","data":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_00000000000000000000000004"}}} +{"seq":5,"at":1791900005000,"dir":"in","data":{"jsonrpc":"2.0","id":3,"method":"session/set_model","params":{"sessionId":"ses_00000000000000000000000004","modelId":"opencode/big-pickle"}}} +{"seq":6,"at":1791900006000,"dir":"out","data":{"jsonrpc":"2.0","id":3,"result":{}}} +{"seq":7,"at":1791900007000,"dir":"in","data":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"ses_00000000000000000000000004","prompt":[{"type":"text","text":"Run the shell command `echo spike > made_by_agent.txt` in the current directory, then reply DONE. Do not ask me questions."}]}}} +{"seq":8,"at":1791900008000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"}]}}}} +{"seq":9,"at":1791900009000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":"The"}}}}} +{"seq":10,"at":1791900010000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" user wants me to run a"}}}}} +{"seq":11,"at":1791900011000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" shell command. But the instructions"}}}}} +{"seq":12,"at":1791900012000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" say I"}}}}} +{"seq":13,"at":1791900013000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" should not really do file"}}}}} +{"seq":14,"at":1791900014000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" operations with"}}}}} +{"seq":15,"at":1791900015000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" bash. But they"}}}}} +{"seq":16,"at":1791900016000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" explicitly asked."}}}}} +{"seq":17,"at":1791900017000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" Simple"}}}}} +{"seq":18,"at":1791900018000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" task"}}}}} +{"seq":19,"at":1791900019000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":"."}}}}} +{"seq":20,"at":1791900020000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/home/dev/spike/repo-acp"}],"rawInput":{"cwd":"/home/dev/spike/repo-acp"}}}}} +{"seq":21,"at":1791900021000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","status":"in_progress","kind":"execute","title":"echo spike > made_by_agent.txt","locations":[{"path":"/home/dev/spike/repo-acp"}],"rawInput":{"command":"echo spike > made_by_agent.txt","cwd":"/home/dev/spike/repo-acp"}}}}} +{"seq":22,"at":1791900022000,"dir":"out","data":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"ses_00000000000000000000000004","toolCall":{"toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","title":"echo spike > made_by_agent.txt","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"echo spike > made_by_agent.txt"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"seq":23,"at":1791900023000,"dir":"in","data":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"seq":24,"at":1791900024000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","status":"in_progress","kind":"execute","title":"echo spike > made_by_agent.txt","locations":[{"path":"/home/dev/spike/repo-acp"}],"rawInput":{"command":"echo spike > made_by_agent.txt","cwd":"/home/dev/spike/repo-acp"}}}}} +{"seq":25,"at":1791900025000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","status":"completed","title":"echo spike > made_by_agent.txt","content":[{"type":"content","content":{"type":"text","text":"(no output)"}}],"rawOutput":{"output":"(no output)","metadata":{"output":"(no output)","exit":0,"truncated":false}}}}}} +{"seq":26,"at":1791900026000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"The"}}}}} +{"seq":27,"at":1791900027000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":" command ran"}}}}} +{"seq":28,"at":1791900028000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":". Now reply"}}}}} +{"seq":29,"at":1791900029000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":" D"}}}}} +{"seq":30,"at":1791900030000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"ONE"}}}}} +{"seq":31,"at":1791900031000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"."}}}}} +{"seq":32,"at":1791900032000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"D"}}}}} +{"seq":33,"at":1791900033000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"ONE"}}}}} +{"seq":34,"at":1791900034000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"usage_update","used":16718,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}} +{"seq":35,"at":1791900035000,"dir":"out","data":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","usage":{"inputTokens":206,"outputTokens":3,"totalTokens":16730,"thoughtTokens":9,"cachedReadTokens":16512},"_meta":{}}}} diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 0c079220..30f2f900 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -315,6 +315,21 @@ func (c *conv) call(toolID string, tc ToolCall) { } } +// retitle says what a call does once its agent says more: its target, in +// the group it is in, when it does the same kind of thing. +func (c *conv) retitle(toolID string, tc ToolCall) { + i, ok := c.byTool[toolID] + it := c.at(i) + if !ok || it == nil || it.Kind != "tools" || it.Verb != tc.Verb { + return + } + for j := range it.Items { + if it.Items[j].ID == toolID { + it.Items[j].Target, it.Items[j].File = tc.Target, tc.File + } + } +} + // launched marks a helper as running in the background: its tool call // answers at once, and it is back only when its notification says so. func (c *conv) launched(toolID string) { diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 20187506..50a55133 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -215,7 +215,7 @@ export interface Session { // How the box reaches the agent ("driver" capability): "terminal", its // own screen in tmux, or "claude", Claude Code driven through its stream // ("driver.claude" capability). - driver?: "terminal" | "claude" | "codex" | string; + driver?: "terminal" | "claude" | "codex" | "acp" | string; // Where a structured session can switch ("driver.switch" capability): // "terminal", to Claude Code's own screen, or "chat", back from it. POST // sessions/{name}/switch with { to } switches it. @@ -398,6 +398,9 @@ export interface AgentPreset { // it has them). Leaving one out means the CLI's default. models?: string[]; efforts?: string[]; + // What starts its Agent Client Protocol server ("acp", "--acp"): with it, + // the agent can run structured on boxes listing "driver.acp". + acp?: string; } // AgentPath is where a box found an agent CLI: through the person's shell @@ -478,7 +481,7 @@ export interface TaskRequest { // Code structured ("driver.claude" capability); mode is a structured // agent's permission mode to start in ("bypassPermissions": berthd allows // what it asks, questions and plans aside). - driver?: "terminal" | "claude" | "codex"; + driver?: "terminal" | "claude" | "codex" | "acp"; mode?: string; }