Course · Pi · Rebuild the defaults
Task discipline: one thing in progress at a time
The damage-control gate from the permissions lesson answers “is this specific action safe?” It has nothing to say about a different failure mode you’ve probably already hit with stash (the course’s running example, a small FastAPI feed reader): you hand the agent a three-part job - re-sync the backlog feeds, backfill the entries still missing their published dates, update the README’s stale known-failing-test note - and it starts on all three at once, drops one half-finished, and reports back as if the whole thing landed.
Nothing it did was dangerous. It just wasn’t disciplined about sequencing. Same turn tax as the last lesson, different payer: a gate that stops sloppy sequencing instead of stopping damage. (That README note is real, by the way - the suite has been green since the feeds fix landed, and the file still confesses otherwise; you noticed it during the dedup work and left it as a chore. It finally gets fixed here.)
Two gates, one event
Section titled “Two gates, one event”You’re about to register a second tool_call handler on top of the one from the last lesson, and it’s worth knowing how Pi handles that before you write it. Handlers run in extension load order, then registration order within each extension, and each one sees any mutation the handlers before it already made to event.input - there’s no re-validation pass after a mutation, the next handler just gets the already-changed input.
What isn’t spelled out is the block side: if damage-control blocks a call outright, whether tilldone’s handler still runs afterward, or whether the first block wins and the rest never fire, isn’t documented either way. Don’t assume an answer - stack both extensions and watch what actually happens the moment one of them blocks. That’s cheaper than guessing wrong about which gate has the last word.
tilldone: a tool plus a hard gate
Section titled “tilldone: a tool plus a hard gate”Most agent tools answer sequencing with a to-do-list tool the model updates as it works - Claude Code’s TodoWrite is the familiar shape. Pi ships none. The reasoning behind that omission: structured task tracking generally confuses models more than it helps - a plain file on disk, one the model and you can read and edit, beats a rigid schema the model has to keep synchronized.
That’s a fair critique of advisory task lists - the kind the model can simply forget to touch. It says nothing against an enforced one. So build the enforced version: a tool the model can’t route around, because the gate blocking everything else lives outside the model’s control entirely.
The pattern is called tilldone, and it’s two pieces working together: a tool the model calls to manage its own task list, and a hook that blocks every other tool until that list says exactly one thing is in progress. You’ve already seen the shape every Pi extension takes - the factory function, the registration rules, the discovery folders - so none of that is new here, just a new hook wired into it.
A task moves through exactly three states:
| State | Set by | What the gate does while at least one task is stuck here |
|---|---|---|
idle |
add, or toggle demoting a task back |
Blocks every other tool while no task at all is inprogress (a well-behaved list with idle tasks and exactly one active task allows tools) |
inprogress |
toggle, one task at a time |
Allows other tools only while it is the single inprogress task |
done |
toggle, cycling from inprogress |
Allows other tools, as long as exactly one task somewhere in the list is inprogress and not everything is finished |
Read the rows as the gate’s checks applied to the whole list, not one row per task: real lists sit in mixed states (idle tasks alongside the one active task, and so on), and the code block below is the gate’s actual semantics - the walkthrough exercises exactly those mixes.
Give the agent the real stash job from the top of this lesson - re-sync, backfill, README note - and predict what happens the moment it tries to skip ahead. Say the model adds all three tasks, toggles the first (re-sync) to inprogress, and correctly runs bash to kick off the first batch - the gate checks three things (a list exists, it isn’t finished, exactly one task is inprogress), all three pass, the call goes through.
Now say the model, eager to multitask, toggles the second task (backfill) to inprogress too, without finishing the first. Predict the next tool call it tries, before you see the code: does it run, or does it get blocked?
If the toggle action Pi wrote for you does exactly what you asked for and nothing more - “mark one in progress” - and doesn’t demote whatever else was already active, then both tasks are now inprogress at once, and the next non-tilldone tool call gets blocked: “more than one task is in progress.” That’s the prediction. Here’s the check that produces it - the load-bearing part isn’t the task-list bookkeeping, it’s the gate:
pi.on("tool_call", async (event, _ctx) => { if (event.toolName === "tilldone") return { block: false };
const pending = tasks.filter((t) => t.status !== "done"); const active = tasks.filter((t) => t.status === "inprogress");
if (tasks.length === 0) { return { block: true, reason: "No tasks defined. Add tasks before using any other tools." }; } if (pending.length === 0) { return { block: true, reason: "All tasks are done. Add new tasks before using any other tools." }; } if (active.length !== 1) { return { block: true, reason: active.length === 0 ? "No task is in progress. Mark one in progress before doing any work." : "More than one task is in progress. Mark all but one done before continuing." }; }
return { block: false };});Trace it against the scenario above: tasks.length is 3, not 0 - first check passes. pending.length is 3 (none marked done yet) - second check passes. active.length is 2 (both the re-sync and the backfill are inprogress) - third check fails, active.length !== 1 is true, and the reason branches to “More than one task is in progress.”
Blocked, exactly as predicted, before the model ever gets to run the backfill in parallel with an unfinished re-sync. (The snippet above is the core check, not a complete extension: the real implementation must define tasks and rebuild it from session history, covered below.)
Worth knowing honestly: a carefully-specified toggle action would make this branch nearly unreachable, by demoting whatever task was already inprogress the moment a new one is marked active - so at most one is ever active at a time, by construction, and the gate’s “more than one” check never has anything to catch. That’s the better design, and it’s worth asking Pi for explicitly.
But it’s not what you get by default from the plain-English description a paragraph up (“mark one in progress”), and this check is exactly the backstop that catches the gap if you - or Pi, generating the tool for you - don’t think to add it. Belt and suspenders is a fine reason for a check to exist even when the rest of the system makes it redundant most of the time.
You don’t need to hand-write the tool half of this yourself - describe the whole thing to Pi and review what it hands back. Something like: “Write a Pi extension at .pi/extensions/tilldone.ts that registers a tilldone tool for managing a task list - actions to add tasks, mark one in progress, and mark it done - plus a tool_call hook that blocks every other tool unless exactly one task is currently in progress. When a task is marked in progress, demote any other in-progress task back to idle automatically.”
Once Pi returns the file, verify four things before you trust it: that all three gate conditions (no list, all done, no task in progress) actually return block: true; that tilldone itself is exempted from its own gate; that marking a new task in progress demotes the old one instead of leaving two active; and that the task state survives a /reload.
If it doesn’t survive reload, Pi’s version is probably holding tasks in a plain variable that resets on load, instead of rebuilding them from session history - which is the next thing worth knowing about.
Where the state actually lives
Section titled “Where the state actually lives”The version above keeps tasks in a plain in-memory variable for clarity, but the real pattern does something smarter: it never stores task state in a side file at all. On session_start it scans ctx.sessionManager.getBranch() for the most recent tilldone tool result and rebuilds the task list from its details payload. Every way a session comes into being arrives as a session_start, whose event.reason already tells you why: "startup", "reload", "new", "resume", or "fork".
That last value does the work you might expect a separate fork event for. When you /fork, Pi tears the old extension instances down and hands the fresh ones a session_start with reason: "fork" - so the rebuild runs again, on the new branch, from the same handler. The one addition worth making is session_tree: navigate the tree with /tree and that event fires, and you rebuild there too - same scan, same branch. One construction path, every way of arriving at a session.
That one design choice is what makes forking and branching (the session moves covered in the context chapter) behave correctly here for free. Fork the session mid-task and the fork inherits the exact task state at that point in history, because the state was never anywhere but the history:
| Task | Status right before /fork |
Status right after, on the new branch |
|---|---|---|
| Re-sync backlog feeds | done |
done |
| Backfill missing published dates | inprogress |
inprogress |
| Update README’s stale note | idle |
idle |
Nothing here is copied or synced - it’s the same reconstruction the gate ran on session_start, run again because session_start fired again, this time with reason: "fork". If you’d hand-rolled a plain variable instead, the fork would have inherited whatever was in memory at that instant - instance teardown within one process does not reset module-level state, which is why the stale variable usually survives a fork - and it would go silently wrong the moment you restart the process between forking and continuing.
The other piece worth knowing about, even if you don’t build it on day one: a one-shot nag. On agent_end, if tasks remain incomplete, the extension calls pi.sendMessage({ customType: "tilldone-nudge", ... }, { triggerTurn: true }) - the same message-injection primitive from the hooks lesson - which forces the agent to take another turn instead of quietly stopping with work half-done.
A flag guards against nagging every single turn; it resets on the next real input event, so it fires once per stall, not forever. That nudge costs exactly one turn, same as a damage-control hard-stop - the difference is you didn’t ask for this one, the gate did.
What this doesn’t solve
Section titled “What this doesn’t solve”None of these three checks care whether the tasks are the right three tasks. A model can satisfy every rule by adding one throwaway task (“review the change”) it marks done in the same breath it creates it, then quietly do the entire real job as a single blob under task two - every gate condition still passes, because the gate only counts tasks and progress states, never judges whether the decomposition was honest.
Or it can genuinely split re-sync/backfill/README into three tasks and still botch the ordering inside one of them - the gate enforces that exactly one task is active, never that the task was scoped well. Neither failure trips a single check above. That’s not a gap you patch with more code; it’s what you’re still reading the transcript for.
What this buys you on stash
Section titled “What this buys you on stash”Hand the agent “re-sync the backlog feeds, backfill the entries still missing published dates, then update the README’s stale known-failing-test note” and without this gate you might get all three touched shallowly. With it, the model has to put tasks on the list before using any other tool, keep exactly one in progress, and cannot work two tasks at once: before task two’s tools run, task one has to move out of inprogress - marked done, or demoted back to idle. (What the gate enforces is one-active-thing, not finish-before-you-switch: a task demoted to idle is not closed, which is exactly the honesty the next section leans on.)
The turn cost when it fires: one blocked tool call, retried in the same turn - no round trip back to you, unlike a damage-control hard-stop. The turn cost when it doesn’t fire: zero, same as everything else in this module until the moment it earns its keep. And when the last task closes, the README finally tells the truth: the suite has been green since the feeds fix, and the note that confessed otherwise is gone.
You’ve gated what the agent can touch and how many things it can be doing. Neither one tells the agent what the whole job looks like before it starts. Next: the plan mode Pi doesn’t have.