readyroom 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +190 -11
  2. package/dist/server.mjs +557 -752
  3. package/package.json +5 -1
package/README.md CHANGED
@@ -50,6 +50,65 @@ share one place to ask and one place for you to answer.
50
50
  - **Three ways to run it, one program.** `npx` for one machine, Docker for an always-on box,
51
51
  a Cloudflare Worker for when your agents cannot reach either.
52
52
 
53
+ ## Screenshots
54
+
55
+ The page is one flow — the human draining the queue — and one authenticated role, so every
56
+ capture is a *state* of that single flow rather than a role of its own. A few representative
57
+ moments, desktop first:
58
+
59
+ <table>
60
+ <tr>
61
+ <td align="center"><b>Queue, grouped by stream</b></td>
62
+ <td align="center"><b>Options and free text</b></td>
63
+ <td align="center"><b>Option selected</b></td>
64
+ <td align="center"><b>Recent (undoable 5 min)</b></td>
65
+ </tr>
66
+ <tr>
67
+ <td><img src="docs/screenshots/pending-multi-stream.png" width="280" alt="Pending queue grouped by stream heading, with dotted low, double blocking, and solid normal urgency badges."></td>
68
+ <td><img src="docs/screenshots/pending-options.png" width="280" alt="A blocking-urgency ticket with option buttons above a second free-text ticket."></td>
69
+ <td><img src="docs/screenshots/option-selected.png" width="280" alt="A ticket with an option picked, showing a thick left border, a SELECTED marker, and the option button pressed."></td>
70
+ <td><img src="docs/screenshots/recent-answered.png" width="280" alt="An answered ticket in the Recent section with a dashed border, STATUS: ANSWERED badge, the recorded answer, and an Undo button."></td>
71
+ </tr>
72
+ </table>
73
+
74
+ <table>
75
+ <tr>
76
+ <td align="center"><b>Attached media, expanded</b></td>
77
+ <td align="center"><b>Marked up and annotated</b></td>
78
+ </tr>
79
+ <tr>
80
+ <td><img src="docs/screenshots/pending-media.png" width="280" alt="A ticket with an attached screenshot expanded for inspection: the image on a drawing canvas with Box, Draw, and Clear markup tools, above the optional annotations editor."></td>
81
+ <td><img src="docs/screenshots/media-annotated.png" width="280" alt="The attached image marked up with a red box, and a queued note reading 'nudge the title left by 8px' in the annotations editor."></td>
82
+ </tr>
83
+ </table>
84
+
85
+ <table>
86
+ <tr>
87
+ <td align="center"><b>Token gate</b></td>
88
+ <td align="center"><b>Token rejected (401)</b></td>
89
+ <td align="center"><b>Polling fallback</b></td>
90
+ <td align="center"><b>Phone width</b></td>
91
+ </tr>
92
+ <tr>
93
+ <td><img src="docs/screenshots/token-gate.png" width="280" alt="First paint with no token: the token bar is visible and the status reads waiting for token."></td>
94
+ <td><img src="docs/screenshots/token-rejected.png" width="280" alt="A wrong token was saved: a Token rejected (401) error banner with the token bar shown again."></td>
95
+ <td><img src="docs/screenshots/status-polling.png" width="280" alt="The SSE endpoint is unavailable, so the status reads polling every 5s."></td>
96
+ <td><img src="docs/screenshots/mobile-pending.png" width="150" alt="The populated multi-stream queue at phone width."></td>
97
+ </tr>
98
+ </table>
99
+
100
+ There is also an empty board, an inline per-card validation error, and a failed-load banner —
101
+ the page is captured at `1280x800` (desktop) and `390x844` (the two `mobile-*` shots). A full set
102
+ of every screen and state, with a one-line description of each, is in
103
+ [`docs/screenshots/MANIFEST.md`](docs/screenshots/MANIFEST.md) — regenerate the lot with
104
+
105
+ ```sh
106
+ npm run screenshots
107
+ ```
108
+
109
+ which starts its own isolated readyroom on `:8972`, drives each state, and tears it down. For how
110
+ to actually use each of these screens, see [`docs/USAGE.md`](docs/USAGE.md).
111
+
53
112
  ## Vocabulary
54
113
 
55
114
  These words are used exactly, in code, comments, tool descriptions, and docs. No synonyms.
