Task discipline: one thing in progress at a time
The damage-control gate 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 date parser, reindex search - 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.
Most agent tools answer this 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.
tilldone: a tool plus a hard gate
Section titled “tilldone: a tool plus a hard gate”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.
The load-bearing part isn’t the task-list bookkeeping - it’s the gate. The snippet below is the core check, not a complete extension: the real implementation must define tasks and rebuild it from session history as described below.
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 };});Three checks, not one: a list has to exist, it can’t be finished, and exactly one task has to be inprogress - not zero, not three. Try to jump from re-extracting URLs straight into fixing the date parser without marking the first one done, and every tool but tilldone itself gets blocked until the model closes the loop properly. (The real extension’s tool takes actions new-list / add / toggle / remove / update / list / clear, with numeric ids - if you see other action names elsewhere, including earlier drafts of this course, treat them as illustrative rather than the actual API.)
The design decision worth naming explicitly: this is a hard gate, not a to-do list the model can choose to ignore. It blocks the model from starting task N+1 until task N is explicitly marked done - sequencing enforced by code outside the model’s control, not by the model remembering to behave.
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.” Once Pi returns the file, verify three 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; 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. Instead, on session_start (and again on 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.
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.
What this buys you on stash
Section titled “What this buys you on stash”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 discipline isn’t the model being well-behaved - it’s a hook outside the model’s control refusing to let anything else run otherwise.
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.