Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
345
extensions/pi-intercom/broker/broker.ts
Normal file
345
extensions/pi-intercom/broker/broker.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import net from "net";
|
||||
import { writeFileSync, unlinkSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import { randomUUID } from "crypto";
|
||||
import { writeMessage, createMessageReader } from "./framing.js";
|
||||
import { getBrokerSocketPath } from "./paths.js";
|
||||
import type { SessionInfo, Message, Attachment, BrokerMessage } from "../types.js";
|
||||
|
||||
const INTERCOM_DIR = join(homedir(), ".pi/agent/intercom");
|
||||
const SOCKET_PATH = getBrokerSocketPath();
|
||||
const PID_PATH = join(INTERCOM_DIR, "broker.pid");
|
||||
|
||||
interface ConnectedSession {
|
||||
socket: net.Socket;
|
||||
info: SessionInfo;
|
||||
}
|
||||
|
||||
function isAttachment(value: unknown): value is Attachment {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachment = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
attachment.type !== "file"
|
||||
&& attachment.type !== "snippet"
|
||||
&& attachment.type !== "context"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return attachment.language === undefined || typeof attachment.language === "string";
|
||||
}
|
||||
|
||||
function isMessage(value: unknown): value is Message {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = value as Record<string, unknown>;
|
||||
|
||||
if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof message.content !== "object" || message.content === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = message.content as Record<string, unknown>;
|
||||
if (typeof content.text !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return content.attachments === undefined
|
||||
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
|
||||
}
|
||||
|
||||
function isSessionRegistration(value: unknown): value is Omit<SessionInfo, "id"> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof session.cwd !== "string"
|
||||
|| typeof session.model !== "string"
|
||||
|| typeof session.pid !== "number"
|
||||
|| typeof session.startedAt !== "number"
|
||||
|| typeof session.lastActivity !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.name !== undefined && typeof session.name !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.status === undefined || typeof session.status === "string";
|
||||
}
|
||||
|
||||
class IntercomBroker {
|
||||
private sessions = new Map<string, ConnectedSession>();
|
||||
private server: net.Server;
|
||||
private shutdownTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
mkdirSync(INTERCOM_DIR, { recursive: true });
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
unlinkSync(SOCKET_PATH);
|
||||
} catch {
|
||||
// A clean startup has no stale socket to remove.
|
||||
}
|
||||
}
|
||||
this.server = net.createServer(this.handleConnection.bind(this));
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.server.listen(SOCKET_PATH, () => {
|
||||
writeFileSync(PID_PATH, String(process.pid));
|
||||
console.log(`Intercom broker started (pid: ${process.pid})`);
|
||||
});
|
||||
process.on("SIGTERM", () => this.shutdown());
|
||||
process.on("SIGINT", () => this.shutdown());
|
||||
}
|
||||
|
||||
private handleConnection(socket: net.Socket): void {
|
||||
let sessionId: string | null = null;
|
||||
|
||||
const reader = createMessageReader((msg) => {
|
||||
this.handleMessage(socket, msg, sessionId, (id) => {
|
||||
sessionId = id;
|
||||
});
|
||||
}, (error) => {
|
||||
socket.destroy(error);
|
||||
});
|
||||
|
||||
socket.on("data", reader);
|
||||
|
||||
socket.on("close", () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.broadcast({ type: "session_left", sessionId }, sessionId);
|
||||
|
||||
this.scheduleShutdownCheck();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (error) => {
|
||||
console.error("Socket error:", error);
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleShutdownCheck(): void {
|
||||
if (this.shutdownTimer) return;
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
if (this.sessions.size === 0) {
|
||||
console.log("No sessions connected, shutting down");
|
||||
this.shutdown();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
socket: net.Socket,
|
||||
msg: unknown,
|
||||
currentId: string | null,
|
||||
setId: (id: string | null) => void,
|
||||
): void {
|
||||
if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
|
||||
throw new Error("Invalid client message");
|
||||
}
|
||||
|
||||
const clientMessage = msg as { type: string } & Record<string, unknown>;
|
||||
|
||||
if (currentId === null && clientMessage.type !== "register") {
|
||||
throw new Error(`Received ${clientMessage.type} before register`);
|
||||
}
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case "register": {
|
||||
if (!isSessionRegistration(clientMessage.session)) {
|
||||
throw new Error("Invalid register message");
|
||||
}
|
||||
|
||||
if (currentId) {
|
||||
throw new Error("Received duplicate register message");
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
setId(id);
|
||||
const info: SessionInfo = { ...clientMessage.session, id };
|
||||
this.sessions.set(id, { socket, info });
|
||||
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
|
||||
writeMessage(socket, { type: "registered", sessionId: id });
|
||||
this.broadcast({ type: "session_joined", session: info }, id);
|
||||
break;
|
||||
}
|
||||
|
||||
case "unregister": {
|
||||
this.sessions.delete(currentId);
|
||||
this.broadcast({ type: "session_left", sessionId: currentId }, currentId);
|
||||
setId(null);
|
||||
this.scheduleShutdownCheck();
|
||||
break;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
if (typeof clientMessage.requestId !== "string") {
|
||||
throw new Error("Invalid list message");
|
||||
}
|
||||
|
||||
const sessions = Array.from(this.sessions.values()).map(s => s.info);
|
||||
writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions });
|
||||
break;
|
||||
}
|
||||
|
||||
case "send": {
|
||||
const message = clientMessage.message;
|
||||
const messageId = isMessage(message) ? message.id : "unknown";
|
||||
|
||||
if (typeof clientMessage.to !== "string" || !isMessage(message)) {
|
||||
writeMessage(socket, {
|
||||
type: "delivery_failed",
|
||||
messageId,
|
||||
reason: "Invalid message format",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const targets = this.findSessions(clientMessage.to);
|
||||
if (targets.length === 1) {
|
||||
const fromSession = this.sessions.get(currentId);
|
||||
if (!fromSession) {
|
||||
writeMessage(socket, {
|
||||
type: "delivery_failed",
|
||||
messageId: message.id,
|
||||
reason: "Sender session not found",
|
||||
});
|
||||
break;
|
||||
}
|
||||
writeMessage(targets[0].socket, {
|
||||
type: "message",
|
||||
from: fromSession.info,
|
||||
message,
|
||||
});
|
||||
writeMessage(socket, { type: "delivered", messageId: message.id });
|
||||
break;
|
||||
}
|
||||
|
||||
if (targets.length > 1) {
|
||||
writeMessage(socket, {
|
||||
type: "delivery_failed",
|
||||
messageId: message.id,
|
||||
reason: `Multiple sessions named \"${clientMessage.to}\" are connected. Use the session ID instead.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
writeMessage(socket, {
|
||||
type: "delivery_failed",
|
||||
messageId: message.id,
|
||||
reason: "Session not found",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "presence": {
|
||||
const session = this.sessions.get(currentId);
|
||||
if (session) {
|
||||
if (clientMessage.name !== undefined) {
|
||||
if (typeof clientMessage.name !== "string") {
|
||||
throw new Error("Invalid presence name");
|
||||
}
|
||||
session.info.name = clientMessage.name;
|
||||
}
|
||||
if (clientMessage.status !== undefined) {
|
||||
if (typeof clientMessage.status !== "string") {
|
||||
throw new Error("Invalid presence status");
|
||||
}
|
||||
session.info.status = clientMessage.status;
|
||||
}
|
||||
if (clientMessage.model !== undefined) {
|
||||
if (typeof clientMessage.model !== "string") {
|
||||
throw new Error("Invalid presence model");
|
||||
}
|
||||
session.info.model = clientMessage.model;
|
||||
}
|
||||
session.info.lastActivity = Date.now();
|
||||
this.broadcast({ type: "presence_update", session: session.info }, currentId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown client message type: ${clientMessage.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private findSessions(nameOrId: string): ConnectedSession[] {
|
||||
const byId = this.sessions.get(nameOrId);
|
||||
if (byId) {
|
||||
return [byId];
|
||||
}
|
||||
|
||||
const lowerName = nameOrId.toLowerCase();
|
||||
return Array.from(this.sessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
|
||||
}
|
||||
|
||||
private broadcast(msg: BrokerMessage, exclude?: string): void {
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (id !== exclude) {
|
||||
writeMessage(session.socket, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shutdown(): void {
|
||||
console.log("Broker shutting down");
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
session.socket.end();
|
||||
}
|
||||
this.sessions.clear();
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
unlinkSync(SOCKET_PATH);
|
||||
} catch {
|
||||
// The socket may already be gone if shutdown started after a disconnect.
|
||||
}
|
||||
}
|
||||
try {
|
||||
unlinkSync(PID_PATH);
|
||||
} catch {
|
||||
// The PID file may already be gone if startup never completed.
|
||||
}
|
||||
this.server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
new IntercomBroker().start();
|
||||
535
extensions/pi-intercom/broker/client.ts
Normal file
535
extensions/pi-intercom/broker/client.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { EventEmitter } from "events";
|
||||
import net from "net";
|
||||
import { randomUUID } from "crypto";
|
||||
import { writeMessage, createMessageReader } from "./framing.js";
|
||||
import { getBrokerSocketPath } from "./paths.js";
|
||||
import type { SessionInfo, Message, Attachment } from "../types.js";
|
||||
|
||||
const BROKER_SOCKET = getBrokerSocketPath();
|
||||
|
||||
interface SendOptions {
|
||||
text: string;
|
||||
attachments?: Attachment[];
|
||||
replyTo?: string;
|
||||
expectsReply?: boolean;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
id: string;
|
||||
delivered: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function isAttachment(value: unknown): value is Attachment {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachment = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
attachment.type !== "file"
|
||||
&& attachment.type !== "snippet"
|
||||
&& attachment.type !== "context"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return attachment.language === undefined || typeof attachment.language === "string";
|
||||
}
|
||||
|
||||
function isMessage(value: unknown): value is Message {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = value as Record<string, unknown>;
|
||||
|
||||
if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof message.content !== "object" || message.content === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = message.content as Record<string, unknown>;
|
||||
if (typeof content.text !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return content.attachments === undefined
|
||||
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
|
||||
}
|
||||
|
||||
function isSessionInfo(value: unknown): value is SessionInfo {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof session.id !== "string"
|
||||
|| typeof session.cwd !== "string"
|
||||
|| typeof session.model !== "string"
|
||||
|| typeof session.pid !== "number"
|
||||
|| typeof session.startedAt !== "number"
|
||||
|| typeof session.lastActivity !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.name !== undefined && typeof session.name !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.status === undefined || typeof session.status === "string";
|
||||
}
|
||||
|
||||
export class IntercomClient extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private _sessionId: string | null = null;
|
||||
private pendingSends = new Map<string, { resolve: (r: SendResult) => void; reject: (e: Error) => void }>();
|
||||
private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
|
||||
private disconnecting = false;
|
||||
private disconnectError: Error | null = null;
|
||||
|
||||
private failPending(error: Error): void {
|
||||
for (const pending of this.pendingSends.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingSends.clear();
|
||||
for (const pending of this.pendingLists.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingLists.clear();
|
||||
}
|
||||
|
||||
get sessionId(): string | null {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
const socket = this.socket;
|
||||
return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
|
||||
}
|
||||
|
||||
private requireActiveSocket(): net.Socket {
|
||||
if (this.disconnecting) {
|
||||
throw new Error("Client disconnecting");
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
if (!socket || !this._sessionId) {
|
||||
throw new Error("Not connected");
|
||||
}
|
||||
|
||||
if (socket.destroyed || socket.writableEnded || !socket.writable) {
|
||||
throw new Error("Client disconnected");
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
connect(session: Omit<SessionInfo, "id">): Promise<void> {
|
||||
if (this.socket) {
|
||||
return Promise.reject(new Error("Already connected"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(BROKER_SOCKET);
|
||||
this.socket = socket;
|
||||
this.disconnectError = null;
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this._sessionId) {
|
||||
cleanupConnectionAttempt();
|
||||
cleanupSocketListeners();
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
socket.destroy();
|
||||
reject(new Error("Connection timeout"));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
let connectionEstablished = false;
|
||||
|
||||
const onRegistered = () => {
|
||||
settled = true;
|
||||
connectionEstablished = true;
|
||||
cleanupConnectionAttempt();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
settled = true;
|
||||
cleanupConnectionAttempt();
|
||||
cleanupSocketListeners();
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
const wasConnecting = !settled && !this._sessionId;
|
||||
const wasDisconnecting = this.disconnecting;
|
||||
const disconnectError = this.disconnectError ?? new Error("Client disconnected");
|
||||
this.disconnecting = false;
|
||||
cleanupConnectionAttempt();
|
||||
cleanupSocketListeners();
|
||||
this.failPending(disconnectError);
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
this._sessionId = null;
|
||||
this.disconnectError = null;
|
||||
if (connectionEstablished && !wasDisconnecting) {
|
||||
this.emit("disconnected", disconnectError);
|
||||
}
|
||||
if (wasConnecting) {
|
||||
reject(new Error("Connection closed before registration"));
|
||||
}
|
||||
};
|
||||
|
||||
const onSocketError = (err: Error) => {
|
||||
if (connectionEstablished) {
|
||||
this.disconnectError = err;
|
||||
this.emit("error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const onReaderError = (error: Error) => {
|
||||
const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
|
||||
if (!connectionEstablished) {
|
||||
onError(protocolError);
|
||||
return;
|
||||
}
|
||||
this.disconnectError = protocolError;
|
||||
this.emit("error", protocolError);
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
const reader = createMessageReader((msg) => {
|
||||
this.handleBrokerMessage(msg);
|
||||
}, onReaderError);
|
||||
|
||||
const cleanupConnectionAttempt = () => {
|
||||
this.off("_registered", onRegistered);
|
||||
socket.off("error", onError);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
const cleanupSocketListeners = () => {
|
||||
socket.off("data", reader);
|
||||
socket.off("error", onSocketError);
|
||||
socket.off("close", onClose);
|
||||
};
|
||||
|
||||
socket.on("data", reader);
|
||||
socket.on("error", onError);
|
||||
socket.on("close", onClose);
|
||||
|
||||
socket.on("error", onSocketError);
|
||||
this.once("_registered", onRegistered);
|
||||
|
||||
try {
|
||||
writeMessage(socket, { type: "register", session });
|
||||
} catch (error) {
|
||||
cleanupConnectionAttempt();
|
||||
cleanupSocketListeners();
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
socket.destroy();
|
||||
reject(toError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleBrokerMessage(msg: unknown): void {
|
||||
if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
|
||||
throw new Error("Invalid broker message");
|
||||
}
|
||||
|
||||
const brokerMessage = msg as { type: string } & Record<string, unknown>;
|
||||
|
||||
if (this._sessionId === null && brokerMessage.type !== "registered") {
|
||||
throw new Error(`Received ${brokerMessage.type} before registered`);
|
||||
}
|
||||
|
||||
switch (brokerMessage.type) {
|
||||
case "registered": {
|
||||
if (typeof brokerMessage.sessionId !== "string") {
|
||||
throw new Error("Invalid registered message");
|
||||
}
|
||||
|
||||
if (this._sessionId !== null) {
|
||||
throw new Error("Received duplicate registered message");
|
||||
}
|
||||
|
||||
this._sessionId = brokerMessage.sessionId;
|
||||
this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessions": {
|
||||
const { requestId, sessions } = brokerMessage;
|
||||
if (typeof requestId !== "string" || !Array.isArray(sessions) || !sessions.every(isSessionInfo)) {
|
||||
throw new Error("Invalid sessions message");
|
||||
}
|
||||
|
||||
const pending = this.pendingLists.get(requestId);
|
||||
if (!pending) {
|
||||
// Late list responses can still arrive after the caller has already timed out.
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingLists.delete(requestId);
|
||||
pending.resolve(sessions);
|
||||
break;
|
||||
}
|
||||
|
||||
case "message": {
|
||||
const { from, message } = brokerMessage;
|
||||
if (!isSessionInfo(from) || !isMessage(message)) {
|
||||
throw new Error("Invalid message event");
|
||||
}
|
||||
|
||||
this.emit("message", from, message);
|
||||
break;
|
||||
}
|
||||
|
||||
case "delivered": {
|
||||
const { messageId } = brokerMessage;
|
||||
if (typeof messageId !== "string") {
|
||||
throw new Error("Invalid delivered message");
|
||||
}
|
||||
|
||||
const pending = this.pendingSends.get(messageId);
|
||||
if (!pending) {
|
||||
// Late send responses are harmless once the caller has already timed out.
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSends.delete(messageId);
|
||||
pending.resolve({ id: messageId, delivered: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case "delivery_failed": {
|
||||
const { messageId, reason } = brokerMessage;
|
||||
if (typeof messageId !== "string" || typeof reason !== "string") {
|
||||
throw new Error("Invalid delivery_failed message");
|
||||
}
|
||||
|
||||
const pending = this.pendingSends.get(messageId);
|
||||
if (!pending) {
|
||||
// Late send responses are harmless once the caller has already timed out.
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSends.delete(messageId);
|
||||
pending.resolve({ id: messageId, delivered: false, reason });
|
||||
break;
|
||||
}
|
||||
|
||||
case "session_joined": {
|
||||
if (!isSessionInfo(brokerMessage.session)) {
|
||||
throw new Error("Invalid session_joined message");
|
||||
}
|
||||
|
||||
this.emit("session_joined", brokerMessage.session);
|
||||
break;
|
||||
}
|
||||
|
||||
case "session_left": {
|
||||
if (typeof brokerMessage.sessionId !== "string") {
|
||||
throw new Error("Invalid session_left message");
|
||||
}
|
||||
|
||||
this.emit("session_left", brokerMessage.sessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "presence_update": {
|
||||
if (!isSessionInfo(brokerMessage.session)) {
|
||||
throw new Error("Invalid presence_update message");
|
||||
}
|
||||
|
||||
this.emit("presence_update", brokerMessage.session);
|
||||
break;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
if (typeof brokerMessage.error !== "string") {
|
||||
throw new Error("Invalid error message");
|
||||
}
|
||||
|
||||
this.emit("error", new Error(brokerMessage.error));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const socket = this.socket;
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnecting = true;
|
||||
this.disconnectError = null;
|
||||
this.failPending(new Error("Client disconnected"));
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
socket.off("close", onClose);
|
||||
socket.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onClose = () => finish();
|
||||
const onError = () => {
|
||||
socket.destroy();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 2000);
|
||||
|
||||
socket.once("close", onClose);
|
||||
socket.once("error", onError);
|
||||
|
||||
try {
|
||||
writeMessage(socket, { type: "unregister" });
|
||||
socket.end();
|
||||
} catch {
|
||||
// Disconnect should still finish even if the unregister write fails.
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
listSessions(): Promise<SessionInfo[]> {
|
||||
let socket: net.Socket;
|
||||
try {
|
||||
socket = this.requireActiveSocket();
|
||||
} catch (error) {
|
||||
return Promise.reject(toError(error));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = randomUUID();
|
||||
const wrappedResolve = (sessions: SessionInfo[]) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(sessions);
|
||||
};
|
||||
const wrappedReject = (error: Error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingLists.has(requestId)) {
|
||||
this.pendingLists.delete(requestId);
|
||||
wrappedReject(new Error("List sessions timeout"));
|
||||
}
|
||||
}, 5000);
|
||||
this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
|
||||
try {
|
||||
writeMessage(socket, { type: "list", requestId });
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pendingLists.delete(requestId);
|
||||
reject(toError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(to: string, options: SendOptions): Promise<SendResult> {
|
||||
let socket: net.Socket;
|
||||
try {
|
||||
socket = this.requireActiveSocket();
|
||||
} catch (error) {
|
||||
return Promise.reject(toError(error));
|
||||
}
|
||||
|
||||
const messageId = options.messageId ?? randomUUID();
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
timestamp: Date.now(),
|
||||
replyTo: options.replyTo,
|
||||
expectsReply: options.expectsReply,
|
||||
content: {
|
||||
text: options.text,
|
||||
attachments: options.attachments,
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const wrappedResolve = (result: SendResult) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
};
|
||||
const wrappedReject = (error: Error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingSends.has(messageId)) {
|
||||
this.pendingSends.delete(messageId);
|
||||
wrappedReject(new Error("Send timeout"));
|
||||
}
|
||||
}, 10000);
|
||||
this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
|
||||
|
||||
try {
|
||||
writeMessage(socket, { type: "send", to, message });
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pendingSends.delete(messageId);
|
||||
reject(toError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updatePresence(updates: { name?: string; status?: string; model?: string }): void {
|
||||
if (this.disconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeMessage(socket, { type: "presence", ...updates });
|
||||
}
|
||||
}
|
||||
57
extensions/pi-intercom/broker/framing.ts
Normal file
57
extensions/pi-intercom/broker/framing.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Socket } from "net";
|
||||
|
||||
/**
|
||||
* Write a length-prefixed message to a socket.
|
||||
* Format: 4-byte big-endian length + JSON payload
|
||||
*/
|
||||
export function writeMessage(socket: Socket, msg: unknown): void {
|
||||
const json = JSON.stringify(msg);
|
||||
const payload = Buffer.from(json, "utf-8");
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(payload.length, 0);
|
||||
socket.write(Buffer.concat([header, payload]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message reader that handles partial reads.
|
||||
* Calls onMessage for each complete message received.
|
||||
* Protocol or handler errors are reported to onError so the caller can close the socket.
|
||||
*/
|
||||
export function createMessageReader(
|
||||
onMessage: (msg: unknown) => void,
|
||||
onError: (error: Error) => void,
|
||||
) {
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
return (data: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, data]);
|
||||
|
||||
while (buffer.length >= 4) {
|
||||
const length = buffer.readUInt32BE(0);
|
||||
|
||||
if (buffer.length < 4 + length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = buffer.subarray(4, 4 + length);
|
||||
buffer = buffer.subarray(4 + length);
|
||||
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = JSON.parse(payload.toString("utf-8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onMessage(msg);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
15
extensions/pi-intercom/broker/paths.test.ts
Normal file
15
extensions/pi-intercom/broker/paths.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getBrokerSocketPath } from "./paths.js";
|
||||
|
||||
test("getBrokerSocketPath uses named pipe on Windows", () => {
|
||||
const pipePath = getBrokerSocketPath("win32", "C:/Users/rcroh");
|
||||
assert.match(pipePath, /^\\\\\.\\pipe\\pi-intercom-/);
|
||||
assert.doesNotMatch(pipePath, /broker\.sock$/);
|
||||
});
|
||||
|
||||
test("getBrokerSocketPath uses broker.sock on non-Windows", () => {
|
||||
const socketPath = getBrokerSocketPath("linux", "/home/rcroh");
|
||||
assert.match(socketPath, /broker\.sock$/);
|
||||
assert.match(socketPath, /rcroh/);
|
||||
});
|
||||
20
extensions/pi-intercom/broker/paths.ts
Normal file
20
extensions/pi-intercom/broker/paths.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
function sanitizePipeSegment(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "default";
|
||||
}
|
||||
|
||||
export function getBrokerSocketPath(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
homeDir: string = homedir(),
|
||||
): string {
|
||||
if (platform === "win32") {
|
||||
return `\\\\.\\pipe\\pi-intercom-${sanitizePipeSegment(homeDir)}`;
|
||||
}
|
||||
|
||||
return join(homeDir, ".pi/agent/intercom/broker.sock");
|
||||
}
|
||||
111
extensions/pi-intercom/broker/spawn.test.ts
Normal file
111
extensions/pi-intercom/broker/spawn.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
getBrokerLaunchSpec,
|
||||
getBrokerSpawnOptions,
|
||||
getTsxCliPath,
|
||||
getWindowsHiddenLauncherScript,
|
||||
getWindowsBrokerCommandLine,
|
||||
getWindowsHiddenLauncherPath,
|
||||
} from "./spawn.js";
|
||||
|
||||
test("getTsxCliPath points at local tsx cli", () => {
|
||||
const cliPath = getTsxCliPath("C:/repo");
|
||||
assert.equal(cliPath, path.join("C:/repo", "node_modules", "tsx", "dist", "cli.mjs"));
|
||||
});
|
||||
|
||||
test("getWindowsHiddenLauncherPath points at the broker launcher script", () => {
|
||||
const launcherPath = getWindowsHiddenLauncherPath("C:/tmp/intercom");
|
||||
assert.equal(launcherPath, path.join("C:/tmp/intercom", "broker-launch.vbs"));
|
||||
});
|
||||
|
||||
test("getWindowsBrokerCommandLine wraps node, tsx cli, and broker path", () => {
|
||||
const commandLine = getWindowsBrokerCommandLine(
|
||||
"C:/repo/broker.ts",
|
||||
"C:/repo",
|
||||
"C:/Program Files/nodejs/node.exe",
|
||||
);
|
||||
assert.equal(
|
||||
commandLine,
|
||||
`"C:/Program Files/nodejs/node.exe" "${path.join("C:/repo", "node_modules", "tsx", "dist", "cli.mjs")}" "C:/repo/broker.ts"`,
|
||||
);
|
||||
});
|
||||
|
||||
test("getWindowsHiddenLauncherScript runs the broker command without showing a console", () => {
|
||||
const script = getWindowsHiddenLauncherScript('"C:/Program Files/nodejs/node.exe" "C:/repo/node_modules/tsx/dist/cli.mjs" "C:/repo/broker.ts"');
|
||||
assert.match(script, /WshShell\.Run/);
|
||||
assert.match(script, /, 0, False/);
|
||||
});
|
||||
|
||||
test("getBrokerLaunchSpec uses wscript launcher on Windows without writing files", () => {
|
||||
const intercomDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-"));
|
||||
|
||||
try {
|
||||
const spec = getBrokerLaunchSpec(
|
||||
"C:/repo/broker.ts",
|
||||
"npx",
|
||||
["--no-install", "tsx"],
|
||||
"C:/repo",
|
||||
"win32",
|
||||
intercomDir,
|
||||
"C:/Program Files/nodejs/node.exe",
|
||||
);
|
||||
assert.equal(spec.command, "wscript.exe");
|
||||
assert.deepEqual(spec.args, [path.join(intercomDir, "broker-launch.vbs")]);
|
||||
assert.equal(spec.kind, "windows-launcher");
|
||||
assert.equal(spec.launcherCommandLine, `"C:/Program Files/nodejs/node.exe" "${path.join("C:/repo", "node_modules", "tsx", "dist", "cli.mjs")}" "C:/repo/broker.ts"`);
|
||||
assert.equal(existsSync(path.join(intercomDir, "broker-launch.vbs")), false);
|
||||
} finally {
|
||||
rmSync(intercomDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getBrokerLaunchSpec uses custom broker command on Windows", () => {
|
||||
const intercomDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-"));
|
||||
|
||||
try {
|
||||
const spec = getBrokerLaunchSpec("C:/repo/broker.ts", "bun", ["--smol"], "C:/repo", "win32", intercomDir, "C:/Program Files/nodejs/node.exe");
|
||||
assert.equal(spec.command, "wscript.exe");
|
||||
assert.equal(spec.kind, "windows-launcher");
|
||||
assert.equal(spec.launcherCommandLine, `"bun" "--smol" "C:/repo/broker.ts"`);
|
||||
} finally {
|
||||
rmSync(intercomDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getBrokerLaunchSpec uses npx + tsx on non-Windows", () => {
|
||||
const spec = getBrokerLaunchSpec("C:/repo/broker.ts", "npx", ["--no-install", "tsx"], "C:/repo", "linux", "/tmp/intercom", "/usr/bin/node");
|
||||
assert.equal(spec.command, "npx");
|
||||
assert.deepEqual(spec.args, [
|
||||
"--no-install",
|
||||
"tsx",
|
||||
"C:/repo/broker.ts",
|
||||
]);
|
||||
assert.equal(spec.kind, "direct");
|
||||
});
|
||||
|
||||
test("getBrokerLaunchSpec uses custom broker command on non-Windows", () => {
|
||||
const spec = getBrokerLaunchSpec("/repo/broker.ts", "bun", [], "/repo", "linux", "/tmp/intercom", "/usr/bin/node");
|
||||
assert.equal(spec.command, "bun");
|
||||
assert.deepEqual(spec.args, ["/repo/broker.ts"]);
|
||||
assert.equal(spec.kind, "direct");
|
||||
});
|
||||
|
||||
test("getBrokerSpawnOptions hides the broker console window on Windows", () => {
|
||||
const options = getBrokerSpawnOptions("C:/repo");
|
||||
assert.equal(options.windowsHide, true);
|
||||
assert.equal(options.detached, true);
|
||||
assert.equal(options.stdio, "ignore");
|
||||
assert.equal(options.cwd, "C:/repo");
|
||||
});
|
||||
|
||||
test("getBrokerSpawnOptions keeps portable defaults on non-Windows platforms", () => {
|
||||
const options = getBrokerSpawnOptions("/repo");
|
||||
assert.equal(options.windowsHide, true);
|
||||
assert.equal(options.detached, true);
|
||||
assert.equal(options.stdio, "ignore");
|
||||
assert.equal(options.cwd, "/repo");
|
||||
});
|
||||
307
extensions/pi-intercom/broker/spawn.ts
Normal file
307
extensions/pi-intercom/broker/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { homedir } from "os";
|
||||
import net from "net";
|
||||
import { getBrokerSocketPath } from "./paths.js";
|
||||
|
||||
const INTERCOM_DIR = join(homedir(), ".pi/agent/intercom");
|
||||
const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const BROKER_SOCKET = getBrokerSocketPath();
|
||||
const BROKER_PID = join(INTERCOM_DIR, "broker.pid");
|
||||
const BROKER_SPAWN_LOCK = join(INTERCOM_DIR, "broker.spawn.lock");
|
||||
|
||||
type BrokerLaunchSpec =
|
||||
| {
|
||||
kind: "direct";
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
| {
|
||||
kind: "windows-launcher";
|
||||
command: string;
|
||||
args: string[];
|
||||
launcherPath: string;
|
||||
launcherCommandLine: string;
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function getTsxCliPath(extensionDir: string = EXTENSION_DIR): string {
|
||||
return join(extensionDir, "node_modules", "tsx", "dist", "cli.mjs");
|
||||
}
|
||||
|
||||
function quoteWindowsArg(value: string): string {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function getWindowsHiddenLauncherPath(intercomDir: string = INTERCOM_DIR): string {
|
||||
return join(intercomDir, "broker-launch.vbs");
|
||||
}
|
||||
|
||||
function usesDefaultBrokerCommand(brokerCommand: string, brokerArgs: string[]): boolean {
|
||||
return brokerCommand === "npx"
|
||||
&& brokerArgs.length === 2
|
||||
&& brokerArgs[0] === "--no-install"
|
||||
&& brokerArgs[1] === "tsx";
|
||||
}
|
||||
|
||||
export function getWindowsBrokerCommandLine(
|
||||
brokerPath: string,
|
||||
extensionDir: string = EXTENSION_DIR,
|
||||
nodePath: string = process.execPath,
|
||||
brokerCommand = "npx",
|
||||
brokerArgs: string[] = ["--no-install", "tsx"],
|
||||
): string {
|
||||
if (usesDefaultBrokerCommand(brokerCommand, brokerArgs)) {
|
||||
return [quoteWindowsArg(nodePath), quoteWindowsArg(getTsxCliPath(extensionDir)), quoteWindowsArg(brokerPath)].join(" ");
|
||||
}
|
||||
|
||||
return [quoteWindowsArg(brokerCommand), ...brokerArgs.map(quoteWindowsArg), quoteWindowsArg(brokerPath)].join(" ");
|
||||
}
|
||||
|
||||
export function getWindowsHiddenLauncherScript(commandLine: string): string {
|
||||
return [
|
||||
'Set WshShell = CreateObject("WScript.Shell")',
|
||||
`WshShell.Run "${commandLine.replace(/"/g, '""')}", 0, False`,
|
||||
'Set WshShell = Nothing',
|
||||
'',
|
||||
].join("\r\n");
|
||||
}
|
||||
|
||||
function writeWindowsHiddenLauncher(
|
||||
commandLine: string,
|
||||
launcherPath: string = getWindowsHiddenLauncherPath(),
|
||||
): string {
|
||||
mkdirSync(dirname(launcherPath), { recursive: true });
|
||||
writeFileSync(launcherPath, getWindowsHiddenLauncherScript(commandLine), "utf-8");
|
||||
return launcherPath;
|
||||
}
|
||||
|
||||
export function getBrokerLaunchSpec(
|
||||
brokerPath: string,
|
||||
brokerCommand: string,
|
||||
brokerArgs: string[],
|
||||
extensionDir: string = EXTENSION_DIR,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
intercomDir: string = INTERCOM_DIR,
|
||||
nodePath: string = process.execPath,
|
||||
): BrokerLaunchSpec {
|
||||
if (platform === "win32") {
|
||||
const launcherPath = getWindowsHiddenLauncherPath(intercomDir);
|
||||
return {
|
||||
kind: "windows-launcher",
|
||||
command: "wscript.exe",
|
||||
args: [launcherPath],
|
||||
launcherPath,
|
||||
launcherCommandLine: getWindowsBrokerCommandLine(brokerPath, extensionDir, nodePath, brokerCommand, brokerArgs),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "direct",
|
||||
command: brokerCommand,
|
||||
args: [...brokerArgs, brokerPath],
|
||||
};
|
||||
}
|
||||
|
||||
export function getBrokerSpawnOptions(extensionDir: string = EXTENSION_DIR): {
|
||||
detached: true;
|
||||
stdio: "ignore";
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
windowsHide: true;
|
||||
} {
|
||||
return {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
cwd: extensionDir,
|
||||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export async function spawnBrokerIfNeeded(brokerCommand: string, brokerArgs: string[]): Promise<void> {
|
||||
mkdirSync(INTERCOM_DIR, { recursive: true });
|
||||
|
||||
if (await isBrokerRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ownsLock = acquireSpawnLock();
|
||||
if (!ownsLock) {
|
||||
await waitForBroker();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await isBrokerRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "broker.ts");
|
||||
const launch = getBrokerLaunchSpec(brokerPath, brokerCommand, brokerArgs);
|
||||
if (launch.kind === "windows-launcher") {
|
||||
writeWindowsHiddenLauncher(launch.launcherCommandLine, launch.launcherPath);
|
||||
}
|
||||
const child = spawn(launch.command, launch.args, getBrokerSpawnOptions());
|
||||
child.unref();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
child.off("error", onError);
|
||||
child.off("exit", onExit);
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to spawn intercom broker: ${error.message}`, { cause: error }));
|
||||
};
|
||||
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (launch.kind === "windows-launcher" && code === 0 && signal === null) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
if (signal) {
|
||||
reject(new Error(`Intercom broker exited before startup with signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Intercom broker exited before startup with code ${code ?? "unknown"}`));
|
||||
};
|
||||
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
waitForBroker().then(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, (error) => {
|
||||
cleanup();
|
||||
reject(toError(error));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
releaseSpawnLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function isBrokerRunning(): Promise<boolean> {
|
||||
if (await checkSocketConnectable()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsSync(BROKER_PID)) return false;
|
||||
|
||||
try {
|
||||
const pid = parseInt(readFileSync(BROKER_PID, "utf-8").trim(), 10);
|
||||
if (!Number.isFinite(pid)) return false;
|
||||
process.kill(pid, 0);
|
||||
return checkSocketConnectable();
|
||||
} catch {
|
||||
// Missing or unreadable PID state means there is no live broker to reuse.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkSocketConnectable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(BROKER_SOCKET);
|
||||
const finish = (isConnected: boolean) => {
|
||||
clearTimeout(timeout);
|
||||
socket.off("connect", onConnect);
|
||||
socket.off("error", onError);
|
||||
resolve(isConnected);
|
||||
};
|
||||
const onConnect = () => {
|
||||
socket.end();
|
||||
finish(true);
|
||||
};
|
||||
const onError = () => {
|
||||
socket.destroy();
|
||||
finish(false);
|
||||
};
|
||||
socket.on("connect", onConnect);
|
||||
socket.on("error", onError);
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
finish(false);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function acquireSpawnLock(): boolean {
|
||||
const maxRetries = 5;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
if (isSpawnLockStale()) {
|
||||
try {
|
||||
unlinkSync(BROKER_SPAWN_LOCK);
|
||||
} catch {
|
||||
// If we can't delete the stale lock, retry a few times before giving up
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSpawnLockStale(): boolean {
|
||||
if (!existsSync(BROKER_SPAWN_LOCK)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8").trim().split("\n");
|
||||
const pid = Number.parseInt(pidLine, 10);
|
||||
const createdAt = Number.parseInt(createdAtLine, 10);
|
||||
const ageMs = Date.now() - createdAt;
|
||||
|
||||
if (Number.isFinite(pid)) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
// The process that created the lock is gone.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return !Number.isFinite(createdAt) || ageMs > 10_000;
|
||||
} catch {
|
||||
// Unreadable lock contents are treated as stale so a new broker can start.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseSpawnLock(): void {
|
||||
try {
|
||||
unlinkSync(BROKER_SPAWN_LOCK);
|
||||
} catch {
|
||||
// Another cleanup path may already have removed the lock.
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBroker(timeoutMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await checkSocketConnectable()) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error("Broker failed to start within timeout");
|
||||
}
|
||||
Reference in New Issue
Block a user