@@ -96,8 +155,14 @@ ask_human({
96
155
  // when it is safe for the agent to proceed without a human.
97
156
  timeout_seconds?: number, // optional. Default 1800. Max 86400.
98
157
  urgency?: "low" | "normal" | "blocking", // optional. Default "normal".
99
- stream?: string // optional. Overrides the env/basename default.
100
- }) => { id: string, status: "pending", expires_at: string }
158
+ stream?: string, // optional. Overrides the env/basename default.
159
+ media?: Array<{ // optional. Screenshots, audio, or video for the human to inspect.
160
+ mime_type: string, // an image/*, audio/*, or video/* type
161
+ data_base64: string, // standard base64 (a `data:<mime>;base64,` prefix is accepted)
162
+ filename?: string,
163
+ caption?: string
164
+ }>
165
+ }) => { id: string, status: "pending", expires_at: string, media?: MediaRef[] }
101
166
  ```
102
167
 
103
168
  ### `wait_for_answer`
@@ -111,13 +176,19 @@ wait_for_answer({
111
176
  timeout_seconds?: number // default 60, max 600
112
177
  }) => {
113
178
  id: string,
114
- status: "pending" | "answered" | "expired",
179
+ status: "pending" | "answered" | "expired" | "cancelled",
115
180
  answer?: string,
116
181
  answered_by?: string, // "human" | "default"
117
- answered_at?: string
182
+ answered_at?: string,
183
+ media?: MediaRef[], // the media you attached, echoed back
184
+ annotations?: Annotation[] // the human's notes and any rendered markup copy
118
185
  }
119
186
  ```
120
187
 
188
+ When the human drew on an attached image, the annotated copy is returned **as MCP `image`
189
+ content** alongside the text, so an agent that only speaks MCP can see the markup without
190
+ fetching a URL. `annotations[].annotated_media` also names a blob under `GET /api/media/{id}`.
191
+
121
192
  ### `list_pending`
122
193
 
123
194
  Lists the tickets still pending across every agent stream. Takes no arguments.
@@ -141,6 +212,30 @@ its own earlier guess. An answer that came from `default_answer` gets a clearly
141
212
  prefix — `DEFAULT ANSWER (applied on expiry; no human responded):` — because labelling the
142
213
  agent's own default as a human answer would be worse than no prefix at all.
143
214
 
215
+ ## Media and annotations
216
+
217
+ Media is **optional**: most tickets have none, and a ticket with media is answered exactly like
218
+ one without. Attach it with `ask_human`'s `media` array (up to 8 items, 8 MiB each, 24 MiB per
219
+ ticket) when a decision needs a screenshot, a clip, or a recording rather than words.
220
+
221
+ - **Stored out of band.** The bytes live in a media store, keyed by an id; the ticket carries
222
+ only a `MediaRef` (kind, MIME type, filename, caption, size). `GET /api/tickets` and
223
+ `tickets.json` therefore stay small, and the page fetches a blob only when you expand a card.
224
+ - **Served same-origin.** `GET /api/media/{id}` returns the bytes under the same Bearer auth as
225
+ every other `/api/*` route. Nothing is fetched from an external origin.
226
+ - **Expand and inspect.** Each attachment is collapsed behind a *Show N attachments* toggle.
227
+ Images open on a drawing canvas (`Box`, `Draw`, `Clear markup`); audio and video get a player.
228
+ - **Annotate and send back.** The human can mark up an image and add notes (notes on audio/video
229
+ capture the playhead position). On **Answer**, a marked-up image is rendered to a PNG, uploaded
230
+ with `POST /api/media`, and referenced from the answer. Annotations are **optional**: the human
231
+ may answer with text, a chosen option, annotations, or any combination — but not nothing.
232
+ - **Back to the agent.** `wait_for_answer` returns the annotations as structured fields, and any
233
+ rendered markup as MCP `image` content, so the agent sees exactly what the human marked.
234
+
235
+ The three targets share the behaviour; only the storage differs. `FileTicketStore` writes blobs
236
+ under `${READYROOM_STATE_DIR}/media/`, and `DurableObjectTicketStore` chunks each blob across rows
237
+ because a Cloudflare SQLite row is capped at 2 MB.
238
+
144
239
  ## Three ways aboard
145
240
 
146
241
  They run the same program. They differ in where it lives and who can reach it.
@@ -213,6 +308,10 @@ services:
213
308
  - ./readyroom-state:/app/.state
214
309
  ```
215
310
 
311
+ Copy this file to get a runnable setup without retyping it:
312
+ [`examples/docker-compose.yml`](examples/docker-compose.yml) — set `READYROOM_TOKEN`, then
313
+ `docker compose up -d`.
314
+
216
315
  The container runs the same Node entry as npx, in stdio-plus-HTTP mode, as a non-root user, and
217
316
  handles `SIGTERM`.
218
317
 
@@ -368,6 +467,34 @@ The stream is the path segment, so each agent gets its own URL.
368
467
  }
369
468
  ```
370
469
 
