← the Atlas

Kaval Memory — Small Mirror over an On-Disk Transcript

Analysis·seedling·proposed·

Plan of record for kaval's PTY-host memory. Split one 10 K-line mirror into a small line-capped hot mirror, a cold on-disk transcript stored in node:sqlite (reusing @kolu/shared/sqlite, the repo's canonical store — no hand-rolled storage engine), and an attach protocol that never serializes more than a viewport. Kills both the acute reconnect-storm transient and the chronic linear-in-count heap growth.

Companion to the kaval heap-OOM RCA. That note bounded the chronic crash; a live look at production (kaval reported at 4740 MB, draining to ~600 MB over minutes) surfaced a second, acute failure it had filed as a red herring. Reproduced on a clean box with the prod versions (@xterm/[email protected]). This is the plan to end both.

Three terms up front, because the rest leans on them:

That single mirror is quietly doing four unrelated jobs: ① a live VT emulator that answers device queries (XTVERSION, DA1/DSR) and scrapes OSC metadata (cwd / title / command); ② the viewport a freshly-attaching client repaints; ③ the deep scrollback kept for PDF export, search, and scroll-back; ④ the wire snapshot, serialized to ANSI on every attach(). Jobs ② and ④ drive the acute spike; job ③ drives the chronic growth — two distinct failures:

Acute transient Chronic growth
Cause attach() serializes the whole mirror (job ④); a reconnect storm fires ~60 at once the mirror’s deep scrollback (job ③) × an ever-growing terminal count — heap is linear in live-terminal count; terminals are never reaped
Measured 6 concurrent reconnect rounds (60 serializes) → 2.0 GB; 40 rounds → 3.2 GB; drains over minutes (V8 idle reducer) climbs to the ~4 GB ceiling over days → SIGABRT
Fix two kaval-only changes defang it (PR1); the snapshot bound finishes it in PR2 — no reload-history loss the on-disk transcript owns deep history; the mirror shrinks to screen-size

User-facing description

Nothing the user clicks changes. The effects are all in the negative space — what stops happening — plus making deep history honest:

Today After
Terminals get laggier the longer the server is up; a reconnect can spike kolu by 2–3 GB flat memory; reconnect costs tens of KB, not gigabytes
kaval OOM-crashes every few days, restarting the server under you the linear-in-count growth is gone — no scheduled death
Deep scrollback / PDF export / search read the client’s buffer; lost on a cold reconnect served losslessly from disk, surviving server restarts and updates

Architecture-level changes

The root fix is splitting that one over-loaded mirror so each of the four jobs lands where it belongs:

kaval daemon (process) PTY child (node-pty) decoded chunk proc.onData — one synchronous tap VT metadata parser OSC 7 / 0·2 / 633 · device-query → cwd / title / command channels job 1 — leaf hot mirror — SMALL byte-budgeted xterm viewport + cushion · never reaped jobs 2 + 4 — snapshot source transcript/ leaf frames DATA · RESIZE · CKPT VT-specific · in kaval job 3 — deep history withDb() · WAL append reuses the repo's store node:sqlite (WAL) via @kolu/shared/sqlite — canonical index · durability · range · retention one DB per PTY · on disk attach(): bounded snapshot + live deltas getLines(stableRow) — lazy, current width browser xterm keeps its own 50 K visible scrollback
One decoded byte stream, one synchronous tap (proc.onData), fanned to: a VT metadata parser (job ①, a leaf), a small line-capped hot mirror (jobs ② + ④ — the O(1) no-disk snapshot source), and a cold store for job ③. The cold store is a kaval-internal transcript/ leaf that frames the typed DATA/RESIZE/CKPT records and persists them through @kolu/shared/sqlite (node:sqlite, WAL) — the repo's canonical store, not a hand-rolled engine. The client attaches against the small mirror for a bounded snapshot + live deltas, and lazily backfills deep history from the transcript by byte-offset cursor, rendered at the current width.

The boundary: reuse @kolu/shared/sqlite — don’t hand-roll a storage engine

Durable, seekable persistence is a real volatility — but its receptacle already exists in this repo, so the perfect move is to reuse it, not graduate a parallel one. @kolu/shared/sqlite (shared/sqlite) wraps node:sqlite’s DatabaseSync in WAL mode — the repo’s canonical embedded store, already used by the opencode + codex integrations via withDb + createWalSubscription. And node:sqlite is built into Node 24 (kaval’s runtime), the same “reuse the platform” bet we make for zstd via node:zlib. Extracting a hand-rolled @kolu/segment-log (custom binary format + sparse index + WAL fsync + crc tail-recovery + retention sweeper) would be the parallel hand-rolled store the design philosophy forbids — and a pile of code SQLite gives for free.

