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
+492
View File
@@ -0,0 +1,492 @@
import { createHash, randomUUID } from "node:crypto";
import {
chmod,
mkdir,
readdir,
readFile,
realpath,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
import { createRecoverySupervisor } from "./recovery-supervisor.js";
const SESSION_DIRECTORY_MODE = 0o700;
const SESSION_REFERENCE_FILE = "bridge-agent.json";
const DEFAULT_EVENT_LIMIT = 1_000;
export class UnknownAgentError extends Error {
constructor(agentId) {
super(`unknown agent: ${agentId}`);
this.name = "UnknownAgentError";
}
}
function sessionDirectoryFor(sessionRoot, worktreePath) {
const identity = createHash("sha256")
.update(worktreePath)
.digest("hex")
.slice(0, 24);
return path.join(sessionRoot, `agent-${identity}`);
}
async function readSessionReference(sessionDir) {
try {
const value = JSON.parse(
await readFile(path.join(sessionDir, SESSION_REFERENCE_FILE), "utf8"),
);
return {
sessionPath:
typeof value?.sessionPath === "string" &&
path.isAbsolute(value.sessionPath)
? value.sessionPath
: undefined,
worktreePath:
typeof value?.worktreePath === "string" &&
path.isAbsolute(value.worktreePath)
? value.worktreePath
: undefined,
};
} catch (error) {
if (error?.code === "ENOENT" || error instanceof SyntaxError) return {};
throw error;
}
}
async function persistSessionReference(sessionDir, sessionPath, worktreePath) {
await writeFile(
path.join(sessionDir, SESSION_REFERENCE_FILE),
`${JSON.stringify({ sessionPath, worktreePath })}\n`,
{
encoding: "utf8",
mode: 0o600,
},
);
}
function sessionPreview(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join(" ");
}
async function readSessionSummary(sessionPath, currentPath) {
const [content, metadata] = await Promise.all([
readFile(sessionPath, "utf8"),
stat(sessionPath),
]);
let header;
let name;
let firstMessage;
let messageCount = 0;
for (const line of content.split("\n")) {
if (!line) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type === "session") header = entry;
if (entry.type === "session_info" && typeof entry.name === "string")
name = entry.name;
if (entry.type === "message") {
messageCount += 1;
if (!firstMessage && entry.message?.role === "user")
firstMessage = sessionPreview(entry.message.content);
}
}
if (
!header ||
typeof header.id !== "string" ||
typeof header.cwd !== "string"
)
return undefined;
return {
path: sessionPath,
id: header.id,
cwd: header.cwd,
name,
parentSessionPath: header.parentSession,
created: header.timestamp,
modified: metadata.mtime.toISOString(),
messageCount,
firstMessage,
isCurrent: sessionPath === currentPath,
};
}
async function listSessionSummaries(sessionDir, currentPath) {
const entries = await readdir(sessionDir, { withFileTypes: true });
const summaries = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map(async (entry) => {
try {
return await readSessionSummary(
path.join(sessionDir, entry.name),
currentPath,
);
} catch {
return undefined;
}
}),
);
return summaries
.filter(Boolean)
.sort((left, right) => right.modified.localeCompare(left.modified));
}
async function listDirectoryCatalog(sessionRoot, agentsByPath) {
let entries;
try {
entries = await readdir(sessionRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const references = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const reference = await readSessionReference(
path.join(sessionRoot, entry.name),
);
if (!reference.worktreePath) return undefined;
return path.basename(
sessionDirectoryFor(sessionRoot, reference.worktreePath),
) === entry.name
? reference.worktreePath
: undefined;
}),
);
const paths = new Set([
...agentsByPath.keys(),
...references.filter(Boolean),
]);
return [...paths]
.map((worktreePath) => {
const agent = agentsByPath.get(worktreePath);
return {
worktreePath,
state: agent?.state ?? "inactive",
...(agent ? { agentId: agent.id } : {}),
};
})
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
}
function publicAgent(agent) {
return {
id: agent.id,
worktreePath: agent.worktreePath,
sessionDir: agent.sessionDir,
state: agent.state,
};
}
function routeCommand(adapter, operation, payload = {}, agentState) {
switch (operation) {
case "prompt":
case "steer":
case "follow_up":
return adapter.send({ type: operation, message: payload.message });
case "submit_prompt":
return adapter.send({
type: agentState === "streaming" ? "follow_up" : "prompt",
message: payload.message,
});
case "abort":
case "get_state":
case "get_session_stats":
case "get_available_models":
case "get_commands":
return adapter.send({ type: operation });
case "get_transcript":
return adapter.send({ type: "get_messages" });
case "set_model":
return adapter.send({
type: "set_model",
provider: payload.provider,
modelId: payload.modelId,
});
case "set_thinking_level":
return adapter.send({ type: "set_thinking_level", level: payload.level });
case "extension_response":
adapter.respondToExtension(payload.requestId, payload.response);
return Promise.resolve({
type: "response",
command: "extension_ui_response",
success: true,
});
default:
return Promise.reject(
new Error(`unsupported agent operation: ${operation}`),
);
}
}
export function createAgentRegistry({
homeWorktree,
sessionRoot,
startAdapter = startPiRpcAdapter,
eventLimit = DEFAULT_EVENT_LIMIT,
maxRecoveryAttempts,
recoveryDelayForAttempt,
sleep,
}) {
if (typeof homeWorktree !== "string" || !path.isAbsolute(homeWorktree))
throw new TypeError("homeWorktree must be an absolute path");
if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot))
throw new TypeError("sessionRoot must be an absolute path");
if (typeof startAdapter !== "function")
throw new TypeError("startAdapter must be a function");
const agentsByPath = new Map();
const agentsById = new Map();
const inFlight = new Map();
async function ensureAgent(worktreePath) {
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
throw new TypeError("worktreePath must be an absolute path");
const canonicalPath = await realpath(worktreePath);
const existing = agentsByPath.get(canonicalPath);
if (existing) return publicAgent(existing);
const creating = inFlight.get(canonicalPath);
if (creating) return creating;
const creation = (async () => {
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
await mkdir(sessionDir, {
recursive: true,
mode: SESSION_DIRECTORY_MODE,
});
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
const reference = await readSessionReference(sessionDir);
const agent = {
id: `agent-${randomUUID()}`,
worktreePath: canonicalPath,
sessionDir,
sessionPath: reference.sessionPath,
state: "idle",
events: [],
listeners: new Set(),
nextSequence: 1,
adapter: undefined,
stopped: false,
recoveryPromise: undefined,
supervisor: undefined,
};
const publish = (type, data) => {
const event = {
version: "v1",
seq: agent.nextSequence++,
type,
agentId: agent.id,
data,
};
agent.events.push(event);
if (agent.events.length > eventLimit) agent.events.shift();
for (const listener of agent.listeners) listener(event);
};
const launch = async () => {
const adapter = startAdapter({
cwd: canonicalPath,
sessionDir,
...(agent.sessionPath ? { sessionPath: agent.sessionPath } : {}),
onEvent: (event) => {
if (
event.type === "agent_state" &&
typeof event.data?.state === "string"
) {
agent.state = event.data.state;
if (event.data.state === "idle") agent.supervisor.markHealthy();
}
publish(event.type, event.data);
},
onError: (error) => {
agent.state = "error";
publish("agent_state", {
state: "error",
error: { code: error.code ?? "unknown", message: error.message },
});
if (
!agent.stopped &&
["child_exited", "child_error"].includes(error.code)
) {
agent.recoveryPromise = agent.supervisor
.handleUnexpectedExit()
.then((adapterAfterRecovery) => {
if (agent.stopped) return undefined;
agent.state = adapterAfterRecovery ? "idle" : "failed";
publish("agent_state", { state: agent.state });
return adapterAfterRecovery;
});
}
},
});
agent.adapter = adapter;
const state = await adapter.send({ type: "get_state" });
agent.state = state?.data?.isStreaming ? "streaming" : "idle";
const sessionPath = state?.data?.sessionFile;
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
agent.sessionPath = sessionPath;
await persistSessionReference(sessionDir, sessionPath, canonicalPath);
}
return adapter;
};
agent.supervisor = createRecoverySupervisor({
start: launch,
...(maxRecoveryAttempts ? { maxAttempts: maxRecoveryAttempts } : {}),
...(recoveryDelayForAttempt
? { delayForAttempt: recoveryDelayForAttempt }
: {}),
...(sleep ? { sleep } : {}),
onState: (recovery) => publish("recovery", recovery),
});
await launch();
agentsByPath.set(canonicalPath, agent);
agentsById.set(agent.id, agent);
return publicAgent(agent);
})();
inFlight.set(canonicalPath, creation);
try {
return await creation;
} finally {
inFlight.delete(canonicalPath);
}
}
function getAgent(agentId) {
const agent = agentsById.get(agentId);
if (!agent) throw new UnknownAgentError(agentId);
return agent;
}
async function refreshAgentSessionReference(agent) {
const state = await agent.adapter.send({ type: "get_state" });
const sessionPath = state?.data?.sessionFile;
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
agent.sessionPath = sessionPath;
await persistSessionReference(
agent.sessionDir,
sessionPath,
agent.worktreePath,
);
}
}
async function ownedSessionPath(agent, sessionPath) {
const [resolvedSessionDir, resolvedSessionPath] = await Promise.all([
realpath(agent.sessionDir),
realpath(sessionPath),
]);
const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
if (
relative === "" ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
!resolvedSessionPath.endsWith(".jsonl")
) {
throw new TypeError("sessionPath must belong to the selected directory");
}
return resolvedSessionPath;
}
async function switchSession(agent, sessionPath) {
if (agent.state === "streaming")
throw new Error("cannot switch sessions while Pi is working");
const ownedPath = await ownedSessionPath(agent, sessionPath);
const response = await agent.adapter.send({
type: "switch_session",
sessionPath: ownedPath,
});
if (!response?.data?.cancelled) await refreshAgentSessionReference(agent);
return response;
}
async function newSession(agent) {
if (agent.state === "streaming")
throw new Error("cannot start a session while Pi is working");
const response = await agent.adapter.send({ type: "new_session" });
if (!response?.data?.cancelled) await refreshAgentSessionReference(agent);
return response;
}
return {
async start() {
return ensureAgent(homeWorktree);
},
async selectWorktree(worktreePath) {
return ensureAgent(worktreePath);
},
listAgents() {
return [...agentsById.values()]
.map(publicAgent)
.sort((left, right) =>
left.worktreePath.localeCompare(right.worktreePath),
);
},
async listDirectories() {
return listDirectoryCatalog(sessionRoot, agentsByPath);
},
async listSessions(agentId) {
const agent = getAgent(agentId);
return listSessionSummaries(agent.sessionDir, agent.sessionPath);
},
eventsAfter(agentId, cursor = 0) {
if (!Number.isSafeInteger(cursor) || cursor < 0)
throw new TypeError("cursor must be a non-negative integer");
return getAgent(agentId).events.filter((event) => event.seq > cursor);
},
subscribe(agentId, cursor, listener) {
if (typeof listener !== "function")
throw new TypeError("listener must be a function");
const agent = getAgent(agentId);
const events = this.eventsAfter(agentId, cursor);
agent.listeners.add(listener);
return {
events,
unsubscribe: () => agent.listeners.delete(listener),
};
},
async waitForRecovery(agentId) {
return getAgent(agentId).recoveryPromise;
},
async retry(agentId) {
const agent = getAgent(agentId);
agent.supervisor.markHealthy();
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
await agent.recoveryPromise;
return publicAgent(agent);
},
async restart(agentId) {
const agent = getAgent(agentId);
await agent.adapter.stop();
return this.retry(agentId);
},
async route(agentId, operation, payload) {
const agent = getAgent(agentId);
if (operation === "switch_session")
return switchSession(agent, payload.sessionPath);
if (operation === "new_session") return newSession(agent);
return routeCommand(agent.adapter, operation, payload, agent.state);
},
async stop() {
await Promise.all(
[...agentsById.values()].map(async (agent) => {
agent.stopped = true;
agent.supervisor.stop();
await agent.adapter.stop();
agent.state = "stopped";
}),
);
},
};
}
export const sessionDirectoryMode = SESSION_DIRECTORY_MODE;
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import { homedir } from "node:os";
import { runBridgeDaemon } from "./daemon.js";
function readFlag(args, name) {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}
const args = process.argv.slice(2);
runBridgeDaemon({
homeWorktree: readFlag(args, "--worktree") ?? homedir(),
runtimeDir: readFlag(args, "--runtime-dir"),
sessionRoot: readFlag(args, "--session-root"),
}).catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : "bridge daemon failed"}\n`,
);
process.exitCode = 1;
});
+26
View File
@@ -0,0 +1,26 @@
import { startBridgeService } from "./service.js";
export async function startBridgeDaemon(options) {
return startBridgeService(options);
}
export async function runBridgeDaemon(options) {
const daemon = await startBridgeDaemon(options);
let stopping = false;
const stop = async () => {
if (stopping) return;
stopping = true;
await daemon.close();
};
process.stdout.write(
`${JSON.stringify({ socketPath: daemon.socketPath })}\n`,
);
await new Promise((resolve) => {
const shutdown = async () => {
await stop();
resolve();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
+126
View File
@@ -0,0 +1,126 @@
import { randomUUID } from "node:crypto";
import { chmod, open, readFile, unlink } from "node:fs/promises";
const LOCK_MODE = 0o600;
export class BridgeAlreadyRunningError extends Error {
constructor(metadata) {
super(`bridge instance is already running with pid ${metadata.pid}`);
this.name = "BridgeAlreadyRunningError";
this.metadata = metadata;
}
}
export class BridgeLockError extends Error {
constructor(message) {
super(message);
this.name = "BridgeLockError";
}
}
function parseLockMetadata(text, lockPath) {
let metadata;
try {
metadata = JSON.parse(text);
} catch {
throw new BridgeLockError(`lock file is unreadable: ${lockPath}`);
}
if (
metadata === null ||
typeof metadata !== "object" ||
!Number.isSafeInteger(metadata.pid) ||
metadata.pid < 1 ||
typeof metadata.token !== "string" ||
metadata.token.length < 1 ||
typeof metadata.socketPath !== "string" ||
metadata.socketPath.length < 1 ||
typeof metadata.startedAt !== "string" ||
Number.isNaN(Date.parse(metadata.startedAt))
) {
throw new BridgeLockError(`lock file has an invalid format: ${lockPath}`);
}
return metadata;
}
export function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error?.code === "ESRCH") return false;
if (error?.code === "EPERM") return true;
throw error;
}
}
async function readLockMetadata(lockPath) {
try {
return parseLockMetadata(await readFile(lockPath, "utf8"), lockPath);
} catch (error) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
}
export async function acquireBridgeLock({
lockPath,
socketPath,
pid = process.pid,
now = () => new Date(),
processAlive = isProcessAlive,
}) {
if (!lockPath || !socketPath)
throw new TypeError("lockPath and socketPath are required");
const metadata = {
pid,
socketPath,
startedAt: now().toISOString(),
token: randomUUID(),
};
for (let attempt = 0; attempt < 2; attempt += 1) {
let handle;
let createdLock = false;
try {
handle = await open(lockPath, "wx", LOCK_MODE);
createdLock = true;
await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
await handle.sync();
await chmod(lockPath, LOCK_MODE);
await handle.close();
return {
metadata,
async release() {
const current = await readLockMetadata(lockPath);
if (current?.token !== metadata.token) return false;
await unlink(lockPath);
return true;
},
};
} catch (error) {
await handle?.close().catch(() => {});
if (createdLock) {
await unlink(lockPath).catch(() => {});
throw error;
}
if (error?.code !== "EEXIST") throw error;
const existing = await readLockMetadata(lockPath);
if (!existing) continue;
if (processAlive(existing.pid))
throw new BridgeAlreadyRunningError(existing);
// Only a lock with a readable PID that has exited may be reclaimed.
await unlink(lockPath).catch((unlinkError) => {
if (unlinkError?.code !== "ENOENT") throw unlinkError;
});
}
}
throw new BridgeLockError(
`could not acquire lock after stale-owner recovery: ${lockPath}`,
);
}
export const lockMode = LOCK_MODE;
+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;
},
};
}
+76
View File
@@ -0,0 +1,76 @@
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3;
export function exponentialBackoff(
attempt,
baseDelayMs = 1_000,
maxDelayMs = 30_000,
) {
return Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
}
export function createRecoverySupervisor({
start,
maxAttempts = DEFAULT_MAX_RECOVERY_ATTEMPTS,
delayForAttempt = (attempt) => exponentialBackoff(attempt),
sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)),
onState = () => {},
}) {
if (typeof start !== "function")
throw new TypeError("start must be a function");
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1)
throw new TypeError("maxAttempts must be a positive integer");
let attempts = 0;
let stopped = false;
let recovery;
const emit = (state) => {
try {
onState(state);
} catch {
// Observers cannot interrupt recovery.
}
};
async function recover() {
while (!stopped && attempts < maxAttempts) {
attempts += 1;
const attempt = attempts;
const delayMs = delayForAttempt(attempt);
emit({ state: "recovering", attempt, delayMs });
await sleep(delayMs);
if (stopped) return undefined;
try {
const resource = await start();
if (stopped) return undefined;
emit({ state: "healthy", attempt });
return resource;
} catch (error) {
emit({ state: "recovery_error", attempt, error });
}
}
if (!stopped) emit({ state: "failed", attempt: attempts });
return undefined;
}
return {
get attempts() {
return attempts;
},
async handleUnexpectedExit() {
if (stopped) return undefined;
if (!recovery) {
recovery = recover().finally(() => {
recovery = undefined;
});
}
return recovery;
},
markHealthy() {
attempts = 0;
},
stop() {
stopped = true;
},
};
}
+70
View File
@@ -0,0 +1,70 @@
import { chmod, lstat, mkdir, unlink } from "node:fs/promises";
import path from "node:path";
import { acquireBridgeLock } from "./instance-lock.js";
import { startUnixSocketServer } from "./unix-server.js";
const RUNTIME_DIRECTORY_MODE = 0o700;
export async function createRuntimePaths(
runtimeDir = process.env.XDG_RUNTIME_DIR,
) {
if (!runtimeDir || !path.isAbsolute(runtimeDir)) {
throw new Error("XDG_RUNTIME_DIR must be an absolute path");
}
const directory = path.join(runtimeDir, "pi-status-bridge");
await mkdir(directory, { recursive: true, mode: RUNTIME_DIRECTORY_MODE });
await chmod(directory, RUNTIME_DIRECTORY_MODE);
return {
directory,
socketPath: path.join(directory, "bridge.sock"),
lockPath: path.join(directory, "bridge.lock"),
};
}
async function removeStaleSocket(socketPath) {
try {
const socket = await lstat(socketPath);
if (!socket.isSocket())
throw new Error(`refusing to remove non-socket path: ${socketPath}`);
await unlink(socketPath);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
export async function startBridgeServer({
handleRequest,
subscribe,
runtimeDir,
processAlive,
maxFrameBytes,
}) {
const paths = await createRuntimePaths(runtimeDir);
const lock = await acquireBridgeLock({
lockPath: paths.lockPath,
socketPath: paths.socketPath,
...(processAlive ? { processAlive } : {}),
});
try {
await removeStaleSocket(paths.socketPath);
const socketServer = await startUnixSocketServer({
socketPath: paths.socketPath,
handleRequest,
...(subscribe ? { subscribe } : {}),
...(maxFrameBytes ? { maxFrameBytes } : {}),
});
return {
...paths,
async close() {
await socketServer.close();
await lock.release();
},
};
} catch (error) {
await lock.release();
throw error;
}
}
export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE;
+88
View File
@@ -0,0 +1,88 @@
import path from "node:path";
import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js";
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
import { ProtocolError } from "../protocol/index.js";
import { createRuntimePaths, startBridgeServer } from "./runtime.js";
export async function startBridgeService({
homeWorktree,
runtimeDir,
sessionRoot,
startAdapter = startPiRpcAdapter,
processAlive,
maxFrameBytes,
}) {
const paths = await createRuntimePaths(runtimeDir);
const registry = createAgentRegistry({
homeWorktree,
sessionRoot: sessionRoot ?? path.join(paths.directory, "sessions"),
startAdapter,
});
const dispatch = async (request) => {
try {
switch (request.op) {
case "list_agents":
return { agents: registry.listAgents() };
case "list_directories":
return { directories: await registry.listDirectories() };
case "select_agent":
return {
agent: await registry.selectWorktree(request.payload.worktreePath),
};
case "subscribe":
return {
events: registry.eventsAfter(
request.agentId,
request.payload.cursor ?? 0,
),
};
case "retry":
return { agent: await registry.retry(request.agentId) };
case "restart":
return { agent: await registry.restart(request.agentId) };
case "list_sessions":
return { sessions: await registry.listSessions(request.agentId) };
default:
return registry.route(request.agentId, request.op, request.payload);
}
} catch (error) {
if (error instanceof UnknownAgentError)
throw new ProtocolError("unknown_agent", error.message);
throw error;
}
};
const server = await startBridgeServer({
runtimeDir,
handleRequest: dispatch,
subscribe: (request, notify) => {
const subscription = registry.subscribe(
request.agentId,
request.payload.cursor ?? 0,
notify,
);
return {
result: { events: subscription.events },
unsubscribe: subscription.unsubscribe,
};
},
...(processAlive ? { processAlive } : {}),
...(maxFrameBytes ? { maxFrameBytes } : {}),
});
try {
await registry.start();
} catch (error) {
await server.close();
throw error;
}
return {
socketPath: server.socketPath,
listAgents: () => registry.listAgents(),
async close() {
await registry.stop();
await server.close();
},
};
}
+164
View File
@@ -0,0 +1,164 @@
import { createServer } from "node:net";
import { StringDecoder } from "node:string_decoder";
import { chmod, lstat, unlink } from "node:fs/promises";
import {
MAX_FRAME_BYTES,
ProtocolError,
encodeFrame,
errorResponse,
parseRequestFrame,
successResponse,
} from "../protocol/index.js";
const OWNER_ONLY_MODE = 0o600;
function isMissing(error) {
return error && error.code === "ENOENT";
}
async function assertUnusedSocketPath(socketPath) {
try {
const stats = await lstat(socketPath);
if (stats.isSocket())
throw new Error(`socket path is already in use: ${socketPath}`);
throw new Error(`refusing to replace non-socket path: ${socketPath}`);
} catch (error) {
if (!isMissing(error)) throw error;
}
}
function requestIdFromLine(line) {
try {
const value = JSON.parse(line);
return typeof value?.id === "string" ? value.id : undefined;
} catch {
return undefined;
}
}
function write(socket, frame) {
if (!socket.destroyed) socket.write(encodeFrame(frame));
}
export async function startUnixSocketServer({
socketPath,
handleRequest,
subscribe,
maxFrameBytes = MAX_FRAME_BYTES,
}) {
if (typeof socketPath !== "string" || !socketPath.startsWith("/")) {
throw new Error("socketPath must be an absolute Unix-socket path");
}
if (typeof handleRequest !== "function")
throw new TypeError("handleRequest must be a function");
if (subscribe !== undefined && typeof subscribe !== "function")
throw new TypeError("subscribe must be a function");
await assertUnusedSocketPath(socketPath);
const sockets = new Set();
const server = createServer((socket) => {
sockets.add(socket);
socket.on("error", () => {});
const decoder = new StringDecoder("utf8");
const cleanup = new Set();
let pending = "";
let closedForFrameLimit = false;
socket.on("close", () => {
sockets.delete(socket);
for (const unsubscribe of cleanup) unsubscribe();
cleanup.clear();
});
const handleSubscription = async (request) => {
const queued = [];
let ready = false;
const subscription = await subscribe(request, (event) => {
if (ready) write(socket, { version: "v1", type: "event", event });
else queued.push(event);
});
cleanup.add(subscription.unsubscribe);
write(socket, successResponse(request.id, subscription.result));
ready = true;
for (const event of queued)
write(socket, { version: "v1", type: "event", event });
};
const handleLine = async (line) => {
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
try {
const request = parseRequestFrame(normalized, maxFrameBytes);
if (request.op === "subscribe" && subscribe) {
await handleSubscription(request);
return;
}
const result = await handleRequest(request);
write(socket, successResponse(request.id, result));
} catch (error) {
write(socket, errorResponse(requestIdFromLine(normalized), error));
}
};
socket.on("data", (chunk) => {
if (closedForFrameLimit) return;
pending += decoder.write(chunk);
let newline;
while ((newline = pending.indexOf("\n")) !== -1) {
const line = pending.slice(0, newline);
pending = pending.slice(newline + 1);
void handleLine(line);
}
if (Buffer.byteLength(pending, "utf8") > maxFrameBytes) {
closedForFrameLimit = true;
write(
socket,
errorResponse(
undefined,
new ProtocolError(
"frame_too_large",
`frame exceeds ${maxFrameBytes} bytes`,
),
),
);
socket.end();
}
});
socket.on("end", () => {
if (closedForFrameLimit) return;
const finalLine = pending + decoder.end();
if (finalLine.length > 0) void handleLine(finalLine);
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, () => {
server.off("error", reject);
resolve();
});
});
try {
await chmod(socketPath, OWNER_ONLY_MODE);
} catch (error) {
await new Promise((resolve) => server.close(resolve));
await unlink(socketPath).catch(() => {});
throw error;
}
return {
socketPath,
async close() {
for (const socket of sockets) socket.destroy();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
await unlink(socketPath).catch((error) => {
if (!isMissing(error)) throw error;
});
},
};
}
export const socketMode = OWNER_ONLY_MODE;