diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 5434a3c7..6b12756f 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -237,7 +237,7 @@ export function usePaneView(pane: Leaf): "terminal" | "conversation" | undefined const structured = useStore((st) => { if (c.kind !== "terminal") return false; const d = st.boxes[c.box]?.sessions?.find((x) => x.name === c.session)?.driver; - return d === "claude" || d === "codex"; + return d === "claude" || d === "codex" || d === "acp"; }); if (c.kind !== "terminal" || !labs || !runsAgent) return undefined; return c.view ?? (structured ? "conversation" : fallback); diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 1aeef5c9..5f681fad 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -263,7 +263,7 @@ export const boxApi = { c.box(box, "DELETE", `locations/${encodeURIComponent(location)}/worktrees/${encodeURIComponent(worktree)}${force ? "?force=1" : ""}`), // home: in the box user's home folder, tied to no worktree, in place of a // location (boxes with the "session.home" capability). - startSession: (c: Client, box: string, req: ({ location: string } | { home: true }) & { name?: string; command?: string; agent?: string; prompt?: string; model?: string; effort?: string; title?: string; driver?: "terminal" | "claude" | "codex"; mode?: string }) => + startSession: (c: Client, box: string, req: ({ location: string } | { home: true }) & { name?: string; command?: string; agent?: string; prompt?: string; model?: string; effort?: string; title?: string; driver?: "terminal" | "claude" | "codex" | "acp"; mode?: string }) => c.box(box, "POST", "sessions", req), stopSession: (c: Client, box: string, name: string) => c.box(box, "DELETE", `sessions/${encodeURIComponent(name)}`), // A structured agent's conversations, those that worked in path diff --git a/app/src/lib/start-work.ts b/app/src/lib/start-work.ts index bf4cd3ba..2eaa6ff7 100644 --- a/app/src/lib/start-work.ts +++ b/app/src/lib/start-work.ts @@ -4,7 +4,7 @@ import { toastManager } from "@/components/ui/toast"; import { isMock } from "@/hooks/use-berth-connection"; import { agentPresets } from "@/lib/actions"; import { offerAgentHooks } from "@/lib/agent-hooks"; -import type { Session, TaskResult, Worktree } from "@/lib/api"; +import type { AgentPreset, Session, TaskResult, Worktree } from "@/lib/api"; import { ApiError, boxApi } from "@/lib/api"; import { startBroadcast } from "@/lib/broadcast"; import type { AgentPick, ComposerTarget } from "@/lib/composer"; @@ -96,14 +96,19 @@ const fail = (title: string, err: unknown, box?: string) => { return false; }; -// structured is how a Claude Code or Codex agent starts when Labs runs it -// structured: on a box whose berthd drives that agent through its stream -// ("driver.claude") or app server ("driver.codex"). -function structured(box: string, agent: string): { driver?: "claude" | "codex"; mode?: string } { +// structured is how an agent starts when Labs runs it structured: Claude +// Code through its stream ("driver.claude"), Codex through its app server +// ("driver.codex"), and an agent whose preset speaks ACP through that +// ("driver.acp"). +function structured(box: string, agent: string, preset?: AgentPreset): { driver?: "claude" | "codex" | "acp"; mode?: string } { const p = usePrefs.getState(); const caps = useStore.getState().boxes[box]?.info?.capabilities ?? []; - if ((agent !== "claude" && agent !== "codex") || !p.labs || !p.structuredClaude || !caps.includes(`driver.${agent}`)) return {}; - return { driver: agent, mode: p.approvals === "bypass" ? "bypassPermissions" : "default" }; + if (!p.labs || !p.structuredClaude) return {}; + const driver = agent === "claude" || agent === "codex" ? agent : preset?.acp ? "acp" : undefined; + if (!driver || !caps.includes(`driver.${driver}`)) return {}; + // ACP agents have modes of their own: bypass, or theirs as they start. + const mode = p.approvals === "bypass" ? "bypassPermissions" : driver === "acp" ? undefined : "default"; + return { driver, mode }; } export async function startWork(d: StartDraft): Promise { @@ -131,7 +136,7 @@ export async function startWork(d: StartDraft): Promise { toastManager.add({ type: "success", title: `Created ${wt.name}`, description: `${wt.branch ?? ""} on ${d.box}` }); return true; } - const how = { agent: pick.agent, prompt: d.text || undefined, model: pick.model || undefined, effort: pick.effort || undefined, ...structured(d.box, pick.agent) }; + const how = { agent: pick.agent, prompt: d.text || undefined, model: pick.model || undefined, effort: pick.effort || undefined, ...structured(d.box, pick.agent, presets.find((p) => p.id === pick.agent)) }; if (d.where === "new") { const name = d.worktree?.name || freeName(d.box, d.location, d.text ? slug(d.text) : randomName()); const res = await client.box(d.box, "POST", "tasks", { diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx index ef29443d..cb2424d9 100644 --- a/app/src/views/settings/labs-section.tsx +++ b/app/src/views/settings/labs-section.tsx @@ -30,7 +30,7 @@ export function LabsSection() { {labs && ( setPrefs({ structuredClaude: on })} /> diff --git a/design/structured-agents/plan.md b/design/structured-agents/plan.md index 76f07bc9..458d17a8 100644 --- a/design/structured-agents/plan.md +++ b/design/structured-agents/plan.md @@ -1,6 +1,6 @@ # Structured agents: plan -Status: 2026-10-13. Phase 0 (spike) done: go. Phases 1 (driver interface) and 2 (agent-host + log) merged. Phase 3 (Claude driver, behind Labs) merged. Phase 4a (terminal and back) merged; 4b (resume, conversation index) and 5 (Codex) in review, stacked. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. +Status: 2026-10-13. Phase 0 (spike) done: go. Phases 1 (driver interface) and 2 (agent-host + log) merged. Phase 3 (Claude driver, behind Labs) merged. Phase 4a (terminal and back) merged; 4b (resume, conversation index), 5 (Codex) and 6 (ACP) in review, stacked. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. ## Why diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 57e1dfbc..e800cfe1 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,13 @@ again. ## Unreleased +- **Labs: OpenCode, Gemini CLI and Cursor Agent run structured too.** With + *Structured agents* on, they start through the Agent Client Protocol: their + replies stream into the chat, their permission requests come to it (or, + with Bypass, the box allows them), Stop cancels their turn, their own modes + are in the chat's mode menu, and they can be resumed. A repository's own + agent can join in by giving its preset an `acp`. Needs the box upgraded. + - **Labs: Codex agents can run structured too.** With *Structured agents* on (Settings › Labs), a new Codex agent runs as Codex's app server, driven by the box: replies stream into the diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index d663b2ad..d5f1a8b8 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -237,8 +237,8 @@ aren't listed: the agent reads them from each server once it starts. ## Structured agents -With *Structured agents* on in Settings › Labs, a new Claude Code or Codex -agent runs without its terminal. The box starts `claude -p` speaking +With *Structured agents* on in Settings › Labs, a new Claude Code, Codex, +OpenCode, Gemini CLI or Cursor Agent agent runs without its terminal. The box starts `claude -p` speaking stream-json under `berthd agent-host`, which keeps the agent running in its tmux session and writes everything that passes between them to the box's own record of the conversation. The box drives it from there: @@ -271,10 +271,17 @@ own record of the conversation. The box drives it from there: chat. Its modes are default (asks to go past its workspace sandbox), untrusted (asks before untrusted commands), read only, and bypass (no sandbox, nothing asked). It has no terminal switch. +- **OpenCode, Gemini CLI and Cursor Agent** speak the Agent Client Protocol + (`opencode acp`, `gemini --acp`, `cursor-agent acp`), and so does any agent + whose preset in a repository's config has an [`acp`](/reference/config): + the box opens its session (with its own login, for Cursor Agent), streams + its reply, cancels its turn, switches its own modes, and brings its + permission requests to the chat; *Bypass* has the box allow them. The agent keeps working while berthd restarts or upgrades, and the record keeps every streamed word even if the agent is killed. The box needs the -`driver.claude` capability (`driver.codex` for Codex), and `driver.switch` +`driver.claude` capability (`driver.codex` for Codex, `driver.acp` for ACP +agents), and `driver.switch` for the terminal (upgrade it). diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index a9d3751f..c625c63c 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -227,7 +227,8 @@ every agent but a structured one, or `claude` (Claude Code driven through its stream); boxes that say it list `driver` in `capabilities`. `POST sessions` and `POST tasks` take `driver: "claude"` with `agent: "claude"` (or `driver: "codex"` with `agent: "codex"`, Codex's app server, on boxes -listing `driver.codex`), +listing `driver.codex`, or `driver: "acp"` with an agent whose preset has +`acp`, on boxes listing `driver.acp`), and `mode`, the permission mode it starts in (`bypassPermissions` has the box allow what it asks; questions and plans still wait), on boxes that list `driver.claude`. A waiting structured session's `ask` carries `choices` diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 848ed96d..9ac60aaa 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -42,6 +42,7 @@ guide is in [project config](/guides/project-config). | `name` | string | Shown in the app. | | `command` | string | What to run. | | `prompt_flag` | string | How the agent takes a first prompt, when it needs a flag (`--prompt`). Empty: the prompt is the last argument. | +| `acp` | string | What follows `command` to start the agent's Agent Client Protocol server (`acp`, `--acp`). With it the agent can run as a [structured agent](/guides/labs#structured-agents). Given with an `id` and no `command`, it adds that to a built-in agent. | The built-ins are offered when their command is installed on the box: `claude`, `codex`, `opencode` (`--prompt`), `gemini` (`-i`) and `cursor` diff --git a/internal/box/acpdriver.go b/internal/box/acpdriver.go new file mode 100644 index 00000000..81d1ee9a --- /dev/null +++ b/internal/box/acpdriver.go @@ -0,0 +1,961 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/integrations/adapters" + "github.com/cosscom/shipyard/internal/transcript" +) + +// The ACP driver: any agent that speaks the Agent Client Protocol +// (opencode, Gemini CLI, Cursor Agent, or a repository's own agent whose +// preset says how: AgentPreset.ACP), hosted by `berthd agent-host`. berthd +// opens its session (authenticating first when the agent asks for it), +// sends prompts, cancels turns, switches the agent's own modes, and brings +// its permission requests to the chat, picking among their options by kind, +// since each agent names them its own way. What else an agent asks of its +// client (files, terminals, its own extensions) gets an error: berthd +// offers none of those, so the agent uses its own tools. + +// DriverACP is a structured session of an ACP agent. +const DriverACP = "acp" + +// acpEnv is what an agent's ACP server needs to put its permissions to +// berthd: opencode allows everything unless told to ask. +var acpEnv = map[string][]string{ + "opencode": {`OPENCODE_CONFIG_CONTENT={"permission":{"bash":"ask","edit":"ask","webfetch":"ask"}}`}, +} + +// launchACPIn is what starts preset p's ACP server under an agent host. +func (b *Box) launchACPIn(p AgentPreset, mode, dir, sock string) (structuredLaunch, error) { + if p.ACP == "" { + return structuredLaunch{}, badRequest("berth doesn't know how to run %s over ACP: give its preset an acp", p.Name) + } + host, err := b.agentHostCommand() + if err != nil { + return structuredLaunch{}, err + } + words := make([]string, 0, len(host)+6) + for _, w := range host { + words = append(words, shellQuote(w)) + } + words = append(words, "--dir", shellQuote(dir), "--socket", shellQuote(sock), "--", p.Command, p.ACP) + bypass := "0" + if mode == "bypassPermissions" { + bypass = "1" + } + return structuredLaunch{ + mode: mode, agent: p.ID, + command: strings.Join(words, " "), + env: append([]string{"BERTH_DRIVER=structured"}, acpEnv[p.ID]...), + labels: map[string]string{labelDriver: DriverACP, labelConv: dir, labelSock: sock, labelBypass: bypass}, + }, nil +} + +// acpRequest is a permission an ACP agent asks for. +type acpRequest struct { + ID json.RawMessage + ToolCall struct { + Title string `json:"title"` + Kind string `json:"kind"` + RawInput struct { + Command string `json:"command"` + FilePath string `json:"filePath"` + Path string `json:"path"` + } `json:"rawInput"` + } `json:"toolCall"` + Options []struct { + OptionID string `json:"optionId"` + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"options"` +} + +// option is the id of the option of kind, or "". +func (r acpRequest) option(kinds ...string) string { + for _, k := range kinds { + for _, o := range r.Options { + if o.Kind == k { + return o.OptionID + } + } + } + return "" +} + +type acpConv struct { + b *Box + name string + agent string + cwd string + conv string + sock string + log string + + mu sync.Mutex + sessionID string + mode string // the agent's own mode + modes []string + modeConfig string // its mode's config option id, when it has one + canResume bool + canLoad bool + authMethod string + bypass bool + busy bool + since time.Time + draft strings.Builder + pending []acpRequest + replies map[string]chan json.RawMessage + sent map[string]string + prompts map[string]bool + next int + lastSeq int64 + exited bool + initialized bool + changed chan struct{} + following bool + caught chan struct{} + caughtOnce sync.Once + queue []func() + kick chan struct{} + gone bool +} + +type acpDriver struct { + b *Box + mu sync.Mutex + convs map[string]*acpConv +} + +func (b *Box) acp() *acpDriver { + b.acpOnce.Do(func() { b.acpDrv = &acpDriver{b: b, convs: map[string]*acpConv{}} }) + return b.acpDrv +} + +func (d *acpDriver) Kind() string { return DriverACP } + +func (d *acpDriver) forget(name string) { + d.mu.Lock() + c := d.convs[name] + delete(d.convs, name) + d.mu.Unlock() + if c != nil { + c.mu.Lock() + if !c.gone { + c.gone = true + close(c.kick) + } + c.mu.Unlock() + } +} + +func (d *acpDriver) conv(sess Session) (*acpConv, error) { + if sess.conv == "" || sess.sock == "" { + return nil, errors.New(sess.Name + " has no structured conversation") + } + d.mu.Lock() + c := d.convs[sess.Name] + if c == nil || c.conv != sess.conv { + c = &acpConv{b: d.b, name: sess.Name, agent: agentFor(sess), cwd: sess.Dir, conv: sess.conv, sock: sess.sock, + log: filepath.Join(sess.conv, agenthost.LogName), replies: map[string]chan json.RawMessage{}, sent: map[string]string{}, + prompts: map[string]bool{}, changed: make(chan struct{}), caught: make(chan struct{}), kick: make(chan struct{}, 1), bypass: sess.bypass} + if meta, ok := readConversation(sess.conv); ok && c.agent == "" { + c.agent = meta.Agent + } + d.convs[sess.Name] = c + go c.speak() + } + d.mu.Unlock() + c.mu.Lock() + if !c.following { + c.following = true + go c.follow() + } + c.mu.Unlock() + return c, nil +} + +func (c *acpConv) speak() { + for range c.kick { + c.mu.Lock() + q := c.queue + c.queue = nil + c.mu.Unlock() + for _, f := range q { + f() + } + } +} + +func (c *acpConv) signal() { + close(c.changed) + c.changed = make(chan struct{}) +} + +func (c *acpConv) follow() { + agenthost.ReadLog(c.log, 0, func(e agenthost.Entry) error { + c.apply(e, false) + return nil + }) + c.reconcile() + c.caughtOnce.Do(func() { close(c.caught) }) + var noHost time.Time + for { + c.mu.Lock() + from, done := c.lastSeq, c.exited + c.mu.Unlock() + if done { + return + } + err := agenthost.Attach(context.Background(), c.sock, from, func(e agenthost.Entry) error { + c.apply(e, true) + return nil + }) + if !errors.Is(err, agenthost.ErrNoHost) { + noHost = time.Time{} + time.Sleep(100 * time.Millisecond) + continue + } + agenthost.ReadLog(c.log, from, func(e agenthost.Entry) error { + c.apply(e, true) + return nil + }) + if noHost.IsZero() { + noHost = time.Now() + } + if time.Since(noHost) > hostGone { + c.mu.Lock() + c.following = false + c.mu.Unlock() + return + } + time.Sleep(200 * time.Millisecond) + } +} + +func (c *acpConv) reconcile() { + c.mu.Lock() + busy, pending, exited, greeted, lastSeq, bypass := c.busy, append([]acpRequest(nil), c.pending...), c.exited, c.initialized, c.lastSeq, c.bypass + c.mu.Unlock() + if exited { + return + } + if !greeted && lastSeq > 0 { + go c.handshake() + } + if bypass { + for _, r := range pending { + go c.allow(r, false) + } + pending = nil + } + if c.b.Turns == nil { + return + } + st, _ := c.b.Turns.State(c.name) + switch { + case len(pending) > 0 && st.State != "waiting": + c.event(adapters.Waiting, map[string]any{"reason": "permission"}, map[string]any{adapters.AskKey: pending[0].ask()}) + case busy && len(pending) == 0 && st.State != "running": + c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) + case !busy && (st.State == "running" || st.State == "waiting"): + c.event(adapters.Finished, nil, nil) + } +} + +func (c *acpConv) apply(e agenthost.Entry, live bool) { + c.mu.Lock() + defer c.mu.Unlock() + if e.Seq <= c.lastSeq { + return + } + c.lastSeq = e.Seq + var say []func() + defer func() { + c.signal() + if live && len(say) > 0 && !c.gone { + c.queue = append(c.queue, say...) + select { + case c.kick <- struct{}{}: + default: + } + } + }() + switch e.Dir { + case "host": + switch e.Event { + case "started": + c.busy, c.pending, c.since, c.exited, c.initialized = false, nil, time.Time{}, false, false + c.prompts = map[string]bool{} + if live { + say = append(say, func() { go c.handshake() }) + } + case "exited": + c.exited, c.busy, c.pending = true, false, nil + say = append(say, func() { c.event(adapters.Exited, nil, nil) }) + } + return + case "in": + var m codexMessage + if json.Unmarshal(e.Data, &m) != nil { + return + } + switch { + case m.Method == "initialize": + c.initialized = true + c.sent[string(m.ID)] = m.Method + case m.Method == "session/prompt": + c.sent[string(m.ID)] = m.Method + c.prompts[string(m.ID)] = true + if !c.busy { + c.busy, c.since = true, time.UnixMilli(e.At) + c.draft.Reset() + var p struct { + Prompt []struct { + Text string `json:"text"` + } `json:"prompt"` + } + json.Unmarshal(m.Params, &p) + var text []string + for _, b := range p.Prompt { + text = append(text, b.Text) + } + title := adapters.Title(strings.Join(text, "\n")) + say = append(say, func() { + if c.b.Turns != nil { + c.b.Turns.AwaitSent(c.name, 2*time.Second) + } + c.event(adapters.Started, map[string]any{"signal": "prompt"}, map[string]any{"title": title}) + }, c.changedConversation) + } + case m.Method != "" && len(m.ID) > 0: + c.sent[string(m.ID)] = m.Method + case m.Method == "" && len(m.ID) > 0: + c.drop(string(m.ID)) + } + return + case "out": + default: + return + } + var m codexMessage + if len(e.Data) == 0 || json.Unmarshal(e.Data, &m) != nil { + return + } + if m.Method == "" && len(m.ID) > 0 { + id := string(m.ID) + method := c.sent[id] + delete(c.sent, id) + switch method { + case "initialize": + var r struct { + AgentCapabilities struct { + LoadSession bool `json:"loadSession"` + SessionCapabilities struct { + Resume json.RawMessage `json:"resume"` + } `json:"sessionCapabilities"` + } `json:"agentCapabilities"` + AuthMethods []struct { + ID string `json:"id"` + } `json:"authMethods"` + } + if json.Unmarshal(m.Result, &r) == nil { + c.canLoad = r.AgentCapabilities.LoadSession + c.canResume = len(r.AgentCapabilities.SessionCapabilities.Resume) > 0 && string(r.AgentCapabilities.SessionCapabilities.Resume) != "null" + if len(r.AuthMethods) > 0 { + c.authMethod = r.AuthMethods[0].ID + } + } + case "session/new", "session/load", "session/resume": + c.readSession(m.Result) + var r struct { + SessionID string `json:"sessionId"` + } + json.Unmarshal(m.Result, &r) + if r.SessionID != "" { + c.sessionID = r.SessionID + } + if live && m.Error == nil && c.sessionID != "" { + sid, dir := c.sessionID, c.conv + say = append(say, func() { noteConversation(dir, func(x *Conversation) { x.AgentSessionID = sid }) }, func() { c.event(adapters.Ready, nil, nil) }) + } + case "session/set_config_option", "session/set_mode": + c.readSession(m.Result) + case "session/prompt": + delete(c.prompts, id) + if len(c.prompts) == 0 { + c.busy, c.pending, c.since = false, nil, time.Time{} + c.draft.Reset() + data := map[string]any{} + var r struct { + StopReason string `json:"stopReason"` + } + json.Unmarshal(m.Result, &r) + switch { + case m.Error != nil: + data["status"] = "error" + case r.StopReason == "cancelled": + data["status"] = "interrupted" + } + dir := c.conv + say = append(say, func() { c.event(adapters.Finished, data, nil) }, c.changedConversation, + func() { noteConversation(dir, func(x *Conversation) { x.Updated = time.Now().UTC() }) }) + } + } + if ch := c.replies[id]; ch != nil { + delete(c.replies, id) + if m.Error != nil { + msg, _ := json.Marshal(map[string]string{"error": m.Error.Message}) + ch <- msg + } else { + ch <- m.Result + } + } + return + } + if len(m.ID) > 0 && m.Method != "" { + if m.Method == "session/request_permission" { + var r acpRequest + if json.Unmarshal(m.Params, &r) == nil { + r.ID = m.ID + if live && c.bypass { + say = append(say, func() { c.allow(r, false) }) + return + } + c.pending = append(c.pending, r) + if live { + ask := r.ask() + say = append(say, func() { + c.event(adapters.Waiting, map[string]any{"reason": "permission"}, map[string]any{adapters.AskKey: ask}) + }) + } + } + return + } + if live { + id, method := m.ID, m.Method + say = append(say, func() { + c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32601, "message": "Shipyard doesn't answer " + method}}) + }) + } + return + } + if m.Method == "session/update" { + var p struct { + Update struct { + SessionUpdate string `json:"sessionUpdate"` + Content struct { + Text string `json:"text"` + } `json:"content"` + CurrentModeID string `json:"currentModeId"` + } `json:"update"` + } + json.Unmarshal(m.Params, &p) + switch p.Update.SessionUpdate { + case "agent_message_chunk": + if c.busy && c.draft.Len() < maxDraft { + c.draft.WriteString(p.Update.Content.Text) + } + case "tool_call": + c.draft.Reset() + say = append(say, c.changedConversation) + case "tool_call_update": + say = append(say, c.changedConversation) + case "current_mode_update": + if p.Update.CurrentModeID != "" { + c.mode = p.Update.CurrentModeID + } + } + } +} + +// readSession reads a session's modes from session/new (or a mode change's +// answer): its modes, or its mode config option. Called with c.mu held. +func (c *acpConv) readSession(raw json.RawMessage) { + var r struct { + Modes *struct { + CurrentModeID string `json:"currentModeId"` + AvailableModes []struct { + ID string `json:"id"` + } `json:"availableModes"` + } `json:"modes"` + ConfigOptions []struct { + ID string `json:"id"` + Category string `json:"category"` + CurrentValue string `json:"currentValue"` + Options []struct { + Value string `json:"value"` + } `json:"options"` + } `json:"configOptions"` + } + if json.Unmarshal(raw, &r) != nil { + return + } + for _, o := range r.ConfigOptions { + if o.Category != "mode" { + continue + } + c.modeConfig, c.mode, c.modes = o.ID, o.CurrentValue, nil + for _, v := range o.Options { + c.modes = append(c.modes, v.Value) + } + return + } + if r.Modes != nil { + c.mode, c.modes = r.Modes.CurrentModeID, nil + for _, m := range r.Modes.AvailableModes { + c.modes = append(c.modes, m.ID) + } + } +} + +func (c *acpConv) drop(id string) { + for i, r := range c.pending { + if string(r.ID) == id { + c.pending = append(c.pending[:i], c.pending[i+1:]...) + return + } + } +} + +// ask is what a permission asks, as a hook's ask is. +func (r acpRequest) ask() map[string]any { + t := r.ToolCall + tool := map[string]string{"execute": "Bash", "edit": "Edit", "delete": "Edit", "move": "Edit", "read": "Read", "fetch": "WebFetch"}[t.Kind] + input := firstNonEmpty(t.RawInput.Command, firstNonEmpty(t.RawInput.FilePath, t.RawInput.Path)) + ask := map[string]any{"tool": firstNonEmpty(tool, adapters.Clip(t.Title, adapters.AskLimit))} + if input != "" { + ask["input"] = adapters.Clip(input, adapters.AskLimit) + } else if t.Title != "" { + ask["message"] = adapters.Clip(t.Title, adapters.AskLimit) + } + return ask +} + +func (c *acpConv) event(typ string, data, private map[string]any) { + c.mu.Lock() + sid := c.sessionID + c.mu.Unlock() + if data == nil { + data = map[string]any{} + } + data["session"], data["path"] = c.name, c.cwd + if sid != "" { + data["agent_session_id"], data["session_id"] = sid, sid + } + c.b.agentEvent(context.Background(), c.agent, typ, data, private) +} + +func (c *acpConv) changedConversation() { + c.b.Events.Publish(events.Event{Type: events.TranscriptChanged, Box: c.b.Name, Data: map[string]any{"session": c.name, "name": c.name, "path": c.cwd}}) +} + +func (c *acpConv) write(ctx context.Context, v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + deadline := time.Now().Add(10 * time.Second) + for { + _, err := agenthost.Write(ctx, c.sock, b) + if !errors.Is(err, agenthost.ErrNoHost) { + return err + } + if time.Now().After(deadline) { + return ErrSessionExited + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +// call sends the agent a request; with wait 0 it doesn't wait for the +// answer (a prompt's comes at its turn's end), and returns its id. +func (c *acpConv) call(ctx context.Context, method string, params any, wait time.Duration) (json.RawMessage, error) { + select { + case <-c.caught: + case <-time.After(10 * time.Second): + return nil, httpError{http.StatusServiceUnavailable, "still reading " + c.name + "'s log back"} + } + c.mu.Lock() + c.next++ + id := "berth-" + strconv.Itoa(c.next) + "-" + newConvID() + ch := make(chan json.RawMessage, 1) + if wait > 0 { + c.replies[`"`+id+`"`] = ch + } + c.mu.Unlock() + defer func() { + c.mu.Lock() + delete(c.replies, `"`+id+`"`) + c.mu.Unlock() + }() + if err := c.write(ctx, map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { + return nil, err + } + if wait == 0 { + return nil, nil + } + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + select { + case r := <-ch: + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(r, &e) == nil && e.Error != "" { + return nil, httpError{http.StatusConflict, e.Error} + } + return r, nil + case <-ctx.Done(): + return nil, httpError{http.StatusGatewayTimeout, "the agent didn't answer " + method} + } +} + +// handshake greets a newly started agent and opens its session: the +// conversation's own, carried on, when it has one and the agent can. +func (c *acpConv) handshake() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + c.mu.Lock() + if c.initialized { + c.mu.Unlock() + return + } + c.initialized = true + sid, cwd := c.sessionID, c.cwd + c.mu.Unlock() + meta, _ := readConversation(c.conv) + if sid == "" { + sid = meta.AgentSessionID + } + if _, err := c.call(ctx, "initialize", map[string]any{ + "protocolVersion": 1, + "clientCapabilities": map[string]any{"fs": map[string]any{"readTextFile": false, "writeTextFile": false}, "terminal": false}, + "clientInfo": map[string]any{"name": "shipyard", "version": "1"}, + }, 60*time.Second); err != nil { + c.mu.Lock() + c.initialized = false + c.mu.Unlock() + return + } + open := func(method string, params map[string]any) error { + _, err := c.call(ctx, method, params, time.Minute) + c.mu.Lock() + auth := c.authMethod + c.mu.Unlock() + if err != nil && auth != "" && strings.Contains(strings.ToLower(err.Error()), "auth") { + // It uses its own login, once asked to (Cursor Agent). + if _, aerr := c.call(ctx, "authenticate", map[string]any{"methodId": auth}, time.Minute); aerr == nil { + _, err = c.call(ctx, method, params, time.Minute) + } + } + return err + } + base := map[string]any{"cwd": cwd, "mcpServers": []any{}} + c.mu.Lock() + canResume, canLoad := c.canResume, c.canLoad + c.mu.Unlock() + opened := false + if sid != "" && (canResume || canLoad) { + method := "session/load" + if canResume { + method = "session/resume" + } + params := map[string]any{"sessionId": sid} + for k, v := range base { + params[k] = v + } + c.mu.Lock() + c.sessionID = sid + c.mu.Unlock() + opened = open(method, params) == nil + if !opened { + c.mu.Lock() + c.sessionID = "" + c.mu.Unlock() + } + } + if !opened { + open("session/new", base) + } + if meta.Model != "" { + c.mu.Lock() + s := c.sessionID + c.mu.Unlock() + c.call(ctx, "session/set_config_option", map[string]any{"sessionId": s, "configId": "model", "value": meta.Model}, 30*time.Second) + } + if mode := meta.Mode; mode != "" && mode != "bypassPermissions" { + c.setMode(ctx, mode) + } +} + +func (c *acpConv) awaitSession(ctx context.Context) (string, error) { + deadline := time.After(time.Minute) + for { + c.mu.Lock() + sid, changed := c.sessionID, c.changed + c.mu.Unlock() + if sid != "" { + return sid, nil + } + select { + case <-changed: + case <-deadline: + return "", httpError{http.StatusServiceUnavailable, "the agent hasn't opened its session"} + case <-ctx.Done(): + return "", ctx.Err() + } + } +} + +func (c *acpConv) first() (acpRequest, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.pending) == 0 { + return acpRequest{}, false + } + return c.pending[0], true +} + +func (c *acpConv) choose(r acpRequest, optionID string) error { + result := map[string]any{"outcome": map[string]any{"outcome": "selected", "optionId": optionID}} + if optionID == "" { + result = map[string]any{"outcome": map[string]any{"outcome": "cancelled"}} + } + err := c.write(context.Background(), map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": result}) + if err == nil { + c.event(adapters.Started, map[string]any{"signal": "tool"}, nil) + } + return err +} + +// allow says yes to a permission, for this time or always. +func (c *acpConv) allow(r acpRequest, always bool) error { + if always { + return c.choose(r, r.option("allow_always", "allow_once")) + } + return c.choose(r, r.option("allow_once", "allow_always")) +} + +func (c *acpConv) pick(key string) error { + r, ok := c.first() + if !ok { + return httpError{http.StatusConflict, "the agent isn't asking for anything"} + } + switch key { + case "1", "y", "Y": + return c.allow(r, false) + case "2": + return c.allow(r, true) + case "3", "n", "N": + return c.choose(r, r.option("reject_once", "reject_always")) + } + return badRequest("pick 1, 2 or 3") +} + +func (c *acpConv) choices() []AskChoice { + r, ok := c.first() + if !ok { + return nil + } + var out []AskChoice + if r.option("allow_once") != "" { + out = append(out, AskChoice{Key: "1", Label: "Yes"}) + } + if r.option("allow_always") != "" { + out = append(out, AskChoice{Key: "2", Label: "Yes, and don't ask again this session"}) + } + if r.option("reject_once", "reject_always") != "" { + out = append(out, AskChoice{Key: "3", Label: "No"}) + } + return out +} + +func (c *acpConv) setMode(ctx context.Context, mode string) error { + sid, err := c.awaitSession(ctx) + if err != nil { + return err + } + c.mu.Lock() + config := c.modeConfig + c.mu.Unlock() + if config != "" { + _, err = c.call(ctx, "session/set_config_option", map[string]any{"sessionId": sid, "configId": config, "value": mode}, 30*time.Second) + } else { + _, err = c.call(ctx, "session/set_mode", map[string]any{"sessionId": sid, "modeId": mode}, 30*time.Second) + } + if err == nil { + c.mu.Lock() + c.mode = mode + c.mu.Unlock() + } + return err +} + +func (d *acpDriver) Send(ctx context.Context, sess Session, text string, enter bool) error { + c, err := d.conv(sess) + if err != nil { + return err + } + if r, waiting := c.first(); waiting { + if isKey(text) && !enter { + return c.pick(text) + } + if strings.TrimSpace(text) != "" { + if err := c.choose(r, r.option("reject_once", "reject_always")); err != nil { + return err + } + } + } + if !enter { + return badRequest("a structured session takes whole messages") + } + if strings.TrimSpace(text) == "" { + return nil + } + sid, err := c.awaitSession(ctx) + if err != nil { + return err + } + _, err = c.call(ctx, "session/prompt", map[string]any{"sessionId": sid, "prompt": []any{map[string]any{"type": "text", "text": text}}}, 0) + return err +} + +func (d *acpDriver) Interrupt(ctx context.Context, sess Session) (bool, error) { + c, err := d.conv(sess) + if err != nil { + return false, err + } + c.mu.Lock() + sid, busy := c.sessionID, c.busy + c.mu.Unlock() + if !busy || sid == "" { + return true, nil + } + if err := c.write(ctx, map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]any{"sessionId": sid}}); err != nil { + return false, err + } + deadline := time.After(4 * time.Second) + for { + c.mu.Lock() + busy, changed := c.busy, c.changed + c.mu.Unlock() + if !busy { + return true, nil + } + select { + case <-changed: + case <-deadline: + return false, nil + case <-ctx.Done(): + return false, ctx.Err() + } + } +} + +func (d *acpDriver) Controls(ctx context.Context, sess Session) (Controls, error) { + c, err := d.conv(sess) + if err != nil { + return Controls{Agent: agentFor(sess)}, err + } + c.mu.Lock() + defer c.mu.Unlock() + mode := c.mode + if c.bypass { + mode = "bypassPermissions" + } + return Controls{Agent: c.agent, Mode: mode, Modes: append(append([]string{}, c.modes...), "bypassPermissions")}, nil +} + +func (d *acpDriver) SetMode(ctx context.Context, sess Session, mode string) (string, error) { + c, err := d.conv(sess) + if err != nil { + return "", err + } + bypass := mode == "bypassPermissions" + if !bypass { + c.mu.Lock() + known := false + for _, m := range c.modes { + known = known || m == mode + } + c.mu.Unlock() + if !known { + return "", badRequest("unknown mode %q", mode) + } + if err := c.setMode(ctx, mode); err != nil { + return "", err + } + } + c.mu.Lock() + c.bypass = bypass + var allow []acpRequest + if bypass { + allow = append(allow, c.pending...) + } + c.mu.Unlock() + v := "0" + if bypass { + v = "1" + } + d.b.Sessions.tmux(ctx, "set-option", "-t", "="+sess.Name+":", labelBypass, v) + noteConversation(c.conv, func(m *Conversation) { m.Mode = mode }) + for _, r := range allow { + c.allow(r, false) + } + return mode, nil +} + +func (d *acpDriver) Answer(ctx context.Context, sess Session, req AnswerRequest) ([]string, error) { + return nil, httpError{http.StatusConflict, "this agent asks no questions in forms"} +} + +func (d *acpDriver) Conversation(ctx context.Context, sess Session, _ []Session, since int, gen string) (transcript.Result, error) { + if _, err := d.conv(sess); err != nil { + return transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: err.Error()}, nil + } + return reader().Follow(transcript.SourceACPHost, filepath.Join(sess.conv, agenthost.LogName), sess.Dir, max(since, 0), gen) +} + +func (d *acpDriver) Record(ctx context.Context, sess Session, _ []Session) (string, string) { + return transcript.SourceACPHost, filepath.Join(sess.conv, agenthost.LogName) +} + +func (d *acpDriver) Draft(ctx context.Context, sess Session) (Draft, error) { + c, err := d.conv(sess) + if err != nil { + return Draft{Agent: agentFor(sess)}, nil + } + c.mu.Lock() + defer c.mu.Unlock() + out := Draft{Agent: c.agent, Text: c.draft.String()} + if c.busy && len(c.pending) == 0 { + out.Status = &DraftStatus{Word: "Working…"} + if !c.since.IsZero() { + out.Status.Elapsed = time.Since(c.since).Round(time.Second).String() + } + } + return out, nil +} + +func (d *acpDriver) askChoices(sess Session) []AskChoice { + c, err := d.conv(sess) + if err != nil { + return nil + } + return c.choices() +} diff --git a/internal/box/acpdriver_test.go b/internal/box/acpdriver_test.go new file mode 100644 index 00000000..a52d551a --- /dev/null +++ b/internal/box/acpdriver_test.go @@ -0,0 +1,271 @@ +package box + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/agenthost" + "github.com/cosscom/shipyard/internal/transcript" +) + +// fakeACPAgent stands in for an agent speaking ACP, with the messages the +// spike recorded from opencode and Cursor Agent (design/structured-agents): +// it wants authenticate before a session, streams its replies as chunks, +// asks permission to "run the tests", works until cancelled on "work", has +// build and plan modes, and resumes a session by its id. +const fakeACPAgent = `#!/usr/bin/env python3 +import json, sys +state = {"authed": False, "session": None, "mode": "build", "cancelled": False} +srv = [0] +def out(d): + print(json.dumps(d), flush=True) +def update(u): + out({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": state["session"], "update": u}}) +def chunks(text): + for w in text.split(" "): + update({"sessionUpdate": "agent_message_chunk", "messageId": "m", "content": {"type": "text", "text": w + " "}}) +modes = {"currentModeId": "build", "availableModes": [{"id": "build", "name": "Build"}, {"id": "plan", "name": "Plan"}]} +def handle(m): + method, rid, p = m.get("method"), m.get("id"), m.get("params") or {} + if method == "initialize": + out({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": True, "sessionCapabilities": {"resume": {}}}, "authMethods": [{"id": "fake_login", "name": "Login"}]}}) + elif method == "authenticate": + state["authed"] = True + out({"jsonrpc": "2.0", "id": rid, "result": {}}) + elif method in ("session/new", "session/resume", "session/load"): + if not state["authed"]: + out({"jsonrpc": "2.0", "id": rid, "error": {"code": -32000, "message": "Authentication required"}}) + return + state["session"] = p.get("sessionId") or "ses_fake1" + res = {"modes": modes} if method == "session/new" else {} + if method == "session/new": + res["sessionId"] = state["session"] + out({"jsonrpc": "2.0", "id": rid, "result": res}) + elif method == "session/set_mode": + state["mode"] = p["modeId"] + out({"jsonrpc": "2.0", "id": rid, "result": {}}) + update({"sessionUpdate": "current_mode_update", "currentModeId": p["modeId"]}) + elif method == "session/cancel": + state["cancelled"] = True + elif method == "session/prompt": + text = p["prompt"][0]["text"] + if text == "run the tests": + update({"sessionUpdate": "tool_call", "toolCallId": "call_1", "title": "bash", "kind": "execute", "status": "pending", "rawInput": {}}) + update({"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "title": "pnpm test", "kind": "execute", "status": "in_progress", "rawInput": {"command": "pnpm test"}}) + rid0 = srv[0] + srv[0] += 1 + out({"jsonrpc": "2.0", "id": rid0, "method": "session/request_permission", "params": {"sessionId": state["session"], "toolCall": {"toolCallId": "call_1", "title": "pnpm test", "kind": "execute", "rawInput": {"command": "pnpm test"}}, "options": [{"optionId": "allow-once", "kind": "allow_once", "name": "Allow once"}, {"optionId": "allow-always", "kind": "allow_always", "name": "Always"}, {"optionId": "reject-once", "kind": "reject_once", "name": "Reject"}]}}) + r = None + for line in sys.stdin: + x = json.loads(line) + if x.get("id") == rid0 and "method" not in x: + r = x["result"]["outcome"] + break + handle(x) + ok = r.get("outcome") == "selected" and r.get("optionId", "").startswith("allow") + update({"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "status": "completed" if ok else "failed"}) + chunks("The tests pass." if ok else "I didn't run them.") + out({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "end_turn"}}) + elif text == "work": + chunks("Working through it") + for line in sys.stdin: + handle(json.loads(line)) + if state["cancelled"]: + break + state["cancelled"] = False + out({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "cancelled"}}) + else: + chunks("echo: " + text + " (" + state["mode"] + ")") + out({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "end_turn"}}) + elif rid is not None and method: + out({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "unknown"}}) +for line in sys.stdin: + handle(json.loads(line)) +` + +func TestAStructuredACPSessionIsDrivenThroughItsProtocol(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("the stand-in agent needs python3") + } + bin := t.TempDir() + os.WriteFile(filepath.Join(bin, "opencode"), []byte(fakeACPAgent), 0o755) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + exe, _ := os.Executable() + states := &Turns{} + var bx *Box + c, bus := servedBox(t, func(b *Box) { + bx = b + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + var sess Session + if status := call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "opencode", "driver": "acp", "prompt": "hello there"}, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + if sess.Driver != DriverACP { + t.Fatalf("session = %+v", sess) + } + name := sess.Name + until := func(what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for !ok() { + if time.Now().After(deadline) { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + st, _ := states.State(name) + t.Fatalf("%s never happened: %+v / %+v", what, res.Items, st) + } + time.Sleep(50 * time.Millisecond) + } + } + count := func(text string) int { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + n := 0 + for _, it := range res.Items { + if strings.Contains(it.Text, text) { + n++ + } + } + return n + } + state := func(want string) func() bool { + return func() bool { st, _ := states.State(name); return st.State == want } + } + + // It authenticated, opened its session and answered. + until("the first reply", func() bool { return count("echo: hello there (build)") == 1 }) + until("the first turn's end", state("finished")) + if st, _ := states.State(name); st.AgentSessionID != "ses_fake1" { + t.Fatalf("its session = %q", st.AgentSessionID) + } + + // Its own modes, and bypass beside them. + var controls Controls + call(t, c, "GET", "/v1/sessions/"+name+"/controls", "", nil, &controls) + if controls.Mode != "build" || strings.Join(controls.Modes, ",") != "build,plan,bypassPermissions" { + t.Fatalf("controls = %+v", controls) + } + call(t, c, "POST", "/v1/sessions/"+name+"/mode", "", map[string]string{"mode": "plan"}, nil) + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "and now", "when": "now"}, nil) + until("a reply in plan mode", func() bool { return count("echo: and now (plan)") == 1 }) + until("its turn's end", state("finished")) + + // A permission, picked by its kind. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "run the tests", "when": "now"}, nil) + until("the permission", state("waiting")) + if st, _ := states.State(name); st.Ask == nil || st.Ask.Tool != "Bash" || st.Ask.Input != "pnpm test" { + t.Fatalf("ask = %+v", st.Ask) + } + var list []Session + call(t, c, "GET", "/v1/sessions", "", nil, &list) + if len(list) != 1 || list[0].Ask == nil || len(list[0].Ask.Choices) != 3 { + t.Fatalf("choices: %+v", list) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "3", "enter": false, "when": "now", "force": true}, nil) + until("the refusal's reply", func() bool { return count("I didn't run them.") == 1 }) + until("its turn's end", state("finished")) + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+name+"/transcript?since=0", "", nil, &res) + ran := false + for _, it := range res.Items { + for _, tc := range it.Items { + ran = ran || (tc.Verb == "Run" && tc.Target == "pnpm test") + } + } + if !ran { + t.Fatalf("the command isn't a step: %+v", res.Items) + } + + // Bypass: berthd allows it. + call(t, c, "POST", "/v1/sessions/"+name+"/mode", "", map[string]string{"mode": "bypassPermissions"}, nil) + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "run the tests", "when": "now"}, nil) + until("the bypassed run's reply", func() bool { return count("The tests pass.") == 1 }) + until("its turn's end", state("finished")) + + // The reply being written, then a cancel. + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "work", "when": "now"}, nil) + until("the draft", func() bool { + var d Draft + call(t, c, "GET", "/v1/sessions/"+name+"/draft", "", nil, &d) + return strings.Contains(d.Text, "Working through it") + }) + var stopped map[string]bool + call(t, c, "POST", "/v1/sessions/"+name+"/interrupt", "", nil, &stopped) + if !stopped["stopped"] { + t.Fatalf("interrupt: %v", stopped) + } + until("the cancelled turn's end", state("finished")) + + // Ended and resumed: the same session, picked up by its id. + live, err := bx.Sessions.Get(t.Context(), name) + if err != nil { + t.Fatal(err) + } + agenthost.Stop(t.Context(), live.sock) + until("the session's end", func() bool { + s, err := bx.Sessions.Get(t.Context(), name) + return err == nil && s.Exited + }) + if status := call(t, c, "POST", "/v1/sessions/"+name+"/resume", "", nil, nil); status != 200 { + t.Fatalf("resume: %d", status) + } + call(t, c, "POST", "/v1/sessions/"+name+"/send", "", map[string]any{"text": "again", "when": "now"}, nil) + until("a reply once resumed", func() bool { return count("echo: again") == 1 }) + if st, _ := states.State(name); st.AgentSessionID != "ses_fake1" { + t.Fatalf("resumed into %q", st.AgentSessionID) + } +} + +// TestALiveACPSessionWorks drives a real ACP agent: BERTH_LIVE_ACP names +// its preset (opencode, cursor, gemini), BERTH_LIVE_ACP_MODEL its model. +func TestALiveACPSessionWorks(t *testing.T) { + agent := os.Getenv("BERTH_LIVE_ACP") + if agent == "" { + t.Skip("set BERTH_LIVE_ACP=opencode (or cursor, gemini) to drive a real ACP agent") + } + exe, _ := os.Executable() + states := &Turns{} + c, bus := servedBox(t, func(b *Box) { + b.Turns = states + b.ConversationsDir = t.TempDir() + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + }) + states.Attach(bus) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + req := map[string]any{"location": "acme", "agent": agent, "driver": "acp", "mode": "bypassPermissions", "prompt": "Reply with just the word: harbour. Use no tools."} + if m := os.Getenv("BERTH_LIVE_ACP_MODEL"); m != "" { + req["model"] = m + } + var sess Session + if status := call(t, c, "POST", "/v1/sessions", "", req, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + deadline := time.Now().Add(4 * time.Minute) + for { + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+sess.Name+"/transcript?since=0", "", nil, &res) + st, _ := states.State(sess.Name) + for _, it := range res.Items { + if it.Kind == "text" && strings.Contains(strings.ToLower(it.Text), "harbour") && st.State == "finished" { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("no reply: %+v / %+v", res.Items, st) + } + time.Sleep(time.Second) + } +} diff --git a/internal/box/api.go b/internal/box/api.go index 573213d6..951983b6 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -68,6 +68,8 @@ type Box struct { claudeDrv *claudeDriver codexOnce sync.Once codexDrv *codexDriver + acpOnce sync.Once + acpDrv *acpDriver // Hooks, when set, may refuse actions through "before:" hooks. Hooks *hooks.Runner // Flows runs the box's and its repositories' automations. @@ -819,10 +821,10 @@ func wantStructured(driver string) (string, error) { switch driver { case "", DriverTerminal: return "", nil - case DriverClaude, DriverCodex: + case DriverClaude, DriverCodex, DriverACP: return driver, nil } - return "", badRequest("unknown driver %q: terminal, claude or codex", driver) + return "", badRequest("unknown driver %q: terminal, claude, codex or acp", driver) } // launchStructured is what starts preset p under a structured driver, in a @@ -831,6 +833,15 @@ func (b *Box) launchStructured(kind string, p AgentPreset, model, effort, mode s if kind == DriverClaude { return b.launchClaude(p, model, effort, mode, "") } + if kind == DriverACP { + dir, sock, err := b.newConversation() + if err != nil { + return structuredLaunch{}, err + } + l, err := b.launchACPIn(p, mode, dir, sock) + l.model = model + return l, err + } if p.ID != "codex" { return structuredLaunch{}, badRequest("the codex driver runs Codex; %s runs in its terminal", p.Name) } diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 06fb0ebf..788a17aa 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -1044,6 +1044,8 @@ func (b *Box) follow(sess Session) { b.claude().conv(sess) case DriverCodex: b.codex().conv(sess) + case DriverACP: + b.acp().conv(sess) } } @@ -1052,6 +1054,7 @@ func (b *Box) follow(sess Session) { func (b *Box) forgetConversation(name string) { b.claude().forget(name) b.codex().forget(name) + b.acp().forget(name) } // awaitHost waits up to d for a session's host to listen. diff --git a/internal/box/conversations.go b/internal/box/conversations.go index 8d7131c9..60c9ef7a 100644 --- a/internal/box/conversations.go +++ b/internal/box/conversations.go @@ -194,9 +194,9 @@ func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { if sid == "" { sid = conversationSession(sess.conv) } - if meta, _ := readConversation(sess.conv); sid == "" && meta.Agent != "codex" { - // Codex opens its thread again itself (codexdriver.go); Claude needs - // its conversation named. + if meta, _ := readConversation(sess.conv); sid == "" && meta.Agent == "claude" { + // Codex and ACP agents open their sessions again themselves; Claude + // needs its conversation named. return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} } l, err := b.relaunch(ctx, sess.Location, sess.conv, sess.sock, sid, modeFor(sess)) @@ -222,6 +222,13 @@ func (b *Box) resumeInPlace(r *http.Request, sess Session, sid string) error { // server, which opens its thread again itself. func (b *Box) relaunch(ctx context.Context, location, dir, sock, sid, mode string) (structuredLaunch, error) { meta, _ := readConversation(dir) + if meta.Agent != "claude" && meta.Agent != "codex" && meta.Agent != "" { + p, ok := b.presetAt(ctx, location, meta.Agent) + if !ok { + return structuredLaunch{}, badRequest("this box has no %s preset", meta.Agent) + } + return b.launchACPIn(p, firstNonEmpty(mode, meta.Mode), dir, sock) + } if meta.Agent == "codex" { p, ok := b.presetAt(ctx, location, "codex") if !ok { @@ -304,7 +311,7 @@ func (b *Box) resumeConversation(w http.ResponseWriter, r *http.Request) error { return httpError{http.StatusGone, "the folder it worked in, " + conv.Dir + ", is gone"} } sid := conversationSession(dir) - if sid == "" && conv.Agent != "codex" { + if sid == "" && conv.Agent == "claude" { return httpError{http.StatusConflict, "berthd doesn't know which Claude conversation this is"} } sock, err := hostSocket(filepath.Base(dir)) diff --git a/internal/box/driver.go b/internal/box/driver.go index 6932dc96..9eae1a25 100644 --- a/internal/box/driver.go +++ b/internal/box/driver.go @@ -65,6 +65,8 @@ func (b *Box) driverFor(sess Session) Driver { return b.claude() case DriverCodex: return b.codex() + case DriverACP: + return b.acp() } return terminalDriver{b} } diff --git a/internal/box/tasks.go b/internal/box/tasks.go index 7438b3c1..1512f8f5 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -33,6 +33,11 @@ type AgentPreset struct { // them for a built-in agent by giving its id and no command. Models []string `json:"models,omitempty"` Efforts []string `json:"efforts,omitempty"` + // ACP is what follows the command to start the agent's Agent Client + // Protocol server ("acp", "--acp"): with it, the agent can run + // structured (acpdriver.go). A repository can give an agent of its own + // one, or a built-in one by its id. + ACP string `json:"acp,omitempty"` } // builtinAgents are the agent CLIs berth knows how to start, by binary. @@ -45,9 +50,9 @@ var builtinAgents = []AgentPreset{ Models: []string{"opus", "sonnet", "haiku"}, Efforts: []string{"low", "medium", "high", "xhigh", "max"}}, {ID: "codex", Name: "Codex", Command: "codex", ModelFlag: "--model", EffortFlag: "-c model_reasoning_effort=", Efforts: []string{"minimal", "low", "medium", "high"}}, - {ID: "opencode", Name: "OpenCode", Command: "opencode", PromptFlag: "--prompt", ModelFlag: "--model"}, - {ID: "gemini", Name: "Gemini CLI", Command: "gemini", PromptFlag: "-i"}, - {ID: "cursor", Name: "Cursor Agent", Command: "cursor-agent", ModelFlag: "--model"}, + {ID: "opencode", Name: "OpenCode", Command: "opencode", PromptFlag: "--prompt", ModelFlag: "--model", ACP: "acp"}, + {ID: "gemini", Name: "Gemini CLI", Command: "gemini", PromptFlag: "-i", ACP: "--acp"}, + {ID: "cursor", Name: "Cursor Agent", Command: "cursor-agent", ModelFlag: "--model", ACP: "acp"}, } // Presets are the built-in agents this box has, then the location's own from @@ -77,6 +82,9 @@ func Presets(loc *Location) []AgentPreset { if own.Efforts != nil { out[i].Efforts = own.Efforts } + if own.ACP != "" { + out[i].ACP = own.ACP + } continue } out[i] = own @@ -432,6 +440,8 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { choices = b.claude().askChoices(*s) case DriverCodex: choices = b.codex().askChoices(*s) + case DriverACP: + choices = b.acp().askChoices(*s) } { if len(choices) > 0 { diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index 5dde0609..96a844a6 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -170,7 +170,9 @@ func (b *Box) Capabilities() []string { // conversations: GET /v1/conversations, POST .../{id}/resume and // POST /v1/sessions/{name}/resume (conversations.go). // driver.codex: "driver": "codex", Codex's app server (codexdriver.go). - caps = append(caps, "driver.claude", "driver.codex", "driver.switch", "conversations") + // driver.acp: "driver": "acp", an agent whose preset says how it + // speaks ACP (acpdriver.go). + caps = append(caps, "driver.claude", "driver.codex", "driver.acp", "driver.switch", "conversations") } if b.Artifacts != nil { // artifacts: berthd artifact add, a worktree's artifacts and their diff --git a/internal/transcript/acphost.go b/internal/transcript/acphost.go new file mode 100644 index 00000000..5024f035 --- /dev/null +++ b/internal/transcript/acphost.go @@ -0,0 +1,171 @@ +package transcript + +import ( + "encoding/json" + "strings" +) + +// SourceACPHost is a structured agent's own log when the agent speaks the +// Agent Client Protocol (opencode, Gemini CLI, Cursor Agent…), kept by +// berthd agent-host: berthd's prompts in, the agent's session updates out. +const SourceACPHost = "acp-host" + +// acpHostParser reads an agent-host log of an ACP agent. A reply streams +// as chunks: they are gathered and kept as one item once something else +// happens (a tool, the turn's end, the next prompt). +type acpHostParser struct { + text strings.Builder + textOff int64 + // loading is the session/load being answered: what the agent replays + // for it is already in the log. + loading string + prompts map[string]bool +} + +type acpUpdate struct { + SessionUpdate string `json:"sessionUpdate"` + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + ToolCallID string `json:"toolCallId"` + Title string `json:"title"` + Kind string `json:"kind"` + Status string `json:"status"` + RawInput struct { + Command string `json:"command"` + FilePath string `json:"filePath"` + Path string `json:"path"` + } `json:"rawInput"` + Locations []struct { + Path string `json:"path"` + } `json:"locations"` +} + +func (p *acpHostParser) flush(c *conv) { + if t := strings.TrimSpace(p.text.String()); t != "" { + off := c.lineOff + c.lineOff = p.textOff + c.add(Item{Kind: "text", ID: c.id(), Text: clip(t, maxText)}) + c.lineOff = off + } + p.text.Reset() +} + +func (p *acpHostParser) line(c *conv, b []byte) { + var e hostEntry + if json.Unmarshal(b, &e) != nil || len(e.Data) == 0 || e.Data[0] != '{' { + return + } + var m codexRPC + if json.Unmarshal(e.Data, &m) != nil { + return + } + if e.At != 0 { + c.lineAt = e.At + } + id := string(m.ID) + switch e.Dir { + case "in": + switch m.Method { + case "session/prompt": + var params struct { + Prompt []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"prompt"` + } + json.Unmarshal(m.Params, ¶ms) + var parts []string + for _, b := range params.Prompt { + if b.Text != "" { + parts = append(parts, b.Text) + } + } + p.flush(c) + if text := strings.TrimSpace(strings.Join(parts, "\n")); text != "" { + userText(c, text) + } + if p.prompts == nil { + p.prompts = map[string]bool{} + } + p.prompts[id] = true + case "session/load": + p.loading = id + } + return + case "out": + default: + return + } + if m.Method == "" { + // An answer: a prompt's is its turn's end; a load's ends its replay. + if id == p.loading { + p.loading = "" + } + if p.prompts[id] { + delete(p.prompts, id) + p.flush(c) + } + return + } + if m.Method != "session/update" || p.loading != "" { + return + } + var params struct { + Update acpUpdate `json:"update"` + } + if json.Unmarshal(m.Params, ¶ms) != nil { + return + } + u := params.Update + switch u.SessionUpdate { + case "agent_message_chunk": + if p.text.Len() == 0 { + p.textOff = c.lineOff + } + if p.text.Len() < maxText { + p.text.WriteString(u.Content.Text) + } + case "tool_call": + p.flush(c) + c.call(u.ToolCallID, acpToolCall(c, u)) + if u.Status == "completed" || u.Status == "failed" { + c.result(u.ToolCallID, e.At) + } + case "tool_call_update": + // A call announced bare ("bash") says what it runs as it goes. + if u.RawInput.Command != "" || u.RawInput.FilePath != "" || u.RawInput.Path != "" || len(u.Locations) > 0 { + c.retitle(u.ToolCallID, acpToolCall(c, u)) + } + if u.Status == "completed" || u.Status == "failed" { + c.result(u.ToolCallID, e.At) + } + } +} + +// acpToolCall is a tool call as a step: its kind says what it does. +func acpToolCall(c *conv, u acpUpdate) ToolCall { + target := firstNonEmpty(u.RawInput.Command, u.Title) + path := firstNonEmpty(u.RawInput.FilePath, u.RawInput.Path) + if path == "" && len(u.Locations) > 0 { + path = u.Locations[0].Path + } + switch u.Kind { + case "read": + if path != "" { + return ToolCall{Verb: "Read", Target: rel(c.dir, path), File: true} + } + return ToolCall{Verb: "Read", Target: clip(firstLine(target), 80)} + case "edit", "delete", "move": + if path != "" { + return ToolCall{Verb: "Edit", Target: rel(c.dir, path), File: true} + } + return ToolCall{Verb: "Edit", Target: clip(firstLine(target), 80)} + case "search": + return ToolCall{Verb: "Search", Target: clip(firstLine(target), 80)} + case "fetch": + return ToolCall{Verb: "Fetch", Target: clip(firstLine(target), 80)} + } + return ToolCall{Verb: "Run", Target: clip(firstLine(target), 80)} +} diff --git a/internal/transcript/acphost_test.go b/internal/transcript/acphost_test.go new file mode 100644 index 00000000..e0a06fde --- /dev/null +++ b/internal/transcript/acphost_test.go @@ -0,0 +1,40 @@ +package transcript + +import ( + "path/filepath" + "strings" + "testing" +) + +// acp_host_opencode.jsonl is an agent-host log of opencode speaking ACP +// (recorded, then cut down and made anonymous): a prompt, a shell command +// it asked permission for and ran, and its streamed reply. +func TestAStructuredACPSessionsLogReadsAsItsConversation(t *testing.T) { + path := filepath.Join("testdata", "acp_host_opencode.jsonl") + res, err := NewReader().Read(SourceACPHost, path, "/home/dev/spike/repo-acp", 0) + if err != nil { + t.Fatal(err) + } + var got []string + for _, it := range res.Items { + s := it.Kind + switch it.Kind { + case "text", "user": + s += ":" + it.Text + case "tools": + for _, c := range it.Items { + s += ":" + c.Verb + " " + c.Target + } + if !it.Done { + s += " (running)" + } + } + got = append(got, s) + } + want := "user:Run the shell command `echo spike > made_by_agent.txt` in the current directory, then reply DONE. Do not ask me questions.\n" + + "tools:Run echo spike > made_by_agent.txt\n" + + "text:DONE" + if res.Source != "acp" || strings.Join(got, "\n") != want { + t.Fatalf("source %q, items:\n%s", res.Source, strings.Join(got, "\n")) + } +} diff --git a/internal/transcript/history.go b/internal/transcript/history.go index 512a5be3..c6ab15d4 100644 --- a/internal/transcript/history.go +++ b/internal/transcript/history.go @@ -35,6 +35,8 @@ func parserFor(source string) parser { return &hostParser{} case SourceCodexHost: return &codexHostParser{} + case SourceACPHost: + return &acpHostParser{} } return &claudeParser{} } diff --git a/internal/transcript/host.go b/internal/transcript/host.go index 4dcc6312..5497bff1 100644 --- a/internal/transcript/host.go +++ b/internal/transcript/host.go @@ -72,6 +72,8 @@ func shownSource(source string) string { return "claude" case SourceCodexHost: return "codex" + case SourceACPHost: + return "acp" } return source } diff --git a/internal/transcript/testdata/acp_host_opencode.jsonl b/internal/transcript/testdata/acp_host_opencode.jsonl new file mode 100644 index 00000000..e43ba06c --- /dev/null +++ b/internal/transcript/testdata/acp_host_opencode.jsonl @@ -0,0 +1,35 @@ +{"seq":1,"at":1791900001000,"dir":"in","data":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":false},"clientInfo":{"name":"berth-spike","version":"0.0.0"}}}} +{"seq":2,"at":1791900002000,"dir":"out","data":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"sessionCapabilities":{"close":{},"fork":{},"list":{},"resume":{}}},"authMethods":[{"description":"Run `opencode auth login` in the terminal","name":"Login with opencode","id":"opencode-login"}],"agentInfo":{"name":"OpenCode","version":"1.18.21"}}}} +{"seq":3,"at":1791900003000,"dir":"in","data":{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/home/dev/spike/repo-acp","mcpServers":[]}}} +{"seq":4,"at":1791900004000,"dir":"out","data":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_00000000000000000000000004"}}} +{"seq":5,"at":1791900005000,"dir":"in","data":{"jsonrpc":"2.0","id":3,"method":"session/set_model","params":{"sessionId":"ses_00000000000000000000000004","modelId":"opencode/big-pickle"}}} +{"seq":6,"at":1791900006000,"dir":"out","data":{"jsonrpc":"2.0","id":3,"result":{}}} +{"seq":7,"at":1791900007000,"dir":"in","data":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"ses_00000000000000000000000004","prompt":[{"type":"text","text":"Run the shell command `echo spike > made_by_agent.txt` in the current directory, then reply DONE. Do not ask me questions."}]}}} +{"seq":8,"at":1791900008000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"}]}}}} +{"seq":9,"at":1791900009000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":"The"}}}}} +{"seq":10,"at":1791900010000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" user wants me to run a"}}}}} +{"seq":11,"at":1791900011000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" shell command. But the instructions"}}}}} +{"seq":12,"at":1791900012000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" say I"}}}}} +{"seq":13,"at":1791900013000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" should not really do file"}}}}} +{"seq":14,"at":1791900014000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" operations with"}}}}} +{"seq":15,"at":1791900015000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" bash. But they"}}}}} +{"seq":16,"at":1791900016000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" explicitly asked."}}}}} +{"seq":17,"at":1791900017000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" Simple"}}}}} +{"seq":18,"at":1791900018000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":" task"}}}}} +{"seq":19,"at":1791900019000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c6444b0013Nvwktm20XYP26","content":{"type":"text","text":"."}}}}} +{"seq":20,"at":1791900020000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/home/dev/spike/repo-acp"}],"rawInput":{"cwd":"/home/dev/spike/repo-acp"}}}}} +{"seq":21,"at":1791900021000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","status":"in_progress","kind":"execute","title":"echo spike > made_by_agent.txt","locations":[{"path":"/home/dev/spike/repo-acp"}],"rawInput":{"command":"echo spike > made_by_agent.txt","cwd":"/home/dev/spike/repo-acp"}}}}} +{"seq":22,"at":1791900022000,"dir":"out","data":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"ses_00000000000000000000000004","toolCall":{"toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","title":"echo spike > made_by_agent.txt","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"echo spike > made_by_agent.txt"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"seq":23,"at":1791900023000,"dir":"in","data":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"seq":24,"at":1791900024000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","status":"in_progress","kind":"execute","title":"echo spike > made_by_agent.txt","locations":[{"path":"/home/dev/spike/repo-acp"}],"rawInput":{"command":"echo spike > made_by_agent.txt","cwd":"/home/dev/spike/repo-acp"}}}}} +{"seq":25,"at":1791900025000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_a6tgbjap96qjv667uflwuf5f","status":"completed","title":"echo spike > made_by_agent.txt","content":[{"type":"content","content":{"type":"text","text":"(no output)"}}],"rawOutput":{"output":"(no output)","metadata":{"output":"(no output)","exit":0,"truncated":false}}}}}} +{"seq":26,"at":1791900026000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"The"}}}}} +{"seq":27,"at":1791900027000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":" command ran"}}}}} +{"seq":28,"at":1791900028000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":". Now reply"}}}}} +{"seq":29,"at":1791900029000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":" D"}}}}} +{"seq":30,"at":1791900030000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"ONE"}}}}} +{"seq":31,"at":1791900031000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"."}}}}} +{"seq":32,"at":1791900032000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"D"}}}}} +{"seq":33,"at":1791900033000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_134c650eb00192lO7Pi8RQEjR6","content":{"type":"text","text":"ONE"}}}}} +{"seq":34,"at":1791900034000,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_00000000000000000000000004","update":{"sessionUpdate":"usage_update","used":16718,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}} +{"seq":35,"at":1791900035000,"dir":"out","data":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","usage":{"inputTokens":206,"outputTokens":3,"totalTokens":16730,"thoughtTokens":9,"cachedReadTokens":16512},"_meta":{}}}} diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 0c079220..30f2f900 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -315,6 +315,21 @@ func (c *conv) call(toolID string, tc ToolCall) { } } +// retitle says what a call does once its agent says more: its target, in +// the group it is in, when it does the same kind of thing. +func (c *conv) retitle(toolID string, tc ToolCall) { + i, ok := c.byTool[toolID] + it := c.at(i) + if !ok || it == nil || it.Kind != "tools" || it.Verb != tc.Verb { + return + } + for j := range it.Items { + if it.Items[j].ID == toolID { + it.Items[j].Target, it.Items[j].File = tc.Target, tc.File + } + } +} + // launched marks a helper as running in the background: its tool call // answers at once, and it is back only when its notification says so. func (c *conv) launched(toolID string) { diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 20187506..50a55133 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -215,7 +215,7 @@ export interface Session { // How the box reaches the agent ("driver" capability): "terminal", its // own screen in tmux, or "claude", Claude Code driven through its stream // ("driver.claude" capability). - driver?: "terminal" | "claude" | "codex" | string; + driver?: "terminal" | "claude" | "codex" | "acp" | string; // Where a structured session can switch ("driver.switch" capability): // "terminal", to Claude Code's own screen, or "chat", back from it. POST // sessions/{name}/switch with { to } switches it. @@ -398,6 +398,9 @@ export interface AgentPreset { // it has them). Leaving one out means the CLI's default. models?: string[]; efforts?: string[]; + // What starts its Agent Client Protocol server ("acp", "--acp"): with it, + // the agent can run structured on boxes listing "driver.acp". + acp?: string; } // AgentPath is where a box found an agent CLI: through the person's shell @@ -478,7 +481,7 @@ export interface TaskRequest { // Code structured ("driver.claude" capability); mode is a structured // agent's permission mode to start in ("bypassPermissions": berthd allows // what it asks, questions and plans aside). - driver?: "terminal" | "claude" | "codex"; + driver?: "terminal" | "claude" | "codex" | "acp"; mode?: string; }