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