pi-ui-extend 0.1.17 → 0.1.18

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,419 @@
1
+ /**
2
+ * IPC for telegram-mirror: Unix socket with JSON-lines framing, leader
3
+ * election via bind-or-connect, and request-response correlation.
4
+ *
5
+ * Why IPC: Telegram allows exactly one concurrent getUpdates per bot token.
6
+ * With N pi instances sharing one bot, exactly one process must poll and
7
+ * route. The first pi to start becomes leader; later pis connect as
8
+ * followers and forward their events to the leader. If the leader dies,
9
+ * followers detect (heartbeat timeout / socket close) and the next to
10
+ * acquire wins.
11
+ *
12
+ * Wire protocol (one JSON object per line, UTF-8):
13
+ *
14
+ * Handshake
15
+ * follower → leader: { type: "register", info: InstanceInfo }
16
+ * leader → follower: { type: "registered", leader: InstanceInfo, activeId: string|null }
17
+ *
18
+ * Heartbeat (symmetric)
19
+ * any → any: { type: "ping", t: <ms> }
20
+ * any → any: { type: "pong", t: <ms> }
21
+ *
22
+ * Events (follower → leader)
23
+ * { type: "event", from: "<id>", event: <RendererEvent JSON> }
24
+ *
25
+ * Commands (leader → follower) — request/ack
26
+ * { type: "command", reqId, to, command: "sendUserMessage"|"abort"|"compact", args? }
27
+ * { type: "command_ack", reqId, ok: true|false, error? }
28
+ *
29
+ * Queries (leader → follower) — request/reply
30
+ * { type: "query", reqId, to, query: "status" }
31
+ * { type: "query_reply", reqId, ok: true|false, result?, error? }
32
+ *
33
+ * Heartbeat: each side pings every 8s; closes after 20s without any traffic.
34
+ *
35
+ * Leader election:
36
+ * 1. Try `net.createServer().listen(socketPath)`.
37
+ * - Success → leader.
38
+ * - EADDRINUSE → existing socket, try connect.
39
+ * 2. Try `net.createConnection(socketPath)` with 1.5s timeout.
40
+ * - Success → follower.
41
+ * - Any failure → stale socket.
42
+ * 3. Unlink and retry listen once.
43
+ * 4. Give up.
44
+ */
45
+
46
+ import * as fs from "node:fs";
47
+ import * as net from "node:net";
48
+ import * as os from "node:os";
49
+ import * as path from "node:path";
50
+
51
+ export interface InstanceInfo {
52
+ /** Stable unique id, e.g. `${pid}@${cwd}`. */
53
+ id: string;
54
+ /** OS pid. */
55
+ pid: number;
56
+ /** Absolute working dir of this pi process. */
57
+ cwd: string;
58
+ /** Human-friendly label, e.g. basename of cwd + short pid suffix. */
59
+ label: string;
60
+ /** ms-since-epoch when this instance started. */
61
+ started: number;
62
+ }
63
+
64
+ export type IpcMessage =
65
+ | { type: "register"; info: InstanceInfo }
66
+ | { type: "registered"; leader: InstanceInfo; activeId: string | null }
67
+ | { type: "ping"; t: number }
68
+ | { type: "pong"; t: number }
69
+ | { type: "event"; from: string; event: unknown }
70
+ | { type: "command"; reqId: string; to: string; command: string; args?: unknown }
71
+ | { type: "command_ack"; reqId: string; ok: boolean; error?: string }
72
+ | { type: "query"; reqId: string; to: string; query: string }
73
+ | { type: "query_reply"; reqId: string; ok: boolean; result?: unknown; error?: string }
74
+ | { type: "stand_down" };
75
+
76
+ export const DEFAULT_SOCKET_PATH = path.join(
77
+ os.homedir(),
78
+ ".pi",
79
+ "agent",
80
+ "extensions",
81
+ "pi-tools-suite",
82
+ ".run",
83
+ "telegram-mirror.sock",
84
+ );
85
+
86
+ const PING_INTERVAL_MS = 8_000;
87
+ const IDLE_TIMEOUT_MS = 20_000;
88
+ const CONNECT_TIMEOUT_MS = 1_500;
89
+ const REQUEST_TIMEOUT_MS = 5_000;
90
+
91
+ interface PendingRequest {
92
+ resolve: (msg: IpcMessage) => void;
93
+ reject: (error: Error) => void;
94
+ timer: NodeJS.Timeout;
95
+ }
96
+
97
+ /**
98
+ * JSON-lines framing over a single TCP/unix socket.
99
+ *
100
+ * Sends one IpcMessage per `\n`-terminated JSON line. Tracks pending
101
+ * request-response pairs by reqId for command/query correlation. Maintains
102
+ * bidirectional heartbeat; closes the socket on prolonged silence.
103
+ */
104
+ export class IpcSocket {
105
+ public readonly role: "leader" | "follower";
106
+ public readonly info: InstanceInfo | undefined;
107
+
108
+ public onMessage?: (msg: IpcMessage) => void;
109
+ public onClose?: () => void;
110
+ public onError?: (error: Error) => void;
111
+
112
+ private readonly socket: net.Socket;
113
+ private buffer = "";
114
+ private closed = false;
115
+ private readonly pendingRequests = new Map<string, PendingRequest>();
116
+ private pingTimer: NodeJS.Timeout | undefined;
117
+ private watchdogTimer: NodeJS.Timeout | undefined;
118
+
119
+ constructor(socket: net.Socket, role: "leader" | "follower", info?: InstanceInfo) {
120
+ this.socket = socket;
121
+ this.role = role;
122
+ this.info = info;
123
+ socket.setEncoding("utf8");
124
+ socket.setNoDelay(true);
125
+ socket.on("data", (chunk: string | Buffer) => {
126
+ if (this.closed) return;
127
+ this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
128
+ this.consumeBuffer();
129
+ });
130
+ socket.on("error", (error) => {
131
+ if (this.closed) return;
132
+ this.onError?.(error);
133
+ });
134
+ socket.on("close", () => this.handleClose());
135
+
136
+ this.resetWatchdog();
137
+ this.pingTimer = setInterval(() => {
138
+ if (!this.closed) this.send({ type: "ping", t: Date.now() });
139
+ }, PING_INTERVAL_MS);
140
+ }
141
+
142
+ send(msg: IpcMessage): void {
143
+ if (this.closed) return;
144
+ this.socket.write(`${JSON.stringify(msg)}\n`);
145
+ }
146
+
147
+ /**
148
+ * Send a request and await its ack/reply (matched on reqId).
149
+ * Resolves on the matching IpcMessage; rejects on timeout or socket close.
150
+ */
151
+ request(msg: IpcMessage, timeoutMs = REQUEST_TIMEOUT_MS): Promise<IpcMessage> {
152
+ const reqId = (msg as { reqId?: string }).reqId;
153
+ if (!reqId) return Promise.reject(new Error("IpcSocket.request: message must have reqId"));
154
+ return new Promise<IpcMessage>((resolve, reject) => {
155
+ const timer = setTimeout(() => {
156
+ this.pendingRequests.delete(reqId);
157
+ reject(new Error(`IPC request ${reqId} timed out`));
158
+ }, timeoutMs);
159
+ this.pendingRequests.set(reqId, { resolve, reject, timer });
160
+ this.send(msg);
161
+ });
162
+ }
163
+
164
+ close(): void {
165
+ if (this.closed) return;
166
+ this.closed = true;
167
+ this.clearTimers();
168
+ try {
169
+ this.socket.destroy();
170
+ } catch {
171
+ // ignore
172
+ }
173
+ }
174
+
175
+ get isClosed(): boolean {
176
+ return this.closed;
177
+ }
178
+
179
+ private consumeBuffer(): void {
180
+ let newlineIdx = this.buffer.indexOf("\n");
181
+ while (newlineIdx >= 0) {
182
+ const line = this.buffer.slice(0, newlineIdx).trim();
183
+ this.buffer = this.buffer.slice(newlineIdx + 1);
184
+ newlineIdx = this.buffer.indexOf("\n");
185
+ if (!line) continue;
186
+ let parsed: unknown;
187
+ try {
188
+ parsed = JSON.parse(line);
189
+ } catch {
190
+ continue;
191
+ }
192
+ if (!parsed || typeof parsed !== "object") continue;
193
+ this.handleIncoming(parsed as IpcMessage);
194
+ }
195
+ }
196
+
197
+ private handleIncoming(msg: IpcMessage): void {
198
+ this.resetWatchdog();
199
+
200
+ // Auto-reply to ping so the other side's watchdog resets.
201
+ if (msg.type === "ping") {
202
+ this.send({ type: "pong", t: msg.t });
203
+ // Don't forward pings to upper layer.
204
+ return;
205
+ }
206
+ if (msg.type === "pong") {
207
+ // Don't forward pongs either.
208
+ return;
209
+ }
210
+
211
+ // Resolve pending request if this is an ack/reply.
212
+ const reqId = (msg as { reqId?: string }).reqId;
213
+ if (reqId && (msg.type === "command_ack" || msg.type === "query_reply")) {
214
+ const pending = this.pendingRequests.get(reqId);
215
+ if (pending) {
216
+ this.pendingRequests.delete(reqId);
217
+ clearTimeout(pending.timer);
218
+ pending.resolve(msg);
219
+ return;
220
+ }
221
+ }
222
+
223
+ this.onMessage?.(msg);
224
+ }
225
+
226
+ private resetWatchdog(): void {
227
+ if (this.watchdogTimer) clearTimeout(this.watchdogTimer);
228
+ this.watchdogTimer = setTimeout(() => {
229
+ // Idle too long — force close so the other side triggers
230
+ // reconnect/election.
231
+ this.close();
232
+ }, IDLE_TIMEOUT_MS);
233
+ }
234
+
235
+ private handleClose(): void {
236
+ if (this.closed && !this.pingTimer) return; // already cleaned up via close()
237
+ this.closed = true;
238
+ this.clearTimers();
239
+ for (const pending of this.pendingRequests.values()) {
240
+ pending.reject(new Error("socket closed"));
241
+ }
242
+ this.pendingRequests.clear();
243
+ this.onClose?.();
244
+ }
245
+
246
+ private clearTimers(): void {
247
+ if (this.pingTimer) {
248
+ clearInterval(this.pingTimer);
249
+ this.pingTimer = undefined;
250
+ }
251
+ if (this.watchdogTimer) {
252
+ clearTimeout(this.watchdogTimer);
253
+ this.watchdogTimer = undefined;
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Leader-side accept loop. Tracks every connected follower socket.
260
+ * The multiplexer attaches `onMessage`/`onClose` callbacks after the
261
+ * follower registers and we know its InstanceInfo.
262
+ */
263
+ export class IpcServer {
264
+ public readonly socketPath: string;
265
+ public onFollowerConnect?: (socket: IpcSocket) => void;
266
+ public onClose?: () => void;
267
+
268
+ private readonly server: net.Server;
269
+ private readonly sockets = new Set<IpcSocket>();
270
+ private closed = false;
271
+
272
+ constructor(server: net.Server, socketPath: string) {
273
+ this.server = server;
274
+ this.socketPath = socketPath;
275
+ server.on("connection", (raw: net.Socket) => {
276
+ const ipcSocket = new IpcSocket(raw, "leader");
277
+ this.sockets.add(ipcSocket);
278
+ ipcSocket.onClose = () => {
279
+ this.sockets.delete(ipcSocket);
280
+ };
281
+ this.onFollowerConnect?.(ipcSocket);
282
+ });
283
+ server.on("error", () => {
284
+ // server socket errors are unexpected; we'll let the upper layer
285
+ // observe via the close event.
286
+ });
287
+ server.on("close", () => {
288
+ this.closed = true;
289
+ this.onClose?.();
290
+ });
291
+ }
292
+
293
+ get isClosed(): boolean {
294
+ return this.closed;
295
+ }
296
+
297
+ /** Send a message to every connected follower. Best-effort, skips closed sockets. */
298
+ broadcast(msg: IpcMessage): void {
299
+ for (const s of [...this.sockets]) {
300
+ if (!s.isClosed) s.send(msg);
301
+ }
302
+ }
303
+
304
+ async close(): Promise<void> {
305
+ if (this.closed) return;
306
+ this.closed = true;
307
+ for (const s of [...this.sockets]) s.close();
308
+ this.sockets.clear();
309
+ await new Promise<void>((resolve) => {
310
+ this.server.close(() => resolve());
311
+ });
312
+ try {
313
+ fs.unlinkSync(this.socketPath);
314
+ } catch {
315
+ // best-effort
316
+ }
317
+ }
318
+ }
319
+
320
+ type LeadershipOutcome =
321
+ | { role: "leader"; server: IpcServer }
322
+ | { role: "follower"; socket: IpcSocket };
323
+
324
+ /**
325
+ * Try to bind as leader; if the socket is already in use, try to connect as
326
+ * follower; if the existing socket is stale (no listener), unlink and retry
327
+ * the bind. Throws if none of these succeed.
328
+ */
329
+ export async function tryAcquireLeadership(socketPath: string): Promise<LeadershipOutcome> {
330
+ ensureRunDir(socketPath);
331
+
332
+ const server = await tryListen(socketPath);
333
+ if (server) return { role: "leader", server: new IpcServer(server, socketPath) };
334
+
335
+ const sock = await tryConnect(socketPath);
336
+ if (sock) return { role: "follower", socket: new IpcSocket(sock, "follower") };
337
+
338
+ // Stale socket — clean up and retry once.
339
+ try {
340
+ fs.unlinkSync(socketPath);
341
+ } catch {
342
+ // ignore
343
+ }
344
+ const serverRetry = await tryListen(socketPath);
345
+ if (serverRetry) return { role: "leader", server: new IpcServer(serverRetry, socketPath) };
346
+
347
+ throw new Error(`tryAcquireLeadership: could not bind or connect at ${socketPath}`);
348
+ }
349
+
350
+ function tryListen(socketPath: string): Promise<net.Server | null> {
351
+ return new Promise((resolve) => {
352
+ const server = net.createServer();
353
+ const onError = (err: NodeJS.ErrnoException) => {
354
+ server.removeListener("listening", onListening);
355
+ // EADDRINUSE → existing live listener or stale socket; resolve null
356
+ // so caller can try connect or unlink+retry.
357
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve(null);
358
+ else resolve(null);
359
+ };
360
+ const onListening = () => {
361
+ server.removeListener("error", onError);
362
+ resolve(server);
363
+ };
364
+ server.once("error", onError);
365
+ server.once("listening", onListening);
366
+ server.listen(socketPath);
367
+ });
368
+ }
369
+
370
+ function tryConnect(socketPath: string): Promise<net.Socket | null> {
371
+ return new Promise((resolve) => {
372
+ let settled = false;
373
+ const sock = net.createConnection(socketPath);
374
+ const finish = (result: net.Socket | null) => {
375
+ if (settled) {
376
+ if (!result) sock.destroy();
377
+ return;
378
+ }
379
+ settled = true;
380
+ clearTimeout(timer);
381
+ sock.removeListener("connect", onConnect);
382
+ sock.removeListener("error", onError);
383
+ resolve(result);
384
+ };
385
+ const timer = setTimeout(() => finish(null), CONNECT_TIMEOUT_MS);
386
+ const onConnect = () => finish(sock);
387
+ const onError = () => finish(null);
388
+ sock.once("connect", onConnect);
389
+ sock.once("error", onError);
390
+ });
391
+ }
392
+
393
+ function ensureRunDir(socketPath: string): void {
394
+ const dir = path.dirname(socketPath);
395
+ try {
396
+ fs.mkdirSync(dir, { recursive: true });
397
+ } catch (error) {
398
+ const err = error as NodeJS.ErrnoException;
399
+ if (err.code !== "EEXIST") throw error;
400
+ }
401
+ }
402
+
403
+ /** Convenience: generate a stable id for this pi process. */
404
+ export function buildInstanceId(): { id: string; info: InstanceInfo } {
405
+ const cwd = process.cwd();
406
+ const pid = process.pid;
407
+ const id = `${pid}@${cwd}`;
408
+ const cwdBase = path.basename(cwd) || cwd;
409
+ const label = `${cwdBase} (#${pid})`;
410
+ return {
411
+ id,
412
+ info: { id, pid, cwd, label, started: Date.now() },
413
+ };
414
+ }
415
+
416
+ /** Generate a unique request id for command/query correlation. */
417
+ export function generateReqId(): string {
418
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
419
+ }