feat(box): a box keeps attention items, so a plugin's snooze follows you between computers

Draft
ST
4d ago
+1652-3

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 revision on, so an agent replaces its own ask instead of adding another; a post with a session and no key is session:<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 mark there, so a plugin can snooze or resolve an item it builds itself (a waiting agent, a run's gate).
  • Settling. open → answered or closed. An open item about a session closes as session_ended when 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.post and before:attention.update gates 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 nothing below.
    After: laptop B reads the snooze laptop A made, from the box.
Laptop A snoozesLaptop B reads it (its own storage: nothing)
A snoozedB reads the snooze
B resolves, A shows it with no reload (event)Session stopped: its open ask settles on both
A sees B's resolveB after the session ended

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
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.