Files
ThothII/harness/docs/rpc-readiness-findings.md
T
marcopanandClaude Opus 4.8 ae9308470b chore(harness): quietStartup + project-local trust for clean RPC spawn
Set quietStartup:true in .pi/settings.json to suppress Pi banner on RPC stdout.
Add test pinning both quietStartup and theme values. Document project-local trust
requirement (--approve) so no trust prompt blocks RPC loop iteration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:33:03 +02:00

11 KiB
Raw Blame History

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":

// 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():

// 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:

// 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(...):

// 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:

// 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):

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:

// 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 returns 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.