diff --git a/design/structured-agents/plan.md b/design/structured-agents/plan.md new file mode 100644 index 00000000..bec3d619 --- /dev/null +++ b/design/structured-agents/plan.md @@ -0,0 +1,164 @@ +# Structured agents: plan + +Status: 2026-10-13. Phase 0 (spike) done: go. Phase 1 (driver interface) in progress. The spike's recordings join the repo as test fixtures in phase 3, once reviewed. + +## Why + +Today every agent is its TUI in tmux, and the conversation view is rebuilt from +the agent's own record files (`~/.claude/projects/*.jsonl`, Codex rollouts), +stat-polled, plus screen-scraping (`screen-status.ts`, `screen.ts`) and +keystroke-driving (`answer.go`, `controls.go`, `orchestrate.go` paste+Enter). + +Every comparable app (t3code, Vibe Kanban, Sculptor, Paseo, Superset, Nimbalyst, +Orca's structured mode) instead drives the agent over a structured channel and +keeps its own record: + +- Claude: `claude -p` stream-json in/out (Vibe Kanban, Sculptor) or the Agent SDK + on top of the same protocol (t3code, Paseo, Superset). +- Codex: `codex app-server` (JSON-RPC over stdio), unanimously. +- Others: ACP (Agent Client Protocol). +- Own store (SQLite / log); only the agent's session id is kept, for `--resume`. +- CLI transcripts are read only for history import, never as the live feed. + +Policy (checked 2026-10-13): running the real, unmodified `claude` binary with the +user's own login, including `claude -p` and stream-json, is allowed and draws +from subscription limits (support article 15036540, updates 2026-06-15 and +2026-10-07). Never read, copy or proxy the user's credentials. Risk: the paused +June change would have moved `-p`/SDK usage off subscription limits; if it comes +back, the TUI path is the fallback, so the driver stays a setting. + +## Decisions (from the user) + +| | | +|---|---| +| Default | Chat first; "Open in terminal" hands the same session to the TUI via `--resume` | +| Claude link | Raw CLI stream-json spoken by berthd in Go (no Node, no SDK) | +| v1 scope | Claude + Codex (app-server) + a generic ACP driver for the rest | +| ACP agents | Gemini CLI, opencode, Cursor/Copilot/others: one config-driven driver | +| History | berthd's own event log per box; agent session id kept for resume | +| Approvals | Default bypass (berthd auto-allows each request in default mode; works as root); a global setting (repo/kit can override) | +| Rollout | Spike first, then phased PRs behind Labs | + +## Shape + +``` +app ──SSE/API──▶ berthd ──unix socket──▶ berthd agent-host (in tmux) ──stdio──▶ claude -p / codex app-server / ACP agent + │ │ + └── index (SQLite) └── events.jsonl (append-only, berth's own format) +``` + +- The **driver** turns agent protocol into `transcript.Item` upserts (the model + the app already renders) plus live state (busy/idle/waiting, partial text, + status) and requests (approval, question, plan). +- The **agent-host** owns the agent's stdio inside a tmux session, so an agent + survives a berthd restart/upgrade or a dropped link (none of the desktop apps + solve this; it's the Berth-specific part). berthd reconnects and replays the + log from an offset. + +## Phases + +### 0. Spike (throwaway, scratch dir, against claude 2.1.296 and the boxes' codex) + +Answer, with recorded protocol fixtures (sanitised) kept for tests: + +- [ ] Long-lived multi-turn `claude -p --input-format stream-json --output-format stream-json --verbose --include-partial-messages` +- [ ] Control requests: interrupt, set permission mode, set model +- [ ] `--permission-mode bypassPermissions --permission-prompt-tool stdio`: do AskUserQuestion and ExitPlanMode still reach us (Vibe Kanban relies on this)? +- [ ] Bypass when the box user is root (VPS): does Claude refuse, and what does it need? +- [ ] `/compact`, custom commands, skills; `system/init.slash_commands`; which built-ins refuse +- [ ] Images/attachments as content blocks +- [ ] berth's installed hooks: do they fire under `-p`? (avoid double turn events) +- [ ] Session hand-off: stream session → `claude --resume ` TUI → back; `--fork-session`; `--resume-session-at ` +- [ ] Kill mid-turn, resume: what's lost +- [ ] Codex `app-server`: thread/start, turn/start, turn/interrupt, approvals, resume +- [ ] ACP handshake with `gemini --experimental-acp` and `opencode acp` + +Exit: a go/no-go note per item; any no-go changes the phases below. + +### 1. Driver interface (no behaviour change) + +- `internal/agentdrv`: `Driver{Start, Send(prompt, attachments), Interrupt, SetMode, SetModel, Respond(requestID, decision), Stop, Events()}`. +- Wrap today's path as `ptyDriver` (tmux keys + transcript tail) so the + conversation pane talks to one API regardless of driver. +- Box capability `driver`; the app keeps today's code path for older boxes. + +### 2. Agent-host + event log + +- `berthd agent-host` in the agent's tmux session: spawns the agent, owns stdio, + appends to `/conversations//events.jsonl` (versioned), serves a + unix socket. Lock: one agent process per conversation, ever. +- Keep streamed partial text in the log (a killed agent loses it). Stop = + interrupt, then SIGTERM, then kill the process tree (SIGKILL leaves Bash + children running). +- SQLite index: conversation id, location/worktree, agent, driver, agent session + id, title, status, cost, timestamps. +- Push to the app via the events bus (items in the event, not a "changed" ping); + no stat-polling for driver agents. +- Attaching to the tmux session shows a readable live render, so `berth attach` keeps working. + +### 3. Claude driver + +- Spawn: the flags above + `--resume ` when continuing. Always + `--permission-mode default --permission-prompt-tool stdio`; "bypass" is berthd + auto-allowing each request (Claude refuses the bypass flag as root without + `IS_SANDBOX=1`). Questions and plans always come to us. +- Set an env var (e.g. `BERTH_DRIVER=structured`) so `berthd hook` stays silent + for these sessions; the user's other hooks keep running. +- Read commands/models from the `initialize` control request; follow + `session_id` changes (`/clear` starts a new one). Merge assistant messages by + `message.id` (one per content block). +- Map messages → Items, reusing `internal/transcript/claude.go` where shapes + match; partial messages → live text (replaces `screen-status.ts` scraping). +- Approvals, AskUserQuestion, ExitPlanMode → cards (the `question` item exists); + no keystroke form-driving for these agents. +- Subagents via `parent_tool_use_id` → crew items. Usage/cost from `result` → + usage plugin. +- Slash commands from init; terminal-only ones offer "Open in terminal". +- Fork (`--fork-session`) and rewind (`--resume-session-at`). + +### 4. Terminal hand-off + +- "Open in terminal": at idle (or after interrupt) the host stops the driver + and runs `claude --resume ` in the same tmux session; the pane shows the + terminal and the conversation falls back to the transcript tail. +- "Back to chat": the TUI exits, the driver resumes the session; turns made in + the TUI are imported once from the transcript into the event log. + +### 5. Codex driver: `codex app-server` + +thread/start|resume, turn/start|interrupt, approvals → cards, items → Items. + +### 6. Generic ACP driver + +- Agents defined in config (box, kit, repo): + `{ "gemini": "gemini --acp", "opencode": "opencode acp", "cursor": "cursor-agent acp" }`. +- Per-agent quirks: `authenticate` when asked (cursor), resume via + `session/resume`, pick permission options by `kind`, usage optional, ignore + unknown vendor requests (`cursor/create_plan`). +- session/new|load, session/prompt, session/update → Items, + session/request_permission → cards (or auto-allow in bypass), session/cancel. +- Agents without ACP stay terminal-only. + +### 7. Flows onto the driver + +Judges, reviewers and `.ask` steps (`headless.go`) use the same driver, so they +get the event log, live view and cost. Read-only mode = a tool allow/deny list. + +### 8. Make it the default + +- New agents open in chat; Labs toggle removed. +- Settings: approval mode (global, default bypass), default driver + (structured | terminal: the fallback if `-p` billing changes). +- Transcript tailing stays for terminal-mode agents and importing old sessions. + +## Cross-cutting + +- **Protocol drift:** stream-json control messages are undocumented. Contract + tests on recorded fixtures, plus a scheduled CI job against the latest + `claude`/`codex` that fails loudly on unknown message types. +- **Bypass default:** this is a change from today (berth never adds + skip-permissions). Worktrees limit the blast radius; show the mode on every + agent; repo/kit can force `ask`. +- **Credentials:** never touch `~/.claude`/`~/.codex` auth; login stays the + user running `claude`/`codex` themselves (terminal hand-off covers `/login`). +- **Naming:** the Claude name/logo can't be part of the product's name or logo. diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index b846c14a..137ba6b6 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -221,7 +221,9 @@ failed after it went out (the box may have acted on it). The box API: `waiting`, `finished`), plus `preset` (the agent preset it was started with), `turn` (its current or last turn), `state_seq` and `fidelity` (`hooks`, `partial` or `screen`), `queued` (prompts held until it is idle) -and, while it waits, `ask` (what its hooks said it asks for). `exited` is +and, while it waits, `ask` (what its hooks said it asks for). `driver` +says how the box reaches the agent: `terminal` (its own screen in tmux) for +every agent today; boxes that say it list `driver` in `capabilities`. `exited` is true once the program has ended. `title` names the work: the first line of the prompt it started with, or of the first prompt it was sent or typed, cut to about 48 characters; `PATCH sessions/{name}` with `{ title }` renames it diff --git a/docs/reference/plugin-sdk.mdx b/docs/reference/plugin-sdk.mdx index 763354f4..ea848842 100644 --- a/docs/reference/plugin-sdk.mdx +++ b/docs/reference/plugin-sdk.mdx @@ -333,7 +333,7 @@ Go structs have them, in `packages/plugin-sdk/src/types.ts`: | `BoxStatus` | `name`, `address`, `network?`, `fingerprint`, `state` (`connecting`, `online`, `offline`, `untrusted`), `error?`, `latency_ms?`, `since`, `link?` (`slow?`, `reason?`, `max_ms?`, `jitter_ms?`, `path?`) | | `Location` | `name`, `path`, `repo`, `worktrees?`, `scripts`, `agents?`, `remote?`, `slug?`, `default_branch?`, `repo_trust?` (`none`, `trusted`, `untrusted` or `changed`) | | `Worktree` | `name`, `path`, `branch?`, `head?`, `main?`, `setting_up?`, `port?`, `title?`, `parent?` (the path of the worktree whose agent handed this one off) | -| `Session` | `name`, `location?`, `dir`, `command?`, `created`, `attached`, `exited`, `agent?`, `agent_state?`, `state_since?`, `preset?`, `turn?`, `state_seq?`, `fidelity?`, `queued?`, `ask?`, `title?` (what the work is called: the first line of its first prompt, or a rename) | +| `Session` | `name`, `location?`, `dir`, `command?`, `created`, `attached`, `exited`, `agent?`, `agent_state?`, `state_since?`, `preset?`, `turn?`, `state_seq?`, `fidelity?`, `queued?`, `ask?`, `title?` (what the work is called: the first line of its first prompt, or a rename), `driver?` (`terminal`) | | `Turn` | `id` (`"#"`), `session`, `agent?`, `n`, `origin?`, `sent_seq?`, `end_seq?`, `state` (`queued`, `pending`, `running`, `waiting`, `finished`, `exited`, `lost`), `queued?`, `started?`, `ended?`, `waits?` (each `{ start, end?, reason?, ask? }`), `fidelity?`, `idem_key?`, `status?` | | `Ask` | `tool?`, `input?` (the command or file path, at most 300 characters), `why?`, `message?` | | `QueuedPrompt` | `turn`, `preview` (the first 280 characters), `length`, `origin?`, `at` | diff --git a/internal/box/answer.go b/internal/box/answer.go index 3cc35162..3389e9fe 100644 --- a/internal/box/answer.go +++ b/internal/box/answer.go @@ -58,7 +58,7 @@ func (b *Box) answerSession(w http.ResponseWriter, r *http.Request) error { if req.Tool == "" { return badRequest("say which question to answer (tool)") } - sess, path, err := b.claudeRecord(r) + sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) if err != nil { return err } @@ -73,24 +73,10 @@ func (b *Box) answerSession(w http.ResponseWriter, r *http.Request) error { } defer mu.(*sync.Mutex).Unlock() - res, err := reader().Read("claude", path, sess.Dir, 0) + shown, err := b.driverFor(sess).Answer(r.Context(), sess, req) if err != nil { return err } - it, open := transcript.OpenQuestion(res, req.Tool) - switch { - case it.Kind == "": - return httpError{http.StatusNotFound, "that question isn't in Claude's conversation"} - case !open: - return httpError{http.StatusConflict, "those questions were already answered"} - } - shown, err := checkAnswers(it.Questions, req.Answers) - if err != nil { - return err - } - if err := driveAnswers(r.Context(), tmuxTerm{s: b.Sessions, target: "=" + sess.Name + ":"}, it.Questions, req.Answers); err != nil { - return err - } writeJSON(w, AnswerResult{Answered: shown}) return nil } diff --git a/internal/box/api.go b/internal/box/api.go index 82560f46..8a077355 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -55,6 +55,9 @@ type Box struct { // Turns is the turn ledger: what every agent session is doing, turn by // turn. Without it agents read as running. Turns *Turns + // Drivers replace a driver by kind (driver.go); nil, or a kind not in + // it, is the built-in one. + Drivers map[string]Driver // Hooks, when set, may refuse actions through "before:" hooks. Hooks *hooks.Runner // Flows runs the box's and its repositories' automations. diff --git a/internal/box/controls.go b/internal/box/controls.go index 2be3a3ad..685f3b7c 100644 --- a/internal/box/controls.go +++ b/internal/box/controls.go @@ -5,7 +5,6 @@ import ( "net/http" "regexp" "strings" - "time" "github.com/cosscom/shipyard/internal/events" "github.com/cosscom/shipyard/internal/integrations/adapters" @@ -84,11 +83,10 @@ var ( interrupted = regexp.MustCompile(`(?i)\binterrupted\b`) ) -// interruptSession answers POST /v1/sessions/{name}/interrupt: it presses -// Esc, as stopping an agent at its terminal does, waits for the agent to -// stop, and ends its turn in the ledger. Claude Code's hooks say nothing -// when a turn is interrupted, so without this the session would read as -// working until its next prompt. +// interruptSession answers POST /v1/sessions/{name}/interrupt: its driver +// stops the agent (Esc, in a terminal), and the turn ends in the ledger. +// Claude Code's hooks say nothing when a turn is interrupted, so without +// this the session would read as working until its next prompt. func (b *Box) interruptSession(w http.ResponseWriter, r *http.Request) error { name := r.PathValue("name") sess, err := b.Sessions.Get(r.Context(), name) @@ -105,22 +103,10 @@ func (b *Box) interruptSession(w http.ResponseWriter, r *http.Request) error { if b.Turns != nil { st, _ = b.Turns.State(name) } - if err := b.pressKeys(r.Context(), name, "Escape"); err != nil { + stopped, err := b.driverFor(sess).Interrupt(r.Context(), sess) + if err != nil { return err } - // The agent stops within a moment: its "esc to interrupt" goes, and - // Claude Code says "Interrupted". - stopped := false - deadline := time.Now().Add(4 * time.Second) - for !stopped && time.Now().Before(deadline) { - time.Sleep(150 * time.Millisecond) - screen, err := b.Sessions.Screen(r.Context(), name, 0) - if err != nil { - break - } - tail := lastLines(screen, 14) - stopped = !working.MatchString(tail) && adapters.ScreenState(screen) != "waiting" - } if stopped && b.Turns != nil && (st.State == "running" || st.State == "waiting") { b.Events.Publish(events.Event{Type: adapters.Finished, Box: b.Name, Origin: origin(r), Data: map[string]any{ "session": name, "path": sess.Dir, "agent": controlAgent(sess), "source": "interrupt", "status": "interrupted", @@ -182,28 +168,17 @@ func (b *Box) sessionControls(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - agent := controlAgent(sess) - out := Controls{Agent: agent} - if !sess.Exited { - screen, err := b.Sessions.capture(r.Context(), sess.Name, 0) - if err != nil { - return err - } - if agent == "claude" { - out = claudeControls(screen) - out.Modes = []string{"default", "acceptEdits", "plan", "auto", "bypassPermissions"} - } else if m := screenLimit.FindAllString(lastLines(screen, 20), -1); m != nil { - out.Limit = strings.TrimSpace(m[len(m)-1]) - } + out, err := b.driverFor(sess).Controls(r.Context(), sess) + if err != nil { + return err } writeJSON(w, out) return nil } -// setMode answers POST /v1/sessions/{name}/mode {"mode":"plan"}: it presses -// Shift+Tab, as a person cycling Claude Code's modes does, until its screen -// shows that mode, and says where it ended up. A mode the session can't -// reach (bypass, unless it was started allowing it) is a 409. +// setMode answers POST /v1/sessions/{name}/mode {"mode":"plan"}: the +// session's driver switches the mode (Shift+Tab in a terminal), and it says +// where it ended up. A mode the session can't reach is a 409. func (b *Box) setMode(w http.ResponseWriter, r *http.Request) error { var req struct { Mode string `json:"mode"` @@ -219,11 +194,8 @@ func (b *Box) setMode(w http.ResponseWriter, r *http.Request) error { if sess.Exited { return ErrSessionExited } - if controlAgent(sess) != "claude" { - return badRequest("only Claude Code's mode can be switched from here; use its terminal") - } if b.Turns != nil { - // Shift+Tab at a permission prompt picks an option: never there. + // A mode change at a permission prompt can pick an option there. if st, ok := b.Turns.State(name); ok && st.State == "waiting" { return httpError{http.StatusConflict, "the agent is waiting for an answer; answer it first"} } @@ -231,46 +203,10 @@ func (b *Box) setMode(w http.ResponseWriter, r *http.Request) error { if err := b.before(r, "session.send", map[string]any{"name": name, "key": "btab", "mode": req.Mode}); err != nil { return err } - read := func() (string, error) { - screen, err := b.Sessions.Screen(r.Context(), name, 0) - if err != nil { - return "", err - } - if adapters.ScreenState(screen) == "waiting" { - return "", httpError{http.StatusConflict, "the agent is showing a question; answer it first"} - } - return claudeControls(screen).Mode, nil - } - mode, err := read() + mode, err := b.driverFor(sess).SetMode(r.Context(), sess, req.Mode) if err != nil { return err } - seen := map[string]bool{mode: true} - for presses := 0; mode != req.Mode && presses < 7; presses++ { - if err := b.pressKeys(r.Context(), name, "BTab"); err != nil { - return err - } - // The footer redraws within a moment. - next := mode - for wait := 0; wait < 10 && next == mode; wait++ { - time.Sleep(100 * time.Millisecond) - if next, err = read(); err != nil { - return err - } - } - mode = next - if mode != req.Mode && seen[mode] && presses > 0 { - break // round the whole cycle: not one this session has - } - seen[mode] = true - } - if mode != req.Mode { - msg := "this session doesn't offer that mode; it is in " + mode + " mode" - if req.Mode == "bypassPermissions" { - msg = "bypass needs Claude Code started with --dangerously-skip-permissions; this session is in " + mode + " mode" - } - return httpError{http.StatusConflict, msg} - } b.publish(r, "session.mode", map[string]any{"name": name, "mode": mode}) writeJSON(w, map[string]any{"mode": mode}) return nil diff --git a/internal/box/driver.go b/internal/box/driver.go new file mode 100644 index 00000000..74e00ab8 --- /dev/null +++ b/internal/box/driver.go @@ -0,0 +1,222 @@ +package box + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/cosscom/shipyard/internal/integrations/adapters" + "github.com/cosscom/shipyard/internal/transcript" +) + +// A Driver is how berthd reaches the agent in a session: how a prompt gets +// to it, how it is stopped, how its permission mode is read and changed, +// how its questions are answered, and where the chat reads its +// conversation. The API's handlers keep what is the same for every agent +// (gates, turns, the inbox, events) and leave the agent itself to its +// session's driver. +// +// Every session has the terminal driver today: the agent's own TUI in +// tmux, typed into, read from its screen, and its conversation read from +// the transcript file it writes. Structured drivers (Claude Code's +// stream-json, Codex's app-server, ACP) plug in beside it +// (design/structured-agents/plan.md). Claude Code and Codex write their +// transcript files under any driver, so what reads those files (a tool +// call's details, older pages, helpers, fork and rewind, touched files) +// keeps working for every session. +type Driver interface { + // Kind names the driver in the session list ("terminal"). + Kind() string + // Send gives the agent text, and submits it when enter is set. A + // single key with no Enter is an answer to a menu. + Send(ctx context.Context, sess Session, text string, enter bool) error + // Interrupt stops the agent's current turn, and says whether it did. + Interrupt(ctx context.Context, sess Session) (stopped bool, err error) + // Controls are the agent's permission mode and the modes it offers. + Controls(ctx context.Context, sess Session) (Controls, error) + // SetMode switches the permission mode, and returns the mode it is in. + SetMode(ctx context.Context, sess Session, mode string) (string, error) + // Answer answers the questions one AskUserQuestion call asked, and + // returns each answer as the agent shows it. + Answer(ctx context.Context, sess Session, req AnswerRequest) ([]string, error) + // Conversation is the chat's view of the session from item since. + // all is the box's sessions when the caller has just listed them. + Conversation(ctx context.Context, sess Session, all []Session, since int, gen string) (transcript.Result, error) +} + +// DriverTerminal is the driver every session has today. +const DriverTerminal = "terminal" + +// driverFor is the driver for a session's agent. +func (b *Box) driverFor(sess Session) Driver { + kind := firstNonEmpty(sess.Driver, DriverTerminal) + if d := b.Drivers[kind]; d != nil { + return d + } + return terminalDriver{b} +} + +// sendText gives the named session's agent text through its driver. +func (b *Box) sendText(ctx context.Context, name, text string, enter bool) error { + sess, err := b.Sessions.Get(ctx, name) + if err != nil { + return err + } + if sess.Exited { + return ErrSessionExited + } + return b.driverFor(sess).Send(ctx, sess, text, enter) +} + +// terminalDriver is the agent's TUI in its tmux pane. +type terminalDriver struct{ b *Box } + +func (terminalDriver) Kind() string { return DriverTerminal } + +func (t terminalDriver) Send(ctx context.Context, sess Session, text string, enter bool) error { + return t.b.Sessions.Send(ctx, sess.Name, text, enter) +} + +// Interrupt presses Esc, as stopping an agent at its terminal does, and +// waits for the screen to say it stopped: its "esc to interrupt" goes, and +// Claude Code says "Interrupted". +func (t terminalDriver) Interrupt(ctx context.Context, sess Session) (bool, error) { + if err := t.b.pressKeys(ctx, sess.Name, "Escape"); err != nil { + return false, err + } + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + time.Sleep(150 * time.Millisecond) + screen, err := t.b.Sessions.Screen(ctx, sess.Name, 0) + if err != nil { + return false, nil + } + if !working.MatchString(lastLines(screen, 14)) && adapters.ScreenState(screen) != "waiting" { + return true, nil + } + } + return false, nil +} + +// Controls reads the agent's footer. +func (t terminalDriver) Controls(ctx context.Context, sess Session) (Controls, error) { + agent := controlAgent(sess) + out := Controls{Agent: agent} + if sess.Exited { + return out, nil + } + screen, err := t.b.Sessions.capture(ctx, sess.Name, 0) + if err != nil { + return out, err + } + if agent == "claude" { + out = claudeControls(screen) + out.Modes = []string{"default", "acceptEdits", "plan", "auto", "bypassPermissions"} + } else if m := screenLimit.FindAllString(lastLines(screen, 20), -1); m != nil { + out.Limit = strings.TrimSpace(m[len(m)-1]) + } + return out, nil +} + +// SetMode presses Shift+Tab, as a person cycling Claude Code's modes does, +// until its screen shows that mode. A mode the session can't reach +// (bypass, unless it was started allowing it) is a 409. +func (t terminalDriver) SetMode(ctx context.Context, sess Session, want string) (string, error) { + if controlAgent(sess) != "claude" { + return "", badRequest("only Claude Code's mode can be switched from here; use its terminal") + } + read := func() (string, error) { + screen, err := t.b.Sessions.Screen(ctx, sess.Name, 0) + if err != nil { + return "", err + } + if adapters.ScreenState(screen) == "waiting" { + return "", httpError{http.StatusConflict, "the agent is showing a question; answer it first"} + } + return claudeControls(screen).Mode, nil + } + mode, err := read() + if err != nil { + return "", err + } + seen := map[string]bool{mode: true} + for presses := 0; mode != want && presses < 7; presses++ { + if err := t.b.pressKeys(ctx, sess.Name, "BTab"); err != nil { + return "", err + } + // The footer redraws within a moment. + next := mode + for wait := 0; wait < 10 && next == mode; wait++ { + time.Sleep(100 * time.Millisecond) + if next, err = read(); err != nil { + return "", err + } + } + mode = next + if mode != want && seen[mode] && presses > 0 { + break // round the whole cycle: not one this session has + } + seen[mode] = true + } + if mode != want { + msg := "this session doesn't offer that mode; it is in " + mode + " mode" + if want == "bypassPermissions" { + msg = "bypass needs Claude Code started with --dangerously-skip-permissions; this session is in " + mode + " mode" + } + return "", httpError{http.StatusConflict, msg} + } + return mode, nil +} + +// Answer finds the questions in Claude Code's record and fills in its form +// with keys (answer.go). +func (t terminalDriver) Answer(ctx context.Context, sess Session, req AnswerRequest) ([]string, error) { + path, err := t.b.claudeRecordOf(ctx, sess, nil) + if err != nil { + return nil, err + } + res, err := reader().Read("claude", path, sess.Dir, 0) + if err != nil { + return nil, err + } + it, open := transcript.OpenQuestion(res, req.Tool) + switch { + case it.Kind == "": + return nil, httpError{http.StatusNotFound, "that question isn't in Claude's conversation"} + case !open: + return nil, httpError{http.StatusConflict, "those questions were already answered"} + } + shown, err := checkAnswers(it.Questions, req.Answers) + if err != nil { + return nil, err + } + if err := driveAnswers(ctx, tmuxTerm{s: t.b.Sessions, target: "=" + sess.Name + ":"}, it.Questions, req.Answers); err != nil { + return nil, err + } + return shown, nil +} + +// Conversation reads the agent's own transcript file (internal/transcript), +// and asks the watch to say when it is written to. +func (t terminalDriver) Conversation(ctx context.Context, sess Session, all []Session, since int, gen string) (transcript.Result, error) { + agent, path, where := t.b.transcriptFile(ctx, sess, all) + none := func(reason string) (transcript.Result, error) { + return transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: reason}, nil + } + switch { + case agent != "claude" && agent != "codex": + return none("Shipyard reads Claude Code's and Codex's conversations; this session runs " + firstNonEmpty(agent, "no agent") + ".") + case path == "": + return none("No " + agent + " conversation for " + sess.Dir + " since " + sess.Created.Format(time.RFC3339) + " in " + where + ".") + } + // A chat reads this while it shows: tell it as soon as the agent writes. + t.b.transcriptWatch().seen(sess.Name, sess.Dir, path) + // gen is the reading since counts in: one the box let go is read + // afresh, and answered whole (transcript.Result.Gen). + res, err := reader().Follow(agent, path, sess.Dir, max(since, 0), gen) + if err != nil { + return none("Couldn't read " + path + ": " + err.Error()) + } + return res, nil +} diff --git a/internal/box/driver_test.go b/internal/box/driver_test.go new file mode 100644 index 00000000..b35de23f --- /dev/null +++ b/internal/box/driver_test.go @@ -0,0 +1,104 @@ +package box + +import ( + "context" + "os" + "path/filepath" + "slices" + "sync" + "testing" + + "github.com/cosscom/shipyard/internal/transcript" +) + +// recordingDriver stands in for a session's driver and notes each call. +type recordingDriver struct { + mu sync.Mutex + calls []string +} + +func (d *recordingDriver) note(s string) { + d.mu.Lock() + defer d.mu.Unlock() + d.calls = append(d.calls, s) +} + +func (d *recordingDriver) seen() []string { + d.mu.Lock() + defer d.mu.Unlock() + return slices.Clone(d.calls) +} + +func (d *recordingDriver) Kind() string { return DriverTerminal } + +func (d *recordingDriver) Send(_ context.Context, sess Session, text string, enter bool) error { + d.note("send " + sess.Name + " " + text) + return nil +} + +func (d *recordingDriver) Interrupt(_ context.Context, sess Session) (bool, error) { + d.note("interrupt " + sess.Name) + return true, nil +} + +func (d *recordingDriver) Controls(_ context.Context, sess Session) (Controls, error) { + d.note("controls " + sess.Name) + return Controls{Agent: "claude", Mode: "plan"}, nil +} + +func (d *recordingDriver) SetMode(_ context.Context, sess Session, mode string) (string, error) { + d.note("mode " + sess.Name + " " + mode) + return mode, nil +} + +func (d *recordingDriver) Answer(_ context.Context, sess Session, req AnswerRequest) ([]string, error) { + d.note("answer " + sess.Name + " " + req.Tool) + return []string{"Tea"}, nil +} + +func (d *recordingDriver) Conversation(_ context.Context, sess Session, _ []Session, since int, _ string) (transcript.Result, error) { + d.note("conversation " + sess.Name) + return transcript.Result{Source: "driver", Items: []transcript.Item{{Kind: "text", ID: "1", Text: "hello"}}}, nil +} + +func TestTheAgentIsReachedThroughItsSessionsDriver(t *testing.T) { + d := &recordingDriver{} + c, _ := servedBox(t, func(b *Box) { b.Drivers = map[string]Driver{DriverTerminal: d} }) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + bin := t.TempDir() + fake := filepath.Join(bin, "claude") + os.WriteFile(fake, []byte("#!/bin/sh\nexec cat\n"), 0o755) + call(t, c, "POST", "/v1/sessions", "", map[string]string{"location": "acme", "name": "agent", "command": fake}, nil) + + var list []Session + call(t, c, "GET", "/v1/sessions", "", nil, &list) + if len(list) != 1 || list[0].Driver != DriverTerminal { + t.Fatalf("the session doesn't say its driver: %+v", list) + } + + for _, step := range []struct { + method, path string + body any + }{ + {"POST", "/v1/sessions/agent/send", map[string]any{"text": "fix the build"}}, + {"POST", "/v1/sessions/agent/interrupt", nil}, + {"GET", "/v1/sessions/agent/controls", nil}, + {"POST", "/v1/sessions/agent/mode", map[string]string{"mode": "plan"}}, + {"POST", "/v1/sessions/agent/answer", map[string]any{"tool": "toolu_1", "answers": []any{map[string]any{"picks": []string{"Tea"}}}}}, + } { + if status := call(t, c, step.method, step.path, "", step.body, nil); status != 200 { + t.Fatalf("%s %s: %d", step.method, step.path, status) + } + } + var conv transcript.Result + call(t, c, "GET", "/v1/sessions/agent/transcript?since=0", "", nil, &conv) + if conv.Source != "driver" || len(conv.Items) != 1 { + t.Fatalf("the conversation didn't come from the driver: %+v", conv) + } + + want := []string{"send agent fix the build", "interrupt agent", "controls agent", "mode agent plan", "answer agent toolu_1", "conversation agent"} + if got := d.seen(); !slices.Equal(got, want) { + t.Fatalf("driver calls = %q, want %q", got, want) + } +} diff --git a/internal/box/history.go b/internal/box/history.go index 9a60fdab..638b8583 100644 --- a/internal/box/history.go +++ b/internal/box/history.go @@ -55,14 +55,21 @@ func (b *Box) claudeRecord(r *http.Request) (Session, string, error) { if err != nil { return Session{}, "", err } - agent, path, _ := b.transcriptFile(r, sess, all) + path, err := b.claudeRecordOf(r.Context(), sess, all) + return sess, path, err +} + +// claudeRecordOf is sess's Claude Code record, or why there is none. all +// is the box's sessions when the caller has just listed them. +func (b *Box) claudeRecordOf(ctx context.Context, sess Session, all []Session) (string, error) { + agent, path, _ := b.transcriptFile(ctx, sess, all) switch { case agent != "claude": - return sess, "", httpError{http.StatusBadRequest, "this works with Claude Code's conversations; this session runs " + firstNonEmpty(agent, "no agent")} + return "", httpError{http.StatusBadRequest, "this works with Claude Code's conversations; this session runs " + firstNonEmpty(agent, "no agent")} case path == "": - return sess, "", httpError{http.StatusNotFound, "this session's conversation can't be read yet"} + return "", httpError{http.StatusNotFound, "this session's conversation can't be read yet"} } - return sess, path, nil + return path, nil } // listHelpers answers GET /v1/sessions/{name}/subagents: the helpers the diff --git a/internal/box/inbox.go b/internal/box/inbox.go index 5087eb09..bd208d41 100644 --- a/internal/box/inbox.go +++ b/internal/box/inbox.go @@ -77,7 +77,7 @@ func (b *Box) sendQueued(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - if err := b.Sessions.Send(ctx, name, it.Text, it.Enter); err != nil { + if err := b.driverFor(sess).Send(ctx, sess, it.Text, it.Enter); err != nil { b.Turns.putBack(it) return err } diff --git a/internal/box/notify_text.go b/internal/box/notify_text.go index f3e0c1bb..d53fa68e 100644 --- a/internal/box/notify_text.go +++ b/internal/box/notify_text.go @@ -3,7 +3,6 @@ package box import ( "context" "fmt" - "net/http" "sort" "strconv" "strings" @@ -358,7 +357,7 @@ func (h *boxNotifyHost) describe(ctx context.Context, r *Report) { sumFiles(r) return } - agent, path, _ := b.transcriptFile(requestFor(ctx), sess, nil) + agent, path, _ := b.transcriptFile(ctx, sess, nil) if path == "" || (agent != "claude" && agent != "codex") { sumFiles(r) return @@ -379,11 +378,6 @@ func (h *boxNotifyHost) describe(ctx context.Context, r *Report) { sumFiles(r) } -func requestFor(ctx context.Context) *http.Request { - r, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) - return r -} - func relTo(dir, p string) string { if rest, ok := strings.CutPrefix(p, dir+"/"); ok { return rest diff --git a/internal/box/orchestrate.go b/internal/box/orchestrate.go index 9e483688..32cf77dc 100644 --- a/internal/box/orchestrate.go +++ b/internal/box/orchestrate.go @@ -218,7 +218,7 @@ func (b *Box) sendPrompt(ctx context.Context, name string, req SendRequest, orig if b.Turns != nil && req.When == "idle" && !b.Turns.Ready(name) { return hold() } - if err := b.Sessions.Send(ctx, name, req.Text, enter); err != nil { + if err := b.driverFor(sess).Send(ctx, sess, req.Text, enter); err != nil { return SendResult{}, err } // Only that something was sent: prompts never go into events. diff --git a/internal/box/phone.go b/internal/box/phone.go index b9e81bd6..e67e0204 100644 --- a/internal/box/phone.go +++ b/internal/box/phone.go @@ -297,7 +297,7 @@ func (p *Phone) Handler(b *Box, listenAddr string) http.Handler { writeError(w, statusFor(err), err.Error()) return } - if err := b.Sessions.Send(r.Context(), name, req.Text, req.Enter == nil || *req.Enter); err != nil { + if err := b.sendText(r.Context(), name, req.Text, req.Enter == nil || *req.Enter); err != nil { writeError(w, statusFor(err), err.Error()) return } diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 3f7023c7..64f8f3ad 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -60,6 +60,9 @@ type Session struct { // "terminal": true): the service's name, kept as @berth_service. It is // never an agent, whatever it runs. Service string `json:"service,omitempty"` + // Driver is how berthd reaches the session's agent (driver.go): + // "terminal" for every agent today. Empty when it runs no agent. + Driver string `json:"driver,omitempty"` // Scope is the systemd scope unit its program runs in (@berth_scope), // on boxes where sessions get one; Usage what its processes use, in // the session list. diff --git a/internal/box/tasks.go b/internal/box/tasks.go index 6389307a..8d68a42f 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -379,6 +379,7 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { if s.Agent == "" { continue } + s.Driver = b.driverFor(*s).Kind() if s.Exited { if b.Turns != nil { b.Turns.Exited(s.Name) diff --git a/internal/box/touched.go b/internal/box/touched.go index cbb95a64..649a00da 100644 --- a/internal/box/touched.go +++ b/internal/box/touched.go @@ -107,7 +107,7 @@ func (b *Box) touchedIn(r *http.Request, wt Worktree) []FileTurn { if _, inside := relInside(root, dir); !inside && dir != root { continue } - agent, path, _ := b.transcriptFile(r, s, sessions) + agent, path, _ := b.transcriptFile(r.Context(), s, sessions) if path == "" || (agent != "claude" && agent != "codex") { continue } diff --git a/internal/box/transcriptapi.go b/internal/box/transcriptapi.go index cc2d39ef..6a27d450 100644 --- a/internal/box/transcriptapi.go +++ b/internal/box/transcriptapi.go @@ -1,12 +1,12 @@ package box import ( + "context" "errors" "net/http" "path/filepath" "strconv" "sync" - "time" "github.com/cosscom/shipyard/internal/transcript" ) @@ -15,7 +15,7 @@ import ( // conversation, for the conversation and for a tool call's details alike. // all is the box's sessions when the caller has just listed them (each // list is a tmux command); nil lists them here. -func (b *Box) transcriptFile(r *http.Request, sess Session, all []Session) (agent, path, where string) { +func (b *Box) transcriptFile(ctx context.Context, sess Session, all []Session) (agent, path, where string) { // Sessions.Get doesn't name the agent (the list does, in enrich): the // preset a session was started with, else its command's first word, // which is all a session from before presets has. @@ -41,10 +41,10 @@ func (b *Box) transcriptFile(r *http.Request, sess Session, all []Session) (agen // Every Claude session in this folder gets its own transcript, in // the folder of the account it started on (the usage plugin's // accounts set CLAUDE_CONFIG_DIR per box or project). - configDir := b.Sessions.EnvVar(r.Context(), sess, "CLAUDE_CONFIG_DIR") + configDir := b.Sessions.EnvVar(ctx, sess, "CLAUDE_CONFIG_DIR") claims := []transcript.Claim{{Name: sess.Name, ID: id, Started: sess.Created, ConfigDir: configDir}} if all == nil { - all, _ = b.Sessions.List(r.Context()) + all, _ = b.Sessions.List(ctx) } for _, o := range all { if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" { @@ -59,12 +59,12 @@ func (b *Box) transcriptFile(r *http.Request, sess Session, all []Session) (agen oid = st.AgentSessionID } } - claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")}) + claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(ctx, o, "CLAUDE_CONFIG_DIR")}) } path = transcript.AssignClaude(sess.Dir, claims)[sess.Name] where = transcript.ClaudeDirIn(configDir, sess.Dir) case "codex": - codexHome := b.Sessions.EnvVar(r.Context(), sess, "CODEX_HOME") + codexHome := b.Sessions.EnvVar(ctx, sess, "CODEX_HOME") path = transcript.CodexPathIn(codexHome, sess.Dir, id, sess.Created) where = filepath.Join(firstNonEmpty(codexHome, "~/.codex"), "sessions") } @@ -89,28 +89,16 @@ func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - since, _ := strconv.Atoi(r.URL.Query().Get("since")) - agent, path, where := b.transcriptFile(r, sess, all) - none := func(reason string) error { - writeJSON(w, transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: reason}) - return nil - } - switch { - case agent != "claude" && agent != "codex": - return none("Shipyard reads Claude Code's and Codex's conversations; this session runs " + firstNonEmpty(agent, "no agent") + ".") - case path == "": - return none("No " + agent + " conversation for " + sess.Dir + " since " + sess.Created.Format(time.RFC3339) + " in " + where + ".") - } if r.URL.Query().Has("before") { - return olderPage(w, r, agent, path, sess.Dir) + // Older pages come from the agent's own record, under any driver. + if agent, path, _ := b.transcriptFile(r.Context(), sess, all); path != "" && (agent == "claude" || agent == "codex") { + return olderPage(w, r, agent, path, sess.Dir) + } } - // A chat reads this while it shows: tell it as soon as the agent writes. - b.transcriptWatch().seen(sess.Name, sess.Dir, path) - // gen is the reading since counts in: one the box let go is read - // afresh, and answered whole (transcript.Result.Gen). - res, err := transcripts.Follow(agent, path, sess.Dir, max(since, 0), r.URL.Query().Get("gen")) + since, _ := strconv.Atoi(r.URL.Query().Get("since")) + res, err := b.driverFor(sess).Conversation(r.Context(), sess, all, since, r.URL.Query().Get("gen")) if err != nil { - return none("Couldn't read " + path + ": " + err.Error()) + return err } writeJSON(w, res) return nil @@ -124,7 +112,7 @@ func (b *Box) toolDetail(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - agent, path, _ := b.transcriptFile(r, sess, all) + agent, path, _ := b.transcriptFile(r.Context(), sess, all) if path == "" { return httpError{http.StatusNotFound, "this session's conversation can't be read"} } diff --git a/internal/box/turns.go b/internal/box/turns.go index 6e3af0ee..0771fbf6 100644 --- a/internal/box/turns.go +++ b/internal/box/turns.go @@ -1511,7 +1511,7 @@ func (b *Box) deliverInbox(ctx context.Context) { continue } unlock := b.lockSend(it.Session) - if err := b.Sessions.Send(ctx, it.Session, it.Text, it.Enter); err != nil { + if err := b.sendText(ctx, it.Session, it.Text, it.Enter); err != nil { b.Turns.failQueued(it, err.Error()) unlock() continue diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index 1a7b789d..c22d5168 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -147,7 +147,9 @@ func (b *Box) Capabilities() []string { // /v1/locations/{name}/worktrees/{worktree} names one, worktreetitles.go). // agents.paths: info says where each agent CLI was found // (agent_paths), and POST /v1/agents/refresh looks again (agentpaths.go). - caps := []string{"transcript", "diff", "titles", "sample", "history", "commands", "service.terminal", "answer", "session.home", "files", "files.dir", "agents.install", "worktree.titles", "agents.paths", "login"} + // driver: agent sessions say how berthd reaches their agent ("driver", + // driver.go). + caps := []string{"transcript", "diff", "titles", "sample", "history", "commands", "service.terminal", "answer", "session.home", "files", "files.dir", "agents.install", "worktree.titles", "agents.paths", "login", "driver"} if b.Turns != nil { // controls: POST .../keys, .../interrupt and .../mode, GET // .../controls (controls.go). diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index f6ee1ffa..3a628167 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -212,6 +212,9 @@ export interface Session { // true, from boxes with the "service.terminal" capability): the service's // name. It never runs an agent, and closing its tab never stops it. service?: string; + // How the box reaches the agent ("driver" capability): "terminal", its + // own screen in tmux, for every agent today. + driver?: "terminal" | string; // The systemd scope its processes run in, on boxes where sessions get // one (Linux with systemd), and what they use: memory (bytes, page cache // included), its ceiling if the box sets one (Settings → Boxes), processor