/** * Handshake / session layer: `pi-bridge/1 + handshake/1 + plan/2`. * * Three versions, three jobs. Collapsing them into one number was why every * semantic change used to look like a wire change: * - `WIRE_ID` framing only; bumped when the message shapes stop being * parseable by the other side at all. * - `HANDSHAKE_VERSION` (HV) negotiation semantics. * - `PLAN_CONTRACT_VERSION` (CV) the PLAN body. * * Why this layer exists at all: the previous design broadcast the full preamble, * the full contract and the full capabilities on every single turn, and had no * way to say "I am pi, I am on round 3, the last plan you confirmed was X". * That made a page reload unrecoverable (the only option was to start over) and * made every round expensive. * * Everything here is pure: no browser, no pi, no I/O. The whole session state * machine is testable offline, which is the point -- protocol bugs are the kind * that silently corrupt an execution loop. */ import { createHash, randomBytes } from "node:crypto"; export const WIRE_ID = "pi-bridge/1"; export const HANDSHAKE_VERSION = 1; export const PLAN_CONTRACT_VERSION = 2; /** Features pi requires the other side to support before work may start. */ export const REQUIRED_FEATURES = ["frame", "resume", "repair", "need_info"] as const; export type Feature = (typeof REQUIRED_FEATURES)[number] | string; /** * Capability tags are deliberately coarse. They exist so ChatGPT stops * proposing steps pi cannot run, not to sandbox anything: real enforcement is * the execution environment plus the fatal/blocked paths. */ export const DEFAULT_CAPABILITIES = [ "file.read", "file.write", "shell.local", "test.repo", "git.local", ] as const; /** * `shell.local` does NOT imply network access. Spelling out the denials matters * more than the capabilities: "you have a shell" is what makes a model propose * curl. */ export const DEFAULT_DENIALS = ["net", "gui", "account", "remote.config", "remote.api"] as const; /** Machine error codes for REPAIR. Short and stable on purpose. */ export const ERROR_CODES = { BLOCKS: "E_BLOCKS", HEADER: "E_HEADER", FRAME: "E_FRAME", REQUIRED: "E_REQUIRED", DECISION: "E_DECISION", PARSE: "E_PARSE", VERSION: "E_VERSION", CONTRACT: "E_CONTRACT", /** * pi could not prove it obtained the complete reply. * * This exists because "do not execute" and "the other side is at fault" are * two different judgements. A copy-button degradation is still fatal to * execution, but blaming ChatGPT for it would send a REPAIR for a message * that was never malformed -- and the most dangerous choice here is to * downgrade to a warning and carry on, because executing a plan you are not * sure you fully read corrupts FRAME, planKey and checkpoint credibility. */ CAPTURE: "E_CAPTURE", SOURCE_UNCERTAIN: "E_SOURCE_UNCERTAIN", } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; /** Two repairs, then the loop stops with `protocol_violation`. */ export const MAX_REPAIRS = 2; /* ------------------------------------------------------------------ */ /* Canonicalization and hashing */ /* ------------------------------------------------------------------ */ /** * Canonical form for anything that gets hashed. * * Both sides must agree on "the same text". Without a canonical form you get the * worst failure mode available to a protocol: same version number, different * content, so every hash comparison fails and nothing can be diagnosed. */ export function canonicalText(text: string): string { return text .replace(/\r\n?/g, "\n") .split("\n") .map((line) => line.replace(/[ \t]+$/g, "")) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } export function sha256(text: string): string { return createHash("sha256").update(canonicalText(text), "utf8").digest("hex"); } /** Hashes travel as 8 hex chars; the full digest stays local for diagnosis. */ export function shortHash(text: string): string { return sha256(text).slice(0, 8); } /** Contract tag, e.g. `p2.a1b2c3d4`. */ export function computeContractHash(contract: string, version = PLAN_CONTRACT_VERSION): string { return `p${version}.${shortHash(contract)}`; } /** * Capability tag, e.g. `c1.12ab34cd`. * * Canonical form is the sorted capability list then the sorted denial list, * joined by newlines. Sorting is what makes it independent of declaration order. */ export function computeCapabilityHash( capabilities: readonly string[] = DEFAULT_CAPABILITIES, denials: readonly string[] = DEFAULT_DENIALS, ): string { const canonical = [ [...capabilities].sort().join("\n"), "--", [...denials].sort().join("\n"), ].join("\n"); return `c1.${shortHash(canonical)}`; } /** * Hash exactly the text given, with no further normalization. * * `shortHash` runs its input through canonicalText, which also folds blank-line * runs and trailing spaces per line. That is right for a contract, where the * point is that both sides hash the same bytes regardless of how the text was * pasted. It is wrong for a qid: qid identifies the exact request that will be * sent, so folding the body on top of the caller's canonicalisation would make * qid point at text the other side never sees. */ export function computeQid(request: string): string { return createHash("sha256").update(request, "utf8").digest("hex").slice(0, 8); } /** 16 hex chars. Uses node:crypto, so no dependency is added. */ export function newSessionId(): string { return randomBytes(8).toString("hex"); } /* ------------------------------------------------------------------ */ /* Session and frame */ /* ------------------------------------------------------------------ */ export interface Checkpoint { round: number; planKey: string; } export interface Session { sid: string; hv: number; cv: number; /** e.g. `p2.a1b2c3d4` */ cvHash: string; /** e.g. `c1.12ab34cd` */ capHash: string; qid: string; /** Round currently being worked on; 1 before anything has run. */ round: number; /** planKey of the last plan that was validated AND executed AND settled. */ prev: string; /** The same fact as `prev`, in a form that survives a restart. */ checkpoint: Checkpoint | null; /** * The `done` plan, kept separately from `checkpoint`. * * `done` is not "please run one more round"; it is the other side's judgement * that the work is finished, and it has no execution phase. Folding it into * `checkpoint` would record a plan as "executed and settled" when nothing was * executed, and recovery would then have to guess. */ terminal: Checkpoint | null; status: "open" | "closed"; features: Feature[]; established: boolean; } export const PREV_NONE = "-"; export function newSession(options: { request: string; contract: string; capabilities?: readonly string[]; denials?: readonly string[]; sid?: string; }): Session { return { sid: options.sid ?? newSessionId(), hv: HANDSHAKE_VERSION, cv: PLAN_CONTRACT_VERSION, cvHash: computeContractHash(options.contract, PLAN_CONTRACT_VERSION), capHash: computeCapabilityHash(options.capabilities, options.denials), qid: computeQid(options.request), round: 1, prev: PREV_NONE, checkpoint: null, terminal: null, status: "open", features: [...REQUIRED_FEATURES], established: false, }; } export interface Frame { sid: string; round: number; /** planKey of the last settled plan, or PREV_NONE. */ prev: string; qid: string; } export function buildFrame(frame: Frame): string { return `FRAME: s=${frame.sid} r=${frame.round} p=${frame.prev} q=${frame.qid}`; } const FRAME_RE = /^\s*FRAME:\s*s=(\S+)\s+r=(\d+)\s+p=(\S+)\s+q=(\S+)\s*$/im; export function parseFrame(text: string): Frame | null { const match = FRAME_RE.exec(text); if (!match) return null; return { sid: match[1], round: Number(match[2]), prev: match[3], qid: match[4] }; } /* ------------------------------------------------------------------ */ /* Message kinds */ /* ------------------------------------------------------------------ */ export type MessageKind = "hello" | "round" | "resume" | "repair" | "resync" | "ack" | "plan" | "unknown"; const HEADER_RE = /^\s*#\s*([A-Z]+)\s+(\S+)\s*$/im; /** Identify what a block of text is, by its header. Used before parsing. */ export function detectMessageKind(text: string): MessageKind { const match = HEADER_RE.exec(text); if (!match) return "unknown"; if (match[2] !== WIRE_ID) return "unknown"; switch (match[1].toUpperCase()) { case "HELLO": return "hello"; case "HS": return "ack"; case "ROUND": return "round"; case "RESUME": return "resume"; case "REPAIR": return "repair"; case "RESYNC": return "resync"; case "PLAN": return "plan"; default: return "unknown"; } } /** `KEY: value` line lookup, case-insensitive, first match wins. */ export function field(text: string, key: string): string | null { const pattern = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, "im"); const match = pattern.exec(text); return match ? match[1] : null; } /* ------------------------------------------------------------------ */ /* Outgoing builders */ /* ------------------------------------------------------------------ */ export interface HelloOptions { session: Session; contract: string; capabilities?: readonly string[]; denials?: readonly string[]; /** What the whole session is trying to achieve. Static, sent once. */ objective?: string; /** * The user request this qid stands for. * * NOT sent in HELLO by default. Observed live: with the request inside the * HELLO, ChatGPT answered it and emitted a PLAN instead of an ACK, and the * establish gate correctly refused to start. A handshake turn must not * contain anything that looks like a question -- the request belongs in * ROUND 1, which is also where the receipt and the repo context go. */ rootRequest?: string; } /** * One full static declaration, sent once per conversation. * * The contract and the capability lists appear here and nowhere else in steady * state; that is where the token saving comes from. */ export function buildHello(options: HelloOptions): string { const { session } = options; const capabilities = options.capabilities ?? DEFAULT_CAPABILITIES; const denials = options.denials ?? DEFAULT_DENIALS; return [ `# HELLO ${WIRE_ID}`, `S: ${session.sid}`, `HV: ${session.hv}`, `CV: ${session.cv} ${session.cvHash}`, `CAP: ${session.capHash}`, `QID: ${session.qid}`, `REQ: ${session.features.join(",")}`, "", "ACTION: handshake-only", "NOTE:", "本轮是握手轮,不是任务轮。请只输出下面 ACK-TEMPLATE 那个代码块并填好字段,", "不要产出方案,不要回答 OBJECTIVE 里的目标。真正的任务在紧接着的下一条消息里。", "", "CAPS:", capabilities.join(","), "", "DENY:", denials.join(","), "", "ACK-TEMPLATE:", `# HS ${WIRE_ID}`, "TYPE: ack", `S: ${session.sid}`, `HV: ${session.hv}`, `CV: ${session.cv} ${session.cvHash}`, `CAP: ${session.capHash}`, `FEAT: ${session.features.join(",")}`, "", "OBJECTIVE:", options.objective ?? "(未指定)", "", "CONTRACT:", options.contract, ...(options.rootRequest ? ["", "ROOT-REQUEST (本轮不要回答):", options.rootRequest] : []), ].join("\n"); } /** Steady state: one frame line plus only what actually changed. */ export function buildRound(frame: Frame, dynamic: string): string { return [`# ROUND ${WIRE_ID}`, buildFrame(frame), "", canonicalText(dynamic)].join("\n"); } /** Recover a session without resending the contract or the capabilities. */ export function buildResume(session: Session): string { const checkpoint = session.checkpoint ? `${session.checkpoint.round}:${session.checkpoint.planKey}` : `${session.round - 1}:${PREV_NONE}`; return [ `# RESUME ${WIRE_ID}`, `S: ${session.sid}`, `HV: ${session.hv}`, `CV: ${session.cv} ${session.cvHash}`, `CAP: ${session.capHash}`, `CK: ${checkpoint}`, `NEXT: ${session.round}`, ].join("\n"); } /** A fatal protocol error: same round, no execution, no new round. */ export function buildRepair(frame: Frame, codes: readonly ErrorCode[]): string { return [ `# REPAIR ${WIRE_ID}`, buildFrame(frame), `ERR: ${codes.join(",")}`, "ACTION: resend-same-round", ].join("\n"); } /** Last resort: re-declare static state inside the same conversation. */ export function buildResync(options: HelloOptions): string { return [`# RESYNC ${WIRE_ID}`, "", buildHello(options)].join("\n"); } /* ------------------------------------------------------------------ */ /* ACK parsing and the establish gate */ /* ------------------------------------------------------------------ */ export interface Ack { sid: string | null; hv: number | null; cv: number | null; cvHash: string | null; capHash: string | null; features: string[]; type: string | null; } export function parseAck(text: string): Ack | null { if (detectMessageKind(text) !== "ack") return null; const cvField = field(text, "CV"); const [cv, cvHash] = cvField ? cvField.split(/\s+/) : [null, null]; return { sid: field(text, "S"), hv: Number(field(text, "HV") ?? NaN) || null, cv: Number(cv ?? NaN) || null, cvHash: cvHash ?? null, capHash: field(text, "CAP"), features: (field(text, "FEAT") ?? "") .split(",") .map((value) => value.trim()) .filter(Boolean), type: field(text, "TYPE"), }; } export interface EstablishResult { ok: boolean; errors: string[]; errorsCodes: ErrorCode[]; } /** * The establish gate. * * Every condition must hold before pi is allowed to run any task. A half- * established session is worse than none: pi would execute plans while * believing a contract is in force that the other side never agreed to. */ export function validateAck(offer: Session, ack: Ack | null): EstablishResult { const errors: string[] = []; const codes: ErrorCode[] = []; if (!ack) { return { ok: false, errors: ["没有找到 ACK 块"], errorsCodes: [ERROR_CODES.HEADER] }; } if (ack.type !== "ack") { errors.push(`TYPE 不是 ack(收到 ${ack.type ?? "空"})`); codes.push(ERROR_CODES.HEADER); } if (ack.sid !== offer.sid) { errors.push(`sid 不一致(期望 ${offer.sid},收到 ${ack.sid ?? "空"})`); codes.push(ERROR_CODES.FRAME); } if (ack.hv !== offer.hv) { errors.push(`HV 不被接受(期望 ${offer.hv},收到 ${ack.hv ?? "空"})`); codes.push(ERROR_CODES.VERSION); } if (ack.cv !== offer.cv || ack.cvHash !== offer.cvHash) { errors.push(`CV 或契约 hash 不一致(期望 ${offer.cv} ${offer.cvHash},收到 ${ack.cv ?? "空"} ${ack.cvHash ?? "空"})`); codes.push(ERROR_CODES.CONTRACT); } if (ack.capHash !== offer.capHash) { errors.push(`能力 hash 不一致(期望 ${offer.capHash},收到 ${ack.capHash ?? "空"})`); codes.push(ERROR_CODES.CONTRACT); } const missing = offer.features.filter((feature) => !ack.features.includes(feature)); if (missing.length > 0) { errors.push(`缺少必需能力确认:${missing.join(", ")}`); codes.push(ERROR_CODES.REQUIRED); } return { ok: errors.length === 0, errors, errorsCodes: [...new Set(codes)] }; } /* ------------------------------------------------------------------ */ /* Fatal vs advisory */ /* ------------------------------------------------------------------ */ /** * A fatal error means pi must not execute anything. `E_VERSION` and * `E_CONTRACT` are separated out because repairing the *format* of a message * cannot fix a version mismatch -- those need RESYNC. */ export type FaultDomain = "remote" | "local" | "version"; /** * Whose fault is it? * * Only a `remote` fault justifies asking the other side to resend. A `local` * fault is pi's own capture/transport problem and must be recovered locally. */ export function faultDomain(code: ErrorCode): FaultDomain { if (code === ERROR_CODES.VERSION || code === ERROR_CODES.CONTRACT) return "version"; if (code === ERROR_CODES.CAPTURE || code === ERROR_CODES.SOURCE_UNCERTAIN) return "local"; return "remote"; } export function isFatalRepairable(code: ErrorCode): boolean { return faultDomain(code) === "remote"; } /** Does this fault require RESYNC instead of REPAIR? */ export function needsResync(code: ErrorCode): boolean { return faultDomain(code) === "version"; } /** Repair budget exhausted? */ export function repairsExhausted(attempts: number): boolean { return attempts > MAX_REPAIRS; } /* ------------------------------------------------------------------ */ /* Recovery state machine */ /* ------------------------------------------------------------------ */ export type RecoveryCase = /** Renderer reloaded; pi's runtime is intact. */ | "browser-reload" /** pi restarted; local state file survived. */ | "pi-restart-with-cache" /** pi restarted and the cache is gone, but the conversation is readable. */ | "pi-restart-no-cache" /** The model has clearly lost the protocol context. */ | "context-lost"; export interface RecoveryPlan { action: "continue-current-round" | "resume" | "resync" | "new-session"; /** Round to resume at; never earlier than the last confirmed checkpoint + 1. */ nextRound: number; /** Plans after this point must not be replayed automatically. */ replayFromRound: number; reason: string; } /** * Decide what to do after an interruption. * * The load-bearing rule: an unconfirmed plan is never replayed. Recovery lands * on the last checkpoint that the *next* ROUND's `prev` confirmed, because a * PLAN reply by itself only proves the plan was written, not that it ran. */ export function planRecovery(options: { recoveryCase: RecoveryCase; checkpoint: Checkpoint | null; currentRound: number; /** Was a plan for `currentRound` already sent to pi? */ currentRoundDispatched: boolean; frameFromHistory: Frame | null; hasLocalSession: boolean; }): RecoveryPlan { const { recoveryCase, checkpoint, currentRound, currentRoundDispatched, frameFromHistory, hasLocalSession } = options; const confirmedRound = checkpoint?.round ?? 0; if (recoveryCase === "browser-reload") { return { action: "continue-current-round", nextRound: currentRound, replayFromRound: currentRound + 1, reason: currentRoundDispatched ? "浏览器重载,pi 运行态仍在;继续等待同一轮的回复,不新开轮次" : "浏览器重载,pi 运行态仍在;重发同一轮请求", }; } if (recoveryCase === "pi-restart-with-cache" && hasLocalSession) { return { action: "resume", nextRound: Math.max(confirmedRound + 1, currentRound), replayFromRound: confirmedRound + 1, reason: "本地会话缓存可用;从最后确认的 checkpoint 之后继续", }; } if (recoveryCase === "pi-restart-no-cache") { if (!frameFromHistory) { return { action: "resync", nextRound: 1, replayFromRound: 1, reason: "本地状态与历史 FRAME 都不可用,无法确定进度;需要 RESYNC 并重新规划", }; } // The readable history is authoritative, but only up to its checkpoint: // a plan visible after it may already have run. return { action: "resume", nextRound: frameFromHistory.round, replayFromRound: frameFromHistory.round, reason: `从历史 FRAME 重建:sid=${frameFromHistory.sid} 轮次=${frameFromHistory.round};checkpoint 之后的 PLAN 一律不自动重放`, }; } return { action: "resync", nextRound: Math.max(confirmedRound + 1, 1), replayFromRound: confirmedRound + 1, reason: "协议上下文已丢失;在同一会话内 RESYNC,保留 checkpoint 不回退", }; } /** * Advance the session after a plan has been validated, executed and settled. * * `prev` is set here, in the *next* round's frame, which is what makes the * conversation history itself a recovery log. */ export function advanceSession(session: Session, settledPlanKey: string, decision?: Decision): Session { // A terminal plan is recorded as terminal, not as a settled checkpoint: no // round is advanced and no next ROUND will ever confirm it. if (decision === "done") { return { ...session, terminal: { round: session.round, planKey: settledPlanKey }, status: "closed", }; } return { ...session, prev: settledPlanKey, checkpoint: { round: session.round, planKey: settledPlanKey }, round: session.round + 1, }; } export type Decision = "continue" | "done" | "blocked" | "need_info" | "unknown"; export interface BlockedAssessment { /** Machine-verifiable contradiction between the claimed blocker and reality. */ conflict: boolean; /** Capabilities the plan claims to be missing but that pi actually has. */ contradictions: string[]; /** * `close` agreement is possible; the session stops. * `re-ask` pi found a fact conflict and must feed it back for * re-adjudication. pi never rewrites `blocked` into * `continue` on its own -- that would cross the line into * plan semantics, which belong to the other side. */ action: "close" | "re-ask"; reason: string; } /** * Two-layer adjudication for `blocked`. * * The other side owns the *semantic* claim ("this is blocked by X"). pi owns * only whether X contradicts machine-verifiable runtime facts. If it does, pi * sends the conflict back for a new decision rather than overriding it. */ export function assessBlocked(options: { /** Capability tags the plan says it is missing. */ claimedBlockers: string[]; /** Capabilities pi actually has right now. */ capabilities: readonly string[]; /** False when a guard or boundary already forbids continuing. */ guardAllows: boolean; }): BlockedAssessment { if (!options.guardAllows) { return { conflict: false, contradictions: [], action: "close", reason: "guard 或 boundary 已经禁止继续,blocked 与运行时事实一致", }; } const contradictions = options.claimedBlockers.filter((need) => options.capabilities.includes(need)); if (contradictions.length > 0) { return { conflict: true, contradictions, action: "re-ask", reason: `方案声称缺少 ${contradictions.join(", ")},但 pi 实际具备;反馈给 ChatGPT 重新裁决,pi 不自行改写 DECISION`, }; } return { conflict: false, contradictions: [], action: "close", reason: "blocked 与机器可验证的运行时事实一致", }; } /* ------------------------------------------------------------------ */ /* Frame discovery from readable history */ /* ------------------------------------------------------------------ */ export interface DiscoveredTurn { text: string; /** conversation-turn-N, when the caller knows it. */ turnId?: string; } export interface DiscoveredFrame { turnId?: string; /** HELLO / ROUND / RESUME / REPAIR / PLAN / ACK, if this turn carried one. */ kind: MessageKind | null; frame: Frame | null; sid: string | null; } export interface SessionDiscovery { frames: DiscoveredFrame[]; /** Best reconstruction from what is still readable. */ session: Frame | null; /** Was a HELLO ever seen in the scanned window? */ helloSent: boolean; /** Was an ACK ever seen in the scanned window? */ ackSeen: boolean; } /** * Rebuild what we can from the conversation text. * * Deliberately pure and separate from the DOM: the extraction is a thin page * call, and everything with logic in it is testable offline. Discovery cannot * be relied on blindly either -- ChatGPT virtualises long threads, so an early * HELLO may simply not be in the DOM any more. That is why a missing HELLO is * reported as a fact rather than treated as an error. */ export function parseProtocolFrames(turns: readonly DiscoveredTurn[]): SessionDiscovery { const frames: DiscoveredFrame[] = []; let helloSent = false; let ackSeen = false; let best: Frame | null = null; for (const turn of turns) { // detectMessageKind returns the string "unknown", which is truthy. Without // normalising it first, `!kind` never fired and every unrelated turn was // recorded as a protocol frame. const detected = detectMessageKind(turn.text); const kind: MessageKind | null = detected === "unknown" ? null : detected; const frame = parseFrame(turn.text); const sidMatch = /^\s*S:\s*([0-9a-f]{16})\s*$/im.exec(turn.text); const sid = sidMatch ? sidMatch[1] : null; if (kind === "hello") helloSent = true; if (kind === "ack") ackSeen = true; if (kind === null && !frame && !sid) continue; frames.push({ turnId: turn.turnId, kind, frame, sid }); // Highest round wins: the newest ROUND is the authoritative checkpoint. if (frame && (!best || frame.round >= best.round)) best = frame; } return { frames, session: best, helloSent, ackSeen }; } /** One-line session summary for logs and status output. */ export function describeSession(session: Session): string { const checkpoint = session.checkpoint ? `${session.checkpoint.round}:${session.checkpoint.planKey.slice(0, 12)}` : "无"; return ( `sid=${session.sid} hv=${session.hv} cv=${session.cv}(${session.cvHash}) cap=${session.capHash} ` + `r=${session.round} prev=${session.prev} ck=${checkpoint} ${session.status}` + `${session.terminal ? ` terminal=${session.terminal.round}` : ""} ` + `${session.established ? "已建立" : "未建立"}` ); }