readyroom 0.1.2 → 0.1.4
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 +250 -102
- package/package.json +15 -2
- package/dist/worker/760a79b513c62d05de332c66a928fb95896bc5d3-index.html +0 -743
- package/dist/worker/README.md +0 -1
- package/dist/worker/worker.js +0 -19775
- package/dist/worker/worker.js.map +0 -8
package/README.md
CHANGED
|
@@ -1,14 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://www.npmjs.com/package/readyroom"><img alt="npm" src="https://img.shields.io/npm/v/readyroom?color=0e646e&labelColor=0B2140"></a>
|
|
3
|
+
<img alt="node ≥ 22" src="https://img.shields.io/badge/node-%E2%89%A5%2022-0e646e?labelColor=0B2140">
|
|
4
|
+
<img alt="three ways to run it: npx, Docker, Cloudflare Worker" src="https://img.shields.io/badge/run-npx%20%7C%20docker%20%7C%20worker-0e646e?labelColor=0B2140">
|
|
5
|
+
<img alt="license: Unlicense" src="https://img.shields.io/badge/license-Unlicense-0e646e?labelColor=0B2140">
|
|
6
|
+
</p>
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- **Self-hosted Docker on a Raspberry Pi** — the homelab owner. Always-on, arm64, LAN-reachable, survives reboots.
|
|
9
|
-
- **Cloudflare Worker** — a team with no infrastructure to run. Always-on, reachable from anywhere, no box to patch.
|
|
8
|
+
# readyroom
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
**The bridge runs the ship. The ready room decides.**
|
|
11
|
+
|
|
12
|
+
> [!WARNING]
|
|
13
|
+
> readyroom is under active development. Don't rely on it for anything that matters until v1.
|
|
14
|
+
|
|
15
|
+
## What is this?
|
|
16
|
+
|
|
17
|
+
An agent that reaches a decision it should not make alone has two bad options today. It can
|
|
18
|
+
guess, and the guess becomes fact downstream. Or it can stop and wait, which holds its session
|
|
19
|
+
open until a human notices, and usually surfaces as a modal that hijacks whatever you were
|
|
20
|
+
doing.
|
|
21
|
+
|
|
22
|
+
readyroom gives it a third. The agent writes a **ticket** — one question, optional choices,
|
|
23
|
+
minimal context — and goes back to work. You drain the tickets from one page when you get to
|
|
24
|
+
them. The page groups them under the agent that asked, so you always know who is waiting on
|
|
25
|
+
you, and the agent collects the answer the next time it asks.
|
|
26
|
+
|
|
27
|
+
The name is the design. On a ship, the bridge is where the work happens and the **ready room**
|
|
28
|
+
is where the captain steps away to decide something that should not be decided in the noise.
|
|
29
|
+
The whole invariant is **N agents, one interrupt channel**: however many agents you run, they
|
|
30
|
+
share one place to ask and one place for you to answer.
|
|
31
|
+
|
|
32
|
+
## Why readyroom
|
|
33
|
+
|
|
34
|
+
- **No agent ever blocks.** `ask_human` enqueues a ticket and returns in under 200 ms. A
|
|
35
|
+
blocking tool holds the MCP session open and wedges the client the moment you step away
|
|
36
|
+
from the keyboard for twenty minutes.
|
|
37
|
+
- **The queue survives a restart.** Tickets are written atomically to disk, or to a single
|
|
38
|
+
Durable Object on the Worker. A restart that forgot pending tickets would mean an agent
|
|
39
|
+
waiting forever on a question nobody can see any more.
|
|
40
|
+
- **You know which agent is asking.** Tickets are grouped by **stream** — the agent that
|
|
41
|
+
asked — with the newest stream first. A flat list of questions from four agents is not a
|
|
42
|
+
queue you can drain; it is a pile.
|
|
43
|
+
- **It never takes over your screen.** There is no modal and no focus stealing. The page is a
|
|
44
|
+
**status board, not a chat log**: it shows what is waiting and clears when you have answered.
|
|
45
|
+
- **Draining is keyboard-first.** `j`/`k` to move, `1`-`5` to pick an option, `Enter` to
|
|
46
|
+
submit, `u` to undo. A queue with only a mouse is the slow path, and the point of a queue is
|
|
47
|
+
that it drains quickly.
|
|
48
|
+
- **The log is append-only and never pruned.** An approval log is the only record that a human
|
|
49
|
+
authorised something, so it is kept in full. That is what a log is.
|
|
50
|
+
- **Three ways to run it, one program.** `npx` for one machine, Docker for an always-on box,
|
|
51
|
+
a Cloudflare Worker for when your agents cannot reach either.
|
|
12
52
|
|
|
13
53
|
## Vocabulary
|
|
14
54
|
|
|
@@ -19,21 +59,25 @@ These words are used exactly, in code, comments, tool descriptions, and docs. No
|
|
|
19
59
|
- **drain** — the human answering tickets from the page.
|
|
20
60
|
- **heads-up page** — the single HTML page served at `GET /`.
|
|
21
61
|
|
|
22
|
-
The tool names (`ask_human`, `wait_for_answer`, `list_pending`, `cancel_ticket`) are the
|
|
23
|
-
|
|
24
|
-
|
|
62
|
+
The tool names (`ask_human`, `wait_for_answer`, `list_pending`, `cancel_ticket`) are the
|
|
63
|
+
agent-facing contract and do not change with the product name. The product name is a separate
|
|
64
|
+
layer, the way `ripgrep` ships `rg`.
|
|
25
65
|
|
|
26
66
|
## The one rule that matters
|
|
27
67
|
|
|
28
68
|
**`ask_human` enqueues and returns. It never waits.**
|
|
29
69
|
|
|
30
|
-
A blocking tool holds the MCP session open and wedges the client when the human is heads-down
|
|
31
|
-
|
|
32
|
-
|
|
70
|
+
A blocking tool holds the MCP session open and wedges the client when the human is heads-down
|
|
71
|
+
for twenty minutes. Enqueue-then-poll keeps the transport healthy. `ask_human` returns
|
|
72
|
+
`{ id, status: "pending" }` in under 200 ms.
|
|
33
73
|
|
|
34
|
-
|
|
74
|
+
This rule is also what makes the Worker target possible at all. A Workers invocation cannot
|
|
75
|
+
hold a session open for twenty minutes; a request that enqueues and returns is stateless enough
|
|
76
|
+
to run per-request on the edge. There is no blocking variant on any target.
|
|
35
77
|
|
|
36
|
-
|
|
78
|
+
The `ask_human` tool description is where the agent learns the protocol: *on `pending`,
|
|
79
|
+
continue with independent work and call `wait_for_answer` again later; do not poll in a tight
|
|
80
|
+
loop.*
|
|
37
81
|
|
|
38
82
|
## The four MCP tools
|
|
39
83
|
|
|
@@ -58,7 +102,8 @@ ask_human({
|
|
|
58
102
|
|
|
59
103
|
### `wait_for_answer`
|
|
60
104
|
|
|
61
|
-
Waits for the human to answer, then returns the result. Returns immediately if the ticket is
|
|
105
|
+
Waits for the human to answer, then returns the result. Returns immediately if the ticket is
|
|
106
|
+
already answered or expired.
|
|
62
107
|
|
|
63
108
|
```ts
|
|
64
109
|
wait_for_answer({
|
|
@@ -91,11 +136,24 @@ cancel_ticket({ id: string }) => { id: string, status: "cancelled" }
|
|
|
91
136
|
|
|
92
137
|
### The `HUMAN ANSWER:` prefix
|
|
93
138
|
|
|
94
|
-
A delivered human answer is prefixed with `HUMAN ANSWER:` so a model can never confuse it with
|
|
139
|
+
A delivered human answer is prefixed with `HUMAN ANSWER:` so a model can never confuse it with
|
|
140
|
+
its own earlier guess. An answer that came from `default_answer` gets a clearly different
|
|
141
|
+
prefix — `DEFAULT ANSWER (applied on expiry; no human responded):` — because labelling the
|
|
142
|
+
agent's own default as a human answer would be worse than no prefix at all.
|
|
95
143
|
|
|
96
|
-
|
|
144
|
+
## Three ways aboard
|
|
97
145
|
|
|
98
|
-
|
|
146
|
+
They run the same program. They differ in where it lives and who can reach it.
|
|
147
|
+
|
|
148
|
+
| | `npx` | Docker from GHCR | Cloudflare Worker |
|
|
149
|
+
|---|---|---|---|
|
|
150
|
+
| setup | `npx readyroom` | pull `ghcr.io/therebelrobot/readyroom` | `wrangler deploy` |
|
|
151
|
+
| runs | on the host, as you | in a container, as a non-root user | on the edge, with no box to patch |
|
|
152
|
+
| state | `./.state` on disk | a volume mount | one SQLite Durable Object |
|
|
153
|
+
| reachable from | loopback only, by default | loopback; your LAN through a reverse proxy | anywhere, behind Cloudflare Access |
|
|
154
|
+
| MCP transport | stdio + Streamable HTTP | stdio + Streamable HTTP | Streamable HTTP |
|
|
155
|
+
| live updates | SSE | SSE | polling; the page falls back on its own |
|
|
156
|
+
| best for | trying it in a minute | always-on, arm64, survives reboots | agents that cannot reach your house |
|
|
99
157
|
|
|
100
158
|
### npx — one developer, one machine, thirty seconds
|
|
101
159
|
|
|
@@ -103,18 +161,29 @@ A delivered human answer is prefixed with `HUMAN ANSWER:` so a model can never c
|
|
|
103
161
|
npx readyroom
|
|
104
162
|
```
|
|
105
163
|
|
|
106
|
-
No config, no container, no account. The server speaks stdio MCP (wire it into your MCP client)
|
|
164
|
+
No config, no container, no account. The server speaks stdio MCP (wire it into your MCP client)
|
|
165
|
+
and also serves Streamable HTTP on the same process for clients that want it.
|
|
107
166
|
|
|
108
|
-
**The singleton.** Three MCP clients spawning `npx readyroom` would otherwise produce three
|
|
167
|
+
**The singleton.** Three MCP clients spawning `npx readyroom` would otherwise produce three
|
|
168
|
+
processes, three attempts to bind port 8971, and three writers to one `tickets.json` — which
|
|
169
|
+
breaks the single-writer invariant and can corrupt the queue. So:
|
|
109
170
|
|
|
110
|
-
- The **first** process to start becomes the **daemon**: it binds the port, owns the store, and
|
|
111
|
-
|
|
171
|
+
- The **first** process to start becomes the **daemon**: it binds the port, owns the store, and
|
|
172
|
+
writes `${READYROOM_STATE_DIR}/daemon.json` containing `{ pid, port, started_at }`.
|
|
173
|
+
- Every **subsequent** process becomes a **client**: it reads `daemon.json`, confirms the pid is
|
|
174
|
+
alive and `/healthz` answers, then proxies its MCP tool calls to the daemon over
|
|
175
|
+
`http://127.0.0.1:<port>`. It serves stdio on its own file descriptors and nothing else. It
|
|
176
|
+
never writes `tickets.json`.
|
|
112
177
|
|
|
113
|
-
If `daemon.json` is stale (pid dead, or the port refuses), the starting process acquires the
|
|
178
|
+
If `daemon.json` is stale (pid dead, or the port refuses), the starting process acquires the
|
|
179
|
+
daemon role itself and overwrites the file. If the daemon is unreachable but a foreign service
|
|
180
|
+
holds the port, the process **refuses to start** with a message naming the port — it never
|
|
181
|
+
silently falls back to a second writer.
|
|
114
182
|
|
|
115
183
|
### Self-hosted Docker on a Raspberry Pi
|
|
116
184
|
|
|
117
|
-
Always-on, arm64, LAN-reachable, survives reboots. The image is multi-arch
|
|
185
|
+
Always-on, arm64, LAN-reachable, survives reboots. The image is multi-arch
|
|
186
|
+
(`linux/amd64,linux/arm64`); arm64 is required because the deploy target is a Pi.
|
|
118
187
|
|
|
119
188
|
```sh
|
|
120
189
|
docker run --rm -d \
|
|
@@ -124,7 +193,8 @@ docker run --rm -d \
|
|
|
124
193
|
ghcr.io/therebelrobot/readyroom:latest
|
|
125
194
|
```
|
|
126
195
|
|
|
127
|
-
Or as a mergeable compose snippet. It binds to loopback; LAN exposure goes through the reverse
|
|
196
|
+
Or as a mergeable compose snippet. It binds to loopback; LAN exposure goes through the reverse
|
|
197
|
+
proxy.
|
|
128
198
|
|
|
129
199
|
```yaml
|
|
130
200
|
services:
|
|
@@ -143,13 +213,18 @@ services:
|
|
|
143
213
|
- ./readyroom-state:/app/.state
|
|
144
214
|
```
|
|
145
215
|
|
|
146
|
-
The container runs the same Node entry as npx, in stdio-plus-HTTP mode, as a non-root user, and
|
|
216
|
+
The container runs the same Node entry as npx, in stdio-plus-HTTP mode, as a non-root user, and
|
|
217
|
+
handles `SIGTERM`.
|
|
147
218
|
|
|
148
|
-
>
|
|
219
|
+
> [!NOTE]
|
|
220
|
+
> **GHCR packages start private.** After the first publish run, flip the package to public in
|
|
221
|
+
> the GitHub package settings, or `docker pull` will fail with an authentication error.
|
|
149
222
|
|
|
150
223
|
### Cloudflare Worker
|
|
151
224
|
|
|
152
|
-
Always-on, reachable from anywhere, no box to patch. The Worker serves the heads-up page and
|
|
225
|
+
Always-on, reachable from anywhere, no box to patch. The Worker serves the heads-up page and
|
|
226
|
+
the Streamable HTTP MCP endpoint from the same origin, backed by one SQLite Durable Object
|
|
227
|
+
instance.
|
|
153
228
|
|
|
154
229
|
```sh
|
|
155
230
|
# 1. Supply the token as a secret (never committed).
|
|
@@ -159,9 +234,11 @@ wrangler secret put READYROOM_TOKEN
|
|
|
159
234
|
npm run deploy:worker
|
|
160
235
|
```
|
|
161
236
|
|
|
162
|
-
The MCP endpoint is per-agent: **`POST /mcp/{stream}`**. Each agent gets its own URL, configured
|
|
163
|
-
|
|
164
|
-
|
|
237
|
+
The MCP endpoint is per-agent: **`POST /mcp/{stream}`**. Each agent gets its own URL, configured
|
|
238
|
+
once in the client. There is no `cwd` on Workers, so `basename(cwd)` cannot be the fallback —
|
|
239
|
+
the stream is required and comes from the path segment or an explicit `stream` tool argument.
|
|
240
|
+
If neither is present, the tool call fails with an error naming both ways to supply it. No
|
|
241
|
+
default is invented.
|
|
165
242
|
|
|
166
243
|
## Environment variables
|
|
167
244
|
|
|
@@ -178,7 +255,8 @@ Five variables, plus one optional extra.
|
|
|
178
255
|
|
|
179
256
|
### Mapping to the Worker
|
|
180
257
|
|
|
181
|
-
On the Worker these are **`vars` and `secrets` in the Wrangler config, not process
|
|
258
|
+
On the Worker these are **`vars` and `secrets` in the Wrangler config, not process
|
|
259
|
+
environment**. The mapping is explicit:
|
|
182
260
|
|
|
183
261
|
| Node env var | Worker equivalent |
|
|
184
262
|
|---|---|
|
|
@@ -189,30 +267,50 @@ On the Worker these are **`vars` and `secrets` in the Wrangler config, not proce
|
|
|
189
267
|
| `READYROOM_STATE_DIR` | Not applicable — there is no state dir; the Durable Object owns the state. |
|
|
190
268
|
| `READYROOM_PORT` | Not applicable — the Worker is addressed by URL. |
|
|
191
269
|
|
|
192
|
-
---
|
|
193
|
-
|
|
194
270
|
## The loopback bind rule, per target
|
|
195
271
|
|
|
196
|
-
An approval endpoint is a privilege-escalation surface — whoever can answer a ticket can grant
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
- **
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
272
|
+
An approval endpoint is a privilege-escalation surface — whoever can answer a ticket can grant
|
|
273
|
+
an agent permission to act. The bind rule exists for that reason.
|
|
274
|
+
|
|
275
|
+
- **npx** — binds `127.0.0.1` by default. `READYROOM_BIND` may override, but when it is
|
|
276
|
+
anything other than a loopback address the server **refuses to start** unless
|
|
277
|
+
`READYROOM_TOKEN` is set. With no token configured, `/api/*` is reachable only on loopback,
|
|
278
|
+
where the OS is the access control.
|
|
279
|
+
- **Docker** — the container binds `0.0.0.0` internally and the port is mapped to `127.0.0.1`
|
|
280
|
+
on the host. The process therefore *always* sees a non-loopback bind, so the token is
|
|
281
|
+
mandatory and the refusal rule always applies. **The loopback guarantee comes from the port
|
|
282
|
+
mapping, not from the process.**
|
|
283
|
+
- **Worker** — there is no loopback and no safe fallback configuration. The token is mandatory,
|
|
284
|
+
the refusal rule has no analogue, and the service is public by construction. This is not
|
|
285
|
+
pretended otherwise.
|
|
286
|
+
|
|
287
|
+
## Who has the conn
|
|
288
|
+
|
|
289
|
+
Whoever can answer a ticket can grant an agent permission to act. Three rules follow.
|
|
290
|
+
|
|
291
|
+
1. **Every `/api/*` route requires `Authorization: Bearer $READYROOM_TOKEN`,** compared in
|
|
292
|
+
constant time. The comparison is a shared `constantTimeEqual(a, b)` that compares digests
|
|
293
|
+
byte by byte with no early return — `node:crypto.timingSafeEqual` is a Node builtin and is
|
|
294
|
+
not reliably present on Workers, so one shared implementation is the only way all three
|
|
295
|
+
targets get the same guarantee.
|
|
296
|
+
2. **The page collects the token once and stores it in `localStorage`,** attaching it to every
|
|
297
|
+
request. A `401` drops the stored token and re-prompts.
|
|
298
|
+
3. **The Streamable HTTP MCP endpoint rejects a missing or mismatched `Origin` with `403`.**
|
|
299
|
+
The MCP specification requires this of HTTP transports, and it is the reason the SDK ships
|
|
300
|
+
host and origin validation guards. It is the browser-side guard against a malicious page in
|
|
301
|
+
the same browser POSTing to the local MCP endpoint (DNS rebinding / cross-origin).
|
|
209
302
|
|
|
210
303
|
The reason for all three: an approval endpoint is a privilege-escalation surface.
|
|
211
304
|
|
|
212
305
|
### Never expose this to a public network directly
|
|
213
306
|
|
|
214
|
-
- **LAN access goes through the existing reverse proxy with its own auth in front.** The
|
|
215
|
-
|
|
307
|
+
- **LAN access goes through the existing reverse proxy with its own auth in front.** The
|
|
308
|
+
container (or npx process) binds loopback; the reverse proxy terminates TLS, enforces its own
|
|
309
|
+
authentication, and forwards to `127.0.0.1:8971`. The proxy is the only thing on the LAN that
|
|
310
|
+
can reach the port.
|
|
311
|
+
- **Worker deployments should sit behind Cloudflare Access.** The Worker is public by
|
|
312
|
+
construction; Cloudflare Access is the layer that keeps it from being reachable by anyone
|
|
313
|
+
with the URL.
|
|
216
314
|
|
|
217
315
|
Topology:
|
|
218
316
|
|
|
@@ -222,11 +320,10 @@ agent (MCP client) ──stdio──▶ readyroom (loopback :8971)
|
|
|
222
320
|
human browser ──HTTPS──▶ reverse proxy (own auth) ──▶ 127.0.0.1:8971
|
|
223
321
|
```
|
|
224
322
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
## Storage
|
|
323
|
+
## The queue and the log
|
|
228
324
|
|
|
229
|
-
One interface, two implementations. The ticket lifecycle — enqueue, expire, answer, undo — is
|
|
325
|
+
One interface, two implementations. The ticket lifecycle — enqueue, expire, answer, undo — is
|
|
326
|
+
written once against the interface and does not branch on target.
|
|
230
327
|
|
|
231
328
|
```ts
|
|
232
329
|
interface TicketStore {
|
|
@@ -240,50 +337,81 @@ interface TicketStore {
|
|
|
240
337
|
|
|
241
338
|
### `FileTicketStore` (npx / Docker)
|
|
242
339
|
|
|
243
|
-
`${READYROOM_STATE_DIR}/tickets.json`, default `./.state`. Writes are atomic: write
|
|
340
|
+
`${READYROOM_STATE_DIR}/tickets.json`, default `./.state`. Writes are atomic: write
|
|
341
|
+
`tickets.json.tmp`, `fsync`, `rename`. Single process, single writer, loaded on boot. The queue
|
|
342
|
+
must survive a container restart — a restart that forgets pending tickets means an agent waits
|
|
343
|
+
forever.
|
|
244
344
|
|
|
245
|
-
Every state change is also appended to `${READYROOM_STATE_DIR}/audit.jsonl` — one JSON object
|
|
345
|
+
Every state change is also appended to `${READYROOM_STATE_DIR}/audit.jsonl` — one JSON object
|
|
346
|
+
per line.
|
|
246
347
|
|
|
247
348
|
### `DurableObjectTicketStore` (Worker)
|
|
248
349
|
|
|
249
|
-
One Durable Object instance, SQLite-backed via `ctx.storage.sql`, tables `tickets` and `audit`.
|
|
350
|
+
One Durable Object instance, SQLite-backed via `ctx.storage.sql`, tables `tickets` and `audit`.
|
|
351
|
+
A single DO instance is a single writer with a transaction boundary — exactly the
|
|
352
|
+
`FileTicketStore` invariant expressed on the edge. It is addressed deterministically with
|
|
353
|
+
`idFromName("queue")` so every request lands on the same instance. The migration uses
|
|
354
|
+
`new_sqlite_classes`; `new_classes` would leave the SQL API unavailable.
|
|
250
355
|
|
|
251
356
|
### Invariants that hold on both
|
|
252
357
|
|
|
253
|
-
- **The audit log is append-only and never pruned.** This is a **deliberate difference from a
|
|
254
|
-
|
|
358
|
+
- **The audit log is append-only and never pruned.** This is a **deliberate difference from a
|
|
359
|
+
retention policy** — an approval log is the only record that a human authorised something, so
|
|
360
|
+
it is kept in full. That is what a log is.
|
|
361
|
+
- **Expiry is evaluated lazily on read.** A ticket is expired if `expires_at` has passed and it
|
|
362
|
+
is still pending; the read path resolves it to `expired` and applies `default_answer` if one
|
|
363
|
+
was set. Workers have no long-lived process, so a timer-based expiry would silently not happen
|
|
364
|
+
there, and divergence between targets on when a ticket expires is worse than a few wasted
|
|
365
|
+
reads. A Cron Trigger on Workers is an optional optimization, never the contract.
|
|
255
366
|
- **The undo window is five minutes,** measured against the audit entry's timestamp.
|
|
256
367
|
|
|
257
|
-
---
|
|
258
|
-
|
|
259
368
|
## The Worker's three consequences
|
|
260
369
|
|
|
261
370
|
The Worker has no state dir, no bind address, and no daemon role. Three consequences follow:
|
|
262
371
|
|
|
263
|
-
1. **The token is mandatory.** There is no loopback and no safe fallback, so a token-less
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
372
|
+
1. **The token is mandatory.** There is no loopback and no safe fallback, so a token-less
|
|
373
|
+
request to any `/api/*` route is rejected. (On the Node target, a missing token is an allow —
|
|
374
|
+
the bind refusal rule guarantees a token-less server is only reachable on loopback.)
|
|
375
|
+
2. **`/api/events` returns `501`** with a short plain-text body. The heads-up page's polling
|
|
376
|
+
fallback carries the Worker target; no Durable-Object-held stream fleet is built for v1.
|
|
377
|
+
3. **`wait_for_answer` clamps its timeout** to a short window (5 seconds) and returns
|
|
378
|
+
`status: "pending"` promptly rather than erroring. This is the **one place the three targets
|
|
379
|
+
differ in observable behavior.** Returning `pending` promptly means the agent's documented
|
|
380
|
+
protocol — call again later — is already the correct handling, so no agent needs to know
|
|
381
|
+
which target it is talking to.
|
|
268
382
|
|
|
269
383
|
## The heads-up page
|
|
270
384
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
385
|
+
The page is a **status board**, not a chat log. It shows what is waiting and clears when you
|
|
386
|
+
have answered — which is why there is no markdown parser and no message history.
|
|
387
|
+
|
|
388
|
+
`public/index.html`. One file, shared by all three targets. All CSS and JS inline. No CDN, no
|
|
389
|
+
external origin, no build step. It renders correctly when opened from the server with the
|
|
390
|
+
network otherwise cut. It is inlined at build time into each target's bundle.
|
|
391
|
+
|
|
392
|
+
- **Live.** Subscribes to `/api/events`; if the connection fails, falls back to polling
|
|
393
|
+
`/api/tickets` every 5 s. This fallback is the Worker target's live-update path, so it is
|
|
394
|
+
load-bearing, not a nicety.
|
|
395
|
+
- **Grouped by stream.** Tickets grouped under a stream heading, newest stream first. You need
|
|
396
|
+
to know which agent is asking.
|
|
397
|
+
- **Card contents.** Stream name, ticket age (`4m`), urgency badge, the question, the context,
|
|
398
|
+
option buttons, a free-text field, and an Answer button.
|
|
399
|
+
- **Keyboard-first.** `j`/`k` move between tickets, `1`-`5` pick an option, `Enter` submits, `u`
|
|
400
|
+
undoes the last answer. Draining a queue with a mouse is the slow path.
|
|
277
401
|
- **Tab title carries the count.** `(3) heads-up`.
|
|
278
|
-
- **No colour-only signalling.** Urgency and status are carried by text and borders as well as
|
|
279
|
-
|
|
280
|
-
- **
|
|
281
|
-
|
|
402
|
+
- **No colour-only signalling.** Urgency and status are carried by text and borders as well as
|
|
403
|
+
hue, so the page is legible on an e-ink display and in grayscale print.
|
|
404
|
+
- **No animation on the critical path.** Respects `prefers-reduced-motion`. No hover-only
|
|
405
|
+
affordances — every action is reachable by keyboard and by tap.
|
|
406
|
+
- **Undo.** Answered tickets stay in a "recent" section for 5 minutes with an Undo button that
|
|
407
|
+
calls `/api/undo`.
|
|
408
|
+
- **First paint is synchronous.** Rendered at the end of the script, not inside
|
|
409
|
+
`requestAnimationFrame`, and re-rendered on `window` `load` — the page may be embedded in a
|
|
410
|
+
hidden or zero-sized frame, where rAF never fires. The empty board reads "Nothing on the
|
|
411
|
+
board."
|
|
282
412
|
|
|
283
413
|
`context` is rendered as text with line breaks preserved. There is no markdown parser.
|
|
284
414
|
|
|
285
|
-
---
|
|
286
|
-
|
|
287
415
|
## HTTP route table
|
|
288
416
|
|
|
289
417
|
```
|
|
@@ -296,26 +424,39 @@ POST /api/undo -> { id } -> 200 | 409 outside the undo window
|
|
|
296
424
|
POST /mcp/{stream} -> the Streamable HTTP MCP endpoint (Origin-guarded)
|
|
297
425
|
```
|
|
298
426
|
|
|
299
|
-
Unknown route
|
|
300
|
-
|
|
301
|
-
---
|
|
427
|
+
Unknown route -> `404`. Known route, wrong method -> `405`. The Node target additionally serves
|
|
428
|
+
`POST /internal/tool-call`, the loopback proxy the client process uses.
|
|
302
429
|
|
|
303
430
|
## CI/CD
|
|
304
431
|
|
|
305
432
|
Four GitHub Actions workflows:
|
|
306
433
|
|
|
307
|
-
- **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build,
|
|
308
|
-
|
|
309
|
-
- **`.github/workflows/publish
|
|
310
|
-
|
|
434
|
+
- **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build,
|
|
435
|
+
`wrangler deploy --dry-run`, `docker build`.
|
|
436
|
+
- **`.github/workflows/publish.yml`** — on the default branch and `v*` tags: buildx for
|
|
437
|
+
`linux/amd64,linux/arm64` (arm64 is required; the deploy target is a Pi), push to GHCR, auth
|
|
438
|
+
via the built-in `GITHUB_TOKEN` with `packages: write` (no PAT, no repo secrets),
|
|
439
|
+
`docker/metadata-action` tags (`latest` on the default branch, semver on `v*`, `sha-<short>`),
|
|
440
|
+
GHA build cache scoped per image, one matrix entry per image. Supply chain:
|
|
441
|
+
`provenance: mode=max`, `sbom: true`, and `actions/attest-build-provenance` pushed to the
|
|
442
|
+
registry.
|
|
443
|
+
- **`.github/workflows/publish-npm.yml`** — on `v*` tags: `npm publish` with provenance, using
|
|
444
|
+
npm Trusted Publishing (OIDC) rather than a stored token. It needs `id-token: write` and the
|
|
445
|
+
trusted-publisher entry configured on the package.
|
|
446
|
+
- **`.github/workflows/deploy-worker.yml`** — on `v*` tags: `wrangler deploy` with
|
|
447
|
+
`CLOUDFLARE_API_TOKEN`. **Optional and off by default** — it only runs when the repository
|
|
448
|
+
variable `ENABLE_WORKER_DEPLOY` is `'true'`. A Worker deploy needs the operator's own
|
|
449
|
+
Cloudflare account, so it is a documented manual step (`npm run deploy:worker`) unless the
|
|
450
|
+
deploy token is supplied.
|
|
311
451
|
|
|
312
452
|
`.github/dependabot.yml` covers `github-actions`, `npm`, and `docker`.
|
|
313
453
|
|
|
314
454
|
### npm publishing
|
|
315
455
|
|
|
316
|
-
Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish
|
|
317
|
-
|
|
318
|
-
|
|
456
|
+
Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish
|
|
457
|
+
must be interactive** (`npm login && npm publish`) because a trusted-publisher entry can only be
|
|
458
|
+
configured against a package that already exists on the registry. After that, the workflow
|
|
459
|
+
publishes via OIDC.
|
|
319
460
|
|
|
320
461
|
## Development
|
|
321
462
|
|
|
@@ -332,27 +473,34 @@ Repo conventions:
|
|
|
332
473
|
|
|
333
474
|
- TypeScript, Node >= 22. `module` and `moduleResolution` = `ESNext` / `bundler`.
|
|
334
475
|
- **Never** put `.js` extensions in import paths. Import `./queue`, not `./queue.js`.
|
|
335
|
-
- `tsx` for dev, `esbuild` for build. Bundle to a single file, so there is no runtime import
|
|
336
|
-
|
|
337
|
-
-
|
|
476
|
+
- `tsx` for dev, `esbuild` for build. Bundle to a single file, so there is no runtime import
|
|
477
|
+
resolution.
|
|
478
|
+
- Zero runtime dependencies except `@modelcontextprotocol/sdk`. `wrangler` and
|
|
479
|
+
`@cloudflare/workers-types` are devDependencies; Durable Objects are a platform primitive and
|
|
480
|
+
need no package.
|
|
481
|
+
- **All logging goes to stderr.** Nothing may be written to stdout except the MCP protocol. A
|
|
482
|
+
stray `console.log` in a shared module breaks the npx target only — the hardest one to notice.
|
|
338
483
|
- Unlicense.
|
|
339
484
|
|
|
340
485
|
Three entry points, one shared core:
|
|
341
486
|
|
|
342
487
|
- `src/index.ts` — Node entry: daemon-or-client, stdio MCP, HTTP, `FileTicketStore`.
|
|
343
|
-
- `src/worker.ts` — Worker entry: `export default { fetch }`, the Durable Object class,
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
488
|
+
- `src/worker.ts` — Worker entry: `export default { fetch }`, the Durable Object class,
|
|
489
|
+
`DurableObjectTicketStore`.
|
|
490
|
+
- `src/core/*` — ticket lifecycle, validation, the four tool definitions, the page. No
|
|
491
|
+
target-specific imports anywhere under `src/core/`.
|
|
347
492
|
|
|
348
493
|
## Out of scope
|
|
349
494
|
|
|
350
|
-
No multi-user accounts, no roles, no permissions, no rich markdown rendering, no database, no
|
|
351
|
-
|
|
352
|
-
|
|
495
|
+
No multi-user accounts, no roles, no permissions, no rich markdown rendering, no database, no
|
|
496
|
+
mobile app, no push notifications, no WebSockets, no cloud sync, no LLM in the decision path.
|
|
497
|
+
**The human decides.**
|
|
353
498
|
|
|
354
|
-
|
|
499
|
+
Also not in scope: any compliance posture. `readyroom` is a convenience gate, not an
|
|
500
|
+
authorization-of-record system for regulated data. It is not covered by a BAA on any target, its
|
|
501
|
+
audit log has no retention policy, and nothing in it should be treated as evidence a human
|
|
502
|
+
authorized a regulated action. Users who need that should look elsewhere.
|
|
355
503
|
|
|
356
504
|
## License
|
|
357
505
|
|
|
358
|
-
Unlicense.
|
|
506
|
+
Public domain, under [The Unlicense](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readyroom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "An MCP server that lets coding agents request a human decision without hijacking whichever window is focused. Agents enqueue a ticket; the human drains the queue from one page when they choose to.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"mcp-server",
|
|
9
|
+
"approval",
|
|
10
|
+
"agents",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"self-hosted",
|
|
13
|
+
"human-in-the-loop"
|
|
14
|
+
],
|
|
6
15
|
"bin": {
|
|
7
16
|
"readyroom": "dist/server.mjs"
|
|
8
17
|
},
|
|
@@ -12,6 +21,10 @@
|
|
|
12
21
|
"publishConfig": {
|
|
13
22
|
"access": "public"
|
|
14
23
|
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/therebelrobot/readyroom.git"
|
|
27
|
+
},
|
|
15
28
|
"files": [
|
|
16
29
|
"dist",
|
|
17
30
|
"README.md",
|
|
@@ -34,7 +47,7 @@
|
|
|
34
47
|
"release:minor": "npm version minor -m \"chore: release v%s\" && git push && git push --tags",
|
|
35
48
|
"release:major": "npm version major -m \"chore: release v%s\" && git push && git push --tags",
|
|
36
49
|
"release:tags": "git tag --sort=-v:refname | grep '^v'",
|
|
37
|
-
"
|
|
50
|
+
"prepack": "npm run build"
|
|
38
51
|
},
|
|
39
52
|
"dependencies": {
|
|
40
53
|
"@modelcontextprotocol/sdk": "^1.32.1"
|