Plan: the ask broker — one ask, put to the local dialog and a paired phone at once; first answer wins (#140)
On this page
Status: Done (2026-09-15) — one branch, three commits; the pack consumer-sim rpc check unchanged with nobody registered
Branch: feat/ask-broker · Issue: #140 (contract text, filed by pivot;
implementation here) · Close step: the MR carries Closes #140; after merge
one line on #140 with the merge SHA and a note on pivot’s #64 that pi-modes
0.25.0 carries the broker; #142 (pi-remote) unblocks on it.
Erratum (2026-09-15): askAny returns AskOutcome<T> { value, by } rather
than the contract sketch’s bare T (internal to pi-modes; the cross-repo
contract is unchanged). A remote approval is traced with an optional
via: "remote" on AgentTraceRecord. Landed from the cold reviews: the
registry refuses a slot of another version rather than sharing it; a local
dialog that throws - synchronously too - settles the remotes as cancelled
unless a remote already won; dialogOpts returns undefined, not {},
when neither a wait nor a signal applies, which the per-call-site
regression tests caught in the first draft.
Design
What this is for
pivot’s box side becomes a pi extension: the developer types /remote-control
in the TUI, scans a QR, and the phone follows that session and answers its
asks. pi has no hook for one extension to observe or answer another’s
ctx.ui.* dialog. Every ask a phone must answer originates in pi-modes - the
gate defer, plan approval, the ask tool, the memory picker - so the seam is
here: a broker that puts one ask to the local dialog and every registered
remote answerer at once. The first answer wins; the losers are cancelled
through pi’s signal option; with nobody registered the call is today’s.
The contract (RemoteAsk, RemoteAnswer, RemoteAnswerer,
registerRemoteAnswerer, remoteAttached) is the text in #140; both repos
build against it and a change gets a note back to pivot. askAny is
pi-modes-internal and is free to return more than the contract sketch shows.
What pi provides (pinned to the installed 0.85.1; not in this tree)
-
dist/core/extensions/types.d.ts:36-41ExtensionUIDialogOptions { signal?, timeout? }, accepted byselect/confirm/input. -
dist/modes/interactive/interactive-mode.jsshowExtensionSelector: an abortedsignalhides the selector and resolvesundefined;showExtensionConfirmis a selector over["Yes","No"], so an aborted confirm resolvesfalse. A cancelled local dialog therefore yields a value indistinguishable from a dismissal or a no. The broker must classify by WHO settled, never by the loser’s value. -
dist/modes/rpc/rpc-mode.js:47-72createDialogPromise: RPC honourssignalandtimeoutthe same way (#135 pinned the timeout defaults). -
types.d.ts:117-127custom<T>(factory, options?)takes no signal. Our pane factory (plan-tools.tspaneFactory) receivesdone; the local side of a plan approval in the TUI is closed by callingdonefrom the signal’s abort listener, which resolvesundefinedand is discarded. -
docs/extensions.md"ui_prompt_start / ui_prompt_end": notification-only events around any extension’s blocking dialog. Not used here; pi-remote uses them for the "waiting at the desk" nudge (#142).
Decisions
-
Ownership by runtime. Everything inside pi’s process is this repo’s: the broker (this plan) and
pi-remote(#142). Relay, PWA, crypto and the wire are pivot’s, reaching us as@gadhs/pivot-wire. -
A phone adds an answerer, not a deadline. The wait comes from the host kind: TUI - none, with or without a phone; headless -
gateTimeoutMs/dialogTimeoutMsas #135 set them. "Silence = deny" is the headless doctrine because nobody may be there; in the TUI someone is or will be. -
The winner decides; the loser’s value is discarded. A remote answer is taken only if it validates (
confirmboolean;selectvalue inoptions;inputstring); anything else - throw, reject, malformed, unknown option,undefined, unregistered mid-ask - is a decline and the local dialog keeps waiting. A local settle cancels every remote. -
Process-global registry. Two extensions in one process are not guaranteed one module instance (pi-subagents taught us; the pi-workflow bridge is the pattern:
session-wiring.tsBRIDGE_SYMBOL). The registry lives atglobalThis[Symbol.for("gadhs.pi-modes.ask-broker")]as{ version: 1, answerers: Set }; pi-remote refuses/remote-controlloudly on a missing slot or another version. -
Nobody registered = today’s call. The local callback is invoked with no signal and the exact options it passes today; no new log lines, no new frames. Regression tests on every call site pin this.
-
Attribution. New source
remoteinVerdictSource,TraceSource,MARK,DECIDER("denied by the human on the paired device"),remedy. The review log’sgadhs_host_ask.verdictgainsby: "local" | "remote"and itswaitMsbecomestimeoutMs ?? null(null in the TUI, where there is no wait). The gate’s span still closes before any ask (#135). -
While a phone is attached the desk sees pi-modes' confirm for a gate defer, not pi-permission-system’s richer prompt (no allow-always on either surface). Said in
modes.adocandsecurity.adoc.
The broker (packages/pi-modes/ask-broker.ts)
export type AskOrigin = "gate" | "plan" | "ask" | "memory";
export interface RemoteAsk { id; origin; modeId; kind: "confirm"|"select"|"input";
title; body?; options?; placeholder?; timeoutMs? } // contract, #140
export type RemoteAnswer = {kind:"confirm";value:boolean} | {kind:"select";value:string}
| {kind:"input";text:string};
export interface RemoteAnswerer {
ask(req: RemoteAsk, signal: AbortSignal): Promise<RemoteAnswer | undefined>;
settle(id: string, outcome: "answered-locally"|"answered-remotely"|"timeout"|"cancelled"): void;
}
export function registerRemoteAnswerer(a: RemoteAnswerer): () => void;
export function remoteAttached(): boolean;
export function resetAskBrokerForTests(): void;
// Exported at the package subpath `@gadhs/pi-modes/ask-broker` (a new
// `exports` entry beside `./config`); the root export stays the extension
// factory. pi-remote imports the contract from the subpath and nothing else.
// The Symbol text `gadhs.pi-modes.ask-broker` is the #140 contract's (the
// bridge's older `gadhs:` spelling is not changed; the contract text wins).
export interface AskOutcome<T> { value: T; by: "local" | "remote" }
export function askAny<T>(
req: Omit<RemoteAsk, "id">, // the broker mints the id
local: (signal: AbortSignal | undefined) => Promise<T>,
fromRemote: (a: RemoteAnswer) => T | undefined, // undefined = not a valid answer here
): Promise<AskOutcome<T>>;
askAny: with no answerers, { value: await local(undefined), by: "local" }.
Otherwise one AbortController for the local dialog and one per remote;
each remote’s ask is wrapped so a throw or reject is a decline. Loop over
Promise.race of the local promise and the still-pending remotes: a local
settle aborts every remote, calls settle(id, elapsed ≥ timeoutMs ?
"timeout" : "answered-locally") on each, returns by: "local"; a remote
settle whose fromRemote yields a value aborts the local controller and the
other remotes, calls settle(id, "answered-remotely") on the others,
returns by: "remote" (the local promise’s later resolution is dropped); a
decline removes that remote from the race; when none remain the local
promise is awaited alone. unregister aborts that answerer’s in-flight asks
(a Map<RemoteAnswerer, Set<AbortController>>), so an answerer that leaves
mid-ask declines. settle throwing is swallowed with a debug line. Every
function ≤ 40 lines: askAny delegates to raceLocalOnly /
raceWithRemotes / takeRemote / takeLocal.
Call sites
-
judge-wiring.tsatHeadlessHost→ renamedaskBeyondTheGate(it is no longer headless-only): routes a defer whenisHeadless(ctx.mode) || remoteAttached().hasUI === falsekeepsnoDialogsDeny.host-ask.tsconfirmAtHosttakestimeoutMs?: number(absent = no deadline) and asks throughaskAny({origin:"gate", kind:"confirm", title, body, modeId, timeoutMs?}, (signal) ⇒ ui.confirm(title, body, {timeout?, signal}), a ⇒ a.kind==="confirm" ? a.value : undefined). Classification:by: "remote"→trueallow /falsedeny, sourceremote, reason "the paired device answered no";by: "local"→ today’s clock rule when a timeout is set, elsetrueallow /falsedeny with sourcehost. ThetimeoutMs ⇐ 0short-circuit applies only when a timeout is set. -
plan-tools.ts:askApproval(TUI pane) wrapsui.custominaskAnywithorigin:"plan", kind:"select", options: exitPlanChoices().customhas no signal and runs the factory asynchronously, so the local callback does two things: attaches an abort listener that calls the pane’sdoneonce the factory has handed it over, and has the factory itself checksignal.abortedon entry and calldone(undefined)at once - a phone that answers before the pane is built still closes it (wiring test);hostApproval(headless select) wraps likewise withtimeoutMs;keepPlanning’s feedback `inputwraps withkind:"input". A remote approval is announced ("approved from the paired device") and traced withvia: "remote"beside the existing fields. -
command-wiring.tsaskHuman: theselectand theinputeach go throughaskAnywithorigin:"ask"; theOWN_WORDSbranch is two asks in sequence. `/mode’s picker is not an ask and stays as it is. -
memory-wiring.tspickMemory: the select goes throughaskAnywithorigin:"memory"; a remote’s value maps back throughcandidateLabel.
Docs
modes.adoc: a subsection under "Headless hosts" - "A paired device: the
ask broker" - who owns the ask when (table: TUI / TUI+phone / headless /
headless+phone × wait × who may answer), the winner rule, what the desk
sees while attached. security.adoc: an attached device is a second
keyboard, paired = trusted, the bounded dialog set applies on every surface
while attached, and the residuals (asks not offered through the broker are
answerable only at the desk; a phone that registered without a live link
narrows the desk’s dialog until it unregisters - pivot’s rule). Two
sentences become wrong the moment this ships and are corrected in the same
commit: modes.adoc’s headless bullet "reach a phone from a terminal
session" (it can, for pi-modes' own dialogs, through the broker) and
`remote.adoc’s "Partial" paragraph "pi gives no hook for one extension to
mirror another’s dialog" (true of pi; pi-modes now offers its own).
`remote.adoc’s pivot paragraph says extension-not-daemon and points at
#142. CHANGELOG. No tunables: nothing new in `tuning.adoc.
Scope
Units
U1 broker - ask-broker.ts + its unit suite. U2 call sites -
the four files above, authorize.ts / trace.ts / judge-wiring.ts
sources, their tests. U3 docs + release - the three pages, CHANGELOG,
pi-modes 0.25.0, @gadhs/pi 0.44.0 (pack consumer-sim before the tag, registry
after). One branch, three commits.
Tests, by category
-
unit (the broker’s suite): nobody registered →
localcalled once withundefinedsignal,by: "local"; register/unregister togglesremoteAttached; the slot is{version: 1}onglobalThisunder the named Symbol;resetAskBrokerForTestsempties it. -
concurrency: local first → every remote’s signal aborted,
settle(id, "answered-locally")once each, remote’s later answer ignored; remote first with a valid answer → local signal aborted, other remotes aborted and settledanswered-remotely,by: "remote", the local’s laterundefineddropped; two remotes answering in the same tick → exactly one taken. -
contract (
RemoteAnswervalidation): throw, reject,undefined, malformed object,selectvalue not inoptions,confirmanswering aselect→ each is a decline and the local answer is taken when it comes; unregister mid-ask → that remote’s signal aborted, decline;settlethrowing does not propagate. -
timing: headless
timeoutMsset, silent remote, local resolves at the deadline →settle(id, "timeout"),by: "local"(the caller’s clock rule then denies as before); injected clock, no real waits. -
property (fast-check): a schedule of {local answers at t | never} × {each of 0-3 remotes: answers valid at t | answers invalid at t | declines at t | throws at t | never} × {timeout T | none} → exactly one outcome,
bynames the earliest valid settler, every non-winner’s signal is aborted,settlecalled exactly once per remote with a reason consistent with the outcome, no promise left pending after the outcome (checked by racing against a resolved sentinel). -
regression, one per call site: with nobody registered
ui.confirm/ui.select/ui.input/ui.customreceive exactly today’s arguments (nosignal, sametimeout), the review log has no new line,traceunchanged - asserted against the pre-change fixtures in each call site’s existing suite (the judge-wiring callback, lifecycle, command-wiring and memory-wiring suites underpackages/pi-modes/test/). -
wiring, per call site with a remote registered: gate defer in the TUI routes to the broker with no
timeoutand asignal; remote yes → allowremote; remote no → denyremotewith the DECIDER bracket; local no → denyhost; headless + remote keepsgateTimeoutMs; the log line carriesby. Plan approval: pane opened, remote "Approve" closes it viadone, the plan is approved and announced; remote "Keep planning" → the feedbackinputis offered to the phone too.ask: remote select value returned as the answer; remote free text via theOWN_WORDSbranch. Memory: remote label maps to the record; an unknown label declines. -
trace/attribution:
MARK.remote,DECIDER.remote,remedy("remote"),attributeDenyproperty extended to drawremote;formatTraceDetail.
Risks, by failure shape
-
fail-open: a remote’s malformed or late answer read as consent - validation in
fromRemote, winner-decides, and a settled ask ignores every later resolution (controller state, not value). -
misattribution (silent): an aborted local dialog resolving
false/undefinedbooked as a human’s no or a dismissal - the loser’s value is never read; the trace names who answered. -
hang (fail-closed): a registered answerer whose link is dead - in the TUI the local dialog is still on screen and nothing is decided until a person acts; headless, pi’s own timeout resolves the local side and the broker settles the remote as
timeout, denying as today. Losers are never awaited. -
silent: the nobody-registered path drifting from today’s call - the per-call-site regression tests assert exact arguments and no new log lines.
-
fail-closed (accepted):
remoteAttached()true with no live phone narrows the desk’s gate dialog - pivot’s rule 1 (register only with a live link, unregister on loss) and the docs own it. -
leak (silent): controllers or answerers surviving a session, so a later ask is offered to a phone that is gone - unregister aborts in-flight asks;
resetAskBrokerForTestsin every suite’safterEach; the registry holds answerers only, never asks.
Release
pi-modes 0.25.0 (new exports, new source, new behaviour on shipped surfaces
→ minor); @gadhs/pi 0.44.0. Pack consumer-sim before the tag (its rpc
headless check must be unchanged: nobody registered), registry after.