pi-gang 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ import net from "net";
2
+ import { chmodSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import { randomUUID } from "crypto";
6
+ import { writeMessage, createMessageReader } from "./framing.js";
7
+ import { getBrokerSocketPath } from "./paths.js";
8
+ import { appendIntercomLog } from "./tap.js";
9
+ import { startGuiServer, recentFeed, type GuiServer } from "../gui/server.js";
10
+ import type { SessionInfo, Message, Attachment, BrokerMessage } from "../types.js";
11
+
12
+ const INTERCOM_DIR = join(homedir(), ".pi/agent/intercom");
13
+ const SOCKET_PATH = getBrokerSocketPath();
14
+ const PID_PATH = join(INTERCOM_DIR, "broker.pid");
15
+
16
+ interface ConnectedSession {
17
+ socket: net.Socket;
18
+ info: SessionInfo;
19
+ }
20
+
21
+ function requireRegisteredSessionId(currentId: string | null, messageType: string): string {
22
+ if (currentId === null) {
23
+ throw new Error(`Received ${messageType} before register`);
24
+ }
25
+ return currentId;
26
+ }
27
+
28
+ function isAttachment(value: unknown): value is Attachment {
29
+ if (typeof value !== "object" || value === null) {
30
+ return false;
31
+ }
32
+
33
+ const attachment = value as Record<string, unknown>;
34
+
35
+ if (
36
+ attachment.type !== "file"
37
+ && attachment.type !== "snippet"
38
+ && attachment.type !== "context"
39
+ ) {
40
+ return false;
41
+ }
42
+
43
+ if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
44
+ return false;
45
+ }
46
+
47
+ return attachment.language === undefined || typeof attachment.language === "string";
48
+ }
49
+
50
+ function isMessage(value: unknown): value is Message {
51
+ if (typeof value !== "object" || value === null) {
52
+ return false;
53
+ }
54
+
55
+ const message = value as Record<string, unknown>;
56
+
57
+ if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
58
+ return false;
59
+ }
60
+
61
+ if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
62
+ return false;
63
+ }
64
+
65
+ if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
66
+ return false;
67
+ }
68
+
69
+ if (typeof message.content !== "object" || message.content === null) {
70
+ return false;
71
+ }
72
+
73
+ const content = message.content as Record<string, unknown>;
74
+ if (typeof content.text !== "string") {
75
+ return false;
76
+ }
77
+
78
+ return content.attachments === undefined
79
+ || (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
80
+ }
81
+
82
+ function isSessionRegistration(value: unknown): value is Omit<SessionInfo, "id"> {
83
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
84
+ return false;
85
+ }
86
+
87
+ const session = value as Record<string, unknown>;
88
+
89
+ if (
90
+ typeof session.cwd !== "string"
91
+ || typeof session.model !== "string"
92
+ || typeof session.pid !== "number"
93
+ || typeof session.startedAt !== "number"
94
+ || typeof session.lastActivity !== "number"
95
+ ) {
96
+ return false;
97
+ }
98
+
99
+ if (session.name !== undefined && typeof session.name !== "string") {
100
+ return false;
101
+ }
102
+
103
+ return session.status === undefined || typeof session.status === "string";
104
+ }
105
+
106
+ const RECENT_FEED_LIMIT = 50;
107
+
108
+ class IntercomBroker {
109
+ private sessions = new Map<string, ConnectedSession>();
110
+ private recentFeed: Array<Record<string, unknown>> = [];
111
+ private server: net.Server;
112
+ private gui: GuiServer;
113
+ private shutdownTimer: NodeJS.Timeout | null = null;
114
+
115
+ constructor() {
116
+ mkdirSync(INTERCOM_DIR, { recursive: true, mode: 0o700 });
117
+ if (process.platform !== "win32") chmodSync(INTERCOM_DIR, 0o700);
118
+ if (process.platform !== "win32") {
119
+ try {
120
+ unlinkSync(SOCKET_PATH);
121
+ } catch {
122
+ // A clean startup has no stale socket to remove.
123
+ }
124
+ }
125
+ this.server = net.createServer(this.handleConnection.bind(this));
126
+ this.gui = startGuiServer({
127
+ getSnapshot: () => ({
128
+ sessions: Array.from(this.sessions.values()).map((s) => s.info),
129
+ feed: recentFeed(this.recentFeed, Date.now()),
130
+ }),
131
+ });
132
+ }
133
+
134
+ start(): void {
135
+ this.server.listen(SOCKET_PATH, () => {
136
+ if (process.platform !== "win32") chmodSync(SOCKET_PATH, 0o600);
137
+ writeFileSync(PID_PATH, String(process.pid), { mode: 0o600 });
138
+ if (process.platform !== "win32") chmodSync(PID_PATH, 0o600);
139
+ console.log(`Intercom broker started (pid: ${process.pid})`);
140
+ });
141
+ process.on("SIGTERM", () => this.shutdown());
142
+ process.on("SIGINT", () => this.shutdown());
143
+ }
144
+
145
+ private handleConnection(socket: net.Socket): void {
146
+ let sessionId: string | null = null;
147
+
148
+ const reader = createMessageReader((msg) => {
149
+ this.handleMessage(socket, msg, sessionId, (id) => {
150
+ sessionId = id;
151
+ });
152
+ }, (error) => {
153
+ socket.destroy(error);
154
+ });
155
+
156
+ socket.on("data", reader);
157
+
158
+ socket.on("close", () => {
159
+ if (sessionId) {
160
+ this.sessions.delete(sessionId);
161
+ this.broadcast({ type: "session_left", sessionId }, sessionId);
162
+
163
+ this.scheduleShutdownCheck();
164
+ }
165
+ });
166
+
167
+ socket.on("error", (error) => {
168
+ console.error("Socket error:", error);
169
+ });
170
+ }
171
+
172
+ private scheduleShutdownCheck(): void {
173
+ if (this.shutdownTimer) return;
174
+
175
+ this.shutdownTimer = setTimeout(() => {
176
+ this.shutdownTimer = null;
177
+ if (this.sessions.size === 0) {
178
+ console.log("No sessions connected, shutting down");
179
+ this.shutdown();
180
+ }
181
+ }, 5000);
182
+ }
183
+
184
+ private handleMessage(
185
+ socket: net.Socket,
186
+ msg: unknown,
187
+ currentId: string | null,
188
+ setId: (id: string | null) => void,
189
+ ): void {
190
+ if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
191
+ throw new Error("Invalid client message");
192
+ }
193
+
194
+ const clientMessage = msg as { type: string } & Record<string, unknown>;
195
+
196
+ if (currentId === null && clientMessage.type !== "register") {
197
+ throw new Error(`Received ${clientMessage.type} before register`);
198
+ }
199
+
200
+ switch (clientMessage.type) {
201
+ case "register": {
202
+ if (!isSessionRegistration(clientMessage.session)) {
203
+ throw new Error("Invalid register message");
204
+ }
205
+
206
+ if (currentId) {
207
+ throw new Error("Received duplicate register message");
208
+ }
209
+
210
+ const id = randomUUID();
211
+ setId(id);
212
+ const info: SessionInfo = { ...clientMessage.session, id };
213
+ this.sessions.set(id, { socket, info });
214
+
215
+ if (this.shutdownTimer) {
216
+ clearTimeout(this.shutdownTimer);
217
+ this.shutdownTimer = null;
218
+ }
219
+
220
+ writeMessage(socket, { type: "registered", sessionId: id });
221
+ this.broadcast({ type: "session_joined", session: info }, id);
222
+ break;
223
+ }
224
+
225
+ case "unregister": {
226
+ const sessionId = requireRegisteredSessionId(currentId, clientMessage.type);
227
+ this.sessions.delete(sessionId);
228
+ this.broadcast({ type: "session_left", sessionId }, sessionId);
229
+ setId(null);
230
+ this.scheduleShutdownCheck();
231
+ break;
232
+ }
233
+
234
+ case "list": {
235
+ if (typeof clientMessage.requestId !== "string") {
236
+ throw new Error("Invalid list message");
237
+ }
238
+
239
+ const sessions = Array.from(this.sessions.values()).map(s => s.info);
240
+ writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions });
241
+ break;
242
+ }
243
+
244
+ case "send": {
245
+ const message = clientMessage.message;
246
+ const messageId = isMessage(message) ? message.id : "unknown";
247
+
248
+ if (typeof clientMessage.to !== "string" || !isMessage(message)) {
249
+ writeMessage(socket, {
250
+ type: "delivery_failed",
251
+ messageId,
252
+ reason: "Invalid message format",
253
+ });
254
+ break;
255
+ }
256
+
257
+ const targets = this.findSessions(clientMessage.to);
258
+ if (targets.length === 1) {
259
+ const sessionId = requireRegisteredSessionId(currentId, clientMessage.type);
260
+ const fromSession = this.sessions.get(sessionId);
261
+ if (!fromSession) {
262
+ writeMessage(socket, {
263
+ type: "delivery_failed",
264
+ messageId: message.id,
265
+ reason: "Sender session not found",
266
+ });
267
+ break;
268
+ }
269
+ writeMessage(targets[0].socket, {
270
+ type: "message",
271
+ from: fromSession.info,
272
+ message,
273
+ });
274
+ writeMessage(socket, { type: "delivered", messageId: message.id });
275
+ // Same hook feeds both observability layers: durable log + live GUI feed.
276
+ const entry = {
277
+ ts: Date.now(),
278
+ id: message.id,
279
+ from: fromSession.info.name ?? fromSession.info.id,
280
+ to: targets[0].info.name ?? targets[0].info.id,
281
+ text: message.content.text,
282
+ replyTo: message.replyTo,
283
+ expectsReply: message.expectsReply,
284
+ };
285
+ appendIntercomLog(entry);
286
+ const feedEvent = { type: "message", ...entry };
287
+ this.recentFeed.push(feedEvent);
288
+ if (this.recentFeed.length > RECENT_FEED_LIMIT) this.recentFeed.shift();
289
+ this.gui.broadcast(feedEvent);
290
+ break;
291
+ }
292
+
293
+ if (targets.length > 1) {
294
+ writeMessage(socket, {
295
+ type: "delivery_failed",
296
+ messageId: message.id,
297
+ reason: `Multiple sessions named \"${clientMessage.to}\" are connected. Use the session ID instead.`,
298
+ });
299
+ break;
300
+ }
301
+
302
+ writeMessage(socket, {
303
+ type: "delivery_failed",
304
+ messageId: message.id,
305
+ reason: "Session not found",
306
+ });
307
+ break;
308
+ }
309
+
310
+ case "presence": {
311
+ const sessionId = requireRegisteredSessionId(currentId, clientMessage.type);
312
+ const session = this.sessions.get(sessionId);
313
+ if (session) {
314
+ if (clientMessage.name !== undefined) {
315
+ if (typeof clientMessage.name !== "string") {
316
+ throw new Error("Invalid presence name");
317
+ }
318
+ session.info.name = clientMessage.name;
319
+ }
320
+ if (clientMessage.status !== undefined) {
321
+ if (typeof clientMessage.status !== "string") {
322
+ throw new Error("Invalid presence status");
323
+ }
324
+ session.info.status = clientMessage.status;
325
+ }
326
+ if (clientMessage.model !== undefined) {
327
+ if (typeof clientMessage.model !== "string") {
328
+ throw new Error("Invalid presence model");
329
+ }
330
+ session.info.model = clientMessage.model;
331
+ }
332
+ session.info.lastActivity = Date.now();
333
+ this.broadcast({ type: "presence_update", session: session.info }, sessionId);
334
+ }
335
+ break;
336
+ }
337
+
338
+ default:
339
+ throw new Error(`Unknown client message type: ${clientMessage.type}`);
340
+ }
341
+ }
342
+
343
+ private findSessions(nameOrId: string): ConnectedSession[] {
344
+ const byId = this.sessions.get(nameOrId);
345
+ if (byId) {
346
+ return [byId];
347
+ }
348
+
349
+ const lowerName = nameOrId.toLowerCase();
350
+ return Array.from(this.sessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
351
+ }
352
+
353
+ private broadcast(msg: BrokerMessage, exclude?: string): void {
354
+ for (const [id, session] of this.sessions) {
355
+ if (id !== exclude) {
356
+ writeMessage(session.socket, msg);
357
+ }
358
+ }
359
+ // session_joined / session_left / presence_update all flow through here → mirror to browsers.
360
+ this.gui.broadcast(msg as unknown as Record<string, unknown>);
361
+ }
362
+
363
+ private shutdown(): void {
364
+ console.log("Broker shutting down");
365
+
366
+ for (const session of this.sessions.values()) {
367
+ session.socket.end();
368
+ }
369
+ this.sessions.clear();
370
+ this.gui.close();
371
+ if (process.platform !== "win32") {
372
+ try {
373
+ unlinkSync(SOCKET_PATH);
374
+ } catch {
375
+ // The socket may already be gone if shutdown started after a disconnect.
376
+ }
377
+ }
378
+ try {
379
+ unlinkSync(PID_PATH);
380
+ } catch {
381
+ // The PID file may already be gone if startup never completed.
382
+ }
383
+ this.server.close();
384
+ process.exit(0);
385
+ }
386
+ }
387
+
388
+ new IntercomBroker().start();