From b1caabeb6cb280effd0efdde70c492b290422085 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 13 Oct 2026 12:33:17 +0100 Subject: [PATCH] refactor(box): agents are reached through a driver, so structured ones can plug in Sending, interrupting, reading and switching the permission mode, answering questions and reading the conversation now go through the session's driver. Today's tmux path is the "terminal" driver, with the same behaviour as before. Agent sessions say their driver ("driver" in the session list, and the "driver" capability), and design/structured-agents/plan.md lays out the structured drivers that come next. Co-Authored-By: Claude Opus 5.5 --- design/structured-agents/plan.md | 164 +++++++++++++++++++++++ docs/reference/app-api.mdx | 4 +- docs/reference/plugin-sdk.mdx | 2 +- internal/box/answer.go | 18 +-- internal/box/api.go | 3 + internal/box/controls.go | 92 ++----------- internal/box/driver.go | 222 +++++++++++++++++++++++++++++++ internal/box/driver_test.go | 104 +++++++++++++++ internal/box/history.go | 15 ++- internal/box/inbox.go | 2 +- internal/box/notify_text.go | 8 +- internal/box/orchestrate.go | 2 +- internal/box/phone.go | 2 +- internal/box/sessions.go | 3 + internal/box/tasks.go | 1 + internal/box/touched.go | 2 +- internal/box/transcriptapi.go | 40 ++---- internal/box/turns.go | 2 +- internal/box/upgrade.go | 4 +- packages/plugin-sdk/src/types.ts | 3 + 20 files changed, 554 insertions(+), 139 deletions(-) create mode 100644 design/structured-agents/plan.md create mode 100644 internal/box/driver.go create mode 100644 internal/box/driver_test.go 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