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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user