Files
stack/packages/conversation
jason.woltjeandClaude Opus 5.5 a5beb6d97d feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session
parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control
board adds GET /api/conversations and /api/conversation behind the Host
and Origin guard. Both are read-only, their queries are validated, and
each refusal code maps to a status.

Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code:
R1 revise (branch ids moving on append, the assumed-link bridge merging
branches, one unreadable seat directory turning the catalogue into a 500),
then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve
(07b10ad1), R2 approve (b9d92003). The package lands with the routes,
because serve.mjs imports the reader at load.

On an index export: the eight suites 24/90/43/17/14/15/63/18,
conversation and control-board 153/153, webui 9/9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:36:20 -05:00
..

conversation

Read-only Pi conversation histories for the Console: a catalogue of approved session files, full branch history in CHAT-01 pages, and cursors. Opening a conversation never resumes, forks, launches or controls anything, and nothing here writes a file.

Issue #1507 (CHAT-02, row 5). Brief: agents/dewey/work/chat-02/BRIEF.md R4. Plain ESM, no dependencies, Node 24 or newer. A library with no server: the control board serves it on two GET routes (D3).

API

import { rootsFromSpecs, createReader } from "@mosaic/conversation";

const reader = createReader({ roots: () => rootsFromSpecs(specs, registrations) });
reader.catalogue();                                  // { ok, conversations, refusedRoots, generatedAt }
reader.open({ conversation, branch? });              // first page
reader.next({ cursor, conversation, branch });       // next page, or a follow

open and next return { ok: true, page, cursor, follow, view } or { ok: false, refusal: { code, reconcile, message } }.

  • page is a CHAT-01 page, and cursor and follow are CHAT-01 cursor records. page.nextCursor is cursor.id when more parts remain in the snapshot.
  • On the last page, follow replaces cursor. Calling next with it takes a fresh snapshot and returns only what was appended since (see Follow).
  • view holds what the Console needs beyond CHAT-01: defaultBranch, branches, incomplete (a truncated trailing line), forkedFromEarlierSession and unreadableLines.
  • actor defaults to local-operator and purpose to history. Any other value is refused.

Sources

Roots come from the board's repository specs only: <projectRoot>/.pi/state/<seat>/sessions, with the project named after the root directory. Fleet and connector specs are not roots, and there is no global scan. A conversation id is pi- plus a hash of project root, seat and file name. It is resolved by listing the roots again, so no path comes from the caller.

A seat registration is seat-written, so it is a hint, not authority:

  • It counts for a root only when seat, layout repo, project and sessionsDir all match (samePath). Anything else is ignored.
  • It supplies engineStartedAt for conversations created at or after its startedAt.
  • A non-Pi harness makes the root unsupported. The root appears as one catalogue row with unsupportedReason: "unsupported-harness" (D2), and its directory is never read.
  • A seat on another harness has no Pi sessions directory, so the board has no spec for it. Its registration adds that placeholder row when it names the standard directory under a project root that is already approved. This is Rocko's case today: claude-code, no sessions.

Deviation from brief §2.1: the brief speaks of a registration sessionFile. Registrations have no such field, only sessionsDir, so the rule above applies to the directory. A registration never adds a root or names a file.

Opening a file

src/safe-fs.mjs:

  • Every component from the project root down to the sessions directory must be a real directory (lstat).
  • A session file must be a regular *.jsonl directly in the root.
  • It is opened O_RDONLY | O_NOFOLLOW | O_NONBLOCK, and the descriptor's (dev, ino) must equal the lstat taken before the open.
  • The components are checked again after the open. Node has no openat, so a directory swapped between the checks and the open is detected afterwards, not prevented.

The project root itself may be a symlink (the compatibility path to this checkout). The header cwd must be the project or inside it. Real paths are compared, and a cwd that no longer exists is compared as written. That comparison calls realpath on the recorded cwd, which resolves the path but opens nothing.

SessionManager.open is never used.

Parser

