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,424 @@
1
+ /**
2
+ * telegram-mirror: bidirectional Telegram mirror of the running pix session.
3
+ *
4
+ * Opt-in module configured via ~/.config/pi/pi-tools-suite.jsonc under the
5
+ * `telegramMirror` key:
6
+ *
7
+ * "telegramMirror": {
8
+ * "enabled": true,
9
+ * "botToken": "123456789:ABCdef...", // from @BotFather
10
+ * "chatId": 123456789 // numeric id allowed to control the bot
11
+ * }
12
+ *
13
+ * Self-disables when the section is missing, `enabled: false`, `botToken` is
14
+ * empty, or `chatId` is not an integer.
15
+ *
16
+ * === Multi-instance architecture ===
17
+ *
18
+ * Telegram allows exactly one concurrent getUpdates call per bot token, so N
19
+ * pi processes can't each poll the same bot. This module elects a leader:
20
+ *
21
+ * - First pi to start binds `~/.pi/agent/extensions/pi-tools-suite/.run/
22
+ * telegram-mirror.sock`. It owns the TG polling loop and a Multiplexer
23
+ * that routes events/commands between Telegram and pi instances.
24
+ * - Subsequent pi processes connect to the socket as followers. They
25
+ * forward their pix events to the leader over IPC and execute commands
26
+ * received from the leader.
27
+ * - If the leader dies (socket close / heartbeat timeout), followers race
28
+ * to take over; the first to bind wins.
29
+ *
30
+ * See IPC protocol in `./ipc.ts` and routing logic in `./multiplexer.ts`.
31
+ *
32
+ * The user picks the active instance in Telegram with /list and /use N.
33
+ * Events from non-active instances are dropped (silent).
34
+ */
35
+
36
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
+ import { loadTelegramMirrorConfig } from "../config.js";
38
+ import { TelegramBot } from "./bot.js";
39
+ import { captureAbortableContext, registerPixEventHandlers, type PixMirrorHooks, type RendererSink } from "./events.js";
40
+ import { TurnRenderer, type RendererEvent } from "./renderer.js";
41
+ import {
42
+ DEFAULT_SOCKET_PATH,
43
+ IpcServer,
44
+ IpcSocket,
45
+ buildInstanceId,
46
+ tryAcquireLeadership,
47
+ type IpcMessage,
48
+ } from "./ipc.js";
49
+ import { Multiplexer, type LocalDispatch } from "./multiplexer.js";
50
+
51
+ type Role = "starting" | "leader" | "follower";
52
+
53
+ interface MirrorContext {
54
+ abort(): void;
55
+ isIdle(): boolean;
56
+ hasPendingMessages(): boolean;
57
+ compact(): void;
58
+ }
59
+
60
+ const RECONNECT_DELAY_MS = 2_000;
61
+
62
+ export default function telegramMirror(pi: ExtensionAPI): void {
63
+ const cfg = loadTelegramMirrorConfig();
64
+ if (!cfg || !cfg.enabled) {
65
+ // Soft-disable: no logging at startup, the user simply hasn't opted in.
66
+ return;
67
+ }
68
+
69
+ const { botToken: token, chatId } = cfg;
70
+ const { id: selfId, info: selfInfo } = buildInstanceId();
71
+
72
+ let role: Role = "starting";
73
+ let bot: TelegramBot | undefined;
74
+ let renderer: TurnRenderer | undefined;
75
+ let multiplexer: Multiplexer | undefined;
76
+ let server: IpcServer | undefined;
77
+ let clientSocket: IpcSocket | undefined;
78
+ let mirrorCtx: MirrorContext | undefined;
79
+ let captureHooksInstalled = false;
80
+ let reconnectTimer: NodeJS.Timeout | undefined;
81
+ let shutdown = false;
82
+ let disabled = false;
83
+
84
+ const log = (message: string): void => {
85
+ // eslint-disable-next-line no-console
86
+ console.error(`[telegram-mirror] ${message}`);
87
+ };
88
+
89
+ // Dispatch the leader uses to execute commands on its own pi session.
90
+ const localDispatch: LocalDispatch = {
91
+ sendUserMessage(text) {
92
+ pi.sendUserMessage(text);
93
+ },
94
+ abort() {
95
+ mirrorCtx?.abort();
96
+ },
97
+ compact() {
98
+ mirrorCtx?.compact();
99
+ },
100
+ status() {
101
+ if (!mirrorCtx) return undefined;
102
+ return { idle: mirrorCtx.isIdle(), hasPending: mirrorCtx.hasPendingMessages() };
103
+ },
104
+ };
105
+
106
+ // Renderer sink that routes events based on current role.
107
+ const eventSink: RendererSink = {
108
+ push(event: RendererEvent) {
109
+ if (role === "leader" && multiplexer) {
110
+ multiplexer.pushLocalEvent(event);
111
+ } else if (role === "follower" && clientSocket && !clientSocket.isClosed) {
112
+ clientSocket.send({ type: "event", from: selfId, event });
113
+ }
114
+ // starting / no IPC yet → drop; events between session_start and
115
+ // IPC ready are lost (acceptable; rendering is lossy anyway).
116
+ },
117
+ };
118
+
119
+ const hooks: PixMirrorHooks = {
120
+ getRenderer: () => eventSink,
121
+ notifyAgentEnd: () => undefined,
122
+ };
123
+
124
+ registerPixEventHandlers(pi, hooks);
125
+
126
+ pi.on("session_start", async (_event, ctx) => {
127
+ if (!captureHooksInstalled) {
128
+ captureHooksInstalled = true;
129
+ captureAbortableContext(ctx as ExtensionContext | undefined, {
130
+ captureAbort(fn) {
131
+ mirrorCtx = mirrorCtx ?? makeCtx({ abort: fn });
132
+ (mirrorCtx as Mutable<MirrorContext>).abort = fn;
133
+ },
134
+ captureIdle(fn) {
135
+ mirrorCtx = mirrorCtx ?? makeCtx({ isIdle: fn });
136
+ (mirrorCtx as Mutable<MirrorContext>).isIdle = fn;
137
+ },
138
+ capturePending(fn) {
139
+ mirrorCtx = mirrorCtx ?? makeCtx({ hasPendingMessages: fn });
140
+ (mirrorCtx as Mutable<MirrorContext>).hasPendingMessages = fn;
141
+ },
142
+ captureCompact(fn) {
143
+ mirrorCtx = mirrorCtx ?? makeCtx({ compact: fn });
144
+ (mirrorCtx as Mutable<MirrorContext>).compact = fn;
145
+ },
146
+ });
147
+ }
148
+ await start();
149
+ });
150
+
151
+ pi.on("agent_start", (_e, ctx) => refreshCtx(ctx as ExtensionContext | undefined));
152
+ pi.on("before_agent_start", (_e, ctx) => refreshCtx(ctx as ExtensionContext | undefined));
153
+
154
+ pi.on("session_shutdown", async (event) => {
155
+ // On reload/fork the module will be reloaded in the same process —
156
+ // keep IPC and bot alive so cluster leadership stays stable.
157
+ if (event?.reason === "reload" || event?.reason === "fork") return;
158
+ shutdown = true;
159
+ await teardown();
160
+ });
161
+
162
+ async function start(): Promise<void> {
163
+ if (shutdown) return;
164
+ if (disabled) return;
165
+ if (role !== "starting") return;
166
+ if (reconnectTimer) return;
167
+
168
+ try {
169
+ const outcome = await tryAcquireLeadership(DEFAULT_SOCKET_PATH);
170
+ if (outcome.role === "leader") {
171
+ await becomeLeader(outcome.server);
172
+ } else {
173
+ becomeFollower(outcome.socket);
174
+ }
175
+ } catch (error) {
176
+ log(`failed to acquire leadership: ${errorMessage(error)}`);
177
+ scheduleReconnect();
178
+ }
179
+ }
180
+
181
+ function scheduleReconnect(): void {
182
+ if (shutdown) return;
183
+ if (disabled) return;
184
+ if (reconnectTimer) return;
185
+ reconnectTimer = setTimeout(() => {
186
+ reconnectTimer = undefined;
187
+ void start();
188
+ }, RECONNECT_DELAY_MS);
189
+ }
190
+
191
+ async function becomeLeader(server_: IpcServer): Promise<void> {
192
+ role = "leader";
193
+ server = server_;
194
+
195
+ // Validate bot token before declaring leadership; if auth fails,
196
+ // step down so another pi with a working config can try.
197
+ let created: TelegramBot;
198
+ try {
199
+ created = new TelegramBot({ token, allowedChatId: chatId });
200
+ } catch (error) {
201
+ log(`failed to create bot: ${errorMessage(error)}`);
202
+ await stepDown();
203
+ return;
204
+ }
205
+
206
+ try {
207
+ const me = await created.getMe();
208
+ if (!me?.ok || !me.result) {
209
+ log(`getMe rejected the token; stepping down`);
210
+ created.abort();
211
+ await stepDown();
212
+ return;
213
+ }
214
+ bot = created;
215
+ log(`connected as @${me.result.username} (leader) [${selfInfo.label}]`);
216
+ } catch (error) {
217
+ log(`getMe failed: ${errorMessage(error)}`);
218
+ created.abort();
219
+ await stepDown();
220
+ return;
221
+ }
222
+
223
+ renderer = new TurnRenderer(bot, log);
224
+ multiplexer = new Multiplexer({
225
+ selfId,
226
+ selfInfo,
227
+ bot,
228
+ renderer,
229
+ server: server_,
230
+ dispatch: localDispatch,
231
+ standDown: clusterStandDown,
232
+ log,
233
+ });
234
+ multiplexer.init();
235
+
236
+ bot.startPolling(async (update) => {
237
+ const text = update.message?.text;
238
+ if (typeof text !== "string") return;
239
+ if (!update.message?.chat || !bot?.isAllowedChat(update.message.chat.id)) return;
240
+ await multiplexer?.handleTgText(text);
241
+ });
242
+ }
243
+
244
+ function becomeFollower(socket: IpcSocket): void {
245
+ role = "follower";
246
+ clientSocket = socket;
247
+ socket.send({ type: "register", info: selfInfo });
248
+
249
+ socket.onMessage = (msg: IpcMessage) => {
250
+ if (msg.type === "registered") {
251
+ log(`registered with leader ${msg.leader.label} [${selfInfo.label}]`);
252
+ return;
253
+ }
254
+ if (msg.type === "stand_down") {
255
+ log(`received stand_down from leader; stopping`);
256
+ disabled = true;
257
+ if (reconnectTimer) {
258
+ clearTimeout(reconnectTimer);
259
+ reconnectTimer = undefined;
260
+ }
261
+ socket.close();
262
+ return;
263
+ }
264
+ if (msg.type === "command") {
265
+ void handleFollowerCommand(socket, msg.reqId, msg.command, msg.args);
266
+ return;
267
+ }
268
+ if (msg.type === "query") {
269
+ void handleFollowerQuery(socket, msg.reqId, msg.query);
270
+ return;
271
+ }
272
+ // pings/pongs/acks handled in IpcSocket
273
+ };
274
+
275
+ socket.onClose = () => {
276
+ log(`lost connection to leader; retrying in ${RECONNECT_DELAY_MS}ms`);
277
+ clientSocket = undefined;
278
+ role = "starting";
279
+ scheduleReconnect();
280
+ };
281
+
282
+ socket.onError = (error) => {
283
+ log(`ipc client error: ${error.message}`);
284
+ };
285
+ }
286
+
287
+ async function handleFollowerCommand(socket: IpcSocket, reqId: string, command: string, args: unknown): Promise<void> {
288
+ let ok = true;
289
+ let error: string | undefined;
290
+ try {
291
+ switch (command) {
292
+ case "sendUserMessage":
293
+ pi.sendUserMessage(((args as { text?: string } | undefined)?.text ?? ""));
294
+ break;
295
+ case "abort":
296
+ mirrorCtx?.abort();
297
+ break;
298
+ case "compact":
299
+ mirrorCtx?.compact();
300
+ break;
301
+ default:
302
+ ok = false;
303
+ error = `unknown command: ${command}`;
304
+ }
305
+ } catch (err) {
306
+ ok = false;
307
+ error = errorMessage(err);
308
+ }
309
+ socket.send({ type: "command_ack", reqId, ok, error });
310
+ }
311
+
312
+ async function handleFollowerQuery(socket: IpcSocket, reqId: string, query: string): Promise<void> {
313
+ if (query === "status") {
314
+ const result = mirrorCtx
315
+ ? { idle: mirrorCtx.isIdle(), hasPending: mirrorCtx.hasPendingMessages() }
316
+ : { idle: true, hasPending: false };
317
+ socket.send({ type: "query_reply", reqId, ok: true, result });
318
+ return;
319
+ }
320
+ socket.send({ type: "query_reply", reqId, ok: false, error: `unknown query: ${query}` });
321
+ }
322
+
323
+ async function stepDown(): Promise<void> {
324
+ role = "starting";
325
+ if (server) {
326
+ try {
327
+ await server.close();
328
+ } catch {
329
+ // ignore
330
+ }
331
+ server = undefined;
332
+ }
333
+ scheduleReconnect();
334
+ }
335
+
336
+ /**
337
+ * Cluster-wide teardown triggered by `/disconnect` from Telegram.
338
+ *
339
+ * Leader: broadcast `stand_down` to every follower, wait briefly for
340
+ * delivery, then tear down bot + server. The socket file is unlinked
341
+ * in IpcServer.close so no follower can race into leadership after.
342
+ *
343
+ * Follower: just tear down local IPC. (Should not normally be reached
344
+ * from /disconnect because TG commands arrive at the leader only.)
345
+ *
346
+ * `disabled` is set BEFORE broadcast/teardown so that any subsequent
347
+ * socket close / reconnect attempt short-circuits and the cluster
348
+ * stays down until pi is /reload-ed (which re-runs this module
349
+ * factory and resets `disabled`).
350
+ */
351
+ async function clusterStandDown(): Promise<void> {
352
+ if (disabled) return;
353
+ disabled = true;
354
+ if (server && role === "leader") {
355
+ try {
356
+ server.broadcast({ type: "stand_down" });
357
+ } catch (error) {
358
+ log(`broadcast stand_down failed: ${errorMessage(error)}`);
359
+ }
360
+ // Give followers a moment to receive stand_down and set their
361
+ // own `disabled` flag before we close the sockets.
362
+ await new Promise<void>((resolve) => setTimeout(resolve, 200));
363
+ }
364
+ await teardown();
365
+ log(`disconnected (/disconnect). /reload in pi to resume.`);
366
+ }
367
+
368
+ async function teardown(): Promise<void> {
369
+ if (reconnectTimer) {
370
+ clearTimeout(reconnectTimer);
371
+ reconnectTimer = undefined;
372
+ }
373
+ renderer?.reset();
374
+ renderer = undefined;
375
+ multiplexer?.close();
376
+ multiplexer = undefined;
377
+ if (bot) bot.abort();
378
+ bot = undefined;
379
+ if (clientSocket) clientSocket.close();
380
+ clientSocket = undefined;
381
+ if (server) {
382
+ try {
383
+ await server.close();
384
+ } catch {
385
+ // ignore
386
+ }
387
+ server = undefined;
388
+ }
389
+ role = "starting";
390
+ }
391
+
392
+ function refreshCtx(ctx: ExtensionContext | undefined): void {
393
+ if (!ctx) return;
394
+ if (!mirrorCtx) {
395
+ mirrorCtx = makeCtx({
396
+ abort: () => ctx.abort(),
397
+ isIdle: () => ctx.isIdle(),
398
+ hasPendingMessages: () => ctx.hasPendingMessages(),
399
+ compact: () => ctx.compact(),
400
+ });
401
+ return;
402
+ }
403
+ const m = mirrorCtx as Mutable<MirrorContext>;
404
+ m.abort = () => ctx.abort();
405
+ m.isIdle = () => ctx.isIdle();
406
+ m.hasPendingMessages = () => ctx.hasPendingMessages();
407
+ m.compact = () => ctx.compact();
408
+ }
409
+ }
410
+
411
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
412
+
413
+ function makeCtx(seed: Partial<MirrorContext>): MirrorContext {
414
+ return {
415
+ abort: seed.abort ?? (() => undefined),
416
+ isIdle: seed.isIdle ?? (() => true),
417
+ hasPendingMessages: seed.hasPendingMessages ?? (() => false),
418
+ compact: seed.compact ?? (() => undefined),
419
+ };
420
+ }
421
+
422
+ function errorMessage(error: unknown): string {
423
+ return error instanceof Error ? error.message : String(error);
424
+ }