pi-ui-extend 0.1.64 → 0.1.66

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.
@@ -1,638 +0,0 @@
1
- /**
2
- * Multiplexer: leader-side orchestrator for the telegram mirror.
3
- *
4
- * Owns:
5
- * - The follower registry (instanceId → { socket, info, lastSeen }).
6
- * - The `activeId` (which instance's events go to Telegram and which
7
- * instance receives commands from Telegram).
8
- * - Routing of incoming pi events (own + forwarded by followers) to the
9
- * Telegram renderer — only if the emitter matches `activeId`.
10
- * - Routing of incoming Telegram text/commands to the active instance
11
- * (executed locally if the leader is active, or forwarded over IPC to
12
- * the follower otherwise).
13
- *
14
- * Lifecycle:
15
- * - Constructed once when this pi wins leadership.
16
- * - `init()` is called after the bot connects (so we can default active
17
- * to self).
18
- * - `attachFollower()` is wired into IpcServer.onFollowerConnect.
19
- * - `close()` is called on session_shutdown or leadership loss.
20
- *
21
- * Failure modes handled:
22
- * - Follower socket closes → drop from registry, fall back to leader if
23
- * it was active.
24
- * - Active follower doesn't reply within ~10s → command/query fails with
25
- * a user-readable error to Telegram.
26
- * - Leader crash → followers detect via IpcSocket watchdog and try to
27
- * acquire leadership themselves (handled in index.ts, not here).
28
- */
29
-
30
- import type { TelegramBot, TelegramReplyMarkup, TelegramUpdate } from "./bot.js";
31
- import type { RendererEvent, RendererInstance } from "./renderer.js";
32
- import type { IpcServer, IpcSocket, InstanceInfo } from "./ipc.js";
33
- import { generateReqId } from "./ipc.js";
34
-
35
- /** Locally-executable operations on the leader's own pi session. */
36
- export interface LocalDispatch {
37
- sendUserMessage(text: string): void;
38
- currentDialog(): string | undefined;
39
- abort(): void;
40
- compact(): void;
41
- status(): { idle: boolean; hasPending: boolean } | undefined;
42
- }
43
-
44
- export interface MultiplexerDeps {
45
- selfId: string;
46
- selfInfo: InstanceInfo;
47
- bot: TelegramBot;
48
- renderer: {
49
- push(event: RendererEvent): void;
50
- reset(): void;
51
- showTranscript?(instance: RendererInstance | undefined, transcript: string): Promise<void>;
52
- sentIds?: readonly number[];
53
- };
54
- server: IpcServer;
55
- dispatch: LocalDispatch;
56
- /** Cluster-wide teardown: broadcast stand_down to followers then stop polling. */
57
- standDown: () => Promise<void>;
58
- log?: (message: string) => void;
59
- }
60
-
61
- interface FollowerEntry {
62
- socket: IpcSocket;
63
- info: InstanceInfo;
64
- }
65
-
66
- interface InstanceEntry {
67
- info: InstanceInfo;
68
- isLeader: boolean;
69
- }
70
-
71
- interface InstanceStatus {
72
- idle: boolean;
73
- hasPending: boolean;
74
- }
75
-
76
- const COMMAND_TIMEOUT_MS = 10_000;
77
-
78
- export class Multiplexer {
79
- private readonly selfId: string;
80
- private readonly selfInfo: InstanceInfo;
81
- private readonly bot: TelegramBot;
82
- private readonly renderer: MultiplexerDeps["renderer"];
83
- private readonly server: IpcServer;
84
- private readonly dispatch: LocalDispatch;
85
- private readonly standDown: () => Promise<void>;
86
- private readonly log: (message: string) => void;
87
-
88
- private readonly followers = new Map<string, FollowerEntry>();
89
- private activeId: string | null = null;
90
- private readonly lastStatus = new Map<string, InstanceStatus>();
91
-
92
- constructor(deps: MultiplexerDeps) {
93
- this.selfId = deps.selfId;
94
- this.selfInfo = deps.selfInfo;
95
- this.bot = deps.bot;
96
- this.renderer = deps.renderer;
97
- this.server = deps.server;
98
- this.dispatch = deps.dispatch;
99
- this.standDown = deps.standDown;
100
- this.log = deps.log ?? (() => undefined);
101
-
102
- this.server.onFollowerConnect = (socket) => this.attachFollower(socket);
103
- }
104
-
105
- init(): void {
106
- // Default active to self (the leader). If a follower was active in a
107
- // previous life of the cluster, that state is lost — the user must
108
- // /use again. Acceptable for MVP.
109
- if (this.activeId === null) this.activeId = this.selfId;
110
- }
111
-
112
- getActiveId(): string | null {
113
- return this.activeId;
114
- }
115
-
116
- /** Snapshot of known instances, leader first. */
117
- listInstances(): InstanceEntry[] {
118
- return [
119
- { info: this.selfInfo, isLeader: true },
120
- ...[...this.followers.values()].map((entry) => ({ info: entry.info, isLeader: false })),
121
- ];
122
- }
123
-
124
- /** Local pi events flow in here. */
125
- pushLocalEvent(event: RendererEvent): void {
126
- this.handleInstanceEvent(this.selfId, event);
127
- }
128
-
129
- async showActiveDialog(): Promise<void> {
130
- if (!this.activeId) return;
131
- await this.showDialogFor(this.activeId);
132
- }
133
-
134
- // ─── Telegram → pix dispatch ──────────────────────────────────────────
135
-
136
- async handleTgText(text: string): Promise<void> {
137
- const trimmed = text.trim();
138
- if (!trimmed) return;
139
-
140
- if (trimmed.startsWith("/")) {
141
- const messageId = updateMessageIdFromText();
142
- const [head, ...rest] = trimmed.split(/\s+/);
143
- const arg = rest.join(" ").trim();
144
- const command = normalizeBotCommand(head);
145
- switch (command) {
146
- case "/start":
147
- case "/help":
148
- await this.reply(HELP_TEXT, mainMenuMarkup());
149
- return;
150
- case "/menu":
151
- await this.reply("Choose a project/session to follow:", this.instancesMarkup());
152
- return;
153
- case "/list":
154
- await this.replyList();
155
- return;
156
- case "/use":
157
- await this.handleUse(arg);
158
- return;
159
- case "/abort":
160
- case "/stop":
161
- await this.routeCommandToActive("abort", undefined, "abort");
162
- return;
163
- case "/compact":
164
- await this.routeCommandToActive("compact", undefined, "compact");
165
- return;
166
- case "/status":
167
- await this.handleStatus();
168
- return;
169
- case "/clear":
170
- case "/clear_history":
171
- case "/clean":
172
- await this.clearTelegramHistory(messageId ? [messageId] : []);
173
- return;
174
- case "/say":
175
- if (!arg) {
176
- await this.reply("Usage: /say <message>");
177
- return;
178
- }
179
- await this.routeCommandToActive("sendUserMessage", { text: arg }, "/say");
180
- return;
181
- case "/new":
182
- await this.reply(
183
- "⚠️ /new from Telegram is not supported. Run /new inside pi.\n\n" +
184
- "You can still send free text below; it will be forwarded to the active instance.",
185
- );
186
- return;
187
- case "/disconnect":
188
- await this.reply("👋 Disconnecting telegram-mirror cluster. Run /reload in any pi to resume.");
189
- try {
190
- await this.standDown();
191
- } catch (error) {
192
- this.log(`standDown failed: ${errorMessage(error)}`);
193
- }
194
- return;
195
- default:
196
- // Unknown command → forward verbatim to the active instance.
197
- break;
198
- }
199
- }
200
- await this.routeCommandToActive("sendUserMessage", { text: trimmed }, "message");
201
- }
202
-
203
- async handleTelegramUpdate(update: TelegramUpdate): Promise<void> {
204
- const callback = update.callback_query;
205
- if (callback) {
206
- await this.handleCallback(callback.id, callback.data ?? "");
207
- return;
208
- }
209
- const text = update.message?.text;
210
- if (typeof text === "string") await this.handleTgTextWithMessage(text, update.message?.message_id);
211
- }
212
-
213
- async handleTgTextWithMessage(text: string, messageId: number | undefined): Promise<void> {
214
- const previous = currentTelegramMessageId;
215
- currentTelegramMessageId = messageId;
216
- try {
217
- await this.handleTgText(text);
218
- } finally {
219
- currentTelegramMessageId = previous;
220
- }
221
- }
222
-
223
- // ─── Followers ───────────────────────────────────────────────────────
224
-
225
- private attachFollower(socket: IpcSocket): void {
226
- socket.onMessage = (msg) => {
227
- if (msg.type === "register") {
228
- this.registerFollower(socket, msg.info);
229
- return;
230
- }
231
- if (msg.type === "instance_update") {
232
- this.updateFollowerInfo(socket, msg.info);
233
- return;
234
- }
235
- if (msg.type === "event") {
236
- this.handleFollowerEvent(msg.from, msg.event as RendererEvent);
237
- return;
238
- }
239
- // pings/pongs/acks/replies are handled inside IpcSocket before
240
- // reaching here.
241
- };
242
- socket.onClose = () => {
243
- const entry = this.findFollowerBySocket(socket);
244
- if (!entry) return;
245
- this.followers.delete(entry.info.id);
246
- this.log(`follower disconnected: ${entry.info.label}`);
247
- if (this.activeId === entry.info.id) {
248
- this.activeId = this.selfId;
249
- void this.reply(`⚠️ Active instance ${entry.info.label} disconnected; fell back to leader (${this.selfInfo.label}).`);
250
- }
251
- };
252
- socket.onError = (error) => {
253
- this.log(`follower socket error: ${error.message}`);
254
- };
255
- }
256
-
257
- private registerFollower(socket: IpcSocket, info: InstanceInfo): void {
258
- const prev = this.followers.get(info.id);
259
- if (prev) {
260
- // Duplicate id (rare: pid reuse on same cwd). Replace the old conn.
261
- prev.socket.close();
262
- }
263
- this.followers.set(info.id, { socket, info });
264
- this.log(`follower registered: ${info.label}`);
265
- socket.send({ type: "registered", leader: this.selfInfo, activeId: this.activeId });
266
- void this.reply(`➕ ${formatInstanceTitle(info)} connected.`, this.instancesMarkup());
267
- }
268
-
269
- private updateFollowerInfo(socket: IpcSocket, info: InstanceInfo): void {
270
- const existing = this.followers.get(info.id);
271
- if (existing) {
272
- existing.info = info;
273
- return;
274
- }
275
- this.followers.set(info.id, { socket, info });
276
- }
277
-
278
- updateSelfInfo(info: InstanceInfo): void {
279
- this.selfInfo.cwd = info.cwd;
280
- this.selfInfo.label = info.label;
281
- this.selfInfo.sessionId = info.sessionId;
282
- this.selfInfo.sessionFile = info.sessionFile;
283
- this.selfInfo.sessionName = info.sessionName;
284
- }
285
-
286
- private findFollowerBySocket(socket: IpcSocket): FollowerEntry | undefined {
287
- for (const entry of this.followers.values()) {
288
- if (entry.socket === socket) return entry;
289
- }
290
- return undefined;
291
- }
292
-
293
- private handleFollowerEvent(fromId: string, event: RendererEvent): void {
294
- if (!this.followers.has(fromId)) return; // unknown/unregistered
295
- this.handleInstanceEvent(fromId, event);
296
- }
297
-
298
- private handleInstanceEvent(fromId: string, event: RendererEvent): void {
299
- const entry = this.getInstance(fromId);
300
- if (!entry) return;
301
- if (event.kind === "turn_start") {
302
- this.recordStatus(fromId, { idle: false, hasPending: false }, entry.info);
303
- if (this.activeId === fromId) this.renderer.push(withInstance(event, entry.info));
304
- return;
305
- }
306
- if (event.kind === "turn_end") {
307
- this.recordStatus(fromId, { idle: true, hasPending: false }, entry.info);
308
- if (this.activeId === fromId) {
309
- this.renderer.push(event);
310
- }
311
- return;
312
- }
313
- if (this.activeId === fromId) this.renderer.push(event);
314
- }
315
-
316
- private recordStatus(instanceId: string, next: InstanceStatus, info: InstanceInfo): void {
317
- const prev = this.lastStatus.get(instanceId);
318
- this.lastStatus.set(instanceId, next);
319
- if (prev && prev.idle === next.idle) return;
320
- const icon = next.idle ? "🟢" : "🟡";
321
- const text = `${icon} ${formatInstanceTitle(info)} is ${formatStatus(next)}.`;
322
- void this.reply(text, this.instancesMarkup(), { silent: instanceId !== this.activeId });
323
- }
324
-
325
- // ─── Routing helpers ─────────────────────────────────────────────────
326
-
327
- private async routeCommandToActive(
328
- command: "sendUserMessage" | "abort" | "compact",
329
- args: unknown,
330
- label: string,
331
- ): Promise<void> {
332
- if (!this.activeId) {
333
- await this.reply("⚠️ No active instance. Run /list and /use N first.");
334
- return;
335
- }
336
-
337
- if (this.activeId === this.selfId) {
338
- try {
339
- switch (command) {
340
- case "sendUserMessage":
341
- this.dispatch.sendUserMessage((args as { text: string })?.text ?? "");
342
- break;
343
- case "abort":
344
- this.dispatch.abort();
345
- break;
346
- case "compact":
347
- this.dispatch.compact();
348
- break;
349
- }
350
- } catch (error) {
351
- await this.reply(`❌ ${label} failed: ${errorMessage(error)}`);
352
- return;
353
- }
354
- if (command !== "sendUserMessage") await this.reply(`✅ ${label} requested`);
355
- return;
356
- }
357
-
358
- const follower = this.followers.get(this.activeId);
359
- if (!follower) {
360
- this.activeId = this.selfId;
361
- await this.reply(`⚠️ Active instance disappeared; fell back to leader. Run /list to confirm.`);
362
- return;
363
- }
364
- try {
365
- const reqId = generateReqId();
366
- const ack = await follower.socket.request(
367
- { type: "command", reqId, to: this.activeId, command, args },
368
- COMMAND_TIMEOUT_MS,
369
- );
370
- if (ack.type !== "command_ack" || !ack.ok) {
371
- await this.reply(`❌ ${label} on ${follower.info.label}: ${ack.type === "command_ack" ? ack.error ?? "unknown error" : "unexpected reply"}`);
372
- return;
373
- }
374
- if (command !== "sendUserMessage") await this.reply(`✅ ${label} on ${follower.info.label}`);
375
- } catch (error) {
376
- await this.reply(`❌ ${label} on ${follower.info.label} failed: ${errorMessage(error)}`);
377
- }
378
- }
379
-
380
- private async handleStatus(): Promise<void> {
381
- if (!this.activeId) {
382
- await this.reply("⚠️ No active instance. Run /list and /use N first.");
383
- return;
384
- }
385
- if (this.activeId === this.selfId) {
386
- const status = this.dispatch.status();
387
- if (!status) return await this.reply("⚠️ Status unavailable: no active session yet.");
388
- await this.reply(`🟢 ${this.selfInfo.label}: ${formatStatus(status)}`);
389
- return;
390
- }
391
- const follower = this.followers.get(this.activeId);
392
- if (!follower) {
393
- this.activeId = this.selfId;
394
- await this.reply(`⚠️ Active instance disappeared; fell back to leader.`);
395
- return;
396
- }
397
- try {
398
- const reqId = generateReqId();
399
- const reply = await follower.socket.request(
400
- { type: "query", reqId, to: this.activeId, query: "status" },
401
- COMMAND_TIMEOUT_MS,
402
- );
403
- if (reply.type !== "query_reply" || !reply.ok) {
404
- await this.reply(`❌ status on ${follower.info.label}: ${reply.type === "query_reply" ? reply.error ?? "unknown error" : "unexpected reply"}`);
405
- return;
406
- }
407
- const result = (reply.result ?? {}) as { idle?: boolean; hasPending?: boolean };
408
- await this.reply(`🟢 ${follower.info.label}: ${formatStatus({ idle: !!result.idle, hasPending: !!result.hasPending })}`);
409
- } catch (error) {
410
- await this.reply(`❌ status on ${follower.info.label} failed: ${errorMessage(error)}`);
411
- }
412
- }
413
-
414
- private async handleUse(arg: string): Promise<void> {
415
- const instances = this.listInstances();
416
- if (!arg) {
417
- await this.reply("Usage: /use <number or id prefix>. Run /list to see options.");
418
- return;
419
- }
420
- const target = resolveUseTarget(arg, instances);
421
- if (!target) {
422
- await this.reply(`⚠️ No instance matches "${arg}". Run /list.`);
423
- return;
424
- }
425
- this.activeId = target.info.id;
426
- this.renderer.reset();
427
- await this.reply(`✅ Following: ${formatInstanceTitle(target.info)}${target.isLeader ? " (leader)" : ""}`, this.instancesMarkup());
428
- await this.showDialogFor(target.info.id);
429
- }
430
-
431
- private async showDialogFor(instanceId: string): Promise<void> {
432
- const entry = this.getInstance(instanceId);
433
- if (!entry) return;
434
- let text: string | undefined;
435
- if (instanceId === this.selfId) {
436
- text = this.dispatch.currentDialog();
437
- } else {
438
- const follower = this.followers.get(instanceId);
439
- if (!follower) return;
440
- try {
441
- const reqId = generateReqId();
442
- const reply = await follower.socket.request(
443
- { type: "query", reqId, to: instanceId, query: "dialog" },
444
- COMMAND_TIMEOUT_MS,
445
- );
446
- if (reply.type === "query_reply" && reply.ok && isRecord(reply.result)) {
447
- text = typeof reply.result.text === "string" ? reply.result.text : undefined;
448
- }
449
- } catch (error) {
450
- this.log(`dialog query failed for ${entry.info.label}: ${errorMessage(error)}`);
451
- return;
452
- }
453
- }
454
- if (!text?.trim()) return;
455
- if (this.renderer.showTranscript) {
456
- await this.renderer.showTranscript(instanceToRenderer(entry.info), text);
457
- return;
458
- }
459
- this.renderer.reset();
460
- this.renderer.push({ kind: "turn_start", instance: instanceToRenderer(entry.info) });
461
- this.renderer.push({ kind: "assistant_text", delta: text });
462
- }
463
-
464
- private async replyList(): Promise<void> {
465
- const instances = this.listInstances();
466
- if (instances.length === 0) {
467
- await this.reply("No instances registered yet.");
468
- return;
469
- }
470
- const lines = instances.map((entry, idx) => {
471
- const marker = entry.info.id === this.activeId ? " [following]" : "";
472
- const role = entry.isLeader ? " (leader)" : "";
473
- const status = this.lastStatus.get(entry.info.id);
474
- const statusText = status ? ` — ${formatStatus(status)}` : "";
475
- return `${idx + 1}. ${formatInstanceTitle(entry.info)}${role}${marker}${statusText}`;
476
- });
477
- lines.push("");
478
- lines.push("Tap a button below, or use /use N.");
479
- await this.reply(lines.join("\n"), this.instancesMarkup());
480
- }
481
-
482
- private async handleCallback(callbackId: string, data: string): Promise<void> {
483
- try {
484
- await this.bot.answerCallbackQuery(callbackId);
485
- } catch (error) {
486
- this.log(`answerCallbackQuery failed: ${errorMessage(error)}`);
487
- }
488
-
489
- if (data === "tg:list") {
490
- await this.replyList();
491
- return;
492
- }
493
- if (data === "tg:status") {
494
- await this.handleStatus();
495
- return;
496
- }
497
- if (data === "tg:help") {
498
- await this.reply(HELP_TEXT, mainMenuMarkup());
499
- return;
500
- }
501
- if (data.startsWith("tg:use:")) {
502
- await this.handleUse(data.slice("tg:use:".length));
503
- return;
504
- }
505
- }
506
-
507
- private instancesMarkup(): TelegramReplyMarkup {
508
- const rows = this.listInstances().map((entry, idx) => {
509
- const prefix = entry.info.id === this.activeId ? "✅ " : "";
510
- return [{ text: `${prefix}${idx + 1}. ${buttonLabel(entry.info)}`, callback_data: `tg:use:${idx + 1}` }];
511
- });
512
- rows.push([
513
- { text: "🔄 List", callback_data: "tg:list" },
514
- { text: "📊 Status", callback_data: "tg:status" },
515
- ]);
516
- return { inline_keyboard: rows };
517
- }
518
-
519
- private async clearTelegramHistory(extraMessageIds: readonly number[] = []): Promise<void> {
520
- const result = await this.bot.deleteKnownMessages([...(this.renderer.sentIds ?? []), ...extraMessageIds]);
521
- this.renderer.reset();
522
- this.log(`telegram history clear requested: attempted=${result.attempted} deleted=${result.deleted}`);
523
- }
524
-
525
- private getInstance(id: string): InstanceEntry | undefined {
526
- if (id === this.selfId) return { info: this.selfInfo, isLeader: true };
527
- const follower = this.followers.get(id);
528
- return follower ? { info: follower.info, isLeader: false } : undefined;
529
- }
530
-
531
- private async reply(text: string, replyMarkup?: TelegramReplyMarkup, options: { silent?: boolean } = {}): Promise<void> {
532
- try {
533
- await this.bot.sendMessage(text, { replyMarkup, silent: options.silent });
534
- } catch (error) {
535
- this.log(`reply failed: ${errorMessage(error)}`);
536
- }
537
- }
538
-
539
- close(): void {
540
- this.renderer.reset();
541
- }
542
- }
543
-
544
- function formatStatus(status: { idle: boolean; hasPending: boolean }): string {
545
- if (status.idle) return "idle";
546
- return status.hasPending ? "streaming (queued messages waiting)" : "streaming";
547
- }
548
-
549
- function formatInstanceTitle(info: InstanceInfo): string {
550
- return info.sessionName ? `${info.label} · ${info.sessionName}` : info.label;
551
- }
552
-
553
- function buttonLabel(info: InstanceInfo): string {
554
- return info.sessionName ? `${info.label} · ${info.sessionName}` : info.label;
555
- }
556
-
557
- function withInstance(event: Extract<RendererEvent, { kind: "turn_start" }>, info: InstanceInfo): RendererEvent {
558
- return {
559
- ...event,
560
- instance: instanceToRenderer(info),
561
- };
562
- }
563
-
564
- function instanceToRenderer(info: InstanceInfo): RendererInstance {
565
- return {
566
- label: info.label,
567
- cwd: info.cwd,
568
- ...(info.sessionId ? { sessionId: info.sessionId } : {}),
569
- ...(info.sessionName ? { sessionName: info.sessionName } : {}),
570
- };
571
- }
572
-
573
- function normalizeBotCommand(head: string): string {
574
- const withoutMention = head.split("@", 1)[0] ?? head;
575
- return withoutMention.toLowerCase();
576
- }
577
-
578
- function isRecord(value: unknown): value is Record<string, unknown> {
579
- return value !== null && typeof value === "object" && !Array.isArray(value);
580
- }
581
-
582
- let currentTelegramMessageId: number | undefined;
583
-
584
- function updateMessageIdFromText(): number | undefined {
585
- return currentTelegramMessageId;
586
- }
587
-
588
- function mainMenuMarkup(): TelegramReplyMarkup {
589
- return {
590
- inline_keyboard: [
591
- [{ text: "🧭 Choose project/session", callback_data: "tg:list" }],
592
- [{ text: "📊 Active status", callback_data: "tg:status" }],
593
- ],
594
- };
595
- }
596
-
597
- function errorMessage(error: unknown): string {
598
- return error instanceof Error ? error.message : String(error);
599
- }
600
-
601
- function resolveUseTarget(arg: string, instances: { info: InstanceInfo; isLeader: boolean }[]): { info: InstanceInfo; isLeader: boolean } | undefined {
602
- const trimmed = arg.trim();
603
- // By 1-based index
604
- const asNumber = Number(trimmed);
605
- if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= instances.length) {
606
- return instances[asNumber - 1];
607
- }
608
- // By id/label substring (case-insensitive)
609
- const lower = trimmed.toLowerCase();
610
- const exact = instances.find((entry) => entry.info.id.toLowerCase() === lower);
611
- if (exact) return exact;
612
- const partial = instances.find(
613
- (entry) => entry.info.id.toLowerCase().includes(lower) || entry.info.label.toLowerCase().includes(lower),
614
- );
615
- return partial;
616
- }
617
-
618
- const HELP_TEXT = [
619
- "<b>pix Telegram mirror</b>",
620
- "",
621
- "Free text → forwarded to the <i>followed</i> pi session.",
622
- "",
623
- "<b>Multi-instance commands</b>",
624
- "/menu — show Telegram buttons",
625
- "/list — show all known pi instances",
626
- "/use N — follow by 1-based index from /list",
627
- "/use &lt;id&gt; — switch by id/label substring",
628
- "/disconnect — stop the bot cluster-wide (resume with /reload in pi)",
629
- "",
630
- "<b>Per-instance commands</b>",
631
- "/abort /stop — cancel current turn on active",
632
- "/compact — trigger compaction on active",
633
- "/status — show idle/streaming state of active",
634
- "/clear — best-effort delete known bot messages from this chat",
635
- "/say &lt;msg&gt; — explicit send (escape /-prefixed text)",
636
- "/new — not supported; run /new inside pi",
637
- "/help — this message",
638
- ].join("\n");