feat(bridge): establish local pi status bridge

Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
This commit is contained in:
2026-07-27 14:51:45 +02:00
commit b7fea83ed6
91 changed files with 15029 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_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_model",
"set_thinking_level",
]);
export class PiRpcError extends Error {
constructor(code, message) {
super(message);
this.name = "PiRpcError";
this.code = code;
}
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertAbsolutePath(value, field) {
if (typeof value !== "string" || !path.isAbsolute(value)) {
throw new TypeError(`${field} must be an absolute path`);
}
}
function emitSafely(callback, value) {
try {
callback(value);
} catch {
// Adapter observers must not interrupt the RPC reader.
}
}
function normalizePiEvent(event) {
switch (event.type) {
case "agent_start":
return { type: "agent_state", data: { state: "streaming", event } };
case "agent_end":
// A low-level run can be followed by compaction, retry, or queued work.
// Only agent_settled is an authoritative idle transition.
return { type: "transcript", data: { event } };
case "agent_settled":
return { type: "agent_state", data: { state: "idle", event } };
case "turn_start":
case "turn_end":
case "message_start":
case "message_end":
return { type: "transcript", data: { event } };
case "message_update":
return { type: "stream", data: { event } };
case "tool_execution_start":
case "tool_execution_update":
case "tool_execution_end":
return { type: "tool", data: { event } };
case "queue_update":
return { type: "queue", data: { event } };
case "compaction_start":
case "compaction_end":
case "auto_retry_start":
case "auto_retry_end":
return { type: "recovery", data: { event } };
case "extension_ui_request":
return { type: "extension_ui_request", data: { event } };
case "extension_error":
// Extension errors are diagnostics; the Pi process can still be healthy.
return { type: "transcript", data: { event } };
default:
return undefined;
}
}
function parseFrame(line, onError) {
try {
const value = JSON.parse(line);
if (!isRecord(value) || typeof value.type !== "string") {
throw new PiRpcError(
"invalid_message",
"Pi RPC frame must be an object with a type",
);
}
return value;
} catch (error) {
onError(
error instanceof PiRpcError
? error
: new PiRpcError("invalid_json", "Pi RPC emitted invalid JSON"),
);
return undefined;
}
}
function serializeFrame(value) {
try {
return `${JSON.stringify(value)}\n`;
} catch {
throw new PiRpcError(
"invalid_command",
"Pi RPC command must be JSON-serializable",
);
}
}
export function startPiRpcAdapter({
cwd,
sessionDir,
sessionPath,
command = "pi",
spawnProcess = spawn,
onEvent = () => {},
onError = () => {},
commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
maxFrameBytes = MAX_PI_RPC_FRAME_BYTES,
}) {
assertAbsolutePath(cwd, "cwd");
assertAbsolutePath(sessionDir, "sessionDir");
if (sessionPath !== undefined) assertAbsolutePath(sessionPath, "sessionPath");
if (typeof spawnProcess !== "function")
throw new TypeError("spawnProcess must be a function");
const child = spawnProcess(
command,
[
"--mode",
"rpc",
"--session-dir",
sessionDir,
...(sessionPath ? ["--session", sessionPath] : []),
],
{
cwd,
stdio: ["pipe", "pipe", "pipe"],
},
);
if (!child?.stdin || !child.stdout || !child.stderr) {
throw new PiRpcError(
"spawn_failed",
"Pi RPC child must expose stdin, stdout, and stderr streams",
);
}
let sequence = 0;
let stdoutBuffer = "";
let closed = false;
let intentionalStop = false;
const decoder = new StringDecoder("utf8");
const pending = new Map();
let resolveExit;
const exited = new Promise((resolve) => {
resolveExit = resolve;
});
const reportError = (error) => emitSafely(onError, error);
const rejectPending = (error) => {
for (const entry of pending.values()) {
clearTimeout(entry.timeout);
entry.reject(error);
}
pending.clear();
};
const finish = ({ code, signal, error }) => {
if (closed) return;
closed = true;
const terminalError =
error ??
new PiRpcError(
"child_exited",
`Pi RPC child exited before responding (code ${code ?? "null"}, signal ${signal ?? "none"})`,
);
rejectPending(terminalError);
if (!intentionalStop) reportError(terminalError);
resolveExit({ code, signal });
};
const handleResponse = (response) => {
if (typeof response.id !== "string") {
reportError(
new PiRpcError(
"uncorrelated_response",
"Pi RPC response did not include an id",
),
);
return;
}
const entry = pending.get(response.id);
if (!entry) {
reportError(
new PiRpcError(
"uncorrelated_response",
`Pi RPC response has no pending command: ${response.id}`,
),
);
return;
}
clearTimeout(entry.timeout);
pending.delete(response.id);
entry.resolve(response);
};
const handleEvent = (event) => {
const normalized = normalizePiEvent(event);
if (!normalized) {
reportError(
new PiRpcError(
"unsupported_event",
`Pi RPC emitted unsupported event: ${event.type}`,
),
);
return;
}
emitSafely(onEvent, { seq: ++sequence, ...normalized });
};
const handleLine = (line) => {
const frame = parseFrame(
line.endsWith("\r") ? line.slice(0, -1) : line,
reportError,
);
if (!frame) return;
if (frame.type === "response") handleResponse(frame);
else handleEvent(frame);
};
child.stdout.on("data", (chunk) => {
if (closed) return;
stdoutBuffer += decoder.write(chunk);
let newlineIndex;
while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
handleLine(line);
}
if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) {
reportError(
new PiRpcError(
"frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
);
stdoutBuffer = "";
}
});
child.stdout.on("end", () => {
if (closed) return;
const tail = stdoutBuffer + decoder.end();
stdoutBuffer = "";
if (tail.length > 0)
reportError(
new PiRpcError(
"unterminated_frame",
"Pi RPC stdout ended without an LF-terminated frame",
),
);
});
child.stderr.on("data", () => {});
child.stderr.on("error", (error) =>
reportError(new PiRpcError("stderr_error", error.message)),
);
child.on("error", (error) =>
finish({
code: null,
signal: null,
error: new PiRpcError("child_error", error.message),
}),
);
child.on("exit", (code, signal) => finish({ code, signal }));
function write(value) {
if (closed || child.stdin.destroyed)
throw new PiRpcError("child_exited", "Pi RPC child is not available");
child.stdin.write(serializeFrame(value));
}
return {
child,
get sequence() {
return sequence;
},
send(commandInput) {
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
return Promise.reject(
new PiRpcError(
"invalid_command",
"Pi RPC command must include a type",
),
);
}
if (Object.hasOwn(commandInput, "id")) {
return Promise.reject(
new PiRpcError(
"invalid_command",
"command IDs are assigned by the adapter",
),
);
}
if (!supportedCommands.has(commandInput.type)) {
return Promise.reject(
new PiRpcError(
"unsupported_command",
`Pi RPC command is not supported: ${commandInput.type}`,
),
);
}
const id = `bridge-${randomUUID()}`;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);
reject(
new PiRpcError(
"command_timeout",
`Pi RPC command timed out: ${commandInput.type}`,
),
);
}, commandTimeoutMs);
timeout.unref?.();
pending.set(id, { resolve, reject, timeout });
try {
write({ ...commandInput, id });
} catch (error) {
clearTimeout(timeout);
pending.delete(id);
reject(error);
}
});
},
respondToExtension(requestId, response) {
if (
typeof requestId !== "string" ||
requestId.length === 0 ||
!isRecord(response)
) {
throw new PiRpcError(
"invalid_extension_response",
"extension response requires a request id and response object",
);
}
// Pi uses this id to resume the blocked extension dialog; it cannot be replaced by a command ID.
write({ type: "extension_ui_response", id: requestId, ...response });
},
async stop() {
if (closed) return exited;
intentionalStop = true;
rejectPending(
new PiRpcError(
"child_stopped",
"Pi RPC child was stopped by the bridge",
),
);
child.kill("SIGTERM");
return exited;
},
};
}