Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View 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();

View 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 });
}
}

View 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;
}
}
};
}

View 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/);
});

View 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");
}

View 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");
});

View 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");
}