spike(harness): probe gate behavior in pi --mode rpc + findings

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>
This commit is contained in:
2026-06-27 19:38:04 +02:00
co-authored by Claude Sonnet 4.6
parent c9c150c942
commit 59bd6135e2
2 changed files with 292 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
# 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.
+35
View File
@@ -0,0 +1,35 @@
// rpc_probe.mjs — manual probe: drive `pi --mode rpc` and observe the gate.
// Usage (from harness/, with .env loaded + VPN up): node scripts/rpc_probe.mjs
import { spawn } from "node:child_process";
const pi = spawn("pi", ["--mode", "rpc"], { cwd: process.cwd(), env: process.env });
const send = (obj) => {
const line = JSON.stringify(obj) + "\n";
process.stdout.write(`>>> SEND ${line}`);
pi.stdin.write(line);
};
let buf = "";
pi.stdout.on("data", (chunk) => {
buf += chunk.toString("utf8");
for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (!line.trim()) continue;
process.stdout.write(`<<< RECV ${line}\n`);
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.type === "extension_ui_request") {
// Reply in BOTH the gate's expected shape and Pi's native shape; observe which one unblocks.
const id = msg.id ?? msg.ui_request?.id;
send({ type: "extension_ui_response", id, control: "freetext", text: "PROBE-ANSWER" });
}
}
});
pi.stderr.on("data", (d) => process.stdout.write(`!!! STDERR ${d}`));
pi.on("exit", (code) => console.log(`### pi exited ${code}`));
// Kick off the workflow via an RPC prompt command (NOT interactive keystrokes).
setTimeout(() => send({ type: "prompt", message: '/nuova-domanda "quante cardioversioni nel 2024"' }), 500);
setTimeout(() => { pi.stdin.end(); }, 60000);