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,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;
|
||||
Executable
+20
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
import { connectLocalClient } from "./local-client.js";
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage:",
|
||||
" pi-status-bridge-client --socket <path> request '<json-request>'",
|
||||
" pi-status-bridge-client --socket <path> subscribe --agent <agent-id> [--cursor <seq>]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function flag(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
}
|
||||
|
||||
async function requestOnce(client, rawRequest) {
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(rawRequest);
|
||||
} catch {
|
||||
throw new Error("request must be valid JSON");
|
||||
}
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== "object" ||
|
||||
Array.isArray(request) ||
|
||||
typeof request.op !== "string"
|
||||
) {
|
||||
throw new Error("request must be an object with an op");
|
||||
}
|
||||
const result = await client.request(request.op, {
|
||||
...(request.agentId ? { agentId: request.agentId } : {}),
|
||||
...(request.payload === undefined ? {} : { payload: request.payload }),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
async function streamSubscription(client, args) {
|
||||
const agentId = flag(args, "--agent");
|
||||
const cursorValue = flag(args, "--cursor");
|
||||
if (!agentId) throw new Error(usage());
|
||||
const cursor =
|
||||
cursorValue === undefined ? 0 : Number.parseInt(cursorValue, 10);
|
||||
if (!Number.isSafeInteger(cursor) || cursor < 0)
|
||||
throw new Error("cursor must be a non-negative integer");
|
||||
const unsubscribe = await client.subscribe(agentId, cursor, (event) => {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
const stop = () => resolve();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
});
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
async function main(args) {
|
||||
const socketPath =
|
||||
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||
const remaining = args.filter(
|
||||
(_, index) => args[index - 1] !== "--socket" && args[index] !== "--socket",
|
||||
);
|
||||
if (!socketPath) throw new Error(usage());
|
||||
|
||||
const client = await connectLocalClient({ socketPath });
|
||||
try {
|
||||
if (remaining[0] === "request" && remaining[1] && remaining.length === 2) {
|
||||
await requestOnce(client, remaining[1]);
|
||||
return;
|
||||
}
|
||||
if (remaining[0] === "subscribe") {
|
||||
await streamSubscription(client, remaining.slice(1));
|
||||
return;
|
||||
}
|
||||
throw new Error(usage());
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
main(process.argv.slice(2)).catch((error) =>
|
||||
fail(error instanceof Error ? error.message : "client failed"),
|
||||
);
|
||||
@@ -0,0 +1,166 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createConnection } from "node:net";
|
||||
import path from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import {
|
||||
MAX_FRAME_BYTES,
|
||||
ProtocolError,
|
||||
validateRequest,
|
||||
} from "../protocol/index.js";
|
||||
|
||||
export class LocalClientError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "LocalClientError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function frame(value) {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
export async function connectLocalClient({
|
||||
socketPath,
|
||||
maxFrameBytes = MAX_FRAME_BYTES,
|
||||
}) {
|
||||
if (typeof socketPath !== "string" || !path.isAbsolute(socketPath)) {
|
||||
throw new TypeError("socketPath must be an absolute Unix-socket path");
|
||||
}
|
||||
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const connection = createConnection(socketPath);
|
||||
connection.once("connect", () => resolve(connection));
|
||||
connection.once("error", reject);
|
||||
});
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const pending = new Map();
|
||||
const subscriptions = new Map();
|
||||
let buffer = "";
|
||||
let closed = false;
|
||||
|
||||
const rejectPending = (error) => {
|
||||
for (const { reject } of pending.values()) reject(error);
|
||||
pending.clear();
|
||||
};
|
||||
const dispatchEvent = (event) => {
|
||||
const subscription = subscriptions.get(event.agentId);
|
||||
if (!subscription) return;
|
||||
if (subscription.ready) subscription.listener(event);
|
||||
else subscription.queued.push(event);
|
||||
};
|
||||
const dispatchLine = (line) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
} catch {
|
||||
rejectPending(
|
||||
new LocalClientError("invalid_json", "bridge emitted invalid JSON"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message?.type === "event" && message.event) {
|
||||
dispatchEvent(message.event);
|
||||
return;
|
||||
}
|
||||
if (typeof message?.id !== "string") return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.ok) request.resolve(message.result);
|
||||
else
|
||||
request.reject(
|
||||
new LocalClientError(
|
||||
message.error?.code ?? "bridge_error",
|
||||
message.error?.message ?? "bridge request failed",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
if (closed) return;
|
||||
buffer += decoder.write(chunk);
|
||||
let index;
|
||||
while ((index = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 1);
|
||||
dispatchLine(line);
|
||||
}
|
||||
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes) {
|
||||
rejectPending(
|
||||
new LocalClientError(
|
||||
"frame_too_large",
|
||||
`bridge frame exceeds ${maxFrameBytes} bytes`,
|
||||
),
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
socket.on("error", (error) =>
|
||||
rejectPending(new LocalClientError("socket_error", error.message)),
|
||||
);
|
||||
socket.on("close", () => {
|
||||
closed = true;
|
||||
rejectPending(
|
||||
new LocalClientError("socket_closed", "bridge socket closed"),
|
||||
);
|
||||
});
|
||||
|
||||
const request = (op, { agentId, payload } = {}) => {
|
||||
if (closed || socket.destroyed)
|
||||
return Promise.reject(
|
||||
new LocalClientError("socket_closed", "bridge socket is closed"),
|
||||
);
|
||||
const id = `client-${randomUUID()}`;
|
||||
let requestFrame;
|
||||
try {
|
||||
requestFrame = validateRequest({
|
||||
version: "v1",
|
||||
id,
|
||||
op,
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(payload === undefined ? {} : { payload }),
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(
|
||||
error instanceof ProtocolError
|
||||
? error
|
||||
: new LocalClientError("invalid_request", "invalid bridge request"),
|
||||
);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.write(frame(requestFrame));
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
request,
|
||||
async subscribe(agentId, cursor, listener) {
|
||||
if (typeof listener !== "function")
|
||||
throw new TypeError("listener must be a function");
|
||||
const subscription = { listener, queued: [], ready: false };
|
||||
subscriptions.set(agentId, subscription);
|
||||
try {
|
||||
const result = await request("subscribe", {
|
||||
agentId,
|
||||
payload: cursor === undefined ? {} : { cursor },
|
||||
});
|
||||
for (const event of result.events) listener(event);
|
||||
subscription.ready = true;
|
||||
for (const event of subscription.queued) listener(event);
|
||||
subscription.queued = [];
|
||||
} catch (error) {
|
||||
subscriptions.delete(agentId);
|
||||
throw error;
|
||||
}
|
||||
return () => subscriptions.delete(agentId);
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function publishNoctaliaState(
|
||||
state,
|
||||
{ execute = async (command, args) => execFileAsync(command, args) } = {},
|
||||
) {
|
||||
if (!state || typeof state !== "object")
|
||||
throw new TypeError("state is required");
|
||||
const { state: bridgeState, projectLabel, attentionCount, detail } = state;
|
||||
if (
|
||||
typeof bridgeState !== "string" ||
|
||||
typeof projectLabel !== "string" ||
|
||||
!Number.isSafeInteger(attentionCount) ||
|
||||
typeof detail !== "string"
|
||||
) {
|
||||
throw new TypeError(
|
||||
"state must contain string state/projectLabel/detail and integer attentionCount",
|
||||
);
|
||||
}
|
||||
await execute("qs", [
|
||||
"-c",
|
||||
"noctalia-shell",
|
||||
"ipc",
|
||||
"call",
|
||||
"plugin:pi-status-bridge",
|
||||
"setBridgeState",
|
||||
bridgeState,
|
||||
projectLabel,
|
||||
String(attentionCount),
|
||||
detail,
|
||||
]);
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
import { connectLocalClient } from "./local-client.js";
|
||||
import { publishNoctaliaState } from "./noctalia-ipc.js";
|
||||
import { createNoctaliaStateRelay } from "./noctalia-relay.js";
|
||||
|
||||
function flag(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const args = process.argv.slice(2);
|
||||
const socketPath =
|
||||
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||
const agentId = flag(args, "--agent");
|
||||
if (!socketPath || !agentId)
|
||||
throw new Error(
|
||||
"Usage: pi-status-bridge-noctalia-relay --socket <path> --agent <id>",
|
||||
);
|
||||
const client = await connectLocalClient({ socketPath });
|
||||
const agents = await client.request("list_agents");
|
||||
const agent = agents.agents.find((candidate) => candidate.id === agentId);
|
||||
if (!agent) throw new Error(`unknown agent: ${agentId}`);
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client,
|
||||
agent,
|
||||
onState: (state) => {
|
||||
void publishNoctaliaState(state).catch((error) =>
|
||||
process.stderr.write(`${error.message}\n`),
|
||||
);
|
||||
},
|
||||
});
|
||||
await relay.start();
|
||||
await new Promise((resolve) => {
|
||||
const shutdown = () => resolve();
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
});
|
||||
relay.stop();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import path from "node:path";
|
||||
|
||||
function detailFor(state) {
|
||||
switch (state) {
|
||||
case "streaming":
|
||||
return "Streaming";
|
||||
case "recovering":
|
||||
return "Recovering Pi session";
|
||||
case "failed":
|
||||
return "Recovery failed";
|
||||
case "error":
|
||||
return "Bridge error";
|
||||
default:
|
||||
return "Idle";
|
||||
}
|
||||
}
|
||||
|
||||
export function createNoctaliaStateRelay({ client, agent, onState }) {
|
||||
if (
|
||||
!client ||
|
||||
typeof client.request !== "function" ||
|
||||
typeof client.subscribe !== "function"
|
||||
)
|
||||
throw new TypeError("client must support request and subscribe");
|
||||
if (!agent?.id || !agent?.worktreePath)
|
||||
throw new TypeError("agent id and worktreePath are required");
|
||||
if (typeof onState !== "function")
|
||||
throw new TypeError("onState must be a function");
|
||||
|
||||
let state = "idle";
|
||||
let attentionCount = 0;
|
||||
let unsubscribe = () => {};
|
||||
const projectLabel = path.basename(agent.worktreePath) || agent.worktreePath;
|
||||
const publish = () =>
|
||||
onState({ state, projectLabel, attentionCount, detail: detailFor(state) });
|
||||
const handleEvent = (event) => {
|
||||
if (event.type === "agent_state" && typeof event.data?.state === "string")
|
||||
state = event.data.state;
|
||||
if (event.type === "queue")
|
||||
attentionCount =
|
||||
(event.data?.event?.steering?.length ?? 0) +
|
||||
(event.data?.event?.followUp?.length ?? 0);
|
||||
if (event.type === "extension_ui_request")
|
||||
attentionCount = Math.max(attentionCount, 1);
|
||||
publish();
|
||||
};
|
||||
|
||||
return {
|
||||
async start() {
|
||||
const response = await client.request("get_state", { agentId: agent.id });
|
||||
state = response?.data?.isStreaming ? "streaming" : "idle";
|
||||
publish();
|
||||
unsubscribe = await client.subscribe(agent.id, 0, handleEvent);
|
||||
},
|
||||
stop() {
|
||||
unsubscribe();
|
||||
unsubscribe = () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import path from "node:path";
|
||||
|
||||
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" }],
|
||||
["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" }],
|
||||
["get_available_models", { agent: true, payload: "none" }],
|
||||
["get_commands", { agent: true, payload: "none" }],
|
||||
["set_model", { agent: true, payload: "model" }],
|
||||
["set_thinking_level", { agent: true, payload: "thinking" }],
|
||||
]);
|
||||
|
||||
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;
|
||||
return assertString(value, "payload.cursor", {
|
||||
maxLength: 256,
|
||||
pattern: idPattern,
|
||||
});
|
||||
}
|
||||
|
||||
function validatePayload(kind, value) {
|
||||
if (kind === "none") {
|
||||
if (value !== undefined)
|
||||
throw new ProtocolError(
|
||||
"invalid_message",
|
||||
"payload is not allowed for this operation",
|
||||
);
|
||||
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 "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 "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 };
|
||||
}
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user