Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review: y-markdown's delimiter map now reuses the shared DELIMITERS export from critic-constants.ts instead of duplicating the strings, and blockText nests all active mark types on a run (highlight outermost) instead of rendering only the first one, so overlapping criticHighlight + criticAddition/Deletion/Comment marks no longer lose a delimiter pair silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds mint/verify/revoke/list for per-document agent tokens: app/lib/agent-tokens.ts generates and hashes tokens (SHA-256), and DocumentAgent gains an agent_tokens SQLite table plus mintAgentToken/getAgentRoster/revokeAgentToken/verifyAgentToken RPC methods. Extends the integration test's sql mock with a generic in-memory table store for tables beyond doc_state, reusable by later agent tasks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alarm() only deleted doc_state, so tokens minted before a document expired stayed valid against whatever content later landed at the same doc id. Clear agent_tokens alongside doc_state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveAnchor's nearest-index heuristic can resolve `to` below `from`
after duplicate-hash blocks or concurrent edits, which made deleteBlocks
pass a negative length that Yjs silently ignores while the insert still
ran -- degrading agentReplace into a pure insert that returned { ok: true }
despite the document diverging from the request. Reject inverted ranges
with a stale_anchor error before mutating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DocumentAgent's mutation RPCs (agentInsert/agentReplace/agentSuggest) now share a private applyMutation() for direct Yjs application. A pace of natural/fast with at least one connected human enqueues the mutation into a `performances` table instead of applying it instantly; a setTimeout-chain runner types insert/suggest text out via app/lib/performance-chunks.ts chunks so connected clients see it appear incrementally, while replace still applies atomically once dequeued. Anchors are re-resolved at dequeue time (not enqueue time) so a mutation gone stale while queued is simply dropped. Leftover queue rows are applied instantly on the next ensureInitialised() (eviction recovery), and the alarm handler now also purges the performances table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rmance engine Code review found two Important issues in the Task 6 performance queue: 1. performTypedInsert/performTypedSuggest captured a block index (or text position) once and kept reusing it across every `await sleep(...)` tick, so a concurrent pace:"instant" mutation (which bypasses the queue) could shift the document under them, landing later writes at a stale spot. Fixed by claiming the target slot synchronously (zero yield before the first write), then tracking the write position from there on via a Y.RelativePosition, which stays correct across concurrent structural edits and aborts cleanly if it stops resolving. 2. Eviction recovery re-applied a queued mutation's full original text even if some ticks had already landed, duplicating what was typed. Fixed by deleting a performance's `performances` row at the moment its first write lands (the same synchronous-claim moment from fix 1) instead of at completion — from that instant the typed content is already part of the Yjs doc and persisted normally, so an eviction mid-typing now loses only the untyped tail instead of duplicating anything. Added a covering test that reproduces fix 1 (an anchored typed insert vs. a concurrent instant insert on the same anchor) — verified it fails against the pre-fix code and passes against the fix. Redesigned the eviction recovery test to use two queued mutations so it genuinely exercises the pre-first-write case under the new claim-then-delete semantics. Tightened the natural-pace typing test's partial-content assertion to a bounded timer advance instead of an assertion that also passes for a fully atomic implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gives agents a synthetic Yjs awareness client so humans see them in the presence stack and caret while they work. app/lib/agent-awareness.ts hand-encodes MSG_AWARENESS frames for a stable per-name synthetic clientId; DocumentAgent tracks join/leave/idle state in a new agentPresence map, broadcasting on every change and replaying it to late joiners in onConnect. onPerformanceCursor now sources the caret position from the performance engine's live Y.RelativePosition tracking (the text node + current offset) instead of the frozen block index it was called with before, so it stays correct under concurrent edits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an `events` table (seq/type/payload/created_at) plus `agentAwaitEvents` long-polling for it. Human-origin Yjs transactions are scanned via frag.observeDeep for @mentions of roster agents (with a 30s-capped doc_changed digest), and a threads-map observer notifies an agent when a human replies to its thread. Agent-originated mutations are tagged with a Yjs "agent" transaction origin so they're excluded from both detectors — this also covers onRequest's initial content/threads import, which isn't a live human edit. agentComment/agentReply were missing from the RPC surface (deferred from Task 5) and are needed to produce thread_reply events, so they're added here too: agentComment creates a ThreadData entry (requires `comment`), agentReply appends to one (doc_not_found for an unknown thread id). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rload - thread_reply now fires only when a human-origin threads-map edit actually grows replies.length (via event.changes.keys' oldValue), not on any edit to an agent-authored thread (resolve toggles, re-saves). - agentComment/agentReply now call checkRateLimit like the other mutation RPCs, instead of bypassing the agent's rate-limit budget. - Added "thread_not_found" to the closed AgentErrorCode union and use it from agentReply instead of overloading "doc_not_found". Also replaces the fake-timer tests' single-flush workaround (flushMicrotasks) with waitForTimerRegistered, which polls vi.getTimerCount() instead of guessing a fixed number of setImmediate ticks — the single flush was an intermittent hang under full-suite load (verifyAgentToken's real crypto.subtle.digest call didn't always resolve in one tick before vi.advanceTimersByTimeAsync raced ahead of the long-poll's setTimeout ever being registered). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a VaporMcp Durable Object that exposes the agent tool surface over streamable HTTP at /mcp. The tool table lives in agents/mcp-tools.ts, which imports nothing from the `agents` package so it stays testable in plain Vitest; agents/mcp.ts wires it to DocumentAgent stubs and adds create_document (no token, needs env). The bearer token from the Authorization header rides to the DO as ctx.props; DocumentAgent remains the only thing that validates it, and every tool — errors included — returns its result as JSON text content. Drops baseUrl from tsconfig.cloudflare.json: it resolved "agents/mcp" to this repo's own agents/ directory instead of the npm package. A paths entry keeps the package's internal types nameable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds GET /:id.md for a document's raw markdown (public, no token, backed by a new DocumentAgent.exportMarkdown RPC) and a GET /mcp help page shown to browsers (Accept: text/html) instead of a protocol error, with connection snippets for Claude Code, claude.ai, and generic MCP clients. The two handlers live in workers/routes.ts as pure functions that avoid importing the `agents` package, so they unit-test in plain vitest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restricts the origin interpolated into the /mcp help page to a strict http(s) allowlist, falling back to the default origin otherwise, since it derives from the client-controlled Host header and was being spliced unescaped into raw HTML/JSON. Also adds X-Content-Type-Options: nosniff to the new GET /:id.md response, since it serves raw user content at a public URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the /:id/agents resource route (GET roster, POST mint/revoke) backed by DocumentAgent's mintAgentToken/getAgentRoster/revokeAgentToken RPCs, with server-side capability validation since the route is the untyped boundary. Wires an InviteAgentDialog into the doc header: a form with a pre-filled unused name suggestion, capability switches (suggest+comment on, write off by default), a one-time token screen with copy-ready Claude Code/claude.ai/mcpServers snippets, and a live roster list with revoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds role="dialog"/aria-modal/aria-labelledby, Escape-to-close, a backdrop-click-only close (ignoring bubbled clicks from the panel), and minimal focus management (focus the name input on open, return focus to the invoking button on close). No dialog primitive exists in this codebase to build on, so this is the manual minimal version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add redirectHost() to workers/routes.ts that 301-redirects vpr.fyi, www.vpr.fyi, vaporware.fyi, www.vaporware.fyi, www.vapor.fyi to https://vapor.fyi while preserving path and query - Call redirectHost() FIRST in workers/app.ts fetch handler before MCP help - Add vpr.fyi and vaporware.fyi custom domain routes to wrangler.jsonc - Add comprehensive tests for all redirect scenarios and edge cases Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents moved from /docs/:id to /:id, but vapor.fyi is live and documents last 99 hours, so links shared before the move are still being opened — and 404ing. Add redirectLegacyDocPath, a pure handler alongside redirectHost: it 301s GET /docs/:id to /:id and GET /docs/:id.md to /:id.md, preserving the query string and using a path-relative Location so the redirect stays on whichever host served it. Wired into workers/app.ts ahead of routeAgentRequest and React Router. Also note that redirectHost's www.* entries only fire if DNS is later pointed at this worker — they aren't registered routes today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RESERVED_SLUGS was declared but never read, and two spec requirements around it were unimplemented. Add the missing ".well-known" entry and an isReservedSlug helper, then use it in both places the spec calls for: generateDocumentId re-rolls a candidate that collides with a reserved slug, and the /:id loader 404s reserved names explicitly before the id-shape check, without resolving a Durable Object stub. Also add MAX_AGENTS_PER_DOC alongside the list, for the roster cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parseCriticMarkupToContent throws on CriticMarkup substitution, and four agent paths called it unguarded on arbitrary MCP input. Worst was replace: deleteBlocks and the insert shared one Yjs transaction, and Yjs has no rollback, so a parse throw after the delete committed the delete and lost the replaced blocks. The throw also escaped the RPC as an exception instead of a typed error, wedged the performance queue with isPerforming stuck true, and could abort ensureInitialised mid-flight — leaving the Yjs observers unregistered (no mentions or events for the life of the instance) and a leftover performances row alive to collide with an id counter that restarts at 1. - critic-parser: add tryParseCriticMarkup, returning a result value. parseCriticMarkupToContent stays as a throwing wrapper for the document-import path, which catches it into a 400. - y-markdown: replace insertMarkdownBlocks with buildMarkdownBlocks (parse to detached nodes, no document touched) plus insertBlockNodes, so callers can validate before opening a transaction. - document: add the unsupported_markup error code and return it from dispatchMutation, before the instant/queued fork, so an agent gets the same typed error at any pace and nothing unparseable is ever persisted. applyMutation and performTypedInsert re-check as a backstop for rows written by older builds. - runPerformances clears isPerforming in a finally and drops a failing mutation (row included) so the queue drains past it; eviction recovery applies each leftover row in its own try/catch. - Guard the unguarded JSON.parse sites: agentRead skips an unparseable thread, agentReply returns thread_not_found for one, and a corrupt rate-limit log is treated as empty (it is rewritten on that same check). The integration test's SQL fake now enforces the PRIMARY KEY on performances.id and UNIQUE on agent_tokens.name, so the id-collision class of bug fails a test instead of passing silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three problems in the document event log, all in the same observer or its reader. Mentions only ever fired on paste. The observer matched findMentions against each individual delta op, and a human typing arrives one character per op, so "@scribe" never matched — the existing test inserted the whole string at once and masked it. Scan the containing block's full text instead (findBlockTextForXmlText already resolves it), de-duplicated per (block text node, agent name) so later keystrokes in the same block don't re-fire. A name is forgotten as soon as it leaves the block's text, so deleting and retyping a mention notifies again. The map is a WeakMap keyed by the live text node, so it needs no explicit clearing. doc_changed digests were recorded above the roster check, so every document with no agents on it accrued events rows nobody could read. Record the digest only once at least one agent is on the roster. agentAwaitEvents filtered on seq alone, so every agent received every other agent's mentions and thread replies. Filter both types to the calling agent; doc_changed stays broadcast. The returned cursor now advances to the highest row scanned rather than the last row returned, so an agent never re-scans notifications addressed to someone else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_document reaches the same document store as POST /new but applied none of its input guards, so an MCP client could create a document from arbitrary-size or binary content. Add the same 1MB ceiling and NUL-byte check, as validateNewDocumentMarkdown in mcp-tools.ts — agents/mcp.ts can't be imported in plain Vitest, so the guard lives where it can be tested directly and is called from the tool. mintAgentToken had no roster ceiling either. A document is a public, unauthenticated URL, so cap it at MAX_AGENTS_PER_DOC (16) and return a rate_limited error naming the cap. Revoking an agent frees a slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h hidden ids, hexagon agents (#60)
One document per draft: re-read, then replace/insert only the blocks that changed so URLs and comment anchors survive; a whole-document range replace is a last resort. The create_document tool description carries the same nudge. Context: #59. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…dio client, demo cast (#63) Follow-ups to #60. - **Face pile draws agents as hexagons.** The pile checked the agent flag on the wrong object, so every agent drew as a circle, and the homepage's "also online" cast reset the flag to false. The paper backing behind each face follows the shape now. - **Anonymous agents keep their animal**, in white inside the hexagon. Owned agents show their client's mark. - **LM Studio** is a recognised client (`lmstudio-mcp-server-session` → "LM Studio"), with a placeholder mark and no invite tab yet. Known clients take their table label everywhere, so "Rob's Lmstudio Mcp Server Session" becomes "Rob's LM Studio". - **Demo doc:** Alice's Claude makes the cursor comment in Alice's purple; the Badger reply stays. Verified on the local homepage: fox and Alice as circles, Alice's Claude as a purple hexagon with the Claude mark, the badger inside a blue hexagon. 747 tests, typecheck, and lint pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #62. ## What Sign-in was Google-only. Apple joins it as a sibling: the same ID-token verification, the same session cookie, and one more button wherever sign-in is offered. - **Verifier.** `verifyIdToken(provider, …)` in `app/lib/auth.server.ts` with a small `PROVIDERS` table (JWKS URL, accepted issuers); `verifyGoogleIdToken` and the new `verifyAppleIdToken` wrap it. Apple sends `email_verified` as the string `"true"` in some tokens, which is accepted. Principals are `apple:<sub>` via `principalFor(provider, sub)`; `principalFromSub` stays for Google. - **Routes.** `/auth/config` now returns `{ googleClientId, appleClientId }`; the client renders one button per non-empty id. New `POST /auth/apple` verifies the popup flow's `id_token`, takes the name from the first authorization's `user` field, keeps the stored profile name on later sign-ins (Apple never sends it again), and falls back to the address only for a brand-new account. Both providers finish through one `completeSignIn`. - **Browser.** `app/lib/apple-signin.ts` loads Apple's JS toolkit on click, runs the popup flow, and posts to `/auth/apple`. The OAuth consent page (`app/lib/oauth-pages.ts`) inlines the same flow next to Google's, so the MCP grant works for Apple users, and says so when an instance has no provider at all. - **Sign-in is a dialog now.** `SignInDialog` is a Dialog like New document and Invite an agent, with one button per configured provider, the webview fallback note, and a note when the instance has no provider; it closes itself once a session exists. The header menu shows a **Sign in** row while signed out that opens it. `vapor://signin` opens it from document text (the tour's "Names are optional" paragraph now links there), and so does the sign-in event a dropped file fires while signed out. `HeaderMenu` loses all the GSI/Apple code. - **Config and docs.** `APPLE_CLIENT_ID` (the Services ID) alongside `GOOGLE_CLIENT_ID`; either, both, or neither. `docs/self-hosting.md` step 7 becomes "Sign-in providers" with the Apple walkthrough (App ID → Services ID → domain and `https://<origin>/auth/apple` return URL; Apple refuses `http`, so localhost can't be listed). Privacy page covers both providers and Hide My Email. README, CLAUDE.md, `.dev.vars.example`, wrangler comments updated. ## Design notes - **Separate identities per provider.** Principals are keyed on the provider's `sub`, so someone using Google one day and Apple the next gets two profiles, two counterpart agents, two sets of grants. Linking them by verified email is possible later but is not in this PR; the Registry's `e:<email>` index simply points at whichever signed in last. (This supersedes the "same email, same profile" line in the original issue, which predated the move to `sub`-keyed principals.) - **No avatar from Apple.** The profile's `avatar` stays null and `Avatar.tsx` already falls back to the letter mark. - **Relay emails.** A Hide My Email user is stored under the relay address, which is also what others would need to mention them by. Called out on the privacy page. ## Verified Typecheck and lint clean. 742 tests pass; the 16 failures are the pre-existing Node 26 localStorage ones (`safe-storage`, `anon-identity`, `use-theme`), green on Node 22 in CI. Not exercised against a live Apple Services ID: that needs a paid developer account and an `https` return URL, so the first real run will be on a deployed preview once vapor.fyi has an `APPLE_CLIENT_ID`. Things to check then, from the issue: the nested popup inside the MCP clients' consent popup, and the first-login name capture. ## Rollout for vapor.fyi Nothing changes until `APPLE_CLIENT_ID` is added to `deploy/vapor.fyi.jsonc` (after registering `vapor.fyi` and `https://vapor.fyi/auth/apple` on a Services ID). Without it the header and consent page look exactly as they do today. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sets APPLE_CLIENT_ID to the Services ID `fyi.vapor`, registered for domain vapor.fyi with return URL https://vapor.fyi/auth/apple. Adds the Apple button to the sign-in dialog and the MCP consent page (#64). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The toolkit URL in #64 (`appleauth/static/jstoolkit/v1/…`) returns 404, so the Apple button did nothing. Apple serves it at `appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js` (verified 200). Same fix in the sign-in dialog and the OAuth consent page. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…own (#67) Closes #40. ## What Every agent mutation ran in a Yjs transaction tagged with the bare string `"agent"`, and the document observers skipped that origin. That kept an agent from hearing its own typing, but it also meant no agent's edit ever produced an event: one agent `@mentioning` another never woke it, and an agent's reply never reached the agent that opened the thread. - **Origin carries the actor.** Agent writes now run with `{ kind: "agent", actor: <roster name> }` (`agentOrigin(name)` in `agents/document.ts`). `applyMutation` takes the acting agent; the typed-performance paths, `agentComment`, and `agentReply` tag theirs; recovered performance rows use their stored `agent_name`. The bare `"agent"` origin is reserved for system writes (import, restore), which still fire nothing. - **Observers run for agent edits.** Mention detection, thread replies, and the doc_changed digest treat an agent edit like a human one and put `actor` on the event payload. The digest window is per actor, so an agent's typing burst is one event to the others and never consumes the window meant for human edits. An agent writing its own name is not a self-mention. - **Each agent filters its own.** `agentAwaitEvents`, `eventsPoll`, and the webhook dispatcher drop events whose `actor` is the requesting agent, server-side, so no client changes. The cursor still advances past filtered rows. - **Catalog.** Event descriptions and the payload schema name the `actor` field. ## Not changed A mention inside a brand-new comment's text (as opposed to a reply) was never detected, for people or agents; the thread observer only looks at replies. Left as is; worth its own issue if wanted. ## Verified Typecheck and lint clean. 750 tests pass; the 16 failures are the pre-existing Node 26 localStorage ones. New integration tests cover: an agent's mention reaching the other agent with `actor`, the actor hearing nothing and its cursor advancing; self-name not a mention; an agent's reply firing `thread_reply` for the author only; `events_poll` applying the same filter and carrying `actor` on the wire. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…to the alarm (#68) Closes #58. Stacked on #67 (same file); GitHub retargets this to `main` when that merges. ## Finding A Durable Object bills for every moment it is awake, and any pending timer keeps it awake. Auditing `DocumentAgent` for standing timers turned up one the sleeping-tabs plan missed, plus the two it listed: 1. **y-protocols' `Awareness` starts a 3-second `setInterval` in its constructor**, and `ensureInitialised()` constructs one for every document. Nothing cleared it (only `doc.destroy()` on expiry would). So every document that had ever been opened held a standing interval until eviction, which is exactly the "≈29 hours awake per day on ≤13k requests" shape in the August 31 numbers. 2. The 5-minute per-agent idle `setTimeout`, re-armed on every join and cursor tick. 3. The 60-second idle-snapshot `setTimeout`, re-armed on every edit. ## Fix - The awareness interval is cleared right after construction. Its job, forgetting peers whose heartbeat lapsed for 30s, now runs on incoming awareness traffic (`pruneOutdatedAwareness`); `onClose` already removes a departing client's state. - Idle presence and the idle snapshot become rows in a `schedule` table served by the DO's single alarm. `armAlarm()` sets the alarm to the earliest deadline or the document's expiry; the alarm handler runs what is due, then re-arms, and expires the document only once its 99 hours are actually up. Expiry is computed from the stored `createdAt` (as `docExpiresAt()` already did) rather than read back from the alarm, so `remainingLifetimeMs` is unchanged in meaning. - Creation and system writes (import, restore) book no snapshot; a deadline that moves by under 5 seconds is not rewritten, so a typing burst doesn't write storage per keystroke. - The 1-second persistence debounce is the only timer left. The 15-second long-poll cap stands. ## Verified Typecheck and lint clean; 752 tests pass (the 16 failures are the pre-existing Node 26 localStorage ones). New integration tests: presence clears at its deadline through the alarm while the document survives, and the alarm is re-armed for the expiry; the deadline moves with performances; `vi.getTimerCount()` is 0 after initialisation plus an agent join and an edit; stale awareness is pruned on the next awareness message; the expiry tests now move the clock past the TTL instead of assuming any alarm firing is the expiry. **Not verified against the dashboard.** I have no analytics token on this machine. After deploy, `node tools/do-usage.mjs --days 3` (needs `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_ANALYTICS_TOKEN`) should show `DocumentAgent` duration collapse to a small multiple of request-handling time; the per-namespace graph at Workers → Durable Objects → DocumentAgent shows the same. ## Behaviour notes - Agent presence now clears at the deadline even if the DO was evicted in between (the alarm survives hibernation), where a lost `setTimeout` used to leave a ghost cursor until the next wake. - The alarm may fire a little early for a task; the handler tolerates one second of slack and otherwise just re-arms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ument (#69) ## URLs A document's address is its title as a slug plus the id: `/agent-identity-plan-26g5wsew`. The id resolves; the slug is for people reading the link and is regenerated from the live title, so `/26g5wsew`, a stale slug, and `/<slug>-<id>.md` all still open the document. `/new` and the MCP `create_document` tool return the slugged URL. In the editor, the visible URL and the tab title follow the first heading through `history.replaceState`, without involving the router; query string and hash survive. `app/shared/doc-url.ts` holds the segment parser, the slug rules (ASCII words, 60-char cap at a word boundary), and the title/description extraction (CriticMarkup resolved as accepted, comments dropped, mention ids hidden). ## Link previews The loader reads the title and first paragraph from the Durable Object and renders them server-side: `<title>` as "Title · vapor", a description tag, Open Graph type, site name, title, description, canonical URL, image with width/height/alt, and Twitter card tags. Per arfct/link-previews (docs/imessage.md): iMessage shows a description only for pages that look like a social post, so document pages serve `og:type=article` plus an ActivityPub alternate link pointing back at the page. Other platforms ignore the link. Left for later: a 1200×630 per-document image (the square logo is the summary card today) and absolute icon hrefs. ## Verified - Dev server: creating a titled doc returns the slugged URL; the server HTML fetched with the four-way iMessage user agent carries every tag; a wrong slug loads the document and the URL is corrected client-side; typing into the title rewrites the address within a second. - 774 unit tests plus integration tests pass; typecheck and lint clean. New tests: `doc-url`, `doc-meta`, slugged cases in the loader, raw-route, and `/new` tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
#74) Closes #70. Closes #71. ## #70 — comments were never anchored The browser places a comment thread by its inline marks: a `criticHighlight` over the quoted words and the comment text right after it as a hidden `criticComment` run, matched to the thread-map entry by comment text (`scanDocumentComments` / `matchThreadsToComments`). The MCP `comment` tool wrote the thread-map entry only, so every agent comment was an orphan and rendered floating. `agentComment` now lays down the same marks the UI's CommentInput does, in the same transaction as the map write: - with `quote` (an exact substring of the block): highlight over the span, hidden run right after it; - without: a bare marker at the end of the block's text, like a standalone `{>>comment<<}`; - a quote that isn't in the block returns `find_not_matched` with the block's text as the snippet, where it used to silently create a floating thread. The thread id is `threadIdForComment` (moved from `useThreads` to `app/shared/thread-id.ts`), the deterministic id a browser would mint for the same mark, so a client that scans the mark before the map entry arrives converges on one key. Exports now read `{==Hello==}{>>needs work<<}` exactly like a person's comment. ### Threads that still have no marks Existing agent comments (the ones on `agent-setup-report-clarence-on-vapor-fslav1fw`, for instance) and imported comments whose passage has since changed have no marks to anchor to. They used to stack after the last anchored card and, when selected, get pinned to anchor 0 — the animate-to-the-top effect. Two fallbacks in the rail: - The thread's `highlightText` is searched for in the document (`findTextPosition`: first occurrence inside a single text block, offsets mapped through inline children so a mention chip doesn't skew it) and the card anchors level with that passage. - A card that still has no anchor is pinned, when active, where the stack already placed it rather than at the top (`layoutComments`). ### Collapsed cards clip at 480px A long comment or a deep reply chain no longer stands at full height in the rail while unselected: the collapsed card clips its body at 480px with a bottom fade (only when it is actually cut off), and the selected card shows everything. ## #71 — resolve, edit, delete Three tools, all requiring the `comment` capability, mirroring `useThreads`: - `resolve_thread` (`thread_id`, `resolved` default true): lifts the highlight and marker and keeps the words, as the UI's Resolve does; reopening leaves the text alone. Anyone may resolve, as in the UI. - `edit_comment` (`thread_id`, optional `reply_id`, `text`): the agent's own comment or reply. Editing the opening comment rewrites its hidden run too, since the text is the match key. - `delete_comment` (`thread_id`, optional `reply_id`): the agent's own reply, or its whole thread with the marks. Ownership: agent authors on comments and replies now carry `id: "agent:<roster name>"` (the roster name is public already as the mention token; the principal never leaves the server). Someone else's returns `not_author`; a missing reply `reply_not_found`. Legacy agent authors without an id are recognised by name plus client. ## Verified Typecheck and lint clean; 778 tests pass (the usual 16 Node 26 localStorage failures aside). New integration tests cover the marks laid down with and without a quote via the exported markdown, the `find_not_matched` path, a duplicate-text comment getting its own id, resolve and reopen, edit of comments and replies with `not_author` and `reply_not_found`, delete of replies and threads, a person's thread being resolvable but not editable by an agent, and capability checks. Unit tests cover the tool-argument mapping, `findTextPosition` (inside a block, across a mark boundary, first block wins, absent and empty passages), and the layout's anchorless active card staying put. Not exercised in a browser: the rail picking up an agent's anchored comment live. The marks are byte-for-byte what CommentInput writes, and the export proves the placement, but a look at doc `fslav1fw`'s successor after deploy would close the loop. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #73. ## Cause `CommentEditor` is a TipTap editor, so its root element carries TipTap's `tiptap` class alongside our `comment-editor`. The body editor's rules in `app.css` (`.tiptap { padding: 100px 1.5rem 1.5rem; font-size: 1.15rem; … }` and `.tiptap p { max-width: 65ch }`) therefore applied to the comment box as well. `.comment-editor` only reset outline, min-height, and paragraph margin, at equal specificity, so the padding and type won. That is the ~300px box in the screenshot: 100px of top padding above the paragraph, the placeholder centred in the box, the caret on the paragraph line below it. ## Fix The `.comment-editor` block now resets padding (0), font size (inherit), line height (1.5), and paragraph width (none), with a comment explaining that it has to stay below the `.tiptap` rules in the file to win the cascade. The box is one line tall with the placeholder on that line, and grows as the comment wraps. Replies use the same editor and get the same fix. ## Verified CSS only; typecheck, lint, and the suite are unaffected. Not checked in a browser here, so worth opening a comment box on the deployed site once merged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #72. ## What The header's face pile showed everyone on the document except the viewer. Their own face now sits at the far right, a small step apart from the other faces, so they can see how they appear to everyone else (animal or signed-in photo, and colour) and confirm they are signed in. - The pile renders even when nobody else is on the document; the button's label reads "Only you here", otherwise "N people, M here now (including you)". - The list that opens gains a final "You · here now" row with the same face. - Ordering for everyone else is unchanged: oldest left, newest right, "+N" for the overflow. - Circle avatars now carry the person's name as a tooltip, as hexagons already did (also what the test reads). ## Verified Typecheck and lint clean; 785 tests pass (the usual 16 Node 26 localStorage failures aside). New tests render the pile with a live Y.Doc and awareness: alone it shows one face, the viewer's, marked as the self face; with another person online the viewer comes last with the gap, and the list shows the You row. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #76. The faces sat above the pill's centre: each per-face wrapper was an inline span, so the inline-flex face aligned to the span's text baseline with descender space under it. The wrappers are flex boxes now, so the faces centre in the 48px pill. Test asserts the wrappers are flex. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ts, useful errors (#91) Closes #78. Closes #79. Closes #80. ## #78 — a failed exchange burned the code `handleToken` took the code out of the Registry first and validated after, so a wrong `client_id`, `redirect_uri`, or verifier spent the code and the person had to authorize again. Now it peeks, validates every field against the stored code, and only then takes it. A concurrent exchange that loses the race gets the winner's response if it has landed. A successful exchange's response is remembered for **60 seconds** keyed by the spent code (`Registry.putReplay` / `getReplay`). A retry presenting the same code and the same verifier from the same client gets the identical tokens, so a response lost to a dropped connection no longer costs a browser round-trip. A different verifier or client presenting the spent code is refused as before. ## #79 — loopback redirect URIs on any port `redirectUriMatches` treats `http://localhost`, `http://127.0.0.1`, and `http://[::1]` URIs as matching when scheme, host, path, and query agree, ignoring the port, per RFC 8252 §7.3. A registered URI with no port (Claude Code's CIMD document declares `http://localhost/callback`) covers every port. Applied at authorize and at the token exchange. Non-loopback URIs still match exactly. `[::1]` is accepted as a registrable loopback host. ## #80 — the mismatch error says something An unknown client or an unregistered redirect URI must not redirect, so the answer stays on our side: a JSON `invalid_request` with `error_description` naming the received URI and the registered list for clients, the consent page carrying the same text for browsers (negotiated on `Accept`). The text is escaped in the page. ## Verified Typecheck and lint clean; the OAuth suite grew from 9 to 14 tests: wrong verifier / client / redirect leave the code usable and the corrected retry succeeds; a retry of a spent code replays the same tokens while other callers are refused; the loopback matcher's cases; an ephemeral-port client authorizing and exchanging on a new port; the negotiated error carrying both URIs. Full suite green apart from the known Node 26 localStorage failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…son, create_document says what it allows (#92) Closes #86. Closes #88. Closes #90. ## #90 — quotes copied from read_document didn't match Corrected diagnosis: it was not a mark boundary. A block's text with marks is a single `Y.XmlText`, so a search across bold or code already worked. What failed was the quote itself: `read_document` hands each block over as **markdown**, so an agent copying from it sends `` a `list_documents` for `` with backticks the page's text doesn't have. `findInBlock` now tries the text as given, then with inline syntax stripped (`stripInlineMarkdown` in `app/shared/quote-text.ts`: backticks, `**`/`__`, `*`/`_` emphasis without touching snake_case, `~~`, link and image brackets, backslash escapes). The mark covers what actually matched and the thread's `highlightText` is the page text. Applies to `comment`'s `quote` and `suggest`'s `find`, instant and typed paths. ## #88 — presence per tab `read_document`'s `presence` pushed one entry per awareness state. It now dedupes people by the awareness user's `id`, falling back to name. Agents were already listed from the roster. ## #86 — create_document over-promised The result now carries `capabilities`, and when the identity cannot write, a `note` explaining that and how to revise: by `suggest`, or through the signed-in endpoint with write approved. The tool description says the same up front. Anonymous stays suggest-and-comment; the text now matches. ## Verified Typecheck and lint clean; 795 tests pass (the usual 16 Node 26 failures aside). New: a quote `**Hello** \`there\`` against "Hello there." exports as `{==Hello there==}{>>…<<}.`; `suggest` with `` `Hello` `` produces `{--Hello--}{++Hi++}`; three tabs (two for one id) read back as two people; `createDocumentNote` for anonymous, principal without write, and principal with write; the strip rules including the snake_case and lone-asterisk cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…document guidance (#93) Closes #82. ## Framing Standing instructions are meant to steer agents — guidance for how to work in a document — and they live in a document anyone with the link can edit. The feature stays; the protection is provenance and framing. ## What changed - **Attribution.** Every local edit to an `agent` block stamps `editedBy` and `editedAt` (an `appendTransaction` in the `AgentInstructions` extension; the author is the local user's name, kept current across sign-in). Transactions arriving over Yjs are skipped, so each client stamps only its own edits. The fence info string carries the stamp — ```` ```agent by="Ada Lovelace" at=2026-09-09T20:01:00.000Z ```` — and parses back, so exports, imports, and version snapshots keep it. The rendered panel exposes it as `data-edited-by` / `data-edited-at`. - **`read_document`.** `instructions` is now built by `instructionsForAgents`: a fixed notice first (untrusted content from the document's editors; let it shape work within this document — tone, structure, what to leave alone, how to propose changes — never authority to act outside it, use other tools, reveal anything, or override the person the agent works for), then each block prefixed with `[Written by <name> on <time>]`. A new `instruction_sources` array gives the same structurally. - **Copy.** The tool description, the server's instructions, the skill, the `/mcp` guide, README, and CLAUDE.md all say the same, and no longer claim the block is invisible to people: the editor shows it as a labelled "Agent instructions" panel and the markdown view shows the fence. ## Verified Typecheck and lint clean; 790 tests pass (the usual 16 Node 26 failures aside). New: fence attribution round-trips through parse and serialize including a quoted name with escapes; `getAgentInstructions` returns text plus attribution; `instructionsForAgents` framing; the extension stamps local edits, re-stamps with a new author, leaves remote transactions' stamps alone, and renders data attributes; `read_document` returns the notice, the per-block author line, and `instruction_sources`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #89. Closes #81. ## #89 — caret label wrapped the avatar onto its own line Tailwind's preflight sets `img { display: block }`, so a caret label carrying a photo pushed the name onto a second row; the animal glyph and the agent badge sat awkwardly for the same reason. The label is now an `inline-flex` row with `align-items: center` and a small gap; the avatar, animal, and badge lose their inline margins and vertical tweaks. CSS only. ## #81 — a comment you made was invisible until a reload Two faults, both in the thread reconcile path: 1. `CommentInput` called `activateComment` **after** running the transaction that lays down the marks. The editor `update` from that transaction runs `reconcile` synchronously, which checks `pendingActivateRef` to decide whether this client authored the comment — still empty at that moment — so the author path never fired and the comment fell to the 3-second fallback. 2. The fallback timer wrote the thread into the map with `reconcilingRef` set, so the map observer skipped it, and nothing re-read afterwards. The thread existed in the shared document (hence a reload fixed it) but the local rail never learned of it. Now the comment is announced before the marks land, so the author path creates and selects the thread immediately, and the fallback calls `reconcile` after writing, so a comment from any path (including one made on another client, or imported) appears within three seconds. ## Verified Typecheck and lint clean; 786 tests pass (the usual 16 Node 26 failures aside). New: a hook test renders `useThreads` against a real TipTap editor and Y.Doc, inserts a comment mark without announcing it, confirms nothing shows, advances three seconds, and confirms the thread appears with a position and the local user as author — the exact sequence that used to leave the rail empty. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ents (#95) Closes #83. Closes #84. ## #83 — expiry is visible and announced - `read_document` returns `created_at` and `expires_at` (ISO 8601); `create_document` returns them too. A new `documentSummary()` RPC (exists, title, lifetime) serves listings without a full read. - A fourth event, **`document.expiring`**, fires once six hours before deletion with `expires_at` in its payload, delivered by poll and webhook like the others. It runs from the alarm-scheduled `expiring` task (the scheduler from #58), booked at creation — and, for documents created before this change, on an agent's next enrollment, so existing documents get the warning too. The single alarm now serves the idle deadlines, the expiring warning, and the expiry, earliest first. ## #84 — list_documents - When a signed-in identity's roster row is created, the document agent records the enrollment in the Registry (`docs:<principal>`, bounded to 200). Expiry removes the document from every enrolled principal's list. - `list_documents` (signed-in endpoint; `capability_denied` on anonymous) reads the list, fetches each document's summary, drops and forgets any that no longer exist, and returns `{ documents: [{ id, url, title, created_at, expires_at, enrolled_at }] }`, most recently enrolled first. ## Docs The `/mcp` guide (HTML and markdown), README, the skill's archive step, and CLAUDE.md describe the fields, the event, and the tool. ## Verified Typecheck and lint clean; 804 tests pass (the usual 16 Node 26 failures aside). New: lifetime fields on read and summary; the expiring task booked at creation six hours before expiry, and, when the alarm fires at that deadline, the event recorded and pollable as `document.expiring` while the document lives on; Registry enrollments add, refresh without duplicating, list most recent first, and remove; the catalog lists four events. Existing alarm tests updated for the extra deadline. Not exercised end to end: `list_documents` itself lives in the MCP server class, which the unit harness cannot import; its pieces (enrollment mirror, summary, pruning) are tested individually. Worth one call against the deployed endpoint after merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…dded (#96) Part of #59 — the two bug-shaped pieces. The server-side `patch` tool stays open on the issue. ## Every block in the range is verified `replace` gains an optional `anchors` argument: every anchor in the range, as `read_document` returned them. `staleAnchors` resolves each before anything is charged or queued, and again when a queued (paced) replace actually lands, and returns `stale_block` naming the blocks that changed — with their current anchors and a snippet — so the agent re-reads those and retries, and nothing anyone typed in the middle of the range is overwritten. Without `anchors` the behaviour is unchanged (endpoints only), so existing clients keep working; the tool description and the skill's "revise in place" step tell agents to pass them. ## Charged for the delta `replaceCharge` bills the hourly character budget for the lines the new markdown adds over the range it replaces (a trimmed line-level delta, at least one character), not for every line it re-states. A whole-document replace that changes one paragraph costs that paragraph. A range that doesn't resolve is charged in full and fails at dispatch as before. ## Verified Typecheck and lint clean; 807 tests pass (the usual 16 Node 26 failures aside). New: a middle block edited after the read stops the replace with `stale_block` naming it and the document keeps the edit; fresh anchors let the replace apply; a three-block rewrite changing one line is charged that line; a rewrite sharing nothing is charged in full; the tool maps `anchors` through. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #85. ## What A signed-in person mints a long-lived **personal access token** with a label and a grant (suggest & comment, or full write) under **Share → Invite an agent → Other → Access token**, sees it once, and sends it as `Authorization: Bearer vpt_…` to the signed-in `/mcp` endpoint from any client that can set a header. One secret in a password store for every machine, no browser in the loop. The token carries the same identity and counterpart agent the OAuth path would, and is revocable from the same place. - **Registry.** `createAccessToken`, `lookupAccessToken`, `listAccessTokens`, `revokeAccessToken`. Records live under the token's SHA-256 (`pat:<hash>`) with a per-principal index (`pats:<principal>`); the raw token is never stored, the view carries a 12-character id and the token's last four characters. Use is stamped at most once a minute. Twenty tokens per principal. - **Routes.** `/me/tokens`: GET lists, POST mints (201, the token in the body, shown once), DELETE `?id=` revokes. Same-origin, cookie session, pure and unit-tested like `/me/wake`. - **MCP endpoint.** A `vpt_` bearer resolves through the Registry to `{ principal, email, caps }`; anything else takes the JWT path unchanged, so OAuth clients see no difference. - **UI.** `TokenSection` in the invite dialog's Other tab (signed in): the list with grant and last use, New token with label and grant, the minted token and its header line shown once, Revoke per row. - **Docs.** The `/mcp` guide (HTML and markdown), README, and CLAUDE.md. ## Verified Typecheck and lint clean; 825 tests pass (the usual 16 Node 26 failures aside). New: Registry mint/lookup/list/revoke including a stranger's revoke being a no-op and the per-principal cap; the routes' auth, same-origin, validation, 201, 429, and revoke; the component signed out, listing, minting and showing once, and revoking. Not exercised end to end: the bearer path in `workers/app.ts` is wiring the unit harness cannot import. After merge, mint a token in the dialog and run `claude mcp add --transport http vapor https://vapor.fyi/mcp --header "Authorization: Bearer vpt_…"` (or a curl `initialize`) to confirm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Main's build failed after #97 merged: the rebase of #97 onto #95 kept both inserted blocks at the same spot in `agents/registry.ts` but dropped `listEnrollments`' closing brace. Restored, plus the matching describe nesting in the Registry test. Typecheck, lint, and the suite are green again; the live site was unaffected since the failed deploy never replaced the running version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…underneath (#99) ## What was wrong Undo was wired up already — `@tiptap/extension-collaboration` binds ⌘Z, ⇧⌘Z, and ⌘Y to the Yjs `UndoManager` — but it failed for most real edits, for two reasons found with a jsdom probe against the editor's own extension stack: 1. **Most edits were never captured.** `BlockId` appends a transaction after any change that creates a block, flagged `addToHistory: false`. The y-sync plugin folds every ProseMirror transaction from one update into a single Yjs transaction and takes the batch's history flag from the *last* transaction it saw (and even calls `stopCapturing` on the undo manager for it). So typing inside an existing paragraph undid fine, while anything that made a paragraph — the common case — was excluded, along with the person's own words. The flag is removed from `BlockId` and from the agent-instructions attribution stamp; undoing the batch restores the attrs with the content, which is what you want anyway. A new CLAUDE.md critical rule says why appended transactions must not carry that flag here. 2. **Redo right after undo threw.** y-tiptap's selection restore resolved a stale position against the current document ("Position 13 out of range"): the undo plugin remembers the selection from the state *before* the last transaction, so after an undo its memory pointed into content the undo had just removed. The new `UndoRedo` extension, registered after Collaboration so it replaces its `undo`/`redo` commands, dispatches one empty transaction first, which moves that memory onto the current document, then runs the library's undo/redo. The collaboration shortcuts call the commands by name and so land here; the menu buttons call them directly. ## What's added Undo and Redo buttons at the top of the Format menu, enabled by `editor.can().undo()` / `redo()`, with the shortcuts in their tooltips. `redo` joins the Material Symbols subset. ## Verified Typecheck and lint clean; 821 tests pass (the usual 16 Node 26 failures aside). New tests against the real collaboration stack: undo and redo of an edit that created a block; ⌘Z, ⇧⌘Z, ⌘Y through the keymap; a collaborator's edit arriving as a Yjs update is never undone locally; an edit to a standing-instructions block (which stamps attribution) undoes as one step; the menu buttons' enabled state and click. Not checked in a browser. Worth typing a few paragraphs on a document after deploy and pressing ⌘Z a few times, then ⇧⌘Z. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #100. ## EPUB export — `GET /:id.epub` A single-chapter EPUB 3 built in the Worker (`app/shared/epub.ts`, `fflate`): `mimetype` first and stored, container, package document (title from the first heading, `urn:vapor:<id>`, modified time, source URL), a nav, a small stylesheet, and the chapter rendered by markdown-it in XHTML mode with CriticMarkup resolved as accepted, comments and `agent` fences dropped, and mention ids hidden. Attachment images referenced in the document are fetched from R2 and packaged, with the chapter's URLs rewritten to them, so the file stands alone (Kindle does not fetch remote images). File name `<slug>-<id>.epub`. **Download EPUB** joins the Share menu and the new dialog, signed in or not. ## Send to device A **Send to device** dialog, from the header menu and the Share menu, with two rows and the download. **Kindle.** Amazon takes documents by email to the reader's `@kindle.com` address from an approved sender. The reader saves the address once (`PUT /me/devices`, validated as a Kindle address); the instance sends from `SEND_FROM_EMAIL` through Resend's HTTP API with `RESEND_API_KEY` (`workers/kindle.ts`, one fetch). Both are optional operator values — without them the row says so and offers the download plus Amazon's upload page. The dialog shows the exact sender address the reader must approve at Amazon, once. **reMarkable.** The reader gets an eight-character one-time code from my.remarkable.com/device/desktop/connect and pastes it (`POST /me/devices`). The Worker exchanges it at the reMarkable token endpoint for a device token and stores it **sealed** with the same key that seals wake secrets. Each send exchanges it for a short-lived user token and uploads the EPUB to the cloud's document endpoint (`internal.cloud.remarkable.com/doc/v2/files`, with the extension's `rm-meta` / `rm-source` headers); the document lands in the root folder. Unpair from the same row (`DELETE`). `POST /:id/send { target }` does the delivery; five sends a minute per principal. Routes are same-origin cookie routes, pure and dependency-injected (`workers/device-routes.ts`). ## Docs README (a "Reading it elsewhere" section), `docs/self-hosting.md` (the two mail values and the approved-sender step), `.dev.vars.example`, wrangler comments, CLAUDE.md. ## Verified Typecheck and lint clean; 842 tests pass (the usual 16 Node 26 failures aside). New: the EPUB's zip layout (first entry `mimetype`, stored), package document contents, resolved chapter text, packaged image and rewritten URL, file naming; the reMarkable pairing, user-token, and upload request shapes and their error mapping; the Resend request with the base64 attachment; the routes' auth, same-origin, validation, mailer-absent and unconfigured refusals, throttle, and both send flows; Registry settings with the token stored sealed; the dialog signed out, saving and sending, pairing, and the no-mail message; the Share menu rows. **Not verified with devices.** Neither delivery can be exercised here: Send to Kindle needs a Resend account with a verified domain plus a reader who has approved the sender, and reMarkable needs a paired tablet. The request shapes follow Resend's documented API and the reMarkable endpoints the Read-on-reMarkable extension and rmapi use; the first real send of each needs a person with the device, and the EPUB itself is worth opening in Apple Books or Calibre once. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Does the code side of #103 so an instance can be submitted to the ChatGPT plugins directory (reaching free/Plus and mobile users). ## What changed - **Tool metadata** (`agents/mcp-tools.ts`, `agents/mcp.ts`): every tool now has a `title`, MCP `annotations` (`readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint`; anything landing in a public doc is open-world) and `securitySchemes` in `_meta` (`noauth` for the anonymous endpoint, `oauth2` with the capability scope). `jsonContent` returns `structuredContent` alongside the text. - **UserInfo** (`workers/oauth.ts`): `GET /oauth/userinfo` → `{sub, email, email_verified: true, name?}` for session JWTs and `vpt_` tokens; `userinfo_endpoint` in server metadata. `sub` is the principal, never the email. - **Domain verification** (`workers/routes.ts`): `GET /.well-known/openai-apps-challenge` serves the `OPENAI_APPS_CHALLENGE` var verbatim, 404 when unset. Added to `env.d.ts`, wrangler comments, `.dev.vars.example`, deploy-config known vars. - **Privacy** (`app/routes/privacy.tsx`): data / purpose / recipients / retention table. - **Skill**: setup line names ChatGPT/Codex connectors too, not just `claude mcp add`. - **Docs**: self-hosting "Listing in ChatGPT" section; CLAUDE.md routes table and tool notes. ## Tests New: userinfo (metadata, session bearer, PAT, 401s), apps challenge (token, unset, fallthrough), tool metadata invariants (titles, annotations, anonymous reach). Full suite: 853 passing, the 16 Node 26-only failures unchanged. ## Not in this PR (operator steps) Org identity verification, reviewer test account, listing assets, portal submission — listed on #103. Closes nothing on its own; #103 tracks the submission. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #100 / #101. ## EPUB type The stylesheet (`READING_CSS` in `app/shared/epub.ts`) now mirrors the editor's type from `docs/design-system.md` and `app.css`: a system sans body at 1.6 leading; bold sans headings at 1.875 / 1.5 / 1.25 em with the opening title larger and lighter (2.5em, weight 500), as the page does; IBM Plex Mono with the reader's mono as fallback for code at 0.875em on a light ground; an italic, rule-left blockquote; ruled tables. All relative units, so Kindle's and reMarkable's own text-size settings still apply. Georgia is gone. ## PDF Cloudflare Workers have no PDF renderer, so the PDF path is the browser's: **`GET /:id/print`** serves the same rendering as a standalone HTML page with the same stylesheet plus print rules (page margins, no breaks inside code blocks, tables, or images, no headings stranded at a page's foot), and `?print=1` opens the print dialog on load, where Save as PDF lives. **Print or save as PDF** is in the Share menu and the Send to device dialog; slugged ids are accepted like the EPUB route. A server-rendered `/:id.pdf` would need Cloudflare Browser Rendering (a paid-plan binding self-hosters may not have) and is left as a possible follow-up. ## Verified Typecheck and lint clean; 848 tests pass (the usual 16 Node 26 failures aside). New: the stylesheet's key rules and its presence in the EPUB; the printable page's title, page rules, resolved text, kept same-origin image paths, and the auto-print toggle; the route for bare and slugged ids and a missing document; the menu row. Not checked in a reader or a print preview. Worth one look at `/<id>/print?print=1` in a browser and the EPUB in Apple Books after deploy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #104 for the two optional items the ChatGPT plugin portal flagged. ## Output schemas - `ToolDef.output` is a zod shape built with `output()`: the success fields, each optional, plus `error`. The SDK validates `structuredContent` against `outputSchema` on every call and every tool can return `{ error }`, so the schema must admit both. - All 19 tools registered with `outputSchema`, including `create_document`, `list_documents`, `attach` in `agents/mcp.ts`. - Verified against a local dev server: `tools/list` shows 19/19 with schemas; `read_document`, `join`, `leave`, `comment` (success and `stale_anchor`), `reply`, `resolve_thread`, `suggest`, `events_list`, `events_poll`, and capability-denied `insert`/`list_documents` all pass validation. ## OpenID Connect - `GET /.well-known/openid-configuration`: the OAuth metadata plus `subject_types_supported`, `claims_supported`. Routed alongside `/.well-known/oauth-*`. - `scopes_supported`: `openid email profile` + `suggest comment write`. - The honoured OpenID scopes are stored on the auth code and refresh grant (`scope?`, optional so existing records read fine) and every token response's `scope` is those plus the granted caps, instead of `""`. - No ID token is issued; identity is via `/oauth/userinfo` (#104), which OpenAI's docs say is the required part. ## Tests Output-schema invariants (error + success sample for every tool), OIDC discovery agreement with OAuth metadata, `honouredScope`/`grantedScope`, and a full code → token → refresh → userinfo round trip carrying scope. Suite: 861 passing, the 16 Node 26-only failures unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Geist <agent@artifact.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Author
|
Closing because this branch is based on the fork's substantially diverged main branch; the focused PR belongs against arfct/vapor instead. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
chatgpt-app-submission.jsoncovering all 19 MCP toolslist_documentsas non-read-only because it may prune stale Registry enrollmentsValidation
git diff --checkpassednpm run typecheckcould not start becausenode_modules/.bin/wrangleris not installed in this checkout