What SQLite eliminates: the index (a B-tree on indexed columns), durability + crash recovery (its own WAL journal — a kaval OOM-abort loses at most the last txn), range queries by line / byte / time (an indexed BETWEEN), and retention (a DELETE + incremental vacuum). What stays a kaval leaf (packages/kaval/src/transcript/) is only the terminal-domain glue that imports @xterm/headless and so can’t be agnostic: the DATA/RESIZE/CKPT schema, the byte-offset↔line index, and rendering a range by replaying from the nearest checkpoint. That leaf composes @kolu/shared/sqlite — the electricity split done right: the persistence volatility is already encapsulated; the VT meaning is injected on top.

Implementation details

Two PRs — and only two because the rest is one indivisible change. Shrinking the mirror, writing the transcript, and reading it back are mutually dependent: shrink without read-back regresses copy-all / search / deep-scroll, and read-back without the shrink fixes no memory. So they ship together — there is no point where kolu has less functionality than today.

PR1 Defang the storm — kaval-internal, no disk, no wire change

Shipped #1573. (The PR chip lives here, not in the heading, so the section anchor stays #pr1-defang-the-storm--kaval-internal-no-disk-no-wire-change — an empty JSX node trailing a heading slugs to a stray -.)

User impact: a reconnect storm (rapid reload, network blip, laptop wake — especially with many terminals open) no longer spikes kolu by gigabytes; the worst case drops to a brief, bounded blip. Crucially, a full reload still restores the same history as today — the server still sends the whole mirror snapshot, this PR just stops duplicating and stacking that work. So PR1 is a true strict improvement with no regression — which is why it can ship first, ahead of the disk work. (Bounding the snapshot — the change that finishes the job but would shrink what a reload restores — is deliberately held to PR2, where backfill makes it lossless.)

Two changes, both in attach() (ptyHost.ts:668-688), neither touching snapshot content:

  1. Cancellable: after the eager subscribe, if (signal?.aborted) return { snapshot: "", deltas } ("" is wire-legal). A disconnect aborts the in-flight attaches and reissues them; today the aborted half still serializes the full mirror for a reader that’s gone — this makes them no-ops, removing the abort-doubling.
  2. Epoch-coalesced snapshot: a per-Entry snapshotCache, cleared synchronously inside the headless.write callback before publish (ptyHost.ts:624-630). Repeated/rapid attaches to one terminal within a single publish-epoch (an idle terminal = a long epoch) serialize once and share the immutable string. Race-free: cache-set and publish-clear are on the same synchronous tap.

Net: the storm drops from the measured multi-GB to roughly (live-terminal count) × one full snapshot — transient tens-to-low-hundreds of MB — with reload history untouched. One honest cost of the memo: it leaves one serialized snapshot pinned per terminal between mutations (an idle terminal’s lingers until its next byte or resize — and getScreenState populates the same slot). That retention is real, but it’s bounded by — and strictly smaller than — the mirror it shadows (a filled 10 K snapshot is ~4 MB of ANSI vs the ~25 MB live cell buffer it serializes), and it’s freed on the next mirror mutation or on teardown — so steady-state heap stays the same O(live-terminal count) class the mirror already occupies, not a new leak. The epoch grain is load-bearing, not a missing release: a reconnect storm’s attaches arrive across many event-loop turns, so only an actual data-parse boundary reliably outlasts the burst — a turn/microtask/timer release would fire mid-storm and re-introduce the N serializes. PR2’s snapshot bound then takes each snapshot — pinned or transient — to ~tens of KB.

Guard: a reconnect-storm test (N concurrent + repeated attach(), assert peak allocation falls to O(terminal-count) full snapshots, not O(storm-size)) — the guard missing today for the exact spike investigated. Channel is untouched (channel.ts:120-131 already correct).

PR2 The on-disk transcript — write, read, and shrink, atomically

User impact: terminals stay snappy no matter how long the server’s been up or how many you have open, and kolu stops OOM-crashing-and-restarting every few days (the chronic death). A full reload now shows the visible screen instantly, then fills in history as you scroll — reaching the full retained on-disk depth (deeper than today’s 10 K mirror, up to the retention cap), rendered at the correct width, and surviving server restarts. No regression window: the snapshot is only bounded — and the mirror only shrunk — once backfill is in place to restore depth, all in this one merge.

