Process shape

One Node process, built by Vite and run by systemd. npm run build is vite build (package.json:8) and the adapter emits build/index.js, which is what the unit executes. Deployment mechanics live in operations; this page covers what the process does.

Two things about the build configuration are unusual enough to state plainly:

  • There is no svelte.config.js in the repository. Both the adapter and the Svelte 5 runes compiler option are passed inline to the sveltekit() Vite plugin (vite.config.ts:9-15), with runes enabled for every file outside node_modules (vite.config.ts:11-12).
  • Vitest is configured in the same file rather than a separate config, via test.include (vite.config.ts:17-19).

The dependency set is deliberately small: @lucide/svelte, github-slugger, remark, remark-frontmatter, remark-parse and unified at runtime (package.json:29-36); Tailwind 4, daisyUI 5, adapter-node and Vitest as dev dependencies (package.json:14-28). Notably remark-gfm is absent, which has direct consequences for table parsing — see md-parser.

Storage layout

There is no database. DATA_DIR is the single storage root, resolved once at module load from the environment with a hardcoded fallback (src/lib/server/store.ts:10), and split into two subtrees (src/lib/server/store.ts:11-12):

Citation shorthand on this page: a bare store.ts means src/lib/server/store.ts and a bare importer.ts means src/lib/server/importer.ts. Every other path is written in full.

PathWritten byContents
decks/<id>/deck.jsonwriteDeck (store.ts:139-144)the whole parsed FrameDeck
decks/<id>/assets/...copyAssets (importer.ts:207-239)images copied in at import, subpaths preserved
sessions/<id>/session.jsoncreateSession, then every commit (store.ts:153-168, store.ts:349)id, deckId, createdAt, lastSeq, optional collab link
sessions/<id>/events.jsonlcommit (store.ts:347)one JSON event per line, append-only
sessions/<id>/scribbles/saveScribble (store.ts:409-414)PNG overlays, named <frame>--<seq>.png

The skeleton is created lazily and exactly once: ensureDataDir memoises its promise and clears it again if the mkdir fails, so a transient failure does not poison every later call (src/lib/server/store.ts:37-48).

Ids are validated, not trusted. A deck or session id must match /^[a-z0-9][a-z0-9._-]*$/i and stay under 128 characters, or the call throws a 400 (src/lib/server/store.ts:14-15, src/lib/server/store.ts:59-64). Every derived path then goes through contain, which resolves the target and rejects anything that is not the root itself or below it (src/lib/server/store.ts:51-57). Deck asset paths get the same treatment against the deck’s own asset directory, because the subpath arrives from the URL (src/lib/server/store.ts:74-81).

JSON records are written atomically — temp file with random suffix, then rename (src/lib/server/store.ts:83-88) — so a crash mid-write cannot leave a truncated session.json or deck.json.

The append-only event bus

events.jsonl is the source of truth for a session. The server owns seq and ts; a client may only supply the semantic fields (src/lib/types.ts:35-36). The full event shape and its validation rules are documented in api.

Appends are serialised per session. withSessionLock keeps one promise chain per session id and runs the next task whether the predecessor resolved or rejected, so a single failure cannot stall the chain (src/lib/server/store.ts:302-320). Everything that assigns a seq runs inside that chain, which is what makes interleaving or seq reuse impossible.

Sequence numbers come from highWaterSeq, not from session.json alone. It starts at meta.lastSeq but scans the log and takes the maximum, then caches the result per session (src/lib/server/store.ts:322-332). That is a deliberate guard: if session.json ever lost an update, the next append still lands above every seq already on disk rather than colliding with one. commit then builds the event, appends one line, updates session.json atomically, and publishes to the broker — in that order, so nothing is announced before it is durable (src/lib/server/store.ts:334-352).

Reads are tolerant by design. readEvents skips empty lines, and a line that fails JSON.parse is skipped individually rather than failing the whole read (src/lib/server/store.ts:246-253). A torn final line — the realistic failure mode for an append-only log — therefore costs one event, not the session. Events at or below the requested afterSeq are filtered out in the same pass (src/lib/server/store.ts:254).

Two writes deliberately bypass the event log. Scribble uploads store the PNG and log only the resulting filename, never the data URL (src/lib/server/store.ts:383-386), and the collab room link is written straight into session.json and never into an event at all (src/lib/server/store.ts:360).

The broker and SSE

Live fan-out is a single EventEmitter with its listener cap removed, one listener per open stream (src/lib/server/broker.ts:9-11). publish emits on a per-session channel (src/lib/server/broker.ts:13-15) and subscribe returns an unsubscribe function that stream handlers are required to call (src/lib/server/broker.ts:17-24).

This is in-process only. The module documents it as single-process by design, on the basis that the service runs as one Node instance under systemd (src/lib/server/broker.ts:4-8). There is no cross-process pub/sub, so a second replica would not see the first replica’s events. Anything that needs a durable or cross-process view reads events.jsonl or polls with ?after=.

