Files
ThothII/docs/superpowers/plans/2026-06-27-backend-implementation.md
T
marcopanandClaude Opus 4.8 aecc86f328 docs(plans): correggi wire contract post-spike (ctx.ui.input nativo, niente sendRaw)
Lo spike Task 1 ha provato che ctx.sendRaw non esiste e pi.on(extension_ui_response)
non e' dispatchato. Aggiornati: Piano1 Task2 (mock ctx.ui), Task4 (rewrite gate a
ctx.ui.input con descriptor in title), Task10 (fake-pi-rpc shape nativa); Piano2
Task3/Task4 (SessionBridge decodifica title<->value). Contratto FE invariato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:47:21 +02:00

46 KiB
Raw Blame History

Backend Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Costruire il backend ThothII: un orchestratore/traduttore Node+Fastify+TS che avvia Pi in RPC (un processo per sessione), fa da ponte JSONL↔(SSE/REST) verso il frontend, delega a tht l'esecuzione del SQL finale, e applica auth pluggabile.

Architecture: Backend senza stato persistente proprio (verità su disco harness). Un RpcClient (framing LF-only) per ogni processo Pi gestito dal PiProcessManager (uno per sessione attiva). Il SessionBridge traduce extension_ui_request↔ui_request correlando per id e mantiene il widget pendente per il re-emit su riconnessione SSE. Le interazioni con il modello (prompt/steer/set_model/set_thinking/get_available_models) usano i comandi RPC nativi di Pi. Il SQL finale è delegato a tht sql preview/export (nessun client DB nel backend). Tutto testabile in CI contro il fake-pi-rpc consegnato dal Piano Harness.

Tech Stack: Node ≥ 20, TypeScript (ESM), Fastify 5, vitest (test), tsx (dev run). Pi = @earendil-works/pi-coding-agent (pi --mode rpc). CLI tht (Python, già installato nel venv harness).

Global Constraints

  • Prerequisito: il Piano Harness (2026-06-27-harness-rpc-readiness.md) deve essere completato — il backend dipende da: gate RPC-ready, THT_SESSION id injection, tht sql preview --json/--offset, tht session list/show --json, campi manifest, fake-pi-rpc.
  • Framing RPC: LF-only JSONL — JSON.stringify(v)+"\n"; lettura split su \n, strip \r finale. MAI readline. Riferimento: @earendil-works/pi-coding-agent/dist/modes/rpc/jsonl.js.
  • WIRE CONTRACT (corretto post-spike, Piano Harness Task 1): il gate usa l'API UI nativa di Pi. Sul wire arriva {type:"extension_ui_request", id, method:"input", title:"<widget-descriptor JSON>"}; il backend decodifica title → descriptor, lo espone al FE come ui_request, e risponde {type:"extension_ui_response", id, value:"<ui_response JSON>"} (o {id, cancelled:true}). ctx.sendRaw non esiste; non esistono eventi {ui_request:…} nidificati. Il contratto verso il FE (ui_request/ui_response, architettura §4) resta invariato — la traduzione native↔FE è responsabilità del SessionBridge.
  • Deployment MVP: localhost, mono-operatore (D12-B). Auth default none (utente dev@local).
  • Il backend NON tocca il DB: ogni esecuzione SQL passa da tht (BE-2). Nessun driver pg/REST nel backend.
  • Posizione harness: path configurabile (THT_HARNESS_DIR, default ../harness rispetto al backend); tht invocato dal venv harness; spawn di Pi con cwd = THT_HARNESS_DIR.
  • Nessun segreto nel codice: credenziali via .env/ambiente, mai committate.
  • Test in CI senza Pi/LLM reali: si usa fake-pi-rpc (Piano Harness, harness/tests/fake_pi/fake_pi_rpc.mjs). L2 con Pi reale resta separato e informativo.

Task 1: Scaffold del progetto backend

Files:

  • Create: backend/package.json, backend/tsconfig.json, backend/vitest.config.ts
  • Create: backend/src/config.ts, backend/src/server.ts, backend/src/app.ts
  • Test: backend/test/health.test.ts

Interfaces:

  • Produces: buildApp(config: AppConfig): FastifyInstance (registra le route, non ascolta); loadConfig(env): AppConfig con { port, harnessDir, thtBin, piBin, authMode, defaults: {provider?, model?, thinking?}, maxPiProcesses }.

  • Step 1: package.json + tsconfig + vitest config

// backend/package.json
{
  "name": "thothii-backend",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.json",
    "test": "vitest run",
    "start": "node dist/server.js"
  },
  "dependencies": { "fastify": "^5.0.0" },
  "devDependencies": { "typescript": "^5.6.0", "tsx": "^4.19.0", "vitest": "^2.1.0", "@types/node": "^22.0.0" }
}
// backend/tsconfig.json
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler",
  "strict": true, "outDir": "dist", "rootDir": "src", "esModuleInterop": true, "skipLibCheck": true },
  "include": ["src"] }
// backend/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"] } });
  • Step 2: Scrivere il test health
// backend/test/health.test.ts
import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";

test("GET /health ritorna ok", async () => {
  const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
  const res = await app.inject({ method: "GET", url: "/health" });
  expect(res.statusCode).toBe(200);
  expect(res.json()).toEqual({ status: "ok" });
});
  • Step 3: Eseguire (deve fallire)

Run: cd backend && npm install && npm test Expected: FAIL — Cannot find module '../src/app.js'.

  • Step 4: Implementare config + app + server
