Files
ThothII/harness/docs/rpc-readiness-findings.md
T
marcopanandClaude Opus 4.8 ae9308470b chore(harness): quietStartup + project-local trust for clean RPC spawn
Set quietStartup:true in .pi/settings.json to suppress Pi banner on RPC stdout.
Add test pinning both quietStartup and theme values. Document project-local trust
requirement (--approve) so no trust prompt blocks RPC loop iteration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:33:03 +02:00

264 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## Project-local trust for clean RPC spawn
To ensure RPC stdout remains clean (no interactive trust prompts polluting the JSONL stream), project-local files must be pre-approved via the `--approve` flag or pre-trusted settings. Task 9 sets `quietStartup: true` in `.pi/settings.json` to suppress Pi's banner; access to this project-local config must be granted ahead of time so the RPC spawn does not pause for a trust confirmation, which would block the command loop.