Build it bottom-up inside the PR so the bound/shrink is last — the transcript can be exercised by tests before any user path depends on it:

Pinned constants

Constant Value Where it lives Why this value
SCRAPE_TAIL_LINES 40 the mirror-floor formula in ptyHost.ts Exactly TAIL_REGION_LINES (screen.ts:61), the deepest detector tail any live reader asks for — consumed at agent-adapter.ts:88 and flowing through readScreenText(tailLines) at local.ts:265. Derived from that constant (not a fresh magic number) so the floor can’t drift below a reader’s need — reuse the source of truth.
mirror floor rows + 40 same The mirror only owes the screen plus the deepest scrape tail. No OSC handler (7, 0/2, 633;E) or device-query handler reads the scrollback buffer — they extract metadata only — so nothing reads past rows + 40. DEFAULT_MIRROR_SCROLLBACK stops being a depth dial.
HOT_WINDOW 500 lines the attach() bound and getScreenState, serialize({ scrollback: HOT_WINDOW }) (ptyHost.ts:728) The bounded attach snapshot. ~500 lines serializes to tens-to-low-hundreds of KB (vs today’s multi-MB full-mirror serialize), repaints the visible screen plus ~10× viewport of over-scroll margin so a reload rarely hits a backfill round in the first gesture, and the pager fetches anything deeper on scroll. Independent of K; tunable.
checkpoint K one CKPT per ~64 KB zstd DATA block (≈500 lines), deferred to the next clean line boundary the proc.onData writer (ptyHost.ts:624-630) Pinned below.

Why K is block-anchored, not a raw line count. The replay-cost spike measured restore checkpoint → resize → replay K lines → render a range on Node 24 / @xterm/[email protected] / [email protected]: K=50: 2.9 ms, K=100: 3.6 ms, K=200: 4.3 ms, K=500: 4.1 ms, K=1000: 6.0 ms. The curve is flat and fixed-cost-dominated — ~2.5–3 ms of throwaway-construct + serialize-restore + reflow, then ~0.003 ms/line marginal. An earlier draft floated a flat K = 1000 lines; the spike refines it to one checkpoint per ~64 KB DATA block because:

Under the DATA-replayed-return rule, worst-case replay distance per page is ≈ K + maxLines + rows ≈ 560 lines~4 ms/page render — comfortably sub-frame (< 16 ms) even on a 2–3×-slower production host, and it runs behind the read semaphore so backfill can’t re-storm the read path. Checkpoint storage is negligible: each CKPT = serialize({ scrollback: 0 }) ≈ a few KB, so ~200 checkpoints per 100 K retained lines is sub-MB. K can range 256 (~32 KB) to 1000 (~128 KB) without harm; smaller buys little (fixed cost dominates), larger trims the frame-budget margin.

The backfill seam protocol

Cursor type. byteSeq is an opaque, monotonic byte offset into the per-PTY decoded DATA stream (the running sum of decoded-output byte lengths). It is always minted and consumed at a ground-state clean line boundary — cursor column 0, primary screen, top row is the first row of a logical line (not a wrapped continuation). The record.firstByteSeq index resolves a byteSeq to the ~64 KB block that contains it. Line numbers never go on the wire — they shift under reflow (reflowCursorLine); a byte offset is reflow-stable, width a render-time parameter.

Request (client stream namespace, snapshot-then-deltas):

`history({ id, beforeCursor: byteSeq, maxLines: int, width: int })`

Response: `{ rows: string[] /* rendered ANSI, top→bottom */, nextCursor: byteSeq, atFloor: bool }`

Server algorithm (one call; every render runs behind the read semaphore):

  1. Seed checkpoint C = the latest CKPT captured at or before the first line that will be returned. Analytic pick: latest CKPT with capture-line ≤ line(beforeCursor) − maxLines − rows. The −rows margin guarantees C’s own restored viewport (the “seed”) has scrolled entirely above the returned window. Fall back through earlier checkpoints, ultimately the implicit byte-0 checkpoint (a fresh terminal at the initial width).
  2. Render the segment: throwaway headless at C.cols → restore C.vtState (serialize restore) → resize(width, rows) (xterm native reflow of the seed) → write the zstd-decompressed DATA run for (C.firstByteSeq, beforeCursor]. Do not replay RESIZE records — this is reflow-to-current. Dispose.
  3. Slice: take the last ≥ maxLines rows; snap the top edge up to a logical-line start (the row whose isWrapped === false). nextCursor = that line’s firstByteSeq. Load-bearing invariant: the topmost returned line MUST be DATA-replayed (parsed fresh after C.firstByteSeq), never a reflowed-seed line. If the slice would include a seed line, walk back to an earlier C and re-render.
  4. Return rows + nextCursor + atFloor.