470
+ ### Directing an agent to use readyroom (`AGENTS.md`)
471
+
472
+ Paste the block below into your project's `AGENTS.md` (or `CLAUDE.md`) so the agent knows
473
+ readyroom is available and how to reach it. It goes at the top level of that file, alongside
474
+ your other project instructions.
475
+
476
+ ````markdown
477
+ ## Asking a human
478
+
479
+ When you reach a decision you cannot make alone — a choice with real consequences, a
480
+ preference only the operator holds, or anything you would otherwise guess at — ask the
481
+ operator through the `readyroom` MCP server instead of guessing or blocking.
482
+
483
+ - `ask_human` — enqueue a question and return immediately. It never waits. Pass `question`
484
+ (one sentence), and optionally `options` (2-5 mutually exclusive choices), `context`,
485
+ `default_answer`, `timeout_seconds`, `urgency`, and `stream`. It returns
486
+ `{ id, status: "pending" }`.
487
+ - `wait_for_answer` — block for the human's answer to a ticket you enqueued. Pass the `id`.
488
+ It returns immediately if the ticket is already answered or expired; if it is still
489
+ pending, continue with independent work and call it again later. Do not poll in a tight loop.
490
+ - `list_pending` — list the tickets still pending across every agent stream. Takes no arguments.
491
+ - `cancel_ticket` — withdraw a ticket you no longer need answered. Pass the `id`.
492
+
493
+ A delivered human answer is prefixed with `HUMAN ANSWER:`. An answer applied from
494
+ `default_answer` on expiry is prefixed `DEFAULT ANSWER (applied on expiry; no human
495
+ responded):` — never treat that as a human answer.
496
+ ````
497
+
371
498
  ### Auth and secrets
372
499
 
373
500
  `src/security.ts` holds both guards.
@@ -537,7 +664,9 @@ The Worker has no state dir, no bind address, and no daemon role. Three conseque
537
664
  ## The readyroom page
538
665
 
539
666
  The page is a **status board**, not a chat log. It shows what is waiting and clears when you
540
- have answered — which is why there is no markdown parser and no message history.
667
+ have answered — which is why there is no markdown parser and no message history. For a
668
+ walkthrough of opening it, reading a ticket, and answering one, see
669
+ [`docs/USAGE.md`](docs/USAGE.md).
541
670
 
542
671
  `public/index.html`. One file, shared by all three targets. All CSS and JS inline. No CDN, no
543
672
  external origin, no build step. It renders correctly when opened from the server with the
@@ -549,7 +678,8 @@ network otherwise cut. It is inlined at build time into each target's bundle.
549
678
  - **Grouped by stream.** Tickets grouped under a stream heading, newest stream first. You need
550
679
  to know which agent is asking.
551
680
  - **Card contents.** Stream name, ticket age (`4m`), urgency badge, the question, the context,
552
- option buttons, a free-text field, and an Answer button.
681
+ any attached media behind an expand-for-inspection toggle (with drawing and notes), option
682
+ buttons, a free-text field, and an Answer button.
553
683
  - **Keyboard-first.** `j`/`k` move between tickets, `1`-`5` pick an option, `Enter` submits, `u`
554
684
  undoes the last answer. Draining a queue with a mouse is the slow path.
555
685
  - **Tab title carries the count.** `(3) readyroom`.
@@ -573,8 +703,10 @@ GET /healthz -> 200, body "ok"
573
703
  GET / -> the readyroom page (index.html)
574
704
  GET /api/tickets -> { tickets: Ticket[] } pending + last 5 min of answered
575
705
  GET /api/events -> text/event-stream of ticket changes (501 on the Worker)
576
- POST /api/answer -> { id, answer, answered_by? } -> 200 | 404 | 409 already answered
706
+ POST /api/answer -> { id, answer?, answered_by?, annotations? } -> 200 | 400 | 404 | 409
577
707
  POST /api/undo -> { id } -> 200 | 409 outside the undo window
708
+ POST /api/media -> { mime_type, data_base64 } -> 201 { media: MediaRef }
709
+ GET /api/media/{id} -> the blob bytes (Content-Type from the stored MIME) | 404
578
710
  POST /mcp/{stream} -> the Streamable HTTP MCP endpoint (Origin-guarded)
