feat(box): an agent can ask for you without stopping, and gets your answer as its next prompt

Draft
ST
3d ago
+2489-5

Stacked on #219 (attention items on the box): this branch is #219's three commits plus four of its own. Review the last four, or the diff against 5b1b834.

Summary

A question form stops an agent until someone answers. For what needs a person but can wait (a PR ready to merge, a key only they can rotate), an agent now posts an ask and carries on. When the person answers or closes it, the box types that into the agent as its next prompt.

sequenceDiagram
    participant Agent
    participant Box as berthd
    participant App as Shipyard (a plugin on berth.attention)
    Agent->>Box: berth_ask / berthd ask {kind, title, details?, options?, recommended?}
    Box-->>Agent: {key: session:<name>, revision, status: open} (at once)
    Note over Agent: ends its turn, or keeps working
    App->>Box: PATCH /v1/attention/session:<name> {answer}
    Box->>Box: before:session.send gate, then save with telling = who settled it
    Box->>Agent: prompt held until idle, idem key attention:<key>@<settled>
    Box->>Box: told โ†’ telling cleared (each sweep retries until it is)
 internal/box/
   attentionapi.go
+    POST /v1/sessions/{name}/attention   # AgentAsk: strict decode, kind โˆˆ decision|action|ready|blocked,
+                                          # options only on a decision, at the session's own key
     PATCH /v1/attention/{key}
+      settling a session's ask โ†’ before:session.send for that session
+      tellAskers()                        # also from every sweep: survives a restart, never twice
   attention.go
+    AttentionItem.telling                 # set at settle, cleared once the box holds the prompt
   api.go
+    decodeStrict                          # decodeLimit that refuses unknown fields, naming them
 internal/mcpserver/mcp.go
+  berth_ask                               # additionalProperties: false, returns at once
 internal/boxcmd/askcmd.go
+  berthd ask --kind K --title T [--details D] [--option "LABEL[: what it means]"]... [--recommended L]
 internal/integrations/skills/berth/SKILL.md
+  "Need the user but can go on? Ask without stopping"

An ask is words and nothing else: no link or command for a button to follow, and every kind but a decision gets the app's own buttons. The box refuses an unknown kind and any field it doesn't name, so an agent can't sneak in a url or a command.

Evidence

Live, real Claude Code on a dev box (.claude/skills/verify-berthd/scripts/scenarios/ask-live.mjs on dev-box.sh). Session asker runs with only this build's berthd mcp (--strict-mcp-config) and calls berth_ask. Session ask runs bin/berthd ask. A small test plugin on berth.attention shows both asks, and the scenario clicks in it:

Both asks, posted while each agent kept goingAfter Monday on the decision and Close on the ready
two asksanswered and closed
- asker's ask is on the box (key session:asker, by mcp), and its turn ended without an answer;
  mcp-posted.txt: {"key":"session:asker","replaced":false,"revision":1,"status":"open"}
- mcp-answer.txt: Monday
- cli-answer.txt: The user closed your ask in Shipyard (ready: "PR #12 is ready to merge") without answering it (resolved). ...
- asker: turn asker#2, origin laptop:devbox, idem_key attention:session:asker@2026-11-06T13:35:01.840177069Z, finished
- ask:   turn ask#2,   origin laptop:devbox, idem_key attention:session:ask@2026-11-06T13:35:01.938993468Z
OK, console clean

Each agent wrote the answer it got as its next prompt to a file, using its own Bash tool. The scenario reads the file back, and also the turn the box recorded (prompted by the person's laptop, under the ask's idempotency key).

Unit tests (go test ./internal/box -run 'AnAgentsAsk|ASettledAsk|SettlingAnAsk|AnswerText', ./internal/mcpserver -run AnAsk):

  • A repeated ask replaces the last one: revision 2, still one item.
  • Refused: kind review or mark, an extra field (url, key, an option's command), options on a ready, a session that isn't there.
  • A snooze tells nothing. An answer is held for a busy agent once, and changing it later doesn't tell the agent again. A close tells it, and a mark doesn't.
  • A box that went down between saving the answer and queueing it: the next sweep tells the agent, once. telling survives a restart of the store, a snooze while the agent is being told doesn't send the answer twice, and a session that's gone lets it go.
  • A before:session.send hook that refuses โ†’ the settling PATCH gets 403 and the ask stays open.
  • Mutation checks: with the sweep retry off, or the gate off, those tests fail.

go vet ./... and go test -race ./... pass (only TestTheCatalogIsCapped fails here: it counts this machine's own commands and fails on main too). The app's checks, test, build and tsc -p e2e pass. docs-site builds. cargo check didn't run: no Rust changed, and this machine has no GTK.

Review: Codex review and adversarial-review ran once, plus one re-review. Fixed:

  • an answer lost if the box restarted after saving it (now telling plus the sweep retries)
  • a settle getting around the session's send gate
  • a duplicate send when a snooze landed during delivery (now keyed on when the ask settled)

Merge Danger

Door: two-way

Additive: one route, one MCP tool, one command, one optional field on attention items. Agents that never ask lose nothing, and reverting removes it all.

Blast Radius: small

New behaviour only runs when an agent posts an ask. One existing path changes: a PATCH that settles a session's own ask (session:<name>), even one a plugin posted, now types into that session and passes its before:session.send gate. decodeLimit now shares decodeBody with decodeStrict and behaves as before.

๐Ÿค– Generated with Claude Code


Autofix with [code]smith
Open in [code]smith ยท Need help on this PR? Tag @codesmith-bot with what you need.