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,535 @@
1
+ import { EventEmitter } from "events";
2
+ import net from "net";
3
+ import { randomUUID } from "crypto";
4
+ import { writeMessage, createMessageReader } from "./framing.js";
5
+ import { getBrokerSocketPath } from "./paths.js";
6
+ import type { SessionInfo, Message, Attachment } from "../types.js";
7
+
8
+ const BROKER_SOCKET = getBrokerSocketPath();
9
+
10
+ interface SendOptions {
11
+ text: string;
12
+ attachments?: Attachment[];
13
+ replyTo?: string;
14
+ expectsReply?: boolean;
15
+ messageId?: string;
16
+ }
17
+
18
+ interface SendResult {
19
+ id: string;
20
+ delivered: boolean;
21
+ reason?: string;
22
+ }
23
+
24
+ function toError(error: unknown): Error {
25
+ return error instanceof Error ? error : new Error(String(error));
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 isSessionInfo(value: unknown): value is SessionInfo {
83
+ if (typeof value !== "object" || value === null) {
84
+ return false;
85
+ }
86
+
87
+ const session = value as Record<string, unknown>;
88
+
89
+ if (
90
+ typeof session.id !== "string"
91
+ || typeof session.cwd !== "string"
92
+ || typeof session.model !== "string"
93
+ || typeof session.pid !== "number"
94
+ || typeof session.startedAt !== "number"
95
+ || typeof session.lastActivity !== "number"
96
+ ) {
97
+ return false;
98
+ }
99
+
100
+ if (session.name !== undefined && typeof session.name !== "string") {
101
+ return false;
102
+ }
103
+
104
+ return session.status === undefined || typeof session.status === "string";
105
+ }
106
+
107
+ export class IntercomClient extends EventEmitter {
108
+ private socket: net.Socket | null = null;
109
+ private _sessionId: string | null = null;
110
+ private pendingSends = new Map<string, { resolve: (r: SendResult) => void; reject: (e: Error) => void }>();
111
+ private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
112
+ private disconnecting = false;
113
+ private disconnectError: Error | null = null;
114
+
115
+ private failPending(error: Error): void {
116
+ for (const pending of this.pendingSends.values()) {
117
+ pending.reject(error);
118
+ }
119
+ this.pendingSends.clear();
120
+ for (const pending of this.pendingLists.values()) {
121
+ pending.reject(error);
122
+ }
123
+ this.pendingLists.clear();
124
+ }
125
+
126
+ get sessionId(): string | null {
127
+ return this._sessionId;
128
+ }
129
+
130
+ isConnected(): boolean {
131
+ const socket = this.socket;
132
+ return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
133
+ }
134
+
135
+ private requireActiveSocket(): net.Socket {
136
+ if (this.disconnecting) {
137
+ throw new Error("Client disconnecting");
138
+ }
139
+
140
+ const socket = this.socket;
141
+ if (!socket || !this._sessionId) {
142
+ throw new Error("Not connected");
143
+ }
144
+
145
+ if (socket.destroyed || socket.writableEnded || !socket.writable) {
146
+ throw new Error("Client disconnected");
147
+ }
148
+
149
+ return socket;
150
+ }
151
+
152
+ connect(session: Omit<SessionInfo, "id">): Promise<void> {
153
+ if (this.socket) {
154
+ return Promise.reject(new Error("Already connected"));
155
+ }
156
+
157
+ return new Promise((resolve, reject) => {
158
+ const socket = net.connect(BROKER_SOCKET);
159
+ this.socket = socket;
160
+ this.disconnectError = null;
161
+ let settled = false;
162
+ const timeout = setTimeout(() => {
163
+ if (!this._sessionId) {
164
+ cleanupConnectionAttempt();
165
+ cleanupSocketListeners();
166
+ if (this.socket === socket) {
167
+ this.socket = null;
168
+ }
169
+ socket.destroy();
170
+ reject(new Error("Connection timeout"));
171
+ }
172
+ }, 10000);
173
+
174
+ let connectionEstablished = false;
175
+
176
+ const onRegistered = () => {
177
+ settled = true;
178
+ connectionEstablished = true;
179
+ cleanupConnectionAttempt();
180
+ resolve();
181
+ };
182
+
183
+ const onError = (err: Error) => {
184
+ settled = true;
185
+ cleanupConnectionAttempt();
186
+ cleanupSocketListeners();
187
+ if (this.socket === socket) {
188
+ this.socket = null;
189
+ }
190
+ socket.destroy();
191
+ reject(err);
192
+ };
193
+
194
+ const onClose = () => {
195
+ const wasConnecting = !settled && !this._sessionId;
196
+ const wasDisconnecting = this.disconnecting;
197
+ const disconnectError = this.disconnectError ?? new Error("Client disconnected");
198
+ this.disconnecting = false;
199
+ cleanupConnectionAttempt();
200
+ cleanupSocketListeners();
201
+ this.failPending(disconnectError);
202
+ if (this.socket === socket) {
203
+ this.socket = null;
204
+ }
205
+ this._sessionId = null;
206
+ this.disconnectError = null;
207
+ if (connectionEstablished && !wasDisconnecting) {
208
+ this.emit("disconnected", disconnectError);
209
+ }
210
+ if (wasConnecting) {
211
+ reject(new Error("Connection closed before registration"));
212
+ }
213
+ };
214
+
215
+ const onSocketError = (err: Error) => {
216
+ if (connectionEstablished) {
217
+ this.disconnectError = err;
218
+ this.emit("error", err);
219
+ }
220
+ };
221
+
222
+ const onReaderError = (error: Error) => {
223
+ const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
224
+ if (!connectionEstablished) {
225
+ onError(protocolError);
226
+ return;
227
+ }
228
+ this.disconnectError = protocolError;
229
+ this.emit("error", protocolError);
230
+ socket.destroy();
231
+ };
232
+
233
+ const reader = createMessageReader((msg) => {
234
+ this.handleBrokerMessage(msg);
235
+ }, onReaderError);
236
+
237
+ const cleanupConnectionAttempt = () => {
238
+ this.off("_registered", onRegistered);
239
+ socket.off("error", onError);
240
+ clearTimeout(timeout);
241
+ };
242
+
243
+ const cleanupSocketListeners = () => {
244
+ socket.off("data", reader);
245
+ socket.off("error", onSocketError);
246
+ socket.off("close", onClose);
247
+ };
248
+
249
+ socket.on("data", reader);
250
+ socket.on("error", onError);
251
+ socket.on("close", onClose);
252
+
253
+ socket.on("error", onSocketError);
254
+ this.once("_registered", onRegistered);
255
+
256
+ try {
257
+ writeMessage(socket, { type: "register", session });
258
+ } catch (error) {
259
+ cleanupConnectionAttempt();
260
+ cleanupSocketListeners();
261
+ if (this.socket === socket) {
262
+ this.socket = null;
263
+ }
264
+ socket.destroy();
265
+ reject(toError(error));
266
+ }
267
+ });
268
+ }
269
+
270
+ private handleBrokerMessage(msg: unknown): void {
271
+ if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
272
+ throw new Error("Invalid broker message");
273
+ }
274
+
275
+ const brokerMessage = msg as { type: string } & Record<string, unknown>;
276
+
277
+ if (this._sessionId === null && brokerMessage.type !== "registered") {
278
+ throw new Error(`Received ${brokerMessage.type} before registered`);
279
+ }
280
+
281
+ switch (brokerMessage.type) {
282
+ case "registered": {
283
+ if (typeof brokerMessage.sessionId !== "string") {
284
+ throw new Error("Invalid registered message");
285
+ }
286
+
287
+ if (this._sessionId !== null) {
288
+ throw new Error("Received duplicate registered message");
289
+ }
290
+
291
+ this._sessionId = brokerMessage.sessionId;
292
+ this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
293
+ break;
294
+ }
295
+
296
+ case "sessions": {
297
+ const { requestId, sessions } = brokerMessage;
298
+ if (typeof requestId !== "string" || !Array.isArray(sessions) || !sessions.every(isSessionInfo)) {
299
+ throw new Error("Invalid sessions message");
300
+ }
301
+
302
+ const pending = this.pendingLists.get(requestId);
303
+ if (!pending) {
304
+ // Late list responses can still arrive after the caller has already timed out.
305
+ return;
306
+ }
307
+
308
+ this.pendingLists.delete(requestId);
309
+ pending.resolve(sessions);
310
+ break;
311
+ }
312
+
313
+ case "message": {
314
+ const { from, message } = brokerMessage;
315
+ if (!isSessionInfo(from) || !isMessage(message)) {
316
+ throw new Error("Invalid message event");
317
+ }
318
+
319
+ this.emit("message", from, message);
320
+ break;
321
+ }
322
+
323
+ case "delivered": {
324
+ const { messageId } = brokerMessage;
325
+ if (typeof messageId !== "string") {
326
+ throw new Error("Invalid delivered message");
327
+ }
328
+
329
+ const pending = this.pendingSends.get(messageId);
330
+ if (!pending) {
331
+ // Late send responses are harmless once the caller has already timed out.
332
+ return;
333
+ }
334
+
335
+ this.pendingSends.delete(messageId);
336
+ pending.resolve({ id: messageId, delivered: true });
337
+ break;
338
+ }
339
+
340
+ case "delivery_failed": {
341
+ const { messageId, reason } = brokerMessage;
342
+ if (typeof messageId !== "string" || typeof reason !== "string") {
343
+ throw new Error("Invalid delivery_failed message");
344
+ }
345
+
346
+ const pending = this.pendingSends.get(messageId);
347
+ if (!pending) {
348
+ // Late send responses are harmless once the caller has already timed out.
349
+ return;
350
+ }
351
+
352
+ this.pendingSends.delete(messageId);
353
+ pending.resolve({ id: messageId, delivered: false, reason });
354
+ break;
355
+ }
356
+
357
+ case "session_joined": {
358
+ if (!isSessionInfo(brokerMessage.session)) {
359
+ throw new Error("Invalid session_joined message");
360
+ }
361
+
362
+ this.emit("session_joined", brokerMessage.session);
363
+ break;
364
+ }
365
+
366
+ case "session_left": {
367
+ if (typeof brokerMessage.sessionId !== "string") {
368
+ throw new Error("Invalid session_left message");
369
+ }
370
+
371
+ this.emit("session_left", brokerMessage.sessionId);
372
+ break;
373
+ }
374
+
375
+ case "presence_update": {
376
+ if (!isSessionInfo(brokerMessage.session)) {
377
+ throw new Error("Invalid presence_update message");
378
+ }
379
+
380
+ this.emit("presence_update", brokerMessage.session);
381
+ break;
382
+ }
383
+
384
+ case "error": {
385
+ if (typeof brokerMessage.error !== "string") {
386
+ throw new Error("Invalid error message");
387
+ }
388
+
389
+ this.emit("error", new Error(brokerMessage.error));
390
+ break;
391
+ }
392
+
393
+ default:
394
+ throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
395
+ }
396
+ }
397
+
398
+ async disconnect(): Promise<void> {
399
+ const socket = this.socket;
400
+ if (!socket) {
401
+ return;
402
+ }
403
+
404
+ this.disconnecting = true;
405
+ this.disconnectError = null;
406
+ this.failPending(new Error("Client disconnected"));
407
+
408
+ await new Promise<void>((resolve) => {
409
+ let settled = false;
410
+ const finish = () => {
411
+ if (settled) {
412
+ return;
413
+ }
414
+ settled = true;
415
+ clearTimeout(timeout);
416
+ socket.off("close", onClose);
417
+ socket.off("error", onError);
418
+ resolve();
419
+ };
420
+ const onClose = () => finish();
421
+ const onError = () => {
422
+ socket.destroy();
423
+ };
424
+ const timeout = setTimeout(() => {
425
+ socket.destroy();
426
+ }, 2000);
427
+
428
+ socket.once("close", onClose);
429
+ socket.once("error", onError);
430
+
431
+ try {
432
+ writeMessage(socket, { type: "unregister" });
433
+ socket.end();
434
+ } catch {
435
+ // Disconnect should still finish even if the unregister write fails.
436
+ socket.destroy();
437
+ }
438
+ });
439
+ }
440
+
441
+ listSessions(): Promise<SessionInfo[]> {
442
+ let socket: net.Socket;
443
+ try {
444
+ socket = this.requireActiveSocket();
445
+ } catch (error) {
446
+ return Promise.reject(toError(error));
447
+ }
448
+
449
+ return new Promise((resolve, reject) => {
450
+ const requestId = randomUUID();
451
+ const wrappedResolve = (sessions: SessionInfo[]) => {
452
+ clearTimeout(timeout);
453
+ resolve(sessions);
454
+ };
455
+ const wrappedReject = (error: Error) => {
456
+ clearTimeout(timeout);
457
+ reject(error);
458
+ };
459
+ const timeout = setTimeout(() => {
460
+ if (this.pendingLists.has(requestId)) {
461
+ this.pendingLists.delete(requestId);
462
+ wrappedReject(new Error("List sessions timeout"));
463
+ }
464
+ }, 5000);
465
+ this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
466
+ try {
467
+ writeMessage(socket, { type: "list", requestId });
468
+ } catch (error) {
469
+ clearTimeout(timeout);
470
+ this.pendingLists.delete(requestId);
471
+ reject(toError(error));
472
+ }
473
+ });
474
+ }
475
+
476
+ send(to: string, options: SendOptions): Promise<SendResult> {
477
+ let socket: net.Socket;
478
+ try {
479
+ socket = this.requireActiveSocket();
480
+ } catch (error) {
481
+ return Promise.reject(toError(error));
482
+ }
483
+
484
+ const messageId = options.messageId ?? randomUUID();
485
+ const message: Message = {
486
+ id: messageId,
487
+ timestamp: Date.now(),
488
+ replyTo: options.replyTo,
489
+ expectsReply: options.expectsReply,
490
+ content: {
491
+ text: options.text,
492
+ attachments: options.attachments,
493
+ },
494
+ };
495
+
496
+ return new Promise((resolve, reject) => {
497
+ const wrappedResolve = (result: SendResult) => {
498
+ clearTimeout(timeout);
499
+ resolve(result);
500
+ };
501
+ const wrappedReject = (error: Error) => {
502
+ clearTimeout(timeout);
503
+ reject(error);
504
+ };
505
+ const timeout = setTimeout(() => {
506
+ if (this.pendingSends.has(messageId)) {
507
+ this.pendingSends.delete(messageId);
508
+ wrappedReject(new Error("Send timeout"));
509
+ }
510
+ }, 10000);
511
+ this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
512
+
513
+ try {
514
+ writeMessage(socket, { type: "send", to, message });
515
+ } catch (error) {
516
+ clearTimeout(timeout);
517
+ this.pendingSends.delete(messageId);
518
+ reject(toError(error));
519
+ }
520
+ });
521
+ }
522
+
523
+ updatePresence(updates: { name?: string; status?: string; model?: string }): void {
524
+ if (this.disconnecting) {
525
+ return;
526
+ }
527
+
528
+ const socket = this.socket;
529
+ if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
530
+ return;
531
+ }
532
+
533
+ writeMessage(socket, { type: "presence", ...updates });
534
+ }
535
+ }
@@ -0,0 +1,73 @@
1
+ import type { Socket } from "net";
2
+
3
+ export const MAX_FRAME_BYTES = 4 * 1024 * 1024;
4
+
5
+ /**
6
+ * Write a length-prefixed message to a socket.
7
+ * Format: 4-byte big-endian length + JSON payload
8
+ */
9
+ export function writeMessage(socket: Socket, msg: unknown): void {
10
+ const json = JSON.stringify(msg);
11
+ const payload = Buffer.from(json, "utf-8");
12
+ if (payload.length > MAX_FRAME_BYTES) {
13
+ throw new Error(`Intercom message exceeds ${MAX_FRAME_BYTES} byte limit`);
14
+ }
15
+ const header = Buffer.alloc(4);
16
+ header.writeUInt32BE(payload.length, 0);
17
+ socket.write(Buffer.concat([header, payload]));
18
+ }
19
+
20
+ /**
21
+ * Create a message reader that handles partial reads.
22
+ * Calls onMessage for each complete message received.
23
+ * Protocol or handler errors are reported to onError so the caller can close the socket.
24
+ */
25
+ export function createMessageReader(
26
+ onMessage: (msg: unknown) => void,
27
+ onError: (error: Error) => void,
28
+ ) {
29
+ let buffer = Buffer.alloc(0);
30
+
31
+ return (data: Buffer) => {
32
+ buffer = Buffer.concat([buffer, data]);
33
+
34
+ while (buffer.length >= 4) {
35
+ const length = buffer.readUInt32BE(0);
36
+ if (length > MAX_FRAME_BYTES) {
37
+ buffer = Buffer.alloc(0);
38
+ onError(new Error(`Intercom frame exceeds ${MAX_FRAME_BYTES} byte limit`));
39
+ return;
40
+ }
41
+
42
+ if (buffer.length > 4 + MAX_FRAME_BYTES) {
43
+ buffer = Buffer.alloc(0);
44
+ onError(new Error(`Intercom frame buffer exceeds ${MAX_FRAME_BYTES} byte limit`));
45
+ return;
46
+ }
47
+
48
+ if (buffer.length < 4 + length) {
49
+ break;
50
+ }
51
+
52
+ const payload = buffer.subarray(4, 4 + length);
53
+ buffer = buffer.subarray(4 + length);
54
+
55
+ let msg: unknown;
56
+ try {
57
+ msg = JSON.parse(payload.toString("utf-8"));
58
+ } catch (error) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
61
+ return;
62
+ }
63
+
64
+ try {
65
+ onMessage(msg);
66
+ } catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
69
+ return;
70
+ }
71
+ }
72
+ };
73
+ }
@@ -0,0 +1,20 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+
4
+ function sanitizePipeSegment(value: string): string {
5
+ return value
6
+ .replace(/[^a-zA-Z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "")
8
+ .toLowerCase() || "default";
9
+ }
10
+
11
+ export function getBrokerSocketPath(
12
+ platform: NodeJS.Platform = process.platform,
13
+ homeDir: string = homedir(),
14
+ ): string {
15
+ if (platform === "win32") {
16
+ return `\\\\.\\pipe\\pi-intercom-${sanitizePipeSegment(homeDir)}`;
17
+ }
18
+
19
+ return join(homeDir, ".pi/agent/intercom/broker.sock");
20
+ }