feat(rpc): add validated desktop Pi RPC command palette

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+55 -36
View File
@@ -2,28 +2,13 @@ import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import { PI_RPC_COMMANDS } from "./pi-rpc-command-spec.js";
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000;
export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024;
const supportedCommands = new Set([
"prompt",
"steer",
"follow_up",
"abort",
"get_state",
"get_session_stats",
"new_session",
"switch_session",
"get_messages",
"get_available_models",
"get_commands",
"set_session_name",
"compact",
"set_model",
"set_thinking_level",
]);
const supportedCommands = new Set(PI_RPC_COMMANDS);
export class PiRpcError extends Error {
constructor(code, message) {
@@ -43,9 +28,9 @@ function assertAbsolutePath(value, field) {
}
}
function emitSafely(callback, value) {
function emitSafely(callback, ...values) {
try {
callback(value);
callback(...values);
} catch {
// Adapter observers must not interrupt the RPC reader.
}
@@ -161,6 +146,7 @@ export function startPiRpcAdapter({
let sequence = 0;
let stdoutBuffer = "";
let discardingOversizedFrame = false;
let closed = false;
let intentionalStop = false;
const decoder = new StringDecoder("utf8");
@@ -170,7 +156,7 @@ export function startPiRpcAdapter({
resolveExit = resolve;
});
const reportError = (error) => emitSafely(onError, error);
const reportError = (error, metadata) => emitSafely(onError, error, metadata);
const rejectPending = (error) => {
for (const entry of pending.values()) {
clearTimeout(entry.timeout);
@@ -237,37 +223,58 @@ export function startPiRpcAdapter({
if (frame.type === "response") handleResponse(frame);
else handleEvent(frame);
};
child.stdout.on("data", (chunk) => {
if (closed) return;
stdoutBuffer += decoder.write(chunk);
const reportOversizedFrame = () =>
reportError(
new PiRpcError(
"frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
{ terminal: false },
);
const consumeStdout = (text) => {
let remaining = text;
if (discardingOversizedFrame) {
const newlineIndex = remaining.indexOf("\n");
if (newlineIndex === -1) return;
discardingOversizedFrame = false;
remaining = remaining.slice(newlineIndex + 1);
}
stdoutBuffer += remaining;
let newlineIndex;
while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (Buffer.byteLength(line, "utf8") > maxFrameBytes) {
reportOversizedFrame();
continue;
}
handleLine(line);
}
if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) {
reportError(
new PiRpcError(
"frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
);
reportOversizedFrame();
stdoutBuffer = "";
discardingOversizedFrame = true;
}
};
child.stdout.on("data", (chunk) => {
if (!closed) consumeStdout(decoder.write(chunk));
});
child.stdout.on("end", () => {
if (closed) return;
const tail = stdoutBuffer + decoder.end();
stdoutBuffer = "";
if (tail.length > 0)
consumeStdout(decoder.end());
if (discardingOversizedFrame) {
discardingOversizedFrame = false;
return;
}
if (stdoutBuffer.length > 0)
reportError(
new PiRpcError(
"unterminated_frame",
"Pi RPC stdout ended without an LF-terminated frame",
),
);
stdoutBuffer = "";
});
child.stderr.on("data", () => {});
child.stderr.on("error", (error) =>
@@ -293,7 +300,7 @@ export function startPiRpcAdapter({
get sequence() {
return sequence;
},
send(commandInput) {
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
return Promise.reject(
new PiRpcError(
@@ -318,12 +325,24 @@ export function startPiRpcAdapter({
),
);
}
if (
requestedTimeoutMs !== undefined &&
(!Number.isSafeInteger(requestedTimeoutMs) || requestedTimeoutMs <= 0)
) {
return Promise.reject(
new PiRpcError(
"invalid_timeout",
"Pi RPC command timeout must be a positive safe integer",
),
);
}
const id = `bridge-${randomUUID()}`;
const timeoutMs =
commandInput.type === "compact"
requestedTimeoutMs ??
(commandInput.type === "compact"
? compactCommandTimeoutMs
: commandTimeoutMs;
: commandTimeoutMs);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);