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.
@@ -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 { tmpdir, homedir } from "node:os";
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
- // --- Per-session message outbox for offline/backgrounded clients ---
52
- // Buffers messages when no live client can receive them.
53
- // Stored per-session so drain delivers to the correct session.
54
- const MAX_OUTBOX_PER_SESSION = 50;
55
- const OUTBOX_DIR = join(homedir(), ".aibroker", "outbox");
56
- const outboxMap = new Map(); // sessionId → entries
57
- let missedImageCount = 0;
58
- function addToOutbox(msg) {
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
- if (type === "typing")
61
- return;
62
- if (type === "image") {
63
- missedImageCount++;
64
- return;
65
- }
66
- const sessionId = msg.sessionId || "_global";
67
- let queue = outboxMap.get(sessionId);
68
- if (!queue) {
69
- queue = [];
70
- outboxMap.set(sessionId, queue);
71
- }
72
- queue.push({ msg, timestamp: Date.now() });
73
- if (queue.length > MAX_OUTBOX_PER_SESSION)
74
- queue.shift();
75
- // Persist to disk (best-effort, async)
76
- persistOutbox();
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
- function drainOutbox(ws) {
79
- // Determine which session this client is currently viewing.
80
- // Only drain messages that match the client's session or have no session ID.
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
- // Collect entries eligible for this client, leaving non-matching entries in place.
83
- const eligibleEntries = [];
84
- const remainingMap = new Map();
85
- for (const [sessionKey, entries] of outboxMap.entries()) {
86
- const eligible = [];
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
- const otherCount = eligibleEntries.length - textCount - voiceCount;
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: "text",
125
- content: `📬 While you were away: ${parts.join(", ")}`,
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, then drain buffered messages
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
- // Fallback: send as literal text (vi keys like "dd", "0", "G", etc.)
584
- pasteTextIntoSession(targetSession, key);
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
- // Only send to PAILot — this is triggered by PAILot commands
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, opts) {
606
- // Only deliver to clients that have proven liveness recently.
607
- // iOS can keep a WebSocket "open" while the app is backgrounded —
608
- // ws.send() succeeds but the app never processes the data.
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
- // Exception: direct replies (pailot_send) skip the session gate the user
613
- // must see replies regardless of which session they're viewing.
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
- const applySessionGate = !opts?.skipSessionGate;
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
- // Session gate: if both the message and client have a session, they must match
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 = true;
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
- log(`[PAILot] broadcast type=${msg.type} session=${msgSessionId ?? "none"}: delivered=${delivered ? 1 : 0} skippedDead=${skippedDead} skippedSession=${skippedSession} outboxed=${!delivered}${opts?.skipSessionGate ? " (gate-bypass)" : ""}`);
637
- // No live clients received the message buffer for later
638
- if (!delivered) {
639
- addToOutbox(msg);
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 ext = (msg.mimeType ?? "image/jpeg").includes("png") ? "png" : "jpg";
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
- writeFileSync(imgPath, imgBuf);
916
- log(`[PAILot] Image saved (${imgBuf.length} bytes) → ${imgPath}`);
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
- // Welcome — outbox drains after client sends "sync" command
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, opts) {
1007
- const resolvedSession = resolveSessionId(sessionId);
1008
- broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) }, opts);
1009
- broadcast({ type: "text", content: text, ...(resolvedSession && { sessionId: resolvedSession }) }, opts);
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, opts) {
1017
- const resolvedSession = resolveSessionId(sessionId);
1018
- broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) }, opts);
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: sendBuffer.toString("base64"),
1067
+ audioBase64: voiceBase64,
1045
1068
  ...(resolvedSession && { sessionId: resolvedSession }),
1046
- }, opts);
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: imageBuffer.toString("base64"),
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