pi-decide
Pi extension. A structured multi-question dialog the model puts to you — with an autopilot that answers it itself when nobody is around, so long unattended runs never block on a human decision.
Package details
Install pi-decide from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-decide- Package
pi-decide- Version
0.3.3- Published
- Oct 27, 2026
- Downloads
- 316/mo · 316/wk
- Author
- zeroicey
- License
- MIT
- Types
- extension
- Size
- 147.9 KB
- Dependencies
- 0 dependencies · 4 peers
Pi manifest JSON
{
"extensions": [
"./src/extension.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-decide
A Pi extension that lets the agent ask you structured questions — and answers them itself when you are not there.
Two halves, one tool:
- Ask.
ask_user_questionopens a tabbed terminal dialog: up to 8 questions, 2–8 written-out options each with a description, an optional markdown preview beside the list, a free-text row, per-question notes, and a Submit tab that reviews everything before it goes back. - Decide. When nobody answers — you are asleep, the run is headless, or the session policy says so — a model picks the best option for each question, says why, and the answer comes back to the agent labelled as an autopilot decision, never as your approval.
pi install npm:pi-decide
Then /reload if Pi is already running.
⚠️
pi-decideregisters the tool nameask_user_question, which is also used by@juicesharp/rpiv-ask-user-question. Run one or the other: two extensions registering the same tool name will fight over the active tool list.
Why the autopilot exists
A coding agent that asks a good question is more useful than one that guesses. A coding agent that asks a question and then blocks forever because the human went to sleep is worse than either.
Interruptions only pay off when someone is actually there to take them. So
pi-decide splits "should I ask?" from "who answers?":
| Mode | Behaviour | Use when |
|---|---|---|
off (default) |
Ask, and wait — but not forever. If nobody answers within deadlineSeconds (15 min by default), the autopilot answers. Because asking is not a promise that a human is still there. |
You are at the desk. |
on |
Never ask. The model answers every questionnaire itself. | Long unattended runs, cron, CI. |
auto |
Open the dialog, but count down. No keypress within N seconds → the model answers. | "I'll be here for a while, but not for all of it." |
The one guarantee that holds in every mode: a run never waits forever. That
is a system invariant rather than a policy outcome — which is why even off has
a safety net. off means "prefer to ask me", not "wait for me indefinitely":
a run that waits indefinitely for someone who went to sleep is exactly the bug
this extension exists to fix.
If you genuinely want a dialog that waits until you come back, say so explicitly:
/decide hold # this session: ask and wait forever, no safety net
/decide deadline 0 # same thing, expressed as config
hold is deliberately awkward to reach and warns when you use it.
Switch per session:
/decide status # what is in effect, and why
/decide off # ask (still capped by the 15 min safety net)
/decide auto 180 # ask, self-answer after 180s
/decide on # never ask (this session)
/decide deadline 60 # tune the safety net only
/decide hold # ask and wait forever (removes the safety net)
/decide reset # back to the config file
The footer shows 🤖 auto or 🤖 auto:180s while a non-default policy is
active (* marks a session override).
Precedence, and why it is asymmetric
no interactive UI (headless json/print) → autopilot, always
per-call `mode: "ask"` → always ask (except over `on`)
per-call `mode: "auto"` → autopilot (except over `off`)
session override (`/decide …`) → wins over the config file
config file `autopilot.mode` → the default
...and on top of all of that, whatever asks also counts down: auto uses
its own delay, off uses deadlineSeconds. Only hold / deadlineSeconds: 0
removes the net, and it says so in the footer (🤖 hold).
Three deliberate consequences:
- Headless mode can never block. There is no terminal to answer in, so the only alternatives are "autopilot answers" and "the run hangs".
- A model cannot escalate itself into a blocking dialog.
mode: "auto"is ignored when the policy isoff— you said you want to be asked, and only you can lift that. - A model cannot silence a question either.
mode: "ask"always waits, even under a session override ofauto/on, unless the session policy ison(which is your explicit "never ask me").
What the model sees back
The answer envelope is written so an unattended decision cannot be mistaken for consent:
These answers were chosen by the autopilot, not by the user: nobody was available.
The user did not see these questions and did not confirm these choices. Proceed on
your own judgement; if the choice turns out to be unrecoverable, say so plainly in
your final report instead of silently assuming it was approved.
Answers:
- Cache: "TTL 5m" [autopilot]
- Scope: "Session store only" plus: unless writes are hot [autopilot]
user note: ask me later
A cancel is equally explicit: "do not treat the cancellation as agreement, and do not re-ask the same questions immediately."
How the autopilot decides
- A stated recommendation wins outright, per question. When the model marks
recommended, that is the answer — no judge call, no label matching, no chance of drift. The model that wrote the question just did the work with full context; a judge reading a truncated transcript can only add error. This is also the direct answer to "if the options already contain the best one, just use it." - Only un-recommended questions reach a judge. One non-streaming completion sees a bounded excerpt of the recent conversation plus just those questions, and returns each answer as JSON with a one-line reason. When every question was recommended, no model call happens at all.
- Every failure still produces an answer. A broken judge, an unreadable reply, a timeout — all of them fall back to the first option. The one outcome this feature exists to prevent is a run that waits forever.
- Irreversible questions get a safety rail. See below.
The safety rail
"Never block" must not be read as "always act". A question that can destroy data, force-push, spend money or publish is not a question an unattended autopilot should answer at all. Two layers trip the rail:
- the model setting
irreversible: trueon a question, and - a conservative scan of the question, option labels and descriptions
(
rm -rf,delete,drop,force push,reset --hard,publish,deploy to prod,revoke,rotate ... key,overwrite,wipe, …).
A tripped question is still answered — but only with an option that reads as
the cautious path (abort, skip, keep, back up first, dry run, do nothing). If no such option exists, the call comes back as refused: the
model is told in as many words to stop, finish what is safe, and report the
decision as blocked on you. A stuck-but-safe run beats a fast destructive one.
The judge never runs on the render path, so a slow model cannot freeze the dialog, and the countdown keeps ticking while it thinks.
Configuration
~/.pi/agent/pi-decide.json. Every field is optional; a missing or invalid
value silently falls back to its default, because a typo must never be able to
wedge the tool.
{
"enabled": true,
"autopilot": { "mode": "off", "timeoutSeconds": 180, "deadlineSeconds": 900 },
"ui": { "bell": true },
"judge": {
"model": null,
"tailMessages": 10,
"maxTailChars": 8000,
"timeoutMs": 90000
},
"limits": { "maxQuestions": 8, "maxOptions": 8 },
"notify": true,
"debug": false
}
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch. When false the tool refuses every call. |
autopilot.mode |
"off" |
off / on / auto. |
autopilot.timeoutSeconds |
180 |
Countdown for auto. 0 means auto behaves like off (waits until the deadline). |
autopilot.deadlineSeconds |
900 |
Safety net for off: after this long with no input the autopilot answers. 0 disables it, which is the only way a run can hang. |
ui.bell |
true |
Terminal BEL when the dialog starts waiting (TUI only). |
judge.model |
null |
null uses the session model. Pin {"provider":"…","model":"…"} to decouple the judge from whatever model the run uses. A pinned model that does not resolve now warns loudly instead of degrading silently. |
judge.tailMessages |
10 |
Trailing messages the judge sees. |
judge.maxTailChars |
8000 |
Hard cap on the excerpt. |
judge.timeoutMs |
90000 |
Abort the judge after this long. |
limits.maxQuestions |
8 |
Per-invocation ceiling. |
limits.maxOptions |
8 |
Per-question ceiling. |
notify |
true |
Show a notification when the autopilot answers. |
debug |
false |
Log validation warnings and judge failures to stderr. |
The file is re-read on every call, so edits apply without a reload.
Pinning the judge model
By default the judge is the session's model. That is zero-config, but it couples
the judge to the run: if a key expires or the provider goes down, the
questionnaire cannot be answered either. Point judge.model at a cheap model on
a different provider to remove that coupling.
Keys
| Key | Action |
|---|---|
↑ ↓ |
Move the highlight |
Enter |
Pick the option / open the focused row |
Space |
Toggle a checkbox on a multiSelect question |
Tab / ← → |
Move between questions (and the Submit tab) |
n |
Note on this answer; on the Submit tab, one global note |
Esc |
Back out of the editor; cancel the whole questionnaire |
Ctrl+] / c |
Collapse the dialog to one line, then restore it |
| click the title bar | Same, with a mouse (fullscreen mode) |
Making room to read
The dialog sits in the editor slot, so every row it draws is a row of agent output you cannot see — and the whole point of a question is usually that you need to read something first. Three things keep that from turning into a staring contest:
- It never takes the whole screen. It stops at roughly 75% of the terminal height, so the footer and the message that triggered the question stay visible.
[ − collapse ]in the title bar, andc. Both shrink it to a single line — still showing the question, the answered count and the countdown — and both bring it back. Clicking the button works in fullscreen mode; the key works everywhere.- When rows really run out, it says what it hid. The option list is
windowed around the highlighted row with an explicit
… N more optionsmarker, previews and descriptions go first, and the key hints at the bottom are never the thing that gets cut.
Type something. is always the last row of a question and replaces the selected
option — you cannot end up with "LRU" and an unrelated sentence. In
multiSelect mode, a typed answer is exclusive on purpose: a mix would be
ambiguous, and a free-text answer that silently kept checkboxes is worse than
asking twice.
Multi-select
Set multiSelect: true on a question and its options become checkboxes:
✓ LRU
❯ ✓ TTL 5m ★
▢ No cache
Type something.
Add a note
Space toggles the highlighted row, Enter submits every checked one. The
hint line only advertises Space toggle when the current question actually
accepts it, so a single-select dialog never suggests a key that does nothing.
Two details that are deliberate rather than incidental:
- No numbering. A number answers "which one?", and multi-select is asking
"which of these?" — you pick a label, not position 3. The boxes are uniform
width (
▢/✓), so a label never shifts sideways when it is checked, and the appendedType something./Add a noterows keep the same column without pretending to be tickable. ★is not the checkbox. It marks the option the model recommends, which is a different axis from the user's tick: exactly one option can be recommended, any number can be checked.
The model is told to reach for this when options are not mutually exclusive — an invented "All of the above" option loses which items were actually chosen, and gives the autopilot nothing to reason about.
Running unattended
The intended pattern:
# Start a long run, then walk away.
/decide auto 120
The first questionnaire you miss waits two minutes, then the autopilot answers,
the footer notifies you, and the run keeps moving. Ask for
"recommended": "<label>" from your own prompts and the quality of those
decisions tracks the quality of your recommendations.
Related
- The autopilot is the companion of
pi-unstall: that one notices when a run stopped, this one notices when a run is waiting. @juicesharp/rpiv-ask-user-questionis the extension this one is modelled on. It does the dialog well; it has no answer for "nobody is home".
Verified behaviour
Every claim below was exercised against a real Pi process, not only in unit tests.
| Case | Result |
|---|---|
Headless (-p), policy on |
autopilot answers, envelope labelled [autopilot] |
| Six consecutive calls in one run | 6/6 answered, none hung |
Explicit mode: "ask" in a headless run |
overridden to autopilot — cannot hang, and the model is told so |
| Judge model pinned to a name that does not exist | loud warning, falls back to the stated recommendation |
Judge overruns judge.timeoutMs |
aborted, falls back, run continues |
Runtime limits.maxQuestions below the model's request |
rejected with the exact count, nothing shown |
recommended: "Drop it" on a question whose description says rm -rf |
steered to "Back up first", envelope says so |
| Irreversible question with no cautious option | refused; the model is told to stop and report it blocked |
off policy, real TUI dialog |
tabs, stars, notes, free text and the Submit review page all behave as documented |
off policy, nobody answers |
the 15 min safety net fires and the envelope says so |
auto 6, nobody touching the keyboard |
self-answered after 6s, envelope says [timeout, after 6s] |
| Keys arriving after the dialog settled | ignored without throwing |
| Widths 0–140 columns, CJK / emoji / ZWJ / BiDi / ANSI in a preview | no over-wide line, no throw |
Requirements
- Node.js 22 or newer.
- Pi Agent 1.3 or newer.
License
MIT