The replay-to-live handoff is the delicate part, and the stream route solves it by ordering:

subscribe(id, ...)        <- listener installed first, events buffered
write(': connected')
readEvents(id, after)     <- replay, each event raises lastSeq
replaying = false
flush buffered            <- seq gate drops anything replay already sent
setInterval(': ping')     <- 25s keepalive

Subscription happens before the replay read and incoming events are buffered until the replay finishes; the seq <= lastSeq gate then drops whatever the replay already covered, so an event committed mid-replay is delivered exactly once (src/routes/api/sessions/[id]/stream/+server.ts:44-65, gate at line 48). The keepalive comment runs every 25 seconds (src/routes/api/sessions/[id]/stream/+server.ts:7, src/routes/api/sessions/[id]/stream/+server.ts:67). A failed write or a cancel tears down both the listener and the timer through one idempotent shutdown (src/routes/api/sessions/[id]/stream/+server.ts:27-31, src/routes/api/sessions/[id]/stream/+server.ts:71-73). The response sets x-accel-buffering: no so a reverse proxy does not buffer the stream (src/routes/api/sessions/[id]/stream/+server.ts:81).

How the client maps onto the files

The workspace route loads three things and tolerates two of them failing: the session with its deck (hard failure, 404 or 502), then the event history, then the deck list (src/routes/s/[sessionId]/+page.ts:6-33).

SurfaceFileReadsWrites
Left railsrc/lib/components/Rail.sveltedeck sections, route_done setnothing; emits a jump callback
Frame listsrc/lib/components/FrameCard.svelteone Frameverdict, note, task, scribble callbacks
Dialog railsrc/lib/components/DialogRail.sveltethe event arraynothing; emits a jump callback
Voicesrc/lib/voice.tsagent say eventsspeech events
Scribblesrc/lib/Scribble.sveltethe frame imagePOST /api/sessions/:id/scribble
Collab railsrc/lib/components/CollabRail.svelteGET /api/sessions/:id/collabPOST and DELETE on the same path

The rail groups sections by heading depth: level 2 or shallower starts a new group, deeper sections become its items (src/lib/components/Rail.svelte:19-35). A group counts as done only when every one of its frame-bearing sections is in the done set (src/lib/components/Rail.svelte:49-52), and its icon is chosen from the section’s findings and first frame type (src/lib/components/Rail.svelte:54-62).

Frame cards are keyed by DOM id frame-<frame.id> (src/lib/components/FrameCard.svelte:44), which is why the page scrolls with getElementById and never querySelector — frame ids contain a slash (src/routes/s/[sessionId]/+page.svelte:112-114). Image frames render frame.src lazily (src/lib/components/FrameCard.svelte:84) while table and code frames render frame.caption directly (src/lib/components/FrameCard.svelte:88-90), which is the client-side consequence of the parser quirk described in md-parser. A card carries a finding or clean badge from frame.badges plus a separate verdict badge when one has been recorded (src/lib/components/FrameCard.svelte:55-66).

Event flow in the browser is idempotent on seq. ingest drops any event whose seq it has already seen, then keeps the array sorted (src/routes/s/[sessionId]/+page.svelte:87-90). Posting an event optimistically ingests it using the server-assigned seq from the response, so the SSE echo of the same event deduplicates against it (src/routes/s/[sessionId]/+page.svelte:96-110). The stream is reconnected with exponential backoff from one second, doubling to a 30-second ceiling, always resuming from the highest seq held locally (src/routes/s/[sessionId]/+page.svelte:278-303).

Navigation is derived, not clicked. An IntersectionObserver over the scroll container tracks the active section (src/routes/s/[sessionId]/+page.svelte:330-341) and a debounced effect emits at most one nav event per section change, one second after it settles (src/routes/s/[sessionId]/+page.svelte:317-327). On reopen, the last nav event in the history restores scroll position and focus (src/routes/s/[sessionId]/+page.svelte:272-275, src/routes/s/[sessionId]/+page.svelte:120-129).

Speech is browser-side in both directions. Agent say events are spoken only when the speaker toggle is on (src/routes/s/[sessionId]/+page.svelte:93) through a queued speechSynthesis wrapper that strips markdown before speaking (src/lib/voice.ts:76-94, src/lib/voice.ts:123-130). Push-to-talk uses SpeechRecognition (src/lib/voice.ts:162) and publishes the final transcript as a user speech event (src/lib/voice.ts:232-245).

The dialog rail hides status events entirely (src/lib/components/DialogRail.svelte:22), maps each kind to a Lucide icon (src/lib/components/DialogRail.svelte:24-41), and auto-scrolls to the newest entry on the next animation frame (src/lib/components/DialogRail.svelte:47-54).

Read next: api for the endpoint contracts, md-parser for the deck format, operations for the unit and vhosts, or back to index.