Skip to content

Course · Pi · Rebuild the defaults

Task discipline: one thing in progress at a time

The damage-control gate from the last lesson answers “is this specific action safe?” It has nothing to say about a different failure mode you’ve probably already hit with stash: you hand the agent a three-part job - re-extract a batch of URLs, fix the accented-character bug in search, reindex - 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. That’s this lesson’s slice of the same turn tax: a gate that stops sloppy sequencing instead of stopping damage.

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 fire in registration order, 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 what happens on 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.

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:

StateSet byWhat the gate does while at least one task is stuck here
idleadd, or toggle demoting a task backBlocks every other tool - nothing is inprogress yet
inprogresstoggle, one task at a timeAllows every other tool to run
donetoggle, cycling from inprogressAllows other tools, as long as at least one task overall is still pending

Give the agent the real stash job from the top of this lesson - re-extract, fix the search bug, reindex - and predict what happens the moment it tries to skip ahead. Say the model adds all three tasks, toggles the first (re-extract) to inprogress, and correctly runs bash to kick off the extraction - 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 (fix the search bug) 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 re-extract and the search fix 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 search fix in parallel with an unfinished extraction. (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.

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. Instead, on session_start (and again on session_switch, session_fork, and session_tree, after you navigate the session tree), it scans ctx.sessionManager.getBranch() for the most recent tilldone tool result and rebuilds the task list from its details payload. That one choice is what makes forking and branching - the session moves you learned back in Module 3 - 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:

TaskStatus right before /forkStatus right after, on the new branch
Re-extractdonedone
Fix search buginprogressinprogress
Reindexidleidle

Nothing here is copied or synced - it’s the same reconstruction the gate ran on session_start, run again because session_fork fired. If you’d hand-rolled a plain variable instead, the fork would have inherited whatever was in memory at that instant, which is usually right but 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 Module 5’s 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.

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 extract/parse/reindex 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.

Hand the agent “re-extract the last 40 saved URLs, then fix the accented-character bug in search, then update the README” and without this gate you might get all three touched shallowly. With it, the model has to declare the three tasks up front, mark exactly one in progress, and can’t reach for bash or write on task two until task one is explicitly closed. 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.

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.