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-41 ExtensionUIDialogOptions { signal?, timeout? }, accepted by select / confirm / input.

  • dist/modes/interactive/interactive-mode.js showExtensionSelector: an aborted signal hides the selector and resolves undefined; showExtensionConfirm is a selector over ["Yes","No"], so an aborted confirm resolves false. 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-72 createDialogPromise: RPC honours signal and timeout the same way (#135 pinned the timeout defaults).

  • types.d.ts:117-127 custom<T>(factory, options?) takes no signal. Our pane factory (plan-tools.ts paneFactory) receives done; the local side of a plan approval in the TUI is closed by calling done from the signal’s abort listener, which resolves undefined and 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

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

  2. A phone adds an answerer, not a deadline. The wait comes from the host kind: TUI - none, with or without a phone; headless - gateTimeoutMs / dialogTimeoutMs as #135 set them. "Silence = deny" is the headless doctrine because nobody may be there; in the TUI someone is or will be.

  3. The winner decides; the loser’s value is discarded. A remote answer is taken only if it validates (confirm boolean; select value in options; input string); 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.

  4. 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.ts BRIDGE_SYMBOL). The registry lives at globalThis[Symbol.for("gadhs.pi-modes.ask-broker")] as { version: 1, answerers: Set }; pi-remote refuses /remote-control loudly on a missing slot or another version.

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

  6. Attribution. New source remote in VerdictSource, TraceSource, MARK, DECIDER ("denied by the human on the paired device"), remedy. The review log’s gadhs_host_ask.verdict gains by: "local" | "remote" and its waitMs becomes timeoutMs ?? null (null in the TUI, where there is no wait). The gate’s span still closes before any ask (#135).

  7. 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.adoc and security.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.ts atHeadlessHost → renamed askBeyondTheGate (it is no longer headless-only): routes a defer when isHeadless(ctx.mode) || remoteAttached(). hasUI === false keeps noDialogsDeny. host-ask.ts confirmAtHost takes timeoutMs?: number (absent = no deadline) and asks through askAny({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"true allow / false deny, source remote, reason "the paired device answered no"; by: "local" → today’s clock rule when a timeout is set, else true allow / false deny with source host. The timeoutMs ⇐ 0 short-circuit applies only when a timeout is set.

  • plan-tools.ts: askApproval (TUI pane) wraps ui.custom in askAny with origin:"plan", kind:"select", options: exitPlanChoices(). custom has no signal and runs the factory asynchronously, so the local callback does two things: attaches an abort listener that calls the pane’s done once the factory has handed it over, and has the factory itself check signal.aborted on entry and call done(undefined) at once - a phone that answers before the pane is built still closes it (wiring test); hostApproval (headless select) wraps likewise with timeoutMs; keepPlanning’s feedback `input wraps with kind:"input". A remote approval is announced ("approved from the paired device") and traced with via: "remote" beside the existing fields.

  • command-wiring.ts askHuman: the select and the input each go through askAny with origin:"ask"; the OWN_WORDS branch is two asks in sequence. `/mode’s picker is not an ask and stays as it is.

  • memory-wiring.ts pickMemory: the select goes through askAny with origin:"memory"; a remote’s value maps back through candidateLabel.

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 → local called once with undefined signal, by: "local"; register/unregister toggles remoteAttached; the slot is {version: 1} on globalThis under the named Symbol; resetAskBrokerForTests empties 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 settled answered-remotely, by: "remote", the local’s later undefined dropped; two remotes answering in the same tick → exactly one taken.

  • contract (RemoteAnswer validation): throw, reject, undefined, malformed object, select value not in options, confirm answering a select → each is a decline and the local answer is taken when it comes; unregister mid-ask → that remote’s signal aborted, decline; settle throwing does not propagate.

  • timing: headless timeoutMs set, 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, by names the earliest valid settler, every non-winner’s signal is aborted, settle called 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.custom receive exactly today’s arguments (no signal, same timeout), the review log has no new line, trace unchanged - asserted against the pre-change fixtures in each call site’s existing suite (the judge-wiring callback, lifecycle, command-wiring and memory-wiring suites under packages/pi-modes/test/).

  • wiring, per call site with a remote registered: gate defer in the TUI routes to the broker with no timeout and a signal; remote yes → allow remote; remote no → deny remote with the DECIDER bracket; local no → deny host; headless + remote keeps gateTimeoutMs; the log line carries by. Plan approval: pane opened, remote "Approve" closes it via done, the plan is approved and announced; remote "Keep planning" → the feedback input is offered to the phone too. ask: remote select value returned as the answer; remote free text via the OWN_WORDS branch. Memory: remote label maps to the record; an unknown label declines.

  • trace/attribution: MARK.remote, DECIDER.remote, remedy("remote"), attributeDeny property extended to draw remote; 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 / undefined booked 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; resetAskBrokerForTests in every suite’s afterEach; the registry holds answerers only, never asks.

Out of scope

pi-remote itself (#142); anything on the wire or the phone; pi-permission- system’s own prompts and third-party dialogs (nudge-only, #142); a /mode picker on the phone; ADR-004’s amendment (rides #142).

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.

Edit this page · latest