Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-berthd/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>`, `nav-more`, `nav-badge`, `home-grid`, the list at the top of `app/e2e/fixtures.ts`), roles and accessible names. Never coordinates.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
| 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 |
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/verify-berthd/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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-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)
Expand All @@ -19,6 +20,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

Expand Down
75 changes: 75 additions & 0 deletions .claude/skills/verify-berthd/scripts/attention-live.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .claude/skills/verify-berthd/scripts/drive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`));
Expand Down
Loading