# How it behaves, precisely

The mechanics behind [README](../README.md). Read this when you are debugging behaviour, deciding
whether to change a default, or wondering why the loop did something. It assumes you know that a
**settle** is a model response with no tool calls — the only turn that can end a run.

The rule the whole design follows: **the extension never edits your prompt.** It appends one hidden
nudge at a stop and otherwise keeps out of the conversation. Reading and updating the list is the
agent's job, through the todo tool it already has.

## The turn it adds

`rpiv-todo` renders and stores the list but never asks pi for another turn. Pi ends a run when the model
answers with no tool calls. This extension hooks that boundary: while the branch has open tasks it
requests one more turn, so the model continues instead of handing control back.

Two consequences follow from that being the only mechanism available:

- **A continuation is a persisted entry.** pi has no "resume without appending anything", so each
  continuation writes one `custom_message` with `display: false` and one of two constant bodies:
  *"Continue with the open todo tasks; use the todo tool to read and update the list. If the human's
  latest instruction says to stop or limits what to do, do that instead."* It is invisible in the TUI,
  but it is in the session file, it is what the model reads, and it is the only text this extension
  ever adds.
- **Only a settle spends a round.** `turn_end` fires after every assistant response, including one that
  ended in a tool call; those already cause pi's next request, so they cost nothing and write no marker.
  A round, a stall and a marker therefore always concern a turn that would otherwise have ended the run.

## Markers and role alternation

A marker sits between two assistant turns, which is what keeps the conversation alternating: a request
that contained `assistant → assistant` in a row is rejected by alternation-requiring providers.

The `context` hook may remove a marker and nothing else. It drops one when either non-marker neighbour
is user-role — next to your own message, or in a run of markers — because that position would project to
`user → user`. A marker at the tail always stays: it is the nudge, and dropping it would leave the
continuation request ending on an assistant turn, which is the shape pi refuses to continue from.

The hook returns `undefined` when nothing needs removing, so an ordinary request is passed through as
the object it already was — not re-built, not extended.

## Stalls, and the block tool

Nothing here stops the loop for being quiet. A model that keeps answering without moving the list is
kept alive — until the list clears, the agent asks for a person, you stop it, or a cap you configured
is reached — because "stuck" and "waiting for a person" look identical from the outside, and one of them
must not be punished. Without a cap that means it keeps costing turns; every tenth settle since the list
last changed raises a warning, so a frozen list — the shape this package exists for — does not run
invisibly either.

- A **settle** whose task signature is unchanged increments `staleSettles`; a settle that changed the
  list resets it. `settleRun` counts assistant turns since the last tool turn, and it is that counter —
  not `staleSettles` — that decides *when to ask*.
- **The nudge has two variants.** The plain one says continue and use the todo tool. The second adds
  *"If you cannot proceed without a person, call `todo_loop_block` with the reason."*, and is used for
  exactly one shape: the model stopped, we sent one request, and it stopped again **without running a
  tool and without the list changing** (`settleRun === 2`). `settleRun` counts assistant turns since the
  last tool turn, so work in between restarts it — that stop gets the plain nudge, and the stop after it
  is asked instead. Because the run keeps growing, one stall asks once rather than on every stop.
- A **tool turn** resets `settleRun`, so the next stop is not asked about even
  when the list was not touched. A tool turn that ends `error`/`aborted` resets it too: it is still a
  request between two stops.
- **`todo_loop_block`** is the agent's own exit. It is advertised in the prompt from the first turn —
  pi lists it in `<tools>` and carries its guideline in `<rules>`, because the tool declares a
  `promptSnippet` — and the nudge is the timed offer, not the only way to learn it exists. It takes a
  reason, stores `{reason, anchor}` in memory and stops continuations for that message. The human is notified, `/todoloop` shows
  `waiting for you: <reason>`, and the human's next message is a new anchor, so the block clears itself
  — there is no unblock call.
- A block with an empty reason is refused, and the loop keeps running: the reason is the whole point.
  The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
  disabled extension never notifies or writes state.
- **`staleSettles`** counts settles since the list last changed; a tool turn that leaves the list
  unchanged does not clear it. It never injects anything and never stops anything: it only decorates
  the reason string
  (`, no todo change for N replies`) so a burn to a configured cap does not read like healthy work.
- **Deleting the list is announced; finishing it is not.** If every task goes from open to closed in a
  turn whose only tool results were `todo`, the *calls* decide the message: a `delete` in that turn
  produces a warning naming how many tasks were thrown away, because a deleted task is not finished
  work. `completed` calls say nothing — finishing work and ticking the boxes in one turn is the normal
  ending. Calls are counted rather than the snapshot's rows, because rpiv-todo keeps deleted tasks as
  tombstones in every later snapshot; `clear` is announced the same way, since it empties the list
  without completing anything; a result with no `action` at all falls back to the vague wording, since
  there is nothing to inspect.

## Counters

