Plan: headless hosts — plan approval by select, gate asks that time out to deny, no hangs under RPC (#135)
On this page
Status: Done (2026-09-14) — one branch; the consumer-sim proof measured the gate deny at 3002 ms after the ask on a 3 s wait
Branch: feat/headless-hosts · Issue: #135 · Close step: the MR carries
Closes #135; after merge one line on the issue with the merge SHA, the
consumer-sim timings observed, and anything deferred.
Erratum (2026-09-14): the host remedy shipped as "The person at the host
said no to this request." rather than the judge’s wording in the table
below; a human’s no is not a rephrasing problem. Also landed from the cold
review: a dialog wait of 0 skips the dialog on every surface (pi reads a
zero timeout as none), a host with no dialogs (hasUI false) is denied as
unattended rather than booked as a human’s no, and a confirm that throws
is a deny; the gate’s span closes before the host is asked, so a host
wait is never booked as judge latency. modes.json is untouched: the two
waits default in code (DEFAULT_HOST_TIMEOUTS) and live in the overlay,
not the seed. The distribution’s own bump rides the release commit.
Design
What is wrong
pi runs our extensions under hosts other than the terminal: pi --mode rpc
(IDE plugins, pi-web-ui, remote-pi’s daemons, the DHS pivot client being
built in its own repository), and later pi-server. Two of our surfaces do
the wrong thing there.
What pi provides (pinned to the installed @earendil-works/pi-coding-agent
0.85.1; the package is not in this tree, so the plan quotes it):
-
dist/core/extensions/types.d.ts:208export type ExtensionMode = "tui" | "rpc" | "json" | "print";- carried asmodeonExtensionContext(line 213) and on the tool context (line 398), besidehasUI. -
types.d.ts:36-41interface ExtensionUIDialogOptions { signal?: AbortSignal; timeout?: number }- accepted byselect,confirm,input(lines 70-73). -
dist/modes/rpc/rpc-mode.js:47-72createDialogPromise: under RPC a dialog is emitted asextension_ui_requestand, whenopts.timeoutelapses unanswered, resolves the method’s default -selectandinput→undefined,confirm→false(lines 84-86). -
docs/rpc.md:1195-1205: in RPC modecustom()returnsundefined,ctx.hasUIistrue, and "Usectx.mode === \"tui\"to guard TUI-specific features like `custom()`".
No file in this tree reads ctx.mode today; this plan introduces the one
predicate that does.
-
Plan approval (
plan-tools.tsaskApproval) isui.customover the pi-tui pane (#43). Under RPC the pane never opens, the choice isundefined,interpretExitChoicereads that as keep planning, andkeepPlanningthen awaitsui.input(…)for feedback - which, under a host that does not forward dialogs (remote-pi’s daemon path today), never returns. Under a host that does forward them, the human gets a free-text prompt about a plan they were never shown a choice for. -
A gate defer (
authorize.tsreturns{ kind: "defer" }for a high-consequence match, a valve cap, the loop guard, or a judge that could not decide) hands the ask to the permission system’s own dialog. We do not read that dialog and cannot time it. Under a non-forwarding host the turn hangs forever; under a forwarding one it waits for a human who may be on a phone, with no bound.
Everything else we raise already crosses RPC correctly: the ask tool is
select/input, the ADC-retry notices are notify, the status line is
setStatus. The judge’s own asks - the permission system’s select/confirm
- also cross correctly when forwarded; the problem is only that nothing
bounds them.
What changes
Principle. Detect the host, not the human: isHeadless(ctx.mode) - a
string mode other than "tui" - is the signal, available on every
ExtensionContext and tool context. Outside the
TUI, every dialog we own is bounded, and silence is the safe answer - a
refused approval, a denied ask. Inside the TUI nothing changes: the pane,
the permission system’s own dialog with its allow-always memory, no
timeouts. pi’s ExtensionUIDialogOptions.timeout does the bounding: under
RPC an unanswered select resolves undefined and an unanswered confirm
resolves false (rpc-mode.js createDialogPromise); we never build a
timer of our own.
1. Plan approval outside the TUI (plan-tools.ts). askApproval branches
on toolCtx.mode: "tui" → the pane as today; otherwise hostApproval:
the plan is already in the transcript (onUpdate renders it before any
ask); a notify carries the review’s one line when there is one
(matchingPlanReview: verdict, reviewer, edited since); then
ui.select(title, exitPlanChoices(), { timeout: dialogTimeoutMs }). A
select answer goes through interpretExitChoice unchanged. An
undefined outside the TUI is no answer, not keep planning: exitPlanMode
returns a distinct refusal - "no approval from the host within N min;
staying in plan mode" - and never calls ui.input. A genuine Keep
planning choice still asks for feedback, with the same timeout. Fail-closed
is unchanged: nothing but an explicit approve option approves.
2. Gate defers at a headless host (new packages/pi-modes/host-ask.ts,
wired in judge-wiring.ts adjudicateAsk). After authorizeAsk returns,
when the verdict is defer and modes.lastCtx()?.mode is not "tui":
confirmAtHost(ui, ask, verdict, timeoutMs) calls
ui.confirm(title, body, { timeout: gateTimeoutMs }) with a title naming
the tool and a body carrying the command or path preview (capped), the mode,
and why it was deferred (the source and pattern the verdict carries). true
→ { kind: "allow", source: "host" }. false → { kind: "deny", source:
"host", reason: "denied by the human at the host" }. Silence resolves
false too, so the two are told apart by elapsed time against the timeout:
at or past it → { kind: "deny", source: "unattended", reason: "no answer
from the host within 30 s; run the session interactively to approve, or
allow the pattern in the mode’s rules" }. gateTimeoutMs: 0 denies without
asking (an operator’s "never wait"). The permission system receives allow or
deny - both legitimate authorizer answers - and never a defer from a headless
session. recordVerdict counts a host allow as allowed and the two denies
as denied; the trace carries the new sources. authorize.ts is untouched:
it stays pure and TUI-agnostic; the host step is the wiring’s.
3. ask outside the TUI (command-wiring.ts). The AskUi seam widens:
mode: string beside hasUI, and select(title, options, opts?) /
input(title, placeholder, opts?) gain the options parameter - today the
interface types them without one, so the timeout cannot be passed until it
does. askHuman passes { timeout: dialogTimeoutMs } when headless and
nothing inside the TUI; a timeout reads as no answer, which
interpretAnswer (ask-tool.ts:60) already renders.
4. Trace and attribution (trace.ts, judge-wiring.ts). Two new
sources, additive:
TraceSource |
MARK |
DECIDER (in attributeDeny) |
Remedy line (formatTraceDetail, deny) |
|---|---|---|---|
|
|
denied by the human at the host that drives this session |
as the judge’s: rephrasing or saying why may change it |
|
|
no human answered at the host within the wait; denied by gadhs policy, not the human |
No one answered at this host. Run the session interactively to approve, or allow the pattern in the mode’s rules. |
5. Two tunables (config.ts, modes.json). A new block on
ModesConfig, resolved by one helper hostTimeouts(config) so no caller
reads it raw. Validation needs a nonNegativeInteger check: ConfigCheck
has nonNegativeNumber (config.ts:269) and no integer form. Not
doctrine: waits, like reviewTimeoutMs.
| Key | Default | Bounds |
|---|---|---|
|
30 000 |
a gate ask at a headless host; |
|
600 000 |
plan approval and |
6. The proof (tools/consumer-sim.mjs, a live() check). With the packed
distribution installed and an overlay gadhs-pi-modes.json setting
unattended.gateTimeoutMs to 3 000, dialogTimeoutMs to 3 000 and
highConsequence: ["git push*"] on auto, spawn pi --mode rpc from the
scratch repo, write two prompts over stdin - one that runs git push
--dry-run, one that calls enter_plan_mode then exit_plan_mode - and
never answer any extension_ui_request. Pass when: an
extension_ui_request with method: "confirm" was emitted, the matching
tool result carries the unattended deny reason, and agent_end arrived
within the wait plus a margin; and for the plan, a select request with
timeout: 3000 was emitted and the tool result carries the "no approval
from the host" refusal. The check reads stdout as strict JSONL (LF only, per
docs/rpc.md), fails on any hang past 120 s, and is skipped under
--no-model like the other live checks. It rides in the tag pipeline’s
rehearse job only through its pack-based run before the tag, as every live
check does.
7. Docs. modes.adoc: a section Headless hosts (RPC) under the
talking-to-the-human section - what changes outside the TUI, what does not,
the two sources in the trace. tuning.adoc: the unattended block beside
the other knobs. security.adoc: the posture (silence is deny; a headless
session never defers to a human it cannot reach), the two residuals (the
permission system’s own ask rules on the excluded surfaces still prompt
without us - the seed keeps them allow and reconcile advises; allow-always
is unavailable from a host since we return one-shot decisions), and one
doctrine paragraph: delegation goes through the gate; a tool that injects
into another session as the user (remote-pi’s mesh agent_send) is out of
policy for the agency distribution; and the misattribution residual - a
human’s false landing exactly at the deadline is recorded as unattended. A new page remote.adoc under
Operations (nav beside local-dev): the supported remote workflows - VS Code
Remote-SSH and code tunnel with pi in the terminal, ssh + tmux from a
phone (Termius, Blink) over the agency VPN, both carrying the whole
distribution unchanged; messenger bridges and remote-pi as personal-use
tools whose transcripts transit a third party and whose dialogs stay at the
desk; the pivot project as the first-class path. CHANGELOG.adoc.
What does not change
The TUI: pane, permission-system dialog, no timeouts, no new notices.
authorize.ts and its order. The permission system, consumed unmodified.
hasUI === false hosts (print, json): NO_HUMAN_REFUSAL and
NO_HUMAN_ASK as today - a host with no dialogs at all is not a host to
wait on. The battery, the eval corpus, the judge prompt. Nothing crosses the
event bus: pi offers no hook to mirror a dialog to a sibling extension, and
that remains an upstream ask, not ours.
Risks, by failure shape
Fail-open - a host answer misread as allow. confirm returns a boolean;
only true inside the wait allows; every other outcome denies. The
property test in U1 holds it.
Hang - a dialog without a timeout outside the TUI. The grep-able
invariant: outside the TUI every ui.select/confirm/input we make
passes timeout; the consumer-sim check is the live proof that a headless
session ends. The permission system’s own prompt is not ours to time - and
after this change it is never reached from a headless session, because we
no longer return defer there.
Hang, by wrong host detection. One predicate, isHeadless(mode), is
typeof mode === "string" && mode !== "tui"; every branch uses it and
nothing else. So: "tui" is the terminal; any other string - rpc,
json, print, or a host pi adds later - is headless and gets timeouts,
never a hang; a missing mode (a pi that stopped reporting it) is treated
as the terminal, which is today’s behaviour - the known state rather than a
new one - and the consumer-sim check would go red on such a pi.
Misattribution (fail-closed) - silence told as a human’s no, or a human’s
no told as silence. The two denies differ in source and wording; elapsed
time against the deadline decides; a host that answers false exactly at
the deadline reads as unattended. Both are denies, so the direction is
safe; the residual is recorded in security.adoc.
Fail-closed by design, but unusable - an approval given 30 s. Approvals
and ask use the dialog wait (10 min), not the gate wait; the tunables are
separate for this reason.
Silent regression - the coverage bar. pi-modes holds 97/90 as thresholds; every new branch above has a named test, so the gate stays green or says why.
Scope. remote.adoc and the delegation-doctrine paragraph are not
headless mechanics; they are in this plan because the operator decided both
in the same session as the mechanics (2026-09-14) and because the feature is
only usable if developers are told which remote paths carry the
distribution and which do not. They ship as docs in U2 and add no code.
Scope
One branch, one MR, one release.
-
U1 - host asks and the plan fallback (
host-ask.ts,judge-wiring.ts,plan-tools.ts,command-wiring.ts,trace.ts,config.ts,modes.json). Tests, each with its category:-
unit,
host-ask.test.ts: yes → allow with sourcehost; no → deny with sourcehost;gateTimeoutMs: 0denies without calling confirm; the body carries the tool, the capped preview, the mode and the deferral’s source and pattern. -
timing,
host-ask.test.tswith an injected clock: afalsereturned at or past the deadline denies with sourceunattendedand a reason naming the wait; one returned inside it denies with sourcehost. -
wiring,
judge-wiring-callback.test.ts: underrpca defer from every source (high-consequence, valve, threshold, judge undecided) becomes a host confirm and the permission system receives allow or deny; undertuithe defer passes through untouched; stats and trace record the outcome; a missingmodebehaves astui. -
regression (the defect this plan is for),
plan-tools.test.ts: underrpcan unanswered approval returns the no-approval refusal andui.inputis never called - the case that hung. -
wiring,
plan-tools.test.ts: underrpcthe review line is notified,selectreceives the choices and the timeout, an approve option approves, a Keep planning choice asks for feedback with the timeout; undertuithe pane path is unchanged (existing tests stand). -
wiring,
command-wiring.test.ts:askpasses the timeout when headless and none inside the TUI. -
unit,
trace.test.tsandconfig.test.ts: the two sources format with their remedy lines; the block validates, rejects negatives and non-integers, defaults resolve. -
property (fast-check),
judge-wiring-callback.test.ts: for anyAttributedVerdictauthorizeAskcould return, any host answer and any timing, the wired result underrpcis neverdeferand isallowonly fortrueinside the wait.
-
-
U2 - the proof and the docs (
tools/consumer-sim.mjs, the four doc pages, nav, CHANGELOG). contract (wire): the consumer-sim check reads pi’s RPC JSONL and asserts theconfirmandselectrequests carry the configuredtimeoutand the tool results carry the two refusal texts - the shape the pivot client will consume. Run the pack-based simulation with models before the tag as always; record the observed deny latency on the issue. -
Release - pi-modes 0.23.0 (new behaviour outside the TUI, new config block),
@gadhs/pi0.42.0; rehearse job gates publish as before.
Out of scope, named: mirroring dialogs to a phone from a TUI session (an
upstream change: pi emitting extension-UI requests on pi.events); any
transport, relay or client (the pivot repository); adopting pi-server
(tracked when it ships); a rule denying remote-pi’s mesh tools mechanically
(doctrine first; a deny rule if the agency ever installs it).