diff --git a/.claude/skills/verify-berthd/SKILL.md b/.claude/skills/verify-berthd/SKILL.md index d484f15e8..e6fd82235 100644 --- a/.claude/skills/verify-berthd/SKILL.md +++ b/.claude/skills/verify-berthd/SKILL.md @@ -52,7 +52,7 @@ export const plugins = [{ id: "counts", name: "Counts", source: `export default export const allowErrors = [/count boom/]; export const init = (layout) => { /* runs in the page before the app, with initArg */ }; export const initArg = { BracketLeft: "å" }; -export async function run({ page, shot, step, expect }) { … } +export async function run({ page, shot, step, expect, call, out }) { … } ``` `init`, when there is one, runs in the page before the app's own scripts, with `initArg` (Playwright's `addInitScript`): it stands in for what the browser would say, such as a keyboard layout (`keyboard-layout`). `expect` is Playwright's, so assertions wait as they do in the e2e suite. Use stable hooks: `data-testid` (`nav-`, `nav-more`, `nav-badge`, `home-grid`, the list at the top of `app/e2e/fixtures.ts`), roles and accessible names. Never coordinates. diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..7ea27832a 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -16,7 +16,7 @@ The maintained list of what a person can do in the app, and how to prove each on | Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it | | Shortcuts in the keyboard's layout (⌘Å and ⌘¨ for Back and Forward on Norwegian) in tooltips, the Keyboard shortcuts sheet, Settings and ⌘K; the sheet's descriptions fit their lines | [navigation-history.md](navigation-history.md) (`history-layout`) | `keyboard-layout`, `keyboard-layout-us`; `app/e2e/back-forward.spec.ts` asserts it | | Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it | -| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) | +| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`); `attention-two-laptops-live` (`berth.attention`, one box and two laptop agents, `../scripts/attention-live.sh`); `ask-live` (an agent's ask from `berth_ask` and `berthd ask`, answered in a plugin, back as its next prompt; real Claude Code on `../scripts/dev-box.sh`) | | Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it | | Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock | | Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it | diff --git a/.claude/skills/verify-berthd/features/plugins.md b/.claude/skills/verify-berthd/features/plugins.md index 8c6838cfe..b600bff95 100644 --- a/.claude/skills/verify-berthd/features/plugins.md +++ b/.claude/skills/verify-berthd/features/plugins.md @@ -8,6 +8,8 @@ Plugins add screens, sidebar items, Home widgets, worktree panels and sections, - `plugin-sidebar`: `addSidebarItem` puts a place under More; `useCount` gives it a count (see [sidebar-nav.md](sidebar-nav.md) `nav-counts`). - `plugin-widget`: `addHomeWidget` adds to Home's picker (see [home-widgets.md](home-widgets.md)). - `plugin-attach`: `berth.api.attach` uploads a file the way the composer does, into a running session's worktree or a worktree an agent is about to start in (`.berth/attachments`, which git ignores); a pasted `image.png` goes as `pasted-HHMMSS.png`, the box puts a timestamp before every name, and a file over 20 MB is refused before it is sent. `pasted: true` shrinks a big screenshot to WebP first. A path from `berth.api.localPaths(text)` goes up through the laptop agent, except to a box paired at 127.0.0.1, where it resolves as it is. Needs a real box: `../scripts/scenarios/plugin-attach-live.mjs` on `../scripts/dev-box.sh`, run twice: as `up` starts it, and with `VERIFY_BOX_ADDR=$(tailscale ip -4):7544` so the box counts as another computer (`node drive.mjs plugin-attach-live --port $PORT --agent http://127.0.0.1:9378`). The routes, the refusal, paths and `localPaths` are unit-tested in `app/src/lib/attachments.test.ts`. +- `plugin-attention`: `berth.attention` lists, posts and updates the box's attention items, so a plugin's snooze or "resolved" on one computer is what every other computer paired with the box reads (a plugin's `storage` is per computer). A post replaces the item at its key and counts its revision on; an update naming a revision that has gone by is refused (409); an open item about a session closes as `session_ended` when the session is stopped or its program ends. Needs a real box and two laptops: `../scripts/attention-live.sh up` starts box `devq` on 7547 with laptop A (UI 9472) and laptop B (UI 9474), each paired with its own code, away from `dev-box.sh`'s ports; then `eval "$(attention-live.sh env)"` and `node drive.mjs attention-two-laptops-live --port $PORT --agent http://127.0.0.1:9472`. It drives laptop B in a second browser of its own and puts B's screenshots in the same folder. Run it once per `up`: it posts fixed keys, so a second pass sees later revisions; `down` then `up` between passes. The store's rules are `go test ./internal/box -run 'Attention|AMark|ItemsSettle|AnAgentReplaces'`, the app's calls `app/src/lib/attention.test.ts`. +- `plugin-ask`: an agent's own ask (`berthd ask` or the `berth_ask` tool of `berthd mcp`) lands in `berth.attention` at `session:`, by `mcp` (the tool) or `berth` (the CLI), and returns at once, so the agent ends its turn without an answer. A plugin draws its words as text; only a decision has options. What the person does to it in the plugin (an `answer`, a close) reaches the agent as its next prompt, held until it is idle, under the idempotency key `attention:@` (a snooze in between never tells it twice), and the box keeps trying through a restart (`telling` on the item until it holds the prompt), prompted by the person's laptop (`laptop:`). Needs a real Claude Code: on `../scripts/dev-box.sh up` (every `BERTH_*` unset), `node drive.mjs ask-live --port $PORT --agent http://127.0.0.1:9378`. It starts a second Claude Code, `asker`, with only this build's `berthd mcp` (`--strict-mcp-config`), and has dev-box.sh's `ask` run this build's `bin/berthd ask`; each writes the answer it got to a file in `acme-shop` with its own Bash tool, which the scenario reads. About three minutes; once per `up`. The box's rules are `go test ./internal/box -run 'AnAgentsAsk|AnswerText'`, the tool's `go test ./internal/mcpserver -run AnAsk`. - `plugin-errors`: a plugin that throws while loading shows as failed in Settings → Plugins; one that throws while rendering shows a note in its own place; a sidebar count that throws shows no count, and one that throws while its plugin turns off (an effect's cleanup) leaves the other counts and the app's dialogs working (`app/e2e/plugin-nav-badges.spec.ts`). The rest of the app carries on. ## How to get to it (user POV) @@ -19,6 +21,7 @@ Plugins add screens, sidebar items, Home widgets, worktree panels and sections, - A scenario's `plugins` array is loaded as extra built-ins: `{ id, name, source }`, where `source` is an ES module whose default export gets the plugin context (`berth`). Bare imports of `react` and `@berth/plugin` work as in a real plugin (the app rewrites them to its shims). See `../scripts/scenarios/plugin-nav-badges.mjs`. - This is the way to prove a plugin-facing API: write the smallest plugin that uses it, and drive what it adds. +- `run` also gets `out`, the evidence folder, for a scenario that drives a second browser (a second laptop) and shoots it too (`attention-two-laptops-live`). ## Gotchas diff --git a/.claude/skills/verify-berthd/scripts/attention-live.sh b/.claude/skills/verify-berthd/scripts/attention-live.sh new file mode 100755 index 000000000..5d772bf13 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/attention-live.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# attention-live.sh up|env|down: one box and two laptop agents of this +# checkout's build, as two computers paired with the same box, for the +# attention scenario (scenarios/attention-two-laptops-live.mjs). No agent +# CLI runs: the scenario starts a plain shell session itself. +# +# Its own ports, so it never meets dev-box.sh (7544/9377/9378) or the +# machine's Shipyard (1377-1379): the box on 127.0.0.1:7547, laptop A on +# 9471/9472 and laptop B on 9473/9474 (proxy/UI). `env` prints what +# drive.mjs needs; `down` stops it all and deletes the state. +set -euo pipefail +root=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +dir=${VERIFY_BOX_DIR:-/tmp/vb-attention} +addr=127.0.0.1:7547 +box_env=(BERTH_HOME="$dir/box-home" BERTH_USER_DIR="$dir/box-user" TMUX_TMPDIR="$dir/tmux") +box() { env "${box_env[@]}" "$root/bin/berthd" "$@"; } +# lap a|b …: laptop A (proxy 9471, UI 9472) or B (9473, 9474). +lap() { + local who=$1 p=9471 + shift + [[ $who == b ]] && p=9473 + env BERTH_HOME="$dir/lap-$who-home" BERTH_USER_DIR="$dir/lap-$who-user" BERTH_PROXY_ADDR="127.0.0.1:$p" BERTH_UI_ADDR="127.0.0.1:$((p + 1))" "$root/bin/berth" "$@" +} + +case ${1:-} in +up) + for p in 7547 9471 9472 9473 9474; do + if ss -ltn | grep -q ":$p "; then echo "port $p is taken: is it up already? ($0 down)" >&2; exit 1; fi + done + mkdir -p "$dir"/{box-home,box-user,lap-a-home,lap-a-user,lap-b-home,lap-b-user,tmux} && chmod 700 "$dir/tmux" + make -C "$root" build >/dev/null + env "${box_env[@]}" nohup "$root/bin/berthd" serve --listen "$addr" >"$dir/berthd.log" 2>&1 & + echo $! >"$dir/berthd.pid" + until grep -q "berthd serving" "$dir/berthd.log"; do sleep 0.5; done + if grep -q "updated berth's hooks" "$dir/berthd.log"; then + echo "the box rewrote agent hooks in $HOME (berthd.log): put them back before going on" >&2 + exit 1 + fi + # Each laptop pairs with a code of its own, as two computers do. + lap a pair "$(box pair --address "$addr" --json | jq -r .link)" --name devq >/dev/null + lap b pair "$(box pair --address "$addr" --json | jq -r .link)" --name devq >/dev/null + repo=$dir/acme-shop + if [[ ! -d $repo/.git ]]; then + git init -q -b main "$repo" && echo "# acme shop" >"$repo/README.md" + git -C "$repo" add . && git -C "$repo" -c user.email=dev@acme.test -c user.name=acme commit -qm init + fi + box location add acme-shop "$repo" >/dev/null + # The CLI starts each agent; wait until both answer. + lap a ui-token >/dev/null && lap b ui-token >/dev/null + for u in 9472 9474; do + until ss -ltn | grep -q "127.0.0.1:$u "; do sleep 0.5; done + done + echo "up: box devq ($addr), laptop A http://127.0.0.1:9472, laptop B http://127.0.0.1:9474" + ;; +env) + # The tokens are secrets: export them, don't print them anywhere else. + echo "export VERIFY_BERTH_TOKEN=$(lap a ui-token | jq -r .token) VERIFY_BERTH_TOKEN_B=$(lap b ui-token | jq -r .token) VERIFY_AGENT_B=http://127.0.0.1:9474" + ;; +down) + [[ -f $dir/berthd.pid ]] && kill "$(cat "$dir/berthd.pid")" 2>/dev/null || true + env TMUX_TMPDIR="$dir/tmux" tmux -L berth kill-server 2>/dev/null || true + # The agents the CLI started: the ones on 9472/9474 with this state, no other. + for port in 9472 9474; do + for pid in $(ss -ltnpH "sport = :$port" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u); do + tr '\0' '\n' <"/proc/$pid/environ" | grep -q "^BERTH_HOME=$dir/lap-" && kill "$pid" + done + done + rm -rf "$dir" + echo "down: $dir removed" + ;; +*) + echo "usage: attention-live.sh up|env|down" >&2 + exit 2 + ;; +esac diff --git a/.claude/skills/verify-berthd/scripts/drive.mjs b/.claude/skills/verify-berthd/scripts/drive.mjs index 4917aae96..2871bc901 100755 --- a/.claude/skills/verify-berthd/scripts/drive.mjs +++ b/.claude/skills/verify-berthd/scripts/drive.mjs @@ -102,7 +102,7 @@ try { step(agent ? `open http://localhost:${port}/ on the agent at ${agent}` : `open http://localhost:${port}/?${params}`); await page.goto(`http://localhost:${port}/?${params}`); await expect(page.getByTestId("nav-home")).toBeVisible({ timeout: 20_000 }); - await scenario.run({ page, shot, step, expect, call }); + await scenario.run({ page, shot, step, expect, call, out }); } catch (err) { ok = false; failures.push(redact(`scenario failed: ${err.message?.split("\n").slice(0, 6).join("\n")}`)); diff --git a/.claude/skills/verify-berthd/scripts/scenarios/ask-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/ask-live.mjs new file mode 100644 index 000000000..a747c0379 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/ask-live.mjs @@ -0,0 +1,169 @@ +// Asking without stopping, with real Claude Code on a dev box +// (features/plugins.md, plugin-ask): an agent posts an ask with the +// berth_ask tool and one with `berthd ask`, each ends its turn without +// waiting, the asks show in a plugin through berth.attention, and what the +// person does there (an answer, a close) reaches each agent as its next +// prompt, which it writes to a file with its own Bash tool. +// +// ../dev-box.sh up && eval "$(../dev-box.sh env)" # every BERTH_* unset +// node drive.mjs ask-live --port $PORT --agent http://127.0.0.1:9378 +// +// dev-box.sh's session "ask" (Claude Code in its terminal, with the +// machine's own MCP servers) runs this checkout's bin/berthd for the CLI. +// The scenario starts a second Claude Code, "asker", whose only MCP server +// is this checkout's `berthd mcp`. Run it once per `up`. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const live = true; +export const params = { labs: "0" }; + +const BOX = "devq"; +const dir = process.env.VERIFY_BOX_DIR ?? "/tmp/verify-berthd-box"; +const root = execFileSync("git", ["-C", dirname(fileURLToPath(import.meta.url)), "rev-parse", "--show-toplevel"]).toString().trim(); +const berthd = join(root, "bin", "berthd"); +const repo = join(dir, "acme-shop"); +// The dev box's own state, and nothing of the session this runs from. +const boxEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("BERTH_"))); +Object.assign(boxEnv, { BERTH_HOME: `${dir}/box-home`, BERTH_USER_DIR: `${dir}/box-user`, TMUX_TMPDIR: `${dir}/tmux` }); +const bd = (...args) => execFileSync(berthd, args, { env: boxEnv }).toString(); +const tmux = (...args) => execFileSync("tmux", ["-L", "berth", ...args], { env: boxEnv }).toString(); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function until(what, fn, ms = 180_000) { + const end = Date.now() + ms; + for (;;) { + const v = await fn().catch(() => undefined); + if (v) return v; + if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); + await sleep(2000); + } +} +const file = (name) => (existsSync(join(repo, name)) ? readFileSync(join(repo, name), "utf8").trim() : ""); + +const MCP_ASK = "Ship the acme release notes on Friday?"; +const CLI_ASK = "PR #12 is ready to merge"; + +export const plugins = [ + { + id: "ask-check", + name: "Ask check", + source: ` +import { createElement as h, useEffect, useState } from "react"; +export default function (berth) { + const BOX = ${JSON.stringify(BOX)}; + const Screen = () => { + const [items, setItems] = useState(null); + const [error, setError] = useState(""); + const load = () => berth.attention.list(BOX).then(setItems, (e) => setError(e.message)); + useEffect(() => { + load(); + return berth.on("attention.*", load); + }, []); + const act = (it, change) => berth.attention.update(BOX, it.key, { revision: it.revision, ...change }).then(load, (e) => setError(e.message)); + // An agent's words are drawn as text, and only a decision gets its + // options as buttons; every other kind gets this plugin's own verb. + return h("div", { style: { padding: 16, display: "grid", gap: 12 } }, + error && h("p", { role: "alert" }, error), + h("ul", { "aria-label": "Asks", style: { display: "grid", gap: 12 } }, (items ?? []).filter((it) => it.kind !== "mark").map((it) => + h("li", { key: it.key, "data-testid": "ask-" + it.session }, + h("p", { "data-testid": "ask-row" }, it.session + " · " + it.kind + " · " + it.status + (it.answer ? " (" + it.answer + ")" : "") + (it.closed_reason ? " (" + it.closed_reason + ")" : "") + " · rev " + it.revision + " · by " + it.by), + h("p", { style: { fontWeight: 600 } }, it.title), + it.details && h("p", null, it.details), + it.status === "open" && h("div", { style: { display: "flex", gap: 8 } }, + it.kind === "decision" + ? (it.options ?? []).map((o) => h("button", { key: o.label, onClick: () => act(it, { answer: o.label }) }, o.label + (o.label === it.recommended ? " (recommended)" : "") + (o.description ? ": " + o.description : ""))) + : h("button", { onClick: () => act(it, { status: "closed" }) }, "Close")), + ))), + ); + }; + berth.addScreen({ id: "ask-check", title: "Ask check", Component: Screen }); + berth.addSidebarItem({ id: "ask-check", title: "Ask check", icon: "Bell", screen: "ask-check" }); +} +`, + }, +]; + +export async function run({ page, shot, step, expect, call, out }) { + const box = (path, init) => call(`/v1/boxes/${BOX}/api${path}`, init); + const open = async (session) => (await box(`/attention?session=${session}&open=1`)).find((i) => i.kind !== "mark"); + const idle = async (session) => ["idle", "finished"].includes((await box("/sessions")).find((s) => s.name === session)?.agent_state); + + step("start Claude Code session asker in acme-shop, whose only MCP server is this build's `berthd mcp`"); + const cfg = join(dir, "ask-mcp.json"); + writeFileSync(cfg, JSON.stringify({ mcpServers: { berth: { command: berthd, args: ["mcp"], env: { BERTH_HOME: boxEnv.BERTH_HOME, BERTH_USER_DIR: boxEnv.BERTH_USER_DIR } } } })); + bd("session", "new", "acme-shop", "--name", "asker", "--", "claude", "--dangerously-skip-permissions", "--strict-mcp-config", "--mcp-config", cfg); + // A folder it hasn't seen asks to be trusted first (dev-box.sh's way). + await sleep(8000); + tmux("send-keys", "-t", "asker", "Up"); + await sleep(500); + tmux("send-keys", "-t", "asker", "Enter"); + await until("asker at its prompt", async () => /bypass permissions/i.test(bd("session", "screen", "asker")), 60_000); + + step("asker: post a decision with berth_ask, and end the turn without waiting"); + await box("/sessions/asker/send", { + method: "POST", + body: JSON.stringify({ + when: "now", + text: `Call the berth_ask tool once, with kind "decision", title "${MCP_ASK}", details "The freeze starts Monday.", options [{"label":"Friday"},{"label":"Monday","description":"after the freeze"}] and recommended "Friday". Don't wait for an answer: right after the call, write the tool's result to mcp-posted.txt with your Bash tool, then end your turn with one short line. Later a message will give you my answer. When it does, write only the answer, one word, to mcp-answer.txt with your Bash tool, and end your turn.`, + }), + }); + const mcpItem = await until("asker's ask on the box", () => open("asker")); + if (mcpItem.kind !== "decision" || mcpItem.title !== MCP_ASK || mcpItem.by !== "mcp" || mcpItem.recommended !== "Friday" || mcpItem.options?.length !== 2) throw new Error(`asker posted ${JSON.stringify(mcpItem)}`); + await until("asker to carry on and write mcp-posted.txt", async () => file("mcp-posted.txt").includes("session:asker")); + await until("asker's turn to end", () => idle("asker")); + step(`asker's ask is on the box (key ${mcpItem.key}, by mcp), and its turn ended without an answer; mcp-posted.txt: ${file("mcp-posted.txt")}`); + + step("ask (dev-box.sh's Claude Code): post a ready with this build's `berthd ask`, and end the turn"); + await until("ask at its prompt", () => idle("ask"), 60_000); + await box("/sessions/ask/send", { + method: "POST", + body: JSON.stringify({ + when: "now", + text: `Run this with your Bash tool, exactly as it is: ${berthd} ask --kind ready --title "${CLI_ASK}" --details "The acme checks pass." Then end your turn with one short line; don't wait. Later a message will come about it. When it does, write that message's whole text to cli-answer.txt with your Bash tool, and end your turn.`, + }), + }); + const cliItem = await until("ask's ask on the box", () => open("ask")); + if (cliItem.kind !== "ready" || cliItem.title !== CLI_ASK || cliItem.options) throw new Error(`ask posted ${JSON.stringify(cliItem)}`); + await until("ask's turn to end", () => idle("ask")); + + step("the app: open the plugin's screen; both asks show, the decision with its options, the ready with only the plugin's own Close"); + await page.getByTestId("nav-more").click(); + await page.getByRole("menu").getByRole("menuitem", { name: "Ask check" }).click(); + const mcpRow = page.getByTestId("ask-asker"); + const cliRow = page.getByTestId("ask-ask"); + await expect(mcpRow.getByTestId("ask-row")).toHaveText(`asker · decision · open · rev ${mcpItem.revision} · by mcp`); + await expect(mcpRow.getByText(MCP_ASK)).toBeVisible(); + await expect(mcpRow.getByRole("button")).toHaveText(["Friday (recommended)", "Monday: after the freeze"]); + await expect(cliRow.getByTestId("ask-row")).toHaveText(new RegExp(`^ask · ready · open · rev ${cliItem.revision} · by `)); + await expect(cliRow.getByText(CLI_ASK)).toBeVisible(); + await expect(cliRow.getByRole("button")).toHaveText(["Close"]); + await shot("1-two-asks"); + + step("answer asker's decision with Monday, and close ask's ready"); + await mcpRow.getByRole("button", { name: "Monday: after the freeze" }).click(); + await expect(mcpRow.getByTestId("ask-row")).toHaveText(`asker · decision · answered (Monday) · rev ${mcpItem.revision + 1} · by app`); + await cliRow.getByRole("button", { name: "Close" }).click(); + await expect(cliRow.getByTestId("ask-row")).toHaveText(`ask · ready · closed (resolved) · rev ${cliItem.revision + 1} · by app`); + await shot("2-answered-and-closed"); + + step("each agent gets it as its next prompt, and writes it down with its own Bash tool"); + const answer = await until("asker to write mcp-answer.txt", async () => file("mcp-answer.txt")); + if (answer !== "Monday") throw new Error(`asker wrote ${JSON.stringify(answer)}`); + const closed = await until("ask to write cli-answer.txt", async () => file("cli-answer.txt")); + if (!closed.includes("closed your ask") || !closed.includes(CLI_ASK)) throw new Error(`ask wrote ${JSON.stringify(closed)}`); + step(`mcp-answer.txt: ${answer}; cli-answer.txt: ${closed}`); + + step("second view: each session's turns, where the last was prompted by the person's laptop, under the ask's idempotency key"); + for (const [session, item] of [["asker", mcpItem], ["ask", cliItem]]) { + const turns = await box(`/sessions/${session}/turns?limit=5`); + const told = turns.find((t) => t.idem_key?.startsWith(`attention:${item.key}@`)); + if (!told || !told.origin?.startsWith("laptop:")) throw new Error(`${session}'s turns: ${JSON.stringify(turns)}`); + step(`${session}: turn ${told.id}, origin ${told.origin}, idem_key ${told.idem_key}, ${told.state}`); + } + writeFileSync(join(out, "asker-screen.txt"), bd("session", "screen", "asker", "--history", "80")); + writeFileSync(join(out, "ask-screen.txt"), bd("session", "screen", "ask", "--history", "80")); + bd("session", "kill", "asker"); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/attention-two-laptops-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/attention-two-laptops-live.mjs new file mode 100644 index 000000000..6a89c5b80 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/attention-two-laptops-live.mjs @@ -0,0 +1,180 @@ +// Attention items on a real box, read from two computers +// (features/plugins.md, plugin-attention): a snooze made in a plugin on +// laptop A is what the same plugin reads on laptop B, though B's own plugin +// storage is empty; B resolving an item shows on A at once, from the box's +// event; and an open item settles on both when its session ends. +// +// On ../attention-live.sh (one box "devq", laptop A on 9472, laptop B on +// 9474): +// +// ../attention-live.sh up && eval "$(../attention-live.sh env)" +// node drive.mjs attention-two-laptops-live --port $PORT --agent http://127.0.0.1:9472 +// +// It starts a shell session "att-shell" in acme-shop and posts two asks +// about it, as an agent would. + +export const live = true; +export const params = { labs: "0" }; + +const BOX = "devq"; +const SESSION = "att-shell"; +const ASK = `session:${SESSION}`; +const DEPLOY = "acme/deploy-check"; +const UNTIL = "2030-01-02T09:00:00Z"; +const AGENT_B = process.env.VERIFY_AGENT_B ?? "http://127.0.0.1:9474"; +const TOKEN_B = process.env.VERIFY_BERTH_TOKEN_B; + +export const plugins = [ + { + id: "attention-check", + name: "Attention check", + source: ` +import { createElement as h, useEffect, useState } from "react"; +export default function (berth) { + const BOX = ${JSON.stringify(BOX)}; + const Screen = () => { + const [items, setItems] = useState(null); + const [error, setError] = useState(""); + const [local, setLocal] = useState(berth.storage.get("snoozed", "")); + const load = () => berth.attention.list(BOX).then(setItems, (e) => setError(e.message)); + useEffect(() => { + load(); + return berth.on("attention.*", load); + }, []); + const act = (key, change, note) => { + if (note) { + berth.storage.set("snoozed", note); + setLocal(note); + } + berth.attention.update(BOX, key, change).then(load, (e) => setError(e.message)); + }; + const line = (it) => + it.key + " · " + it.kind + " · " + it.status + (it.closed_reason ? " (" + it.closed_reason + ")" : "") + + " · " + (it.snoozed_until ? "snoozed until " + it.snoozed_until : "not snoozed") + " · rev " + it.revision + " · by " + it.by; + return h("div", { style: { padding: 16, display: "grid", gap: 8 } }, + h("p", null, "berth.attention is " + typeof berth.attention), + h("p", { "data-testid": "att-local" }, "This computer's plugin storage: " + (local || "nothing")), + error && h("p", { role: "alert" }, error), + h("ul", { "aria-label": "Attention items" }, (items ?? []).map((it) => + h("li", { key: it.key }, + h("span", { "data-testid": "att-row" }, line(it)), + it.title && h("span", null, " — " + it.title), + " ", + h("button", { onClick: () => act(it.key, { snoozed_until: ${JSON.stringify(UNTIL)}, revision: it.revision }, it.key) }, "Snooze " + it.key), + " ", + h("button", { onClick: () => act(it.key, { status: "closed", revision: it.revision }) }, "Resolve " + it.key), + ))), + items && h("p", null, items.length + " items on " + BOX), + ); + }; + berth.addScreen({ id: "attention-check", title: "Attention check", Component: Screen }); + berth.addSidebarItem({ id: "attention-check", title: "Attention check", icon: "Bell", screen: "attention-check" }); +} +`, + }, +]; + +const openScreen = async (page, expect) => { + await page.getByTestId("nav-more").click(); + await page.getByRole("menu").getByRole("menuitem", { name: "Attention check" }).click(); + await expect(page.getByText("berth.attention is object")).toBeVisible(); +}; + +const row = (page, key) => page.getByTestId("att-row").filter({ hasText: new RegExp(`^${key.replace(/[/#]/g, "\\$&")} ·`) }); + +export async function run({ page, shot, step, expect, call, out }) { + if (!TOKEN_B) throw new Error("VERIFY_BERTH_TOKEN_B is not set: eval \"$(attention-live.sh env)\""); + const box = (path, init) => call(`/v1/boxes/${BOX}/api${path}`, init); + const info = await box("/info"); + if (!info.capabilities?.includes("attention")) throw new Error(`${BOX} doesn't list the attention capability: ${info.capabilities}`); + + step(`start a shell session ${SESSION} in acme-shop, and post two asks about it as its agent would`); + await box("/sessions", { method: "POST", body: JSON.stringify({ location: "acme-shop", name: SESSION, command: "cat" }) }); + await box("/attention", { method: "POST", body: JSON.stringify({ session: SESSION, kind: "decision", title: "Ship the acme checkout on Friday?", options: [{ label: "Yes" }, { label: "Not yet" }], recommended: "Not yet" }) }); + await box("/attention", { method: "POST", body: JSON.stringify({ key: DEPLOY, session: SESSION, kind: "action", title: "Log in to the acme staging deploy" }) }); + + step("laptop A: open the plugin's screen; both asks are open and not snoozed"); + await openScreen(page, expect); + await expect(row(page, ASK)).toHaveText(`${ASK} · decision · open · not snoozed · rev 1 · by app`); + await expect(row(page, DEPLOY)).toHaveText(`${DEPLOY} · action · open · not snoozed · rev 1 · by app`); + await expect(page.getByTestId("att-local")).toHaveText("This computer's plugin storage: nothing"); + await shot("1-a-two-open-asks"); + + step(`laptop A: snooze ${ASK} until ${UNTIL} (the plugin also notes it in its own storage on A)`); + await page.getByRole("button", { name: `Snooze ${ASK}` }).click(); + await expect(row(page, ASK)).toHaveText(`${ASK} · decision · open · snoozed until ${UNTIL} · rev 2 · by app`); + await expect(page.getByTestId("att-local")).toHaveText(`This computer's plugin storage: ${ASK}`); + await shot("2-a-snoozed"); + + step("laptop B: another browser, its own laptop agent paired with the same box, its own plugin storage"); + const browser = page.context().browser(); + const ctxB = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" }); + // As drive.mjs readies its own page: the machine's agent refused, the + // scenario's plugin served as a built-in. + await ctxB.route(/^https?:\/\/(?:[^/]*\.)?(?:localhost|127\.0\.0\.1|\[::1\]):(?:1377|1378|1379)(?:\/|$)/, (r) => r.abort()); + const pageB = await ctxB.newPage(); + const errorsB = []; + pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text())); + pageB.on("pageerror", (e) => errorsB.push(e.message)); + await pageB.route("**/builtin-plugins/index.json", async (r) => { + const res = await r.fetch(); + const list = await res.json(); + await r.fulfill({ response: res, json: [...list, ...plugins.map((p) => ({ id: p.id, name: p.name, version: "0.0.0-verify", main: "index.js" }))] }); + }); + for (const p of plugins) await pageB.route(`**/builtin-plugins/${p.id}/index.js`, (r) => r.fulfill({ contentType: "text/javascript", body: p.source })); + const origin = new URL(page.url()).origin; + await pageB.goto(`${origin}/?${new URLSearchParams({ token: TOKEN_B, agent: AGENT_B, ...params })}`); + await expect(pageB.getByTestId("nav-home")).toBeVisible({ timeout: 20_000 }); + // Laptop B's own API, as call() is laptop A's. + const callB = (path, init = {}) => fetch(`${AGENT_B}/v1/boxes/${BOX}/api${path}`, { ...init, headers: { Authorization: `Bearer ${TOKEN_B}`, "Content-Type": "application/json" } }); + // B's screenshots go beside the driver's own. + const shotB = (label) => pageB.screenshot({ path: `${out}/${label}.png` }); + try { + await openScreen(pageB, expect); + step("laptop B: the snooze made on A is there, read from the box; B's own plugin storage has nothing"); + await expect(row(pageB, ASK)).toHaveText(`${ASK} · decision · open · snoozed until ${UNTIL} · rev 2 · by app`); + await expect(pageB.getByTestId("att-local")).toHaveText("This computer's plugin storage: nothing"); + await shotB("3-b-reads-the-snooze"); + + step(`laptop B: resolve ${ASK}; laptop A shows it closed without a reload, from the box's attention.updated event`); + await pageB.getByRole("button", { name: `Resolve ${ASK}` }).click(); + await expect(row(pageB, ASK)).toHaveText(`${ASK} · decision · closed (resolved) · snoozed until ${UNTIL} · rev 3 · by app`); + await expect(row(page, ASK)).toHaveText(`${ASK} · decision · closed (resolved) · snoozed until ${UNTIL} · rev 3 · by app`); + await shot("4-a-sees-b-resolve"); + + step(`a stale write is refused: ending ${ASK}'s snooze from revision 2 answers 409`); + const stale = await callB(`/attention/${encodeURIComponent(ASK)}`, { method: "PATCH", body: JSON.stringify({ revision: 2, snoozed_until: "" }) }); + if (stale.status !== 409) throw new Error(`a stale revision answered ${stale.status}, not 409`); + + step(`end session ${SESSION}: its open ask ${DEPLOY} settles on both laptops, as the box's own change`); + await box(`/sessions/${SESSION}`, { method: "DELETE" }); + await expect(row(page, DEPLOY)).toHaveText(`${DEPLOY} · action · closed (session_ended) · not snoozed · rev 2 · by berth`); + await expect(row(pageB, DEPLOY)).toHaveText(`${DEPLOY} · action · closed (session_ended) · not snoozed · rev 2 · by berth`); + await shot("5-a-session-ended"); + await shotB("6-b-session-ended"); + + step("start a session whose program ends by itself after 4 s, with an ask: the box's next look at its sessions settles it"); + await box("/sessions", { method: "POST", body: JSON.stringify({ location: "acme-shop", name: "att-brief", command: "sleep 4" }) }); + await box("/attention", { method: "POST", body: JSON.stringify({ session: "att-brief", kind: "blocked", title: "The acme build is stuck" }) }); + await expect(row(page, "session:att-brief")).toHaveText("session:att-brief · blocked · open · not snoozed · rev 1 · by app"); + await expect(row(pageB, "session:att-brief")).toHaveText("session:att-brief · blocked · closed (session_ended) · not snoozed · rev 2 · by berth", { timeout: 30_000 }); + await expect(row(page, "session:att-brief")).toHaveText("session:att-brief · blocked · closed (session_ended) · not snoozed · rev 2 · by berth"); + await box("/sessions/att-brief", { method: "DELETE" }).catch(() => {}); + + step("second view: the box's API through laptop B says the same"); + const res = await callB("/attention"); + const items = await res.json(); + const ask = items.find((i) => i.key === ASK); + const deploy = items.find((i) => i.key === DEPLOY); + if (ask?.status !== "closed" || ask.closed_reason !== "resolved" || ask.snoozed_until !== UNTIL || deploy?.closed_reason !== "session_ended") { + throw new Error(`the box holds ${JSON.stringify(items)}`); + } + step(`GET attention on laptop B: ${ASK} closed (resolved), snoozed until ${UNTIL}; ${DEPLOY} closed (session_ended)`); + if (errorsB.length) throw new Error(`laptop B's console: ${errorsB.join(" | ")}`); + } catch (err) { + await shotB("failed-b").catch(() => {}); + throw err; + } finally { + await ctxB.close(); + } +} diff --git a/app/src/lib/attention.test.ts b/app/src/lib/attention.test.ts new file mode 100644 index 000000000..21fd751c0 --- /dev/null +++ b/app/src/lib/attention.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { Client } from "@/lib/api"; + +import { makeAttentionApi } from "./attention.ts"; + +// A client that records the box calls and answers from a script. +function fakeClient(answer: (method: string, path: string) => unknown) { + const calls: { box: string; method: string; path: string; body: unknown }[] = []; + const client = { + box: async (box: string, method: string, path: string, body?: unknown) => { + calls.push({ box, method, path, body }); + return answer(method, path); + }, + } as unknown as Client; + return { client, calls }; +} + +const item = { key: "devl/shop/wait", revision: 2, kind: "mark", status: "open", created: "", updated: "" }; + +test("a key goes to the box path-escaped, slashes and all", async () => { + const { client, calls } = fakeClient(() => item); + const api = makeAttentionApi(client); + await api.update("devl", "devl/shop/wait#3", { snoozed_until: "2026-11-07T08:00:00Z", revision: 1 }); + await api.get("devl", "devl/shop/wait#3"); + assert.deepEqual( + calls.map((c) => [c.box, c.method, c.path]), + [ + ["devl", "PATCH", "attention/devl%2Fshop%2Fwait%233"], + ["devl", "GET", "attention/devl%2Fshop%2Fwait%233"], + ], + ); + assert.deepEqual(calls[0].body, { snoozed_until: "2026-11-07T08:00:00Z", revision: 1 }); +}); + +test("list asks for one session's or the open items, and an empty answer is a list", async () => { + const { client, calls } = fakeClient(() => null); + const api = makeAttentionApi(client); + assert.deepEqual(await api.list("devl", { session: "shop claude", open: true }), []); + await api.list("devl"); + assert.deepEqual( + calls.map((c) => c.path), + ["attention?session=shop+claude&open=1", "attention"], + ); +}); + +test("get is undefined for a key with no item, and other errors still reject", async () => { + const notFound = Object.assign(new Error("no attention item with that key"), { status: 404, code: "not_found" }); + const offline = Object.assign(new Error("devl is offline"), { status: 502, code: "box_offline" }); + let next: Error = notFound; + const { client } = fakeClient(() => { + throw next; + }); + const api = makeAttentionApi(client); + assert.equal(await api.get("devl", "nope"), undefined); + next = offline; + await assert.rejects(api.get("devl", "nope"), /offline/); +}); + +test("post sends the ask as it is", async () => { + const { client, calls } = fakeClient(() => item); + await makeAttentionApi(client).post("devl", { session: "shop-claude", kind: "decision", title: "Which queue?", options: [{ label: "SQS" }], recommended: "SQS" }); + assert.deepEqual(calls[0], { box: "devl", method: "POST", path: "attention", body: { session: "shop-claude", kind: "decision", title: "Which queue?", options: [{ label: "SQS" }], recommended: "SQS" } }); +}); diff --git a/app/src/lib/attention.ts b/app/src/lib/attention.ts new file mode 100644 index 000000000..7b6732a3e --- /dev/null +++ b/app/src/lib/attention.ts @@ -0,0 +1,35 @@ +import type { AttentionItem, BerthAttention } from "@berth/plugin"; + +import type { Client } from "@/lib/api"; + +// What needs a person, kept on each box (internal/box/attention.go), so a +// snooze or an answer made on one computer holds on every other. The key +// goes path-escaped: a plugin's keys may hold slashes. + +const itemPath = (key: string) => `attention/${encodeURIComponent(key)}`; + +// A box that holds no item at a key says so with 404 "not_found". +const missing = (err: unknown) => { + const e = err as { status?: number; code?: string } | null; + return e?.status === 404 && e.code === "not_found"; +}; + +// makeAttentionApi is the store as plugins see it (berth.attention). +export function makeAttentionApi(c: Client): BerthAttention { + return { + list: async (box, opts = {}) => { + const q = new URLSearchParams(); + if (opts.session) q.set("session", opts.session); + if (opts.open) q.set("open", "1"); + const qs = q.toString(); + return (await c.box(box, "GET", `attention${qs ? `?${qs}` : ""}`)) ?? []; + }, + get: (box, key) => + c.box(box, "GET", itemPath(key)).catch((err: unknown) => { + if (missing(err)) return undefined; + throw err; + }), + post: (box, item) => c.box(box, "POST", "attention", item), + update: (box, key, change) => c.box(box, "PATCH", itemPath(key), change), + }; +} diff --git a/app/src/plugins/context.ts b/app/src/plugins/context.ts index 33dd48176..69eb77b2b 100644 --- a/app/src/plugins/context.ts +++ b/app/src/plugins/context.ts @@ -3,6 +3,7 @@ import { createContext } from "react"; import { boxApi, type Client } from "@/lib/api"; import { attachApi, localPaths } from "@/lib/attachments"; +import { makeAttentionApi } from "@/lib/attention"; import { makeHelperApi } from "@/lib/helper"; import { makeLinearApi } from "@/lib/linear"; import { notify } from "@/lib/notify"; @@ -101,6 +102,7 @@ export function makeContext(id: string, client: Client): BerthPluginContext { prompts: makePromptsApi(), linear: makeLinearApi(client), helper: makeHelperApi(client, id), + attention: makeAttentionApi(client), orchestrate: { send: orchestrate.send, wait: orchestrate.wait, diff --git a/cmd/berthd/main.go b/cmd/berthd/main.go index d2f48d3f4..e570fecb8 100644 --- a/cmd/berthd/main.go +++ b/cmd/berthd/main.go @@ -409,6 +409,9 @@ func serve(b boxHome, args []string) error { // A worktree that is removed, however it was, is one they forget. defer bx.Helpers.Watch(bus)() bx.AutoFix = &box.AutoFixStore{Path: filepath.Join(b.dir, "autofix.json")} + bx.Attention = &box.AttentionStore{Path: filepath.Join(b.dir, "attention.json")} + // A stopped session's asks settle at once, however it was stopped. + defer bx.WatchAttention()() // How agents start when what starts them names no driver // (agentdefaults.go): the app keeps it to the person's Labs setting. bx.AgentDefaults = &box.AgentDefaultsStore{Path: filepath.Join(b.dir, "agent-defaults.json")} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cc4ef4e5a..1db85e541 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -61,6 +61,21 @@ again. branch, and asks git again only when the repository changes (or after a minute). +- **A plugin's snooze can follow you between computers.** Each box now keeps + a small list of what needs you: asks an agent posts, and marks such as + "snoozed until 9" or "resolved" on items a plugin builds. Every app paired + with the box reads the same list, so snoozing on one laptop holds on the + other. An item settles by itself when its session ends, and is forgotten + two weeks after it settles. Plugins reach it as `berth.attention`. + +- **An agent can tell you something needs you, and keep working.** A question + form stops the agent until you answer. Now it can post an ask instead with + `berthd ask` or the `berth_ask` tool: a decision, an action only you can + take, work ready to merge, or that it's blocked. Its next ask replaces the + last one. When you answer or close it, the agent gets your answer as its + next prompt. An ask is plain words: no links or commands for a button to + run. + ## v0.3.31 - **Worktrees with submodules can be removed.** git refuses to remove any diff --git a/docs/guides/agent-integrations.mdx b/docs/guides/agent-integrations.mdx index 33cc10c3c..807530d67 100644 --- a/docs/guides/agent-integrations.mdx +++ b/docs/guides/agent-integrations.mdx @@ -203,6 +203,43 @@ berth emit agent.finished path="$PWD" --origin=mytool # on a laptop berthd emit deploy.done url=https://… --origin mytool # on a box ``` +## Asking without stopping + +A question form stops an agent until someone answers it. For what needs a +person but can wait (a pull request ready to merge, a key only they can +rotate), an agent posts an **ask** and carries on: + +```sh +berthd ask --kind ready --title "PR #12 is ready to merge" --details "Tests and the acme checks pass." +berthd ask --kind decision --title "Ship acme on Friday?" \ + --option "Friday" --option "Monday: after the freeze" --recommended Friday +``` + +or calls the `berth_ask` tool of `berthd mcp` with the same fields. `kind` is +one of: + +| Kind | For | +| --- | --- | +| `decision` | a choice: `options` to pick from (up to 12, each a label and what it means) and the one it `recommended`; the person may write their own answer too | +| `action` | something only the person can do | +| `ready` | work ready for them to review or merge | +| `blocked` | the agent can't go on without them | + +The ask goes on the box's [attention items](/reference/plugin-sdk#attention) +at the session's own key, so a new ask replaces the agent's last one, and it +closes by itself when the session ends. It returns at once. When the person +answers or closes it, the box types that into the agent as a prompt, held +until the agent is idle, and only once per answer. It keeps trying until the +prompt is held, through a restart of the box. Settling an ask is a send to +the agent's session, so a `before:session.send` [gate](/reference/events#gates) +can refuse it. + +An ask is words and nothing else. The box refuses a kind it doesn't know and +any field it doesn't name, and only a decision has options: the app gives +every other kind its own buttons. There is no link or command for a button +to follow, so a plugin that shows asks draws the title, details and option +labels as text. + ## Worktrees from any tool berthd watches its locations. A worktree created by another app, an agent, diff --git a/docs/guides/runs.mdx b/docs/guides/runs.mdx index 4b8cd8d3c..8244df959 100644 --- a/docs/guides/runs.mdx +++ b/docs/guides/runs.mdx @@ -221,6 +221,7 @@ JSON: | `berth_exec` | a detached run; with `wait` (at most 90 s) its exit code and failing tail | | `berth_run_start`, `berth_run_get`, `berth_run_cancel` | a run, in brief | | `berth_attempts` | an attempts run | +| `berth_ask` | the ask's key and revision: it tells the person something needs them, without stopping the agent ([asking without stopping](/guides/agent-integrations#asking-without-stopping)) | Agents can start and follow runs but not decide their gates: those are a person's. diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 3888a0bb5..1b6750209 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -157,6 +157,7 @@ failed after it went out (the box may have acted on it). The box API: | `GET doctor` | `Check[]` | | `POST secrets/test` | `{ ref }` → `{ ok, length?, error? }`: resolves a [secret reference](/guides/secrets) on the box now (`op://…` or `env://…`), skipping its cache, and reports whether it could and the value's length, never the value. A malformed reference is `ok: false` with why | | `POST secrets/report` | Only on the box's own socket: what `berthd secret exec` resolved for a session or service, which variables resolved and which failed and why, never values | +| `POST sessions/{name}/attention` | `{ kind, title, details?, options?, recommended? }` → `AttentionItem`: the session's own ask, at `session:`, replacing its last ([asking without stopping](/guides/agent-integrations#asking-without-stopping)). `kind` is `decision`, `action`, `ready` or `blocked`, only a decision takes `options`, and a field it doesn't name is refused (400). When a person answers or closes it, the session gets a prompt held until it is idle; that `PATCH attention/{key}` passes the session's `before:session.send` gate too | | `POST sessions/{name}/send` | `{ text, enter?, when?, force?, idem_key? }` → `{ sent, queued?, duplicate?, turn?, seq?, at }`: types a prompt, or with `when: "idle"` holds it until the agent is idle. With `when`, refused (409) while the agent waits for someone unless `force` ([orchestration](/guides/orchestration#send)) | | `GET sessions/{name}/wait?for=finished,waiting&after=TIME&timeout=10m` | `{ state, timed_out, turn? }`: `after` is an RFC 3339 time, placed in the box's journal order; `timeout` a duration, 1s to 1h (default 10m) | | `GET sessions/{name}/turns?limit=N` | `Turn[]`, oldest first: the session's last `N` turns (default 20). A wait's `ask` is what the agent asked for, from its hooks: `{ tool?, input?, why?, message? }`, each at most 300 characters (the command or the file's path, never what it would write) | @@ -185,6 +186,7 @@ failed after it went out (the box may have acted on it). The box API: | `POST team` | a `TeamBundle` from the laptop (`id`, `name`, `org`, `commit`, `script`, `steps`, `github`, `files`: path → base64, `projects`: `id`, `repo`, `url`, `path`, `source`, `trust_hash`, `kit`, `init`, `env`) → `TeamStatus`. Starts the steps in a terminal; 409 while it runs. Boxes with the `team` capability | | `POST team/{id}/retry` | `{ from? }` → `TeamStatus` | | `GET shares`, `POST shares`, `DELETE shares/{id}` | public shares | +| `GET attention?session=S&open=1`, `POST attention`, `GET attention/{key}`, `PATCH attention/{key}` | [Attention items](/reference/plugin-sdk#attention): what needs a person, the same for every app paired with the box. `POST` takes `{ key?, kind, title, details?, options?, recommended?, session?, revision? }` and replaces the item at its key; `PATCH` takes `{ revision?, status?, answer?, closed_reason?, snoozed_until?, session? }` and makes a mark at a key with no item. Both answer with the `AttentionItem`; a `revision` that has gone by is refused (409). Path-escape the key (`%2F`, `%23`). Boxes that keep them list `attention` in `capabilities` | | `GET units`, `POST units`, `GET units/{name}`, `DELETE units/{name}`, `POST units/{name}/restart`, `GET units/{name}/log` | units | | `GET helpers` | `{ platform, helpers: HelperOnBox[] }`: the [helpers of plugins](/guides/plugins#give-a-plugin-a-helper) on the box, and the box's platform (`linux-arm64`). A `HelperOnBox` is `{ plugin, state, platform, name?, version?, sha256?, digest?, pid?, since?, restarts?, attach?, error?, attached? }`, with `state` one of `off`, `installing`, `starting`, `running`, `failed`; `digest` names the program and how it is run, for a laptop to compare with a manifest. Boxes that run helpers list `helpers` in `capabilities`; an older one answers 404 | | `GET helpers/{plugin}` | `HelperOnBox`; `state` is `off` for a plugin with no helper here | diff --git a/docs/reference/berthd.mdx b/docs/reference/berthd.mdx index 7ed1cb934..eac6e8eca 100644 --- a/docs/reference/berthd.mdx +++ b/docs/reference/berthd.mdx @@ -91,6 +91,7 @@ asks the app to open the page as a browser tab beside the agent; the | `berthd flow secret FLOW [--scope S]` | Make a webhook flow's signing secret | | `berthd session rename NAME [TITLE]` | Name a session's work; no TITLE clears it | | `berthd session kill NAME` | Stop a session | +| `berthd ask --kind decision\|action\|ready\|blocked --title T [--details D] [--option "LABEL[: what it means]"]... [--recommended LABEL] [--session NAME] [--json]` | From an agent: tell the person something needs them, without stopping. A new ask replaces the session's last one, and the answer comes back as a prompt ([asking without stopping](/guides/agent-integrations#asking-without-stopping)) | ## Ports and sharing diff --git a/docs/reference/events.mdx b/docs/reference/events.mdx index def6c26af..ee8983296 100644 --- a/docs/reference/events.mdx +++ b/docs/reference/events.mdx @@ -116,6 +116,8 @@ events. | `run.gate_decided` | the same, with `step`, `approve`, `by` | | `browser.opened`, `browser.closed` | `location`, `name`, `path`; `closed` has `reason` (`idle`, `closed`, the guard, another worktree needing one, its worktree removed) | | `artifact.added`, `artifact.updated`, `artifact.removed` | `id`, `title`, `kind`, `location`, `name`, `path`, `version`, and `session` when an agent made it; `problem` when a rewrite of its file wasn't taken. A rewrite the box noticed has origin `watch`; a visual diff from `berthd shots compare` has origin `shots` and its `summary`. See [artifacts](/guides/artifacts) | +| `attention.posted`, `attention.updated` | `key`, `revision`, `kind`, `status`, and `session`, `closed_reason` and `snoozed_until` when set: an [attention item](/reference/plugin-sdk#attention) was posted or replaced, or answered, closed, reopened or snoozed. Never its title, details or answer: read the item for those | +| `attention.settled` | the same, with `status: "closed"` and `closed_reason: "session_ended"`, origin `berth`: an open item's session ended | | `browser.failed` | `location`, `name`, `path`, `reason`: `sandbox` (Chromium's sandbox can't start; see [agent browser](/guides/agent-browser#when-ubuntu-blocks-it)) or `error` | | `browser.navigated` | `location`, `name`, `path`, `tab`, `url`: the [agent browser](/guides/agent-browser) loaded a page (`tab` is `""` for the main tab) | | `browser.shot` | `location`, `name`, `path`, `tab`, `file`, `url` | @@ -206,6 +208,8 @@ exits non-zero refuses it with what it printed. Gates in a laptop's | `before:run.pr` | `run`, `path`, `branch`: a run about to push and open a pull request | | `before:artifact.add` | `location`, `name`, `path`, `title`, `kind`, `id` (when updating one) | | `before:artifact.remove` | `location`, `name`, `path`, `id` | +| `before:attention.post` | `key`, `kind`, `session`: posting an [attention item](/reference/plugin-sdk#attention) | +| `before:attention.update` | `key`, and `status` and `snoozed_until` when the change sets them | | `before:browser.open` | `location`, `name`, `path`, `tab`, `url`: an agent browser opening a page | | `before:browser.eval`, `before:browser.watch` | `location`, `name`, `path`, `tab`: running JavaScript in it, watching it live | | `before:browser.allow` | `origin`: letting agents' browsers load a public origin | diff --git a/docs/reference/plugin-sdk.mdx b/docs/reference/plugin-sdk.mdx index e7dbcfa01..edaaa374d 100644 --- a/docs/reference/plugin-sdk.mdx +++ b/docs/reference/plugin-sdk.mdx @@ -29,6 +29,7 @@ that gives `activate` its types. | `prompts` | [`BerthPrompts`](#prompts): the saved prompt library | | `linear` | [`BerthLinear`](#linear): the person's Linear issues, once they added a key | | `helper` | [`BerthHelper`](#helper): the plugin's own program on a box, for a plugin whose manifest carries one | +| `attention` | [`BerthAttention`](#attention): what needs a person, kept on each box so every computer reads the same | | `storage` | `get(key, fallback)`, `set(key, value)`: small JSON values kept on this computer, apart from other plugins' | ### Adding things @@ -304,6 +305,63 @@ helper writes there once it is listening. A request goes through the box as JSON, one answer for one request, so a helper's event stream can't be followed this way: read again on a timer while your view is on screen. +## attention + +What needs a person, kept on each box: an ask an agent posts (a decision, +an action, something to review), and a person's marks on items a plugin +builds from the box's own signals, such as a snooze or "resolved". The +plugin's `storage` is this computer's alone, so a snooze kept there is news +to the person's other laptop; kept here, every app paired with the box +reads it. ("Attention" is a working name: a box's inbox is already the +prompts it holds for an agent.) + +An item is named by its `key`. Posting to a key replaces the item there and +counts its `revision` on, so an agent replaces its own ask rather than +adding another; a post with a `session` and no key is that session's own, +`session:`. An item is `open`, `answered` or `closed`; the last two are +settled. An open item about a session closes by itself, with +`closed_reason: "session_ended"`, when the session ends: at once when it is +stopped, and within about 20 seconds when its program ends by itself. A +settled item is forgotten 14 days later. + +| Call | What it does | +| --- | --- | +| `list(box, { session?, open? })` | `AttentionItem[]`, the most recently changed first: `{ key, revision, kind, title?, details?, options?, recommended?, status, answer?, closed_reason?, snoozed_until?, session?, by, telling?, created, updated, settled? }`. `by` is where the last change came from (`app`, `phone`, an agent's tool, `berth`); `telling` is set while a settled agent's ask waits to reach its agent | +| `get(box, key)` | One item, or `undefined` when there is none at that key | +| `post(box, { key?, kind, title, details?, options?, recommended?, session?, revision? })` | Posts an ask, or replaces the one at its key: it opens again, its answer and snooze gone. `kind` is a short lowercase word of the poster's (`mark` is the box's own), `options` up to 12 `{ label, description? }` and `recommended` one of their labels. A `session` must be on the box (404 otherwise) with its program running (409 `session_exited` otherwise) | +| `update(box, key, { revision?, status?, answer?, closed_reason?, snoozed_until?, session? })` | Answers, closes, reopens or snoozes an item; what it leaves out stays. An `answer` alone makes it `answered`, a `closed_reason` alone closes it (closed without one is `resolved`), and `status: "open"` reopens it. `snoozed_until` is an ISO time, or `""` to end a snooze. At a key with no item it makes a mark (`kind: "mark"`), about `session` when given one | + +An agent posts its own ask with `berthd ask` or the `berth_ask` tool +([asking without stopping](/guides/agent-integrations#asking-without-stopping)): +`kind` is `decision`, `action`, `ready` or `blocked`, at its session's key. +When an `update` answers or closes one, the box tells the agent with a +prompt, so an `answer` is what the agent reads. That is a send to the +agent's session, so a `before:session.send` gate can refuse the update +(403), and the box keeps trying until the prompt is held for the agent, a +restart included. Draw an agent's words as +text, never as a link or markup, and give every kind but a decision your own +buttons. + +With `revision`, a post or update is refused (409) if the item has changed +since that revision, so you never overwrite a change you haven't seen. A +`before:attention.post` or `before:attention.update` [gate](/reference/events#gates) +can refuse one (403 `refused`). Each change is announced as +`attention.posted`, `attention.updated` or `attention.settled`, with the +key, revision and status and never the item's words: listen with +`berth.on("attention.*", …)` and read the item again. + +A box keeps up to 1000 items, the longest-settled going first to make +room. On a box whose `berthd` is from before this, every call rejects +(501 or 404): check that `info(box).capabilities` has `"attention"`. An +older Shipyard has no `berth.attention`: check before calling. The box +API is `GET /v1/attention?session=&open=1`, `POST /v1/attention`, and +`GET` and `PATCH /v1/attention/{key}` with the key path-escaped. + +```ts +// A snooze that follows the person between computers. +await berth.attention.update("devl", "devl/shop-claude/wait", { snoozed_until: new Date(Date.now() + 3_600_000).toISOString() }); +``` + ## React hooks From `@berth/plugin`, backed by the app's live state: diff --git a/internal/box/api.go b/internal/box/api.go index 2efa67ec8..1bd00d962 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/cosscom/shipyard/internal/box/runs" @@ -130,6 +131,12 @@ type Box struct { // Helpers runs the helper programs plugins bring (helpers.go); nil on a // box without them. Helpers *Helpers + // tellMu and tellAgain keep tellAskers to one at a time. + tellMu sync.Mutex + tellAgain atomic.Bool + // Attention keeps what needs a person, for every app paired with the + // box (attention.go); nil on a box without it. + Attention *AttentionStore } func (b *Box) own(path string) { @@ -279,6 +286,7 @@ func (b *Box) Mount(s *wire.Server) { b.mountBrowser(route) b.mountNotify(route) b.mountArtifacts(route) + b.mountAttention(route) b.mountHelpers(s, route) b.mountPairing(s, route) } @@ -333,12 +341,24 @@ func decode(r *http.Request, v any) error { return decodeLimit(r, v, 64<<10) } // a command line, so tmux's and the kernel's limits don't apply to it. const maxPromptBody = 2 << 20 -func decodeLimit(r *http.Request, v any, limit int64) error { +func decodeLimit(r *http.Request, v any, limit int64) error { return decodeBody(r, v, limit, false) } + +// decodeStrict is decodeLimit refusing a field v doesn't name. +func decodeStrict(r *http.Request, v any, limit int64) error { return decodeBody(r, v, limit, true) } + +func decodeBody(r *http.Request, v any, limit int64, strict bool) error { body := &countingReader{r: io.LimitReader(r.Body, limit)} - if err := json.NewDecoder(body).Decode(v); err != nil { + dec := json.NewDecoder(body) + if strict { + dec.DisallowUnknownFields() + } + if err := dec.Decode(v); err != nil { if body.n >= limit { return httpError{http.StatusRequestEntityTooLarge, fmt.Sprintf("the request is larger than this box takes (%d KB)", limit>>10)} } + if f, ok := unknownField(err); ok { + return badRequest("%s isn't something this takes", f) + } return badRequest("invalid request body") } return nil diff --git a/internal/box/attention.go b/internal/box/attention.go new file mode 100644 index 000000000..802aa17f7 --- /dev/null +++ b/internal/box/attention.go @@ -0,0 +1,614 @@ +package box + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "regexp" + "slices" + "sort" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "github.com/cosscom/shipyard/internal/statefile" +) + +// Attention items are what needs a person, kept on the box so every app +// and computer paired with it reads the same list: an ask an agent posts +// (a decision, an action, something to review), and a person's marks on +// items a plugin builds from the box's own signals (a waiting agent, a run's +// gate), such as a snooze or "resolved". Plugin storage is per computer, so +// without this a snooze on one laptop is news to the other. +// +// An item is named by its key. Posting to a key replaces what is there and +// counts its revision on, so an agent replaces its own ask instead of adding +// another; an agent's post without a key is keyed by its session. An item +// settles when it is answered or closed, or by itself when its session ends, +// and a settled item is forgotten 14 days later. +// +// "Attention" is a working name: the box's inbox already means prompts held +// for an agent (inbox.go). + +// AttentionItem is one item. +type AttentionItem struct { + Key string `json:"key"` + // Revision counts every change from 1, so a client can tell an item + // changed, and refuse to overwrite a change it hasn't seen. + Revision int `json:"revision"` + // Kind is what it is ("decision", "action", "review", "blocked", …, the + // poster's own word), or "mark" for a person's mark on an item built + // from signals. + Kind string `json:"kind"` + Title string `json:"title,omitempty"` + Details string `json:"details,omitempty"` + Options []AttentionOption `json:"options,omitempty"` + Recommended string `json:"recommended,omitempty"` + // Status is open, answered or closed; the last two are settled. + Status string `json:"status"` + Answer string `json:"answer,omitempty"` + ClosedReason string `json:"closed_reason,omitempty"` + SnoozedUntil time.Time `json:"snoozed_until,omitzero"` + // Session, when set, is the session it is about: it settles when that + // session ends. + Session string `json:"session,omitempty"` + // By is the origin of the last change ("app", "phone", an agent's tool, + // "berth" for the box itself). + By string `json:"by,omitempty"` + // Telling is set from when a person settles a session's own ask until + // the box holds its answer for the agent (tellAskers): who settled it, + // as the box's gates name them. + Telling string `json:"telling,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Settled time.Time `json:"settled,omitzero"` +} + +// AttentionOption is one of the answers an ask offers. +type AttentionOption struct { + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +// AttentionPost is a new ask, or one that replaces the ask at its key. +type AttentionPost struct { + Key string `json:"key,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Details string `json:"details,omitempty"` + Options []AttentionOption `json:"options,omitempty"` + Recommended string `json:"recommended,omitempty"` + Session string `json:"session,omitempty"` + // Revision, when set, is the revision the poster last read: the post is + // refused if the item has changed since (0 replaces whatever is there). + Revision int `json:"revision,omitempty"` +} + +// AttentionUpdate changes an item; what it leaves out stays as it is. An +// update to a key with no item makes a mark there. +type AttentionUpdate struct { + Revision int `json:"revision,omitempty"` + Status *string `json:"status,omitempty"` + Answer *string `json:"answer,omitempty"` + ClosedReason *string `json:"closed_reason,omitempty"` + // SnoozedUntil is an RFC 3339 time; "" ends a snooze. + SnoozedUntil *string `json:"snoozed_until,omitempty"` + // Session is the session a new mark is about. It is ignored on an item + // that exists. + Session string `json:"session,omitempty"` +} + +const ( + AttentionOpen = "open" + AttentionAnswered = "answered" + AttentionClosed = "closed" + + // attentionMark is the kind of a mark on an item the box doesn't hold. + attentionMark = "mark" + // attentionSessionEnded is the closed_reason of an item whose session + // ended while it was open. + attentionSessionEnded = "session_ended" +) + +// AttentionKeep is how long a settled item is kept. +const AttentionKeep = 14 * 24 * time.Hour + +const ( + maxAttentionItems = 1000 + maxAttentionKey = 300 + maxAttentionTitle = 300 + maxAttentionText = 16 << 10 + maxAttentionOptions = 12 + maxAttentionLabel = 200 + maxAttentionOptDesc = 1000 + maxAttentionSession = 200 +) + +var ( + errAttentionRevision = httpError{http.StatusConflict, "the item changed since that revision; read it again"} + errAttentionFull = httpError{http.StatusTooManyRequests, fmt.Sprintf("the box already holds %d open attention items; settle some first", maxAttentionItems)} + attentionWord = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) +) + +// AttentionStore keeps the box's items in one state file. +type AttentionStore struct { + Path string + // Now is the clock; nil is time.Now. + Now func() time.Time + + mu sync.Mutex + items map[string]*AttentionItem + loaded bool + // missing names the sessions of open items that the last sweep didn't + // find. A session is taken to have ended only when two sweeps in a row + // miss it, so one listing that came back short settles nothing. + missing map[string]bool +} + +func (s *AttentionStore) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +// load reads the file once. It holds s.mu. A file it can't read now fails +// the call, and the next call tries again: starting empty would have the +// next change write over every item. A file that isn't JSON is set aside +// as attention.json.corrupt-