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 sequence
Section titled “The sequence”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
The rail lists what is stored
Section titled “The rail lists what is stored”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
The cold read builds nothing
Section titled “The cold read builds nothing”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 invariants it shows
Section titled “The invariants it shows”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
Looking must not build (#86).
Section titled “Looking must not build (#86).”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
permission-mode-adapter.test.ts: a stored thread answers from its transcript alone: the last mode entry, every mode to offer
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 anfrom 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
transcript-to-messages.test.ts: a stored refused attempt, then the reply that got through: the refusal rides the attempt, not the reply (#381)
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
The contract
Section titled “The contract”export const EnsoTranscriptEntry = Type.Union([ EnsoTranscriptMessage, EnsoTranscriptToolResult, EnsoTranscriptCustom, EnsoTranscriptOther,])Where it is stated
Section titled “Where it is stated”respondWithStoredThreadsinserver/index.ts: the flow, rail-listingrespondWithThreadHistoryinserver/index.ts: looking-builds-nothingfollowThreadinserver/follow.ts: cold-then-livecoldReadinginserver/thread-read.ts: opening-modetranscriptToMessagesinserver/transcript-to-messages.ts: transcript-ourscreateSessioninhost/agent-host.ts: one-idreadThreadUrlinthread-url.ts: url-names-thread- the seam: the listing, the cold read, the open-or-create
- the mode walk
- the words: stored thread, resume, live / cold, session reserved for the log (Thread)