src/pi.mjs, pinned against @earendil-works/pi-coding-agent 0.85.1 (docs/session-format.md, dist/core/session-manager.js):

  • Line 1 must be the session header.
  • Entries form an id/parentId tree. Where an id repeats, the later entry wins, as in Pi's index.
  • The default leaf is the last valid entry in file order, as Pi loads it. Each leaf ends one branch; other branches are read-only and opened by branch.
  • Branch names do not change while the file grows. The first root's line is main, even before the file has entries. Each later root (Pi's resetLeaf, or an entry whose parent is missing) and each later child at a fork starts a branch named b.<entry id>; the earliest child in file order continues its parent's branch. An inner entry is not a branch.
  • A malformed line becomes a notice at its file position on every branch, after the leaf too, and reading continues. The same placement on every branch keeps a branch's earlier parts unchanged while the file grows.
  • A missing parent stops the history with a notice. When unreadable lines sit just before the entry, the notice names them as the likely place of the parent. The history is never joined across the gap: the entries before it may belong to another branch, and they read as their own branch. A loop stops with a notice.
  • parentSession gives a "forked from an earlier session" notice and is never opened.
  • Redacted reasoning (redacted: true) is unavailable with empty text. thinkingSignature is never read.
  • A header cwd must be absolute and inside the project; a relative one is refused as foreign-project.
  • A compaction is a marker in place, and the full history stays on the path. retainedTail entries are already on the path, so they are not rendered twice.
  • Model and thinking-level changes, labels, session names and extension state are not shown. custom_message shows only with display: true. An unknown entry type or role becomes a notice.
  • Ids that do not fit the CHAT-01 id pattern are hashed (h- plus 40 hex). OpenAI tool call ids such as call_x|fc_y are the real case. Entry ids are namespaced (n. for native, x. for notices), so no native id can collide with a notice.
  • get_entries order is not used.

Pages

src/parts.mjs applies the CHAT-01 limits:

  • at most 100 parts and 8 MiB of serialized UTF-8 per page;
  • at most 64 blocks per part;
  • at most 262144 characters per string.

Strings split into fragments. A fragment is also cut at 1 MiB of JSON, and blocks group into parts of at most 4 MiB, so one part always fits a page. Content is never clipped, and a surrogate pair is never cut.

Snapshots, epochs, cursors

  • A snapshot is the file up to its last newline when the descriptor was read. A trailing partial line is left out and sets view.incomplete. snapshotDigest is the SHA-256 of those bytes.
  • sourceEpoch is a hash of (dev, ino) and the digest at open. Follows carry it forward while the prefix verifies, so growth keeps the epoch. A new open after growth hashes the longer prefix and gets a different id for the same epoch, so an epoch id is comparable only within one cursor chain.
  • Every next re-reads the pinned prefix and refuses source-replaced (with reconcile) when dev or ino changed, the file is shorter, or the prefix digest differs (an in-place rewrite with the same inode).
  • Cursors live in memory, with an LRU cap (1000) and a 10-minute TTL. They are bound to actor, purpose, conversation, branch, snapshot, epoch and expiry.
  • Refusals: cursor-unknown (never issued or evicted), cursor-expired, cursor-foreign (any binding differs) and source-replaced. All carry reconcile: true. A foreign attempt does not consume the cursor, so the old view keeps working.
  • local-operator is the only actor on this unauthenticated loopback route. That is not multi-actor safety. Authenticated actors come with CHAT-04R.

Follow

A follow cursor verifies the old prefix, then takes a fresh snapshot and reads the same branch in it. page.branch is always the cursor's branch.

  • The parts before the cursor must be unchanged (compared as a digest of entry ids). If they are, the page holds only the parts appended to this branch, which is empty when the conversation continued on another branch. view.defaultBranch shows where Pi's default leaf is now.
  • If the branch is gone or its earlier parts changed (a duplicate id that replaces an entry, or a missing parent that turns up later), it refuses source-replaced.

Refusal codes

Code Meaning Reconcile
unknown-conversation not in the approved roots now yes
unknown-branch not a branch of this conversation yes
unavailable the session root no longer exists no
unsupported-harness non-Pi seat (D2) no
unsafe-path symlink, bad name, not a regular file, swapped no
foreign-project header cwd outside the project no
unreadable permission denied on a file, a root or a directory above it inside the approved roots no
not-a-pi-session, incomplete-header first line is not a complete Pi header incomplete: yes
too-large over 256 MiB no
cursor-unknown, cursor-expired, cursor-foreign, source-replaced see above yes
unknown-actor, unsupported-purpose not local-operator / history no

Costs

Each page reads and hashes the whole pinned prefix. A parse cache and a branch-entries cache (two snapshots each) avoid re-parsing. On this checkout's 18 roots (largest file 18.6 MB), reading every page of every conversation took at most 680 ms per conversation.

Tests

node --test packages/conversation/tests/ covers fixtures F1–F15 and F17 of the brief (F16 is in the control-board suite, which serves the routes).

  • Every reader call runs inside a fingerprint of the fixture tree: size, SHA-256, mtime, (dev, ino), mode and every directory listing, before and after.
  • Files that no read may touch are mode 000, so an attempted open would throw EACCES rather than refuse.
  • Every page and cursor is validated against docs/plans/chat-01/contracts.schema.json with Python jsonschema.