Skip to content

Flow: Resume: a stored thread, from the rail to live

A stored thread is one whose log the runtime has flushed: nothing reaches disk before the first assistant message, so a thread that has only run slash commands has nothing to come back to. Resume is not a code identifier (glossary, Thread states): the rail’s listing, the URL naming the thread, GET …/history read cold from the file, adoption in the browser under the SAME id, then the first prompt building the runtime over that file. The thread id IS the runtime’s session id (#95): no mapping, no index file, across a restart. The pane’s messages come from /history (adoptThread); the snapshot carries the same messages, and custom-event-router.ts reads one fact off them: whether the last assistant message is a refused attempt (#381). The host’s name for the listing is listSessions; there is no listThreads.

The first prompt builds the thread under its own id

Section titled “The first prompt builds the thread under its own id”

Recorded by server-handler.test.ts: ⚠ a prompt on a thread passes THAT id to the host — the thread id is pi’s session id, so a stored one reopens. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant B as browser (the test client)
  participant S as server (server/index.ts)
  participant T as thread registry
  participant H as host (fake, over stored threads)
  B->>S: POST /api/threads/stored-1/prompt {message: "continue"}
  T->>H: createSession(stored-1)
  Note over T: thread built: session session-1 in /fake/agent/sessions/--fake-cwd--
  Note over T: run 1 acquired
  Note over T: run 1 released · idle timer [ms] ms
  Note over S: prompt [id] opened on run 1
  S-->>B: 202

Recorded by server-handler.test.ts: GET /api/stored-threads lists stored threads with title, count and age, marking the ones a thread here is RUNNING. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant B as browser (the test client)
  participant S as server (server/index.ts)
  participant T as thread registry
  participant H as host (fake, over stored threads)
  B->>S: GET /api/stored-threads
  S->>H: listSessions()
  S-->>B: 200

Recorded by server-handler.test.ts: GET /api/threads/:id/history serves a STORED thread’s branch as messages with its persisted mode, and builds no session. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant B as browser (the test client)
  participant S as server (server/index.ts)
  participant T as thread registry
  participant H as host (fake, over stored threads)
  B->>S: GET /api/threads/stored-1/history
  S->>H: readStoredThread(stored-1)
  S-->>B: 200 {threadId: "stored-1", messages, permissionMode}
  B->>S: GET /api/threads/never-existed/history
  S->>H: readStoredThread(never-existed)
  S-->>B: 404

The rail is the listing, newest first, titled by the first user message.

Section titled “The rail is the listing, newest first, titled by the first user message.”

The host’s own listing, one header parse per file, newest first; a blank first message is no title. busy marks a thread a run on THIS server holds: the row is shown, not locked (#114), and an idle thread the server still holds is resumable, since held is not busy (PR #104 review).

Stated at respondWithStoredThreads in server/index.ts.

Pinned by:

  • agent-host.test.ts: listSessions names stored sessions newest first with the first user message; readStoredThread reads one without building pi
  • server-handler.test.ts: GET /api/stored-threads lists stored threads with title, count and age, marking the ones a thread here is RUNNING
  • server-handler.test.ts: ⚠ a thread this server holds IDLE is resumable — /history reads pi’s memory, /sessions does not mark it busy

readStoredThread opens the file into memory and writes nothing until an append; this route peeks the registry, and a thread neither live nor stored is a 404, while the follow’s cold read is an EMPTY snapshot, so observation may precede the first prompt (follow-snapshot-frames/cold-empty). A session file in a directory that is no project here is a 403 naming the directory, not that 404 (#378). The page reads the three by where the id came from (stored-threads.ts#createResumeRequests): a 404 for the URL’s own thread (written before its first prompt, then lost to a restart) starts it under that id, said nowhere; a 404 for a rail row is a stale listing; either failure is said on the rail’s “could not open the thread” line, never as the listing failing.

Stated at respondWithThreadHistory in server/index.ts.

Pinned by:

  • agent-host.test.ts: listSessions names stored sessions newest first with the first user message; readStoredThread reads one without building pi
  • server-handler.test.ts: GET /api/threads/:id/history serves a STORED thread’s branch as messages with its persisted mode, and builds no session

Cold, then live, on one follow; nothing is resumed by cursor.

Section titled “Cold, then live, on one follow; nothing is resumed by cursor.”

A resumed pane’s follow opens before any run and reads live: false (the file’s messages, the file’s usage with no context, openDialogs: []); the first prompt acquires, createSession opens the file, and the same follow carries status live: true, then busy: true, generation: 1.

Stated at followThread in server/follow.ts.

Pinned by:

  • follow.test.ts: ⚠ a stored thread is followed cold from its file, and the same follow goes live on its first prompt (#86, PR #488 review)

The opening mode is the branch’s last modes custom entry, or the fresh state.

Section titled “The opening mode is the branch’s last modes custom entry, or the fresh state.”

A cold thread’s mode is storedPermissionModeState’s walk of the transcript from the end over custom entries, which builds nothing and offers the full registered set (#86: looking must not build); a live thread answers from the runtime (liveReading), fresher than the file.

Stated at coldReading in server/thread-read.ts.

Pinned by:

  • chat.e2e.ts: the URL names the thread (#142): reload keeps it, back/forward walk threads, a pasted link opens cold, a bad id says so
  • server-handler.test.ts: GET /api/threads/:id/history serves a STORED thread’s branch as messages with its persisted mode, and builds no session
  • server-handler.test.ts: ⚠ a thread this server holds IDLE is resumable — /history reads pi’s memory, /sessions does not mark it busy

The transcript is ours; the reader assembles and never decodes.

Section titled “The transcript is ours; the reader assembles and never decodes.”

Below the seam host/transcript.ts#transcriptOf reads the runtime’s stored entries defensively: same length, same order, an unmodelled kind is other with its type. Here a tool result is paired into the assistant message holding its call, one whose call is not on the branch is dropped, and custom and other are skipped; an image’s bytes render as a data URL, never a fetch (selected-message-part.tsx). A refused attempt (#381), which pi keeps on the branch with stopReason: "error", is a message carrying its refusal, and its bubble says <Provider> · <model> refused: <class> (<status>) — <reason> where the reply would have been, not an empty bubble. The provider is pi’s display name for it (#387), from pi-ai’s built-in catalogue, since a cold read has no session to ask.

Stated at transcriptToMessages in server/transcript-to-messages.ts.

Pinned by:

  • custom-event-router.test.ts: a snapshot whose last reply is a refused attempt shows the word; one with a reply after it clears it (#381)
  • thread-pane.test.tsx: a resumed thread’s image renders as an from the stored history, bytes inline (#68, #95)
  • thread-pane.test.tsx: a resumed thread’s refused attempt says the refusal in its own bubble, before the reply (#381)
  • transcript-to-messages.test.ts: a user message pi stored with an image beside its text comes back as text + image parts (#68) — the bytes are in the file

One id, end to end: a file under the thread’s id is OPENED, none means one is created under it.

Section titled “One id, end to end: a file under the thread’s id is OPENED, none means one is created under it.”

locate resolves the id through the same listing readStoredThread uses (#95), so the file this later flushes is found next time, and the browser adopts under the history’s threadId (thread-stores.ts#adoptThread). The file sits under the agent dir pi resolves from the env the host set, where pi --mode rpc put it, so the mode’s active_agent entries and the transcript land where the TUI’s would; permissionModeState() below walks them.

Stated at createSession in host/agent-host.ts.

Pinned by:

  • threads-rail.test.tsx: adopting stored history restores dates, title and mode under the stored thread id exactly once, and never asks to start a session (#391)
  • agent-host.test.ts: ⚠ createSession(threadId) OPENS the session file already under that id — the branch is the stored one, not a fresh file
  • agent-host.test.ts: createSession(threadId) with no file under that id CREATES the session under it, so the file it flushes is found next time
  • server-handler.test.ts: ⚠ a prompt on a thread passes THAT id to the host — the thread id is pi’s session id, so a stored one reopens

The URL names the thread; the latest navigation wins.

Section titled “The URL names the thread; the latest navigation wins.”

A thread id resumes, none is fresh, a malformed one is fresh AND said; urlForThread keeps every other parameter. The boot resumes with pop (no history written), a rail click pushes (chat-screen.tsx, BOOT and navigate), and two guards drop a late answer whole: createResumeRequests (last click wins) and the page’s epoch (a navigation since).

Stated at readThreadUrl in thread-url.ts.

Pinned by:

  • chat.e2e.ts: the URL names the thread (#142): reload keeps it, back/forward walk threads, a pasted link opens cold, a bad id says so
  • chat.e2e.ts: ⚠ New Thread during a slow boot resume wins outright: URL and pane agree, the late history is dropped (PR #143 review)
  • thread-url.test.ts: a URL that names a thread id resumes it; none is a fresh page; one that cannot be a thread id is fresh AND said
export const EnsoTranscriptEntry = Type.Union([
EnsoTranscriptMessage,
EnsoTranscriptToolResult,
EnsoTranscriptCustom,
EnsoTranscriptOther,
])