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.
- package/README.md +190 -11
- package/dist/server.mjs +557 -752
- 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
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|