pi-plan-mode — Codex-like Plan Mode for Pi
Adds a read-only /plan collaboration mode: the agent explores your repo,
grills you for the full scope (design-tree rounds of decision questions,
each with a recommended answer), and only then produces a Markdown plan
file you review and edit before implementation — the same review-before-do
loop Copilot CLI's plan mode gives you.
Usage
/plan <prompt> plan the request (read-only); /plan approve starts implementation
/plan status (state + plan file path)
/plan approve approve the written plan: leaves plan mode, restores tools,
and tells the agent to implement the plan file
/plan check optional fresh-context comprehension check (see below)
/plan edit edit the plan in the editor dialog (Ctrl+G hands off to $EDITOR)
/plan exit | off cancel plan mode without implementing
Keyboard and CLI:
ctrl+alt+p toggle plan mode on/off
ctrl+alt+e edit the plan file (same as /plan edit)
pi --plan start pi already in plan mode
While plan mode is active, tool membership comes from annotations
(packages/plan-mode/tool-policy.mjs), not a name list:
Local readers this repo registers (
readOnlyHintand notopenWorldHint): the code-search tools,skill_select, andview_context.readOnlyHint: truefrom any other tool is ignored.readOnlyHint: falseblocks, including a built-in that claims it.Plan mode's own tools, activated even though they are deferred:
plan_mode_question,plan_fetch_url, andplan_mode_complete(the last one writes only the plan file).web_search, the open-world search tool the mode already used. Other open-world readers (web_fetch,typesafe_ask,read_relevant) stay out, so planning does not grow a second fetch path or a paid judgment call.Unannotated tools from other packages that were already allowed:
read,bash,grep,find,ls, andlore_recall(lore's read-only memory recall; if lore is unloaded the name matches nothing).Tools whose exposure is not
directstay inactive, except the plan-mode trio. That covers deferred, model-only, codemode, and hidden, so the background-job tools are not pulled into the planning prompt. Any other tool call is blocked, and the previous tool set is restored on exit.view_contextis the one local reader newly available in the mode; it only reports context usage.Mutating tools are blocked (
edit,write,update_planand anything outside the plan toolset), andbashruns under a fail-closed read-only allowlist (seeshared/bash-policy.mjs, modeled on the reference plan-mode extension): known mutators are blocked outright, only explicitly allowlisted read-only commands pass (with per-command rules that forbid dangerous flags such assed -i,find -exec/-delete,tar -x,git push,npm install), and anything else is blocked because it is not known to be read-only. Redirects and command substitution are rejected. This is a guardrail, not a sandbox: deliberately scripted mutations (e.g. anawkprogram that writes a file) are out of scope.Read-only web access via
plan_fetch_urlandweb_search:plan_fetch_urlfetches an http(s) URL and returns clean readable content — markdown by default with title/URL metadata, capped at 40k characters;web_searchruns a keyless web search (DuckDuckGo by default, or a configured SearXNG instance) returning titles/URLs/snippets, up to 10 results). Both are powered by the same zero-dependency engine aspi-web-fetch: browser-like headers, redirect following, alternate-content fallback, and theghCLI for GitHub URLs when available. 20s timeout and an Esc-cancellable request (search uses web-fetch's 15s default). These are the sanctioned network paths:curl/wgetstay blocked by the bash allowlist, so web research can't double as a mutation or exfiltration vector.web_searchresolves from the pi-web-fetch extension — if that extension is unloaded, the name matches nothing and the model is simply never offered the tool.A footer status ("plan (read-only)") and a widget (mode + plan file path) keep the state visible while active; both clear on exit.
The agent explores read-only, then grills you for the full scope: it works the request as a design tree and asks each round's frontier questions — numbered, with options and a recommended answer via
plan_mode_question— until every decision is settled and nothing is silently assumed. A round's questions come back as one dialog at a time (they are serialized so concurrent question calls never stack dialogs and hang the turn); answer each and the agent gets the whole round back. Only then does it finish withplan_mode_complete({ plan }), which:- validates the plan,
- asks TypeSafe whether each step names its verification and identifies the callers or consumers it affects, and appends the answer as a Plan check section (see below),
- writes it to
.pi/PLAN.mdin the working directory (not repo-rootPLAN.md— that would collide with projects' ownPLAN.mdfiles and force a per-project gitignore entry) (revisions overwrite in place unless the file was edited — then they land in.pi/PLAN.md.2,.3, … so your edits are never clobbered; writes are atomic), and - ends the turn.
The plan check
The repo's AGENTS.md asks for a judgment before a plan is presented: does each
step name how it will be verified, and are the callers it affects identified?
That is per-step, so plan-review.mjs splits the plan into steps (list items
first, then headings, then the plan as one step), asks both questions for every
step in one request, and appends what falls short to the plan file:
## Plan check
TypeSafe read the steps above and found:
No verification named:
- step 3 — Add a cache layer between the loader and the store
Affected callers not identified:
- step 5 — Rename config.timeout to config.deadline
This is a reading of the plan text, not of the code. Edit the step or say why it holds.
It costs one request with an 8-second timeout and is never allowed to block the plan: no key, a failed request or an unusable answer means the plan is written exactly as submitted. A plan where every step holds up gets a one-line "every step names its verification" instead of silence, so the check is visible when it passes too.
/plan check (comprehension)
Before /plan approve, you can run /plan check: one hidden read-only
child (via subagents/spawn.mjs runChild, pinned to cross-family, no
fleet widget or panes) reads only the plan file path — it never sees the
planning chat — and may read files the plan names. It must restate the plan under
What the plan changes and How it will be done, then list Gaps
(undefined terms, unstated assumptions, missing verification). TypeSafe (Jev) then
answers two yes/no questions: whether the restatement matches the plan goal, and
whether any step depends on something the plan never states. The result is
appended to the plan file as ## Plan comprehension check, and Jev's answers
are logged in the usual jev-decision ledger like the step review above.
Cost: one child run (up to 8 turns, 120s timeout) plus one Jev request (12s).
It is opt-in and advisory — it never blocks or changes /plan approve.
Hard failures are reported honestly:
- A failed, timed-out, or aborted reader is
not runwith the reason, never "no gaps". - A missing TypeSafe key or a Jev failure still shows the reader's restatement
and gaps; the verdict is
not run, never a pass. - With no plan file yet,
/plan checksays so and does nothing.
You then read/edit .pi/PLAN.md — directly, via /plan edit or ctrl+alt+e
(Ctrl+G inside the dialog opens your $EDITOR; the plan is written back when
you close it), or in any editor of your choice — and run /plan approve. That
ends plan mode, restores your tools, and automatically tells the agent to
implement the plan file — no separate exit/implement step. The file is the
durable artifact — it survives compaction and is the implementation handoff,
so plan mode needs no in-memory plan retention.
Notes and non-goals (for now)
- Plan mode state is not persisted across pi restarts; the plan file is the source of truth.
/plan startstill exists as a no-prompt way to enter plan mode, but/plan <prompt>is the intended entry point.- No export/save/fresh-session handoff, settings file, or tool pre-selection menu yet — say the word if you want any of them.
- The bash guard is a fail-closed read-only allowlist: plan-mode bash is
limited to inspection commands (see
shared/bash-policy.mjs). Test runners (npm test,go test), script interpreters (node,python3 -c), installs, and network tools are blocked in plan mode — exit plan mode to run them; useplan_fetch_url/web_searchfor web research instead. The model is additionally instructed not to mutate.
Answering a question the context already settles
plan_mode_question is where a discoverable question becomes a dialog, so it
is also where that can be enforced: the tool, not the prompt. Before showing a
dialog it asks Jev one thing — which option the agent's own recommended
answer names — and answers only when that reading lands on the recommendation
at 0.9 confidence or above. A question is a dialog because the agent was in
two minds about it; a reading that agrees with the recommendation was not
going to be news.
The decision is about the agent, not the user, and it is conservative: a
preference, a cost, an interface others rely on, a deletion, no recommended
answer, an unfamiliar option, a reading that disagrees or is unsure all ask
the user exactly as before. That list is deterministic, in ask-gate.mjs,
not the judge's opinion. When it does answer, the user sees one line naming
the reason and the agent is told the answer was inferred rather than given, so
the plan cannot claim the user chose it.
A first version asked the judge whether "the context settles this" and got
0.32 and 0.57 on the most inferable questions in node scripts/judge-live.mjs
— the right answer to the wrong question, since one line of request is not
context. Reading the recommendation needs nothing the tool does not already
have.
PI_PLAN_JUDGE=off disables it, and without a TypeSafe key it is skipped
silently. Same failure mode as everywhere else: a slow or missing judge means
the dialog happens, never that a question is answered by nobody.
Development
npm test # node --test against shared/bash-policy.mjs, plan-file.mjs, dialog-queue.mjs
npm run check # syntax-check entrypoints + tests