579
711
  ```
580
712
 
@@ -583,18 +715,22 @@ Unknown route -> `404`. Known route, wrong method -> `405`. The Node target addi
583
715
 
584
716
  ## CI/CD
585
717
 
586
- Four GitHub Actions workflows:
718
+ Five GitHub Actions workflows:
587
719
 
720
+ - **`.github/workflows/release.yml`** — on every push to `main` (that is, every merged PR): runs
721
+ [semantic-release](https://semantic-release.gitbook.io) over the conventional-commit messages
722
+ since the last tag, then versions, changelogs, tags, and publishes the release. It is the
723
+ entry point; the four below are its downstream stages.
588
724
  - **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build,
589
725
  `wrangler deploy --dry-run`, `docker build`.
590
726
  - **`.github/workflows/publish.yml`** — on the default branch and `v*` tags: buildx for
591
727
  `linux/amd64,linux/arm64` (arm64 is required; the deploy target is a Pi), push to GHCR, auth
592
728
  via the built-in `GITHUB_TOKEN` with `packages: write` (no PAT, no repo secrets),
593
- `docker/metadata-action` tags (`latest` on the default branch, semver on `v*`, `sha-<short>`),
594
- GHA build cache scoped per image, one matrix entry per image. Supply chain:
729
+ `docker/metadata-action` tags (`latest` on the default branch and on `v*`, semver on `v*`,
730
+ `sha-<short>`), GHA build cache scoped per image, one matrix entry per image. Supply chain:
595
731
  `provenance: mode=max`, `sbom: true`, and `actions/attest-build-provenance` pushed to the
596
732
  registry.
597
- - **`.github/workflows/publish-npm.yml`** — on `v*` tags: `npm publish` with provenance, using
733
+ - **`.github/workflows/npm-publish.yml`** — on `v*` tags: `npm publish` with provenance, using
598
734
  npm Trusted Publishing (OIDC) rather than a stored token. It needs `id-token: write` and the
599
735
  trusted-publisher entry configured on the package.
600
736
  - **`.github/workflows/deploy-worker.yml`** — on `v*` tags: `wrangler deploy` with
@@ -605,6 +741,48 @@ Four GitHub Actions workflows:
605
741
 
606
742
  `.github/dependabot.yml` covers `github-actions`, `npm`, and `docker`.
607
743
 
744
+ ### Releasing
745
+
746
+ Merging a PR to `main` cuts the release. There is no version to bump and no tag to push:
747
+ `release.yml` runs [semantic-release](https://semantic-release.gitbook.io), which reads the
748
+ commit messages since the last release and picks the version by the usual convention:
749
+
750
+ | Commit | Version bump |
751
+ | --- | --- |
752
+ | `fix: …` | patch |
753
+ | `feat: …` | minor |
754
+ | any type carrying a `BREAKING CHANGE:` footer | minor while `0.x`, major from `1.0` |
755
+ | `docs:`, `chore:`, `refactor:`, `test:`, … | no release |
756
+
757
+ It writes `CHANGELOG.md` and the new version into `package.json`, commits them
758
+ (`chore(release): vX.Y.Z [skip ci]`), tags `vX.Y.Z`, and opens the GitHub Release. A merge whose
759
+ commits are all non-releasing finishes green and publishes nothing.
760
+
761
+ **Pre-1.0 behaviour.** While the version is still `0.x`, a breaking change bumps **minor** rather
762
+ than major, so a `BREAKING CHANGE:` never jumps you to 1.0.0 by surprise. That downgrade lives in
763
+ `release.config.js` and switches itself off the moment the current version's major is no longer
764
+ `0`. To reach 1.0 deliberately, run `npm run release:major` — it bumps and tags `v1.0.0` — and
765
+ from the next release on breaking changes bump major again.
766
+
767
+ **Why the release workflow dispatches the others instead of leaning on the tag.** A tag pushed
768
+ by a workflow's built-in `GITHUB_TOKEN` does not start other workflows: GitHub suppresses runs
769
+ for events caused by `GITHUB_TOKEN` to prevent recursion, so the `v*` triggers above would stay
770
+ silent. `workflow_dispatch` is the one event that rule exempts, so `release.yml` dispatches
771
+ `npm-publish.yml`, `publish.yml`, and (when enabled) `deploy-worker.yml` on the new tag itself.
772
+ The whole pipeline therefore runs on the built-in token — **no PAT and no repo secret**. If you
773
+ ever switch `release.yml` to a PAT or a GitHub App token, remove that dispatch step, or the tag
774
+ trigger and the dispatch will both fire.
775
+
776
+ The `npm run release:patch|minor|major` scripts still work as a manual fallback — they push a
777
+ tag, and the `v*` triggers pick it up — but with semantic-release they are no longer part of the
778
+ normal flow.
779
+
780
+ semantic-release, `@semantic-release/changelog`, and `@semantic-release/git` are
781
+ devDependencies; the other release plugins resolve from semantic-release itself. The release job
782
+ deliberately does **not** request `id-token: write`, so the OIDC token never sits beside
783
+ installed dependency code: publishing stays in `npm-publish.yml`, whose two-job split keeps the
784
+ token out of the build.
785
+
608
786
  ### npm publishing
609
787
 
610
788
  Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish
@@ -621,6 +799,7 @@ npm run typecheck # tsc --noEmit
621
799
  npm run build # esbuild -> dist/server.mjs (single file, HTML inlined)
622
800
  npm run build:worker # wrangler deploy --dry-run --outdir dist/worker
623
801
  npm run dev:worker # wrangler dev
802
+ npm run screenshots # capture every page state into docs/screenshots/
624
803
  ```
625
804
 
626
805
  Repo conventions: