readyroom 0.1.8 → 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 +106 -10
  2. package/dist/server.mjs +566 -18
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -71,6 +71,17 @@ moments, desktop first:
71
71
  </tr>
72
72
  </table>
73
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
+
74
85
  <table>
75
86
  <tr>
76
87
  <td align="center"><b>Token gate</b></td>
@@ -144,8 +155,14 @@ ask_human({
144
155
  // when it is safe for the agent to proceed without a human.
145
156
  timeout_seconds?: number, // optional. Default 1800. Max 86400.
146
157
  urgency?: "low" | "normal" | "blocking", // optional. Default "normal".
147
- stream?: string // optional. Overrides the env/basename default.
148
- }) => { 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[] }
149
166
  ```
150
167
 
151
168
  ### `wait_for_answer`
@@ -159,13 +176,19 @@ wait_for_answer({
159
176
  timeout_seconds?: number // default 60, max 600
160
177
  }) => {
161
178
  id: string,
162
- status: "pending" | "answered" | "expired",
179
+ status: "pending" | "answered" | "expired" | "cancelled",
163
180
  answer?: string,
164
181
  answered_by?: string, // "human" | "default"
165
- 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
166
185
  }
167
186
  ```
168
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
+
169
192
  ### `list_pending`
170
193
 
171
194
  Lists the tickets still pending across every agent stream. Takes no arguments.
@@ -189,6 +212,30 @@ its own earlier guess. An answer that came from `default_answer` gets a clearly
189
212
  prefix — `DEFAULT ANSWER (applied on expiry; no human responded):` — because labelling the
190
213
  agent's own default as a human answer would be worse than no prefix at all.
191
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
+
192
239
  ## Three ways aboard
193
240
 
194
241
  They run the same program. They differ in where it lives and who can reach it.
@@ -631,7 +678,8 @@ network otherwise cut. It is inlined at build time into each target's bundle.
631
678
  - **Grouped by stream.** Tickets grouped under a stream heading, newest stream first. You need
632
679
  to know which agent is asking.
633
680
  - **Card contents.** Stream name, ticket age (`4m`), urgency badge, the question, the context,
634
- 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.
635
683
  - **Keyboard-first.** `j`/`k` move between tickets, `1`-`5` pick an option, `Enter` submits, `u`
636
684
  undoes the last answer. Draining a queue with a mouse is the slow path.
637
685
  - **Tab title carries the count.** `(3) readyroom`.
@@ -655,8 +703,10 @@ GET /healthz -> 200, body "ok"
655
703
  GET / -> the readyroom page (index.html)
656
704
  GET /api/tickets -> { tickets: Ticket[] } pending + last 5 min of answered
657
705
  GET /api/events -> text/event-stream of ticket changes (501 on the Worker)
658
- 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
659
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
660
710
  POST /mcp/{stream} -> the Streamable HTTP MCP endpoint (Origin-guarded)
661
711
  ```
662
712
 
@@ -665,18 +715,22 @@ Unknown route -> `404`. Known route, wrong method -> `405`. The Node target addi
665
715
 
666
716
  ## CI/CD
667
717
 
668
- Four GitHub Actions workflows:
718
+ Five GitHub Actions workflows:
669
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.
670
724
  - **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build,
671
725
  `wrangler deploy --dry-run`, `docker build`.
672
726
  - **`.github/workflows/publish.yml`** — on the default branch and `v*` tags: buildx for
673
727
  `linux/amd64,linux/arm64` (arm64 is required; the deploy target is a Pi), push to GHCR, auth
674
728
  via the built-in `GITHUB_TOKEN` with `packages: write` (no PAT, no repo secrets),
675
- `docker/metadata-action` tags (`latest` on the default branch, semver on `v*`, `sha-<short>`),
676
- 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:
677
731
  `provenance: mode=max`, `sbom: true`, and `actions/attest-build-provenance` pushed to the
678
732
  registry.
679
- - **`.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
680
734
  npm Trusted Publishing (OIDC) rather than a stored token. It needs `id-token: write` and the
681
735
  trusted-publisher entry configured on the package.
682
736
  - **`.github/workflows/deploy-worker.yml`** — on `v*` tags: `wrangler deploy` with
@@ -687,6 +741,48 @@ Four GitHub Actions workflows:
687
741
 
688
742
  `.github/dependabot.yml` covers `github-actions`, `npm`, and `docker`.
689
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
+
690
786
  ### npm publishing
691
787
 
692
788
  Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish
package/dist/server.mjs CHANGED
@@ -7322,6 +7322,13 @@ var UNDO_WINDOW_MS = 5 * 60 * 1e3;
7322
7322
  var RECENT_WINDOW_MS = 5 * 60 * 1e3;
7323
7323
  var POLL_INTERVAL_MS = 250;
7324
7324
  var DEFAULT_STATE_DIR = "./.state";
7325
+ var MEDIA_DIR = "media";
7326
+ var MAX_MEDIA_FILES = 8;
7327
+ var MAX_MEDIA_BYTES_PER_FILE = 8 * 1024 * 1024;
7328
+ var MAX_MEDIA_BYTES_PER_TICKET = 24 * 1024 * 1024;
7329
+ var MAX_ANNOTATION_BYTES = 8 * 1024 * 1024;
7330
+ var MAX_ANNOTATIONS = 32;
7331
+ var MAX_BODY_BYTES = 48 * 1024 * 1024;
7325
7332
 
7326
7333
  // src/config.ts
7327
7334
  var ENV_ALLOWED_ORIGINS = "READYROOM_ALLOWED_ORIGINS";
@@ -7501,7 +7508,80 @@ async function acquireDaemonRole(stateDir, port) {
7501
7508
  }
7502
7509
 
7503
7510
  // public/index.html
