From 59bd6135e22635be52c914b9fb22e0f412b2349e Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 27 Jun 2026 19:38:04 +0200 Subject: [PATCH] spike(harness): probe gate behavior in pi --mode rpc + findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- harness/docs/rpc-readiness-findings.md | 257 +++++++++++++++++++++++++ harness/scripts/rpc_probe.mjs | 35 ++++ 2 files changed, 292 insertions(+) create mode 100644 harness/docs/rpc-readiness-findings.md create mode 100644 harness/scripts/rpc_probe.mjs diff --git a/harness/docs/rpc-readiness-findings.md b/harness/docs/rpc-readiness-findings.md new file mode 100644 index 00000000..08a5757c --- /dev/null +++ b/harness/docs/rpc-readiness-findings.md @@ -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. diff --git a/harness/scripts/rpc_probe.mjs b/harness/scripts/rpc_probe.mjs new file mode 100644 index 00000000..75439050 --- /dev/null +++ b/harness/scripts/rpc_probe.mjs @@ -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);