Static analysis of Pi runtime (rpc-mode.js, agent-session.js, runner.js)
confirms all 4 decisive questions:
- Q1 kickoff: NO — source:"rpc" bypasses the "interactive" guard (agent-session.js:720)
- Q2 widget emission: NO — ctx.sendRaw does not exist, crashes (0 refs in core/extensions/)
- Q3 response routing: NO — rpc-mode.js returns after consuming extension_ui_response;
pi.on("extension_ui_response") never fires (not in runner.js)
- Q4 steering: YES — steer() bypasses emitInput entirely
Decision: both Task 3 (kickoff guard) and Task 4 (emitAndWait → ctx.ui.* / Variant A)
require gate adaptation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
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:
- Replace
ctx.sendRaw(...)inemitAndWaitwithctx.ui.select(...)/ctx.ui.input(...)depending on widget type. - Remove
pi.on("extension_ui_response", handleUiResponse)— it has no effect. - Remove the
_pendingmap — it is only used by the dead round-trip. - Map
buildSelectRequestdescriptors →ctx.ui.selectcalls; mapbuildMultiselectRequest→ actx.ui.inputcall with structured prompt + JSON response parsing; mapbuildArtifactGate→ctx.ui.confirm. - The RPC client (the not-yet-built frontend) receives standard
extension_ui_requestevents withmethod: "select"/"input"/"confirm"and responds with the standardextension_ui_responseshape 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.