diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index cc37a88c..6b12756f 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" || d === "acp"; + }); 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..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"; 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/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..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,13 +96,19 @@ const fail = (title: string, err: unknown, box?: string) => { return false; }; -// structured is how a Claude agent starts when Labs runs it structured: on -// a box whose berthd drives Claude Code through its stream. -function structured(box: string, agent: string): { driver?: "claude"; mode?: string } { +// structured is how an agent starts when Labs runs it structured: Claude +// Code through its stream ("driver.claude"), Codex through its app server +// ("driver.codex"), and an agent whose preset speaks ACP through that +// ("driver.acp"). +function structured(box: string, agent: string, preset?: AgentPreset): { driver?: "claude" | "codex" | "acp"; mode?: string } { const p = usePrefs.getState(); const caps = useStore.getState().boxes[box]?.info?.capabilities ?? []; - if (agent !== "claude" || !p.labs || !p.structuredClaude || !caps.includes("driver.claude")) return {}; - return { driver: "claude", mode: p.approvals === "bypass" ? "bypassPermissions" : "default" }; + if (!p.labs || !p.structuredClaude) return {}; + const driver = agent === "claude" || agent === "codex" ? agent : preset?.acp ? "acp" : undefined; + if (!driver || !caps.includes(`driver.${driver}`)) return {}; + // ACP agents have modes of their own: bypass, or theirs as they start. + const mode = p.approvals === "bypass" ? "bypassPermissions" : driver === "acp" ? undefined : "default"; + return { driver, mode }; } export async function startWork(d: StartDraft): Promise { @@ -130,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 38077675..cb2424d9 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..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) in review. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. +Status: 2026-10-13. Phase 0 (spike) done: go. Phases 1 (driver interface) and 2 (agent-host + log) merged. Phase 3 (Claude driver, behind Labs) merged. Phase 4a (terminal and back) merged; 4b (resume, conversation index), 5 (Codex) and 6 (ACP) in review, stacked. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. ## Why diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 825bfd01..e800cfe1 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,20 @@ again. ## Unreleased +- **Labs: OpenCode, Gemini CLI and Cursor Agent run structured too.** With + *Structured agents* on, they start through the Agent Client Protocol: their + replies stream into the chat, their permission requests come to it (or, + with Bypass, the box allows them), Stop cancels their turn, their own modes + are in the chat's mode menu, and they can be resumed. A repository's own + agent can join in by giving its preset an `acp`. Needs the box upgraded. + +- **Labs: Codex agents can run structured too.** With *Structured agents* + on (Settings › Labs), a new Codex agent + runs as Codex's app server, driven by the box: replies stream into the + chat, commands and file changes to approve come to it as Allow / Always + allow / Deny (or, with Bypass, run without asking), its questions are + answered there, and it can be stopped and resumed. Needs the box upgraded. + - **Labs: a structured Claude agent's conversation can be resumed.** When one ends, its pane offers *Resume the conversation* rather than a fresh start, and once its session is closed a worktree's menu lists its @@ -24,7 +38,7 @@ again. back to the chat, with what was said in the terminal in its place. Needs the box upgraded. -- **Labs: structured Claude agents.** Turn on *Structured Claude agents* +- **Labs: structured Claude agents.** Turn on *Structured agents* in Settings › Labs and new Claude Code agents run without their terminal: the box drives Claude Code through its stream and keeps its own record of the conversation, so replies stream in as they are written, diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index 0bb107af..d5f1a8b8 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -235,10 +235,10 @@ box with the `commands` capability: `GET /v1/sessions/{name}/commands` (cached a minute, capped at 600) and `GET …/files?q=`. MCP servers' prompts aren't listed: the agent reads them from each server once it starts. -## Structured Claude agents +## Structured agents -With *Structured Claude agents* on in Settings › Labs, a new Claude Code -agent runs without its terminal. The box starts `claude -p` speaking +With *Structured agents* on in Settings › Labs, a new Claude Code, Codex, +OpenCode, Gemini CLI or Cursor Agent agent runs without its terminal. The box starts `claude -p` speaking stream-json under `berthd agent-host`, which keeps the agent running in its tmux session and writes everything that passes between them to the box's own record of the conversation. The box drives it from there: @@ -265,9 +265,24 @@ own record of the conversation. The box drives it from there: Claude Code's `--resume`, in its own record, so the chat shows its earlier turns. +- **Codex** runs as `codex app-server` under the same host: the box opens its + thread, sends each prompt as a turn with the agent's mode, model and effort, + and brings its command and file-change approvals and its questions to the + chat. Its modes are default (asks to go past its workspace sandbox), + untrusted (asks before untrusted commands), read only, and bypass (no + sandbox, nothing asked). It has no terminal switch. +- **OpenCode, Gemini CLI and Cursor Agent** speak the Agent Client Protocol + (`opencode acp`, `gemini --acp`, `cursor-agent acp`), and so does any agent + whose preset in a repository's config has an [`acp`](/reference/config): + the box opens its session (with its own login, for Cursor Agent), streams + its reply, cancels its turn, switches its own modes, and brings its + permission requests to the chat; *Bypass* has the box allow them. + The agent keeps working while berthd restarts or upgrades, and the record keeps every streamed word even if the agent is killed. The box needs the -`driver.claude` capability, and `driver.switch` for the terminal (upgrade +`driver.claude` capability (`driver.codex` for Codex, `driver.acp` for ACP +agents), and `driver.switch` +for the terminal (upgrade it). ## Zen diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 59d3ee8e..c625c63c 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -225,7 +225,10 @@ and, while it waits, `ask` (what its hooks said it asks for). `driver` says how the box reaches the agent: `terminal` (its own screen in tmux) for every agent but a structured one, or `claude` (Claude Code driven through its stream); boxes that say it list `driver` in `capabilities`. `POST -sessions` and `POST tasks` take `driver: "claude"` with `agent: "claude"`, +sessions` and `POST tasks` take `driver: "claude"` with `agent: "claude"` +(or `driver: "codex"` with `agent: "codex"`, Codex's app server, on boxes +listing `driver.codex`, or `driver: "acp"` with an agent whose preset has +`acp`, on boxes listing `driver.acp`), and `mode`, the permission mode it starts in (`bypassPermissions` has the box allow what it asks; questions and plans still wait), on boxes that list `driver.claude`. A waiting structured session's `ask` carries `choices` 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 b1f49e86..951983b6 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -66,6 +66,10 @@ type Box struct { AgentHost []string claudeOnce sync.Once 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. @@ -707,10 +711,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 +816,42 @@ 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, DriverACP: + return driver, nil + } + return "", badRequest("unknown driver %q: terminal, claude, codex or acp", 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 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) + } + 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 +865,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..788a17aa 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,26 @@ 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) + case DriverACP: + b.acp().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) + b.acp().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 +1076,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..60c9ef7a 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 == "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.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,44 @@ 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 != "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 { + 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 +311,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 == "claude" { 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 +337,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..9eae1a25 100644 --- a/internal/box/driver.go +++ b/internal/box/driver.go @@ -60,8 +60,13 @@ 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() + case DriverACP: + return b.acp() } return terminalDriver{b} } diff --git a/internal/box/tasks.go b/internal/box/tasks.go index fe6c696f..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 @@ -299,10 +307,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 +410,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 +434,17 @@ 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) + case DriverACP: + choices = b.acp().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..96a844a6 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -169,7 +169,10 @@ 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). + // 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/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..c6ab15d4 100644 --- a/internal/transcript/history.go +++ b/internal/transcript/history.go @@ -33,6 +33,10 @@ func parserFor(source string) parser { return &codexParser{} case SourceClaudeHost: 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 90034ecb..5497bff1 100644 --- a/internal/transcript/host.go +++ b/internal/transcript/host.go @@ -67,8 +67,13 @@ 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" + 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/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/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 d2813cb5..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" | 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"; + driver?: "terminal" | "claude" | "codex" | "acp"; mode?: string; }