# Working on chopin Notes for whoever changes this next, human or otherwise. The readme is for people running it; this is for people editing it. ## Commands ```bash bun run dev # supervisor: Vite + server on one origin, Ctrl-C stops both bun test # no browser, no agent spawned bun run e2e # Chromium, PostgreSQL, builds the client first bun run types # every package, and e2e bun run ci # dprint check && oxlint && token checks bun run build # production client bun run start # serve the built client bun run docker:up # production image + PostgreSQL through the app Compose profile ``` `.github/workflows/ci.yml` runs the static/unit checks, browser suite, and production image build in three parallel jobs. The browser and container jobs build independently so a formatting mistake does not wait behind either one. A failed browser run uploads its report and traces. `bun run dev` needs a migrated PostgreSQL database, GitHub App configuration, and an OAuth-attempt encryption key. `AGENT=off` runs everything except the agent, which is what both suites use. The proxy architecture, remote HMR command, callback policy, and exact-host invariants live in [Remote development](docs/exe-dev.md). `Dockerfile` is one application image, not a web tier and an API tier: the build stage produces `apps/web/dist`, and the Bun server serves it on the same origin as `/api` and `/ws`. Its default command migrates before serving and runs as the image's unprivileged `bun` user. Compose keeps that service behind the `app` profile so `bun run db:up` continues to start only PostgreSQL for development. `bun run e2e` needs a browser once: `bun run e2e:browsers`. It builds the client, starts two temporary PostgreSQL services and two servers of its own — 8788, and 8789 with the injection flags on — then removes the databases. Having `bun run dev` open at the same time costs nothing. To iterate on one browser test without rebuilding the unchanged client: ```bash E2E_SKIP_BUILD=1 bun scripts/e2e.ts e2e/table.e2e.ts --grep "row limit" ``` The regular suite fakes only GitHub's network responses. OAuth state, process-local sessions, installation-aware repository authorization, channel routes, sockets and PostgreSQL persistence are the production implementations. ## Shape ``` packages/dialect 4.6k the MDX dialect and its Lexical schema packages/editor 10.5k the browser editor, cursors, the sidecar, agent marks packages/question 1.8k questionnaires: definition, shared answer, derivation packages/protocol 0.9k the wire, as types, plus the addressing rule packages/viewport 0.1k browser viewport geometry and event subscriptions apps/server 11.4k rooms, documents, questions, comments, the agent apps/web 1.5k the three panes e2e 1.3k the browser suite, and the servers it runs against scripts/dev.ts the development supervisor ``` Dependencies point one way: `apps` depend on `packages`, `packages` depend on `protocol`, and nothing depends on an app. `protocol` is types plus one runtime module — the rule for whether a message addresses the agent, which both ends need and must not disagree about. ## How a room works Each room holds a Yjs document with a **headless Lexical editor bound to it**. That mirror is the reason the server can validate an edit rather than relay bytes it cannot read: it turns opaque CRDT updates into a tree that can be projected back to canonical MDX with nobody connected. Client updates are grouped for 5ms, applied together, and only then acknowledged. Yjs cannot undo a transaction, so a batch that leaves the document invalid is not rolled back — the document is rebuilt from its last known-good state under a fresh epoch and everyone re-opens. Agent edits take a different path. They are staged on the parsed document, serialised, re-parsed, validated, and only then _reconciled_ into the live tree, so blocks the agent did not touch keep their Lexical identity and nobody loses a cursor to somebody else's edit. Accepted updates and sidecar state are committed through `StorageAdapter` before acknowledgement. A complete checkpoint retains canonical MDX, the Yjs epoch and document bytes; the ordered update journal after it is folded into a later checkpoint. ## Decisions, and why **The dialect is an allowlist.** Anything not described in `packages/dialect/src/dialect.ts` is rejected before it reaches a renderer. Plan content is parsed and rendered, never evaluated. **Repository authorization is the boundary.** The first invoking editor's process-local GitHub App user session owns the planner until reset. The SDK runs in empty mode with repository-scoped read tools, no host filesystem or shell, and permission callbacks that recheck the session, credential revision, ownership generation and installation repository access. **Ids are minted wherever a component is created**, client or server. A ULID has enough entropy that two editors cannot collide, so buying uniqueness with a round trip would only make a block appear later than the keystroke that asked for it. The agent's ids are minted server-side in `plan/edit.ts` because a model that copies a block it read copies the id with it. **The record owns an answer; the plan shows it.** A questionnaire's answer lives in the durable sidecar, and what appears in the MDX is a projection kept so the source reads correctly alone. An agent rewriting the prose around a decision cannot change the decision. **Resolution is two-phase.** Answering has to reach both the record and the document, and until both have happened nobody may be told it is final. A failed document write rolls the claim back and the questionnaire stays open. **The agent acts only when addressed** with `@ai`. Everything else is conversation, carried into its next turn as context. Without this, two people planning together cannot talk — "should we ask about auth first?" would start a turn. Not `@plan`: it is the most common noun in the product. **Accepting a comment is an address.** The `@ai` rule separates conversation from instruction, and a button press is already the latter — so accept starts a turn, through the same queue a message does. It writes a system line into the transcript, because an agent that begins editing for no visible reason is worse than a noisy log. Accepting also freezes the thread: it is what the room settled, so `edit_plan` cannot author or remove the `` it projects. **Anchors are a relative position plus a digest**, and the two do different jobs. The position survives edits around a block; the digest recovers it when the position cannot resolve — a move rebuilds a block's collaborative identity, and an epoch rotation discards the history the position was expressed in. A digest matching two blocks recovers neither. **A passage is the same idea one level finer.** A comment marks a phrase, not a block, so it carries the block anchors plus a pair of relative positions into the text and the quoted words. The positions make the highlight stretch as somebody types inside the phrase; the quote finds it again when they cannot resolve. Two occurrences equally near the recorded offset recover neither. **A client sends what it read, not where it thinks that is.** `comment:start` carries block indices and the selected text; the server finds the quote and mints every Yjs position itself. Finding it is the concurrency check, and a better one than a digest — it tests whether the sentence is still there, which is what matters, and a browser cannot compute a canonical block digest without re-serialising the document. The two ends must agree on which blocks the source addresses; they need not agree on the offset, so a divergence surfaces as a refusal rather than a comment landing on the wrong sentence. **A decision has one place in the plan, not two.** A question used to carry what it was about and what its answer produced as separate anchor sets, on the theory that they move independently. Asked to tell them apart, the agent anchored the first block of the result and called it the subject — in every record it ever wrote — so a resolved card offered two adjacent, identically labelled buttons that led to the same block. The subject was also never derived when a question was asked and never invalidated when the plan moved, so it sat permanently unreviewed: an inert link, and an entry in `anchors_pending` that no amount of anchoring could clear. A comment thread still keeps two, because there the halves have different authors and different shapes and neither can stand for the other. **The quote is the way back, not the card.** A sidecar card holds a reply box, an Accept and a Dismiss, so making the whole of it a link would mean deciding on every click whether the reader meant the link or the control they actually hit. The quotation becomes a button when the thread has somewhere to send one and stays a blockquote when it does not, which is the rule a question's answer already followed. It matters most for an accepted comment: a `` draws nothing in the prose, so the pane is the whole of where the decision can be seen and the quote is the only route to what it produced. **A table's header row is furniture, not a row.** GFM has exactly one header and it is always the first, so nothing in the document marks it as one — being first is the whole of what makes it the header, and the column alignment hangs off its cells. A drag that moved it would therefore not reorder the table so much as change what it claims: two rows would swap meaning at the next save and the alignment would follow whichever stopped being the header. So the header cannot be dragged, cannot be dropped onto, cannot be removed, and nothing can be inserted above it. Its bar is still drawn, because a rail with a gap where a row plainly is reads as a bug. The last _body_ row can go, though — `| a |\n| - |\n` is well formed and round-trips, and refusing it would mean the only way to empty a table is to delete it. **The rails are measured, because a `` cannot contain a `
`.** Every other widget in the editor puts its chrome in a `data-plan-chrome` slot the node reserves and Lexical is told to leave alone. A table has nowhere to put one: the browser hoists a stray div straight back out. So the choice was between subclassing `TableNode` and measuring cell rectangles, and measuring is what the selection bubble already does. The grips are one lane and the buttons another, with nothing overlapping — a cross in the middle of a grip is exactly where a drag is most naturally begun, and it swallowed the gesture it was drawn beside. Intermittently, too, since whether it had become live yet depended on a re-render landing between the pointer arriving and the button going down. **A limit is enforced where the button is, not where the document is.** A table past `MAX_TABLE_ROWS` or `MAX_TABLE_COLUMNS` applies locally, syncs cleanly and is then refused by the server — which cannot undo a Yjs transaction, so it rebuilds the room under a fresh epoch and everyone in it loses their undo and their cursors. `table/shape.ts` is asked before anything is created, so the cost of reaching a hundred rows is a grey button rather than everybody's session. The same argument as `toolbar/url.ts`, one order of magnitude worse. **A rendered fence is a second reading, never the document.** `@pierre/diffs` colours code and draws patches, and it is asked for nothing else: it has an edit mode, and turning it on would put a character model of its own beside the one the room is collaborating in, so that two people typing would each be correcting a copy. The source stays where it was, as Lexical text children under Yjs, directly editable underneath what was drawn from it. It arrives on demand, because Shiki's grammars outweigh everything else the editor loads and a plan with no code in it should pay none of that. **A fence with no language draws nothing**, and that is the whole of what makes the control worth having. Uncoloured text above the same uncoloured text is two of the same thing with a caret in only one of them; naming the language is what turns the upper one into something the lower one is not. So `/code` opens a fence with a selector and no preview, and choosing from it is an edit to the fence like any other — it syncs, it saves, and everybody else's copy is coloured too. **A patch is repaired on the way to the renderer, and nowhere else.** Nobody writes a hunk header correctly, least of all a model quoting a change it is proposing, and the counts in one are how a parser knows where the hunk ends — so a count that is too low truncates the change silently, which is the one outcome worth any amount of trouble to avoid. `widgets/code.ts` recounts them from the lines that are actually there, reads a stripped blank line as the unchanged line it was, and takes off the `a/` and `b/` that the renderer only removes for git's own form. What comes out is handed over and thrown away; the fence keeps every byte the author wrote. Anything still malformed is refused by the parser and drawn as coloured text, which is the honest rendering of a fragment that was never a patch. **Enter is a newline in a fence, and twice over is the way out.** Lexical asks a block to make its own successor and a code block returns null, which is why Enter inside one did nothing whatsoever — no newline, and no way past a fence sitting at the end of a plan. `widgets/enter.tsx` answers `INSERT_PARAGRAPH_COMMAND` rather than the keystroke, because that is what rich text turns an unshifted Enter into and what everything else meaning "start a new block" dispatches. The blank line that asked to leave goes with the request, or every fence anybody escaped would keep a trailing empty line nobody typed. **A hover asks; a click sends.** Both leave the same wash, because they are the same fact — this is the prose that card refers to — and the difference is how long it is owed. A hover is over when the pointer moves; a click has put the reader somewhere they were not looking, so the mark is pinned for five seconds after the pointer has gone, or they arrive at a block with nothing to say which one it was. Pointing at another card in the meantime borrows the wash and gives it back, which is what makes a detour a detour. **There is one pin, in `marks.ts`, not one per store.** A reader has one pointer, so going to a comment has to put out the question they went to before it — the same argument that already put the registry there. Whether a walk through several places continues is asked of the pin rather than announced by it: nothing needs redrawing when it lapses, since the lapse redraws itself, and a callback fired on the way to replacing a pin would reach a store in the middle of walking and wipe the step it had just taken. **Hard-fail at boot.** Missing GitHub App configuration, an unusable database, or a second writer lease are all detectable before serving traffic. `AGENT=off` is the deliberate way to run without the planner. **A mark for an agent edit waits to be seen.** The agent writes wherever it likes, including several screens away from whoever is reading, so a mark that spent its few seconds off screen would have told nobody anything. Nothing starts counting down until it has been in the viewport, which makes it an unread marker rather than a flash — and unread markers do not expire on a clock, so an unseen one waits indefinitely and the set is bounded by a cap instead. Once seen the clock runs whether or not the mark is still in view: two states and one timer, so nothing can go dark and light up again on a later scroll, which would read as a second edit that never happened. **Those marks take no space and add no element.** An agent editing below the fold must not shift the sentence somebody is in the middle of typing, and a mark expiring a few seconds later must not shift it back. What the agent removed has no element at all, which is why a hole is drawn in `box-shadow` on the edge of the block still beside it and why the side is part of its address — and why what was removed can only be read in the list behind the chips. **The agent's cursor and the agent's marks disagree about time, on purpose.** The cursor is presence: it says the agent is working _here, now_, so it is broadcast the moment an edit lands, it is not held back for anyone, and it goes shortly after the turn ends. A mark is a record: it waits until it has been read and may sit unseen indefinitely. So somebody who comes back after a long turn sees marks and no cursor, and somebody watching sees both — which looks like an inconsistency until you notice they answer different questions. The cursor cannot replace the marks, because a cursor off screen is simply not there, and that is the case the marks exist for. **It sits at the end of what the agent wrote, and says its name for longer.** An anchor names a block from its start, which is the stable end of one; a cursor is where an edit _finished_, so `room.endOf` is its own function rather than a flag on `anchorAt`. A caret at the top of a new block points at the first thing the reader already knows and looks like something about to be typed. The label keeps the same distinction: a person's caret is one of several and its owner is watching it, so a second names them and that is enough, while the agent's turns up unannounced in a document somebody else is reading and the naming is the entire point of drawing it. **The agent rides in the presence mirror's own local slot.** Everything else in `plan/presence.ts` is a reflection of some socket, which is what lets a disconnect clear it; the agent has no socket, so it goes where nothing can attribute it to one. That slot is also in the join snapshot, so somebody arriving mid-turn sees where the agent is. ## Things that fail silently Every bug found in this project so far was invisible. These are the mechanisms. **An unhandled node type kills collaboration.** MDXEditor re-serialises the document on every update, and a type its serialiser cannot write throws inside the first update listener — which stops every listener after it, including the one that syncs. The editor keeps accepting edits and none of them leave the browser. `registry.test.ts` asserts every dialect node is writable; keep it passing when you touch the dialect. **Lexical swallows errors thrown inside `editor.update()`**, routing them to the editor's `onError`. A seeding failure looked like an empty document until `plan/room.ts` started carrying the error out by hand. **Everything in an SDK event lives under `event.data`**, and several fields have near-homonyms on the envelope. `event.id` is the event's; `event.data.messageId` is the message's. Reading the wrong one produced a duplicated reply and a streaming caret that never stopped. Nothing throws. **`session.send()` resolves when the message is accepted, not when the turn ends.** Waiting on it alone tears the handler down before the agent has said anything. Turns end on `session.idle`. **A custom agent's `tools` list cannot admit MCP tools.** Not by wildcard, not by exact name, not with the server declared on the agent. The entry matches nothing and is dropped without a word, and the agent then explains its way around the absence. The session's `availableTools` is the filter that understands `builtin:`, `mcp:` and `custom:` prefixes — the allowlist lives there, and the agent declares none of its own. **Permission is decided before execution**, so a refused tool produces no start and no completion. `chat/service.ts` renders `permission.completed` denials as failed tool chips; without that a boundary the agent keeps hitting is invisible. **Tailwind v4 translate utilities emit the `translate` property**, not `transform`. They compose rather than override, so an element positioned by script must not also carry them. **A grammar the highlighter does not have rejects a promise nobody is watching.** `@pierre/diffs` starts its first highlight from a render and never attaches a handler to it, so a fence saying `foobar` — or `pseudocode`, which a model will write — resolves nothing, renders nothing, and leaves a block that is simply blank. `code-view.tsx` asks `resolveLanguage` first and falls back to plain text, which is also why the answer is cached: the question is asked again on every keystroke in the block. **One preview slot, two writers, and the loser is whoever committed first.** The rendered output used to be assigned as `innerHTML` and the code renderer is React, so a fence that changed language from `mermaid` had to clear the slot on the way out — and a passive effect's cleanup runs _after_ React has already inserted the next renderer's nodes into it. Everything in `data-plan-preview` is React's now, including the markup KaTeX and Mermaid produce as strings. **A `