# 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 # 230 tests, no agent spawned bun run types # every package bun run ci # dprint check && oxlint bun run build # production client bun run start # serve the built client ``` `bun run dev` needs `GITHUB_TOKEN`. `AGENT=off` runs everything except the agent, which is what the tests use. ## Shape ``` packages/dialect 4.0k the MDX dialect and its Lexical schema packages/editor 4.0k the browser editor, cursors, decisions pane packages/question 1.8k questionnaires: definition, shared answer, derivation packages/protocol 0.6k the wire, as types, plus the addressing rule apps/server 6.9k rooms, documents, questions, the agent apps/web 1.4k the three panes 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. Canonical MDX goes to `data//plan.mdx` on a debounce, with questionnaire records, anchors and the transcript beside it. Yjs history is deliberately not persisted: it would buy undo across a restart at the price of a binary checkpoint that could disagree with the source. ## 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. **The permission gate is the only boundary.** There is no sandbox — this runs as you, on your filesystem. So `apps/server/src/agent/permissions.ts` is an allowlist: writes refused outright rather than confined, reads limited to `WORKING_DIR` with credential patterns excluded, shell limited to commands the runtime classifies read-only, unrecognised kinds denied. Read it before pointing the agent at anything you care about. **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 `data//state.json`, 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. **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. **Hard-fail at boot.** A missing token, an unusable CLI or a `WORKING_DIR` that is not there are all detectable in seconds by trying, and discovering them when somebody types their first message is worse. `AGENT=off` is the deliberate way to run without one. ## 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. **`bun run