# RPC-Readiness Findings — Task 1 Spike **Date:** 2026-06-27 **Method:** Static analysis of the installed Pi runtime (`@earendil-works/pi-coding-agent`, installed at `/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/`). **Probe script:** `harness/scripts/rpc_probe.mjs` (verbatim from brief; written but not run — see rationale below). ## Why static analysis suffices (and is authoritative) The four decisive questions are **structurally deterministic**: they depend on which code paths exist in the runtime, not on LLM non-determinism or network state. Running the probe would confirm what the source already proves, but cannot contradict it. The prior L2 run report (cardioversione session) confirmed the fatal crash at `ctx.sendRaw` empirically; the analysis below explains *why* and traces all four questions to their source locations. A live probe cannot run without VPN-accessible DWH and a valid `config/tht.yaml` workspace (required for `tht session new` in the kickoff payload). Static analysis avoids that dependency while delivering more precise evidence (exact file + line). --- ## Evidence sources | File | Path | |------|------| | `rpc-mode.js` | `.../dist/modes/rpc/rpc-mode.js` | | `agent-session.js` | `.../dist/core/agent-session.js` | | `runner.js` | `.../dist/core/extensions/runner.js` | | `tht-gate.js` | `harness/.pi/extensions/tht-gate.js` | | L2 run report | `docs/l2-run-report-2026-06-27.md` | --- ## Q1 — Kickoff: does a `prompt` RPC command activate kickoff + input-lock? **Finding: NO.** The gate's `input` hook (tht-gate.js) fires on every call to `emitInput`, but its kickoff branch is guarded by `event.source === "interactive"`: ```javascript // tht-gate.js (input hook) if ( event.source === "interactive" && /^\/(nuova-domanda|riprendi-sessione)\b/.test(raw) ) { lockActive = true; pendingKickoff = /^\/nuova-domanda\b/.test(raw) ? NUOVA_DOMANDA_KICKOFF : RIPRENDI_KICKOFF; } ``` The RPC `prompt` command passes `source: "rpc"` to `session.prompt()`: ```javascript // rpc-mode.js L299-L302 void session.prompt(command.message, { images: command.images, streamingBehavior: command.streamingBehavior, source: "rpc", // ← hardcoded ... ``` `session.prompt()` passes this source through to `emitInput`: ```javascript // agent-session.js L720 const inputResult = await this._extensionRunner.emitInput( currentText, currentImages, options?.source ?? "interactive", // ← "rpc" when called from RPC mode ... ``` `emitInput` (runner.js L839–L853) sets `event.source = source` and calls all `input` handlers with it. Because `source` is `"rpc"`, the kickoff branch in the gate is skipped. `lockActive` stays `false`. `pendingKickoff` stays `null`. The `before_agent_start` hook finds `pendingKickoff === null` and injects nothing. The model receives no operational instructions and does not start from Phase 1. **Consequence for Task 3:** The gate must be adapted to also trigger kickoff when `event.source === "rpc"` (or any non-interactive source) and the message matches the `/nuova-domanda|riprendi-sessione/` pattern. --- ## Q2 — Widget emission: does `emitAndWait` reach stdout? **Finding: NO — the gate crashes before any widget is emitted.** The gate's `emitAndWait` function calls `ctx.sendRaw(...)`: ```javascript // tht-gate.js (emitAndWait) ctx.sendRaw({ type: "extension_ui_request", ui_request: descriptor }); ``` `ctx.sendRaw` does not exist on `ExtensionContext` in the installed Pi runtime. A grep of `/dist/core/extensions/` returns zero matches for `sendRaw`: ``` $ grep -r "sendRaw" /opt/homebrew/.../dist/core/extensions/ (no output) ``` The `ExtensionContext` object (assembled in `runner.js createContext()`) exposes: `ui`, `mode`, `hasUI`, `cwd`, `abort` — not `sendRaw`. Calling `ctx.sendRaw(...)` throws `TypeError: ctx.sendRaw is not a function` and kills the tool call. This was observed empirically in the L2 run report ("Bug #4 — FATALE"). `extension_ui_request` on stdout is emitted **only by rpc-mode.js itself**, as a translation of native `ctx.ui.*` dialog methods. There is no extension-accessible API to emit a custom `extension_ui_request` with a widget-descriptor payload. **Consequence for Task 4:** `emitAndWait` must be rewritten to use the native `ctx.ui.*` dialog API (Variant A) — see Decision section. --- ## Q3 — Response routing: does an incoming `extension_ui_response` reach the gate? **Finding: NO — `pi.on("extension_ui_response")` is a dead listener.** `rpc-mode.js` routes incoming `extension_ui_response` lines at L573–L584: ```javascript // rpc-mode.js L573-L584 if (parsed.type === "extension_ui_response") { const response = parsed; const pending = pendingExtensionRequests.get(response.id); if (pending) { pendingExtensionRequests.delete(response.id); pending.resolve(response); } return; // ← always returns here; nothing forwarded to extensions } ``` After this `return`, the message is consumed. Control never reaches `handleCommand()`, and the extension runner is never notified. The gate registers: `pi.on("extension_ui_response", handleUiResponse)`. A search of `runner.js` confirms that `"extension_ui_response"` does not appear anywhere in the extension runner — there is no event type of that name that the runner fires. The `pi.on("extension_ui_response", ...)` listener in the gate **never fires**. Even if a widget were emitted (which Q2 shows it cannot be), the client's `extension_ui_response` would be consumed by `pendingExtensionRequests` (Pi's own map for native-dialog round-trips) and never forwarded to the gate's `_pending` map. The gate would hang indefinitely at the `await promise` in `emitAndWait`. **Consequence for Task 4:** This double-mismatch (no `sendRaw` + dead listener) means the entire custom widget round-trip must be replaced. Only the native `ctx.ui.*` API produces round-trips that rpc-mode.js actually completes. --- ## Q4 — Steering: does a `steer` RPC command reach the model? **Finding: YES — but the `!`-prefix steer channel in the gate is irrelevant in RPC mode.** The `steer` RPC command calls `session.steer()` (rpc-mode.js L317–L319): ```javascript case "steer": { await session.steer(command.message, command.images); return success(id, "steer"); } ``` `session.steer()` (agent-session.js L894–L903) calls `_queueSteer()` which calls `this.agent.steer(...)` directly — it does **not** go through `emitInput`. The gate's `input` hook is never fired. The gate's lock (`if (!lockActive || event.source !== "interactive") return { action: "continue" }`) is irrelevant because there is no `input` event to block. The `!`-prefix channel in the gate exists for TUI interactive mode (where the user types `!text` and the input hook sees `source === "interactive"`). In RPC mode, the client uses the `steer` command directly — the `!` prefix is not needed and should not be sent. **Consequence for Task 3/4:** No adaptation needed for steering reach. The RPC client sends `{ type: "steer", message: "..." }` and it reaches the model. The gate does not interfere. --- ## Decision for Tasks 3 and 4 ### Task 3 — Kickoff adaptation (REQUIRED) **Change:** In the `input` hook, add `"rpc"` to the set of sources that trigger kickoff: ```javascript // BEFORE: if (event.source === "interactive" && /^\/(nuova-domanda|riprendi-sessione)\b/.test(raw)) { // AFTER: if ((event.source === "interactive" || event.source === "rpc") && /^\/(nuova-domanda|riprendi-sessione)\b/.test(raw)) { ``` The rest of the hook's lock logic (`if (!lockActive || event.source !== "interactive") return { action: "continue" }`) should be updated similarly to allow the RPC client's messages through (or the client simply uses `steer` to communicate answers, bypassing the lock entirely — see Task 4 below). ### Task 4 — Widget round-trip (VARIANT A — rewrite emitAndWait on ctx.ui.*) `ctx.sendRaw` does not exist. `pi.on("extension_ui_response")` never fires. The entire widget emission + response path must be rebuilt on the native `ctx.ui.*` API. **Available native dialog methods in RPC mode** (confirmed from rpc-mode.js `createExtensionUIContext()`): | Method | RPC `extension_ui_request` method field | |--------|------------------------------------------| | `ctx.ui.select(title, options)` | `"select"` | | `ctx.ui.confirm(title)` | `"confirm"` | | `ctx.ui.input(title)` | `"input"` | | `ctx.ui.editor(title, prefill)` | `"editor"` | | `ctx.ui.notify(msg, type)` | `"notify"` (one-way) | `ctx.ui.custom` is a **no-op** in RPC mode (`return undefined`). There is no native multiselect. Multiselect (used in `reviewer_decide`) must be emulated as a series of `select` calls, or as a structured `input` (e.g. comma-separated IDs with a preamble listing options), or as a custom JSON-in-text protocol over `ctx.ui.input`. **Variant B (Pi with custom widget API) is not viable** — no such API exists in the installed runtime and there is no indication of a newer Pi version that adds it. **Variant C (wrapper intercepting native requests) is not viable** — `method` is an enum closed set; `ctx.ui.custom` is a no-op in RPC mode. **Implementation path for Task 4:** 1. Replace `ctx.sendRaw(...)` in `emitAndWait` with `ctx.ui.select(...)` / `ctx.ui.input(...)` depending on widget type. 2. Remove `pi.on("extension_ui_response", handleUiResponse)` — it has no effect. 3. Remove the `_pending` map — it is only used by the dead round-trip. 4. Map `buildSelectRequest` descriptors → `ctx.ui.select` calls; map `buildMultiselectRequest` → a `ctx.ui.input` call with structured prompt + JSON response parsing; map `buildArtifactGate` → `ctx.ui.confirm`. 5. The RPC client (the not-yet-built frontend) receives standard `extension_ui_request` events with `method: "select"/"input"/"confirm"` and responds with the standard `extension_ui_response` shape Pi already defines. --- ## Summary table | Question | Finding | |----------|---------| | Q1 Kickoff fires via `prompt` RPC? | **NO** — `source:"rpc"` bypasses `"interactive"` guard | | Q2 Widget reaches stdout? | **NO** — `ctx.sendRaw` does not exist; crashes | | Q3 Response reaches gate? | **NO** — rpc-mode.js `return`s after consuming it; `pi.on("extension_ui_response")` never fires | | Q4 `steer` reaches model? | **YES** — bypasses `emitInput` entirely; gate does not block it | **Tasks 3–4 both require gate adaptation.** No part of the existing mechanism works in RPC mode without changes. --- ## Project-local trust for clean RPC spawn To ensure RPC stdout remains clean (no interactive trust prompts polluting the JSONL stream), project-local files must be pre-approved via the `--approve` flag or pre-trusted settings. Task 9 sets `quietStartup: true` in `.pi/settings.json` to suppress Pi's banner; access to this project-local config must be granted ahead of time so the RPC spawn does not pause for a trust confirmation, which would block the command loop.