Draft
Conversation
Zero-knowledge room collaboration primitives for Plannotator Live Rooms. No server, no UI — types, crypto, and helpers with 84 tests. - HKDF key derivation (auth, event, presence, admin keys) - HMAC verifier/proof generation with null-byte delimited inputs - AES-256-GCM encrypt/decrypt for event, presence, and snapshot channels - Canonical JSON for admin proof binding - Base64url encoding with padding normalization - Room URL parsing (client-only barrel) and construction - Annotation image stripping for V1 room compatibility - Server-safe vs client barrel exports For provenance purposes, this commit was AI assisted.
…(Slice 2) Cloudflare Worker with raw Durable Object for room.plannotator.ai. Room creation, WebSocket challenge-response auth, and hibernation-safe connection state. 49 tests across validation, auth proofs, and CORS. - POST /api/rooms creates room in DO with verifiers + encrypted snapshot - GET /ws/<roomId> upgrades to WebSocket with 30s challenge-response auth - WebSocket attachments survive DO hibernation (no in-memory Maps) - RoomStatus gains "expired" as first-class terminal state with lazy enforcement - Explicit CORS policy: ALLOW_LOCALHOST_ORIGINS flag, Vary: Origin, no implicit bypass - Room ID validation: exactly 22 base64url chars (matches generateRoomId) - Verifier validation: exactly 43 base64url chars (matches HMAC-SHA-256 output) - Snapshot size limit (1.5 MB) with clear 413 response - Auth response fields validated before proof verification - markExpired() purges sensitive material with try/catch and fail-closed semantics - Smoke test script for repeatable integration testing against wrangler dev For provenance purposes, this commit was AI assisted.
…e (Slice 3) Post-auth WebSocket behavior for Plannotator Live Rooms. Two clients can now exchange encrypted annotations in real time, reconnect with replay, and creator can lock/unlock/delete via challenge-response. - Event sequencing: server-assigned seq, per-key storage (event:NNNNNNNNNN), atomic metadata+event write, broadcast to all including sender for lastSeq tracking - Presence relay: broadcast to others only, no storage, no seq - Reconnect replay: snapshot + incremental based on lastSeq, handles fresh join, future claims, and (future) compaction boundary - Admin challenge-response: fresh per-command challenges stored in hibernation-safe WebSocket attachment, verifyAdminProof with canonicalJson command binding, clientId spoofing rejection, lifecycle enforcement - Lock/unlock/delete: room.status broadcast, locked rooms reject events with room.error but keep socket open, delete purges sensitive material and event keys with fail-closed tombstone write - Per-channel size limits: event 512 KB, presence 8 KB, snapshot 1.5 MB - Batch event-key purge (128 per call) and lazy expiry cleanup - RoomStatus.expired enforced across auth, event, admin, and upgrade paths - Live smoke script: 20 integration checks verified against wrangler dev For provenance purposes, this commit was AI assisted.
Adds packages/shared/collab/client-runtime/ (CollabRoomClient class, createRoom/joinRoom factories, apply-event reducer, backoff, opId dedupe, mock-websocket test harness) and packages/ui/hooks/useCollabRoom.ts as the React wrapper. Extends url.ts with admin-fragment parsing and buildAdminRoomUrl. Server-side validation and replay hardening land alongside: admin error-code contract comment, U+0000 replay cursor, cors/WS close constants shared between client and server. For provenance purposes, this commit was AI assisted.
Anchor commit for the Live Rooms V1 consolidation pass. Captures the working tree as-of Slice 5 + presence fixes + fake-presence harness tuning. This commit is NOT intended to ship; it exists so downstream consolidation phases have a stable rollback point via `git reset --hard` or the `v0.17.10-preconsolidation` tag. Baseline at snapshot time: - typecheck: green (shared, ai, server, ui.slice5, editor all clean). - tests: 1028 pass, 3 skip, 0 fail (bun run test). - branch: feat/collab at b78019e. Scope by category: - collab hooks: useCollabRoom (modified), useCollabRoomSession, usePresenceThrottle, useRoomAdminActions, useRoomMode, useAnnotationController, useAnnotationHighlightReconciler (all new) - collab components: packages/ui/components/collab/* (RoomPanel replaced by RoomHeaderControls + RoomMenu; AdminControls, ParticipantAvatars, RemoteCursorLayer, StartRoomModal, JoinRoomGate, ImageStripNotice, RoomStatusBadge, RoomAdminErrorToast) - collab utils: adminSecretStorage, presenceColor, roomIdentityConfirmed - room-service shell: entry.tsx, index.html, static/favicon.svg, vite.config.ts, tsconfig.browser.json, plus room-do.ts / handler.ts presence + admin updates - editor shell: AppRoot.tsx, RoomApp.tsx, roomIdentityHandoff.ts, env.d.ts, tsconfig.json, plus App.tsx start-room / admin flow - shared collab: validation.ts, redact-url.ts, strip-images update, client-runtime/client.ts presence + admin updates, types.ts with room.participant.left broadcast - dev tooling: apps/room-service/scripts/fake-presence.ts (fake multi-participant harness with continuous-lerp motion model); scripts/dev-live-room-local.sh - CI: minor workflow tweaks (release.yml, test.yml) - specs: v1.md and slice plans updated; v1-cursor-presence.md added DO NOT squash-merge. The consolidation PR preserves this commit as an anchor; revert destination for all downstream phases. For provenance purposes, this commit was AI assisted.
Hooks moved (9 files total — 5 source + 4 test): - useCollabRoom(+test) - useCollabRoomSession(+test) - usePresenceThrottle(+test) - useRoomAdminActions - useRoomMode(+test) Pure structural refactor. Zero behavior change. Behavioral surface preserved: - Every hook exports the same symbol with the same signature. - Every consumer reaches it through the wildcard package export (packages/ui/package.json `./hooks/*`), so no export-map change was needed. - tsconfig.slice5.json include paths updated to the new location so the scoped Slice-5 typecheck still covers these files. Consumers updated (6 import sites): - App.tsx: useRoomAdminActions + dynamic-import type for UseCollabRoomSessionReturn - AppRoot.tsx: useRoomMode - RoomApp.tsx: useCollabRoomSession, usePresenceThrottle - useAnnotationController.ts: type import for UseCollabRoomReturn - useAnnotationController.room.test.tsx: same Intra-group relative imports (sibling `./useCollabRoom`) stayed unchanged since all moved together. One cross-folder relative import (useRoomAdminActions.ts → components/collab/AdminControls) needed a bump to `../../` to account for the extra nesting. Review gate passed: - grep `ui/hooks/useCollabRoom*|useRoomAdminActions|useRoomMode|usePresenceThrottle|useCollabRoomSession` in packages/ and apps/: zero old-path hits. - bun run typecheck: clean across shared, ai, server, ui.slice5, editor. - bun run test: 1028 pass, 3 skip, 0 fail (unchanged from baseline). No barrel file added. packages/ui/package.json wildcard already resolves the new paths; introducing a collab/index.ts would add surface area for no gain. Part of the Live Rooms V1 consolidation pass (Phase 2 of 5). Anchor commit: 38c6c51 (tag v0.17.10-preconsolidation). For provenance purposes, this commit was AI assisted.
Net change to App.tsx: -250 / +73. Start-live-room workflow lives in
its own hook under packages/editor/hooks/collab/ alongside the other
creator-side room logic.
Moved out of App.tsx (~250 lines):
- Module-level `getRoomBaseUrl` helper (precedence: window override
→ VITE_ROOM_BASE_URL → production default).
- State: showStartRoomModal, startRoomInFlight, startRoomError,
startRoomAbortRef.
- Memo: imageAnnotationsToStrip (single-source-of-truth for the
modal notice AND the &stripped=N URL handoff count).
- Handlers: handleStartLiveRoom, handleCancelStartRoom,
handleConfirmStartRoom (including pre-open placeholder tab,
identity persistence, createRoom dynamic import, URL fragment
construction, popup-blocker fallback).
Stays in App.tsx:
- canStartLiveRoom gate (isApiMode && !roomModeActive).
- <StartRoomModal> JSX block — still reads getIdentity()/getPresenceColor()
for initial values and destructures everything else from the hook.
Invariants preserved (documented in the new hook's header):
- `window.open('', '_blank')` remains synchronous in the click path,
before any await. Popup blocker bypass depends on this.
- Four `await import(...)` calls stay dynamic for bundle splitting.
- Identity persistence (setCustomIdentity / setPresenceColor) happens
at the same lifecycle point inside handleConfirmStartRoom, not
shared with RoomApp.tsx (different flows, different timings).
- Hook does NOT import from App.tsx — hard line to prevent the
extraction from re-tangling.
Hook lives in packages/editor/hooks/collab/ (not packages/ui/) because
it depends on import.meta.env.VITE_ROOM_BASE_URL whose ambient typing
lives in packages/editor/env.d.ts, and the flow itself is editor-shell
workflow, not generic reusable UI.
Review gate:
- bun run typecheck: clean.
- bun run test: 1028 pass / 3 skip / 0 fail (unchanged from baseline).
- grep `getRoomBaseUrl|setCustomIdentity|setPresenceColor|stripRoomAnnotationImages`
in App.tsx: zero matches.
- grep `showStartRoomModal|handleConfirmStartRoom` in App.tsx: only
hook destructuring + modal JSX site (no stray state defs).
- grep `from ['"]..` in useStartLiveRoom.ts: no parent-relative imports
(hook does not reach into App.tsx).
- Sync-before-await invariant: window.open at line 155 precedes the
first `await import(...)` at line 171.
Depends on Phase 2 (collab hooks move) — born in its final home at
packages/editor/hooks/collab/, same pattern as the peer hooks moved
in e4ccdcd.
Part of the Live Rooms V1 consolidation pass (Phase 1 of 5).
Anchor commit: 38c6c51 (tag v0.17.10-preconsolidation).
For provenance purposes, this commit was AI assisted.
…heckboxOverrides (Phase 3)
Net change to App.tsx: -56 lines of controller-internal knowledge
(the pending/failed/pendingAdditions walking + ann-checkbox-<id>
prefix matching + blockId resolution) moved to a sibling pure
helper `derivePendingCheckboxBlockIds` alongside the consumer hook.
Design choice: sibling helper, not a second hook.
- Keeps the annotation-controller and checkbox-hook decoupled.
- Caller still owns the useMemo (with the correct deps) so the
"local mode returns undefined" conditional lives at the App.tsx
call site where roomModeActive is already in scope.
- Pure function, fully unit-testable in isolation.
Signature:
export function derivePendingCheckboxBlockIds(
controller: AnnotationController,
): ReadonlySet<string>;
Helper reads `controller.annotations` directly (same array App.tsx
destructures at line 147). Call-site in App.tsx drops from ~30 lines
(scan + resolve + walk) to a single useMemo:
const pendingCheckboxBlockIds = useMemo(
() => roomModeActive ? derivePendingCheckboxBlockIds(annotationController) : undefined,
[roomModeActive, controller.pending, controller.failed,
controller.pendingAdditions, controller.annotations],
);
Review gate:
- grep `ann-checkbox-` in App.tsx: one remaining match at line 1282
— that's the delete-handler's local-mode immediate-revert
(different concern, intentional, stays).
- bun run typecheck: clean.
- bun run test: 1028 pass / 3 skip / 0 fail.
- bun test --cwd packages/editor: 13/13 pass, covering useCheckboxOverrides.
The busy-gate and revert-gate semantics are unchanged; this is a
pure relocation. Manual smoke (rapid double-click on a room-mode
checkbox) preserved via the existing `pendingBlockIds` wiring
downstream — the hook consumes the Set identically to before.
Part of the Live Rooms V1 consolidation pass (Phase 3 of 5).
Anchor commit: 38c6c51 (tag v0.17.10-preconsolidation).
For provenance purposes, this commit was AI assisted.
…ase 5)
Three small fixes, no behavior change:
1. packages/ui/components/collab/RemoteCursorLayer.tsx
- `findScrollViewport` docstring previously claimed
LocalPresenceEmitter emits `document`-space cursors. Current
sender (RoomApp.tsx:551) emits `block`-space with a sticky
anchor. Rewrote the comment to reflect that all three
coordinate spaces are protocol-supported and the layer handles
them uniformly; the bundled UI's hot path is `block`.
- `case 'block':` inline comment previously said "our bundled UI
no longer emits this case." It does. Comment rewritten to say
the bundled UI writes block-space with a sticky anchor, and
direct-agent senders that emit block still resolve here.
2. specs/v1-slice4-plan.md line 61
- "The smoke.ts reference incorrectly uses `eventKey` for both
channels" — no longer true. apps/room-service/scripts/smoke.ts
now encrypts presence with `presenceKey` (line 238) and events
with `eventKey` (line 211). Comment rewritten to describe the
correct architecture without the stale bug note.
3. apps/room-service/scripts/fake-presence.ts
- The hardcoded SWATCHES tuple was a 1:1 duplicate of
packages/ui/utils/presenceColor.ts's PRESENCE_SWATCHES. Replaced
with an import. presenceColor.ts is pure (no React / DOM /
ConfigStore per its own header), safe to pull into a Bun
script. Palette is now single-sourced — any UI-side swatch
change automatically propagates to fake participants.
Review gate:
- bun run typecheck: clean.
- bun run test: 1028 pass / 3 skip / 0 fail (baseline unchanged).
- Script load smoke: `bun run apps/room-service/scripts/fake-presence.ts`
exits with `Error: Missing --url. …` — confirms the palette
import resolved (if it hadn't, Bun would have surfaced a
module-not-found before parseArgs).
Root typecheck does NOT cover apps/room-service/scripts because the
room-service tsconfig excludes scripts/**. The script load smoke is
the explicit gate for Phase 5's import change.
Part of the Live Rooms V1 consolidation pass (Phase 5 of 5).
Anchor commit: 38c6c51 (tag v0.17.10-preconsolidation).
For provenance purposes, this commit was AI assisted.
Eliminates the last duplicated literal set between client and server:
both sides now reference a single shared tuple in
`packages/shared/collab/constants.ts`, enforced by a contract test
that iterates every code and asserts runtime admin-scoped routing.
Shared source of truth (packages/shared/collab/constants.ts):
- `AdminErrorCode` const namespace with 11 keyed codes.
- `AdminErrorCode` type = string-literal union derived from the
namespace values.
- `ADMIN_ERROR_CODES` readonly tuple derived from Object.values().
- Documented add-a-code workflow: "Add a key; use it via
sendAdminError; the tuple and runtime Set derive automatically;
contract test enforces end-to-end routing."
Server (apps/room-service/core/room-do.ts):
- Private `sendAdminError(ws, code: AdminErrorCode, message)` wrapper
introduced. 12 admin-path call sites rewritten from raw literal
strings to `AdminErrorCode.<Key>`. The wrapper's type narrow on
`code` means any future typo or non-admin code surfaces as a
compile error at the call site, not as silent drift.
- Non-admin paths (`validation_error`, `room_locked`,
`event_persist_failed`) keep using raw `sendError` — they're
event-channel errors and are explicitly excluded from the admin
contract.
Client (packages/shared/collab/client-runtime/client.ts):
- `ADMIN_SCOPED_ERROR_CODES` Set now derived from `ADMIN_ERROR_CODES`
tuple (`new Set<string>(ADMIN_ERROR_CODES)`), replacing the
hardcoded 11-literal duplicate. Kept as `ReadonlySet<string>`
(not `Set<AdminErrorCode>`) because .has() is called on wire-string
input — forward-compatibility with unknown future codes is
intentional, they fall through as non-admin.
Contract test (client-runtime/client.test.ts):
- New behavior test iterates every entry in `ADMIN_ERROR_CODES`,
sets up a pending admin command, injects a server error with that
code, asserts the promise rejects with AdminRejectedError within
500ms (not at 5s admin timeout). This catches:
* Adding a tuple entry without wiring it to the runtime Set.
* Any regression in the admin-scoped dispatch path.
* Typos that would make a code silently route as mutation.
Audit anchor (from plan's snapshot action):
- 12 admin-path sendError sites confirmed in room-do.ts (matches
plan expectation: 11 distinct codes with `invalid_state` emitted
from 3 sites — lock/unlock/delete guards).
- Every code previously in client.ts's literal Set is present in
the new tuple. No silent drops, no dead handlers.
Review gate:
- grep admin-code literals (`'admin_validation_error'` etc.) in
client.ts + room-do.ts: zero matches. Only legit occurrences are
in constants.ts (definition) and test files (mock wire data).
- grep `this.sendError(ws, '<admin-code>'` in room-do.ts: zero —
every admin path goes through `sendAdminError`.
- bun run typecheck: clean (root + room-service).
- bun run test: 1029 pass / 3 skip / 0 fail (up from 1028 — one
new contract test).
- Targeted: client.test.ts 76 pass (up from 75).
Part of the Live Rooms V1 consolidation pass (Phase 4 of 5 — final).
Anchor commit: 38c6c51 (tag v0.17.10-preconsolidation).
For provenance purposes, this commit was AI assisted.
…pattern
Two related visual bugs on the room origin addressed in one commit:
1. ThemeProvider lived inside `<App>` (App.tsx:1606), so the
pre-join screens rendered by `<RoomApp>` — `<JoinRoomGate>` and
the room-deleted/expired terminal screens — showed up BEFORE
the theme class was applied to `<html>`. Every theme-token
Tailwind class (`bg-background`, `bg-card`, `text-muted-foreground`,
`border-border`, `bg-foreground text-background` on the primary
submit) resolved to empty `var(--*)` lookups, so the gate looked
unstyled (black text on white, no visible borders, no depth).
Hoist `ThemeProvider` to wrap `AppRoot` — it now encloses all
three mode-fork branches (local, invalid-room terminal, and
room mode including the gate). Removed the now-duplicate wrapper
inside `<App>` since every entry point (apps/hook, apps/portal,
apps/room-service) mounts AppRoot as the default export.
The `invalid-room` terminal screen in AppRoot itself benefits
too — its `bg-background` and `text-muted-foreground` classes
now resolve correctly on the public room origin.
2. StartRoomModal and JoinRoomGate had drifted from the canonical
Plannotator dialog pattern that ConfirmDialog, Settings,
ExportModal, AISetupDialog, PermissionModeSetup, etc. all share.
The review showed the backdrop dim felt too weak on Start and
the Join gate looked inconsistent with every other dialog in
the product. Aligned both:
Backdrop (was hand-rolled, now matches the 8-dialog canon):
fixed inset-0 z-[100] flex items-center justify-center
bg-background/80 backdrop-blur-sm p-4
Content card (was `bg-background shadow-lg` or `bg-card border`
with no shadow, now matches):
bg-card border border-border rounded-xl shadow-2xl
Net effect:
- Start: proper theme-tinted 80% backdrop + blur instead of flat
`bg-black/40`; card now has depth via shadow-2xl and an explicit
border (previously `bg-background` + `shadow-lg` only).
- Join: same backdrop + card treatment as every other dialog;
no longer reads as a raw form on a blank page.
Accessibility gaps (missing `role="dialog"`, `aria-modal`, focus
trap, Esc handling, hover states on swatch + primary buttons) are
NOT addressed here — those require either hand-rolled ARIA + focus
plumbing per dialog, or a Radix Dialog migration across all
Plannotator dialogs. Tracked separately; the theme + pattern fix
above doesn't depend on either.
Review gate:
- bun run typecheck: clean.
- bun run test: 1029 pass / 3 skip / 0 fail (unchanged).
- bun run --cwd apps/room-service build:shell: green.
For provenance purposes, this commit was AI assisted.
First commit of the Slice 6a agent direct-client work. Creates the `apps/collab-agent/` workspace package with a minimal CLI skeleton whose only job right now is to verify the dependency graph resolves cleanly under Bun. Layout: - apps/collab-agent/package.json — workspace, deps on shared + ui only - apps/collab-agent/tsconfig.json — Bun target, strict mode - apps/collab-agent/index.ts — dispatcher skeleton with --help - apps/collab-agent/subcommands/ — empty dir (populated in Phases 3/5/6) Import smoke: index.ts eagerly references `parseRoomUrl` from the shared client barrel, `parseMarkdownToBlocks` from `@plannotator/ui/utils/parser`, and `hashNameToSwatch` + `PRESENCE_SWATCHES` from `@plannotator/ui/utils/presenceColor`. None of these modules pull ConfigStore, React, or DOM — verified by the skeleton running `bun run apps/collab-agent/index.ts --help` with exit 0 and no resolution errors. This is the Phase 1 gate from the plan: if any utility grew non-pure imports, Bun would surface it on load here before any subcommand code lands. Package.json deliberately omits `@plannotator/ui` from the avoid list — we import specific utils only. `packages/ui/utils/identity.ts` stays untouched; the agent-identity detector goes into a new pure module in Phase 2. No subcommands wired yet. CLI returns exit 2 for unknown subcommands. --help prints the planned subcommand list so the interface shape is visible. Review gate: - bun run apps/collab-agent/index.ts --help → exit 0, prints help - bunx tsc --noEmit (package-local) → clean - bun run typecheck (root) → clean - bun run test → 1029 pass / 3 skip / 0 fail (unchanged) Part of Slice 6a (Phase 1 of 8). Anchor: v0.17.10-preslice6a. For provenance purposes, this commit was AI assisted.
…rtbeat (Phase 2)
Three primitives the subcommands in later phases will rely on:
1. `packages/ui/utils/agentIdentity.ts` — NEW pure module (no
imports) with:
- `AGENT_TYPES` / `AgentType` — canonical set {claude, codex,
opencode, junie, other}.
- `isAgentIdentity(name)` — true when `name` ends in
`-agent-<known-type>`. Case-sensitive so human names
containing "Agent" don't false-positive.
- `getAgentType(name)` — returns the type for later UI
tooltip/icon choices.
- `constructAgentIdentity({ user, type })` — single source of
truth for the `<user>-agent-<type>` format. Normalises
`user` to lowercase-trimmed and validates charset. Throws
`InvalidAgentIdentityError` on malformed input.
Deliberately NOT placed next to `packages/ui/utils/identity.ts`,
which imports `../config` (ConfigStore with cookies +
server-sync). The agent CLI imports this new file directly
and never touches ConfigStore.
2. `apps/collab-agent/identity.ts` — CLI-side identity helpers:
- Re-exports the pure primitives for ergonomic import.
- `isAgentType(v)` — argv type guard.
- `stripAdminFragment(url)` — removes `admin=<secret>` from the
fragment while preserving `key=<secret>` and other params.
Returns `{ url, stripped }` so the CLI can print a one-shot
warning when an admin link was given. Hard default; NO
`--as-admin` opt-in in V1 (agents are not admins, adding the
flag without a use case is footgun creation per the plan).
3. `apps/collab-agent/heartbeat.ts` — presence heartbeat manager.
`startHeartbeat(client, initial)` returns an `{ update, stop }`
handle; the caller sends the initial presence themselves, then
the manager re-emits the last-known presence every 10 s (3×
headroom under the 30s `PRESENCE_TTL_MS`). Swallows send
failures (presence is lossy by protocol contract) and no-ops
during non-authenticated windows.
This solves the "agent vanishes from the observer while
thinking" problem without any change to receiver-side TTL
logic: the agent's cursor stays fresh because the agent keeps
re-asserting it.
Test coverage:
- `packages/ui/utils/agentIdentity.test.ts` — 12 tests:
detection, construction round-trip, case sensitivity,
invalid-user / invalid-type rejection, null/undefined handling.
- `apps/collab-agent/identity.test.ts` — 7 tests: admin-fragment
strip with and without other params, no-fragment and no-admin
passthrough, argv-type guard.
No heartbeat unit test yet. The manager's contract is "re-send
on a timer while authenticated"; it's exercised end-to-end in
Phase 3's `join` subcommand against a live local room. Adding a
timer-mock unit test now would be ceremony over insight.
Review gate:
- bun run typecheck (root) — clean.
- bunx tsc --noEmit in apps/collab-agent — clean.
- bun test (targeted) — 19 new pass, 0 fail.
- bun run test (full suite) — 1048 pass / 3 skip / 0 fail
(+19 from 1029 Phase 1 baseline).
Part of Slice 6a (Phase 2 of 8). Anchor: v0.17.10-preslice6a.
For provenance purposes, this commit was AI assisted.
…ce (Phase 3)
The agent CLI now has a real dispatcher and four read-oriented
subcommands. Each is a thin wrapper over `CollabRoomClient` with
shared helpers for the identical "parse argv → strip admin →
construct identity → joinRoom → emit presence" boilerplate.
New files:
- apps/collab-agent/subcommands/_lib.ts
Shared helpers: parseCommonArgs (--url/--user/--type), flag
readers (string/bool/number), openAgentSession (strips
admin, constructs identity, joinRoom with autoConnect),
awaitInitialSnapshot (10s timeout), wireSignalShutdown.
`UsageError` class bubbles up to the dispatcher for
consistent argv-error handling.
- apps/collab-agent/subcommands/join.ts
Connect, await snapshot, emit initial presence, start 10s
heartbeat, stream room.event / room.presence / room.status /
room.error to stdout as NDJSON until SIGINT.
- apps/collab-agent/subcommands/read-plan.ts
One-shot. `--with-block-ids` prefixes each block with
`[block:<id>]` using the same `parseMarkdownToBlocks` the
browser calls, so ids round-trip to what the observer sees
in the DOM.
- apps/collab-agent/subcommands/read-annotations.ts
One-shot. Prints the annotations array as pretty JSON.
- apps/collab-agent/subcommands/read-presence.ts
One-shot with a 2s settle delay so inbound presence can
land before we print. Prints `remotePresence` as JSON with
a stderr banner clarifying "recent presence, NOT a
participant roster" — connected-but-idle peers won't
appear.
index.ts rewritten from Phase 1 skeleton to a real dispatcher.
Exit codes: 0 success, 1 runtime error, 2 usage error.
Root package.json gains `agent:run` convenience script.
Key design decisions (from the plan):
- `join` emits an initial presence on connect AND runs a 10s
heartbeat — the V1 protocol has no roster broadcast, peers
only appear via received presence, and the observer's 30s
TTL sweeps idle cursors. Heartbeat at 3× headroom keeps the
agent visible while it's "thinking".
- Admin-fragment strip is applied every subcommand through
openAgentSession. Agents never get admin capability.
- Block ids derive from the SAME `parseMarkdownToBlocks` the
browser uses, so an agent's `--block <id>` (Phase 5) targets
the same block the observer renders.
- One-shot subcommands emit presence once (visible flash)
without heartbeat — the process exits before the TTL matters.
- `read-presence` deliberately-named and banner-labeled to not
lie about what it returns.
Review gate:
- bunx tsc --noEmit in apps/collab-agent — clean.
- bun run typecheck (root) — clean.
- bun run test — 1048 pass / 3 skip / 0 fail (unchanged).
- bun run agent:run --help — prints usage, exit 0.
- bun run apps/collab-agent/index.ts read-plan (no --url) —
prints "Missing --url", exit 2.
Manual end-to-end smoke against a live local room is deferred
to the full Slice 6a verification pass (Phase 8 final).
Part of Slice 6a (Phase 3 of 8). Anchor: v0.17.10-preslice6a.
For provenance purposes, this commit was AI assisted.
Minimal UI allowance so observers can tell agent peers apart from
human peers. Detection is purely identity-based via
`isAgentIdentity` from the pure `packages/ui/utils/agentIdentity.ts`
module (added in Phase 2). No protocol change; no new fields on
PresenceState.
Two surfaces updated:
1. `packages/ui/components/collab/ParticipantAvatars.tsx`
- Added `isAgent` + `agentType` derivation per participant.
- Agent avatars get a small `⚙` marker pinned to the bottom-
right corner of the chip, styled with theme tokens so it
reads on both light and dark themes.
- Tooltip on agents reads `<name> (agent · <type>)`; human
tooltips unchanged.
- New `data-participant-kind="agent"|"human"` attribute for
test assertions and any future selector work.
2. `packages/ui/components/collab/RemoteCursorLayer.tsx`
- `isAgentIdentity(p?.user?.name)` passed to each
`<RemoteCursor>` instance.
- Onscreen cursor label prepends `⚙ ` when the peer is an
agent. Same color, same position.
- Offscreen edge pill (shown when the cursor is outside the
viewport) also prepends `⚙ ` so the marker persists in both
render modes.
- New `data-client-kind="agent"|"human"` attribute on the
cursor node for test/debug inspection.
Agent cursor persistence is handled separately by the agent-side
heartbeat (Phase 2) — the receiver's TTL sweep is unchanged. An
agent that just emits presence once and goes quiet still
disappears after 30s unless the agent keeps its heartbeat
running, which the `join` / `demo` subcommands do.
Test coverage:
- `ParticipantAvatars.test.tsx` gains one case asserting the
marker renders on agent identities and does NOT render on
humans; tooltip text is also asserted.
- `RemoteCursorLayer` doesn't have an existing test file (happy-
dom + rAF + getBoundingClientRect make it hard to unit-test
reliably). Marker is covered by manual smoke in the Slice 6a
final verification.
Review gate:
- bun run typecheck — clean.
- bun run test — 1049 pass / 3 skip / 0 fail (+1 new avatar
agent-marker case from 1048 Phase 3 baseline).
Part of Slice 6a (Phase 4 of 8). Anchor: v0.17.10-preslice6a.
For provenance purposes, this commit was AI assisted.
…(Phase 5)
Adds the `comment` subcommand so an agent can post block-level
COMMENT annotations to a room.
Usage:
--block <blockId> target block id (from read-plan --with-block-ids)
--text <body> comment body
--list-blocks print available blocks as JSON and exit without posting
Targeting is block-level only in V1 by plan decision: startOffset=0,
endOffset=block.content.length, originalText=block.content. This
side-steps the `/api/external-annotations` selection-accuracy issue
that plagues inline-text matching against rendered DOM. The agent
doesn't have to "extract" any text; it just references the block
whose content it wants to comment on.
Block ids are derived from `parseMarkdownToBlocks` — the same call
the browser makes on the same markdown — so the --block id the
agent passes is byte-identical to what the observer renders in the
DOM.
The command is echo-driven, not send-driven: `sendAnnotationAdd`
resolves when the op is queued, but we wait for the `state` event
showing our annotation id in canonical state (echoed) or a
mutation-scoped `lastError` (server rejected the op). 10s timeout
so a silent failure doesn't hang the CLI.
Exit codes:
0 annotation echoed back from server (success)
1 snapshot timeout, echo timeout, unknown --block id, or
server-side mutation rejection (e.g. room locked)
2 argv/usage error (propagated from dispatcher)
Annotation ids are `ann-agent-<uuid>` — distinct prefix makes
agent-posted rows identifiable in logs and exports; the server
treats the id as opaque so the prefix is purely a readability
convention.
Agent identity flows through `author` on the annotation. An
observer sees the annotation attributed to e.g.
`swift-falcon-tater-agent-claude` and (per Phase 4) an avatar /
cursor marker indicating an agent.
Review gate:
- bunx tsc --noEmit in apps/collab-agent — clean.
- bun run typecheck (root) — clean.
- bun run test — 1049 pass / 3 skip / 0 fail (unchanged from Phase 4).
- `bun run apps/collab-agent/index.ts comment` (no flags) —
prints "Missing --url", exit 2.
Manual end-to-end smoke against a live local room is deferred to
the Slice 6a final verification after Phase 6 lands.
Part of Slice 6a (Phase 5 of 8). Anchor: v0.17.10-preslice6a.
For provenance purposes, this commit was AI assisted.
…cursor (Phase 6)
Adds the `demo` subcommand that gives a visible "an agent is
participating" narrative for observers watching a browser tab. It
walks the plan's heading blocks in document order, anchors the
agent's cursor to each heading via `coordinateSpace: 'block'`
presence (so the observer's RemoteCursorLayer resolves against
its own rendered block rect, not a fake y-coordinate), pauses, and
posts a block-level comment at each stop.
Args:
--duration <sec> total wall time, pauses scaled to fit
(default 120s; clamped per-heading to
3–6s so dense plans stay readable and
sparse plans don't camp)
--comment-template <str> comment body per heading; {heading}
and {level} get substituted (default
"[demo] reviewing {heading}")
--dry-run move the cursor + heartbeat without
posting — useful for demoing the
visual presence path in isolation
Design decisions locked in from the plan:
- Cursor is block-space, not rank × 200px document-y. Block
anchoring is robust to the observer's viewport; approximate
document coords would clamp to the edge indicator on narrow
viewports or miss short sections on tall ones. The protocol
already supports this; we just use it.
- Comments are fire-and-forget within the demo loop.
`sendAnnotationAdd` resolves on SEND, not on echo. Awaiting
echo per heading would stall the visible cursor animation
noticeably. A silent mutation rejection still surfaces via
the `error` event stream in `join` logs if the user runs
demo while tailing join output, which is the expected demo
posture.
- Heartbeat runs for the demo duration so the agent avatar
stays in the observer's avatar row between cursor moves
(otherwise the 3–6s pauses would sometimes cross the 30s TTL
boundary in aggregate over long durations, but only
theoretically — heartbeat is belt-and-braces).
- NDJSON progress events on stdout (`demo.start`, `demo.visit`
per heading, `demo.end`) so an invoking agent can see what
the demo is doing programmatically.
Exit codes:
0 demo completed
1 snapshot timeout, no headings in plan, unexpected runtime
error during walk
2 argv/usage error
Review gate:
- bunx tsc --noEmit in apps/collab-agent — clean.
- bun run typecheck (root) — clean.
- bun run test — 1049 pass / 3 skip / 0 fail (unchanged).
- Help text now lists all six subcommands.
Manual smoke (run against a live local room with a browser
observer) is deferred to the final Slice 6a verification in
Phase 7/8.
Part of Slice 6a (Phase 6 of 8). Anchor: v0.17.10-preslice6a.
For provenance purposes, this commit was AI assisted.
Two docs: - AGENT_INSTRUCTIONS.md — prose for an AI agent's prompt. Covers identity convention, the six subcommands, block-level-only comment targeting, rules (no admin, no images, server- authoritative mutation echo), and troubleshooting. Explicitly tells agents not to attempt inline text-range targeting and to put specific wording in the comment body rather than as an anchor — this steers around the selection-accuracy issue in /api/external-annotations without changing any server code. - README.md — human-readable CLI usage. Subcommand table, common-flag reference, exit-code table, admin-URL strip note, local-dev end-to-end recipe (dev:live-room + agent:run join), and a short "internals" section pointing at the reused CollabRoomClient primitives for anyone extending the tool. No code or test changes. Part of Slice 6a (Phase 7 of 8). Anchor: v0.17.10-preslice6a. For provenance purposes, this commit was AI assisted.
Measurable characterization of the current
`/api/external-annotations` text matcher
(`packages/ui/hooks/useAnnotationHighlighter.ts:173-236`). Twelve
scenarios covering the drift shapes the user has observed:
trailing/leading whitespace, extra / tab / non-breaking spaces,
markdown syntax drift (bold / italic), cross-block spans,
repeated-substring ambiguity, smart quotes, exact whole-block
content.
This is NOT a fix. It's a regression fence:
- Current classification recorded per case.
- Summary count (`found=4 missed=8 ambiguous=1`) surfaces in test
output so any fix effort sees the number drop.
- Any future change to the matcher that flips behavior in a case
surfaces as a test failure pointing at the row, forcing the
author to update the classification or the matcher
intentionally.
Files:
- packages/ui/hooks/useAnnotationHighlighter.matrix.test.tsx
13 tests total: 12 per-case characterization + 1 summary
count invariant. Matcher logic is vendored verbatim from the
source closure (line 173-236) because the closure isn't
exported; in-sync-with-source comment marks the dependency.
- specs/v1-selection-accuracy.md
Human-readable write-up. Bucket the 8 misses into three bug
families (whitespace drift, markdown drift, block-boundary
spans), sketch fix candidates per family, flag the ambiguity
case as a separate product decision, point at the candidate
fix site, and give a sequencing suggestion for any slice
that picks this up.
The Slice 6a agent CLI sidesteps this entire surface by using
block-level targeting for comments — it ships
`originalText = block.content` (always exactly one of the DOM
strings), so the matcher's strictness doesn't bite. Agents
continuing to use `/api/external-annotations` for inline
targeting ARE exposed; the AGENT_INSTRUCTIONS.md from Phase 7
tells them to put specific wording in the comment body rather
than as an anchor.
Review gate:
- bun run typecheck (root) — clean.
- bun run test — 1062 pass / 3 skip / 0 fail (+13 matrix tests
from Phase 7's 1049 baseline).
- bun test --cwd packages/ui hooks/useAnnotationHighlighter.matrix.test.tsx
— 13 pass, prints the summary line
"[selection-accuracy matrix] found=4 missed=8 ambiguous=1".
Part of Slice 6a (Phase 8 of 8 — FINAL). Anchor:
v0.17.10-preslice6a.
For provenance purposes, this commit was AI assisted.
…omization Two follow-ups to the Slice 6a `demo` subcommand, landing together because both touch the per-heading presence + annotation-post loop. **Echo confirmation (reviewer high-severity finding).** Prior demo.ts issued `void client.sendAnnotationAdd([annotation])` — fire-and-forget — which meant demo could exit 0 while no comments actually landed. `sendAnnotationAdd`'s own promise resolves when queued for send, not when echoed (see `packages/shared/collab/client-runtime/client.ts:493`), and the runtime's send path rejects on disconnected or non-active rooms (same file:1435). With the promise discarded, those rejections were either unhandled or silently swallowed. The commit message claim that "failures surface through the error stream" was also wrong — demo never subscribed to the `error` event. Fix: - Extract the echo-confirmation pattern (previously inline in `comment.ts`) into a shared helper `awaitAnnotationEcho` in `apps/collab-agent/subcommands/_lib.ts`. Resolves when the annotation appears in canonical state, rejects on a mutation-scoped lastError advance or timeout. The helper's header doc pins the "subscribe before send" ordering contract so future callers don't reintroduce a race. - Refactor `comment.ts` to use the helper (DRY, ~30 lines out). - `demo.ts` now subscribes via the helper before each `sendAnnotationAdd`, awaits the echo with a tighter 5s timeout (vs 10s in `comment.ts`, because demo is time-boxed and a locked-room reject should fall through fast), collects per-heading failures into a `CommentFailure[]` array, keeps walking the tour so the observer still sees the cursor finish, and returns exit code 1 if any comment failed to land. The final `demo.end` NDJSON event now carries `headings`, `failed`, and `failures` so an invoking script can see what happened. **Cursor x/y randomization.** When multiple agents run demo against the same room, their cursor labels previously stacked at the exact `(x: 0, y: 0)` corner of each heading block — same pixel, same label, visually one agent. User testing flagged the case. demo.ts now picks integer-floored `x ∈ [20, 200)` and `y ∈ [0, 24)` per visit. Parallel agents land at different pixels; a single agent's cursor nudges around within the block rather than parking at the origin. Ranges are conservative — most block widths handle 200px horizontal without clipping, and when they don't the existing RemoteCursorLayer edge-indicator path takes over. Vertical range stays near the heading's text baseline. Review gate: - bun run typecheck — clean. - bun run test — 1072 pass / 3 skip / 0 fail. - Manual smoke: demo ran end-to-end against a live local room with --dry-run (cursor tour visible to the observer, clean disconnect at end, exit 0). Part of Slice 6a follow-up. Anchor: v0.17.10-preslice6a. For provenance purposes, this commit was AI assisted.
Adds the user-facing surface the user asked for: "Copy agent instructions" in the room dropdown, parallel to the existing "Copy agent instructions for external annotations" item that `/api/external-annotations` has at `PlanHeaderMenu.tsx:156`. The clipboard payload pre-fills THIS room's URL and the inviter's identity so an agent pasted into an LLM prompt has everything it needs to join in a single message — no "also, the URL is…" follow-ups. Files: - `packages/ui/utils/roomAgentInstructions.ts` (new) — builds the markdown payload. Same shape as `planAgentInstructions.ts`; tailored to the `apps/collab-agent/` CLI instead of /api/external-annotations. Covers identity, subcommand reference with runnable examples (read-plan, read-annotations, read-presence, comment, join, demo), block-level-only targeting rule, admin-strip guarantee, and troubleshooting. Crucially the URL and user identity are pre-filled — agents don't have to parse them out of prose. - `packages/ui/utils/agentIdentity.ts` — adds `toAgentUserSlug(name, fallback='participant')`. Normalizes any display name into a CLI-safe `--user` slug: trim → lowercase → non-[a-z0-9-] → `-` → collapse runs → trim leading/trailing. Fallback when slug is empty. This exists because `constructAgentIdentity` enforces a strict `/^[a-z0-9][a-z0-9-]*$/` charset, and human display names routinely carry spaces, uppercase, and punctuation — feeding raw names into templated CLI examples would have the shell split on spaces (silent truncation, e.g. `--user Michael Ramos` → agent joins as `michael-agent-claude`, dropping "Ramos") or throw `InvalidAgentIdentityError` if quoted. The builder now runs the user identity through this slug before templating. - `packages/ui/utils/agentIdentity.test.ts` — 10 new tests for `toAgentUserSlug`: space→dash, already-slug passthrough, trim, lowercase, punctuation/symbol replacement, collapse runs, strip edges, fallback on empty/all-punctuation, custom fallback, round-trip through `constructAgentIdentity`. - `packages/ui/components/collab/RoomMenu.tsx` — new "Copy agent instructions" menu item between "Copy consolidated feedback" and the admin section. Non-admin participants see it too (any participant can invite an agent on their behalf). New robot icon distinct from the existing link/copy icons. - `packages/ui/components/collab/RoomHeaderControls.tsx` — threads the new `onCopyAgentInstructions` prop through. - `packages/editor/App.tsx` — new `handleCopyRoomAgentInstructions` using the existing `copyToClipboardWithToast` helper. Reads `roomSession.joinUrl` (never the admin URL — defensive layering on top of the CLI's own `#admin=` strip) and `getIdentity()`, hands both to the builder, copies the result, toasts "Agent instructions copied". Review gate: - bun run typecheck — clean. - bun run test — 1072 pass / 3 skip / 0 fail (+10 new slug tests from 1062 baseline). - bun run --cwd apps/room-service build:shell — green. - Manual smoke against a live local room: menu item appears in the Room dropdown, click copies a markdown payload with the current room's URL and the user's slugged identity pre-filled; the payload's example commands are shell-safe and CLI-valid. Part of Slice 6a follow-up. Anchor: v0.17.10-preslice6a. For provenance purposes, this commit was AI assisted.
Needed so `bunx wrangler login` / `bunx wrangler deploy` work from the repo root without a separate global install. Already a transitive dep via apps/room-service; hoisting explicitly makes hackathon-style ad-hoc deploys reproducible from any checkout. For provenance purposes, this commit was AI assisted.
Reworks the clipboard payload so any agent receiving it kicks off
visible activity without waiting for the user to give a task. The
hackathon demo needs this — the presenter shares a link in front of
an audience and the room needs to show cursor + comments within
seconds, not after a back-and-forth.
New section "## What to do right now" sits between the URL block
and the reference material. It tells the agent:
- Run `demo` as the default first action (cursor tour + per-heading
comments; ~2 minutes of immediate visible activity with the
distinguishing `⚙` marker so the audience can tell bot vs human).
- Follow up with one or two thoughtful observations via `comment`
on substantive blocks — real opinions, not template text — as
the signal that the agent actually read the plan.
- Drop the default if the user has given a specific task.
The former standalone "## Demo mode" section becomes a flag
reference ("## Demo mode — flags reference") since the canonical
usage moved up-front. The three read-* examples moved under a new
"## Reference — reading the plan and peers" header so the
top-of-doc reads "identity → URL → ACTION → reference material".
No API change, no tests to update — this is pure prose in a
template string that's already covered by the surrounding UI
flow (click menu item, get string, paste into agent).
Review gate:
- bun run typecheck — clean.
- bun run test — 1072 pass / 3 skip / 0 fail (unchanged).
- bun run --cwd apps/room-service build:shell — green.
Next: rebuild shell + redeploy `plannotator-room-hack` so
participants hitting the deployed Worker also see the updated
instruction payload (not just the creator's local editor).
For provenance purposes, this commit was AI assisted.
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.
Status
Draft, not for merge. Opening this so the code is reviewable during the SF hackathon demo window. The room service will be deployed from this branch to
plannotator-room-hack.*.workers.devas a time-boxed preview; productionroom.plannotator.aiDNS move is a separate follow-up after the event.Summary
End-to-end Live Rooms V1 plus the Slice 6a agent direct-client CLI. Three cohesive waves of work stacked on
main.Wave 1 — Protocol & server (Slices 1–4)
packages/shared/collab— zero-knowledge room protocol: HKDF-derived keys, HMAC proofs, AES-GCM payload encryption, WebSocket transport contracts, canonical JSON for admin binding, URL helpers, image-stripping for room snapshots.apps/room-service— Cloudflare Worker + SQLite-backed Durable Object: room creation, event sequencing, admin lock/unlock/delete, lifecycle, static SPA shell served via ASSETS binding. Strict CSP on/c/:roomId.packages/shared/collab/client-runtime— browser/direct-agent client: auth handshake, snapshot decrypt, event subscription, mutation ops, presence, reconnect, admin command flow. 2709 lines of tests.Wave 2 — Consolidation pass
Post-Slice-5 cleanup (anchor
v0.17.10-preconsolidation): five mechanical phases plus today's ThemeProvider hoist + dialog pattern alignment. Every phase is bisect-friendly. Seepure-twirling-swing.mdplan trail for the protocol.Wave 3 — Slice 6a agent direct-client
apps/collab-agent— Bun CLI that lets an AI agent join a room as a first-class peer:join,read-plan(with block-id markers),read-annotations,read-presence,comment(block-level targeting),demo(scripted heading tour with per-heading echo confirmation + randomized cursor x/y so parallel agents don't stack).<user>-agent-<type>with CLI-side admin-URL strip guard.packages/ui/utils/agentIdentity.tsholds the pure helpers (slug normalization, identity construction/detection) — deliberately NOT added toidentity.tswhich imports ConfigStore.⚙marker on agent avatars + cursor labels so observers can distinguish bots from humans.packages/ui/hooks/useAnnotationHighlighter.matrix.test.tsx) documents 12 scenarios against the existing/api/external-annotationsmatcher. Not a fix — a measurement. Follow-up spec atspecs/v1-selection-accuracy.md.Deferred (documented non-goals)
/api/external-annotationsSSE → room-ops forwarding.POST /api/rooms(deployment hardening, tracked).useCollabRoomSessionmemo-stability verification (no observed bug; deferred with a concrete trigger in the consolidation plan's appendix).Test plan
bun run typecheckgreen across shared, ai, server, ui.slice5, editor.bun run test— 1072 pass / 3 skip / 0 fail.bun run --cwd apps/room-service build:shellgreen.bun run --cwd apps/hook buildgreen.Copy agent instructionspaste → agent CLI round-trip.