// backend/src/config.ts
export interface AppConfig {
  port: number; harnessDir: string; thtBin: string; piBin: string;
  authMode: "none" | "mock" | "oidc";
  defaults: { provider?: string; model?: string; thinking?: string };
  maxPiProcesses: number;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
  return {
    port: Number(env.PORT ?? 8787),
    harnessDir: env.THT_HARNESS_DIR ?? "../harness",
    thtBin: env.THT_BIN ?? "tht",
    piBin: env.PI_BIN ?? "pi",
    authMode: (env.AUTH_MODE as AppConfig["authMode"]) ?? "none",
    defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
    maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
  };
}
// backend/src/app.ts
import Fastify, { type FastifyInstance } from "fastify";
import type { AppConfig } from "./config.js";
export function buildApp(_config: AppConfig): FastifyInstance {
  const app = Fastify({ logger: false });
  app.get("/health", async () => ({ status: "ok" }));
  return app;
}
// backend/src/server.ts
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig(process.env);
const app = buildApp(config);
app.listen({ port: config.port, host: "127.0.0.1" })
  .then((addr) => console.log(`backend listening on ${addr}`));
  • Step 5: Eseguire (deve passare) + commit

Run: cd backend && npm test Expected: PASS.

git add backend/package.json backend/tsconfig.json backend/vitest.config.ts backend/src backend/test
git commit -m "feat(backend): scaffold Fastify+TS + /health"

Task 2: LineSplitter — lettura JSONL LF-only

Files:

  • Create: backend/src/rpc/line-splitter.ts
  • Test: backend/test/line-splitter.test.ts

Interfaces:

  • Produces: attachJsonlReader(stream: Readable, onLine: (line: string) => void): () => void — split su \n, strip \r finale, gestione chunk parziali; ritorna funzione di detach.

  • Step 1: Scrivere il test

import { test, expect } from "vitest";
import { Readable } from "node:stream";
import { attachJsonlReader } from "../src/rpc/line-splitter.js";