Client backward-paging + stitch.

No-gap / no-overlap. Page k covers exactly [nextCursor_k, beforeCursor_k) in byteSeq, snapped to clean line boundaries, and beforeCursor_k == nextCursor_{k−1} (adjacent pages share the boundary). A ground-state boundary maps to a physical-row boundary at every width, so concatenation reconstructs the single-shot render with no duplicated or missing row. The hot↔cold join: the first history() uses beforeCursor = historyCursor (the snapshot top, a CKPT byteSeq), so its rows end exactly at the snapshot top — prepend with no overlap. exportHistory routes the same machinery in FAITHFUL mode (restore@C.cols, replay DATA + RESIZE, render at the historical width, per resize-epoch); searchHistory ranges over the same index.

Cross-width reflow verdict — YES, byte-identical, under two constraints. A checkpoint-rooted segment render is byte-identical to the global single-shot reflow at the client’s width W, validated across W ∈ {40, 80, 100, 120, 160}, every range, a 4000-char wrapped logical line, and wide-CJK / emoji / combining-mark content — provided:

The copy-mode pager

A read-only, fixed-width reader surface — never a splice into the live reflowing grid. The live Terminal.tsx is untouched; it stays mounted and running behind the pager the whole time.

Invocation — three entry points, one state owner.

State owner: a useHistoryPager singleton built with createSharedRoot, a near-clone of useTerminalSearch.ts:31-78 — per-TerminalId open-state, openFor(id) / isOpen(id) / close(), and an on(store.activeId) effect that closes the pager on active-terminal switch (the find-bar contract).

Close: Esc / backdrop (desktop) or drag-down / backdrop tap (mobile), the ✕, or “Jump to live ↓”. Every close routes through withKeyboardDismiss and returns focus to the live grid via refocusTerminal (ModalDialog.tsx:25-39, opted in with refocusOnClose).

Desktop mockup — a tall ModalDialog size="lg" reader, backdrop dims the whole canvas so it reads as a separate surface:

┌──────────────────────────────────────────────────────────────────────────────┐
│  ⏱  History — claude-code (feat/female-flat)      [Aa] [.*]  Find… 3/57  ‹  › │
│                                          Jump ▾    ⤓ PDF              ✕  Esc    │
├──────────────────────────────────────────────────────────────────────────────┤
│ ····· Older output trimmed to stay under the 256 MB history limit ··········· │  ← evicted sentinel
│  $ pnpm test                                                                   │
│  ✓ packages/kaval   (42)                                                       │
│  ✓ packages/shared  (18)                                                       │     read-only xterm
│  …                                                                             │     (DOM renderer, no WebGL)
│  $ git log --oneline -5                                                        │     fixed width = pager cols
│  3eb6cc8  fix(kaval): defang the reconnect storm (#1573)                       │     text is selectable → copy
│  ▓▓▓ match: "reconnect storm" highlighted in view ▓▓▓                          │
│  …                                                                             │
│                                                                                │
├──────────────────────────────────────────────────────────────────────────────┤
│  line ~1,284,330 · 3 days ago · 132 cols                ↓ Jump to live   ◴ new │  ← footer; ◴ ring pulses
└──────────────────────────────────────────────────────────────────────────────┘

  Top-sentinel variants (whichever applies as you scroll up):
    backfilling →   ····· ⠋ loading older history… ·····
    true start  →   ──────────── Beginning of session ────────────
    disabled    →   History isn't being recorded for this terminal.
    disk fault  →   ⚠ History may be incomplete — disk error; showing up to the last good point.

  Jump ▾ menu (anchored popover):     Search field toggles:
    ┌──────────────────────┐           [Aa] case-sensitive   [.*] regex
    │ ⤒  Top of history    │           (chips inside the field, ripgrep-style)
    │ ⤓  Latest            │
    │ ─────────────────    │
    │ 🕘 1 hour ago        │           NOTE: no "jump to line N" — render-line
    │ 🕘 Today 09:00       │           numbers shift under reflow, so the only
    │ 🕘 Yesterday         │           position anchors are TIME and top/latest
    │ 🕘 Pick a time…      │           (the byte-offset cursor is opaque).
    └──────────────────────┘

Mobile mockup — a @corvu/drawer side="bottom" sheet at h-[90vh] (the RightPanelDrawer pattern), grabber pill, ≥44 px tap targets:

              ▁▁▁▁▁▁                                   ← grabber (w-10 h-1 rounded-full)
   ┌────────────────────────────────────────────┐
   │ ⏱ History                 Find…    ⤓    ✕   │     header — each control ≥44px
   │ claude-code · feat/female-flat              │
   │ [ Top ]  [ Latest ]  [ Jump to time ▾ ]     │     jump row (full-width buttons)
   ├────────────────────────────────────────────┤
   │ $ pnpm test                                 │
   │ ✓ packages/kaval (42)                       │     read-only xterm
   │ …                                           │     native touch scroll;
   │ ▓ match highlighted ▓                       │     backfills on scroll-near-top
   │ …                                           │
   │                                             │
   ├────────────────────────────────────────────┤
   │  line ~1,284,330 · 3 days ago               │
   │        ↓   Jump to live   ◴                 │     big target, ring pulses on new output
   └────────────────────────────────────────────┘
        (Drawer.Overlay backdrop dims the tile behind)

The component forks on layoutMode() (useMobile.ts:58-67): ModalDialog for desktop, Drawer for phone/compact — exactly as the right panel forks between a Resizable split and RightPanelDrawer. Touch invocation is a “History” row in MobileChromeSheet.tsx:87-141 next to Palette/Settings/Inspector (with a Kbd chip via formatKeybind), plus the palette. Every control is ≥44 px (above the 24 px WCAG floor the codebase already targets, MobileTileView.tsx:191-211).

Interactions.

Reused primitives: ModalDialog (desktop host, size="lg", height min(74vh, 60rem)); RightPanelDrawer.tsx:58-85 (mobile host — grabber, overlay, withKeyboardDismiss + restoreFocus={false}); SearchBar.tsx:35-184 (header field clone); ScrollToBottom.tsx:7-37 (jump-to-live ring); Terminal.tsx:34,795-808 (streamCall(client.stream.history, …, { signal, onRetry })); useTerminalSearch.ts:31-78 (useHistoryPager singleton); webglBudget.ts (DOM renderer); Surface.ts + Kbd.tsx (popover chrome + chips).

Full PDF export (product question — answered)

Will users get the full PDF instead of a clipped one? YES, decisively. Today exportScrollbackAsPdf.ts:28 serializes serializeAsHTML() off the live client xterm ring — clipped to DEFAULT_SCROLLBACK (50 K) and in practice only to what that client buffered (the header even admits it is “NOT the full session”). After PR2, useTerminalCrud.exportScrollbackPdf (useTerminalCrud.ts:325) repoints at exportHistory, which reads the entire per-id transcript on disk — bounded only by the per-terminal retentionBytes, surviving server restarts. A multi-hour session that scrolled past the client cap exports in full.

Render width = historical-per-span (faithful), not a fixed export width and not the live width. exportHistory walks the transcript oldest→newest and renders each inter-RESIZE span at the cols actually in effect then, freezing each span’s wrapped lines before the next RESIZE is applied. A PDF is an archival document; a 200-col table re-wrapped to a narrow width is the exact content the user exported to keep, turned to garbage. The RESIZE epochs make per-span width recoverable (each CKPT stores cols), and the spike proved checkpoint-replay reproduces the exact screen across resize with RESIZE records load-bearing (the negative control) — using them is reuse the source of truth. When the pager is the export source, “current width” = pager width for the visible-range case; the full-depth archival export uses historical-per-span. (This is a desirable divergence from a live xterm, which reflows all scrollback to one final width.)

Theming handoff (preserving exportScrollbackAsPdf.ts’s existing “server headless has no theme” constraint): the server streams per-span rendered ANSI-with-SGR segments through the stream namespace (a finite ordered stream, idempotent restart on reconnect, so deep depth isn’t one giant message); the client writes each segment into an offscreen @xterm/headless + SerializeAddon carrying the live theme — resizing it to each segment’s historical cols before writing — then serializeAsHTML() → the same themed print window the file opens today. Depth moves to the server (un-clipped, faithful); theme stays client-side.

Edge — history.enabled:false. No transcript, so deep export falls to the live client buffer exactly as today (clipped). Not a fail-fast violation: nothing required is missing — the user chose no retention, and exporting the visible buffer is the correct behavior for that mode. The default is enabled:true, so full-depth is the path almost every terminal takes.

Mobile resize (product question — answered)

A mobile (or any narrow) client resizing a terminal is harmless to scrollback and PDF, because width is render-time for reads and historical-per-span for export.

Width is one shared PTY size — pre-existing, unchanged by PR2. The resize path: client Terminal.tsx FitAddonclient.terminal.resize({id,cols,rows})router.ts:113PtyHostTerminalProxy.resize (local.ts:161) → ptyHost.resize (ptyHost.ts:754) → entry.proc.resize (the one node-pty child) + entry.headless.resize (the one shared mirror) + invalidateSnapshot. There is exactly one proc/headless per terminal — no per-client size. All attached clients share the width, last-write-wins; a mobile client fitting to 40 cols reflows the live grid for everyone via SIGWINCH. This is today’s behavior, not introduced by PR2.

What PR2 adds and why it stays correct:

searchHistory semantics

A server-side scan over the on-disk transcript that re-routes the find-bar’s deep search — not the live xterm SearchAddon.

Session restore semantics

The visible-screen restore is unchanged; the transcript only adds deep, durable reach — it does not resurrect a killed PTY’s live screen.

node:sqlite inside the real nix closure — confirmed

It loads, two ways.

  1. Analytic closure chain. nix/nixpkgs.nix pins nixpkgs rev f8573b9c935cfaa162dd62cc9e75ae2db86f85df; nix/overlay.nix adds only kolu-fonts and does not override nodejs; default.nix’s kaval derivation wraps ${pkgs.nodejs}/bin/node with the tsx loader and bin.ts (no extra flags, no NODE_OPTIONS). So the kaval runtime is exactly pkgs.nodejs, which at that pin resolves (nix eval locally + a clean nix build on a box) to /nix/store/sy0c7j0npsq33d9zhnnzvjnzc52f4y0p-nodejs-24.13.0. node:sqlite is a C++ builtin compiled into that binary, so the full-closure build cannot change the result.
  2. Empirical, on a fresh pu box (destroyed after; egress probed 200): built that exact pinned pkgs.nodejs + pkgs.tsx, then ran node:sqlite three ways under …-nodejs-24.13.0/bin/node, all exit 0 — (a) bare require("node:sqlite")DatabaseSync is a function; (b) a real WAL DB: PRAGMA journal_mode=WAL{journal_mode:wal}, 1000 zstd-magic BLOB rows inserted, indexed range-by-firstByteSeq SELECT returned the right row, BLOB round-tripped as Uint8Array; (c) kaval’s exact launch shape node --import <tsx loader.mjs> file.ts importing node:sqlite from TypeScript — works, the tsx loader passes the builtin specifier through.

No flag, no NODE_OPTIONS, no suppression knob. Node 24.13.0 exposes node:sqlite by default (the --experimental-sqlite requirement was dropped in v23.4); both require() and ESM import return exit 0 with zero flags, including through tsx. The only runtime effect is one ExperimentalWarning: SQLite is an experimental feature… printed to stderr on the first DatabaseSync construction. The implementer must leave the kaval wrapper’s --add-flags unchanged and must NOT add --no-warnings / NODE_NO_WARNINGS / --experimental-sqlite: a suppression knob is exactly the override the fail-fast rule forbids, it would mask unrelated warnings, and the existing codex/opencode integrations already import { DatabaseSync } from "node:sqlite" and emit this same warning in the shipped server with no special handling — PR2 follows that precedent. Net: import { DatabaseSync } from "node:sqlite" in @kolu/shared/sqlite, do nothing to the nix wrapper, accept the one cosmetic stderr line.

Watch-item (not an open question): node:sqlite is officially experimental — the API “might change at any time.” The surface used (new DatabaseSync(path), prepare/run/get/exec, PRAGMA journal_mode=WAL, BLOB↔Uint8Array) is stable across the 24.x line and the version is locked by nixpkgs via npins, so any Node bump rides a deliberate npins update, never silent drift. A future major could change the API — the implementer’s to watch, not a blocker.


Open questions: NONE. Every prior fork is closed and every constant pinned. The single artifact surfaced by the seam spike is not an open decision: the combining-mark-on-width-change misattachment is an upstream xterm reflow limitation, reproduced by a plain live-terminal resize with no checkpoint involved (so the pager is not a regression), and the protocol’s DATA-replayed-return rule keeps it out of every returned and stitched line — it can only touch a reflowed checkpoint seed, which is never displayed. Stated here in the open rather than papered over; it gates nothing in PR2 and a future xterm fix removes even the seed-level trace.