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:
@@ -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.
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user