test("riassembla righe spezzate tra chunk, split solo su \\n", async () => {
  const lines: string[] = [];
  const s = new Readable({ read() {} });
  attachJsonlReader(s, (l) => lines.push(l));
  s.push('{"a":1}\n{"b":'); s.push('2}\r\n{"u":"
 in stringa"}\n'); s.push(null);
  await new Promise((r) => s.on("end", r));
  expect(lines).toEqual(['{"a":1}', '{"b":2}', '{"u":"
 in stringa"}']);
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- line-splitter Expected: FAIL — modulo assente.

  • Step 3: Implementare (port di jsonl.js)
// backend/src/rpc/line-splitter.ts
import { StringDecoder } from "node:string_decoder";
import type { Readable } from "node:stream";
export function attachJsonlReader(stream: Readable, onLine: (line: string) => void): () => void {
  const decoder = new StringDecoder("utf8");
  let buffer = "";
  const emit = (line: string) => onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
  const onData = (chunk: Buffer | string) => {
    buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
    for (let nl; (nl = buffer.indexOf("\n")) !== -1; ) {
      emit(buffer.slice(0, nl)); buffer = buffer.slice(nl + 1);
    }
  };
  const onEnd = () => { buffer += decoder.end(); if (buffer) { emit(buffer); buffer = ""; } };
  stream.on("data", onData); stream.on("end", onEnd);
  return () => { stream.off("data", onData); stream.off("end", onEnd); };
}
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- line-splitter Expected: PASS.

git add backend/src/rpc/line-splitter.ts backend/test/line-splitter.test.ts
git commit -m "feat(backend): LF-only JSONL line splitter"

Task 3: RpcClient — spawn, invio comandi, correlazione risposte

Files:

  • Create: backend/src/rpc/rpc-client.ts
  • Test: backend/test/rpc-client.test.ts

Interfaces:

  • Consumes: attachJsonlReader (Task 2); harness/tests/fake_pi/fake_pi_rpc.mjs (Piano Harness).

  • Produces: class RpcClient:

    • constructor(child: ChildProcessWithoutNullStreams)
    • send(cmd: object): void — scrive JSON.stringify(cmd)+"\n" su stdin
    • request(cmd: object & {type:string}): Promise<any> — invia con id generato, risolve sulla {type:"response"} correlata
    • on(event: "event", cb: (evt: any) => void) — emette ogni messaggio NON-response (eventi: extension_ui_request, text_delta, agent_end, …)
    • nextId(): string
  • Step 1: Scrivere il test (contro fake-pi-rpc)

import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { RpcClient } from "../src/rpc/rpc-client.js";

const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");

test("request(get_available_models) correla la response", async () => {
  const child = spawn("node", [FAKE, SCRIPT]);
  const rpc = new RpcClient(child as any);
  const res = await rpc.request({ type: "get_available_models" });
  expect(res.data.models[0].provider).toBe("zai");
  child.stdin.end();
});

test("prompt emette un evento extension_ui_request", async () => {
  const child = spawn("node", [FAKE, SCRIPT]);
  const rpc = new RpcClient(child as any);
  const got = new Promise<any>((resolve) => rpc.on("event", (e) => e.type === "extension_ui_request" && resolve(e)));
  rpc.send({ type: "prompt", message: "/nuova-domanda \"x\"" });
  const evt = await got;
  expect(evt.method).toBe("input");                       // shape nativa di Pi
  expect(JSON.parse(evt.title).widget).toBe("select");    // descriptor nel title
  child.stdin.end();
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- rpc-client Expected: FAIL — modulo assente.

  • Step 3: Implementare RpcClient
// backend/src/rpc/rpc-client.ts
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { attachJsonlReader } from "./line-splitter.js";
type Listener = (evt: any) => void;
export class RpcClient {
  private seq = 0;
  private pending = new Map<string, (resp: any) => void>();
  private listeners = new Set<Listener>();
  constructor(private child: ChildProcessWithoutNullStreams) {
    attachJsonlReader(child.stdout, (line) => {
      if (!line) return;
      let msg: any; try { msg = JSON.parse(line); } catch { return; }
      if (msg.type === "response" && msg.id && this.pending.has(msg.id)) {
        this.pending.get(msg.id)!(msg); this.pending.delete(msg.id); return;
      }
      for (const l of this.listeners) l(msg);
    });
  }
  nextId(): string { return `c${++this.seq}`; }
  send(cmd: object): void { this.child.stdin.write(JSON.stringify(cmd) + "\n"); }
  request(cmd: object & { type: string }): Promise<any> {
    const id = this.nextId();
    return new Promise((resolve) => { this.pending.set(id, resolve); this.send({ ...cmd, id }); });
  }
  on(_event: "event", cb: Listener): void { this.listeners.add(cb); }
}
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- rpc-client Expected: PASS (2 test).

git add backend/src/rpc/rpc-client.ts backend/test/rpc-client.test.ts
git commit -m "feat(backend): RpcClient (spawn/send/request/event) over JSONL"

Task 4: SessionBridge — traduzione widget-descriptor ↔ RPC + widget pendente

Files:

  • Create: backend/src/bridge/session-bridge.ts
  • Test: backend/test/session-bridge.test.ts

Interfaces:

  • Consumes: RpcClient (Task 3).

  • Produces: class SessionBridge:

    • constructor(rpc: RpcClient)
    • onClientEvent(cb: (e: ClientEvent) => void) — emette verso il FE: {type:"ui_request"|"info"|"text_delta"|"system_event", ...}
    • respond(uiResponse: object & {id:string}): void — invia a Pi {type:"extension_ui_response", id, value: JSON.stringify(uiResponse)} (shape NATIVA: il payload va in value)
    • steer(text: string): void — invia {type:"steer", message: text}
    • pendingWidget(): object | null — l'ultimo descriptor non ancora risposto (per re-emit)
  • Decodifica della shape nativa (WIRE CONTRACT): un evento {type:"extension_ui_request", id, method:"input", title} → il bridge fa JSON.parse(title) → descriptor, lo espone come ui_request; method:"notify" → info; altri method (setStatus/setWidget) ignorati in MVP.

  • Tipi: ClientEvent = {type:"ui_request", ui_request:object} | {type:"text_delta", text:string} | {type:"info",...} | {type:"system_event",...}.

  • Step 1: Scrivere il test

import { test, expect, vi } from "vitest";
import { SessionBridge } from "../src/bridge/session-bridge.js";

function fakeRpc() {
  const sent: any[] = []; let evcb: any;
  return { rpc: { send: (c:any)=>sent.push(c), on: (_:any,cb:any)=>{evcb=cb}, request: vi.fn() } as any,
           sent, fire: (m:any)=>evcb(m) };
}

test("extension_ui_request nativo (method:input, title=json) diventa ui_request ed è il pendente", () => {
  const { rpc, fire } = fakeRpc();
  const b = new SessionBridge(rpc);
  const seen: any[] = []; b.onClientEvent((e)=>seen.push(e));
  const descriptor = { id:"u1", widget:"select" };
  fire({ type:"extension_ui_request", id:"u1", method:"input", title: JSON.stringify(descriptor) });
  expect(seen[0]).toEqual({ type:"ui_request", ui_request: descriptor });
  expect(b.pendingWidget()).toEqual(descriptor);
});

test("respond invia extension_ui_response con payload in value e azzera il pendente", () => {
  const { rpc, sent, fire } = fakeRpc();
  const b = new SessionBridge(rpc);
  fire({ type:"extension_ui_request", id:"u1", method:"input", title: JSON.stringify({ id:"u1", widget:"select" }) });
  b.respond({ id:"u1", choices:["a"] });
  expect(sent.at(-1)).toEqual({ type:"extension_ui_response", id:"u1", value: JSON.stringify({ id:"u1", choices:["a"] }) });
  expect(b.pendingWidget()).toBeNull();
});

test("steer invia un comando steer", () => {
  const { rpc, sent } = fakeRpc();
  new SessionBridge(rpc).steer("considera solo il 2024");
  expect(sent.at(-1)).toEqual({ type:"steer", message:"considera solo il 2024" });
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- session-bridge Expected: FAIL — modulo assente.

  • Step 3: Implementare SessionBridge
// backend/src/bridge/session-bridge.ts
import type { RpcClient } from "../rpc/rpc-client.js";
export type ClientEvent =
  | { type: "ui_request"; ui_request: any }
  | { type: "text_delta"; text: string }
  | { type: "info"; [k: string]: any }
  | { type: "system_event"; [k: string]: any };

export class SessionBridge {
  private pending: any = null;
  private cbs = new Set<(e: ClientEvent) => void>();
  constructor(private rpc: RpcClient) {
    rpc.on("event", (m) => {
      if (m.type === "extension_ui_request" && m.method === "input") {
        let descriptor: any; try { descriptor = JSON.parse(m.title); } catch { return; }
        this.pending = descriptor;
        this.fan({ type: "ui_request", ui_request: descriptor });
      } else if (m.type === "extension_ui_request" && m.method === "notify") {
        this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
      } else if (m.type === "text_delta") {
        this.fan({ type: "text_delta", text: m.text ?? "" });
      } else if (m.type === "system_event") {
        this.fan(m as ClientEvent);
      }
      // altri method nativi (setStatus/setWidget) e altri eventi Pi (agent_end, tool_call) non inoltrati in MVP
    });
  }
  private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); }
  onClientEvent(cb: (e: ClientEvent) => void) { this.cbs.add(cb); }
  respond(uiResponse: object & { id: string }) {
    this.rpc.send({ type: "extension_ui_response", id: uiResponse.id, value: JSON.stringify(uiResponse) });
    if (this.pending && uiResponse.id === this.pending.id) this.pending = null;
  }
  steer(text: string) { this.rpc.send({ type: "steer", message: text }); }
  pendingWidget() { return this.pending; }
}
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- session-bridge Expected: PASS (3 test).

git add backend/src/bridge/session-bridge.ts backend/test/session-bridge.test.ts
git commit -m "feat(backend): SessionBridge widget-descriptor <-> RPC + pending widget"

Task 5: ThtRunner — wrapper dei comandi tht … --json

Files:

  • Create: backend/src/tht/tht-runner.ts
  • Test: backend/test/tht-runner.test.ts

Interfaces:

  • Produces: class ThtRunner (config: {thtBin, harnessDir, configPath}):

    • sessionNew(opts): Promise<{id:string}> → tht session new <q> [--provider…] --json
    • sessionList(): Promise<SessionRow[]> → tht session list --json
    • sessionShow(id): Promise<any> → tht session show <id> --json
    • sqlPreview(id, {limit, offset}): Promise<{columns,rows,execution_ms,truncated}>
    • sqlExport(id): Promise<{path:string}>
    • run(args: string[]): Promise<{code:number, stdout:string, stderr:string}> (primitiva, iniettabile per test)
  • Step 1: Scrivere il test (run iniettabile)

import { test, expect } from "vitest";
import { ThtRunner } from "../src/tht/tht-runner.js";

test("sessionNew parsa l'id dal JSON", async () => {
  const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
  r.run = async () => ({ code: 0, stdout: '{"id":"2026-06-27-100000-x"}', stderr: "" });
  expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
});

test("run con exit != 0 propaga errore con stderr", async () => {
  const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
  r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
  await expect(r.sessionList()).rejects.toThrow(/boom/);
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- tht-runner Expected: FAIL — modulo assente.

  • Step 3: Implementare ThtRunner
// backend/src/tht/tht-runner.ts
import { spawn } from "node:child_process";
export interface ThtConfig { thtBin: string; harnessDir: string; configPath: string; }
export interface SessionRow { id: string; status: string; question: string; summary: string | null;
  created_at: string; updated_at: string | null; author: string | null; }
export class ThtRunner {
  constructor(private cfg: ThtConfig) {}
  run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
    return new Promise((resolve) => {
      const ch = spawn(this.cfg.thtBin, ["-c", this.cfg.configPath, ...args], { cwd: this.cfg.harnessDir });
      let stdout = "", stderr = "";
      ch.stdout.on("data", (d) => (stdout += d)); ch.stderr.on("data", (d) => (stderr += d));
      ch.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
    });
  }
  private async json<T>(args: string[]): Promise<T> {
    const { code, stdout, stderr } = await this.run(args);
    if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`);
    return JSON.parse(stdout) as T;
  }
  async sessionNew(o: { question: string; provider?: string; model?: string; thinking?: string; name?: string }) {
    const a = ["session", "new", o.question];
    for (const [f, v] of [["--provider", o.provider], ["--model", o.model], ["--thinking", o.thinking], ["--name", o.name]] as const)
      if (v) a.push(f, v);
    a.push("--json");
    return this.json<{ id: string }>(a);
  }
  sessionList() { return this.json<SessionRow[]>(["session", "list", "--json"]); }
  sessionShow(id: string) { return this.json<any>(["session", "show", id, "--json"]); }
  sqlPreview(id: string, p: { limit?: number; offset?: number }) {
    const a = ["sql", "preview", `sessions/${id}/sql_final.sql`, "--session", id, "--json"];
    if (p.limit != null) a.push("--limit", String(p.limit));
    if (p.offset) a.push("--offset", String(p.offset));
    return this.json<{ columns: string[]; rows: unknown[][]; execution_ms: number; truncated: boolean }>(a);
  }
  async sqlExport(id: string) {
    const { code, stdout, stderr } = await this.run(["sql", "export", "--session", id]);
    if (code !== 0) throw new Error(`tht sql export exit ${code}: ${stderr.trim()}`);
    return { path: stdout.trim() };
  }
}
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- tht-runner Expected: PASS (2 test).

git add backend/src/tht/tht-runner.ts backend/test/tht-runner.test.ts
git commit -m "feat(backend): ThtRunner wrapper for tht --json (sessions, sql preview/export)"

Task 6: PiProcessManager — un Pi per sessione, spawn/teardown/resume + settings

Files:

  • Create: backend/src/pi/pi-process-manager.ts
  • Test: backend/test/pi-process-manager.test.ts

Interfaces:

  • Consumes: RpcClient (Task 3), SessionBridge (Task 4), AppConfig (Task 1).

  • Produces: class PiProcessManager:

    • spawnFor(sessionId: string, opts: {provider?, model?, thinking?, name?, author?}): Promise<SessionRuntime> — spawn pi --mode rpc con cwd=harnessDir, env {...process.env, THT_SESSION: sessionId, THT_AUTHOR: author, PATH: <venv/bin>:PATH}, --approve; dopo lo spawn applica set_model/set_thinking_level via RPC; manda prompt "/nuova-domanda …" per avviare il workflow.
    • get(sessionId): SessionRuntime | undefined
    • teardown(sessionId): void
    • count(): number — rispetta maxPiProcesses (errore esplicito oltre il cap)
  • SessionRuntime = { rpc: RpcClient; bridge: SessionBridge; child: ChildProcess }

  • Iniettabile: spawnFn (default spawn) per test senza Pi reale.

  • Step 1: Scrivere il test (spawnFn iniettato = fake-pi-rpc)

import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
import { loadConfig } from "../src/config.js";

const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");

test("spawnFor avvia un runtime e il bridge emette il widget F1", async () => {
  const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
  const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
  const rt = await mgr.spawnFor("2026-06-27-100000-x", {});
  const widget = await new Promise<any>((res) => rt.bridge.onClientEvent((e) => e.type === "ui_request" && res(e)));
  expect(widget.ui_request.widget).toBe("select");
  mgr.teardown("2026-06-27-100000-x");
  expect(mgr.count()).toBe(0);
});

test("oltre maxPiProcesses solleva errore", async () => {
  const cfg = { ...loadConfig({}), maxPiProcesses: 1 };
  const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
  await mgr.spawnFor("a", {});
  await expect(mgr.spawnFor("b", {})).rejects.toThrow(/max/i);
  mgr.teardown("a");
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- pi-process-manager Expected: FAIL — modulo assente.

  • Step 3: Implementare PiProcessManager
// backend/src/pi/pi-process-manager.ts
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { SessionBridge } from "../bridge/session-bridge.js";

export interface SessionRuntime { rpc: RpcClient; bridge: SessionBridge; child: ChildProcessWithoutNullStreams; }
type SpawnFn = (cfg: AppConfig, sessionId: string, env: NodeJS.ProcessEnv) => ChildProcessWithoutNullStreams;

export class PiProcessManager {
  private runtimes = new Map<string, SessionRuntime>();
  private spawnFn: SpawnFn;
  constructor(private cfg: AppConfig, opts?: { spawnFn?: (...a: any[]) => ChildProcessWithoutNullStreams }) {
    this.spawnFn = opts?.spawnFn
      ? () => opts.spawnFn!()
      : (cfg, sessionId, env) => nodeSpawn(cfg.piBin, ["--mode", "rpc", "--approve"], { cwd: cfg.harnessDir, env });
  }
  count() { return this.runtimes.size; }
  get(id: string) { return this.runtimes.get(id); }
  async spawnFor(sessionId: string, o: { provider?: string; model?: string; thinking?: string; author?: string }) {
    if (this.runtimes.size >= this.cfg.maxPiProcesses) throw new Error("max Pi processes reached");
    const env = { ...process.env, THT_SESSION: sessionId, THT_AUTHOR: o.author ?? "dev@local" };
    const child = this.spawnFn(this.cfg, sessionId, env);
    const rpc = new RpcClient(child); const bridge = new SessionBridge(rpc);
    const rt: SessionRuntime = { rpc, bridge, child };
    this.runtimes.set(sessionId, rt);
    child.on("exit", () => this.runtimes.delete(sessionId));
    const provider = o.provider ?? this.cfg.defaults.provider;
    const model = o.model ?? this.cfg.defaults.model;
    const thinking = o.thinking ?? this.cfg.defaults.thinking;
    if (provider && model) await rpc.request({ type: "set_model", provider, modelId: model });
    if (thinking) await rpc.request({ type: "set_thinking_level", level: thinking });
    rpc.send({ type: "prompt", message: `/nuova-domanda "kickoff"` });
    return rt;
  }
  teardown(id: string) { const rt = this.runtimes.get(id); if (rt) { rt.child.kill(); this.runtimes.delete(id); } }
}

Nota PATH (rischio L2 #1): in produzione env.PATH deve includere harness/.venv/bin perché Pi spawni tht. Aggiungere alla composizione env: PATH: \${harnessVenvBin}:${process.env.PATH}``. Coperto in Task 11 (wiring reale).

  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- pi-process-manager Expected: PASS (2 test).

git add backend/src/pi/pi-process-manager.ts backend/test/pi-process-manager.test.ts
git commit -m "feat(backend): PiProcessManager (one Pi per session, cap, set_model/thinking)"

Task 7: SSE hub + re-emit del widget pendente

Files:

  • Create: backend/src/sse/sse-hub.ts
  • Test: backend/test/sse-hub.test.ts

Interfaces:

  • Produces: class SseHub:

    • subscribe(sessionId, send: (event: string, data: object) => void, pending?: object | null): () => void — alla sottoscrizione, se pending è presente, invia subito send("ui_request", {ui_request: pending}); ritorna unsubscribe.
    • publish(sessionId, event: string, data: object): void — a tutti i subscriber della sessione.
  • Step 1: Scrivere il test

import { test, expect } from "vitest";
import { SseHub } from "../src/sse/sse-hub.js";

test("re-emette il widget pendente alla sottoscrizione", () => {
  const hub = new SseHub(); const sent: any[] = [];
  hub.subscribe("s1", (ev, data) => sent.push({ ev, data }), { id: "u1", widget: "select" });
  expect(sent[0]).toEqual({ ev: "ui_request", data: { ui_request: { id: "u1", widget: "select" } } });
});

test("publish raggiunge i subscriber e unsubscribe li stacca", () => {
  const hub = new SseHub(); const sent: any[] = [];
  const off = hub.subscribe("s1", (ev, data) => sent.push({ ev, data }));
  hub.publish("s1", "text_delta", { text: "x" });
  off(); hub.publish("s1", "text_delta", { text: "y" });
  expect(sent).toEqual([{ ev: "text_delta", data: { text: "x" } }]);
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- sse-hub Expected: FAIL — modulo assente.

  • Step 3: Implementare SseHub
// backend/src/sse/sse-hub.ts
type Send = (event: string, data: object) => void;
export class SseHub {
  private subs = new Map<string, Set<Send>>();
  subscribe(sessionId: string, send: Send, pending?: object | null): () => void {
    if (!this.subs.has(sessionId)) this.subs.set(sessionId, new Set());
    this.subs.get(sessionId)!.add(send);
    if (pending) send("ui_request", { ui_request: pending });
    return () => this.subs.get(sessionId)?.delete(send);
  }
  publish(sessionId: string, event: string, data: object): void {
    for (const s of this.subs.get(sessionId) ?? []) s(event, data);
  }
}
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- sse-hub Expected: PASS (2 test).

git add backend/src/sse/sse-hub.ts backend/test/sse-hub.test.ts
git commit -m "feat(backend): SSE hub with pending-widget re-emit on (re)subscribe"

Task 8: Auth middleware pluggabile (none/mock/oidc)

Files:

  • Create: backend/src/auth/auth.ts
  • Test: backend/test/auth.test.ts

Interfaces:

  • Produces: authPreHandler(mode)→ Fastify preHandler che imposta req.user = {id}: none→dev@local; mock→header x-mock-user; oidc→verifica bearer (stub MVP: 501 se non configurato). getUser(req): {id:string}.

  • Step 1: Scrivere il test

import { test, expect } from "vitest";
import Fastify from "fastify";
import { authPreHandler, getUser } from "../src/auth/auth.js";

test("mode none assegna dev@local", async () => {
  const app = Fastify(); app.addHook("preHandler", authPreHandler("none"));
  app.get("/me", async (req) => getUser(req));
  expect((await app.inject({ method: "GET", url: "/me" })).json()).toEqual({ id: "dev@local" });
});

test("mode mock legge l'header", async () => {
  const app = Fastify(); app.addHook("preHandler", authPreHandler("mock"));
  app.get("/me", async (req) => getUser(req));
  const res = await app.inject({ method: "GET", url: "/me", headers: { "x-mock-user": "alice" } });
  expect(res.json()).toEqual({ id: "alice" });
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- auth Expected: FAIL — modulo assente.

  • Step 3: Implementare auth
// backend/src/auth/auth.ts
import type { FastifyRequest, FastifyReply } from "fastify";
export function authPreHandler(mode: "none" | "mock" | "oidc") {
  return async (req: FastifyRequest, reply: FastifyReply) => {
    if (mode === "none") (req as any).user = { id: "dev@local" };
    else if (mode === "mock") (req as any).user = { id: (req.headers["x-mock-user"] as string) ?? "mock" };
    else { reply.code(501); throw new Error("OIDC non configurato (MVP: usa none/mock)"); }
  };
}
export function getUser(req: FastifyRequest): { id: string } { return (req as any).user ?? { id: "dev@local" }; }
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- auth Expected: PASS (2 test).

git add backend/src/auth/auth.ts backend/test/auth.test.ts
git commit -m "feat(backend): pluggable auth (none/mock/oidc seam)"

Task 9: Route sessioni + SSE + response/steer (wiring)

Files:

  • Modify: backend/src/app.ts (registra le route, costruisce i singleton)
  • Create: backend/src/routes/sessions.ts
  • Test: backend/test/routes-sessions.test.ts

Interfaces:

  • Consumes: PiProcessManager (6), ThtRunner (5), SseHub (7), auth (8).

  • Produces (route, tutte sotto authPreHandler):

    • POST /sessions {workspace, question, provider?, model?, thinking?, name?} → tht session new (con author dall'auth) → mgr.spawnFor(id) → {id}
    • GET /sessions → tht session list --json
    • GET /sessions/:id → tht session show --json
    • GET /sessions/:id/events → SSE; sottoscrive SseHub con bridge.pendingWidget(); collega bridge.onClientEvent → hub.publish
    • POST /sessions/:id/response {ui_response} → bridge.respond
    • POST /sessions/:id/steer {text} → bridge.steer
    • POST /sessions/:id/close → mgr.teardown
  • Step 1: Scrivere il test (inietta ThtRunner + spawnFn fake)

import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";

const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");

test("POST /sessions crea e avvia, GET /sessions lista", async () => {
  const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
    thtRunner: { sessionNew: async () => ({ id: "s1" }), sessionList: async () => [{ id: "s1" }] } as any,
    spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
  });
  const created = await app.inject({ method: "POST", url: "/sessions", payload: { workspace: "w", question: "q" } });
  expect(created.json()).toEqual({ id: "s1" });
  const list = await app.inject({ method: "GET", url: "/sessions" });
  expect(list.json()).toEqual([{ id: "s1" }]);
});

test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
  const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
    thtRunner: { sessionNew: async () => ({ id: "s1" }) } as any,
    spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
  });
  await app.inject({ method: "POST", url: "/sessions", payload: { workspace: "w", question: "q" } });
  const res = await app.inject({ method: "POST", url: "/sessions/s1/response",
    payload: { ui_response: { id: "u1", choices: ["a"] } } });
  expect(res.statusCode).toBe(204);
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- routes-sessions Expected: FAIL — buildApp non accetta deps / route assenti.

  • Step 3: Rendere buildApp iniettabile e registrare le route

In app.ts accettare deps?: { thtRunner?, spawnFn? }, costruire ThtRunner/PiProcessManager/SseHub, applicare authPreHandler(config.authMode), e registrare sessionRoutes.

// backend/src/routes/sessions.ts (estratto load-bearing)
import type { FastifyInstance } from "fastify";
import type { PiProcessManager } from "../pi/pi-process-manager.js";
import type { ThtRunner } from "../tht/tht-runner.js";
import type { SseHub } from "../sse/sse-hub.js";
import { getUser } from "../auth/auth.js";

export function sessionRoutes(app: FastifyInstance, d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub }) {
  app.post("/sessions", async (req, reply) => {
    const b = req.body as any;
    const { id } = await d.tht.sessionNew({ question: b.question, provider: b.provider, model: b.model, thinking: b.thinking, name: b.name });
    const rt = await d.mgr.spawnFor(id, { provider: b.provider, model: b.model, thinking: b.thinking, author: getUser(req).id });
    rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
    return { id };
  });
  app.get("/sessions", async () => d.tht.sessionList());
  app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id));
  app.post("/sessions/:id/response", async (req, reply) => {
    const id = (req.params as any).id; const rt = d.mgr.get(id);
    if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
    rt.bridge.respond((req.body as any).ui_response); return reply.code(204).send();
  });
  app.post("/sessions/:id/steer", async (req, reply) => {
    const rt = d.mgr.get((req.params as any).id);
    if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
    rt.bridge.steer((req.body as any).text); return reply.code(204).send();
  });
  app.post("/sessions/:id/close", async (req) => { d.mgr.teardown((req.params as any).id); return { closed: true }; });
  app.get("/sessions/:id/events", (req, reply) => {
    const id = (req.params as any).id; const rt = d.mgr.get(id);
    reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
    const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
    const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
    req.raw.on("close", off);
  });
}
  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- routes-sessions Expected: PASS (2 test).

git add backend/src/app.ts backend/src/routes/sessions.ts backend/test/routes-sessions.test.ts
git commit -m "feat(backend): session routes + SSE + response/steer wiring"

Task 10: Route SQL (preview/export) + models + workspaces

Files:

  • Create: backend/src/routes/sql.ts, backend/src/routes/meta.ts
  • Modify: backend/src/app.ts (registra)
  • Test: backend/test/routes-sql-meta.test.ts

Interfaces:

  • Produces:

    • POST /sessions/:id/sql/preview {limit?, offset?} → tht.sqlPreview → {columns, rows, execution_ms, truncated}
    • POST /sessions/:id/sql/export → tht.sqlExport → {path}
    • GET /models → rpc.request({type:"get_available_models"}) da un Pi effimero, OR set statico dai default se nessun Pi attivo (MVP: legge da un processo Pi effimero via mgr); ritorna {models:[...]}
    • GET /workspaces → lista da harnessDir/workspaces/*.yaml (solo nomi + path, no secret)
  • Step 1: Scrivere il test

import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";

test("POST sql/preview ritorna le righe da ThtRunner", async () => {
  const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
    thtRunner: { sqlPreview: async () => ({ columns: ["a"], rows: [[1]], execution_ms: 2, truncated: false }) } as any,
  });
  const res = await app.inject({ method: "POST", url: "/sessions/s1/sql/preview", payload: { limit: 10, offset: 0 } });
  expect(res.json()).toEqual({ columns: ["a"], rows: [[1]], execution_ms: 2, truncated: false });
});

test("GET /workspaces elenca gli yaml senza secret", async () => {
  const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any });
  const res = await app.inject({ method: "GET", url: "/workspaces" });
  expect(res.statusCode).toBe(200);
  expect(Array.isArray(res.json())).toBe(true);
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- routes-sql-meta Expected: FAIL — route assenti.

  • Step 3: Implementare le route
// backend/src/routes/sql.ts
import type { FastifyInstance } from "fastify";
import type { ThtRunner } from "../tht/tht-runner.js";
export function sqlRoutes(app: FastifyInstance, d: { tht: ThtRunner }) {
  app.post("/sessions/:id/sql/preview", async (req) => {
    const b = (req.body ?? {}) as any;
    return d.tht.sqlPreview((req.params as any).id, { limit: b.limit, offset: b.offset });
  });
  app.post("/sessions/:id/sql/export", async (req) => d.tht.sqlExport((req.params as any).id));
}
// backend/src/routes/meta.ts
import type { FastifyInstance } from "fastify";
import { readdirSync } from "node:fs";
import { join } from "node:path";
export function metaRoutes(app: FastifyInstance, d: { harnessDir: string; listModels: () => Promise<any> }) {
  app.get("/workspaces", async () => {
    const dir = join(d.harnessDir, "workspaces");
    return readdirSync(dir).filter((f) => f.endsWith(".yaml"))
      .map((f) => ({ name: f.replace(/\.ya?ml$/, ""), file: f }));
  });
  app.get("/models", async () => d.listModels());
}

In app.ts, listModels fa spawn di un Pi effimero, rpc.request({type:"get_available_models"}), poi teardown; in caso di errore ritorna { models: [] }.

  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test -- routes-sql-meta Expected: PASS (2 test).

git add backend/src/routes/sql.ts backend/src/routes/meta.ts backend/src/app.ts backend/test/routes-sql-meta.test.ts
git commit -m "feat(backend): sql preview/export + models + workspaces routes"

Task 11: Resume + PATH venv + smoke end-to-end F1 (fake-pi-rpc)

Files:

  • Modify: backend/src/pi/pi-process-manager.ts (env PATH venv; resume(sessionId) legge il manifest via ThtRunner)
  • Modify: backend/src/routes/sessions.ts (POST /sessions/:id/resume)
  • Test: backend/test/e2e-f1.test.ts

Interfaces:

  • Produces: mgr.resume(sessionId, tht) — rilegge provider/model/thinking da tht.sessionShow e fa spawnFor con quei valori; PATH dello spawn reale include harnessDir/.venv/bin.

  • Step 1: Scrivere lo smoke end-to-end

import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";

const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");

test("loop F1: crea sessione → SSE riceve il widget → risponde → 204", async () => {
  const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
    thtRunner: { sessionNew: async () => ({ id: "s1" }) } as any,
    spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
  });
  await app.listen({ port: 0, host: "127.0.0.1" });
  const base = `http://127.0.0.1:${(app.server.address() as any).port}`;
  await fetch(`${base}/sessions`, { method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ workspace: "w", question: "q" }) });
  // SSE: leggi il primo evento ui_request
  const es = await fetch(`${base}/sessions/s1/events`);
  const reader = es.body!.getReader(); const chunk = await reader.read();
  const text = new TextDecoder().decode(chunk.value);
  expect(text).toContain("ui_request");
  await reader.cancel();
  const resp = await fetch(`${base}/sessions/s1/response`, { method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ ui_response: { id: "u1", choices: ["a"] } }) });
  expect(resp.status).toBe(204);
  await app.close();
});
  • Step 2: Eseguire (deve fallire)

Run: cd backend && npm test -- e2e-f1 Expected: FAIL inizialmente (race sull'ordine widget/SSE o resume assente). Diagnosticare con systematic-debugging se necessario.

  • Step 3: Implementare resume + PATH venv + fix ordine eventi

  • In pi-process-manager.ts (spawn reale): PATH: \${join(cfg.harnessDir, ".venv/bin")}:${process.env.PATH}``.

  • Aggiungere async resume(id, tht) che legge sessionShow(id) → {provider, model, thinking} e chiama spawnFor.

  • Garantire che la route POST /sessions colleghi bridge.onClientEvent → hub.publish PRIMA di mandare il prompt, così l'evento widget non si perde; il pendingWidget() + re-emit (Task 7) copre comunque il caso SSE sottoscritto dopo.

  • Aggiungere POST /sessions/:id/resume → mgr.resume(id, tht) + ricollegamento bridge→hub.

  • Step 4: Eseguire (deve passare) + commit

Run: cd backend && npm test Expected: PASS (tutta la suite).

git add backend/src/pi/pi-process-manager.ts backend/src/routes/sessions.ts backend/test/e2e-f1.test.ts
git commit -m "feat(backend): resume + venv PATH + end-to-end F1 smoke (fake-pi-rpc)"
  • Step 5: Validazione L2 con Pi reale (informativo, non-CI)

Con harness configurato (.env + VPN) e config/tht.yaml valido: avviare npm run dev, fare POST /sessions con una domanda reale, aprire l'SSE e confermare che il widget F1 arriva dal Pi reale e che la risposta avanza il workflow. Annotare l'esito (questo è il primo loop end-to-end reale BE↔harness).


Self-Review

Spec coverage (vs 2026-06-27-backend-design.md):

  • BE-1 (un Pi per sessione, resume): Task 6 + Task 11 ✓
  • BE-2 (delega SQL): Task 5 (ThtRunner) + Task 10 (route) ✓
  • BE-3 (re-emit widget pendente): Task 7 + Task 4 (pendingWidget) ✓
  • BE-4 (test fake-Pi + unit TS): tutti i task usano vitest; fake-pi-rpc in Task 3/6/9/11 ✓
  • BE-5 (id pre-creato): Task 9 (tht session new poi spawnFor con THT_SESSION) ✓ (dipende dal Piano Harness Task 5)
  • BE-6 (model/thinking/provider per-sessione, persistiti, resume): Task 6 (set_model/set_thinking) + Task 11 (resume legge manifest) ✓
  • BE-7 (settings Pi MVP): --name/provider/model/thinking via sessionNew (Task 5) + GET /models (Task 10); --approve nello spawn (Task 6); quietStartup/trust/systemPrompt sono harness-side (Piano Harness Task 9) ✓
  • API §4 (tutti gli endpoint): Task 9 (sessions/events/response/steer/close) + Task 10 (sql/models/workspaces) + Task 1 (health) ✓
  • Auth D6: Task 8 ✓
  • Componenti §5 (PiProcessManager, RpcClient, SessionBridge, SseHub, ThtRunner, auth, config): Task 1–9 ✓
  • Rischio PATH tht (§9): Task 6 (nota) + Task 11 (fix) ✓

Placeholder scan: nessun TBD. Le note implementative (PATH venv, listModels effimero) sono concretizzate nel task che le richiede (11, 10).

Type consistency: ThtRunner.sqlPreview ritorna {columns, rows, execution_ms, truncated} usato identico in Task 10. SessionRuntime = {rpc, bridge, child} coerente tra Task 6 e Task 9. SessionBridge.respond(uiResponse)/steer(text)/pendingWidget() coerenti tra Task 4, 7, 9. Comandi RPC (set_model {provider, modelId}, set_thinking_level {level}, steer {message}, prompt {message}, get_available_models) coerenti con rpc-types.d.ts di Pi. Envelope {type:"extension_ui_request", ui_request} coerente con il gate (Piano Harness Task 4) e il fake-pi-rpc (Piano Harness Task 10).

Nota di sequenza: Task 1–8 sono indipendenti dal Pi reale (CI puro con fake-pi-rpc). Le validazioni L2 (Task 11 Step 5) richiedono harness configurato + VPN.