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.
- package/apps/desktop-tauri/README.md +103 -0
- package/apps/desktop-tauri/bin/pix-desktop.mjs +89 -0
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +29 -0
- package/dist/app/input/input-paste-handler.d.ts +1 -1
- package/dist/app/input/input-paste-handler.js +6 -5
- package/dist/app/model/model-usage-status.js +4 -27
- package/dist/app/rendering/render-controller.js +12 -8
- package/dist/app/screen/mouse-controller.d.ts +1 -0
- package/dist/app/screen/mouse-controller.js +13 -16
- package/external/pi-tools-suite/src/config.ts +43 -0
- package/external/pi-tools-suite/src/dcp/commands.ts +1 -1
- package/external/pi-tools-suite/src/dcp/index.ts +21 -1
- package/external/pi-tools-suite/src/dcp/state.ts +225 -3
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +5 -0
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/external/pi-tools-suite/src/telegram-mirror/README.md +168 -0
- package/external/pi-tools-suite/src/telegram-mirror/bot.ts +228 -0
- package/external/pi-tools-suite/src/telegram-mirror/events.ts +94 -0
- package/external/pi-tools-suite/src/telegram-mirror/format.ts +120 -0
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +424 -0
- package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +419 -0
- package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +408 -0
- package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +214 -0
- package/package.json +14 -3
|
@@ -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
|
+
}
|