import path from "node:path"; import { validatePiRpcCommand } from "../bridge/pi-rpc-command-spec.js"; export const PROTOCOL_VERSION = "v1"; export const MAX_FRAME_BYTES = 64 * 1024; const requestOperations = new Map([ ["list_agents", { agent: false, payload: "none" }], ["list_directories", { agent: false, payload: "none" }], ["select_agent", { agent: false, payload: "worktree" }], ["forget_directory", { agent: false, payload: "worktree" }], ["get_workspace", { agent: false, payload: "none" }], ["get_workspace_summary", { agent: false, payload: "none" }], ["get_model_catalog", { agent: false, payload: "none" }], ["create_session_runtime", { agent: false, payload: "worktree" }], ["create_quick_runtime", { agent: false, payload: "worktree" }], ["open_session_runtime", { agent: false, payload: "worktreeSession" }], ["close_session_runtime", { agent: false, payload: "runtime" }], ["close_quick_runtime", { agent: false, payload: "runtime" }], ["list_directory_sessions", { agent: false, payload: "worktree" }], ["get_session_runtime_snapshot", { agent: false, payload: "runtime" }], ["subscribe_workspace", { agent: false, payload: "cursor" }], ["get_state", { agent: true, payload: "none" }], ["get_session_stats", { agent: true, payload: "none" }], ["list_sessions", { agent: true, payload: "none" }], ["new_session", { agent: true, payload: "none" }], ["switch_session", { agent: true, payload: "session" }], ["get_transcript", { agent: true, payload: "none" }], ["subscribe", { agent: true, payload: "cursor" }], ["prompt", { agent: true, payload: "message" }], ["submit_prompt", { agent: true, payload: "message" }], ["steer", { agent: true, payload: "message" }], ["follow_up", { agent: true, payload: "message" }], ["abort", { agent: true, payload: "none" }], ["extension_response", { agent: true, payload: "extensionResponse" }], ["retry", { agent: true, payload: "none" }], ["restart", { agent: true, payload: "none" }], ["renew_session", { agent: true, payload: "none" }], ["get_available_models", { agent: true, payload: "none" }], ["get_commands", { agent: true, payload: "none" }], ["set_session_name", { agent: true, payload: "sessionName" }], ["compact", { agent: true, payload: "compact" }], ["set_model", { agent: true, payload: "model" }], ["set_thinking_level", { agent: true, payload: "thinking" }], ["pi_rpc_command", { agent: true, payload: "piRpcCommand" }], ]); const eventTypes = new Set([ "agent_state", "transcript", "stream", "tool", "queue", "recovery", "extension_ui_request", ]); const thinkingLevels = new Set([ "off", "minimal", "low", "medium", "high", "xhigh", "max", ]); const idPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; export class ProtocolError extends Error { constructor(code, message) { super(message); this.name = "ProtocolError"; this.code = code; } } function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function assertRecord(value, field) { if (!isRecord(value)) throw new ProtocolError("invalid_message", `${field} must be an object`); return value; } function assertAllowedKeys(value, allowed, field) { for (const key of Object.keys(value)) { if (!allowed.has(key)) throw new ProtocolError( "invalid_message", `${field}.${key} is not supported`, ); } } function assertString(value, field, { maxLength = 4096, pattern } = {}) { if ( typeof value !== "string" || value.length === 0 || value.length > maxLength ) { throw new ProtocolError( "invalid_message", `${field} must be a non-empty string up to ${maxLength} characters`, ); } if (pattern && !pattern.test(value)) throw new ProtocolError("invalid_message", `${field} has an invalid format`); return value; } function assertOptionalCursor(value) { if (value === undefined) return undefined; if (Number.isSafeInteger(value) && value >= 0) return value; throw new ProtocolError( "invalid_message", "payload.cursor must be a non-negative integer", ); } function validatePayload(kind, value) { if (kind === "none") { if (value !== undefined) throw new ProtocolError( "invalid_message", "payload is not allowed for this operation", ); return undefined; } if (kind === "compact" && value === undefined) return undefined; const payload = assertRecord(value, "payload"); switch (kind) { case "worktree": { assertAllowedKeys(payload, new Set(["worktreePath"]), "payload"); const worktreePath = assertString( payload.worktreePath, "payload.worktreePath", { maxLength: 4096 }, ); if (!path.isAbsolute(worktreePath)) throw new ProtocolError( "invalid_message", "payload.worktreePath must be absolute", ); return { worktreePath }; } case "session": { assertAllowedKeys(payload, new Set(["sessionPath"]), "payload"); const sessionPath = assertString( payload.sessionPath, "payload.sessionPath", { maxLength: 4096 }, ); if (!path.isAbsolute(sessionPath)) throw new ProtocolError( "invalid_message", "payload.sessionPath must be absolute", ); return { sessionPath }; } case "worktreeSession": { assertAllowedKeys( payload, new Set(["worktreePath", "sessionPath"]), "payload", ); const worktreePath = assertString( payload.worktreePath, "payload.worktreePath", { maxLength: 4096 }, ); const sessionPath = assertString( payload.sessionPath, "payload.sessionPath", { maxLength: 4096 }, ); if (!path.isAbsolute(worktreePath) || !path.isAbsolute(sessionPath)) throw new ProtocolError( "invalid_message", "payload worktree and session paths must be absolute", ); return { worktreePath, sessionPath }; } case "runtime": { assertAllowedKeys(payload, new Set(["runtimeId"]), "payload"); return { runtimeId: assertString(payload.runtimeId, "payload.runtimeId", { maxLength: 128, pattern: idPattern, }), }; } case "cursor": { assertAllowedKeys(payload, new Set(["cursor"]), "payload"); return { ...(payload.cursor === undefined ? {} : { cursor: assertOptionalCursor(payload.cursor) }), }; } case "message": { assertAllowedKeys(payload, new Set(["message"]), "payload"); return { message: assertString(payload.message, "payload.message", { maxLength: 32 * 1024, }), }; } case "extensionResponse": { assertAllowedKeys(payload, new Set(["requestId", "response"]), "payload"); const response = assertRecord(payload.response, "payload.response"); if ( !Object.hasOwn(response, "value") && !Object.hasOwn(response, "confirmed") && !Object.hasOwn(response, "cancelled") ) { throw new ProtocolError( "invalid_message", "payload.response must contain value, confirmed, or cancelled", ); } return { requestId: assertString(payload.requestId, "payload.requestId", { maxLength: 128, pattern: idPattern, }), response, }; } case "sessionName": { assertAllowedKeys(payload, new Set(["name"]), "payload"); const name = assertString(payload.name, "payload.name", { maxLength: 4096, }).trim(); if (!name) throw new ProtocolError( "invalid_message", "payload.name must not be blank", ); return { name }; } case "compact": { assertAllowedKeys(payload, new Set(["customInstructions"]), "payload"); const customInstructions = assertString( payload.customInstructions, "payload.customInstructions", { maxLength: 32 * 1024 }, ).trim(); if (!customInstructions) throw new ProtocolError( "invalid_message", "payload.customInstructions must not be blank", ); return { customInstructions }; } case "model": { assertAllowedKeys(payload, new Set(["provider", "modelId"]), "payload"); return { provider: assertString(payload.provider, "payload.provider", { maxLength: 128, }), modelId: assertString(payload.modelId, "payload.modelId", { maxLength: 512, }), }; } case "thinking": { assertAllowedKeys(payload, new Set(["level"]), "payload"); const level = assertString(payload.level, "payload.level", { maxLength: 16, }); if (!thinkingLevels.has(level)) throw new ProtocolError( "invalid_message", "payload.level is not supported", ); return { level }; } case "piRpcCommand": { assertAllowedKeys(payload, new Set(["command", "input"]), "payload"); const command = assertString(payload.command, "payload.command", { maxLength: 64, }); try { return { command, input: validatePiRpcCommand(command, payload.input) }; } catch (error) { throw new ProtocolError("invalid_message", error.message); } } default: throw new ProtocolError("invalid_message", "unsupported payload shape"); } } export function validateRequest(value) { const request = assertRecord(value, "request"); assertAllowedKeys( request, new Set(["version", "id", "op", "agentId", "payload"]), "request", ); if (request.version !== PROTOCOL_VERSION) { throw new ProtocolError( "unsupported_version", `version must be ${PROTOCOL_VERSION}`, ); } const id = assertString(request.id, "request.id", { maxLength: 128, pattern: idPattern, }); const op = assertString(request.op, "request.op", { maxLength: 64 }); const operation = requestOperations.get(op); if (!operation) throw new ProtocolError( "unsupported_operation", `operation ${op} is not supported`, ); let agentId; if (operation.agent) { agentId = assertString(request.agentId, "request.agentId", { maxLength: 128, pattern: idPattern, }); } else if (request.agentId !== undefined) { throw new ProtocolError( "invalid_message", "request.agentId is not allowed for this operation", ); } const payload = validatePayload(operation.payload, request.payload); return { version: PROTOCOL_VERSION, id, op, ...(agentId ? { agentId } : {}), ...(payload === undefined ? {} : { payload }), }; } export function parseRequestFrame(frame, maxFrameBytes = MAX_FRAME_BYTES) { const bytes = Buffer.isBuffer(frame) ? frame.length : Buffer.byteLength(frame, "utf8"); if (bytes > maxFrameBytes) throw new ProtocolError( "frame_too_large", `frame exceeds ${maxFrameBytes} bytes`, ); const line = String(frame).endsWith("\r") ? String(frame).slice(0, -1) : String(frame); if (line.length === 0) throw new ProtocolError("invalid_message", "frame cannot be empty"); let parsed; try { parsed = JSON.parse(line); } catch { throw new ProtocolError("invalid_json", "frame must contain valid JSON"); } return validateRequest(parsed); } export function validateEvent(value) { const event = assertRecord(value, "event"); assertAllowedKeys( event, new Set(["version", "seq", "type", "agentId", "data"]), "event", ); if (event.version !== PROTOCOL_VERSION) throw new ProtocolError( "unsupported_version", `version must be ${PROTOCOL_VERSION}`, ); if (!Number.isSafeInteger(event.seq) || event.seq < 1) throw new ProtocolError( "invalid_message", "event.seq must be a positive integer", ); const type = assertString(event.type, "event.type", { maxLength: 64 }); if (!eventTypes.has(type)) throw new ProtocolError( "unsupported_event", `event ${type} is not supported`, ); return { version: PROTOCOL_VERSION, seq: event.seq, type, agentId: assertString(event.agentId, "event.agentId", { maxLength: 128, pattern: idPattern, }), data: assertRecord(event.data, "event.data"), }; } export function successResponse(id, result = {}) { return { version: PROTOCOL_VERSION, id, ok: true, result }; } export function errorResponse(id, error) { const code = error instanceof ProtocolError ? error.code : "internal_error"; const message = error instanceof Error ? error.message : "internal bridge error"; return { version: PROTOCOL_VERSION, ...(id ? { id } : {}), ok: false, error: { code, message }, }; } export function encodeFrame(value) { return `${JSON.stringify(value)}\n`; }