7504
- var public_default = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>readyroom</title>\n<!-- Suppress the /favicon.ico request. The page ships no icon and fetches nothing\n over the network; an inline empty data URI keeps the console free of a 404. -->\n<link rel="icon" href="data:,">\n<style>\n /* No external fonts, no external origins, no images fetched over the network.\n Everything below is self-contained so the page renders with the network cut. */\n\n :root {\n --ink: #111;\n --paper: #fff;\n --muted: #555;\n --line: #111;\n --line-soft: #999;\n --blocking: #000;\n --low: #666;\n }\n\n * { box-sizing: border-box; }\n\n html, body {\n margin: 0;\n padding: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;\n font-size: 16px;\n line-height: 1.45;\n }\n\n body { padding: 16px; max-width: 1100px; margin: 0 auto; }\n\n h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }\n h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }\n h3 { font-size: 0.95rem; margin: 0 0 8px 0; }\n\n .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }\n\n /* Status + error banners: text carries the meaning, not colour. */\n .banner {\n border: 2px solid var(--line);\n padding: 8px 10px;\n margin: 8px 0;\n font-size: 0.85rem;\n }\n .banner[hidden] { display: none; }\n .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }\n\n #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }\n #tokenbar[hidden] { display: none; }\n #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }\n #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }\n\n button {\n font: inherit;\n min-height: 44px;\n min-width: 44px;\n padding: 8px 12px;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n cursor: pointer;\n }\n button:focus-visible, input:focus-visible, a:focus-visible {\n outline: 3px solid var(--ink);\n outline-offset: 2px;\n }\n /* No hover-only affordances: hover is a bonus, never the only signal. */\n button:hover { background: #eee; }\n\n .stream-group { margin: 0 0 20px 0; }\n .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }\n .stream-name { font-weight: bold; }\n .stream-count { color: var(--muted); font-size: 0.85rem; }\n\n .card {\n border: 2px solid var(--line);\n padding: 12px;\n margin: 10px 0;\n background: var(--paper);\n }\n /* Selection is carried by a thick left border AND a text marker, not colour alone. */\n .card.selected { border-left-width: 10px; }\n .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }\n .card.selected .selmark { display: inline; }\n\n .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }\n .age { color: var(--muted); font-size: 0.85rem; }\n\n .badge {\n display: inline-block;\n border: 2px solid var(--line);\n padding: 1px 6px;\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: bold;\n }\n .badge.urgency-blocking { border-style: double; border-width: 4px; }\n .badge.urgency-low { border-style: dotted; }\n .badge.urgency-normal { border-style: solid; }\n .badge.status-answered { border-style: dashed; }\n\n .question { font-weight: bold; margin: 6px 0; }\n .context {\n white-space: pre-wrap;\n border-left: 3px solid var(--line-soft);\n padding: 6px 10px;\n margin: 8px 0;\n color: var(--ink);\n font-size: 0.9rem;\n }\n .context[hidden] { display: none; }\n\n .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }\n .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n\n .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .answer-row label { font-size: 0.85rem; }\n .answer-row input[type="text"] {\n flex: 1 1 240px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n\n .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }\n .card-error[hidden] { display: none; }\n .card-error .tag { font-weight: bold; text-transform: uppercase; }\n\n .recent .card { border-style: dashed; }\n .empty { color: var(--muted); font-style: italic; }\n\n .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }\n kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }\n\n /* No animation on the critical path. Respect reduced motion. */\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n @media (prefers-reduced-motion: no-preference) {\n .card.selected { transition: border-left-width 80ms linear; }\n }\n\n @media print {\n body { max-width: none; }\n button { display: none; }\n .card { break-inside: avoid; }\n }\n</style>\n</head>\n<body>\n <header>\n <h1>readyroom</h1>\n <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move &middot; <kbd>1</kbd>-<kbd>5</kbd> pick &middot; <kbd>Enter</kbd> answer &middot; <kbd>u</kbd> undo</p>\n <div id="status" class="banner" role="status" aria-live="polite">\n <span class="tag">Status:</span> <span id="status-text">starting</span>\n </div>\n <div id="error" class="banner" role="alert" hidden>\n <span class="tag">Error:</span> <span id="error-text"></span>\n </div>\n </header>\n\n <section id="tokenbar" hidden aria-labelledby="token-label">\n <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>\n <div class="row">\n <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">\n <button id="token-save" type="button">Save token</button>\n </div>\n <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>\n </section>\n\n <main id="app">\n <section id="pending-section" aria-labelledby="pending-heading">\n <h2 id="pending-heading">Pending</h2>\n <div id="pending"></div>\n </section>\n\n <section id="recent-section" class="recent" aria-labelledby="recent-heading">\n <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>\n <div id="recent"></div>\n </section>\n </main>\n\n <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved.</p>\n\n <noscript>\n <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>\n </noscript>\n\n<script>\n(function () {\n "use strict";\n\n var TOKEN_KEY = "readyroom_token";\n var POLL_MS = 5000;\n var UNDO_WINDOW_MS = 5 * 60 * 1000;\n\n var state = {\n tickets: [],\n selectedId: null,\n token: "",\n abort: null,\n pollTimer: null,\n live: false,\n error: "",\n choices: {},\n drafts: {}\n };\n\n var els = {\n status: document.getElementById("status"),\n statusText: document.getElementById("status-text"),\n error: document.getElementById("error"),\n errorText: document.getElementById("error-text"),\n tokenbar: document.getElementById("tokenbar"),\n tokenInput: document.getElementById("token-input"),\n tokenSave: document.getElementById("token-save"),\n pending: document.getElementById("pending"),\n recent: document.getElementById("recent")\n };\n\n /* ---------------- token handling (requirement 11) ---------------- */\n\n function readToken() {\n try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }\n }\n function writeToken(t) {\n try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }\n }\n function dropToken() {\n try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }\n }\n function showTokenBar(show) {\n els.tokenbar.hidden = !show;\n if (show) { try { els.tokenInput.focus(); } catch (e) {} }\n }\n\n /* ---------------- HTTP with Bearer on every request ---------------- */\n\n function api(path, opts) {\n opts = opts || {};\n var headers = { "Authorization": "Bearer " + state.token };\n if (opts.body) { headers["Content-Type"] = "application/json"; }\n return fetch(path, {\n method: opts.method || "GET",\n headers: headers,\n body: opts.body\n }).then(function (res) {\n if (res.status === 401) {\n dropToken();\n state.token = "";\n state.error = "Token rejected (401). Enter it again.";\n showTokenBar(true);\n render();\n var err = new Error("unauthorized");\n err.unauthorized = true;\n throw err;\n }\n return res;\n });\n }\n\n function fetchTickets() {\n if (!state.token) { return Promise.resolve(); }\n return api("/api/tickets").then(function (res) {\n if (!res.ok) { throw new Error("tickets " + res.status); }\n return res.json();\n }).then(function (data) {\n state.tickets = (data && data.tickets) || [];\n state.error = "";\n render();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n /* ---------------- live updates (requirement 1) ---------------- */\n\n function startPolling() {\n if (state.pollTimer) { return; }\n state.live = false;\n fetchTickets();\n state.pollTimer = setInterval(fetchTickets, POLL_MS);\n render();\n }\n function stopPolling() {\n if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }\n }\n\n /* Fetch-based SSE reader.\n EventSource cannot attach an Authorization header, so a Bearer-protected\n /api/events is unreachable through it. Instead we fetch the endpoint with the\n token, read response.body through a ReadableStream reader, decode with a\n TextDecoder, and parse text/event-stream frames: frames are separated by a blank\n line, and the payload rides on `data:` lines. Any failure \u2014 a network error, a\n non-200 status, or a Worker\'s 501 \u2014 falls back to polling /api/tickets every 5 s,\n which is the Worker target\'s live-update path. */\n function handleFrame(frame) {\n var lines = frame.split("\\n");\n var sawData = false;\n for (var i = 0; i < lines.length; i++) {\n if (lines[i].indexOf("data:") === 0) { sawData = true; }\n }\n if (sawData) { fetchTickets(); }\n }\n\n function readSseStream(body) {\n var reader = body.getReader();\n var decoder = new TextDecoder();\n var buffer = "";\n function pump() {\n return reader.read().then(function (chunk) {\n if (chunk.done) { throw new Error("sse stream closed"); }\n buffer += decoder.decode(chunk.value, { stream: true });\n var idx;\n while ((idx = buffer.indexOf("\\n\\n")) !== -1) {\n var frame = buffer.slice(0, idx);\n buffer = buffer.slice(idx + 2);\n handleFrame(frame);\n }\n return pump();\n });\n }\n return pump();\n }\n\n function startLive() {\n stopLive();\n if (!state.token) { return; }\n if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }\n\n var controller = null;\n try { controller = new AbortController(); } catch (e) { controller = null; }\n if (controller) { state.abort = controller; }\n\n var options = { headers: { "Authorization": "Bearer " + state.token } };\n if (controller) { options.signal = controller.signal; }\n\n fetch("/api/events", options).then(function (res) {\n if (!res.ok || !res.body) {\n /* 401, 404, a Worker\'s 501, or a body we cannot stream: poll instead. */\n startPolling();\n return;\n }\n state.live = true;\n stopPolling();\n render();\n return readSseStream(res.body);\n }).catch(function () {\n if (controller && controller.signal.aborted) { return; }\n state.live = false;\n startPolling();\n });\n }\n function stopLive() {\n if (state.abort) {\n try { state.abort.abort(); } catch (e) { /* ignore */ }\n state.abort = null;\n }\n state.live = false;\n }\n\n /* ---------------- derived data ---------------- */\n\n function pendingTickets() {\n return state.tickets.filter(function (t) { return t.status === "pending"; });\n }\n function recentTickets() {\n var now = Date.now();\n return state.tickets.filter(function (t) {\n if (t.status !== "answered" || !t.answered_at) { return false; }\n return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;\n }).sort(function (a, b) {\n return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();\n });\n }\n\n function groupByStream(tickets) {\n var groups = {};\n var order = [];\n tickets.forEach(function (t) {\n if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }\n groups[t.stream].push(t);\n });\n order.forEach(function (s) {\n groups[s].sort(function (a, b) {\n return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n });\n });\n order.sort(function (a, b) {\n return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();\n });\n return order.map(function (s) { return { stream: s, tickets: groups[s] }; });\n }\n\n function orderedPending() {\n var out = [];\n groupByStream(pendingTickets()).forEach(function (g) {\n g.tickets.forEach(function (t) { out.push(t); });\n });\n return out;\n }\n\n function age(iso) {\n var ms = Date.now() - new Date(iso).getTime();\n if (!isFinite(ms) || ms < 0) { ms = 0; }\n var s = Math.floor(ms / 1000);\n if (s < 60) { return s + "s"; }\n var m = Math.floor(s / 60);\n if (m < 60) { return m + "m"; }\n var h = Math.floor(m / 60);\n if (h < 24) { return h + "h"; }\n return Math.floor(h / 24) + "d";\n }\n\n /* ---------------- actions ---------------- */\n\n function setCardError(id, msg) {\n var el = document.getElementById("err-" + id);\n if (el) {\n el.hidden = false;\n var span = el.querySelector(".err-text");\n if (span) { span.textContent = msg; }\n }\n }\n\n function submitAnswer(ticket) {\n var input = document.getElementById("free-" + ticket.id);\n var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();\n var choice = state.choices[ticket.id] || "";\n var answer = text || choice;\n if (!answer) {\n setCardError(ticket.id, "Type an answer or pick an option first.");\n return;\n }\n api("/api/answer", {\n method: "POST",\n body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human" })\n }).then(function (res) {\n if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }\n if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }\n if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }\n delete state.choices[ticket.id];\n delete state.drafts[ticket.id];\n return fetchTickets();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));\n });\n }\n\n function undo(ticketId) {\n api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })\n .then(function (res) {\n if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }\n if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }\n return fetchTickets();\n })\n .catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Undo failed: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n function undoLast() {\n var recent = recentTickets();\n if (recent.length === 0) { return; }\n undo(recent[0].id);\n }\n\n /* ---------------- keyboard (requirement 4) ---------------- */\n\n function isTypingTarget(el) {\n if (!el) { return false; }\n var tag = el.tagName;\n return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;\n }\n\n function moveSelection(delta) {\n var list = orderedPending();\n if (list.length === 0) { return; }\n var idx = -1;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }\n if (idx === -1) { idx = delta > 0 ? -1 : 0; }\n var next = idx + delta;\n if (next < 0) { next = 0; }\n if (next > list.length - 1) { next = list.length - 1; }\n state.selectedId = list[next].id;\n render();\n var card = document.getElementById("card-" + state.selectedId);\n if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }\n }\n\n function pickOption(n) {\n var list = orderedPending();\n var ticket = null;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }\n if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }\n state.choices[ticket.id] = ticket.options[n - 1];\n render();\n }\n\n function submitSelected() {\n var list = orderedPending();\n for (var i = 0; i < list.length; i++) {\n if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }\n }\n }\n\n document.addEventListener("keydown", function (e) {\n if (isTypingTarget(e.target)) { return; }\n if (e.metaKey || e.ctrlKey || e.altKey) { return; }\n if (e.key === "j") { e.preventDefault(); moveSelection(1); }\n else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }\n else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }\n else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }\n else if (e.key === "u") { e.preventDefault(); undoLast(); }\n });\n\n /* ---------------- rendering ---------------- */\n\n function captureDrafts() {\n var inputs = document.querySelectorAll("[data-draft]");\n for (var i = 0; i < inputs.length; i++) {\n state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;\n }\n }\n\n function el(tag, cls, text) {\n var node = document.createElement(tag);\n if (cls) { node.className = cls; }\n if (text !== undefined && text !== null) { node.textContent = text; }\n return node;\n }\n\n function urgencyBadge(urgency) {\n var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);\n return b;\n }\n\n function buildCard(ticket, isRecent) {\n // The `selected` marker is a keyboard-navigation affordance for the pending queue\n // only. An answered card in "recent" carries no selection target (`j`/`k`/`Enter`\n // never act on it), so marking it "selected" would be misleading.\n var card = el("article", "card" + (!isRecent && ticket.id === state.selectedId ? " selected" : ""));\n card.id = "card-" + ticket.id;\n card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);\n\n var head = el("div", "card-head");\n head.appendChild(el("span", "selmark", "selected"));\n head.appendChild(el("span", "stream-name", ticket.stream));\n head.appendChild(el("span", "age", age(ticket.created_at) + " old"));\n head.appendChild(urgencyBadge(ticket.urgency));\n if (isRecent) {\n head.appendChild(el("span", "badge status-answered", "status: answered"));\n if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }\n }\n card.appendChild(head);\n\n card.appendChild(el("p", "question", ticket.question));\n\n if (ticket.context) {\n var ctx = el("div", "context", ticket.context);\n card.appendChild(ctx);\n }\n\n if (isRecent) {\n var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));\n card.appendChild(ans);\n var undoBtn = el("button", null, "Undo");\n undoBtn.type = "button";\n undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);\n undoBtn.addEventListener("click", function () { undo(ticket.id); });\n var row = el("div", "answer-row");\n row.appendChild(undoBtn);\n card.appendChild(row);\n return card;\n }\n\n if (ticket.options && ticket.options.length) {\n var opts = el("div", "options");\n opts.setAttribute("role", "group");\n opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);\n ticket.options.forEach(function (opt, i) {\n var b = el("button", null, (i + 1) + ". " + opt);\n b.type = "button";\n b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");\n b.addEventListener("click", function () {\n state.choices[ticket.id] = opt;\n state.selectedId = ticket.id;\n render();\n });\n opts.appendChild(b);\n });\n card.appendChild(opts);\n }\n\n var form = el("form", "answer-row");\n form.setAttribute("novalidate", "novalidate");\n var label = el("label", null, "Answer");\n label.setAttribute("for", "free-" + ticket.id);\n form.appendChild(label);\n var input = document.createElement("input");\n input.type = "text";\n input.id = "free-" + ticket.id;\n input.setAttribute("data-draft", ticket.id);\n input.setAttribute("autocomplete", "off");\n input.value = state.drafts[ticket.id] || "";\n input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n form.appendChild(input);\n var submit = el("button", null, "Answer");\n submit.type = "submit";\n form.appendChild(submit);\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n submitAnswer(ticket);\n });\n card.appendChild(form);\n\n var err = el("div", "card-error");\n err.id = "err-" + ticket.id;\n err.hidden = true;\n err.setAttribute("role", "alert");\n err.appendChild(el("span", "tag", "Error: "));\n err.appendChild(el("span", "err-text", ""));\n card.appendChild(err);\n\n return card;\n }\n\n function render() {\n captureDrafts();\n\n var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;\n var selStart = null, selEnd = null;\n if (activeId && document.activeElement.setSelectionRange) {\n try {\n selStart = document.activeElement.selectionStart;\n selEnd = document.activeElement.selectionEnd;\n } catch (e) { /* not a text input */ }\n }\n\n var pending = pendingTickets();\n var recent = recentTickets();\n\n /* requirement 5: tab title carries the count */\n document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";\n\n /* status line */\n var statusText = "idle";\n if (state.live) { statusText = "live (server-sent events)"; }\n else if (state.pollTimer) { statusText = "polling every 5s"; }\n else if (!state.token) { statusText = "waiting for token"; }\n els.statusText.textContent = statusText;\n\n if (state.error) {\n els.error.hidden = false;\n els.errorText.textContent = state.error;\n } else {\n els.error.hidden = true;\n els.errorText.textContent = "";\n }\n\n /* pending, grouped by stream, newest stream first */\n els.pending.textContent = "";\n if (pending.length === 0) {\n els.pending.appendChild(el("p", "empty", "No pending tickets."));\n } else {\n groupByStream(pending).forEach(function (group) {\n var section = el("section", "stream-group");\n section.setAttribute("aria-label", "Stream " + group.stream);\n var head = el("div", "stream-head");\n head.appendChild(el("h3", "stream-name", group.stream));\n head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));\n section.appendChild(head);\n group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });\n els.pending.appendChild(section);\n });\n }\n\n /* recent, undoable for 5 minutes */\n els.recent.textContent = "";\n if (recent.length === 0) {\n els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));\n } else {\n recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });\n }\n\n /* restore focus + caret so polling re-renders do not interrupt typing */\n if (activeId) {\n var again = document.getElementById(activeId);\n if (again && again.focus) {\n try {\n again.focus();\n if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }\n } catch (e) { /* ignore */ }\n }\n }\n }\n\n /* ---------------- wiring ---------------- */\n\n els.tokenSave.addEventListener("click", function () {\n var t = els.tokenInput.value.trim();\n if (!t) { return; }\n state.token = t;\n writeToken(t);\n state.error = "";\n showTokenBar(false);\n startLive();\n fetchTickets();\n });\n els.tokenInput.addEventListener("keydown", function (e) {\n if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }\n });\n\n /* requirement 9: re-render on window load */\n window.addEventListener("load", render);\n\n /* boot */\n state.token = readToken();\n if (state.token) {\n showTokenBar(false);\n startLive();\n fetchTickets();\n } else {\n showTokenBar(true);\n }\n\n /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */\n render();\n})();\n</script>\n</body>\n</html>\n';
7511
+ var public_default = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>readyroom</title>\n<!-- Suppress the /favicon.ico request. The page ships no icon and fetches nothing\n over the network; an inline empty data URI keeps the console free of a 404. -->\n<link rel="icon" href="data:,">\n<style>\n /* No external fonts, no external origins, no images fetched over the network.\n Everything below is self-contained so the page renders with the network cut. */\n\n :root {\n --ink: #111;\n --paper: #fff;\n --muted: #555;\n --line: #111;\n --line-soft: #999;\n --blocking: #000;\n --low: #666;\n }\n\n * { box-sizing: border-box; }\n\n html, body {\n margin: 0;\n padding: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;\n font-size: 16px;\n line-height: 1.45;\n }\n\n body { padding: 16px; max-width: 1100px; margin: 0 auto; }\n\n h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }\n h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }\n h3 { font-size: 0.95rem; margin: 0 0 8px 0; }\n\n .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }\n\n /* Status + error banners: text carries the meaning, not colour. */\n .banner {\n border: 2px solid var(--line);\n padding: 8px 10px;\n margin: 8px 0;\n font-size: 0.85rem;\n }\n .banner[hidden] { display: none; }\n .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }\n\n #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }\n #tokenbar[hidden] { display: none; }\n #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }\n #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }\n\n button {\n font: inherit;\n min-height: 44px;\n min-width: 44px;\n padding: 8px 12px;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n cursor: pointer;\n }\n button:focus-visible, input:focus-visible, a:focus-visible {\n outline: 3px solid var(--ink);\n outline-offset: 2px;\n }\n /* No hover-only affordances: hover is a bonus, never the only signal. */\n button:hover { background: #eee; }\n\n .stream-group { margin: 0 0 20px 0; }\n .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }\n .stream-name { font-weight: bold; }\n .stream-count { color: var(--muted); font-size: 0.85rem; }\n\n .card {\n border: 2px solid var(--line);\n padding: 12px;\n margin: 10px 0;\n background: var(--paper);\n }\n /* Selection is carried by a thick left border AND a text marker, not colour alone. */\n .card.selected { border-left-width: 10px; }\n .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }\n .card.selected .selmark { display: inline; }\n\n .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }\n .age { color: var(--muted); font-size: 0.85rem; }\n\n .badge {\n display: inline-block;\n border: 2px solid var(--line);\n padding: 1px 6px;\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: bold;\n }\n .badge.urgency-blocking { border-style: double; border-width: 4px; }\n .badge.urgency-low { border-style: dotted; }\n .badge.urgency-normal { border-style: solid; }\n .badge.status-answered { border-style: dashed; }\n\n .question { font-weight: bold; margin: 6px 0; }\n .context {\n white-space: pre-wrap;\n border-left: 3px solid var(--line-soft);\n padding: 6px 10px;\n margin: 8px 0;\n color: var(--ink);\n font-size: 0.9rem;\n }\n .context[hidden] { display: none; }\n\n .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }\n .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n\n .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .answer-row label { font-size: 0.85rem; }\n .answer-row input[type="text"] {\n flex: 1 1 240px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n\n .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }\n .card-error[hidden] { display: none; }\n .card-error .tag { font-weight: bold; text-transform: uppercase; }\n\n .recent .card { border-style: dashed; }\n .empty { color: var(--muted); font-style: italic; }\n\n /* Media is fetched with the Bearer token (fetch, not <img src>), then shown from an\n object URL. Expansion is a real <button> (keyboard-operable); drawing never relies\n on hover; nothing is colour-only. */\n .media-section { margin: 10px 0; }\n .media-toggle { width: 100%; text-align: left; }\n .media-list[hidden] { display: none; }\n .media-item { border: 1px solid var(--line-soft); padding: 8px; margin: 8px 0; }\n .media-item figcaption { font-size: 0.8rem; color: var(--muted); margin-bottom: 6px; }\n .media-stage { margin: 6px 0; }\n .media-stage img, .media-stage video, .media-stage audio { max-width: 100%; display: block; }\n .media-stage video { background: #000; }\n .media-stage canvas {\n max-width: 100%;\n height: auto;\n display: block;\n touch-action: none;\n cursor: crosshair;\n border: 1px solid var(--line-soft);\n }\n .media-tools { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }\n .media-tools button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n .media-status { font-size: 0.8rem; color: var(--muted); }\n .annotations { border-top: 1px dashed var(--line-soft); margin-top: 10px; padding-top: 8px; }\n .annotations h4 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 6px 0; }\n .note { border: 1px solid var(--line-soft); padding: 6px 8px; margin: 6px 0; font-size: 0.9rem; }\n .note-text { white-space: pre-wrap; }\n .note-meta { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }\n .note-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .note-add input[type="text"] {\n flex: 1 1 200px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n .note-add select {\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n .annotation-image { margin: 8px 0; }\n .annotation-image img { max-width: 100%; border: 1px solid var(--line-soft); }\n\n .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }\n kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }\n\n /* No animation on the critical path. Respect reduced motion. */\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n @media (prefers-reduced-motion: no-preference) {\n .card.selected { transition: border-left-width 80ms linear; }\n }\n\n @media print {\n body { max-width: none; }\n button { display: none; }\n .card { break-inside: avoid; }\n }\n</style>\n</head>\n<body>\n <header>\n <h1>readyroom</h1>\n <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move &middot; <kbd>1</kbd>-<kbd>5</kbd> pick &middot; <kbd>Enter</kbd> answer &middot; <kbd>u</kbd> undo</p>\n <div id="status" class="banner" role="status" aria-live="polite">\n <span class="tag">Status:</span> <span id="status-text">starting</span>\n </div>\n <div id="error" class="banner" role="alert" hidden>\n <span class="tag">Error:</span> <span id="error-text"></span>\n </div>\n </header>\n\n <section id="tokenbar" hidden aria-labelledby="token-label">\n <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>\n <div class="row">\n <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">\n <button id="token-save" type="button">Save token</button>\n </div>\n <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>\n </section>\n\n <main id="app">\n <section id="pending-section" aria-labelledby="pending-heading">\n <h2 id="pending-heading">Pending</h2>\n <div id="pending"></div>\n </section>\n\n <section id="recent-section" class="recent" aria-labelledby="recent-heading">\n <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>\n <div id="recent"></div>\n </section>\n </main>\n\n <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved. Attached media is fetched with your token only when you expand a ticket, and never from an external origin.</p>\n\n <noscript>\n <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>\n </noscript>\n\n<script>\n(function () {\n "use strict";\n\n var TOKEN_KEY = "readyroom_token";\n var POLL_MS = 5000;\n var UNDO_WINDOW_MS = 5 * 60 * 1000;\n\n var state = {\n tickets: [],\n selectedId: null,\n token: "",\n abort: null,\n pollTimer: null,\n live: false,\n error: "",\n choices: {},\n drafts: {},\n /* Media and annotation state, all keyed so it survives the polling re-render:\n notes[ticketId] -> [{ text, media_id?, at_seconds? }]\n noteDrafts[key] -> in-progress note text (key = ticketId + ":" + (mediaId||""))\n shapes[mediaId] -> [{ tool: "box"|"draw", ... }] in the image\'s natural pixels\n expanded[ticketId] -> whether the attachment list is open\n mediaUrls[mediaId] -> object URL of the fetched blob (never revoked; ids are immutable) */\n notes: {},\n noteDrafts: {},\n noteTargets: {},\n shapes: {},\n canvasScale: {},\n tool: "box",\n expanded: {},\n mediaUrls: {},\n mediaPending: {},\n mediaErrors: {},\n /* True while a stroke is in progress, so a poll does not replace the canvas\n mid-drag; the pending render runs when the stroke ends. */\n drawing: false,\n renderQueued: false\n };\n\n var els = {\n status: document.getElementById("status"),\n statusText: document.getElementById("status-text"),\n error: document.getElementById("error"),\n errorText: document.getElementById("error-text"),\n tokenbar: document.getElementById("tokenbar"),\n tokenInput: document.getElementById("token-input"),\n tokenSave: document.getElementById("token-save"),\n pending: document.getElementById("pending"),\n recent: document.getElementById("recent")\n };\n\n /* ---------------- token handling (requirement 11) ---------------- */\n\n function readToken() {\n try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }\n }\n function writeToken(t) {\n try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }\n }\n function dropToken() {\n try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }\n }\n function showTokenBar(show) {\n els.tokenbar.hidden = !show;\n if (show) { try { els.tokenInput.focus(); } catch (e) {} }\n }\n\n /* ---------------- HTTP with Bearer on every request ---------------- */\n\n function api(path, opts) {\n opts = opts || {};\n var headers = { "Authorization": "Bearer " + state.token };\n if (opts.body) { headers["Content-Type"] = "application/json"; }\n return fetch(path, {\n method: opts.method || "GET",\n headers: headers,\n body: opts.body\n }).then(function (res) {\n if (res.status === 401) {\n dropToken();\n state.token = "";\n state.error = "Token rejected (401). Enter it again.";\n showTokenBar(true);\n render();\n var err = new Error("unauthorized");\n err.unauthorized = true;\n throw err;\n }\n return res;\n });\n }\n\n function fetchTickets() {\n if (!state.token) { return Promise.resolve(); }\n return api("/api/tickets").then(function (res) {\n if (!res.ok) { throw new Error("tickets " + res.status); }\n return res.json();\n }).then(function (data) {\n state.tickets = (data && data.tickets) || [];\n state.error = "";\n // Do not replace the DOM mid-stroke; queue the render for when the stroke ends.\n if (state.drawing) { state.renderQueued = true; } else { render(); }\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n /* ---------------- live updates (requirement 1) ---------------- */\n\n function startPolling() {\n if (state.pollTimer) { return; }\n state.live = false;\n fetchTickets();\n state.pollTimer = setInterval(fetchTickets, POLL_MS);\n render();\n }\n function stopPolling() {\n if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }\n }\n\n /* Fetch-based SSE reader.\n EventSource cannot attach an Authorization header, so a Bearer-protected\n /api/events is unreachable through it. Instead we fetch the endpoint with the\n token, read response.body through a ReadableStream reader, decode with a\n TextDecoder, and parse text/event-stream frames: frames are separated by a blank\n line, and the payload rides on `data:` lines. Any failure \u2014 a network error, a\n non-200 status, or a Worker\'s 501 \u2014 falls back to polling /api/tickets every 5 s,\n which is the Worker target\'s live-update path. */\n function handleFrame(frame) {\n var lines = frame.split("\\n");\n var sawData = false;\n for (var i = 0; i < lines.length; i++) {\n if (lines[i].indexOf("data:") === 0) { sawData = true; }\n }\n if (sawData) { fetchTickets(); }\n }\n\n function readSseStream(body) {\n var reader = body.getReader();\n var decoder = new TextDecoder();\n var buffer = "";\n function pump() {\n return reader.read().then(function (chunk) {\n if (chunk.done) { throw new Error("sse stream closed"); }\n buffer += decoder.decode(chunk.value, { stream: true });\n var idx;\n while ((idx = buffer.indexOf("\\n\\n")) !== -1) {\n var frame = buffer.slice(0, idx);\n buffer = buffer.slice(idx + 2);\n handleFrame(frame);\n }\n return pump();\n });\n }\n return pump();\n }\n\n function startLive() {\n stopLive();\n if (!state.token) { return; }\n if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }\n\n var controller = null;\n try { controller = new AbortController(); } catch (e) { controller = null; }\n if (controller) { state.abort = controller; }\n\n var options = { headers: { "Authorization": "Bearer " + state.token } };\n if (controller) { options.signal = controller.signal; }\n\n fetch("/api/events", options).then(function (res) {\n if (!res.ok || !res.body) {\n /* 401, 404, a Worker\'s 501, or a body we cannot stream: poll instead. */\n startPolling();\n return;\n }\n state.live = true;\n stopPolling();\n render();\n return readSseStream(res.body);\n }).catch(function () {\n if (controller && controller.signal.aborted) { return; }\n state.live = false;\n startPolling();\n });\n }\n function stopLive() {\n if (state.abort) {\n try { state.abort.abort(); } catch (e) { /* ignore */ }\n state.abort = null;\n }\n state.live = false;\n }\n\n /* ---------------- derived data ---------------- */\n\n function pendingTickets() {\n return state.tickets.filter(function (t) { return t.status === "pending"; });\n }\n function recentTickets() {\n var now = Date.now();\n return state.tickets.filter(function (t) {\n if (t.status !== "answered" || !t.answered_at) { return false; }\n return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;\n }).sort(function (a, b) {\n return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();\n });\n }\n\n function groupByStream(tickets) {\n var groups = {};\n var order = [];\n tickets.forEach(function (t) {\n if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }\n groups[t.stream].push(t);\n });\n order.forEach(function (s) {\n groups[s].sort(function (a, b) {\n return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n });\n });\n order.sort(function (a, b) {\n return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();\n });\n return order.map(function (s) { return { stream: s, tickets: groups[s] }; });\n }\n\n function orderedPending() {\n var out = [];\n groupByStream(pendingTickets()).forEach(function (g) {\n g.tickets.forEach(function (t) { out.push(t); });\n });\n return out;\n }\n\n function age(iso) {\n var ms = Date.now() - new Date(iso).getTime();\n if (!isFinite(ms) || ms < 0) { ms = 0; }\n var s = Math.floor(ms / 1000);\n if (s < 60) { return s + "s"; }\n var m = Math.floor(s / 60);\n if (m < 60) { return m + "m"; }\n var h = Math.floor(m / 60);\n if (h < 24) { return h + "h"; }\n return Math.floor(h / 24) + "d";\n }\n\n /* ---------------- media + annotations ---------------- */\n\n function formatBytes(n) {\n if (typeof n !== "number" || !isFinite(n)) { return "?"; }\n if (n < 1024) { return n + " B"; }\n if (n < 1024 * 1024) { return (n / 1024).toFixed(1) + " KiB"; }\n return (n / (1024 * 1024)).toFixed(1) + " MiB";\n }\n\n function formatTime(seconds) {\n var s = Math.max(0, Math.floor(seconds));\n var m = Math.floor(s / 60);\n var rest = s % 60;\n return m + ":" + (rest < 10 ? "0" : "") + rest;\n }\n\n function findMedia(ticket, id) {\n var media = ticket.media || [];\n for (var i = 0; i < media.length; i++) { if (media[i].id === id) { return media[i]; } }\n return null;\n }\n\n function mediaName(ticket, id) {\n var media = ticket.media || [];\n for (var i = 0; i < media.length; i++) {\n if (media[i].id === id) { return media[i].filename || ("attachment " + (i + 1) + " (" + media[i].kind + ")"); }\n }\n return "attachment";\n }\n\n /* Media is fetched with the Bearer token, then shown from an object URL: <img src>\n and <audio src> cannot attach an Authorization header, exactly like EventSource. */\n function loadMediaObjectUrl(ref) {\n if (state.mediaUrls[ref.id]) { return Promise.resolve(state.mediaUrls[ref.id]); }\n if (state.mediaPending[ref.id]) { return state.mediaPending[ref.id]; }\n var promise = api("/api/media/" + encodeURIComponent(ref.id)).then(function (res) {\n if (!res.ok) { throw new Error("media " + res.status); }\n return res.blob();\n }).then(function (blob) {\n var url = URL.createObjectURL(blob);\n state.mediaUrls[ref.id] = url;\n delete state.mediaPending[ref.id];\n return url;\n }).catch(function (err) {\n delete state.mediaPending[ref.id];\n state.mediaErrors[ref.id] = err && err.message ? err.message : "load failed";\n throw err;\n });\n state.mediaPending[ref.id] = promise;\n return promise;\n }\n\n function drawShape(ctx, shape) {\n ctx.save();\n ctx.lineWidth = 3;\n ctx.strokeStyle = "#d40000";\n ctx.lineJoin = "round";\n ctx.lineCap = "round";\n if (shape.tool === "box") {\n var x = Math.min(shape.x0, shape.x1);\n var y = Math.min(shape.y0, shape.y1);\n ctx.strokeRect(x, y, Math.abs(shape.x1 - shape.x0), Math.abs(shape.y1 - shape.y0));\n } else if (shape.tool === "draw" && shape.points && shape.points.length > 1) {\n ctx.beginPath();\n ctx.moveTo(shape.points[0].x, shape.points[0].y);\n for (var i = 1; i < shape.points.length; i++) { ctx.lineTo(shape.points[i].x, shape.points[i].y); }\n ctx.stroke();\n }\n ctx.restore();\n }\n\n function toolButton(tool, label) {\n var b = el("button", null, label);\n b.type = "button";\n b.setAttribute("aria-pressed", state.tool === tool ? "true" : "false");\n b.addEventListener("click", function () { state.tool = tool; render(); });\n return b;\n }\n\n /* The expanded image is a canvas you draw on. The base image lives offscreen so a\n repaint can restore it under the committed shapes plus the in-progress stroke. */\n function buildImageCanvas(ref, url) {\n var wrap = el("div", "media-canvas-wrap");\n var tools = el("div", "media-tools");\n tools.appendChild(toolButton("box", "Box"));\n tools.appendChild(toolButton("draw", "Draw"));\n var clear = el("button", null, "Clear markup");\n clear.type = "button";\n clear.addEventListener("click", function () {\n state.shapes[ref.id] = [];\n render();\n });\n tools.appendChild(clear);\n tools.appendChild(el("span", "media-status", "Draw to mark up; markup is sent with your answer."));\n wrap.appendChild(tools);\n\n var canvas = document.createElement("canvas");\n canvas.id = "canvas-" + ref.id;\n canvas.setAttribute("aria-label", (ref.filename || "attached image") + ". Draw on the image to annotate it.");\n wrap.appendChild(canvas);\n\n var base = document.createElement("canvas");\n var current = null;\n var drawing = false;\n\n function repaint() {\n var ctx = canvas.getContext("2d");\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n ctx.drawImage(base, 0, 0);\n var shapes = state.shapes[ref.id] || [];\n for (var i = 0; i < shapes.length; i++) { drawShape(ctx, shapes[i]); }\n if (current) { drawShape(ctx, current); }\n }\n\n function toCanvas(e) {\n var rect = canvas.getBoundingClientRect();\n return {\n x: (e.clientX - rect.left) * (canvas.width / rect.width),\n y: (e.clientY - rect.top) * (canvas.height / rect.height)\n };\n }\n\n function endStroke() {\n if (!drawing) { return; }\n drawing = false;\n state.drawing = false;\n if (current) {\n state.shapes[ref.id] = (state.shapes[ref.id] || []).concat([current]);\n current = null;\n }\n repaint();\n if (state.renderQueued) { state.renderQueued = false; render(); }\n }\n\n canvas.addEventListener("pointerdown", function (e) {\n if (e.button !== undefined && e.button !== 0) { return; }\n drawing = true;\n state.drawing = true;\n try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* not fatal */ }\n var p = toCanvas(e);\n current = state.tool === "box"\n ? { tool: "box", x0: p.x, y0: p.y, x1: p.x, y1: p.y }\n : { tool: "draw", points: [p] };\n repaint();\n e.preventDefault();\n });\n canvas.addEventListener("pointermove", function (e) {\n if (!drawing || !current) { return; }\n var p = toCanvas(e);\n if (current.tool === "box") { current.x1 = p.x; current.y1 = p.y; }\n else { current.points.push(p); }\n repaint();\n e.preventDefault();\n });\n canvas.addEventListener("pointerup", endStroke);\n canvas.addEventListener("pointercancel", endStroke);\n\n var img = new Image();\n img.onload = function () {\n var maxDim = 2400;\n var scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));\n canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));\n canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));\n // Shapes are captured in canvas pixels; record the ratio so an annotated copy\n // rendered at natural size can scale them back up.\n state.canvasScale[ref.id] = canvas.width / img.naturalWidth;\n base.width = canvas.width;\n base.height = canvas.height;\n base.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);\n repaint();\n };\n img.src = url;\n return wrap;\n }\n\n /* A player/stage for one attachment. Loads lazily on first mount; re-mounts are free\n because the object URL is cached in state. */\n function mountMedia(ref, ticket, isRecent) {\n var stage = el("div", "media-stage");\n stage.setAttribute("data-media-stage", "1");\n\n function build() {\n var url = state.mediaUrls[ref.id];\n if (ref.kind === "image" && !isRecent) {\n return buildImageCanvas(ref, url);\n }\n if (ref.kind === "image") {\n var img = document.createElement("img");\n img.alt = ref.filename || "attached image";\n img.src = url;\n return img;\n }\n var node = document.createElement(ref.kind === "audio" ? "audio" : "video");\n node.controls = true;\n node.id = "player-" + ref.id;\n node.preload = "metadata";\n node.src = url;\n return node;\n }\n\n function refresh() {\n stage.textContent = "";\n if (state.mediaUrls[ref.id]) {\n stage.appendChild(build());\n return;\n }\n if (state.mediaErrors[ref.id]) {\n stage.appendChild(el("div", "media-status", "Could not load attachment: " + state.mediaErrors[ref.id]));\n return;\n }\n stage.appendChild(el("div", "media-status", "Loading " + ref.kind + "\u2026"));\n }\n\n refresh();\n if (!state.mediaUrls[ref.id] && !state.mediaErrors[ref.id]) {\n loadMediaObjectUrl(ref).then(refresh, refresh);\n }\n return stage;\n }\n\n function buildMediaItem(ref, ticket, isRecent, index) {\n var fig = el("figure", "media-item");\n fig.setAttribute("data-media-item", ref.id);\n var name = ref.filename || ("attachment " + (index + 1) + " (" + ref.kind + ")");\n fig.appendChild(el("figcaption", null, name + " \xB7 " + ref.mime_type + " \xB7 " + formatBytes(ref.size_bytes)));\n if (ref.caption) { fig.appendChild(el("div", "media-status", ref.caption)); }\n fig.appendChild(mountMedia(ref, ticket, isRecent));\n return fig;\n }\n\n function addNote(ticketId, note) {\n if (!state.notes[ticketId]) { state.notes[ticketId] = []; }\n state.notes[ticketId].push(note);\n // Clear the field itself, not just the draft: render() re-captures the input\'s\n // value at the top of the pass, so clearing only the draft would be undone.\n var input = document.getElementById("note-" + ticketId);\n if (input) { input.value = ""; }\n state.noteDrafts[ticketId + ":note"] = "";\n render();\n }\n\n function removeNote(ticketId, index) {\n if (state.notes[ticketId]) { state.notes[ticketId].splice(index, 1); }\n render();\n }\n\n function buildNotesList(ticket, isRecent) {\n var list = el("div", "notes-list");\n var notes = state.notes[ticket.id] || [];\n if (notes.length === 0) {\n list.appendChild(el("p", "media-status", "No annotations yet. Markup you draw and notes you add are sent back with your answer."));\n return list;\n }\n notes.forEach(function (note, index) {\n var item = el("div", "note");\n item.appendChild(el("div", "note-text", note.text));\n var meta = [];\n if (note.media_id) { meta.push(mediaName(ticket, note.media_id)); }\n if (note.at_seconds !== undefined && note.at_seconds !== null) { meta.push(formatTime(note.at_seconds)); }\n if (meta.length) { item.appendChild(el("div", "note-meta", meta.join(" \xB7 "))); }\n if (!isRecent) {\n var row = el("div", "note-add");\n var rm = el("button", null, "Remove");\n rm.type = "button";\n rm.addEventListener("click", function () { removeNote(ticket.id, index); });\n row.appendChild(rm);\n item.appendChild(row);\n }\n list.appendChild(item);\n });\n return list;\n }\n\n function buildNoteAdder(ticket, media) {\n var form = el("form", "note-add");\n form.setAttribute("novalidate", "novalidate");\n var key = ticket.id + ":note";\n\n var input = document.createElement("input");\n input.type = "text";\n input.id = "note-" + ticket.id;\n input.setAttribute("data-notedraft", key);\n input.setAttribute("autocomplete", "off");\n input.setAttribute("placeholder", "Add a note");\n input.value = state.noteDrafts[key] || "";\n input.addEventListener("input", function () { state.noteDrafts[key] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n\n var select = document.createElement("select");\n select.id = "notetarget-" + ticket.id;\n select.setAttribute("aria-label", "Which attachment the note is about");\n var whole = document.createElement("option");\n whole.value = "";\n whole.textContent = "Whole ticket";\n select.appendChild(whole);\n media.forEach(function (ref, i) {\n var option = document.createElement("option");\n option.value = ref.id;\n option.textContent = ref.filename || ("attachment " + (i + 1) + " (" + ref.kind + ")");\n select.appendChild(option);\n });\n var targetKey = ticket.id + ":notetarget";\n if (state.noteTargets[targetKey]) { select.value = state.noteTargets[targetKey]; }\n select.addEventListener("change", function () { state.noteTargets[targetKey] = select.value; });\n\n var add = el("button", null, "Add note");\n add.type = "submit";\n form.appendChild(input);\n form.appendChild(select);\n form.appendChild(add);\n\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n var text = input.value.trim();\n if (!text) { return; }\n var note = { text: text };\n if (select.value) {\n note.media_id = select.value;\n var ref = findMedia(ticket, select.value);\n if (ref && (ref.kind === "audio" || ref.kind === "video")) {\n var player = document.getElementById("player-" + ref.id);\n if (player && isFinite(player.currentTime)) { note.at_seconds = Math.round(player.currentTime * 10) / 10; }\n }\n }\n addNote(ticket.id, note);\n });\n return form;\n }\n\n function buildAnnotationEditor(ticket) {\n var media = ticket.media || [];\n if (media.length === 0) { return null; }\n var box = el("div", "annotations");\n box.appendChild(el("h4", null, "Annotations (optional)"));\n box.appendChild(buildNotesList(ticket, false));\n box.appendChild(buildNoteAdder(ticket, media));\n return box;\n }\n\n function buildAnnotationsReadonly(ticket) {\n var annotations = ticket.annotations || [];\n if (annotations.length === 0) { return null; }\n var box = el("div", "annotations");\n box.appendChild(el("h4", null, "Annotations (" + annotations.length + ")"));\n annotations.forEach(function (a) {\n var item = el("div", "note");\n if (a.text) { item.appendChild(el("div", "note-text", a.text)); }\n var meta = [];\n if (a.media_id) { meta.push(mediaName(ticket, a.media_id)); }\n if (a.at_seconds !== undefined && a.at_seconds !== null) { meta.push(formatTime(a.at_seconds)); }\n if (a.annotated_media) { meta.push("annotated image"); }\n if (meta.length) { item.appendChild(el("div", "note-meta", meta.join(" \xB7 "))); }\n box.appendChild(item);\n if (a.annotated_media) {\n var fig = el("div", "annotation-image");\n fig.appendChild(mountMedia(a.annotated_media, ticket, true));\n box.appendChild(fig);\n }\n });\n return box;\n }\n\n function buildMediaSection(ticket, isRecent) {\n var media = ticket.media || [];\n var annotations = ticket.annotations || [];\n if (media.length === 0 && annotations.length === 0) { return null; }\n\n var section = el("div", "media-section");\n\n if (media.length > 0) {\n var expanded = !!state.expanded[ticket.id];\n var toggle = el("button", "media-toggle",\n (expanded ? "Hide " : "Show ") + media.length + " attachment" + (media.length === 1 ? "" : "s") +\n (isRecent ? "" : " for inspection"));\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", expanded ? "true" : "false");\n var list = el("div", "media-list");\n list.hidden = !expanded;\n if (expanded) {\n media.forEach(function (ref, i) { list.appendChild(buildMediaItem(ref, ticket, isRecent, i)); });\n }\n toggle.addEventListener("click", function () {\n state.expanded[ticket.id] = !state.expanded[ticket.id];\n render();\n });\n section.appendChild(toggle);\n section.appendChild(list);\n }\n\n if (isRecent) {\n var readOnly = buildAnnotationsReadonly(ticket);\n if (readOnly) { section.appendChild(readOnly); }\n } else {\n var editor = buildAnnotationEditor(ticket);\n if (editor) { section.appendChild(editor); }\n }\n\n return section;\n }\n\n /* Render an annotated copy offscreen, independent of whether the canvas is mounted.\n Shapes are in canvas pixels, so a fresh canvas at the image\'s natural size needs\n them scaled up to match. */\n function renderAnnotatedBlob(ref) {\n return loadMediaObjectUrl(ref).then(function (url) {\n return new Promise(function (resolve, reject) {\n var img = new Image();\n img.onload = function () {\n var canvas = document.createElement("canvas");\n canvas.width = img.naturalWidth;\n canvas.height = img.naturalHeight;\n var ctx = canvas.getContext("2d");\n ctx.drawImage(img, 0, 0);\n // Shapes are in on-screen canvas pixels; the copy is at natural size, so scale\n // by the inverse of the on-screen ratio (1 when the image was not downscaled).\n var baseScale = state.canvasScale[ref.id] || 1;\n var shapes = state.shapes[ref.id] || [];\n ctx.save();\n ctx.scale(1 / baseScale, 1 / baseScale);\n for (var i = 0; i < shapes.length; i++) { drawShape(ctx, shapes[i]); }\n ctx.restore();\n canvas.toBlob(function (blob) { blob ? resolve(blob) : reject(new Error("could not render image")); }, "image/png");\n };\n img.onerror = function () { reject(new Error("could not load image")); };\n img.src = url;\n });\n });\n }\n\n function blobToBase64(blob) {\n return new Promise(function (resolve, reject) {\n var reader = new FileReader();\n reader.onload = function () {\n var result = String(reader.result || "");\n var comma = result.indexOf(",");\n resolve(comma >= 0 ? result.slice(comma + 1) : result);\n };\n reader.onerror = function () { reject(new Error("could not read image")); };\n reader.readAsDataURL(blob);\n });\n }\n\n function uploadImageBlob(blob) {\n return blobToBase64(blob).then(function (data_base64) {\n return api("/api/media", {\n method: "POST",\n body: JSON.stringify({ mime_type: "image/png", data_base64: data_base64 })\n });\n }).then(function (res) {\n if (!res.ok) { throw new Error("media upload " + res.status); }\n return res.json();\n }).then(function (data) {\n if (!data || !data.media || !data.media.id) { throw new Error("media upload returned no id"); }\n return data.media.id;\n });\n }\n\n /* ---------------- actions ---------------- */\n\n function setCardError(id, msg) {\n var el = document.getElementById("err-" + id);\n if (el) {\n el.hidden = false;\n var span = el.querySelector(".err-text");\n if (span) { span.textContent = msg; }\n }\n }\n\n function submitAnswer(ticket) {\n var input = document.getElementById("free-" + ticket.id);\n var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();\n var choice = state.choices[ticket.id] || "";\n var answer = text || choice;\n\n var notes = (state.notes[ticket.id] || []).slice();\n var media = ticket.media || [];\n // A media item counts as markup only if at least one shape was committed on it.\n var marked = media.filter(function (ref) { return (state.shapes[ref.id] || []).length > 0; });\n\n if (!answer && notes.length === 0 && marked.length === 0) {\n setCardError(ticket.id, "Type an answer, pick an option, or add an annotation first.");\n return;\n }\n\n var annotations = notes.map(function (note) {\n var a = { text: note.text };\n if (note.media_id) { a.media_id = note.media_id; }\n if (note.at_seconds !== undefined && note.at_seconds !== null) { a.at_seconds = note.at_seconds; }\n return a;\n });\n\n // Upload each annotated image first (with POST /api/media), then reference it by id\n // in the answer. A missing or non-image reference is a 400, so this order matters.\n Promise.all(marked.map(function (ref) {\n return renderAnnotatedBlob(ref).then(uploadImageBlob).then(function (mediaId) {\n annotations.push({ media_id: ref.id, annotated_media_id: mediaId });\n });\n })).then(function () {\n return api("/api/answer", {\n method: "POST",\n body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human", annotations: annotations })\n });\n }).then(function (res) {\n if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }\n if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }\n if (res.status === 400) { setCardError(ticket.id, "Answer rejected (400)."); return; }\n if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }\n delete state.choices[ticket.id];\n delete state.drafts[ticket.id];\n delete state.notes[ticket.id];\n media.forEach(function (ref) { delete state.shapes[ref.id]; });\n return fetchTickets();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));\n });\n }\n\n function undo(ticketId) {\n api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })\n .then(function (res) {\n if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }\n if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }\n return fetchTickets();\n })\n .catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Undo failed: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n function undoLast() {\n var recent = recentTickets();\n if (recent.length === 0) { return; }\n undo(recent[0].id);\n }\n\n /* ---------------- keyboard (requirement 4) ---------------- */\n\n function isTypingTarget(el) {\n if (!el) { return false; }\n var tag = el.tagName;\n return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;\n }\n\n function moveSelection(delta) {\n var list = orderedPending();\n if (list.length === 0) { return; }\n var idx = -1;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }\n if (idx === -1) { idx = delta > 0 ? -1 : 0; }\n var next = idx + delta;\n if (next < 0) { next = 0; }\n if (next > list.length - 1) { next = list.length - 1; }\n state.selectedId = list[next].id;\n render();\n var card = document.getElementById("card-" + state.selectedId);\n if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }\n }\n\n function pickOption(n) {\n var list = orderedPending();\n var ticket = null;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }\n if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }\n state.choices[ticket.id] = ticket.options[n - 1];\n render();\n }\n\n function submitSelected() {\n var list = orderedPending();\n for (var i = 0; i < list.length; i++) {\n if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }\n }\n }\n\n document.addEventListener("keydown", function (e) {\n if (isTypingTarget(e.target)) { return; }\n // While the pointer/keyboard focus is inside a media stage (the drawing canvas, a\n // player), the single-key shortcuts would fight the media interaction.\n if (e.target && e.target.closest && e.target.closest("[data-media-stage]")) { return; }\n if (e.metaKey || e.ctrlKey || e.altKey) { return; }\n if (e.key === "j") { e.preventDefault(); moveSelection(1); }\n else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }\n else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }\n else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }\n else if (e.key === "u") { e.preventDefault(); undoLast(); }\n });\n\n /* ---------------- rendering ---------------- */\n\n function captureDrafts() {\n var inputs = document.querySelectorAll("[data-draft]");\n for (var i = 0; i < inputs.length; i++) {\n state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;\n }\n }\n\n function captureNoteDrafts() {\n var inputs = document.querySelectorAll("[data-notedraft]");\n for (var i = 0; i < inputs.length; i++) {\n state.noteDrafts[inputs[i].getAttribute("data-notedraft")] = inputs[i].value;\n }\n }\n\n function el(tag, cls, text) {\n var node = document.createElement(tag);\n if (cls) { node.className = cls; }\n if (text !== undefined && text !== null) { node.textContent = text; }\n return node;\n }\n\n function urgencyBadge(urgency) {\n var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);\n return b;\n }\n\n function buildCard(ticket, isRecent) {\n // The `selected` marker is a keyboard-navigation affordance for the pending queue\n // only. An answered card in "recent" carries no selection target (`j`/`k`/`Enter`\n // never act on it), so marking it "selected" would be misleading.\n var card = el("article", "card" + (!isRecent && ticket.id === state.selectedId ? " selected" : ""));\n card.id = "card-" + ticket.id;\n card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);\n\n var head = el("div", "card-head");\n head.appendChild(el("span", "selmark", "selected"));\n head.appendChild(el("span", "stream-name", ticket.stream));\n head.appendChild(el("span", "age", age(ticket.created_at) + " old"));\n head.appendChild(urgencyBadge(ticket.urgency));\n if (isRecent) {\n head.appendChild(el("span", "badge status-answered", "status: answered"));\n if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }\n }\n card.appendChild(head);\n\n card.appendChild(el("p", "question", ticket.question));\n\n if (ticket.context) {\n var ctx = el("div", "context", ticket.context);\n card.appendChild(ctx);\n }\n\n var mediaSection = buildMediaSection(ticket, isRecent);\n if (mediaSection) { card.appendChild(mediaSection); }\n\n if (isRecent) {\n var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));\n card.appendChild(ans);\n var undoBtn = el("button", null, "Undo");\n undoBtn.type = "button";\n undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);\n undoBtn.addEventListener("click", function () { undo(ticket.id); });\n var row = el("div", "answer-row");\n row.appendChild(undoBtn);\n card.appendChild(row);\n return card;\n }\n\n if (ticket.options && ticket.options.length) {\n var opts = el("div", "options");\n opts.setAttribute("role", "group");\n opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);\n ticket.options.forEach(function (opt, i) {\n var b = el("button", null, (i + 1) + ". " + opt);\n b.type = "button";\n b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");\n b.addEventListener("click", function () {\n state.choices[ticket.id] = opt;\n state.selectedId = ticket.id;\n render();\n });\n opts.appendChild(b);\n });\n card.appendChild(opts);\n }\n\n var form = el("form", "answer-row");\n form.setAttribute("novalidate", "novalidate");\n var label = el("label", null, "Answer");\n label.setAttribute("for", "free-" + ticket.id);\n form.appendChild(label);\n var input = document.createElement("input");\n input.type = "text";\n input.id = "free-" + ticket.id;\n input.setAttribute("data-draft", ticket.id);\n input.setAttribute("autocomplete", "off");\n input.value = state.drafts[ticket.id] || "";\n input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n form.appendChild(input);\n var submit = el("button", null, "Answer");\n submit.type = "submit";\n form.appendChild(submit);\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n submitAnswer(ticket);\n });\n card.appendChild(form);\n\n var err = el("div", "card-error");\n err.id = "err-" + ticket.id;\n err.hidden = true;\n err.setAttribute("role", "alert");\n err.appendChild(el("span", "tag", "Error: "));\n err.appendChild(el("span", "err-text", ""));\n card.appendChild(err);\n\n return card;\n }\n\n function render() {\n captureDrafts();\n captureNoteDrafts();\n\n var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;\n var selStart = null, selEnd = null;\n if (activeId && document.activeElement.setSelectionRange) {\n try {\n selStart = document.activeElement.selectionStart;\n selEnd = document.activeElement.selectionEnd;\n } catch (e) { /* not a text input */ }\n }\n\n var pending = pendingTickets();\n var recent = recentTickets();\n\n /* requirement 5: tab title carries the count */\n document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";\n\n /* status line */\n var statusText = "idle";\n if (state.live) { statusText = "live (server-sent events)"; }\n else if (state.pollTimer) { statusText = "polling every 5s"; }\n else if (!state.token) { statusText = "waiting for token"; }\n els.statusText.textContent = statusText;\n\n if (state.error) {\n els.error.hidden = false;\n els.errorText.textContent = state.error;\n } else {\n els.error.hidden = true;\n els.errorText.textContent = "";\n }\n\n /* pending, grouped by stream, newest stream first */\n els.pending.textContent = "";\n if (pending.length === 0) {\n els.pending.appendChild(el("p", "empty", "No pending tickets."));\n } else {\n groupByStream(pending).forEach(function (group) {\n var section = el("section", "stream-group");\n section.setAttribute("aria-label", "Stream " + group.stream);\n var head = el("div", "stream-head");\n head.appendChild(el("h3", "stream-name", group.stream));\n head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));\n section.appendChild(head);\n group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });\n els.pending.appendChild(section);\n });\n }\n\n /* recent, undoable for 5 minutes */\n els.recent.textContent = "";\n if (recent.length === 0) {\n els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));\n } else {\n recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });\n }\n\n /* restore focus + caret so polling re-renders do not interrupt typing */\n if (activeId) {\n var again = document.getElementById(activeId);\n if (again && again.focus) {\n try {\n again.focus();\n if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }\n } catch (e) { /* ignore */ }\n }\n }\n }\n\n /* ---------------- wiring ---------------- */\n\n els.tokenSave.addEventListener("click", function () {\n var t = els.tokenInput.value.trim();\n if (!t) { return; }\n state.token = t;\n writeToken(t);\n state.error = "";\n showTokenBar(false);\n startLive();\n fetchTickets();\n });\n els.tokenInput.addEventListener("keydown", function (e) {\n if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }\n });\n\n /* requirement 9: re-render on window load */\n window.addEventListener("load", render);\n\n /* boot */\n state.token = readToken();\n if (state.token) {\n showTokenBar(false);\n startLive();\n fetchTickets();\n } else {\n showTokenBar(true);\n }\n\n /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */\n render();\n})();\n</script>\n</body>\n</html>\n';
7512
+
7513
+ // src/core/media.ts
7514
+ function mediaKind(mimeType) {
7515
+ const base = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
7516
+ if (base.startsWith("image/")) {
7517
+ return "image";
7518
+ }
7519
+ if (base.startsWith("audio/")) {
7520
+ return "audio";
7521
+ }
7522
+ if (base.startsWith("video/")) {
7523
+ return "video";
7524
+ }
7525
+ return void 0;
7526
+ }
7527
+ function dataUriMime(input) {
7528
+ const match = /^data:([^;,]+);base64,/i.exec(input.trim());
7529
+ return match === null ? void 0 : match[1].toLowerCase();
7530
+ }
7531
+ function stripDataUri(input) {
7532
+ return input.trim().replace(/^data:[^;,]+;base64,/i, "");
7533
+ }
7534
+ function normalizeBase64(input) {
7535
+ return input.replace(/\s+/g, "");
7536
+ }
7537
+ function base64ToBytes(base642) {
7538
+ const binary = atob(base642);
7539
+ const bytes = new Uint8Array(binary.length);
7540
+ for (let index = 0; index < binary.length; index += 1) {
7541
+ bytes[index] = binary.charCodeAt(index);
7542
+ }
7543
+ return bytes;
7544
+ }
7545
+ function bytesToBase64(bytes) {
7546
+ let binary = "";
7547
+ const chunk = 32768;
7548
+ for (let offset = 0; offset < bytes.length; offset += chunk) {
7549
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
7550
+ }
7551
+ return btoa(binary);
7552
+ }
7553
+ function base64ByteLength(base642) {
7554
+ const length = base642.length;
7555
+ if (length === 0) {
7556
+ return 0;
7557
+ }
7558
+ let padding = 0;
7559
+ if (base642.endsWith("==")) {
7560
+ padding = 2;
7561
+ } else if (base642.endsWith("=")) {
7562
+ padding = 1;
7563
+ }
7564
+ return Math.floor(length * 3 / 4) - padding;
7565
+ }
7566
+ function buildMediaRef(id, input, now2) {
7567
+ const ref = {
7568
+ id,
7569
+ kind: mediaKind(input.mime_type) ?? "image",
7570
+ mime_type: input.mime_type,
7571
+ size_bytes: base64ByteLength(input.data_base64),
7572
+ created_at: now2
7573
+ };
7574
+ if (input.filename !== void 0) {
7575
+ ref.filename = input.filename;
7576
+ }
7577
+ if (input.caption !== void 0) {
7578
+ ref.caption = input.caption;
7579
+ }
7580
+ return ref;
7581
+ }
7582
+ function isSafeMediaId(id) {
7583
+ return /^[A-Za-z0-9_-]{1,128}$/.test(id);
7584
+ }
7505
7585
 
7506
7586
  // src/core/stream.ts
7507
7587
  function resolveStream(explicit, defaultStream) {
@@ -7549,6 +7629,11 @@ function makeAudit(ticketId, action, at, by, answer2) {
7549
7629
  }
7550
7630
  return entry;
7551
7631
  }
7632
+ async function uploadMedia(store, input, now2) {
7633
+ const id = newId();
7634
+ await store.putMedia({ id, mime_type: input.mime_type, data_base64: input.data_base64 });
7635
+ return buildMediaRef(id, input, now2);
7636
+ }
7552
7637
  async function enqueue(store, input, options) {
7553
7638
  const stream = resolveStream(input.stream, options.defaultStream);
7554
7639
  const timeoutSeconds = input.timeout_seconds ?? DEFAULT_TIMEOUT_SECONDS;
@@ -7572,8 +7657,19 @@ async function enqueue(store, input, options) {
7572
7657
  if (input.default_answer !== void 0) {
7573
7658
  ticket.default_answer = input.default_answer;
7574
7659
  }
7660
+ const media = [];
7661
+ if (input.media !== void 0 && input.media.length > 0) {
7662
+ for (const item of input.media) {
7663
+ media.push(await uploadMedia(store, item, created_at));
7664
+ }
7665
+ ticket.media = media;
7666
+ }
7575
7667
  await store.put(ticket);
7576
- await store.appendAudit(makeAudit(ticket.id, "created", created_at, stream));
7668
+ const createdAudit = makeAudit(ticket.id, "created", created_at, stream);
7669
+ if (media.length > 0) {
7670
+ createdAudit.media_count = media.length;
7671
+ }
7672
+ await store.appendAudit(createdAudit);
7577
7673
  return ticket;
7578
7674
  }
7579
7675
  function resolveExpiry(ticket, now2) {
@@ -7619,7 +7715,7 @@ async function listResolved(store, filter, now2) {
7619
7715
  }
7620
7716
  return out;
7621
7717
  }
7622
- async function answer(store, id, value, answeredBy, now2) {
7718
+ async function answer(store, id, value, answeredBy, now2, annotations = []) {
7623
7719
  const existing = await getResolved(store, id, now2);
7624
7720
  if (existing === void 0) {
7625
7721
  throw notFound(id);
@@ -7627,15 +7723,62 @@ async function answer(store, id, value, answeredBy, now2) {
7627
7723
  if (existing.status !== "pending") {
7628
7724
  throw conflict(`ticket ${id} is ${existing.status}; only a pending ticket can be answered`);
7629
7725
  }
7726
+ const resolved = [];
7727
+ for (const input of annotations) {
7728
+ const annotation = { id: newId() };
7729
+ if (input.text !== void 0) {
7730
+ annotation.text = input.text;
7731
+ }
7732
+ if (input.media_id !== void 0) {
7733
+ annotation.media_id = input.media_id;
7734
+ }
7735
+ if (input.at_seconds !== void 0) {
7736
+ annotation.at_seconds = input.at_seconds;
7737
+ }
7738
+ if (input.annotated_media_id !== void 0) {
7739
+ const blob = await store.getMedia(input.annotated_media_id);
7740
+ if (blob === void 0) {
7741
+ throw new QueueError(
7742
+ 400,
7743
+ `annotation media ${input.annotated_media_id} not found; upload it with POST /api/media first`
7744
+ );
7745
+ }
7746
+ const kind = mediaKind(blob.mime_type);
7747
+ if (kind !== "image") {
7748
+ throw new QueueError(400, `annotation media ${input.annotated_media_id} must be an image`);
7749
+ }
7750
+ annotation.annotated_media = {
7751
+ id: blob.id,
7752
+ kind,
7753
+ mime_type: blob.mime_type,
7754
+ size_bytes: base64ByteLength(blob.data_base64),
7755
+ created_at: now2
7756
+ };
7757
+ }
7758
+ resolved.push(annotation);
7759
+ }
7760
+ const trimmed = value.trim();
7761
+ if (trimmed === "" && resolved.length === 0) {
7762
+ throw new QueueError(400, "an answer must include text, a chosen option, or at least one annotation");
7763
+ }
7630
7764
  const ticket = {
7631
7765
  ...existing,
7632
7766
  status: "answered",
7633
- answer: value,
7634
7767
  answered_by: answeredBy,
7635
7768
  answered_at: now2
7636
7769
  };
7770
+ if (trimmed !== "") {
7771
+ ticket.answer = value;
7772
+ }
7773
+ if (resolved.length > 0) {
7774
+ ticket.annotations = resolved;
7775
+ }
7637
7776
  await store.put(ticket);
7638
- await store.appendAudit(makeAudit(id, "answered", now2, answeredBy, value));
7777
+ const answeredAudit = makeAudit(id, "answered", now2, answeredBy, trimmed !== "" ? value : void 0);
7778
+ if (resolved.length > 0) {
7779
+ answeredAudit.annotation_count = resolved.length;
7780
+ }
7781
+ await store.appendAudit(answeredAudit);
7639
7782
  return ticket;
7640
7783
  }
7641
7784
  async function undo(store, id, now2) {
@@ -7657,6 +7800,7 @@ async function undo(store, id, now2) {
7657
7800
  }
7658
7801
  const reverted = { ...ticket, status: "pending" };
7659
7802
  delete reverted.answer;
7803
+ delete reverted.annotations;
7660
7804
  delete reverted.answered_by;
7661
7805
  delete reverted.answered_at;
7662
7806
  await store.put(reverted);
@@ -7702,8 +7846,20 @@ var ASK_HUMAN_KEYS = [
7702
7846
  "default_answer",
7703
7847
  "timeout_seconds",
7704
7848
  "urgency",
7705
- "stream"
7849
+ "stream",
7850
+ "media"
7706
7851
  ];
7852
+ var MEDIA_KEYS = ["mime_type", "data_base64", "filename", "caption"];
7853
+ var ANNOTATION_KEYS = ["text", "media_id", "at_seconds", "annotated_media_id"];
7854
+ function formatBytes(bytes) {
7855
+ if (bytes < 1024) {
7856
+ return `${bytes} B`;
7857
+ }
7858
+ if (bytes < 1024 * 1024) {
7859
+ return `${(bytes / 1024).toFixed(1)} KiB`;
7860
+ }
7861
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
7862
+ }
7707
7863
  function fail(tool, message) {
7708
7864
  throw new ValidationError(tool, message);
7709
7865
  }
@@ -7756,6 +7912,174 @@ function optionalInteger(tool, args, key, min, max) {
7756
7912
  }
7757
7913
  return value;
7758
7914
  }
7915
+ function normalizeMediaData(tool, label, rawData, cap) {
7916
+ if (rawData.trim() === "") {
7917
+ fail(tool, `\`${label}\` must be a non-empty base64 string`);
7918
+ }
7919
+ const base642 = normalizeBase64(stripDataUri(rawData));
7920
+ const size = base64ByteLength(base642);
7921
+ if (size > cap) {
7922
+ fail(tool, `\`${label}\` is ${formatBytes(size)}; the limit is ${formatBytes(cap)}`);
7923
+ }
7924
+ try {
7925
+ base64ToBytes(base642);
7926
+ } catch {
7927
+ fail(tool, `\`${label}\` is not valid base64`);
7928
+ }
7929
+ return { base64: base642, size };
7930
+ }
7931
+ function validateAskHumanMedia(tool, raw) {
7932
+ const value = raw["media"];
7933
+ if (value === void 0 || value === null) {
7934
+ return void 0;
7935
+ }
7936
+ if (!Array.isArray(value)) {
7937
+ fail(tool, "`media` must be an array of attachments when present");
7938
+ }
7939
+ if (value.length === 0) {
7940
+ return void 0;
7941
+ }
7942
+ if (value.length > MAX_MEDIA_FILES) {
7943
+ fail(tool, `\`media\` may contain at most ${MAX_MEDIA_FILES} attachments (got ${value.length})`);
7944
+ }
7945
+ const out = [];
7946
+ let totalBytes = 0;
7947
+ for (let index = 0; index < value.length; index += 1) {
7948
+ const item = value[index];
7949
+ const label = `media[${index}]`;
7950
+ if (!isRecord(item)) {
7951
+ fail(tool, `\`${label}\` must be an object`);
7952
+ }
7953
+ for (const key of Object.keys(item)) {
7954
+ if (!MEDIA_KEYS.includes(key)) {
7955
+ fail(
7956
+ tool,
7957
+ `unknown argument \`${label}.${key}\`. Allowed arguments: ${MEDIA_KEYS.join(", ")}`
7958
+ );
7959
+ }
7960
+ }
7961
+ const rawMime = item["mime_type"];
7962
+ const declaredMime = typeof rawMime === "string" && rawMime.trim() !== "" ? rawMime.trim().toLowerCase() : void 0;
7963
+ const rawData = item["data_base64"];
7964
+ if (typeof rawData !== "string") {
7965
+ fail(tool, `\`${label}.data_base64\` is required and must be a base64 string`);
7966
+ }
7967
+ const effectiveMime = declaredMime ?? dataUriMime(rawData);
7968
+ if (effectiveMime === void 0) {
7969
+ fail(tool, `\`${label}.mime_type\` is required (e.g. "image/png", "audio/mpeg", "video/mp4")`);
7970
+ }
7971
+ if (mediaKind(effectiveMime) === void 0) {
7972
+ fail(tool, `\`${label}.mime_type\` must be an image/*, audio/*, or video/* type (got "${effectiveMime}")`);
7973
+ }
7974
+ const { base64: base642, size } = normalizeMediaData(tool, `${label}.data_base64`, rawData, MAX_MEDIA_BYTES_PER_FILE);
7975
+ totalBytes += size;
7976
+ const entry = { mime_type: effectiveMime, data_base64: base642 };
7977
+ const filename = optionalString(tool, item, "filename");
7978
+ if (filename !== void 0 && filename.trim() !== "") {
7979
+ entry.filename = filename.trim();
7980
+ }
7981
+ const caption = optionalString(tool, item, "caption");
7982
+ if (caption !== void 0 && caption.trim() !== "") {
7983
+ entry.caption = caption.trim();
7984
+ }
7985
+ out.push(entry);
7986
+ }
7987
+ if (totalBytes > MAX_MEDIA_BYTES_PER_TICKET) {
7988
+ fail(
7989
+ tool,
7990
+ `\`media\` totals ${formatBytes(totalBytes)}; the per-ticket limit is ${formatBytes(MAX_MEDIA_BYTES_PER_TICKET)}`
7991
+ );
7992
+ }
7993
+ return out;
7994
+ }
7995
+ function validateMediaUpload(args) {
7996
+ const tool = "media_upload";
7997
+ const raw = requireRecord(tool, args);
7998
+ rejectUnknownKeys(tool, raw, MEDIA_KEYS);
7999
+ const rawMime = raw["mime_type"];
8000
+ const declaredMime = typeof rawMime === "string" && rawMime.trim() !== "" ? rawMime.trim().toLowerCase() : void 0;
8001
+ const rawData = raw["data_base64"];
8002
+ if (typeof rawData !== "string") {
8003
+ fail(tool, "`data_base64` is required and must be a base64 string");
8004
+ }
8005
+ const effectiveMime = declaredMime ?? dataUriMime(rawData);
8006
+ if (effectiveMime === void 0) {
8007
+ fail(tool, '`mime_type` is required (e.g. "image/png")');
8008
+ }
8009
+ if (mediaKind(effectiveMime) === void 0) {
8010
+ fail(tool, `\`mime_type\` must be an image/*, audio/*, or video/* type (got "${effectiveMime}")`);
8011
+ }
8012
+ const { base64: base642 } = normalizeMediaData(tool, "data_base64", rawData, MAX_ANNOTATION_BYTES);
8013
+ const entry = { mime_type: effectiveMime, data_base64: base642 };
8014
+ const filename = optionalString(tool, raw, "filename");
8015
+ if (filename !== void 0 && filename.trim() !== "") {
8016
+ entry.filename = filename.trim();
8017
+ }
8018
+ const caption = optionalString(tool, raw, "caption");
8019
+ if (caption !== void 0 && caption.trim() !== "") {
8020
+ entry.caption = caption.trim();
8021
+ }
8022
+ return entry;
8023
+ }
8024
+ function validateAnnotationInputs(args) {
8025
+ const tool = "answer";
8026
+ if (args === void 0 || args === null) {
8027
+ return [];
8028
+ }
8029
+ if (!Array.isArray(args)) {
8030
+ fail(tool, "`annotations` must be an array when present");
8031
+ }
8032
+ if (args.length > MAX_ANNOTATIONS) {
8033
+ fail(tool, `\`annotations\` may contain at most ${MAX_ANNOTATIONS} items (got ${args.length})`);
8034
+ }
8035
+ const out = [];
8036
+ for (let index = 0; index < args.length; index += 1) {
8037
+ const item = args[index];
8038
+ const label = `annotations[${index}]`;
8039
+ if (!isRecord(item)) {
8040
+ fail(tool, `\`${label}\` must be an object`);
8041
+ }
8042
+ for (const key of Object.keys(item)) {
8043
+ if (!ANNOTATION_KEYS.includes(key)) {
8044
+ fail(
8045
+ tool,
8046
+ `unknown argument \`${label}.${key}\`. Allowed arguments: ${ANNOTATION_KEYS.join(", ")}`
8047
+ );
8048
+ }
8049
+ }
8050
+ const text = optionalString(tool, item, "text");
8051
+ const annotatedMediaId = optionalString(tool, item, "annotated_media_id");
8052
+ if ((text === void 0 || text.trim() === "") && (annotatedMediaId === void 0 || annotatedMediaId.trim() === "")) {
8053
+ fail(tool, `\`${label}\` must carry \`text\` or \`annotated_media_id\``);
8054
+ }
8055
+ const entry = {};
8056
+ if (text !== void 0 && text.trim() !== "") {
8057
+ entry.text = text.trim();
8058
+ }
8059
+ if (annotatedMediaId !== void 0 && annotatedMediaId.trim() !== "") {
8060
+ if (!isSafeMediaId(annotatedMediaId)) {
8061
+ fail(tool, `\`${label}.annotated_media_id\` is not a valid media id`);
8062
+ }
8063
+ entry.annotated_media_id = annotatedMediaId;
8064
+ }
8065
+ const mediaId = optionalString(tool, item, "media_id");
8066
+ if (mediaId !== void 0 && mediaId.trim() !== "") {
8067
+ if (!isSafeMediaId(mediaId)) {
8068
+ fail(tool, `\`${label}.media_id\` is not a valid media id`);
8069
+ }
8070
+ entry.media_id = mediaId;
8071
+ }
8072
+ const atSeconds = item["at_seconds"];
8073
+ if (atSeconds !== void 0 && atSeconds !== null) {
8074
+ if (typeof atSeconds !== "number" || !Number.isFinite(atSeconds) || atSeconds < 0) {
8075
+ fail(tool, `\`${label}.at_seconds\` must be a non-negative number of seconds`);
8076
+ }
8077
+ entry.at_seconds = atSeconds;
8078
+ }
8079
+ out.push(entry);
8080
+ }
8081
+ return out;
8082
+ }
7759
8083
  function extraSentenceTerminators(question) {
7760
8084
  const scrubbed = question.replace(/\b(e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St)\./gi, (match) => match.replace(/\./g, "")).replace(/\d+\.\d+/g, "");
7761
8085
  const withoutTrailing = scrubbed.replace(/[.!?]+\s*$/, "");
@@ -7800,6 +8124,7 @@ function validateAskHuman(args) {
7800
8124
  const context = optionalString(tool, raw, "context");
7801
8125
  const default_answer = optionalString(tool, raw, "default_answer");
7802
8126
  const stream = optionalString(tool, raw, "stream");
8127
+ const media = validateAskHumanMedia(tool, raw);
7803
8128
  const result = { question };
7804
8129
  if (options !== void 0) {
7805
8130
  result.options = options;
@@ -7819,6 +8144,9 @@ function validateAskHuman(args) {
7819
8144
  if (stream !== void 0) {
7820
8145
  result.stream = stream;
7821
8146
  }
8147
+ if (media !== void 0) {
8148
+ result.media = media;
8149
+ }
7822
8150
  return result;
7823
8151
  }
7824
8152
  function validateWaitForAnswer(args) {
@@ -7853,7 +8181,9 @@ var ASK_HUMAN_DESCRIPTION = [
7853
8181
  "",
7854
8182
  "Protocol you must follow: on `pending`, continue with independent work and call `wait_for_answer` again later \u2014 do not poll in a tight loop.",
7855
8183
  "",
7856
- 'Set `default_answer` only when it is safe for you to proceed without a human; it is used only if the ticket expires. Set `options` for 2-5 mutually exclusive choices. `timeout_seconds` defaults to 1800 and maxes at 86400. `urgency` defaults to "normal".'
8184
+ 'Set `default_answer` only when it is safe for you to proceed without a human; it is used only if the ticket expires. Set `options` for 2-5 mutually exclusive choices. `timeout_seconds` defaults to 1800 and maxes at 86400. `urgency` defaults to "normal".',
8185
+ "",
8186
+ "Optionally attach `media` (screenshots, audio, or video) for the human to inspect \u2014 media is never required. Each item is { mime_type, data_base64 } with optional `filename` and `caption`; `data_base64` is standard base64 (a `data:<mime>;base64,` prefix is accepted). The human may answer with a decision, with annotations on your media, or both; `wait_for_answer` returns any annotations, and rendered markup comes back as image content."
7857
8187
  ].join("\n");
7858
8188
  var WAIT_FOR_ANSWER_DESCRIPTION = [
7859
8189
  "Wait for the human to answer a ticket created by ask_human, then return the result.",
@@ -7904,6 +8234,28 @@ var TOOL_DEFINITIONS = [
7904
8234
  stream: {
7905
8235
  type: "string",
7906
8236
  description: "Optional. Overrides the configured default stream for this ticket."
8237
+ },
8238
+ media: {
8239
+ type: "array",
8240
+ maxItems: 8,
8241
+ description: "Optional. Media (screenshots, audio, video) for the human to inspect. Never required.",
8242
+ items: {
8243
+ type: "object",
8244
+ properties: {
8245
+ mime_type: {
8246
+ type: "string",
8247
+ description: 'An image/*, audio/*, or video/* type, e.g. "image/png".'
8248
+ },
8249
+ data_base64: {
8250
+ type: "string",
8251
+ description: "Standard base64 bytes. A `data:<mime>;base64,` prefix is accepted."
8252
+ },
8253
+ filename: { type: "string", description: 'Optional. A display name, e.g. "before.png".' },
8254
+ caption: { type: "string", description: "Optional. A caption shown with the attachment." }
8255
+ },
8256
+ required: ["data_base64"],
8257
+ additionalProperties: false
8258
+ }
7907
8259
  }
7908
8260
  },
7909
8261
  required: ["question"],
@@ -7976,12 +8328,63 @@ function waitOutput(ticket) {
7976
8328
  if (ticket.answered_at !== void 0) {
7977
8329
  out.answered_at = ticket.answered_at;
7978
8330
  }
8331
+ if (ticket.media !== void 0) {
8332
+ out.media = ticket.media;
8333
+ }
8334
+ if (ticket.annotations !== void 0) {
8335
+ out.annotations = ticket.annotations;
8336
+ }
7979
8337
  return { ...out };
7980
8338
  }
8339
+ function annotationSummary(ticket) {
8340
+ const annotations = ticket.annotations;
8341
+ if (annotations === void 0 || annotations.length === 0) {
8342
+ return void 0;
8343
+ }
8344
+ const notes = annotations.filter((annotation) => annotation.text !== void 0).length;
8345
+ const markup = annotations.filter((annotation) => annotation.annotated_media !== void 0).length;
8346
+ const parts = [];
8347
+ if (notes > 0) {
8348
+ parts.push(`${notes} note${notes === 1 ? "" : "s"}`);
8349
+ }
8350
+ if (markup > 0) {
8351
+ parts.push(`${markup} annotated image${markup === 1 ? "" : "s"}`);
8352
+ }
8353
+ return `The human also left ${annotations.length} annotation(s) (${parts.join(", ")}); see the attached content and the structured "annotations" field.`;
8354
+ }
8355
+ async function waitResult(ticket, waitedSeconds, store) {
8356
+ const content = [{ type: "text", text: waitText(ticket, waitedSeconds) }];
8357
+ if (ticket.status === "answered" && ticket.annotations !== void 0) {
8358
+ for (const annotation of ticket.annotations) {
8359
+ const ref = annotation.annotated_media;
8360
+ if (ref === void 0) {
8361
+ continue;
8362
+ }
8363
+ const blob = await store.getMedia(ref.id);
8364
+ if (blob === void 0) {
8365
+ continue;
8366
+ }
8367
+ if (ref.kind === "image") {
8368
+ content.push({ type: "image", data: blob.data_base64, mimeType: blob.mime_type });
8369
+ } else if (ref.kind === "audio") {
8370
+ content.push({ type: "audio", data: blob.data_base64, mimeType: blob.mime_type });
8371
+ }
8372
+ }
8373
+ }
8374
+ return { content, structuredContent: waitOutput(ticket) };
8375
+ }
7981
8376
  function waitText(ticket, waitedSeconds) {
7982
8377
  switch (ticket.status) {
7983
- case "answered":
7984
- return answerText(ticket.answer, ticket.answered_by);
8378
+ case "answered": {
8379
+ const lines = [
8380
+ ticket.answer !== void 0 && ticket.answer !== "" ? answerText(ticket.answer, ticket.answered_by) : "HUMAN ANSWER: (no text; the human responded with annotations only \u2014 see the attached content)"
8381
+ ];
8382
+ const summary = annotationSummary(ticket);
8383
+ if (summary !== void 0) {
8384
+ lines.push(summary);
8385
+ }
8386
+ return lines.join("\n\n");
8387
+ }
7985
8388
  case "expired":
7986
8389
  return ticket.answer !== void 0 ? `DEFAULT ANSWER (applied on expiry; no human responded): ${ticket.answer}` : `Ticket ${ticket.id} expired with no answer and no default_answer was set. Do not assume an answer: decide for yourself or ask again with ask_human.`;
7987
8390
  case "cancelled":
@@ -7997,14 +8400,20 @@ async function runAskHuman(args, deps) {
7997
8400
  defaultStream: deps.defaultStream,
7998
8401
  now: deps.now()
7999
8402
  });
8000
- const text = `Ticket ${ticket.id} enqueued (stream "${ticket.stream}", urgency ${ticket.urgency}, status pending, expires_at ${ticket.expires_at}). Continue with independent work and call wait_for_answer with id "${ticket.id}" later. Do not poll in a tight loop.`;
8001
- return textResult(text, {
8403
+ const mediaCount = ticket.media?.length ?? 0;
8404
+ const mediaText = mediaCount > 0 ? `, ${mediaCount} media attachment(s)` : "";
8405
+ const text = `Ticket ${ticket.id} enqueued (stream "${ticket.stream}", urgency ${ticket.urgency}, status pending${mediaText}, expires_at ${ticket.expires_at}). Continue with independent work and call wait_for_answer with id "${ticket.id}" later. Do not poll in a tight loop.`;
8406
+ const structured = {
8002
8407
  id: ticket.id,
8003
8408
  status: "pending",
8004
8409
  stream: ticket.stream,
8005
8410
  urgency: ticket.urgency,
8006
8411
  expires_at: ticket.expires_at
8007
- });
8412
+ };
8413
+ if (ticket.media !== void 0) {
8414
+ structured.media = ticket.media;
8415
+ }
8416
+ return textResult(text, structured);
8008
8417
  }
8009
8418
  async function runWaitForAnswer(args, deps) {
8010
8419
  const input = validateWaitForAnswer(args);
@@ -8018,10 +8427,10 @@ async function runWaitForAnswer(args, deps) {
8018
8427
  throw notFound(input.id);
8019
8428
  }
8020
8429
  if (ticket.status !== "pending") {
8021
- return textResult(waitText(ticket, effective), waitOutput(ticket));
8430
+ return waitResult(ticket, effective, deps.store);
8022
8431
  }
8023
8432
  if (Date.now() >= deadline) {
8024
- return textResult(waitText(ticket, effective), waitOutput(ticket));
8433
+ return waitResult(ticket, effective, deps.store);
8025
8434
  }
8026
8435
  await sleep(POLL_INTERVAL_MS);
8027
8436
  }
@@ -8272,7 +8681,6 @@ function buildAllowedOrigins(port, extra) {
8272
8681
  // src/http.ts
8273
8682
  var SWEEP_MS = 5e3;
8274
8683
  var HEARTBEAT_MS = 25e3;
8275
- var MAX_BODY_BYTES = 1e6;
8276
8684
  var HttpError = class extends Error {
8277
8685
  status;
8278
8686
  constructor(status, message) {
@@ -8410,14 +8818,69 @@ function createHttpApp(options) {
8410
8818
  void sweep();
8411
8819
  async function handleAnswer(req, res) {
8412
8820
  const body = await readJsonBody(req);
8413
- if (!isRecord2(body) || typeof body["id"] !== "string" || typeof body["answer"] !== "string") {
8414
- throw new HttpError(400, "body must be { id: string, answer: string, answered_by?: string }");
8821
+ if (!isRecord2(body) || typeof body["id"] !== "string") {
8822
+ throw new HttpError(
8823
+ 400,
8824
+ "body must be { id: string, answer?: string, answered_by?: string, annotations?: AnnotationInput[] }"
8825
+ );
8826
+ }
8827
+ const rawAnswer = body["answer"];
8828
+ if (rawAnswer !== void 0 && rawAnswer !== null && typeof rawAnswer !== "string") {
8829
+ throw new HttpError(400, "`answer` must be a string when present");
8415
8830
  }
8416
8831
  const answeredBy = body["answered_by"] === "default" ? "default" : "human";
8417
- const ticket = await answer(store, body["id"], body["answer"], answeredBy, now2());
8832
+ let annotations;
8833
+ try {
8834
+ annotations = validateAnnotationInputs(body["annotations"]);
8835
+ } catch (error2) {
8836
+ if (error2 instanceof ValidationError) {
8837
+ throw new HttpError(400, error2.message);
8838
+ }
8839
+ throw error2;
8840
+ }
8841
+ const ticket = await answer(
8842
+ store,
8843
+ body["id"],
8844
+ typeof rawAnswer === "string" ? rawAnswer : "",
8845
+ answeredBy,
8846
+ now2(),
8847
+ annotations
8848
+ );
8418
8849
  broadcast();
8419
8850
  sendJson(res, 200, { ticket });
8420
8851
  }
8852
+ async function handleMediaUpload(req, res) {
8853
+ const body = await readJsonBody(req);
8854
+ let input;
8855
+ try {
8856
+ input = validateMediaUpload(body);
8857
+ } catch (error2) {
8858
+ if (error2 instanceof ValidationError) {
8859
+ throw new HttpError(400, error2.message);
8860
+ }
8861
+ throw error2;
8862
+ }
8863
+ const media = await uploadMedia(store, input, now2());
8864
+ sendJson(res, 201, { media });
8865
+ }
8866
+ async function handleMediaGet(res, id) {
8867
+ if (!isSafeMediaId(id)) {
8868
+ sendText(res, 404, "Not Found");
8869
+ return;
8870
+ }
8871
+ const blob = await store.getMedia(id);
8872
+ if (blob === void 0) {
8873
+ sendText(res, 404, "Not Found");
8874
+ return;
8875
+ }
8876
+ const bytes = Buffer.from(base64ToBytes(blob.data_base64));
8877
+ res.writeHead(200, {
8878
+ "content-type": blob.mime_type,
8879
+ "content-length": bytes.length,
8880
+ "cache-control": "no-store"
8881
+ });
8882
+ res.end(bytes);
8883
+ }
8421
8884
  async function handleUndo(req, res) {
8422
8885
  const body = await readJsonBody(req);
8423
8886
  if (!isRecord2(body) || typeof body["id"] !== "string") {
@@ -8456,6 +8919,38 @@ function createHttpApp(options) {
8456
8919
  await mcpHandler(req, res);
8457
8920
  return;
8458
8921
  }
8922
+ if (path === "/api/media") {
8923
+ if (method !== "POST") {
8924
+ res.writeHead(405, { allow: "POST" });
8925
+ res.end("Method Not Allowed");
8926
+ return;
8927
+ }
8928
+ if (!requireBearer(req, token)) {
8929
+ sendText(res, 401, "Unauthorized");
8930
+ return;
8931
+ }
8932
+ await handleMediaUpload(req, res);
8933
+ return;
8934
+ }
8935
+ if (path.startsWith("/api/media/")) {
8936
+ if (method !== "GET") {
8937
+ res.writeHead(405, { allow: "GET" });
8938
+ res.end("Method Not Allowed");
8939
+ return;
8940
+ }
8941
+ if (!requireBearer(req, token)) {
8942
+ sendText(res, 401, "Unauthorized");
8943
+ return;
8944
+ }
8945
+ let id;
8946
+ try {
8947
+ id = decodeURIComponent(path.slice("/api/media/".length));
8948
+ } catch {
8949
+ id = path.slice("/api/media/".length);
8950
+ }
8951
+ await handleMediaGet(res, id);
8952
+ return;
8953
+ }
8459
8954
  const allowed = ROUTE_METHODS[path];
8460
8955
  if (allowed === void 0) {
8461
8956
  sendText(res, 404, "Not Found");
@@ -20563,11 +21058,23 @@ function log2(message) {
20563
21058
  process.stderr.write(`readyroom: ${message}
20564
21059
  `);
20565
21060
  }
21061
+ async function writeAtomic(path, bytes) {
21062
+ const tmp = `${path}.tmp`;
21063
+ const handle = await open2(tmp, "w");
21064
+ try {
21065
+ await handle.writeFile(bytes);
21066
+ await handle.sync();
21067
+ } finally {
21068
+ await handle.close();
21069
+ }
21070
+ await rename2(tmp, path);
21071
+ }
20566
21072
  var FileTicketStore = class {
20567
21073
  stateDir;
20568
21074
  ticketsPath;
20569
21075
  ticketsTmpPath;
20570
21076
  auditPath;
21077
+ mediaDir;
20571
21078
  /** In-memory mirror of `tickets.json`, keyed by ticket id. */
20572
21079
  tickets = /* @__PURE__ */ new Map();
20573
21080
  /** Tail of the write chain. Every mutation appends to it; nothing runs concurrently. */
@@ -20581,6 +21088,7 @@ var FileTicketStore = class {
20581
21088
  this.ticketsPath = join2(stateDir, TICKETS_FILE);
20582
21089
  this.ticketsTmpPath = join2(stateDir, TICKETS_TMP_FILE);
20583
21090
  this.auditPath = join2(stateDir, AUDIT_FILE);
21091
+ this.mediaDir = join2(stateDir, MEDIA_DIR);
20584
21092
  }
20585
21093
  /**
20586
21094
  * Serialize a task behind every previously queued write. The returned promise
@@ -20674,6 +21182,46 @@ var FileTicketStore = class {
20674
21182
  }
20675
21183
  });
20676
21184
  }
21185
+ /**
21186
+ * Store a media blob as `<mediadir>/<id>` (raw bytes) with a sidecar
21187
+ * `<mediadir>/<id>.json` carrying the MIME type. The sidecar exists because a raw
21188
+ * file cannot say what it is, and `GET /api/media/{id}` needs the content type
21189
+ * without consulting a ticket.
21190
+ *
21191
+ * Writes go through the same tmp -> fsync -> rename path as `tickets.json`, so a
21192
+ * crash mid-write can never leave a truncated blob where a reader would find it.
21193
+ */
21194
+ async putMedia(blob) {
21195
+ if (!isSafeMediaId(blob.id)) {
21196
+ throw new Error(`refusing to store media under an unsafe id: ${blob.id}`);
21197
+ }
21198
+ return this.serialize(async () => {
21199
+ await mkdir2(this.mediaDir, { recursive: true });
21200
+ const bytes = base64ToBytes(blob.data_base64);
21201
+ await writeAtomic(join2(this.mediaDir, blob.id), bytes);
21202
+ const meta2 = JSON.stringify({ mime_type: blob.mime_type, size_bytes: bytes.length });
21203
+ await writeAtomic(join2(this.mediaDir, `${blob.id}.json`), new TextEncoder().encode(meta2));
21204
+ });
21205
+ }
21206
+ /** The blob, or `undefined` for an unknown or unsafe id. Never throws on a bad id. */
21207
+ async getMedia(id) {
21208
+ if (!isSafeMediaId(id)) {
21209
+ return void 0;
21210
+ }
21211
+ let bytes;
21212
+ let metaRaw;
21213
+ try {
21214
+ bytes = await readFile2(join2(this.mediaDir, id));
21215
+ metaRaw = await readFile2(join2(this.mediaDir, `${id}.json`), "utf8");
21216
+ } catch (error2) {
21217
+ if (error2.code === "ENOENT") {
21218
+ return void 0;
21219
+ }
21220
+ throw error2;
21221
+ }
21222
+ const meta2 = JSON.parse(metaRaw);
21223
+ return { id, mime_type: meta2.mime_type, data_base64: bytesToBase64(bytes) };
21224
+ }
20677
21225
  /**
20678
21226
  * Atomic write: tmp file -> fsync -> rename over the real file. The rename is the
20679
21227
  * atomic step; the fsync guarantees the bytes are on disk before the rename lands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readyroom",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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
6
  "keywords": [
@@ -55,8 +55,11 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@cloudflare/workers-types": "^5.20261015.1",
58
+ "@semantic-release/changelog": "^7.0.0",
59
+ "@semantic-release/git": "^11.0.1",
58
60
  "@types/node": "^26.6.5",
59
61
  "esbuild": "^0.28.2",
62
+ "semantic-release": "^25.0.9",
60
63
  "tsx": "^4.23.15",
61
64
  "typescript": "^7.0.2",
62
65
  "wrangler": "^4.151.0"