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:208 export type ExtensionMode = "tui" | "rpc" | "json" | "print"; - carried as mode on ExtensionContext (line 213) and on the tool context (line 398), beside hasUI.

  • types.d.ts:36-41 interface ExtensionUIDialogOptions { signal?: AbortSignal; timeout?: number } - accepted by select, confirm, input (lines 70-73).

  • dist/modes/rpc/rpc-mode.js:47-72 createDialogPromise: under RPC a dialog is emitted as extension_ui_request and, when opts.timeout elapses unanswered, resolves the method’s default - select and inputundefined, confirmfalse (lines 84-86).

  • docs/rpc.md:1195-1205: in RPC mode custom() returns undefined, ctx.hasUI is true, and "Use ctx.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.ts askApproval) is ui.custom over the pi-tui pane (#43). Under RPC the pane never opens, the choice is undefined, interpretExitChoice reads that as keep planning, and keepPlanning then awaits ui.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.ts returns { 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)

host

host

denied by the human at the host that drives this session

as the judge’s: rephrasing or saying why may change it

unattended

unattended

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

unattended.gateTimeoutMs

30 000

a gate ask at a headless host; 0 denies without asking

unattended.dialogTimeoutMs

600 000

plan approval and ask at a headless host; 0 refuses without asking

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.

  1. 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 source host; no → deny with source host; gateTimeoutMs: 0 denies without calling confirm; the body carries the tool, the capped preview, the mode and the deferral’s source and pattern.

    • timing, host-ask.test.ts with an injected clock: a false returned at or past the deadline denies with source unattended and a reason naming the wait; one returned inside it denies with source host.

    • wiring, judge-wiring-callback.test.ts: under rpc a defer from every source (high-consequence, valve, threshold, judge undecided) becomes a host confirm and the permission system receives allow or deny; under tui the defer passes through untouched; stats and trace record the outcome; a missing mode behaves as tui.

    • regression (the defect this plan is for), plan-tools.test.ts: under rpc an unanswered approval returns the no-approval refusal and ui.input is never called - the case that hung.

    • wiring, plan-tools.test.ts: under rpc the review line is notified, select receives the choices and the timeout, an approve option approves, a Keep planning choice asks for feedback with the timeout; under tui the pane path is unchanged (existing tests stand).

    • wiring, command-wiring.test.ts: ask passes the timeout when headless and none inside the TUI.

    • unit, trace.test.ts and config.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 any AttributedVerdict authorizeAsk could return, any host answer and any timing, the wired result under rpc is never defer and is allow only for true inside the wait.

  2. 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 the confirm and select requests carry the configured timeout and 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.

  3. Release - pi-modes 0.23.0 (new behaviour outside the TUI, new config block), @gadhs/pi 0.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).

Edit this page · latest