Workflow Modes (@gadhs/pi-modes)
On this page
A mode is a working style you switch with one keystroke: how capable the model is, how much it may do without asking you, and how carefully its actions are checked. Four modes ship — auto for normal development, plan for read-only investigation, manual when you want to approve every command yourself, and yolo when you want no checks at all.
Everything on this page is configuration, not code. If you want different models, different rules, or your own mode, see the configuration cookbook — most changes are a few lines of JSON.
Who decides what
The design rule behind everything here: simple checks are done by simple code, instantly. A fast, separate AI model (the "judge") is consulted only when a pattern cannot decide — and it answers exactly one question: is this action dangerous? Whether your code is good, in scope, or what was asked for is reviewed by people, not by a model watching over your shoulder.
| Layer | Decides | Cost |
|---|---|---|
Workflow guards (first, before anything else) |
Commit message format, |
~1 ms |
Mode rules |
Routine commands ( |
~1 ms |
|
Actions you name (pushes to main, merges, publishes…) — these always stop and ask you, no model involved. Empty until you configure it. |
~1 ms |
Workspace-write stage |
Any |
~1 ms |
Model judge |
Whatever the rules could not decide: is the action itself dangerous? |
~5 s, rare |
You |
Anything the layers above passed upward |
interactive |
Configuration resolution
-
$GADHS_PI_MODES_CONFIG— explicit path (CI, tests) -
<agentDir>/gadhs-pi-modes.json— per-user overlay, merged over the package baseline (agentDir=$PI_CODING_AGENT_DIRor~/.pi/agent) -
modes.jsonbundled with the package — the org default
Your personal overlay merges with the shipped defaults: cosmetic fields (model, effort, labels) simply override, but permission rules combine strictest-wins per pattern — where you name the same pattern as a shipped rule, the stricter action stands, so agency policy cannot be loosened head-on. A more specific pattern of yours is kept as written, and because evaluation is most-specific-wins it can carve an exception into a broader shipped rule (that is how the known_hosts recipe works). A carve-out written that way — as a narrower form of the shipped pattern — is named in a warning at session start, so a loosened baseline is not silent; write exceptions as refinements, since a sibling glob that merely overlaps a shipped rule carves the same hole unreported. Because the agency rules live inside the package, updating the package updates the rules; there is no copy on your disk to go stale.
Top-level config (ModesConfig)
| Field | Type | Default | Meaning |
|---|---|---|---|
|
boolean |
|
Apply `cycle[0]’s model/tools on a fresh session. Persisted modes are re-applied regardless. |
|
string | null |
|
Keyboard shortcut for cycling modes. (Shift+Tab is reserved by pi.) |
|
string[] |
shipped: |
Order for |
|
boolean |
|
Print the active mode + shortcut hint at session start. |
|
boolean | path |
|
First-run seeding of the permission-system config. |
|
string[] |
12 shipped patterns |
Patterns that stay interactive in every mode, including yolo — see The safety valve. |
|
string |
unset |
Operator override for the packaged global guidance digest (replaces it wholesale). |
|
object |
shipped profiles |
The delegation gate’s declared tool profiles per agent — see The delegation gate. |
|
ModeDescriptor[] |
four shipped |
The modes themselves. |
Per-mode config (ModeDescriptor)
| Field | Type | Meaning |
|---|---|---|
|
string |
Stable id and UI label. Required. |
|
|
Main-agent model applied on switch (composed-provider path, so Vertex/ADC models work). Omit to leave the model alone. |
|
|
Thinking effort applied with the model. |
|
string |
Appended per turn as |
|
string[] |
UX filter of exposed tools. Not enforcement — that is the permission system’s job. |
|
|
Deterministic rules,
evaluated most-specific-wins (so |
sh` when the unit escalated was only |
sh`
starts with |
grep -c*` neutralises the broader `env |
grep*` deny - and then a pipeline deny or ask overrides a less restrictive unit verdict; a pipeline allow leaves the unit’s verdict (or the judge) as it was. |
|
|
|
|
|
|
|
string[] |
Patterns that always stop and ask you, before
any model gets an opinion and before any rule |
|
string[] |
Subagents this mode may delegate to. Omitted/empty = no delegation. |
|
JudgePolicy |
The shipped modes
| Mode | Model / effort | Guard | Workspace writes | Judge | Character |
|---|---|---|---|---|---|
|
Opus 5 / medium |
yes |
allow |
Sonnet 4.6 |
Implement end-to-end. Rules absorb the routine; the judge sees only the ambiguous; writes in-repo are free. |
|
Fable 5 / high |
yes |
— |
Sonnet 4.6 |
Investigate and design, change nothing. Write/edit deny |
|
Opus 5 / medium |
yes |
allow |
none |
The human approves every ask personally. Same rules as auto, no model in the loop. |
|
Opus 5 / medium |
no |
— |
|
Auto-approve everything. yolo means yolo: no rules, no guards — only the safety valve (empty by default) and pi’s structural gates remain. |
Switch with /mode (a picker), /mode <id>, /mode next, or just press
F2. The status line always shows the active mode and what it is doing; when
something is blocked, a line appears in the transcript saying who blocked
it (a fixed policy, the judge, or a workflow check) and why — expand it for
the full reasoning and what to do about it. /gadhs-reset lists every
local tuning file in force and, with --apply, moves them aside so the
package defaults apply again (tuning).
The judge (safety only)
Each mode may declare a judge. It registers as the gadhs-mode-judge link
in the permission system’s authorizerChain and reviews only ask
decisions that survived every deterministic layer.
The judge sees the action and nothing else — not your conversation. It answers one question: is this dangerous? (Running code it cannot read, deleting things, leaking secrets, weakening security, escalating privileges.) It is explicitly not a code reviewer: safe work that is oddly styled or bigger than expected sails through, because quality is your job and your reviewers' — not a model’s.
Secret material is a deny on every rubric, including plan mode’s, whose
question is otherwise "does this mutate anything?": a read-only command whose
output would print credential values — cat or git show of a .env
file or a key, the process environment filtered for a credential term, a
container’s environment through docker inspect, a pod’s secret through
kubectl get secret -o yaml, sops -d, vault kv get, a token printer —
is refused, and the reason names the names-only or count-only alternative
(printenv | cut -d= -f1, grep -rl, test -f). Plain env is allowed
(#120). The read tool’s path rules deny the credential locations
(~/.ssh, ~/.aws, gcloud, kube and docker configs) before any judge; the
rubric covers the routes a path rule cannot see.
| Field | Default | Meaning |
|---|---|---|
|
required* |
Judge model as |
|
unset |
Thinking effort for judge calls. |
|
packaged rubric |
A bundled prompt name ( |
|
|
|
|
unset |
Per-mode safety-valve patterns (replace the global list for that mode). |
|
|
Call budget; timeout defers. The first call of a session is warmed in the background (~20 s cold vs ~5 s warm). |
|
|
Reply budget — the contract is one JSON object. The
budget is for the answer: a model that thinks first gets room for that on
top, added by the Vertex provider per catalog entry ( |
|
|
Denial-loop guard — see below. |
|
|
Session-total guard. |
Which model. Both shipped judges run on Sonnet 4.6 at low (#102,
2026-09-12), argued on the Model battery: every shipped model, per role, as permutations page’s judge table:
over the corpus as it then stood (122 cases) three times, Haiku low and Sonnet low are
within three verdicts of each other (352 and 349 of 366 exact) and neither
produced an unsafe verdict; Sonnet answers in 1.5 s median against Haiku’s
4.2 s at the same price per call; on the battery’s shipped-stack coder
cells the faster judge took a Haiku lane from 91 s to 69 s and its cost
level with the bare lane (an Opus lane, whose commands are almost all
rule-allowed, did not move - its shipped overhead is the larger prompt).
Haiku was cleaner on one critical case Sonnet deferred once; a defer is
friction, not a breach. A different judge is a per-user overlay
(Configuration Cookbook).
An unknown key in a judge block is refused by name when the config loads
(mandateTurns and mandateCharsPerTurn, left over from the deleted mandate
axis, were accepted and read by nothing until #62; delete them from any
overlay that still sets them).
Every failure mode — missing model, missing credentials, timeout, transport error, unparseable output — resolves to defer, never allow. The verdict contract is strict JSON, property-tested against arbitrary output.
Denial-loop guard: after 3 consecutive or 20 total denials the session stops consulting the judge and defers to the human — a looping agent stops burning judge calls, and the human finds out something is wrong.
The safety valve
safetyValve (global) or judge.alwaysAsk (per-mode) name actions that stay
interactive everywhere, including yolo: publishes, force pushes, pushes to
main/master, tags, hard resets, --no-verify, merges, releases. The valve
caps an allow to defer — it never short-circuits adjudication, so a
deterministic deny still denies. Twelve patterns ship. yolo’s own alwaysAsk list is empty by default —
yolo means yolo — but adding patterns there is the one way to keep specific
actions interactive even in yolo (see the cookbook).
Patterns - the valve’s, highConsequence’s and every rule’s - are globs, not
regexes: ` spans anything, everything else is literal, case does not
matter. *Anything includes a newline (#156): a bash unit is often several
lines - a heredoc, a quoted multi-line message - and production matches
terraform apply <<'EOF' … production … EOF the same as its one-line form.
Before this fix it did not, and a multi-line unit matched nothing at all.
Workflow guards (deterministic, at the tool-call seam)
Shipped in @gadhs/pi-workflow since ADR-004 — mode-independence is their
design, so the package whose organizing concept is per-mode variation was
the wrong home. They are documented here because this page is where the
enforcement story reads as one piece; their traces render through pi-modes'
status line and inline widgets when both are loaded, and their decisions do
not change when it is absent.
These run in every mode, yolo included, before any permission ask exists. They are not permissions and cannot be opted out of: yolo answers “can I run this command”, never “do the coding standards apply to me”. A mode that auto-approves asks still attributes its commits, still reflects on each staged diff, and still writes SPDX headers.
All plain code — never the judge — and each completes an action where the correct completion is unambiguous, rather than failing it:
How the guards read a command line
The guards see bash the way the shell does, not the way grep does. A
tree-sitter-bash parse (the same WASM parser, at the same version ranges,
that the third-party permission engine @gotgenes/pi-permission-system
uses — the upstream package architecture consumes
unmodified; range equality is pinned by test, because two parsers drifting
apart recreates the very differential this closes) hands them commands as structure: argv with non-literal
tokens marked unknown, wrapper chains (timeout 300 …) unwrapped, redirects
separated, byte spans preserved. Warmed once at session start; until then,
and whenever structure cannot vouch — an expansion that could hide a
command, a global flag the extractor does not model — every guard answers
byte-identically to its original text matcher. Structure may only narrow
false positives, never widen false negatives. None of these are promises in
prose only — each is a test you can run: parser range equality in
bash-structure.test.ts, the cold path’s byte-identity and the warm/cold
pair in git-guard.test.ts, and the differential in
git-guard-chaos.test.ts, which runs BOTH implementations over hundreds of
generated compounds and fails on any divergence structure cannot prove is
quoted prose.
What this buys in practice: a heredoc reciting commit commands — a commit
message quoting the workflow, a test corpus containing git strings — no
longer trips the guards, which blocked exactly that four times while this
feature was being built. That holds even when a substitution elsewhere on
the line makes structure decline the judgement (#68): the parser still
vouches for where the heredoc bodies are and whether their delimiter was
quoted, so the text fallback reads the line with quoted bodies (<<'EOF',
which the shell does not expand) blanked. An unquoted body stays text —
$(…) inside it runs — and so does every body when a shell or eval is on
the line. Code handed to a shell the parser never opens is unknowable, in
both its spellings: a -c string (sh -c 'git commit …') and a shell fed
its program on stdin (bash <<'EOF' …, cat <<'EOF' | sh, printf '…' |
bash — a shell with no -c and no script file reads stdin; #72). Structure
declines there and the text floor decides, which does see the string and
the body. A shell running a script file (bash deploy.sh) is a program
on disk, like any executable; the guard never reads those and does not
pretend to. One deliberate exception stays:
the --no-verify backstop keeps its raw text form as a permanent floor
whenever structure declines, because that guard is the only thing standing
between a hook bypass and the repository.
A commit the guard can see leaving the session’s checkout is refused
rather than misjudged (#68): cd other && git commit and git -C other
commit are refused by name — the review hashes and reads the session’s
index, and the branch rule reads its HEAD, so a commit elsewhere cannot be
judged from here; open a session there. A relocation the guard cannot
follow (cd "$X", pushd, cd -, env -C, --git-dir, --work-tree)
is refused as unknown rather than guessed. Not seen: bare GIT_DIR=x /
GIT_WORK_TREE=x environment prefixes, which the parser drops, and a cd
inside a shell’s -c string, which no walk opens.
| Guard | Behaviour |
|---|---|
Commit format |
|
|
Blocked, including clustered short flags ( |
Main-branch protection |
|
Co-Authored-By |
Appended mechanically as a |
SPDX at write time |
A new source file missing the header gets it prepended (comment syntax per language, shebang kept on line 1). Existing files are never retrofitted mid-overwrite. |
Debt markers |
An added |
GitLab identity preflight |
|
Reflection pause |
Every commit is reviewed once against J1–J8 by a cold reader — a model that
did not write the diff and has no session history, seeing
The reviewer is never weaker than the author: the floor is Sonnet
(0.89 on the model battery’s reviewer role, re-measured 2026-09-12 under the
snapshot loop), and a model measured above it — Fable 1.00, Opus 5 0.96 —
reviews a change it authored; everything else gets the floor: the Gemini and
MaaS models, Haiku at 0.39, and Opus 4.8, which tied the floor at 0.89 on
the store’s measure and read 0.83 against Sonnet’s 0.86 on a second (three
runs to a scratch store, 2026-09-13; pooled 0.85 against 0.87) — not above
it, at twice the price. A weaker reviewer cannot catch what a stronger writer got
subtly wrong and fails in the worst way — PASS with confident citations. An
unranked model reviews itself rather than being assumed weak. The authoring
model is identified by the session’s provider and id, so a Gemini author
on It informs rather than gates. A cold reader over-flags by construction, so a
hard block on its opinion would be a denial loop driven by missing context; you
see the findings, and the author must answer them or say why it disagrees —
in the commit message, where the answer is kept (above). If
the reviewer cannot be reached the commit still proceeds, with a warning that
the diff has had no cold read.
+
A review ends one of five ways, and the notice, the block text and the log
( |
Setup drift |
At session start (agency-hooks repos only): warns when |
These guards keep the agent on the agency workflow; they are convenience and early feedback, not the security boundary. Your repo’s git hooks still cover commits you make yourself.
Talking to the human: ask, enter_plan_mode, exit_plan_mode
Three tools let the agent involve you deliberately instead of guessing or stopping. They ship with the extension and need no configuration.
ask-
Puts a question to you mid-task and waits. Offer-options questions render as a picker; open questions as a text box. Either way a “Let me answer in my own words” choice is always present, because the agent’s two options are frequently a false dichotomy and the real answer is a third thing. Available in every mode — clarification is not a planning activity, and an agent midway through an implementation that hits a genuine fork should not have to stage a whole plan to ask one question.
enter_plan_mode-
The agent puts itself into plan mode. Deliberately ungated: entering NARROWS its own authority (writes deny, the tool list shrinks, delegation narrows to read-only subagents), and there is nobody to protect from an agent taking fewer powers. Exactly ONE file becomes writable: the plan draft, announced by path on entry. It lives under the agent directory’s
plans/(beside pi’s own per-workspace session state — never/tmp, which dies with reboots mid-plan, and never your repo, which plan mode promises not to touch), is stable per workspace, overwritten by each new plan, and survives a crash — re-entering plan mode announces a surviving draft instead of starting blank. The carve is an exact-path permission stage: a sibling file, a workspace file, or the same path in any other mode gets no special treatment. exit_plan_mode-
Asks you to approve the plan and leave. Before you see anything, the draft gets a cold-review attempt. The same reflection pause that reads every staged diff reads the draft: a model at least as strong as the one that wrote it (the J-gate rule — Sonnet floor, otherwise the authoring model), with no session history, answers the delivery protocol’s four fixed questions and the call returns to the agent with the findings instead of reaching you. P1 conventions (issue named, one header Status, test categories, risks with directions, delivery steps) · P2 contextless implementability — the bar (could a fresh agent or human execute it with no further clarification) · P3 pre-1.0 compatibility scaffolding (this question only removes) · P4 readable by a human (fixed once, never a blocker). The plan is checked against the repository, not against itself: the planning model passes the files its plan names as
reviewPaths, the guard verifies and inlines them, and P2 asks whether the named files, functions and seams exist in those files and are described accurately — a named file not supplied is a FAIL, not N/A, because the author could have supplied it. A FAIL on P1 or P2 is fixed in the plan; anything else the reviewer would raise is an issue for the author, or is dropped. Round one is spent per plan, not per text (#71): the agent’s nextexit_plan_modereaches your dialog whether or not it edited the draft, with the round-one review beside it — marked draft edited since when the text moved — and you, not the verdict, decide. That pass consumes round one, so after your answer the revised plan gets a fresh cold read before you see it again. There is no round three by construction; round two through a different vendor is #70. An unreachable reviewer gets a second try and then yields to self-review, exactly as for commits, and an interrupted or unreachable first attempt spends no round.This one is a privilege escalation, so your dialog is the authorization. The draft file IS the plan: the extension reads the file it announced at entry, so what you review is what was written — the agent cannot present a different document than it authored (the same shape as the commit review reading
git diff --cachedinstead of believing the commit message). The document is presented in one composed pane that owns the whole review: a scrollable viewport on top, then one bordered block with the scroll position (lines a–b of N, only when the document overflows), the cold-review line, the three choices and a key legend. One component, one input owner, a fixed key split:↑/↓move the choice,enterconfirms,esckeeps planning;PgUp/PgDn,j/k,home/end,ctrl+u/ctrl+dscroll the document. Nothing else does anything: one component owns the keyboard, so there is no focus to juggle and nothing forctrl+oto clobber. The earlier arrangement was a display-only right-half panel beside a separate dialog — unfocused, because a focused one stole every key from the dialog — which is why the plan could not be scrolled at all.The cold-review line reports the round-one review for this plan:
Cold review: CLEAN — claude-fable-5-1, 41 s, orFLAGSwith the findings appended under a heading at the end of the document so they scroll with the plan, orUNREVIEWED — <reason> (attempt N of 2), ornone recorded. It comes frompi-workflowover the bridge (planReviewed, the one member that runs in the reverse direction), matched to this session’s plan; when the draft has been edited since the review the line says so —— draft edited since— rather than hiding the verdict or wearing it as current (#71). The plan is also rendered into the transcript, which is the durable record of what was approved.It fits whatever terminal you have. The pane is a focused overlay — the one component, mounted so that the TUI composites it at a screen position and clamps its height rather than letting it grow the transcript. (#40’s hazard was a focused overlay stealing keys from a separate dialog; with one component there is nothing to steal from.) An earlier cut rendered in the normal flow with a floored viewport, left two rows for pi’s own chrome (six at rest), and pushed rows into scrollback on every frame, which looks like the scrollbar crawling to the top of the session and snapping back. There is no minimum size. The three choices are the only mandatory region; everything else yields to them in a fixed order as space runs out — the document first, then the bottom rule, then the legend, then the seam, then the review line, which survives longest because a verdict bears on the decision. With no room for the document the seam reads
plan above, in the transcript, which is true: the full plan is rendered there before the dialog opens. On a very tall terminal the document is capped at 40 rows, so you get more transcript rather than more pane. Below three rows the choices themselves cannot be shown, and the tool refuses rather than drawing options you cannot read — a known size is never treated as an unknown one, which 0.8.1 got wrong for a one-row terminal: its budget floored to zero, zero was read as "no terminal", a fifteen-row pane was assumed, and the approval went through against a dialog nobody could see.Consent fails closed through the pane: only
enteron an offered choice resolves a choice;esc, a dialog that throws (you are told why), a terminal too short for the choices, and a session with no UI all mean “keep planning”. Pins:packages/pi-modes/test/review-pane.test.ts(a sweep from 0 to 200 rows asserting the render never exceeds its budget and shows every choice or nothing; the yield order; a budget below the choices rendering nothing;paneFitsat the boundary; the tall-terminal cap; every line within width with styled/CJK/emoji content; each pager key; region isolation; unrecognised keys; the review-line variants, edited marker included) and the “the approval pane, through the real wiring (#43)” tests intest/wiring-lifecycle.test.tsand “escape keeps planning: the pane resolves undefined and the tool fails closed” intest/wiring.test.ts. Approval carries a duty with it: the draft is scratch, and the durable copy — a committed in-repo plan document per the delivery protocol — is the implementer’s first obligation. Then three choices: implement now, implement but ask before each command, or not yet — keep planning, with optional feedback relayed to the agent verbatim. “Not yet” is a first-class outcome, not a rejection.
The round trip is model-neutral, however you entered. Plan mode applies
its own model on entry (deep reasoning is part of what the mode is), but
approval hands you back the model you were on when planning started — or,
if you switched models during planning, that one — and returns you to the
mode you actually left, whether you entered through enter_plan_mode,
/mode plan, the picker, or the cycle shortcut. Only choices are restored:
entering from a mode’s own default lands you on the destination mode’s
default, with no notice. The restore lands inside the switch and is what
the transcript’s one mode-change message names — "Model is <yours> (your
choice before planning, restored; the mode’s default was not applied)" —
never a default that was applied and then replaced (#109: the first version
announced the default into the transcript and restored yours with a UI
notice only, so the agent believed it was on a model it was not). It does
not depend on stickyModel being on; the two agree by construction, because
both decide “yours or the mode’s” by the same rule (the model is yours when
it is not the last one a mode applied). One residual: a session resumed
while already in plan mode has no record of the pre-plan model, so approval
there falls back to auto and cannot hand that model back — the swap to the
destination default is announced, never silent — while a model you switch
to during the resumed planning is still restored, since that half of the
rule reads the live session. Pinned with the two records pi persists and we
re-read at start (our mode entry and the last model_change; pi itself
replays neither):
test/wiring-lifecycle.test.ts "a resumed session cannot hand back the
pre-plan model (#49)" — both halves: the pre-plan choice lost, the mid-plan
choice kept.
The asymmetry is the whole design, and it is enforced by failing closed: no
UI, a dismissed dialog, an unrecognised answer, or an error inside the dialog
all mean keep planning. pi’s headless UI returns undefined from a dialog,
which is indistinguishable from you pressing escape — so anything that is not
an explicit, offered approval reads as “no”.
ask deliberately fails the other way: nothing is authorized by a question,
so a dismissed one degrades to “no answer” and the agent proceeds on its own
judgement, stating the assumption it made. Same primitive, opposite defaults;
conflating them would turn a dismissed dialog into consent.
/mode — an agent-callable exit would restore
write access with no human in the loop, which is exactly what the approval
dialog exists to prevent.
Subagents get none of these three. Their asks are adjudicated by the parent’s chain, and you could not tell which child was asking.
Headless hosts: pi --mode rpc and whatever drives it
pi runs the same extensions under hosts other than a terminal — an IDE
plugin, pi-web-ui, a supervisor’s daemon, the DHS pivot client — through
pi --mode rpc, where every dialog is serialised to the host as an
extension_ui_request and waits for its answer. pi says which host it is
under (ctx.mode: tui, rpc, json, print), and outside the
terminal the extension behaves differently in exactly these ways
(#135):
-
Every dialog is bounded, and silence is the safe answer. pi’s own dialog timeout does the bounding; an unanswered
selectresolves to nothing, an unansweredconfirmto no. There is no timer of ours. -
A deferred permission ask is put to the host, not to the permission system’s dialog. The gate’s stages run unchanged; when the outcome would have been “ask the human” — a high-consequence match, a safety-valve cap, the loop guard, a judge that could not decide — the extension asks the host to confirm, naming the tool, the command or path, the mode and why it was deferred, with
unattended.gateTimeoutMs(30 s) to answer. Yes inside the wait allows. No denies, attributed to the human at the host. Silence denies, attributed to nobody: the trace saysunattended BLOCKED, the reason says how long it waited and what to do (run the session interactively, or allow the pattern in the mode’s rules). The permission system receives allow or deny and never a defer from a headless session, so a host that does not forward dialogs cannot hang a turn. -
Plan approval is a plain
select. The pane needs a terminal; a host renders a select natively. The plan is already in the transcript (it is rendered before any ask), the cold review’s verdict goes ahead as a notice, then the three choices go withunattended.dialogTimeoutMs(10 min). An explicit approval approves, exactly as in the terminal. No answer is no answer: the tool says “No approval from the host within N min; you are still in plan mode” and does not ask for feedback — that prompt would wait on the same silence. An explicit “keep planning” still asks, bounded by the same wait. -
askcarries the same dialog wait; a timeout reads as “no answer”, which it already renders.
In the terminal none of this applies: the pane, the permission system’s own
dialog with its allow-always memory, no timeouts — a human is at the
keyboard. A host with no dialogs at all (hasUI false: pi -p, json)
is not asked: ask and plan approval keep their refusals, and a deferred
permission ask is denied as unattended with the host named — never booked
as a human’s “no” from a host that has no human. Both waits are tunables (tuning);
0 means do not ask at all - deny or refuse without the dialog. What a
headless session cannot do: offer “allow always” — the extension returns
one-shot decisions — or reach another extension’s dialog (pi gives no hook
for that; the extension’s own dialogs reach a paired phone through the ask
broker below). The trace’s sources for these endings are host,
unattended and, for a phone, remote.
A paired device: the ask broker
The pivot client (see Working remotely) lets a developer
hand the session they are in to their phone with /remote-control. pi has
no hook for one extension to observe or answer another’s dialog, and every
ask a phone must answer originates here — the gate defer, plan approval,
the ask tool, the memory picker — so this package carries an ask
broker (#140): one ask is put to the local dialog and every registered
remote answerer at once — and to every answerer that registers while the
ask is open (#157). The first answer wins and the losers are cancelled
through pi’s own signal option. With nobody registered the local dialog
still gets a live signal, and that is the only difference from a session
without a phone: the ask stays joinable, because the headless plan approval
that waits ten minutes waits for exactly this — someone picking up a phone.
The phone that connects mid-ask is offered the ask it arrived for, with the
same id, and may win it; a phone registering after the ask settled is
offered nothing. One ask is outside this: a terminal gate defer opened
with no phone attached is the permission system’s own dialog (the row
below), decided at the defer, and a phone attaching during it is offered
nothing — the next defer is the broker’s.
Two rules decide everything else:
-
The winner decides; the loser’s value is never read. pi resolves a cancelled selector to nothing and a cancelled confirm to no — values indistinguishable from a dismissal or a refusal — so an outcome is classified by who settled, never by what the cancelled side returned. A remote answer counts only if it validates: a boolean for a confirm, one of the offered options for a select, text for an input. Anything else — a malformed answer, an unknown option, a throw, a dropped link, a phone that unregisters mid-ask — is a decline, and the desk dialog keeps waiting.
-
A phone adds an answerer, not a deadline. The wait comes from the host kind, never from the phone being there:
| Host | Wait | Who may answer |
|---|---|---|
Terminal, no phone |
none |
the keyboard (the permission system’s own dialog for a gate defer) |
Terminal, phone attached |
none |
the keyboard or the phone; the gate defer becomes pi-modes' confirm |
Headless, no phone |
|
the host; silence denies or refuses |
Headless, phone attached |
|
the host or the phone; silence still denies or refuses |
What the desk sees while a phone is attached: a gate defer is pi-modes'
confirm (yes / no, one shot) instead of the permission system’s richer
prompt with its allow-always memory — one dialog on both surfaces is the
point. Plan approval keeps its pane; a phone’s approval closes it. A phone’s
answer is attributed to the human on the paired device: the trace source
is remote, the deny bracket says so, the review log’s
gadhs_host_ask.verdict line carries by: remote, and an approval from
the phone is announced and traced via: remote. Asks the broker does not
carry — the permission system’s own prompts on the excluded surfaces,
third-party extensions, pi’s built-ins — are answerable only at the desk;
the pivot client shows them as a nudge, not a sheet. The registry is
process-global (globalThis[Symbol.for("gadhs.pi-modes.ask-broker")],
versioned) because two extensions in one process are not guaranteed one
module instance; the contract a remote implements is exported at
@gadhs/pi-modes/ask-broker. pivot’s rule keeps the table honest:
pi-remote registers only while its link to a device is live and
unregisters on loss.
How subagents return structured results: yield
A subagent finishes by calling yield with a result matching the contract in
its profile. If the payload does not match, the tool returns every problem at
once rather than one per attempt, and the child retries in the same turn (up to
three times) before the harness accepts prose. Every accepted payload
(there is no size threshold) is written to a sidecar beside the child’s
session record and collected by the parent, foreground or background alike,
then placed in the result the parent reads — appended to the subagent
tool’s result, or to the get_subagent_result report — as "returned a
checked result" (#161), and traced for the human. That matters because
yield ends the child’s turn: the prose the tool returns is whatever the
child said before yielding, and the checked data is nowhere in it; before
#161 the payload arrived as a separate gadhs-contract message and the
fetched report itself showed a header and nothing, which read as an agent
that had returned nothing. (The message path remains for a result the
tool_result pass did not see.) A child whose session has no file is told
its result was accepted but not delivered, and keeps its turn to restate it
in prose.
How the parent finds the sidecar decides whether five children of one agent type get their own results. A fetched background report names the child’s transcript ("Full transcript available at: …"), and the sidecar beside that file is the child’s and no other’s — the exact join; a child that did not yield yields nothing, never a sibling’s. A result that names no transcript — the foreground result, or a report from a pi-subagents that does not print the path — falls back to the join that was the only one before #161: (agent name, written after this spawn started), newest first; foreground spawns do not overlap, so it holds there. A five-agent hunt on the old join delivered one child’s payload for another’s fetch and lost a third.
While a backgrounded child runs, its trace line says so — "is running in the background — id …" (#160); "finished" comes with its fetched report, not with the launch acknowledgment, which used to read "finished — 0.0s".
yield confers no capability — it is a child’s exit door, not a power — so
parents are never granted it.
The retry ladder is a safety net, not the plan: each packaged agent’s body
shows the exact data object its schema declares, field names included,
because the first battery run (#82) found every model’s first yield rejected
when the body said only "put your complete result in data`" - one sent
prose, one named the array after the body’s own heading (`evidence for
citations). A test in @gadhs/pi-agents fails if a body ever stops naming a
schema field. Measured after the change: retries per successful yield fell
from 1.5 to 0.17 on Haiku, 1.0 to 0.25 on Sonnet, 2.0 to 0 on Gemini 3.7.
The delegation gate
canSpawn lists which helper agents a mode may launch (plan mode, for
example, may launch read-only explorers but nothing that writes). The gate
is deterministic: the agent must be on the mode’s list, must have a declared
tool profile, and that profile must not include tools the mode itself lacks
— and an unrecognisable spawn request is refused outright. (Set
subagentPolicy.enforce: false to turn the gate off entirely.) Anything a
helper then tries to do is checked by your session’s rules, in your
current mode — a helper never gets looser permissions than you have.
Each helper runs on its own model, independent of the session’s: Explore
and Verify on Gemini 3.8 Flash, Research on Sonnet 4.6, as shipped
in @gadhs/pi-agents and argued on the Model battery: every shipped model, per role, as permutations page;
tuning shows the
per-user override. A helper’s model is a routing choice, not a permission
one — the gate above applies whatever it runs on.
What a helper cost. pi’s footer prices the main session only; a helper
is its own session and its spend appeared nowhere in dollars. Now each
finished helper’s trace line carries it — Explore finished — 31.8s ·
30,683 tok · $0.0137 (gemini-3.8-flash) — and the status line keeps a
running total, helpers $0.419 (3), beside pi’s own figure. Since 0.37
the spend is in pi’s figure too: the subagent tool result (or the
get_subagent_result that fetched a background report) carries the
helper’s usage field by field, which pi persists on the result and counts
in its footer, /session and RPC totals — so it survives a restart, and
the status line’s breakdown is rebuilt from the result’s details.gadhsHelper
when a session is resumed (#124). The figure is exact when it has no tilde: this extension runs
inside every helper session too and ledgers the usage pi-ai priced on each
of the helper’s messages, cache reads included; when a spawn ends, the
parent claims the
child session that started after the launch and has itself ended — a
still-running helper cannot be claimed, so a fast one finishing takes only
its own entry. Attribution is by start time, so two helpers launched in the
same second could still swap figures between them; the total is the sum
either way. A ~$ figure is a floor - pi-subagents' own token record
priced at the catalog rate, which omits cache reads - used only when the
ledger has no entry to claim; the status total carries the tilde once any
such figure is in it. A helper that could not be matched is traced without
a figure, never with a guess.
What the safety gate cost. Every ask that reaches the judge is a model
call pi’s footer did not include either. Each call’s usage and price go
to the permission system’s review log as a gadhs_mode_judge.call event,
and the status line keeps judge $0.012 (7) - the spend and the number
of calls, with the last long pause beside them (judge $0.012 (7, last
12.4s)) in place of the separate "last safety check" note. Since 0.37 a
call’s usage also rides on the tool result of the call it gated, stamped
details.gadhsJudge, so it is in pi’s total and the breakdown survives a
restart; in parallel-tool mode two gated calls can swap per-call figures,
the total is exact either way (#124). A call the provider did not price
counts but adds nothing.
Subagents may also declare an output_schema (JSON Schema) in their
.pi/agents/<name>.md frontmatter; the result is validated on completion and
a violation is stated to the parent as fact ("treat its conclusion as
unverified"), naming the file the contract came from. One validator serves
runtime and tests.
Precedence between a global definition (~/.pi/agent/agents/<name>.md, where
@gadhs/pi-agents seeds the agency’s) and a trusted project’s
(<repo>/.pi/agents/<name>.md) follows what each line is (#134). pi-subagents
runs the project file whole, and a project file that names no model: runs
on the parent’s model — so the project file’s model replaces the global’s,
present or absent, and pricing follows what ran. The contract is agency
policy: a project file replaces it only by declaring its own output_schema;
omission leaves the global contract in force, and a violation then says so — "the definition that ran declares no contract of its own; declare
output_schema there to replace it" — so the project’s author can see the
source of a contract their file never mentions.
Guidance injection
The system prompt is composed per turn, most specific last:
BASE
<gadhs-guidance scope="global"> packaged digest (or guidance.global override)
<gadhs-guidance scope="language:X"> per detected language (Cargo.toml → rust, …)
<gadhs-mode id="…"> the active mode's systemPrompt
<gadhs-guidance scope="project"> <cwd>/.pi/gadhs-guidance.md (trusted repos only)
Nothing is written to disk, so nothing drifts — pi extension update is
the sync. /gadhs-init scaffolds a project-owned AGENTS.md skeleton (refuses
to overwrite). With no blocks configured the system prompt is returned
byte-identical (the no-op contract, pinned by tests).
Compaction: pi’s summarizer, thinking off
pi summarizes a session on the session’s own model, at the session’s own
thinking level, inside a budget of 0.8 x compaction.reserveTokens (13 107
tokens by default). On a reasoning model at a high effort the thinking spends
that budget before the summary is finished; pi sees a length stop and -
correctly - refuses to persist a partial summary, so compaction fails with
"generation hit the token cap and the summary is incomplete." Observed live
on claude-fable-5-1 at high; lowering the level fixed it. The variable was
thinking, not the model.
pi-modes therefore handles session_before_compact by running pi’s own
compact() - its prompt, its update-summary flow, its split-turn handling,
its fail-closed rule on length - with two inputs changed: the thinking
level is off, and the reserve pi sizes the summary budget from is floored
at 65 536 tokens (a 52k summary budget; a larger operator setting is kept,
and pi still caps at the model’s own maxTokens). The floor came from the
second way the cap bites: a days-long session’s update summary - the
previous summary re-emitted with everything since - did not fit 13 107
tokens with thinking off or on, and both attempts failed. The model stays
the session’s: the summary is the session’s memory and it is written by the
model that made it. The stream goes through the composed provider
(extension, built-in, or api) with auth resolved the way pi resolves it, so
the request differs from pi’s own only in the missing reasoning and the
larger output budget.
When the hook hands back undefined, pi’s default runs: the model has no
thinking to turn off (then the two paths are identical), the provider or
its auth is missing (pi reports that itself), or our attempt failed - one
warning names the reason, and pi’s own attempt surfaces its own error
rather than ours masking it. A cancelled compaction is silent.
When the provider refuses the request
One failure is not a failure to answer but a refusal to be asked. A
provider’s policy classifier can block the summarization request itself:
Anthropic’s read a long session - every cold review, every judge verdict,
quoted verbatim in tool results - as "duplicating model outputs" and
answered "This request was blocked as it seems to violate Anthropic’s
Terms of Service restrictions on reverse engineering or duplicating model
outputs." That is deterministic on the content: the same call on the same
provider fails the same way every time, and pi’s default compaction is
the same call. Observed live (#151): our attempt and pi’s failed as a pair
on every turn, four warnings deep, and the way out was /model to Gemini,
/compact, /model back - by hand.
So a refusal (isPolicyRefusal: the vocabulary - "terms of service",
"usage policy", "request was blocked", "violate" - not the sentence) is
the one case where the hook does not hand back to pi. It writes the summary
once more on the fallback summarizer, compaction.fallbackModel in the
modes overlay, default vertex-gemini/gemini-3.8-flash: another provider
family, a window every session fits, cheap. The same preparation, thinking
off, the same auth path through the registry. The one warning names both:
which provider refused, and which model wrote the summary. With the
fallback set to null, unknown to the registry, or the session’s own model
(no loop), the warning names the way out by hand; with the fallback
failing too, it carries both reasons and pi’s default runs next. A
transport error or a length stop is not a refusal and takes the old path:
pi’s default, on the session’s model.
Pins: test/compaction.test.ts (same preparation, same auth path, the
session’s model, off, each hand-back, and the refusal: recognised by
vocabulary, retried once on the fallback with off and the floored reserve,
one notice, and each way the fallback can be missing or fail),
test/config.test.ts ("the compaction block"), test/wiring-lifecycle.test.ts
("compaction hook wiring"). Verified live 2026-09-01: the requests pi sent
carried no reasoning, the responses were text-only stop, and pi recorded
the compaction as fromExtension.
Switching to a model the context does not fit
pi’s answer to a context that no longer fits is a compact-and-retry on the
current model. Switch a long session from a 1M-window model to a 262k one
and that answer is the problem: the turn is a 400, the recovery’s summary
request carries the same context and is a 400, /compact is the same call,
and the session is stuck on that model. Nothing in pi warns at the switch -
model_select is notification-only, and pi does not compare the context to
the new window. (Two further gaps made it worse on the agency’s own
providers and are fixed in @gadhs/pi-vertex: messages stamped
with the wire’s model name rather than the session’s id, which made pi skip
the recovery as "a different model’s error", and catalog windows larger
than what Vertex accepts - see Models on Vertex AI (@gadhs/pi-vertex).)
pi-modes hears the switch the moment after it happened. When the context
does not fit - the line is pi’s own compaction threshold, tokens >
contextWindow - 16 384 (pi’s default reserve), because above it pi compacts
on arrival and that compaction is the deadlock - it says so by name:
Context is ~812k tokens; vertex-maas/qwen3-coder-480b accepts 262k. Switching from vertex-anthropic/claude-fable-5-1 here will fail: the next turn is a 400, and pi's recovery compacts on vertex-maas/qwen3-coder-480b itself, which cannot fit either. /new starts fresh; switching back and running /compact there also works.
Two ways out, and it names both. /new is the clear command - pi’s own, a
fresh context - and stays yours to type (a fresh session needs a command
context pi does not give an event handler). Compact first it can do for
you: after a /model switch from a model the context fits, a dialog offers
"Compact on <previous> first, then switch to <next>`" or "Keep `<next>`";
the first switches back, runs `/compact there (where the summary can be
written), and switches again when it completes. A compaction that fails
leaves you on the previous model and says why; a switch back that fails
leaves you where you are. Model cycling (Ctrl+P) and session restore only
warn - a dialog on every cycle step is worse than the 400 - and a context
pi cannot yet estimate (right after a compaction) is not judged.
Pins: test/context-fit.test.ts (the fit line, the dialog’s presence per
source and previous model, each route’s calls and its failure handling).
Observed live 2026-09-07 over pi’s RPC: 121k tokens on Haiku, set_model
to vertex-maas/gpt-oss-120b (131k): the warning, the dialog, the switch
back, compaction_end without error, the switch forward, and the next turn
answered on gpt-oss at 43k input.
Observability
-
Status line:
Auto · judge 90r · 12✓ 1✗— mode, enforcement, verdict mix, denial-loop proximity, running subagents. Segments vanish when zero; the model is deliberately absent (pi’s footer already shows it truthfully). -
Inline traces: every non-allow decision renders in the transcript —
policy BLOCKED bash — * | sh(red, will not reconsider) vsjudge BLOCKED read (6.2s) — …(amber, an opinion). Expanded view states the remedy. Allows are not traced (~90 % of verdicts; they would bury the rest). -
node tools/task.mjs verdicts [n] [--why]: the audit trail with the ask and the judge’s reason under each non-allow line. -
GADHS_PI_MODES_DEBUG=/path.jsonl: lifecycle diagnostics (mode/model application, session restore, guard events, normalized asks). pi itself has no logging facility.
Review-log events
Everything lands in the permission review log, stamped with mode and session
identity (pid, started, cwd):
| Event | Meaning |
|---|---|
|
A deterministic rule fired ( |
|
Workspace-write stage allowed a path |
|
Workspace-read stage allowed an in-tree |
|
Operator-configured pattern deferred to human |
|
Judge verdict ( |
|
Valve capped an allow / deferred under yolo |
|
Fail-safe defer ( |
|
Denial-loop guard tripped |
|
Delegation gate decision |
First-run bootstrap
On first load the extension seeds a minimal permission-system config (only when absent — never overwriting, even an invalid one):
{
"permissionReviewLog": true,
"authorizerChain": ["gadhs-mode-judge"],
"permission": {
"*": "ask",
"path": { "*": "allow" },
"external_directory": { "*": "allow" }
}
}
This file is deliberately tiny: it just routes every permission question to
the mode system, which is where all the real policy lives (and updates with
the package): agency rules ship in modes.json and are enforced by the
extension’s authorizer, precisely so that policy reaches every developer on
update — anything written to disk is seeded once and never migrates. The
seeded file must also stay strictly schema-valid: a // comment key
silently clamps the entire permission config, chain included. If you have edited yours and want to know how it differs from
the current default, node tools/task.mjs policy-diff will tell you, and
--write will repair it (it only ever tightens, and asks first).