feat(box): a box keeps attention items, so a plugin's snooze follows you between computers
Summary
A plugin's storage is per computer, so a snooze or "resolved" a person sets on one laptop is news to their other one, and a plugin has nowhere on the box to keep what an agent asks. Each box now keeps a small list of attention items: asks an agent posts, and a person's marks on items a plugin builds from the box's own signals. Every app paired with the box reads the same list. ("Attention" is a working name: the box's inbox already means held prompts.)
sequenceDiagram
participant A as Laptop A (plugin)
participant Box as berthd (attention.json)
participant B as Laptop B (plugin)
A->>Box: PATCH /v1/attention/{key} {snoozed_until, revision}
Box-->>A: item, revision+1
Box-)B: attention.updated {key, revision, status}
B->>Box: GET /v1/attention
Note over Box: session stopped → settle at once (session.stopped observer)<br/>program exited → settle when two sweeps miss it (refreshTurns, 10 s)
internal/box/
+├── attention.go # the store: key + revision replace, settle, 14-day ageing, 1000-item cap
+├── attentionapi.go # GET/POST /v1/attention, GET/PATCH /v1/attention/{key...}, events, the session.stopped observer
├── turns.go # refreshTurns also sweeps attention (the backstop for exits)
└── upgrade.go # capability "attention"
packages/plugin-sdk/src/
+│ BerthAttention # berth.attention.list/get/post/update, AttentionItem types
app/src/lib/
+└── attention.ts # what plugins call, through the laptop agent
- One key per ask. Posting to a key replaces the item there and counts its
revisionon, so an agent replaces its own ask instead of adding another; a post with asessionand no key issession:<name>. A post or update that names a revision that has gone by is refused (409). - Marks. An update to a key with no item makes a
markthere, so a plugin can snooze or resolve an item it builds itself (a waiting agent, a run's gate). - Settling.
open→answeredorclosed. An open item about a session closes assession_endedwhen the session is stopped (at once), or when its program ends (once two sweeps, each with a listing taken after the item last changed, miss it). - Kept safe. A state file the box can't read refuses the call and is read again next time, never taken as empty; one that isn't JSON is set aside as
attention.json.corrupt-<time>. A settle changes memory only after the file holds it. - Events say what changed, never an item's words.
before:attention.postandbefore:attention.updategates can refuse.
Docs: reference/plugin-sdk#attention, the box routes in app-api, events and gates, and a changelog line. Based on 4ebd998 (the commit before #207); it merges clean onto main.
Evidence
A real box and two laptop agents paired with it, each with its own pairing code (.claude/skills/verify-berthd/scripts/attention-live.sh), driven in two browsers by scenarios/attention-two-laptops-live.mjs with a small test plugin. Result ok: true, no console errors on either laptop.
- Before: a snooze kept in plugin storage stays on the laptop that made it. Laptop B's storage line reads
nothingbelow.
After: laptop B reads the snooze laptop A made, from the box.
| Laptop A snoozes | Laptop B reads it (its own storage: nothing) |
|---|---|
![]() | ![]() |
| B resolves, A shows it with no reload (event) | Session stopped: its open ask settles on both |
|---|---|
![]() | ![]() |
Steps the scenario asserts with literal text:
A: snooze session:att-shell → "… open · snoozed until 2030-01-02T09:00:00Z · rev 2 · by app" (A and B)
B: storage line → "This computer's plugin storage: nothing"
B: resolve → "… closed (resolved) · … · rev 3" on B, then on A without a reload
PATCH from revision 2 → 409
DELETE session att-shell → "acme/deploy-check · action · closed (session_ended) · … · rev 2 · by berth" on both
session "sleep 4" with an ask → closed (session_ended) on both, from the sweep
GET /v1/attention via laptop B → the same, read back through the second agent
Unit tests (go test -race ./internal/box -run 'Attention|AMark|ItemsSettle|AnAgentReplaces|ASweep|ASettle', 3× under -race): key and revision replace, marks, settle on session end, the two-miss rule and a post after the listing, ageing out, a restart, a file that can't be read or isn't JSON, a settle whose write fails. app/src/lib/attention.test.ts: paths are escaped (/, #), get is undefined for no item.
Checks run locally (fork PRs wait for a maintainer before CI): go vet ./..., go test -race ./... (only TestTheCatalogIsCapped fails, as on main on this machine: it counts the machine's own commands), the app's typecheck:plugins, check:*, test, build, tsc -p e2e, and the docs build. cargo check is untouched (no Rust change). Codex review and adversarial review ran once; their findings (a sweep against a stale listing, a short listing, a load error taken as empty, a settle not saved, a post to an exited session) are fixed in the last commit with tests.
Merge Danger
Door: two-way
Additive: a new state file (~/.berth/…/attention.json), new routes, a capability, new events and gates. Nothing existing reads or writes them. Reverting leaves an unused file behind.
Blast Radius: small
Only plugins that call berth.attention see it. The one shared path touched is refreshTurns, which now also sweeps the store (a map walk, a file write only when something settles), and a session.stopped observer that hands off to a goroutine. The plugin SDK gains attention on BerthPluginContext: a plugin written against it should check info(box).capabilities and that berth.attention exists, as documented.
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.