| Counter | Meaning | Reset by |
|---|---|---|
| `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
| `staleSettles` | settles since the list last changed — decorates the cap reason and drives the every-tenth warning | the list changing, a new human message |
| `settleRun` | assistant turns since the last tool turn — `2` is the stop the hint asks on | any tool turn, a new human message, `/todoloop reset` |

- **One budget per human message.** A user-role message carrying a `customType` (this extension's
  marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
  the budget; `/todoloop` prints the anchor it is using.
- **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
  another extension's continuation or a provider retry under the same message cannot re-arm the budget.
  A queued human message is a new anchor and resets the budget the ordinary way.
- **A stale list burns to the cap when one is set**, never earlier: only the list changing clears the
  count — a tool turn does not reach the decision at all. That is why the cap reason appends
  `, no todo change for N replies`.
- **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
  and spends no round; the counters are left as they are, so the next settle under the same message can
  continue — which is what a provider retry hits.
- **A stop is announced** once, when the reason changes, so a run cannot end silently on a cap.
- **Counters live in memory.** `/reload`, a restart or a resumed session starts a new budget and
  re-reads the list; a pause does not survive either.

### Reason strings

`/todoloop` prints one of these as `last:`. They are also announced when they change.

| `last:` | Meaning |
|---|---|
| `continuing: 2 tasks still open, continue 1` | working as intended (`continue 1/12` when `maxRounds` is set) |
| `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
| `stopped: no list` | no recognised todo state on the branch — see the five causes below |
| `waiting for you: waiting on Dana's review` | the agent called `todo_loop_block`; your next message resumes |
| `stopped: 12 continuations used, 2 tasks still open` | the budget is spent; `, no todo change for N replies` means it worked without updating the list, `, the agent may need you` means it stopped on a bare stall |
| `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
| `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
| `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
| `counters reset` | `/todoloop reset` cleared the rounds, stalls, list signature and any pending block |
| `idle` | nothing has run yet in this process |

## Where the list comes from

The list is read from the session branch on every turn, never cached: a `rpiv-todo-state` custom
entry's `data.tasks`, or a `todo` toolResult's `details.tasks`, whichever is newer.

That is deliberate. A cache would owe invalidation on session switch, branch, compaction, process start
and fork; and a resumed session with an open list would report `no list` until the model happened to
call `todo` — the failure this package exists to prevent. The cost is one backward walk of the branch,
sub-millisecond on thousands of entries.

Two deviations follow from it:

- **The branch is not the model's context.** After a compaction the model's context drops older
  entries, but the reader still walks the whole branch, so `/todoloop` can report open work the model
  has no list for. This is the hole the removed injection used to paper over: a model that has been
  compacted may not know the list exists until it calls `todo` or is nudged. The nudge is the reminder;
  the tool is the source.
- **A newer malformed entry is skipped** in favour of an older valid one, so after a shape change the
  reader serves the list as of that change. Reading an errored `todo` call as "list clear" would be
  worse.

`no list` therefore has five causes: the model never called the `todo` tool in this session, no todo
tool installed, a tool that is not named `todo`, a shape this extension does not recognise, or every
task `deleted`.

## Scope

- **A delegated child session runs its own loop.** Each session has its own list and counters, so a
  subagent that loads this extension can continue independently of its parent; markers do not
  interleave, but the work can.
- **`/todoloop` needs an interactive session.** In `pi -p` and `--mode json` the loop runs, but there is
  no UI for the status or the stop notification; the only signal is the marker count in the session
  file and the model's final message.
- **`.pi/todo-loop-off` stops every session started in that directory**; `~/.pi/agent/todo-loop-off`
  stops all projects; the config file is global and re-read on every request, so editing it affects
  running sessions.
- **Nothing is written to your working tree.** The reads are the config, the two kill-switch paths and
  the session branch.

## Configuration limits

Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
`maxRounds` is `null` by default, which means no limit: the loop ends when the list clears, the agent
blocks, or you stop it. Set a number to bound what one message of yours can cost; it is clamped so a
typo cannot defeat it (`maxRounds` ≤ 1000), and a value below 1 stays unlimited rather than becoming
"never continue".

## Cost

- A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
  number of continuations × session size — and each continuation lengthens the conversation the next
  one re-sends, so a runaway grows faster than that product suggests. With the default there is no
  continuation cap, so a model
  that keeps stopping without moving the list keeps costing turns until the list clears or you stop
  it; set `maxRounds` if you want a limit. Tool turns are pi's own and unbounded either way, so a
  long tool-heavy stretch can be many turns for one round.
- The extension adds one short marker per continuation (~40 tokens), plus one constant tool declaration
  and the prompt entries pi derives from it — `<tools>` line, `<rules>` guideline, description and
  schema, roughly 700 characters together, in every request. None of it carries task state, and there
  is no per-request block. Neither the snippet nor the guideline is gated by the kill switch or
  `/todoloop pause`; only the hooks and the call itself are. Whatever the model needs it reads with the `todo` tool, and that result
  is part of its own conversation rather than a parallel copy.
- `context` never reads the branch: it returns `undefined` unless one of its own markers has to be
  removed, so a normal turn costs one pass over the message list. `turn_end` reads the config, probes the kill switches and
  walks the branch on every turn.

## More

- [pi extension API alignment](pi-api-alignment.md) — the deliberate workarounds, with the pi version
  each was verified against.
- [AGENTS.md](../AGENTS.md) — layout, invariants, tests, release process.
