Course · Pi · Context
Resume, fork, and reset a Pi session
Yesterday’s session ended in a good place: fetch_feed fixed, both tests green, committed. Pi kept every line of that work. Sessions auto-save as plain JSONL files under ~/.pi/agent/sessions/, organized by project - this repo’s directory is the project’s path with the leading slash dropped, every remaining slash turned into a dash, and a double dash added at each end, so stash (the course’s running example, a small FastAPI feed reader) at /Users/you/stash lives under --Users-you-stash--.
One line per entry: messages, tool calls, model switches, compactions. None of it is hidden from you; you’re just not looking at it yet. Reopening yesterday is a matter of pointing Pi at the right file instead of re-explaining what you already worked out.
What re-explaining actually costs
Section titled “What re-explaining actually costs”Before you resume anything, run this on a real session of your own - any bug you chased for more than a few minutes:
wc -l ~/.pi/agent/sessions/--Users-you-stash--/<the-session-file>.jsonlThat’s not a token count, just a line count - one line per entry, the same units the session format already uses. Now picture retyping that from memory: yesterday’s first hypothesis that the feed XML was simply malformed, the discovery that Atom’s link is a child element rather than an attribute, every dead end you walked into and backed out of.
If the number is small, fair enough, this lesson may not be buying you much. Most real debugging sessions aren’t small, and the gap between “the file already holds it” and “you’d have to rebuild it by talking” is the whole reason session resume exists. A session you can’t reopen isn’t gone, it’s just a retyping tax you haven’t paid yet.
Picking up where you left off
Section titled “Picking up where you left off”From the shell, the fast path is pi -c - continue the most recent session for the directory you’re standing in:
cd stashpi -cThat’s the one you’ll reach for most mornings. When the most recent session isn’t the one you want, open the picker instead: pi -r at startup, /resume from inside a running session. It reaches back across every session Pi has ever run in this project, not just the last one. The shape of the list, illustrative:
> /resume
Select a session: stash · first look around the repo 3 days ago stash · parse_date offset fix 2 days ago ❯ stash · fetch_feed namespace + link yesterday 17:12Once you’re back in, ask directly instead of assuming. The reply below is illustrative, but it’s the kind of answer the restored conversation actually supports:
> where did we leave the feed parsing?
Fixed in two steps: the namespace first - bare iter("entry") never matched {http://www.w3.org/2005/Atom}entry, so the lookup now carries the Atom namespace - then the link, which is a child element, so its href lives on <link>, not <entry>. The test pins the link too. Suite green, committed.That’s not Pi re-deriving anything - it’s reading the restored conversation straight back to you. The investigation you closed yesterday stays closed.
One thing the picker doesn’t do: search inside old sessions for something you don’t remember the title of. It lists sessions by project and recency, and what you type filters over their names, not their contents - there’s no built-in full-text search across everything you’ve ever run.
Because each session is a plain JSONL file, you can close that gap yourself when you need it: rg "unconverted data" ~/.pi/agent/sessions/ finds the afternoon you spent on that strptime error, across every project at once. Pi just doesn’t ship a command that does it for you.
The real fork: two fixes, one problem
Section titled “The real fork: two fixes, one problem”Today’s work arrives through the fix you just landed. With fetch_feed parsing properly, POST /feeds/{feed_id}/sync really works for the first time - so you point it at a feed that’s already stored, to refresh it. It reports success. The articles table now holds every article from that feed twice.
You can see it coming in stash/db.py, and this is derived from reading the repo, not from a hunch: init()’s schema puts no UNIQUE constraint on articles, and save_article is a bare INSERT, so nothing between the sync loop in stash/main.py and the table stops a second copy.
Run the sync path twice against a scratch copy of the repo - executed with the date-offset parse fix and the Atom-namespace parse fix applied (the two earlier repairs to parse_date and fetch_feed), not invented - and you get exactly that:
sync #1: {'saved': 1}sync #2: {'saved': 1}rows in articles: 2links appearing more than once: [('https://example.com/hello', 2)]One more thing worth noticing before you fix anything: the endpoint’s saved count is not evidence. main.py returns len(articles) - articles fetched, not rows inserted - so both syncs above happily say 1. The table is where the truth lives, which is where the check belongs too.
Two fixes, both legitimate, neither obviously cheaper:
- Schema-level. Add a unique index on
articles.link(or onlinkplusfeed_id, if the same article can legitimately arrive via two feeds) and makesave_articleinsert withON CONFLICT(link) DO NOTHING. The storage layer then enforces uniqueness on every caller, forever. The catch: SQLite refuses to build a unique index over rows that already violate it, so the duplicates sitting instash.dbmust be migrated away first. - App-level. Leave the schema alone. Before each insert in the sync path - a lookup helper in
db.py, or inline instash/main.py- check whether the link already exists and skip if so. No migration needed, but every future sync pays a read before every write, and the protection only holds for code paths that remember to check.
Betting on one before you’ve seen either in practice would be guessing. Fork the session at the point where you asked about the bug, so both attempts start from the same understanding without polluting each other:
/fork/fork branches a new session from a previous user message in the current thread - the selector it opens offers your messages as fork points, not every entry; assistant turns and tool calls aren’t candidates. Pick the message, and Pi copies everything up to it into a fresh session file; the original thread keeps existing untouched.
Run it once from “why does re-syncing double every article,” take the schema fix in that branch, then /fork again from the same message for the check-before-insert approach in a second branch. Two live attempts, one shared starting point, neither contaminated by the other’s dead ends.
Don’t just take “copies everything” on faith - count it. Before you fork, run wc -l on the current session’s file and note the number; call it N. Fork, then wc -l the new branch’s file: it should hold the entries up to the message you picked, copied verbatim - the count the original had at that point, not a compressed remnant of it.
Fork again from the same point and the second branch matches too. If a count comes back dramatically smaller, you’re not looking at a copy; /fork doesn’t summarize.
This is the same command doing two different jobs, and it’s worth naming both:
- Explore two options that share a starting point. That’s what you just did - fork twice from the same message, run both approaches, and compare real results instead of arguing about it in the abstract.
- Undo a wrong turn. If a branch spends ten turns building a migration helper you never asked for, you don’t have to prompt back through the whole mistake. Fork from the message right before it went sideways, and continue from there instead. The bad branch isn’t deleted; it’s still on disk if you ever want to see what went wrong. It’s just not the thread you’re continuing in.
To see where you are inside any one session, /tree renders it as the tree it is - branches, labels, the active leaf - and lets you jump to an earlier point, any entry type, without creating a new file. The two fork attempts, though, are separate session files. You hop between them with /resume, same as any other session.
The chapter keeps the schema fix. The migration - deleting duplicates, keeping the first row per link - is a one-time cost, and after it the constraint enforces itself on every future writer: the sync endpoint, any scratch script, whatever gets built in later chapters. The check-before-insert branch taxes every sync with a read per article, and its protection evaporates the first time a new code path forgets to check.
Delete the unused branch or leave it sitting in the picker; either way, one honesty note before moving on - the README still confesses to a known failing test, and the suite has been green since yesterday. Noted for a later chore, not this one.
Knowing when not to resume
Section titled “Knowing when not to resume”Here’s a claim worth testing before you take it on faith: leaving a session running and just typing through it, past the point where the window is genuinely full, is itself a decision - and the worst of the three you actually have.
Pi doesn’t ask permission when that happens; the reset lands anyway, unsteered, at whatever moment the window runs out on its own, and nobody told it which part of the last hour actually mattered. You’ll watch that play out below, against the two choices that pick the moment on purpose instead.
Resuming is cheap enough that it’s tempting to reach for it out of habit - drag the dedup investigation into a task that has nothing to do with it, just because the session is right there. That’s not continuity, that’s clutter: Pi will happily keep discussing duplicate rows while you’re actually trying to add a new feed, and the accumulated context does you no favors.
When the next thing genuinely doesn’t follow from the last one, start clean instead:
/newThat clears to a blank session - no dedup history, no feed-parse trace, nothing to accidentally carry forward. The rule of thumb: resume or fork when you’re continuing something, start clean when you’re not. Getting this wrong in either direction costs you - dragging in unrelated history muddies a fresh task, and starting clean when you were continuing throws away context you’ll just have to rebuild by hand.
That judgment is easier to feel than to memorize. Below is a session hitting exactly that pressure point - 75% full, mid-task - with the window x-rayed beside the terminal so you can see what each of the three choices actually keeps and what it throws away. Pick one, watch it play out, then come back and try the other two.
The command names are generic; in Pi, read “clear” as starting a fresh session, and the “compact” branch as a preview of what the next lesson does about a full window:
However you picked, “keep going” is the one to check against the claim above: it looks like doing nothing, but the window still resets on its own, and the reasoning you watched Pi work out over tens of thousands of tokens gets flattened to a filename the moment it happens. /compact and /clear don’t dodge the reset - nothing does. They just let you pick the moment and say what’s allowed to survive it.
The fork gave you a kept fix and a discarded branch, and the resume habit gives you tomorrow morning for free. But the kept fix carries a bill: stash.db still holds every duplicated row, and SQLite won’t even build the new index until they’re gone.
The cleanup - migrate, then re-sync every stored feed under the constraint to prove it holds - is a job long enough to fill the window outright, and this time you won’t be the one who forces the reset. Next: what happens when the thread runs out of room.