aibroker 0.6.4 → 0.7.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.
- package/dist/adapters/pailot/gateway.d.ts +12 -8
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +377 -167
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/adapters/pailot/mqtt-broker.d.ts +46 -0
- package/dist/adapters/pailot/mqtt-broker.d.ts.map +1 -0
- package/dist/adapters/pailot/mqtt-broker.js +308 -0
- package/dist/adapters/pailot/mqtt-broker.js.map +1 -0
- package/dist/core/hybrid.js +1 -1
- package/dist/core/hybrid.js.map +1 -1
- package/dist/daemon/commands.d.ts.map +1 -1
- package/dist/daemon/commands.js +67 -4
- package/dist/daemon/commands.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +67 -18
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/screenshot.d.ts.map +1 -1
- package/dist/daemon/screenshot.js +37 -44
- package/dist/daemon/screenshot.js.map +1 -1
- package/dist/mcp/index.js +12 -3
- package/dist/mcp/index.js.map +1 -1
- package/package.json +2 -1
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { WebSocketServer, WebSocket } from "ws";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { writeFileSync, readFileSync, existsSync, unlinkSync, appendFileSync } from "node:fs";
|
|
16
|
-
import {
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
17
18
|
const DEBUG_LOG = process.env.PAILOT_DEBUG ? "/tmp/pailot-ws-debug.log" : null;
|
|
18
19
|
function dbg(msg) {
|
|
19
20
|
if (DEBUG_LOG)
|
|
@@ -29,6 +30,7 @@ import { setItermSessionVar, setItermTabName, setItermBadge, killSession, create
|
|
|
29
30
|
import { listPaiProjects, launchPaiProject } from "../../daemon/pai-projects.js";
|
|
30
31
|
import { runAppleScript, sendKeystrokeToSession, sendEscapeSequenceToSession, pasteTextIntoSession, snapshotAllSessions } from "../iterm/core.js";
|
|
31
32
|
import { hybridManager } from "../../core/hybrid.js";
|
|
33
|
+
import { mqttPublishText, mqttPublishVoice, mqttPublishImage, mqttPublishTyping, mqttPublishScreenshot, mqttPublishSessions, mqttPublishTranscript, mqttPublishStatus as mqttPubStatus, mqttPublishControl, isMqttRunning, } from "./mqtt-broker.js";
|
|
32
34
|
const WS_PORT = parseInt(process.env.PAILOT_PORT ?? "8765", 10);
|
|
33
35
|
// --- State ---
|
|
34
36
|
let wss = null;
|
|
@@ -48,126 +50,56 @@ const clientActiveSession = new Map();
|
|
|
48
50
|
const clientLastActive = new Map();
|
|
49
51
|
/** Consider a client "alive" if it responded within this window. */
|
|
50
52
|
const CLIENT_ALIVE_THRESHOLD = 90_000; // 90s (3x the 30s heartbeat)
|
|
51
|
-
// ---
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
let
|
|
58
|
-
|
|
53
|
+
// --- Sequence-based message log ---
|
|
54
|
+
// Every broadcast message gets a monotonic sequence number. The log is a circular
|
|
55
|
+
// buffer of the last N messages. Clients track their lastSeq and request catch_up
|
|
56
|
+
// on connect or foreground resume. This replaces the old outbox system entirely —
|
|
57
|
+
// no per-client state, fully idempotent, works regardless of reconnection path.
|
|
58
|
+
const MESSAGE_LOG_SIZE = 200;
|
|
59
|
+
let nextSeq = 1;
|
|
60
|
+
const messageLog = [];
|
|
61
|
+
/** Append a message to the log with a sequence number. Returns the seq (0 for ephemeral). */
|
|
62
|
+
function appendToLog(msg) {
|
|
59
63
|
const type = msg.type;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
64
|
+
// Don't log ephemeral/meta messages
|
|
65
|
+
if (type === "typing" || type === "pong" || type === "sessions"
|
|
66
|
+
|| type === "session_switched" || type === "unread" || type === "status")
|
|
67
|
+
return 0;
|
|
68
|
+
const seq = nextSeq++;
|
|
69
|
+
messageLog.push({ seq, msg: { ...msg, seq }, timestamp: Date.now() });
|
|
70
|
+
if (messageLog.length > MESSAGE_LOG_SIZE)
|
|
71
|
+
messageLog.shift();
|
|
72
|
+
return seq;
|
|
73
|
+
}
|
|
74
|
+
/** Return all log entries with seq > afterSeq, optionally filtered by session. */
|
|
75
|
+
function getMessagesAfter(afterSeq, sessionId) {
|
|
76
|
+
return messageLog.filter(e => {
|
|
77
|
+
if (e.seq <= afterSeq)
|
|
78
|
+
return false;
|
|
79
|
+
// Session filter: if client has a session and message has a different one, skip
|
|
80
|
+
const msgSession = e.msg.sessionId;
|
|
81
|
+
if (sessionId && msgSession && msgSession !== sessionId)
|
|
82
|
+
return false;
|
|
83
|
+
return true;
|
|
84
|
+
});
|
|
77
85
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
/** Handle catch_up command: replay missed messages to the client. */
|
|
87
|
+
function handleCatchUp(ws, args) {
|
|
88
|
+
const lastSeq = typeof args?.lastSeq === "number" ? args.lastSeq : 0;
|
|
81
89
|
const clientSession = clientActiveSession.get(ws);
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const remaining = [];
|
|
88
|
-
for (const e of entries) {
|
|
89
|
-
const msgSession = e.msg.sessionId;
|
|
90
|
-
// Drain if: message has no session, client has no session, or sessions match
|
|
91
|
-
if (!msgSession || !clientSession || msgSession === clientSession) {
|
|
92
|
-
eligible.push(e);
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
remaining.push(e);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
eligibleEntries.push(...eligible);
|
|
99
|
-
if (remaining.length > 0)
|
|
100
|
-
remainingMap.set(sessionKey, remaining);
|
|
101
|
-
}
|
|
102
|
-
if (eligibleEntries.length === 0 && missedImageCount === 0)
|
|
90
|
+
const missed = getMessagesAfter(lastSeq, clientSession);
|
|
91
|
+
const currentSeq = nextSeq - 1;
|
|
92
|
+
if (missed.length === 0) {
|
|
93
|
+
// Still send response so client can update its epoch/seq tracking
|
|
94
|
+
sendTo(ws, { type: "catch_up", messages: [], serverSeq: currentSeq });
|
|
103
95
|
return;
|
|
104
|
-
let textCount = 0;
|
|
105
|
-
let voiceCount = 0;
|
|
106
|
-
for (const e of eligibleEntries) {
|
|
107
|
-
const t = e.msg.type;
|
|
108
|
-
if (t === "text")
|
|
109
|
-
textCount++;
|
|
110
|
-
else if (t === "voice")
|
|
111
|
-
voiceCount++;
|
|
112
96
|
}
|
|
113
|
-
|
|
114
|
-
const parts = [];
|
|
115
|
-
if (textCount > 0)
|
|
116
|
-
parts.push(`${textCount} text message(s)`);
|
|
117
|
-
if (voiceCount > 0)
|
|
118
|
-
parts.push(`${voiceCount} voice note(s)`);
|
|
119
|
-
if (missedImageCount > 0)
|
|
120
|
-
parts.push(`${missedImageCount} image(s)`);
|
|
121
|
-
if (otherCount > 0)
|
|
122
|
-
parts.push(`${otherCount} other`);
|
|
97
|
+
log(`[PAILot] catch_up: replaying ${missed.length} messages (client lastSeq=${lastSeq}, server seq=${currentSeq})`);
|
|
123
98
|
sendTo(ws, {
|
|
124
|
-
type: "
|
|
125
|
-
|
|
99
|
+
type: "catch_up",
|
|
100
|
+
messages: missed.map(e => e.msg),
|
|
101
|
+
serverSeq: currentSeq,
|
|
126
102
|
});
|
|
127
|
-
// Replay eligible buffered messages sorted by timestamp
|
|
128
|
-
eligibleEntries.sort((a, b) => a.timestamp - b.timestamp);
|
|
129
|
-
for (const entry of eligibleEntries) {
|
|
130
|
-
sendTo(ws, entry.msg);
|
|
131
|
-
}
|
|
132
|
-
// Replace outbox with only the non-drained (session-mismatched) entries
|
|
133
|
-
outboxMap.clear();
|
|
134
|
-
for (const [k, v] of remainingMap)
|
|
135
|
-
outboxMap.set(k, v);
|
|
136
|
-
missedImageCount = 0;
|
|
137
|
-
persistOutbox();
|
|
138
|
-
}
|
|
139
|
-
/** Persist outbox to disk so daemon restarts don't lose messages. */
|
|
140
|
-
function persistOutbox() {
|
|
141
|
-
try {
|
|
142
|
-
const { mkdirSync, writeFileSync: writeSync } = require("node:fs");
|
|
143
|
-
mkdirSync(OUTBOX_DIR, { recursive: true });
|
|
144
|
-
const data = {};
|
|
145
|
-
for (const [k, v] of outboxMap)
|
|
146
|
-
data[k] = v;
|
|
147
|
-
writeSync(join(OUTBOX_DIR, "pending.json"), JSON.stringify({ messages: data, missedImageCount }));
|
|
148
|
-
}
|
|
149
|
-
catch { /* best-effort */ }
|
|
150
|
-
}
|
|
151
|
-
/** Restore outbox from disk on startup. */
|
|
152
|
-
function restoreOutbox() {
|
|
153
|
-
try {
|
|
154
|
-
const path = join(OUTBOX_DIR, "pending.json");
|
|
155
|
-
if (!existsSync(path))
|
|
156
|
-
return;
|
|
157
|
-
const raw = readFileSync(path, "utf-8");
|
|
158
|
-
const data = JSON.parse(raw);
|
|
159
|
-
if (data.messages) {
|
|
160
|
-
for (const [k, v] of Object.entries(data.messages)) {
|
|
161
|
-
outboxMap.set(k, v);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (data.missedImageCount)
|
|
165
|
-
missedImageCount = data.missedImageCount;
|
|
166
|
-
log(`[PAILot] Restored ${outboxMap.size} outbox queue(s) from disk`);
|
|
167
|
-
// Clean up the file after restoring
|
|
168
|
-
unlinkSync(path);
|
|
169
|
-
}
|
|
170
|
-
catch { /* ignore */ }
|
|
171
103
|
}
|
|
172
104
|
function isClientAlive(ws) {
|
|
173
105
|
if (ws.readyState !== WebSocket.OPEN)
|
|
@@ -209,7 +141,6 @@ function isClaudeRelated(snap) {
|
|
|
209
141
|
function handleSyncCommand(ws, args) {
|
|
210
142
|
if (!hybridManager) {
|
|
211
143
|
handleSessionsCommand(ws);
|
|
212
|
-
drainOutbox(ws);
|
|
213
144
|
return;
|
|
214
145
|
}
|
|
215
146
|
const clientActiveId = typeof args?.activeSessionId === "string" ? args.activeSessionId : undefined;
|
|
@@ -256,7 +187,6 @@ function handleSyncCommand(ws, args) {
|
|
|
256
187
|
clientActiveSession.set(ws, clientActiveId);
|
|
257
188
|
log(`[PAILot] sync: restored client session "${sessions[idx].name}" (${clientActiveId.slice(0, 8)}...)`);
|
|
258
189
|
handleSessionsCommand(ws);
|
|
259
|
-
drainOutbox(ws);
|
|
260
190
|
return;
|
|
261
191
|
}
|
|
262
192
|
// Client's session no longer exists in iTerm — fall through to iTerm focus
|
|
@@ -307,9 +237,8 @@ end tell`)?.trim() ?? "";
|
|
|
307
237
|
log(`[PAILot] sync: no focused session found — defaulting to first session "${sessions[0].name}"`);
|
|
308
238
|
}
|
|
309
239
|
}
|
|
310
|
-
// Return sessions with updated active state
|
|
240
|
+
// Return sessions with updated active state
|
|
311
241
|
handleSessionsCommand(ws);
|
|
312
|
-
drainOutbox(ws);
|
|
313
242
|
}
|
|
314
243
|
function handleSessionsCommand(ws) {
|
|
315
244
|
if (!hybridManager) {
|
|
@@ -354,6 +283,10 @@ function handleSessionsCommand(ws) {
|
|
|
354
283
|
if (ws.readyState === WebSocket.OPEN) {
|
|
355
284
|
ws.send(payload);
|
|
356
285
|
}
|
|
286
|
+
// MQTT dual-publish session list (retained) — Phase 1
|
|
287
|
+
if (isMqttRunning()) {
|
|
288
|
+
mqttPublishSessions(sessions);
|
|
289
|
+
}
|
|
357
290
|
}
|
|
358
291
|
function handleSwitchCommand(ws, args) {
|
|
359
292
|
const sessionIndex = args.index;
|
|
@@ -414,6 +347,15 @@ end tell`);
|
|
|
414
347
|
clientActiveSession.set(ws, session.backendSessionId);
|
|
415
348
|
sendTo(ws, { type: "session_switched", name: session.name, sessionId: session.backendSessionId });
|
|
416
349
|
log(`[PAILot] switched to ${session.kind} session "${session.name}" (${session.id})`);
|
|
350
|
+
// MQTT dual-publish control response (Phase 1)
|
|
351
|
+
if (isMqttRunning()) {
|
|
352
|
+
mqttPublishControl({ type: "session_switched", sessionId: session.backendSessionId, name: session.name });
|
|
353
|
+
}
|
|
354
|
+
// Auto-drain: replay any messages for the new session that were skipped while
|
|
355
|
+
// the client was viewing a different session. Uses lastSeq=0 which is safe
|
|
356
|
+
// because getMessagesAfter filters by sessionId — only messages for this
|
|
357
|
+
// session are returned. The client deduplicates via seenSeqsRef.
|
|
358
|
+
handleCatchUp(ws, { lastSeq: 0 });
|
|
417
359
|
}
|
|
418
360
|
function handleRenameCommand(ws, args) {
|
|
419
361
|
const sessionId = args.sessionId;
|
|
@@ -434,6 +376,10 @@ function handleRenameCommand(ws, args) {
|
|
|
434
376
|
}
|
|
435
377
|
}
|
|
436
378
|
sendTo(ws, { type: "session_renamed", sessionId, name });
|
|
379
|
+
// MQTT dual-publish control response (Phase 1)
|
|
380
|
+
if (isMqttRunning()) {
|
|
381
|
+
mqttPublishControl({ type: "session_renamed", sessionId, name });
|
|
382
|
+
}
|
|
437
383
|
// Send updated sessions list so PAILot refreshes the header
|
|
438
384
|
handleSessionsCommand(ws);
|
|
439
385
|
log(`[PAILot] renamed session ${sessionId} to "${name}"`);
|
|
@@ -552,13 +498,23 @@ async function handleNavCommand(ws, args) {
|
|
|
552
498
|
// sendKeystrokeToSession takes ASCII code: 13=enter, 9=tab, 27=escape
|
|
553
499
|
// sendEscapeSequenceToSession takes ANSI direction char: A=up, B=down, C=right, D=left
|
|
554
500
|
const keyMap = {
|
|
501
|
+
// Arrow keys (descriptive names)
|
|
555
502
|
up: () => sendEscapeSequenceToSession(targetSession, "A"),
|
|
556
503
|
down: () => sendEscapeSequenceToSession(targetSession, "B"),
|
|
557
504
|
left: () => sendEscapeSequenceToSession(targetSession, "D"),
|
|
558
505
|
right: () => sendEscapeSequenceToSession(targetSession, "C"),
|
|
506
|
+
// Vim-style arrow keys (Flutter nav screen sends these)
|
|
507
|
+
k: () => sendEscapeSequenceToSession(targetSession, "A"),
|
|
508
|
+
j: () => sendEscapeSequenceToSession(targetSession, "B"),
|
|
509
|
+
h: () => sendEscapeSequenceToSession(targetSession, "D"),
|
|
510
|
+
l: () => sendEscapeSequenceToSession(targetSession, "C"),
|
|
511
|
+
// Action keys
|
|
559
512
|
enter: () => sendKeystrokeToSession(targetSession, 13),
|
|
513
|
+
Return: () => sendKeystrokeToSession(targetSession, 13),
|
|
560
514
|
tab: () => sendKeystrokeToSession(targetSession, 9),
|
|
515
|
+
Tab: () => sendKeystrokeToSession(targetSession, 9),
|
|
561
516
|
escape: () => sendKeystrokeToSession(targetSession, 27),
|
|
517
|
+
Escape: () => sendKeystrokeToSession(targetSession, 27),
|
|
562
518
|
"ctrl-c": () => {
|
|
563
519
|
// Send Ctrl+C (ETX, ASCII 3)
|
|
564
520
|
runAppleScript(`tell application "iTerm2"
|
|
@@ -572,6 +528,20 @@ async function handleNavCommand(ws, args) {
|
|
|
572
528
|
end repeat
|
|
573
529
|
end repeat
|
|
574
530
|
end repeat
|
|
531
|
+
end tell`);
|
|
532
|
+
},
|
|
533
|
+
"ctrl+c": () => {
|
|
534
|
+
runAppleScript(`tell application "iTerm2"
|
|
535
|
+
repeat with w in windows
|
|
536
|
+
repeat with t in tabs of w
|
|
537
|
+
repeat with s in sessions of t
|
|
538
|
+
if id of s is "${targetSession}" then
|
|
539
|
+
tell s to write text (ASCII character 3)
|
|
540
|
+
return
|
|
541
|
+
end if
|
|
542
|
+
end repeat
|
|
543
|
+
end repeat
|
|
544
|
+
end repeat
|
|
575
545
|
end tell`);
|
|
576
546
|
},
|
|
577
547
|
};
|
|
@@ -580,8 +550,10 @@ end tell`);
|
|
|
580
550
|
action();
|
|
581
551
|
}
|
|
582
552
|
else {
|
|
583
|
-
//
|
|
584
|
-
|
|
553
|
+
// Send each character individually (vi keys like "dd" need separate keystrokes)
|
|
554
|
+
for (const ch of key) {
|
|
555
|
+
pasteTextIntoSession(targetSession, ch);
|
|
556
|
+
}
|
|
585
557
|
}
|
|
586
558
|
log(`[PAILot] nav: sent ${key} to session ${targetSession.slice(0, 8)}...`);
|
|
587
559
|
// Auto-screenshot after navigation key with a brief delay for render
|
|
@@ -590,11 +562,10 @@ end tell`);
|
|
|
590
562
|
await triggerScreenshotForPailot();
|
|
591
563
|
}
|
|
592
564
|
}
|
|
593
|
-
async function triggerScreenshotForPailot() {
|
|
565
|
+
async function triggerScreenshotForPailot(sessionId) {
|
|
594
566
|
if (!screenshotHandler)
|
|
595
567
|
return;
|
|
596
|
-
|
|
597
|
-
await screenshotHandler("pailot");
|
|
568
|
+
await screenshotHandler("pailot", sessionId);
|
|
598
569
|
}
|
|
599
570
|
// --- Helpers ---
|
|
600
571
|
function sendTo(ws, msg) {
|
|
@@ -602,41 +573,60 @@ function sendTo(ws, msg) {
|
|
|
602
573
|
ws.send(JSON.stringify(msg));
|
|
603
574
|
}
|
|
604
575
|
}
|
|
605
|
-
function broadcast(msg,
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
576
|
+
function broadcast(msg, direct) {
|
|
577
|
+
// MQTT dual-publish typing indicators (Phase 1)
|
|
578
|
+
// Other message types are published by their specific broadcast* functions.
|
|
579
|
+
if (isMqttRunning() && msg.type === "typing") {
|
|
580
|
+
const sid = msg.sessionId;
|
|
581
|
+
if (sid)
|
|
582
|
+
mqttPublishTyping(sid, !!msg.typing);
|
|
583
|
+
}
|
|
584
|
+
// Append to message log FIRST — every non-ephemeral message gets a seq number.
|
|
585
|
+
// Clients that miss it will catch up via the catch_up command.
|
|
586
|
+
const seq = appendToLog(msg);
|
|
587
|
+
if (seq > 0)
|
|
588
|
+
msg = { ...msg, seq };
|
|
610
589
|
// Session filtering: if the message carries a sessionId, only deliver to
|
|
611
590
|
// clients viewing that session. This prevents cross-session content bleed.
|
|
612
|
-
//
|
|
613
|
-
//
|
|
591
|
+
// When a message is gated, notify live clients so they can show an unread badge.
|
|
592
|
+
// Exception: "direct" messages (explicit pailot_send replies) bypass the gate —
|
|
593
|
+
// the user expects to see responses from any session they interact with.
|
|
614
594
|
const msgSessionId = msg.sessionId;
|
|
615
|
-
|
|
616
|
-
let delivered = false;
|
|
595
|
+
let delivered = 0;
|
|
617
596
|
let skippedDead = 0;
|
|
618
597
|
let skippedSession = 0;
|
|
598
|
+
const gatedClients = [];
|
|
619
599
|
const payload = JSON.stringify(msg);
|
|
620
600
|
for (const ws of clients) {
|
|
621
601
|
if (!isClientAlive(ws)) {
|
|
622
602
|
skippedDead++;
|
|
623
603
|
continue;
|
|
624
604
|
}
|
|
625
|
-
|
|
626
|
-
if (applySessionGate && msgSessionId) {
|
|
605
|
+
if (msgSessionId && !direct) {
|
|
627
606
|
const clientSession = clientActiveSession.get(ws);
|
|
628
607
|
if (clientSession && clientSession !== msgSessionId) {
|
|
629
608
|
skippedSession++;
|
|
609
|
+
gatedClients.push(ws);
|
|
630
610
|
continue;
|
|
631
611
|
}
|
|
632
612
|
}
|
|
633
613
|
ws.send(payload);
|
|
634
|
-
delivered
|
|
614
|
+
delivered++;
|
|
615
|
+
}
|
|
616
|
+
log(`[PAILot] broadcast type=${msg.type} seq=${seq} session=${msgSessionId ?? "none"}: delivered=${delivered} skippedDead=${skippedDead} skippedSession=${skippedSession}${direct ? " DIRECT" : ""}`);
|
|
617
|
+
// Debug: log payload for text messages to diagnose app-side rendering issues
|
|
618
|
+
if (msg.type === "text" && delivered > 0) {
|
|
619
|
+
log(`[PAILot] DEBUG payload: ${payload.slice(0, 200)}`);
|
|
635
620
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
621
|
+
// Notify live clients viewing other sessions about unread messages
|
|
622
|
+
if (delivered === 0 && gatedClients.length > 0 && msg.type !== "typing") {
|
|
623
|
+
const unreadNotification = JSON.stringify({
|
|
624
|
+
type: "unread",
|
|
625
|
+
sessionId: msgSessionId,
|
|
626
|
+
});
|
|
627
|
+
for (const ws of gatedClients) {
|
|
628
|
+
ws.send(unreadNotification);
|
|
629
|
+
}
|
|
640
630
|
}
|
|
641
631
|
}
|
|
642
632
|
// --- Voice message batching ---
|
|
@@ -648,6 +638,11 @@ let voiceBatchTranscripts = [];
|
|
|
648
638
|
let voiceBatchOnMessage = null;
|
|
649
639
|
/** iTerm session ID resolved when the first voice chunk of this batch arrived. */
|
|
650
640
|
let voiceBatchSessionId = "";
|
|
641
|
+
/** Set the voice batch session from external callers (MQTT path). */
|
|
642
|
+
export function setVoiceBatchSession(sessionId) {
|
|
643
|
+
if (!voiceBatchSessionId)
|
|
644
|
+
voiceBatchSessionId = sessionId;
|
|
645
|
+
}
|
|
651
646
|
function flushVoiceBatch() {
|
|
652
647
|
if (voiceBatchTranscripts.length === 0)
|
|
653
648
|
return;
|
|
@@ -658,8 +653,8 @@ function flushVoiceBatch() {
|
|
|
658
653
|
voiceBatchOnMessage = null;
|
|
659
654
|
voiceBatchSessionId = "";
|
|
660
655
|
voiceBatchTimer = null;
|
|
661
|
-
log(`[PAILot] Flushing voice batch (${combined.length} chars)`);
|
|
662
656
|
const routeSession = batchSession || activeItermSessionId;
|
|
657
|
+
log(`[PAILot] Flushing voice batch (${combined.length} chars) → session=${routeSession?.slice(0, 8) ?? "none"} (batch=${batchSession?.slice(0, 8) ?? "none"}, active=${activeItermSessionId?.slice(0, 8) ?? "none"})`);
|
|
663
658
|
if (routeSession) {
|
|
664
659
|
pailotReplyMap.set(routeSession, routeSession);
|
|
665
660
|
setLastRoutedSessionId(routeSession);
|
|
@@ -676,7 +671,7 @@ function flushVoiceBatch() {
|
|
|
676
671
|
}
|
|
677
672
|
// --- Voice transcription for PAILot ---
|
|
678
673
|
const execFileAsync = promisify(execFile);
|
|
679
|
-
async function transcribeAndRoute(audioBase64, onMessage, messageId) {
|
|
674
|
+
export async function transcribeAndRoute(audioBase64, onMessage, messageId) {
|
|
680
675
|
const base = `pailot-voice-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
681
676
|
const audioFile = join(tmpdir(), `${base}.m4a`);
|
|
682
677
|
const filesToClean = [
|
|
@@ -714,6 +709,10 @@ async function transcribeAndRoute(audioBase64, onMessage, messageId) {
|
|
|
714
709
|
// Reflect transcript back to the app so the voice bubble shows text
|
|
715
710
|
if (messageId) {
|
|
716
711
|
broadcast({ type: "transcript", messageId, content: transcript });
|
|
712
|
+
// MQTT dual-publish (Phase 1)
|
|
713
|
+
if (isMqttRunning()) {
|
|
714
|
+
mqttPublishTranscript(messageId, transcript);
|
|
715
|
+
}
|
|
717
716
|
}
|
|
718
717
|
// Batch: accumulate transcripts and reset the timer
|
|
719
718
|
voiceBatchTranscripts.push(transcript);
|
|
@@ -741,8 +740,6 @@ async function transcribeAndRoute(audioBase64, onMessage, messageId) {
|
|
|
741
740
|
*/
|
|
742
741
|
export function startWsGateway(onMessage) {
|
|
743
742
|
wss = new WebSocketServer({ port: WS_PORT });
|
|
744
|
-
// Restore any buffered messages from a previous daemon run
|
|
745
|
-
restoreOutbox();
|
|
746
743
|
// Server-side ping: keep connections alive and detect dead iOS clients.
|
|
747
744
|
// iOS suspends backgrounded apps but keeps the TCP socket "open" — without
|
|
748
745
|
// server pings, ws.readyState stays OPEN indefinitely even though the app is gone.
|
|
@@ -831,6 +828,9 @@ end tell`)?.trim() ?? "";
|
|
|
831
828
|
case "sync":
|
|
832
829
|
handleSyncCommand(ws, args);
|
|
833
830
|
return;
|
|
831
|
+
case "catch_up":
|
|
832
|
+
handleCatchUp(ws, args);
|
|
833
|
+
return;
|
|
834
834
|
case "switch":
|
|
835
835
|
handleSwitchCommand(ws, args);
|
|
836
836
|
return;
|
|
@@ -905,15 +905,32 @@ end tell`)?.trim() ?? "";
|
|
|
905
905
|
});
|
|
906
906
|
return;
|
|
907
907
|
}
|
|
908
|
-
// Image message — save to temp file, route caption as text
|
|
909
|
-
// NOTE: Do NOT send the file path to Claude Code — it tries to read .jpg files
|
|
910
|
-
// as images, which corrupts the conversation context with unprocessable image data.
|
|
908
|
+
// Image message — save to temp file, convert HEIC→JPEG if needed, route caption as text
|
|
911
909
|
if (msg.type === "image" && msg.imageBase64) {
|
|
912
|
-
const
|
|
913
|
-
const imgPath = join(tmpdir(), `pailot-img-${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
|
|
910
|
+
const mime = (msg.mimeType ?? "image/jpeg").toLowerCase();
|
|
914
911
|
const imgBuf = Buffer.from(msg.imageBase64, "base64");
|
|
915
|
-
|
|
916
|
-
|
|
912
|
+
let imgPath;
|
|
913
|
+
if (mime.includes("heic") || mime.includes("heif")) {
|
|
914
|
+
// HEIC/HEIF: save with real extension, convert to JPEG via macOS sips
|
|
915
|
+
const heicPath = join(tmpdir(), `pailot-img-${Date.now()}-${randomUUID().slice(0, 8)}.heic`);
|
|
916
|
+
imgPath = heicPath.replace(/\.heic$/, ".jpg");
|
|
917
|
+
writeFileSync(heicPath, imgBuf);
|
|
918
|
+
try {
|
|
919
|
+
execFileSync("sips", ["-s", "format", "jpeg", heicPath, "--out", imgPath], { timeout: 10000 });
|
|
920
|
+
unlinkSync(heicPath);
|
|
921
|
+
log(`[PAILot] HEIC→JPEG converted (${imgBuf.length} bytes) → ${imgPath}`);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
log(`[PAILot] HEIC conversion failed: ${err}, keeping original`);
|
|
925
|
+
imgPath = heicPath;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
const ext = mime.includes("png") ? "png" : "jpg";
|
|
930
|
+
imgPath = join(tmpdir(), `pailot-img-${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
|
|
931
|
+
writeFileSync(imgPath, imgBuf);
|
|
932
|
+
log(`[PAILot] Image saved (${imgBuf.length} bytes) → ${imgPath}`);
|
|
933
|
+
}
|
|
917
934
|
const caption = msg.caption || "";
|
|
918
935
|
// Embed the path inside parentheses so Claude Code doesn't auto-attach
|
|
919
936
|
// the .jpg as an image (which corrupts the session if the API rejects it).
|
|
@@ -956,8 +973,8 @@ end tell`)?.trim() ?? "";
|
|
|
956
973
|
setMessageSource("whatsapp");
|
|
957
974
|
}
|
|
958
975
|
}
|
|
959
|
-
catch {
|
|
960
|
-
log(`[PAILot] Invalid message from ${addr}`);
|
|
976
|
+
catch (parseErr) {
|
|
977
|
+
log(`[PAILot] Invalid message from ${addr}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)} — raw: ${String(raw).slice(0, 300)}`);
|
|
961
978
|
}
|
|
962
979
|
});
|
|
963
980
|
ws.on("close", () => {
|
|
@@ -972,9 +989,8 @@ end tell`)?.trim() ?? "";
|
|
|
972
989
|
clientLastActive.delete(ws);
|
|
973
990
|
clientActiveSession.delete(ws);
|
|
974
991
|
});
|
|
975
|
-
//
|
|
992
|
+
// Outbox drains after client sends "sync" command or next ping
|
|
976
993
|
// (so activeSessionId is set before messages arrive)
|
|
977
|
-
sendTo(ws, { type: "text", content: "Connected to PAILot gateway." });
|
|
978
994
|
});
|
|
979
995
|
wss.on("error", (err) => {
|
|
980
996
|
log(`WebSocket gateway error: ${err.message}`);
|
|
@@ -1003,19 +1019,25 @@ function resolveSessionId(sessionId) {
|
|
|
1003
1019
|
// Last resort: ask hybrid manager for the active session's backend ID
|
|
1004
1020
|
return hybridManager?.activeSession?.backendSessionId || undefined;
|
|
1005
1021
|
}
|
|
1006
|
-
export function broadcastText(text, sessionId,
|
|
1007
|
-
const resolvedSession = resolveSessionId(sessionId);
|
|
1008
|
-
broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) },
|
|
1009
|
-
broadcast({ type: "text", content: text, ...(resolvedSession && { sessionId: resolvedSession }) },
|
|
1022
|
+
export function broadcastText(text, sessionId, direct) {
|
|
1023
|
+
const resolvedSession = sessionId || resolveSessionId(sessionId);
|
|
1024
|
+
broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) }, direct);
|
|
1025
|
+
broadcast({ type: "text", content: text, ...(resolvedSession && { sessionId: resolvedSession }) }, direct);
|
|
1026
|
+
// MQTT publish — always publish when broker is running
|
|
1027
|
+
if (isMqttRunning()) {
|
|
1028
|
+
if (resolvedSession)
|
|
1029
|
+
mqttPublishTyping(resolvedSession, false);
|
|
1030
|
+
mqttPublishText(resolvedSession ?? "global", text);
|
|
1031
|
+
}
|
|
1010
1032
|
}
|
|
1011
1033
|
/**
|
|
1012
1034
|
* Broadcast a voice note to all connected PAILot clients.
|
|
1013
1035
|
* Converts OGG Opus to M4A (AAC) since iOS can't play OGG natively.
|
|
1014
1036
|
* @param sessionId — iTerm session ID of the originating Claude session
|
|
1015
1037
|
*/
|
|
1016
|
-
export async function broadcastVoice(audioBuffer, transcript, sessionId,
|
|
1017
|
-
const resolvedSession = resolveSessionId(sessionId);
|
|
1018
|
-
broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) },
|
|
1038
|
+
export async function broadcastVoice(audioBuffer, transcript, sessionId, direct) {
|
|
1039
|
+
const resolvedSession = sessionId || resolveSessionId(sessionId);
|
|
1040
|
+
broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) }, direct);
|
|
1019
1041
|
let sendBuffer = audioBuffer;
|
|
1020
1042
|
// Convert OGG Opus → M4A for iOS compatibility
|
|
1021
1043
|
try {
|
|
@@ -1038,25 +1060,39 @@ export async function broadcastVoice(audioBuffer, transcript, sessionId, opts) {
|
|
|
1038
1060
|
catch (err) {
|
|
1039
1061
|
log(`[PAILot] OGG→M4A conversion failed, sending raw: ${err}`);
|
|
1040
1062
|
}
|
|
1063
|
+
const voiceBase64 = sendBuffer.toString("base64");
|
|
1041
1064
|
broadcast({
|
|
1042
1065
|
type: "voice",
|
|
1043
1066
|
content: transcript,
|
|
1044
|
-
audioBase64:
|
|
1067
|
+
audioBase64: voiceBase64,
|
|
1045
1068
|
...(resolvedSession && { sessionId: resolvedSession }),
|
|
1046
|
-
},
|
|
1069
|
+
}, direct);
|
|
1070
|
+
if (isMqttRunning()) {
|
|
1071
|
+
if (resolvedSession)
|
|
1072
|
+
mqttPublishTyping(resolvedSession, false);
|
|
1073
|
+
mqttPublishVoice(resolvedSession ?? "global", voiceBase64, transcript);
|
|
1074
|
+
}
|
|
1047
1075
|
}
|
|
1048
1076
|
/**
|
|
1049
1077
|
* Broadcast a screenshot/image to all connected PAILot clients.
|
|
1050
1078
|
* @param sessionId — iTerm session ID of the originating Claude session
|
|
1051
1079
|
*/
|
|
1052
|
-
export function broadcastImage(imageBuffer, caption, sessionId) {
|
|
1053
|
-
const resolvedSession = resolveSessionId(sessionId);
|
|
1080
|
+
export function broadcastImage(imageBuffer, caption, sessionId, direct) {
|
|
1081
|
+
const resolvedSession = sessionId || resolveSessionId(sessionId);
|
|
1082
|
+
const imgBase64 = imageBuffer.toString("base64");
|
|
1054
1083
|
broadcast({
|
|
1055
1084
|
type: "image",
|
|
1056
|
-
imageBase64:
|
|
1085
|
+
imageBase64: imgBase64,
|
|
1057
1086
|
caption: caption ?? "Screenshot",
|
|
1058
1087
|
...(resolvedSession && { sessionId: resolvedSession }),
|
|
1059
|
-
});
|
|
1088
|
+
}, direct);
|
|
1089
|
+
if (isMqttRunning()) {
|
|
1090
|
+
const target = resolvedSession ?? "global";
|
|
1091
|
+
mqttPublishImage(target, imgBase64, caption ?? "Screenshot");
|
|
1092
|
+
if ((caption ?? "Screenshot").toLowerCase().includes("screenshot")) {
|
|
1093
|
+
mqttPublishScreenshot(target, imgBase64);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1060
1096
|
}
|
|
1061
1097
|
/**
|
|
1062
1098
|
* Broadcast a status change to all connected PAILot clients.
|
|
@@ -1064,6 +1100,10 @@ export function broadcastImage(imageBuffer, caption, sessionId) {
|
|
|
1064
1100
|
*/
|
|
1065
1101
|
export function broadcastStatus(status) {
|
|
1066
1102
|
broadcast({ type: "status", status });
|
|
1103
|
+
// MQTT dual-publish (Phase 1)
|
|
1104
|
+
if (isMqttRunning()) {
|
|
1105
|
+
mqttPubStatus(status);
|
|
1106
|
+
}
|
|
1067
1107
|
}
|
|
1068
1108
|
/**
|
|
1069
1109
|
* Returns true if any PAILot clients are connected.
|
|
@@ -1083,4 +1123,174 @@ export function stopWsGateway() {
|
|
|
1083
1123
|
wss = null;
|
|
1084
1124
|
}
|
|
1085
1125
|
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Handle a command from MQTT (no WebSocket needed).
|
|
1128
|
+
* Replicates the WS command dispatcher for MQTT-only clients.
|
|
1129
|
+
*/
|
|
1130
|
+
export function handleMqttCommand(command, args = {}) {
|
|
1131
|
+
if (!hybridManager) {
|
|
1132
|
+
log(`[MQTT] command ${command} — no hybridManager`);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
switch (command) {
|
|
1136
|
+
case "sessions": {
|
|
1137
|
+
// Prune dead sessions and publish current list via MQTT
|
|
1138
|
+
const liveSnapshots = snapshotAllSessions();
|
|
1139
|
+
const liveIds = new Set(liveSnapshots.map(s => s.id));
|
|
1140
|
+
hybridManager.pruneDeadVisualSessions(liveIds);
|
|
1141
|
+
const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
|
|
1142
|
+
const seenTabs = new Set();
|
|
1143
|
+
for (const snap of liveSnapshots) {
|
|
1144
|
+
if (!isClaudeRelated(snap))
|
|
1145
|
+
continue;
|
|
1146
|
+
const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
|
|
1147
|
+
if (seenTabs.has(displayName))
|
|
1148
|
+
continue;
|
|
1149
|
+
seenTabs.add(displayName);
|
|
1150
|
+
if (!knownIds.has(snap.id)) {
|
|
1151
|
+
hybridManager.registerVisualSession(displayName, "", snap.id);
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
const existing = hybridManager.listSessions().find(s => s.backendSessionId === snap.id);
|
|
1155
|
+
if (existing && existing.name !== displayName)
|
|
1156
|
+
existing.name = displayName;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const active = hybridManager.activeSession;
|
|
1160
|
+
const sessions = hybridManager.listSessions().map((s, i) => ({
|
|
1161
|
+
index: i + 1,
|
|
1162
|
+
name: s.name,
|
|
1163
|
+
type: "claude",
|
|
1164
|
+
kind: s.kind,
|
|
1165
|
+
isActive: active ? s.id === active.id : false,
|
|
1166
|
+
id: s.backendSessionId,
|
|
1167
|
+
}));
|
|
1168
|
+
mqttPublishSessions(sessions);
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
case "sync": {
|
|
1172
|
+
// Sync is sessions + set active from client preference
|
|
1173
|
+
const clientActiveId = args.activeSessionId;
|
|
1174
|
+
if (clientActiveId) {
|
|
1175
|
+
const sessions = hybridManager.listSessions();
|
|
1176
|
+
const idx = sessions.findIndex(s => s.backendSessionId === clientActiveId);
|
|
1177
|
+
if (idx >= 0) {
|
|
1178
|
+
hybridManager.switchToIndex(idx + 1);
|
|
1179
|
+
setActiveItermSessionId(clientActiveId);
|
|
1180
|
+
setLastRoutedSessionId(clientActiveId);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
handleMqttCommand("sessions");
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
case "switch": {
|
|
1187
|
+
const sessionId = args.sessionId;
|
|
1188
|
+
const sessionIndex = args.index;
|
|
1189
|
+
let targetIndex;
|
|
1190
|
+
if (sessionIndex) {
|
|
1191
|
+
targetIndex = sessionIndex;
|
|
1192
|
+
}
|
|
1193
|
+
else if (sessionId) {
|
|
1194
|
+
const sessions = hybridManager.listSessions();
|
|
1195
|
+
const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
|
|
1196
|
+
if (idx >= 0)
|
|
1197
|
+
targetIndex = idx + 1;
|
|
1198
|
+
}
|
|
1199
|
+
if (targetIndex) {
|
|
1200
|
+
const session = hybridManager.switchToIndex(targetIndex);
|
|
1201
|
+
if (session?.kind === "visual") {
|
|
1202
|
+
setActiveItermSessionId(session.backendSessionId);
|
|
1203
|
+
}
|
|
1204
|
+
setLastRoutedSessionId(session?.backendSessionId ?? "");
|
|
1205
|
+
mqttPublishControl({ type: "session_switched", sessionId: session?.backendSessionId, name: session?.name });
|
|
1206
|
+
handleMqttCommand("sessions");
|
|
1207
|
+
}
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
case "screenshot": {
|
|
1211
|
+
const ssSessionId = args.sessionId;
|
|
1212
|
+
log(`[MQTT] screenshot command: requested session=${ssSessionId?.slice(0, 8) ?? "none"}`);
|
|
1213
|
+
if (ssSessionId) {
|
|
1214
|
+
setActiveItermSessionId(ssSessionId);
|
|
1215
|
+
setLastRoutedSessionId(ssSessionId);
|
|
1216
|
+
}
|
|
1217
|
+
triggerScreenshotForPailot(ssSessionId).catch((err) => {
|
|
1218
|
+
log(`[MQTT] screenshot error: ${err}`);
|
|
1219
|
+
});
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
case "nav": {
|
|
1223
|
+
handleNavCommand(null, args).catch((err) => {
|
|
1224
|
+
log(`[MQTT] nav error: ${err}`);
|
|
1225
|
+
});
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
case "rename": {
|
|
1229
|
+
const sessionId = args.sessionId;
|
|
1230
|
+
const name = args.name;
|
|
1231
|
+
if (sessionId && name) {
|
|
1232
|
+
const sessions = hybridManager.listSessions();
|
|
1233
|
+
const session = sessions.find(s => s.backendSessionId === sessionId);
|
|
1234
|
+
if (session) {
|
|
1235
|
+
session.name = name;
|
|
1236
|
+
if (session.kind === "visual") {
|
|
1237
|
+
setItermSessionVar(sessionId, name);
|
|
1238
|
+
setItermTabName(sessionId, name);
|
|
1239
|
+
setItermBadge(sessionId, name);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
mqttPublishControl({ type: "session_renamed", sessionId, name });
|
|
1243
|
+
handleMqttCommand("sessions");
|
|
1244
|
+
}
|
|
1245
|
+
break;
|
|
1246
|
+
}
|
|
1247
|
+
case "remove": {
|
|
1248
|
+
const sessionId = args.sessionId;
|
|
1249
|
+
if (sessionId) {
|
|
1250
|
+
const sessions = hybridManager.listSessions();
|
|
1251
|
+
const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
|
|
1252
|
+
if (idx >= 0) {
|
|
1253
|
+
const target = sessions[idx];
|
|
1254
|
+
if (target.kind === "visual" && target.backendSessionId) {
|
|
1255
|
+
killSession(target.backendSessionId);
|
|
1256
|
+
}
|
|
1257
|
+
hybridManager.removeByIndex(idx + 1);
|
|
1258
|
+
}
|
|
1259
|
+
handleMqttCommand("sessions");
|
|
1260
|
+
}
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
case "create": {
|
|
1264
|
+
const path = args.path;
|
|
1265
|
+
const command = path ? `cd ${path.replace(/"/g, '\\"')} && claude` : "claude";
|
|
1266
|
+
const name = path ? path.split("/").filter(Boolean).pop() ?? "Claude" : "Claude";
|
|
1267
|
+
const sessionId = createClaudeSession(command);
|
|
1268
|
+
if (!sessionId) {
|
|
1269
|
+
log("[MQTT] create: failed to create session");
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
setItermSessionVar(sessionId, name);
|
|
1273
|
+
setItermTabName(sessionId, name);
|
|
1274
|
+
setItermBadge(sessionId, name);
|
|
1275
|
+
hybridManager.registerVisualSession(name, "", sessionId);
|
|
1276
|
+
const sessions = hybridManager.listSessions();
|
|
1277
|
+
const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
|
|
1278
|
+
if (idx >= 0) {
|
|
1279
|
+
hybridManager.switchToIndex(idx + 1);
|
|
1280
|
+
setActiveItermSessionId(sessionId);
|
|
1281
|
+
setLastRoutedSessionId(sessionId);
|
|
1282
|
+
}
|
|
1283
|
+
mqttPublishControl({ type: "session_switched", name, sessionId });
|
|
1284
|
+
handleMqttCommand("sessions");
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
case "catch_up": {
|
|
1288
|
+
// MQTT handles delivery natively — just update the session list
|
|
1289
|
+
handleMqttCommand("sessions");
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
default:
|
|
1293
|
+
log(`[MQTT] unknown command: ${command}`);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1086
1296
|
//# sourceMappingURL=gateway.js.map
|