aibroker 0.6.1 → 0.6.2

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.
Files changed (129) hide show
  1. package/README.md +263 -104
  2. package/dist/adapters/iterm/sessions.d.ts +1 -0
  3. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  4. package/dist/adapters/iterm/sessions.js +23 -0
  5. package/dist/adapters/iterm/sessions.js.map +1 -1
  6. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  7. package/dist/adapters/pailot/gateway.js +267 -52
  8. package/dist/adapters/pailot/gateway.js.map +1 -1
  9. package/dist/aibp/bridge.d.ts +123 -0
  10. package/dist/aibp/bridge.d.ts.map +1 -0
  11. package/dist/aibp/bridge.js +363 -0
  12. package/dist/aibp/bridge.js.map +1 -0
  13. package/dist/aibp/envelope.d.ts +26 -0
  14. package/dist/aibp/envelope.d.ts.map +1 -0
  15. package/dist/aibp/envelope.js +101 -0
  16. package/dist/aibp/envelope.js.map +1 -0
  17. package/dist/aibp/index.d.ts +11 -0
  18. package/dist/aibp/index.d.ts.map +1 -0
  19. package/dist/aibp/index.js +10 -0
  20. package/dist/aibp/index.js.map +1 -0
  21. package/dist/aibp/registry.d.ts +71 -0
  22. package/dist/aibp/registry.d.ts.map +1 -0
  23. package/dist/aibp/registry.js +408 -0
  24. package/dist/aibp/registry.js.map +1 -0
  25. package/dist/aibp/types.d.ts +91 -0
  26. package/dist/aibp/types.d.ts.map +1 -0
  27. package/dist/aibp/types.js +8 -0
  28. package/dist/aibp/types.js.map +1 -0
  29. package/dist/core/state.d.ts +12 -0
  30. package/dist/core/state.d.ts.map +1 -1
  31. package/dist/core/state.js +34 -0
  32. package/dist/core/state.js.map +1 -1
  33. package/dist/daemon/adapter-registry.d.ts.map +1 -1
  34. package/dist/daemon/adapter-registry.js +32 -3
  35. package/dist/daemon/adapter-registry.js.map +1 -1
  36. package/dist/daemon/command-context.d.ts +4 -0
  37. package/dist/daemon/command-context.d.ts.map +1 -1
  38. package/dist/daemon/commands.d.ts.map +1 -1
  39. package/dist/daemon/commands.js +239 -22
  40. package/dist/daemon/commands.js.map +1 -1
  41. package/dist/daemon/core-handlers.d.ts.map +1 -1
  42. package/dist/daemon/core-handlers.js +276 -15
  43. package/dist/daemon/core-handlers.js.map +1 -1
  44. package/dist/daemon/image-context.d.ts +56 -0
  45. package/dist/daemon/image-context.d.ts.map +1 -0
  46. package/dist/daemon/image-context.js +116 -0
  47. package/dist/daemon/image-context.js.map +1 -0
  48. package/dist/daemon/image-gen/index.d.ts +22 -0
  49. package/dist/daemon/image-gen/index.d.ts.map +1 -0
  50. package/dist/daemon/image-gen/index.js +129 -0
  51. package/dist/daemon/image-gen/index.js.map +1 -0
  52. package/dist/daemon/image-gen/providers/cloudflare.d.ts +13 -0
  53. package/dist/daemon/image-gen/providers/cloudflare.d.ts.map +1 -0
  54. package/dist/daemon/image-gen/providers/cloudflare.js +63 -0
  55. package/dist/daemon/image-gen/providers/cloudflare.js.map +1 -0
  56. package/dist/daemon/image-gen/providers/huggingface.d.ts +12 -0
  57. package/dist/daemon/image-gen/providers/huggingface.d.ts.map +1 -0
  58. package/dist/daemon/image-gen/providers/huggingface.js +58 -0
  59. package/dist/daemon/image-gen/providers/huggingface.js.map +1 -0
  60. package/dist/daemon/image-gen/providers/pollinations.d.ts +11 -0
  61. package/dist/daemon/image-gen/providers/pollinations.d.ts.map +1 -0
  62. package/dist/daemon/image-gen/providers/pollinations.js +39 -0
  63. package/dist/daemon/image-gen/providers/pollinations.js.map +1 -0
  64. package/dist/daemon/image-gen/providers/replicate.d.ts +9 -0
  65. package/dist/daemon/image-gen/providers/replicate.d.ts.map +1 -0
  66. package/dist/daemon/image-gen/providers/replicate.js +158 -0
  67. package/dist/daemon/image-gen/providers/replicate.js.map +1 -0
  68. package/dist/daemon/image-gen/types.d.ts +41 -0
  69. package/dist/daemon/image-gen/types.d.ts.map +1 -0
  70. package/dist/daemon/image-gen/types.js +5 -0
  71. package/dist/daemon/image-gen/types.js.map +1 -0
  72. package/dist/daemon/index.d.ts.map +1 -1
  73. package/dist/daemon/index.js +157 -4
  74. package/dist/daemon/index.js.map +1 -1
  75. package/dist/index.d.ts +1 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/mcp/index.js +129 -2
  80. package/dist/mcp/index.js.map +1 -1
  81. package/package.json +1 -1
  82. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).d.ts +0 -49
  83. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).d.ts.map +0 -1
  84. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).js +0 -632
  85. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).js.map +0 -1
  86. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-59).js +0 -632
  87. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).d.ts +0 -49
  88. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +0 -1
  89. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).js +0 -614
  90. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).js.map +0 -1
  91. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-46).js +0 -614
  92. package/dist/adapters/pailot/gateway.d.ts (SFConflict mnott 2026-03-06-21-13-59).map +0 -1
  93. package/dist/adapters/pailot/gateway.d.ts (SFConflict mnott 2026-03-06-21-15-46).map +0 -1
  94. package/dist/adapters/pailot/gateway.js (SFConflict mnott 2026-03-06-21-13-59).map +0 -1
  95. package/dist/adapters/pailot/gateway.js (SFConflict mnott 2026-03-06-21-15-46).map +0 -1
  96. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).d.ts +0 -63
  97. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +0 -1
  98. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).js +0 -229
  99. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).js.map +0 -1
  100. package/dist/daemon/image-gen.d.ts +0 -28
  101. package/dist/daemon/image-gen.d.ts.map +0 -1
  102. package/dist/daemon/image-gen.js +0 -97
  103. package/dist/daemon/image-gen.js.map +0 -1
  104. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).d.ts +0 -12
  105. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).d.ts.map +0 -1
  106. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).js +0 -252
  107. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).js.map +0 -1
  108. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-59).js +0 -252
  109. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).d.ts +0 -12
  110. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +0 -1
  111. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).js +0 -240
  112. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).js.map +0 -1
  113. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-46).js +0 -240
  114. package/dist/daemon/screenshot.d.ts (SFConflict mnott 2026-03-06-21-13-59).map +0 -1
  115. package/dist/daemon/screenshot.d.ts (SFConflict mnott 2026-03-06-21-15-46).map +0 -1
  116. package/dist/daemon/screenshot.js (SFConflict mnott 2026-03-06-21-13-59).map +0 -1
  117. package/dist/daemon/screenshot.js (SFConflict mnott 2026-03-06-21-15-46).map +0 -1
  118. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).d.ts +0 -23
  119. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).d.ts.map +0 -1
  120. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).js +0 -595
  121. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).js.map +0 -1
  122. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-59).js +0 -595
  123. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).d.ts +0 -23
  124. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +0 -1
  125. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).js +0 -592
  126. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).js.map +0 -1
  127. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-46).js +0 -592
  128. package/dist/mcp/index.js (SFConflict mnott 2026-03-06-21-13-59).map +0 -1
  129. package/dist/mcp/index.js (SFConflict mnott 2026-03-06-21-15-46).map +0 -1
@@ -13,7 +13,7 @@
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 } from "node:os";
16
+ import { tmpdir, homedir } from "node:os";
17
17
  const DEBUG_LOG = process.env.PAILOT_DEBUG ? "/tmp/pailot-ws-debug.log" : null;
18
18
  function dbg(msg) {
19
19
  if (DEBUG_LOG)
@@ -24,8 +24,8 @@ import { promisify } from "node:util";
24
24
  import { execFile } from "node:child_process";
25
25
  import { log } from "../../core/log.js";
26
26
  import { WHISPER_BIN, WHISPER_MODEL } from "../kokoro/media.js";
27
- import { setMessageSource, activeItermSessionId, setActiveItermSessionId, } from "../../core/state.js";
28
- import { setItermSessionVar, setItermTabName, killSession, createClaudeSession } from "../iterm/sessions.js";
27
+ import { setMessageSource, activeItermSessionId, setActiveItermSessionId, lastRoutedSessionId, setLastRoutedSessionId, getAibpBridge, } from "../../core/state.js";
28
+ import { setItermSessionVar, setItermTabName, setItermBadge, killSession, createClaudeSession } from "../iterm/sessions.js";
29
29
  import { listPaiProjects, launchPaiProject } from "../../daemon/pai-projects.js";
30
30
  import { runAppleScript, sendKeystrokeToSession, sendEscapeSequenceToSession, pasteTextIntoSession, snapshotAllSessions } from "../iterm/core.js";
31
31
  import { hybridManager } from "../../core/hybrid.js";
@@ -33,35 +33,66 @@ const WS_PORT = parseInt(process.env.PAILOT_PORT ?? "8765", 10);
33
33
  // --- State ---
34
34
  let wss = null;
35
35
  const clients = new Set();
36
- // --- Message outbox for offline clients ---
37
- // Buffers messages when no client is connected so they can be replayed on reconnect.
38
- // Only text messages are buffered fully; voice/image are counted but not stored (too large).
39
- const MAX_OUTBOX = 50;
40
- const outbox = [];
41
- let missedVoiceCount = 0;
36
+ /** Maps PAILot session ID → iTerm session ID for explicit reply routing.
37
+ * When PAILot sends a message tagged with sessionId, we record it here so
38
+ * outbound replies go back tagged with the same sessionId regardless of
39
+ * which iTerm tab happens to be focused on the Mac at response time.
40
+ */
41
+ const pailotReplyMap = new Map();
42
+ /** Track which session each WebSocket client is currently viewing.
43
+ * Messages tagged with a different sessionId are NOT delivered to that client.
44
+ * This prevents cross-session content bleed in multi-session PAILot views.
45
+ */
46
+ const clientActiveSession = new Map();
47
+ /** Track when each client last proved it's alive (pong, message, or connect). */
48
+ const clientLastActive = new Map();
49
+ /** Consider a client "alive" if it responded within this window. */
50
+ 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
42
57
  let missedImageCount = 0;
43
58
  function addToOutbox(msg) {
44
59
  const type = msg.type;
45
- // Skip typing indicators — not useful to replay
46
60
  if (type === "typing")
47
61
  return;
48
- // Count but don't buffer screenshots (very large, less important)
49
62
  if (type === "image") {
50
63
  missedImageCount++;
51
64
  return;
52
65
  }
53
- // Buffer text, voice, sessions, errors, etc.
54
- outbox.push({ msg, timestamp: Date.now() });
55
- if (outbox.length > MAX_OUTBOX)
56
- outbox.shift();
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();
57
77
  }
58
78
  function drainOutbox(ws) {
59
- if (outbox.length === 0 && missedVoiceCount === 0 && missedImageCount === 0)
79
+ // Collect all entries across all sessions
80
+ let totalEntries = 0;
81
+ let textCount = 0;
82
+ let voiceCount = 0;
83
+ for (const entries of outboxMap.values()) {
84
+ totalEntries += entries.length;
85
+ for (const e of entries) {
86
+ const t = e.msg.type;
87
+ if (t === "text")
88
+ textCount++;
89
+ else if (t === "voice")
90
+ voiceCount++;
91
+ }
92
+ }
93
+ if (totalEntries === 0 && missedImageCount === 0)
60
94
  return;
61
- // Send a summary of what was missed
62
- const textCount = outbox.filter(e => e.msg.type === "text").length;
63
- const voiceCount = outbox.filter(e => e.msg.type === "voice").length;
64
- const otherCount = outbox.length - textCount - voiceCount;
95
+ const otherCount = totalEntries - textCount - voiceCount;
65
96
  const parts = [];
66
97
  if (textCount > 0)
67
98
  parts.push(`${textCount} text message(s)`);
@@ -75,14 +106,57 @@ function drainOutbox(ws) {
75
106
  type: "text",
76
107
  content: `📬 While you were away: ${parts.join(", ")}`,
77
108
  });
78
- // Replay buffered messages
79
- for (const entry of outbox) {
109
+ // Replay all buffered messages (sorted by timestamp across sessions)
110
+ const allEntries = [];
111
+ for (const entries of outboxMap.values())
112
+ allEntries.push(...entries);
113
+ allEntries.sort((a, b) => a.timestamp - b.timestamp);
114
+ for (const entry of allEntries) {
80
115
  sendTo(ws, entry.msg);
81
116
  }
82
117
  // Clear outbox
83
- outbox.length = 0;
84
- missedVoiceCount = 0;
118
+ outboxMap.clear();
85
119
  missedImageCount = 0;
120
+ persistOutbox();
121
+ }
122
+ /** Persist outbox to disk so daemon restarts don't lose messages. */
123
+ function persistOutbox() {
124
+ try {
125
+ const { mkdirSync, writeFileSync: writeSync } = require("node:fs");
126
+ mkdirSync(OUTBOX_DIR, { recursive: true });
127
+ const data = {};
128
+ for (const [k, v] of outboxMap)
129
+ data[k] = v;
130
+ writeSync(join(OUTBOX_DIR, "pending.json"), JSON.stringify({ messages: data, missedImageCount }));
131
+ }
132
+ catch { /* best-effort */ }
133
+ }
134
+ /** Restore outbox from disk on startup. */
135
+ function restoreOutbox() {
136
+ try {
137
+ const path = join(OUTBOX_DIR, "pending.json");
138
+ if (!existsSync(path))
139
+ return;
140
+ const raw = readFileSync(path, "utf-8");
141
+ const data = JSON.parse(raw);
142
+ if (data.messages) {
143
+ for (const [k, v] of Object.entries(data.messages)) {
144
+ outboxMap.set(k, v);
145
+ }
146
+ }
147
+ if (data.missedImageCount)
148
+ missedImageCount = data.missedImageCount;
149
+ log(`[PAILot] Restored ${outboxMap.size} outbox queue(s) from disk`);
150
+ // Clean up the file after restoring
151
+ unlinkSync(path);
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ function isClientAlive(ws) {
156
+ if (ws.readyState !== WebSocket.OPEN)
157
+ return false;
158
+ const lastActive = clientLastActive.get(ws) ?? 0;
159
+ return (Date.now() - lastActive) < CLIENT_ALIVE_THRESHOLD;
86
160
  }
87
161
  // Reference to the screenshot handler — set via setScreenshotHandler()
88
162
  // to avoid circular imports (screenshot.ts imports from state.ts which
@@ -141,19 +215,37 @@ function handleSyncCommand(ws, args) {
141
215
  }
142
216
  // If the client had a session open, try to restore it
143
217
  if (clientActiveId) {
144
- const sessions = hybridManager.listSessions();
145
- const idx = sessions.findIndex(s => s.backendSessionId === clientActiveId);
218
+ // First pass: check if it was already registered (by auto-discovery above or a prior sync)
219
+ let sessions = hybridManager.listSessions();
220
+ let idx = sessions.findIndex(s => s.backendSessionId === clientActiveId);
221
+ // Second pass: the session may exist in liveSnapshots but wasn't registered because
222
+ // isClaudeRelated() returned false (e.g. session is at a shell prompt between Claude runs
223
+ // after a daemon restart). Since the client explicitly asked for this session by ID, register
224
+ // it unconditionally if it is still alive in iTerm.
225
+ if (idx < 0 && liveIds.has(clientActiveId)) {
226
+ const snap = liveSnapshots.find(s => s.id === clientActiveId);
227
+ if (snap) {
228
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
229
+ hybridManager.registerVisualSession(displayName, "", clientActiveId);
230
+ sessions = hybridManager.listSessions();
231
+ idx = sessions.findIndex(s => s.backendSessionId === clientActiveId);
232
+ log(`[PAILot] sync: force-registered client session "${displayName}" (${clientActiveId.slice(0, 8)}...)`);
233
+ }
234
+ }
146
235
  if (idx >= 0) {
147
236
  hybridManager.switchToIndex(idx + 1);
148
237
  setActiveItermSessionId(clientActiveId);
238
+ setLastRoutedSessionId(clientActiveId);
239
+ clientActiveSession.set(ws, clientActiveId);
149
240
  log(`[PAILot] sync: restored client session "${sessions[idx].name}" (${clientActiveId.slice(0, 8)}...)`);
150
241
  handleSessionsCommand(ws);
151
242
  drainOutbox(ws);
152
243
  return;
153
244
  }
154
- // Client's session no longer exists — fall through to iTerm focus
245
+ // Client's session no longer exists in iTerm — fall through to iTerm focus
246
+ log(`[PAILot] sync: client session ${clientActiveId.slice(0, 8)}... not found in live iTerm — falling back to focused session`);
155
247
  }
156
- // No client preference — ask iTerm2 which session is focused right now
248
+ // No client preference (or client's session is gone) — ask iTerm2 which session is focused
157
249
  const focusedId = runAppleScript(`tell application "iTerm2"
158
250
  try
159
251
  return id of current session of current tab of current window
@@ -162,16 +254,40 @@ function handleSyncCommand(ws, args) {
162
254
  end try
163
255
  end tell`)?.trim() ?? "";
164
256
  if (focusedId) {
165
- // Find this session in the hybrid manager and activate it
166
- const sessions = hybridManager.listSessions();
167
- const idx = sessions.findIndex(s => s.backendSessionId === focusedId);
257
+ // Find this session in the hybrid manager and activate it.
258
+ // If it wasn't registered by the Claude-related filter, register it now since the
259
+ // user is actively looking at it.
260
+ let sessions = hybridManager.listSessions();
261
+ let idx = sessions.findIndex(s => s.backendSessionId === focusedId);
262
+ if (idx < 0 && liveIds.has(focusedId)) {
263
+ const snap = liveSnapshots.find(s => s.id === focusedId);
264
+ if (snap) {
265
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
266
+ hybridManager.registerVisualSession(displayName, "", focusedId);
267
+ sessions = hybridManager.listSessions();
268
+ idx = sessions.findIndex(s => s.backendSessionId === focusedId);
269
+ }
270
+ }
168
271
  if (idx >= 0) {
169
272
  hybridManager.switchToIndex(idx + 1);
170
273
  setActiveItermSessionId(focusedId);
274
+ setLastRoutedSessionId(focusedId);
275
+ clientActiveSession.set(ws, focusedId);
171
276
  log(`[PAILot] sync: activated focused session "${sessions[idx].name}" (${focusedId.slice(0, 8)}...)`);
172
277
  }
173
278
  else {
174
- log(`[PAILot] sync: focused session ${focusedId.slice(0, 8)}... not registered`);
279
+ log(`[PAILot] sync: focused session ${focusedId.slice(0, 8)}... not found in live iTerm`);
280
+ }
281
+ }
282
+ // Last resort: if nothing is active yet but we have registered sessions, activate the first one
283
+ if (!hybridManager.activeSession) {
284
+ const sessions = hybridManager.listSessions();
285
+ if (sessions.length > 0) {
286
+ hybridManager.switchToIndex(1);
287
+ setActiveItermSessionId(sessions[0].backendSessionId);
288
+ setLastRoutedSessionId(sessions[0].backendSessionId);
289
+ clientActiveSession.set(ws, sessions[0].backendSessionId);
290
+ log(`[PAILot] sync: no focused session found — defaulting to first session "${sessions[0].name}"`);
175
291
  }
176
292
  }
177
293
  // Return sessions with updated active state, then drain buffered messages
@@ -272,8 +388,13 @@ end tell`);
272
388
  if (session.kind === "visual") {
273
389
  setItermSessionVar(session.backendSessionId, newName);
274
390
  setItermTabName(session.backendSessionId, newName);
391
+ setItermBadge(session.backendSessionId, newName);
275
392
  }
276
393
  }
394
+ // Record this as the last routed session so outbound replies go here,
395
+ // regardless of which iTerm tab is focused on the Mac.
396
+ setLastRoutedSessionId(session.backendSessionId);
397
+ clientActiveSession.set(ws, session.backendSessionId);
277
398
  sendTo(ws, { type: "session_switched", name: session.name, sessionId: session.backendSessionId });
278
399
  log(`[PAILot] switched to ${session.kind} session "${session.name}" (${session.id})`);
279
400
  }
@@ -292,6 +413,7 @@ function handleRenameCommand(ws, args) {
292
413
  if (session.kind === "visual") {
293
414
  setItermSessionVar(sessionId, name);
294
415
  setItermTabName(sessionId, name);
416
+ setItermBadge(sessionId, name);
295
417
  }
296
418
  }
297
419
  sendTo(ws, { type: "session_renamed", sessionId, name });
@@ -342,6 +464,7 @@ function handleCreateCommand(ws, args = {}) {
342
464
  }
343
465
  setItermSessionVar(sessionId, name);
344
466
  setItermTabName(sessionId, name);
467
+ setItermBadge(sessionId, name);
345
468
  if (hybridManager) {
346
469
  hybridManager.registerVisualSession(name, "", sessionId);
347
470
  const sessions = hybridManager.listSessions();
@@ -349,6 +472,7 @@ function handleCreateCommand(ws, args = {}) {
349
472
  if (idx >= 0) {
350
473
  hybridManager.switchToIndex(idx + 1);
351
474
  setActiveItermSessionId(sessionId);
475
+ setLastRoutedSessionId(sessionId);
352
476
  }
353
477
  }
354
478
  log(`[PAILot] created new session "${name}" (${sessionId.slice(0, 8)}...)`);
@@ -462,16 +586,28 @@ function sendTo(ws, msg) {
462
586
  }
463
587
  }
464
588
  function broadcast(msg) {
465
- // Check if any client is actually ready to receive
589
+ // Only deliver to clients that have proven liveness recently.
590
+ // iOS can keep a WebSocket "open" while the app is backgrounded —
591
+ // ws.send() succeeds but the app never processes the data.
592
+ //
593
+ // Session filtering: if the message carries a sessionId, only deliver to
594
+ // clients viewing that session. This prevents cross-session content bleed.
595
+ const msgSessionId = msg.sessionId;
466
596
  let delivered = false;
467
597
  const payload = JSON.stringify(msg);
468
598
  for (const ws of clients) {
469
- if (ws.readyState === WebSocket.OPEN) {
470
- ws.send(payload);
471
- delivered = true;
599
+ if (!isClientAlive(ws))
600
+ continue;
601
+ // Session gate: if both the message and client have a session, they must match
602
+ if (msgSessionId) {
603
+ const clientSession = clientActiveSession.get(ws);
604
+ if (clientSession && clientSession !== msgSessionId)
605
+ continue;
472
606
  }
607
+ ws.send(payload);
608
+ delivered = true;
473
609
  }
474
- // No connected clients — buffer for later
610
+ // No live clients — buffer for later
475
611
  if (!delivered) {
476
612
  addToOutbox(msg);
477
613
  }
@@ -483,16 +619,29 @@ const BATCH_WINDOW_MS = 3000;
483
619
  let voiceBatchTimer = null;
484
620
  let voiceBatchTranscripts = [];
485
621
  let voiceBatchOnMessage = null;
622
+ /** iTerm session ID resolved when the first voice chunk of this batch arrived. */
623
+ let voiceBatchSessionId = "";
486
624
  function flushVoiceBatch() {
487
625
  if (voiceBatchTranscripts.length === 0)
488
626
  return;
489
627
  const combined = voiceBatchTranscripts.join(" ");
490
628
  const handler = voiceBatchOnMessage;
629
+ const batchSession = voiceBatchSessionId;
491
630
  voiceBatchTranscripts = [];
492
631
  voiceBatchOnMessage = null;
632
+ voiceBatchSessionId = "";
493
633
  voiceBatchTimer = null;
494
- if (handler) {
495
- log(`[PAILot] Flushing voice batch (${combined.length} chars)`);
634
+ log(`[PAILot] Flushing voice batch (${combined.length} chars)`);
635
+ const routeSession = batchSession || activeItermSessionId;
636
+ if (routeSession) {
637
+ pailotReplyMap.set(routeSession, routeSession);
638
+ setLastRoutedSessionId(routeSession);
639
+ }
640
+ const bridge = getAibpBridge();
641
+ if (bridge && routeSession) {
642
+ bridge.routeFromMobile(routeSession, `[PAILot:voice] ${combined}`);
643
+ }
644
+ else if (handler) {
496
645
  setMessageSource("pailot");
497
646
  handler(`[PAILot:voice] ${combined}`, Date.now());
498
647
  setMessageSource("whatsapp");
@@ -565,6 +714,8 @@ async function transcribeAndRoute(audioBase64, onMessage, messageId) {
565
714
  */
566
715
  export function startWsGateway(onMessage) {
567
716
  wss = new WebSocketServer({ port: WS_PORT });
717
+ // Restore any buffered messages from a previous daemon run
718
+ restoreOutbox();
568
719
  wss.on("listening", () => {
569
720
  log(`WebSocket gateway listening on ws://0.0.0.0:${WS_PORT}`);
570
721
  // Pre-populate hybrid manager with live iTerm sessions so messages
@@ -612,12 +763,15 @@ end tell`)?.trim() ?? "";
612
763
  const addr = req.socket.remoteAddress ?? "unknown";
613
764
  log(`PAILot client connected from ${addr}`);
614
765
  clients.add(ws);
766
+ clientLastActive.set(ws, Date.now());
615
767
  ws.on("message", (raw) => {
768
+ clientLastActive.set(ws, Date.now());
616
769
  try {
617
770
  const rawStr = raw.toString();
618
771
  const msg = JSON.parse(rawStr);
619
772
  dbg(`RAW msg (${rawStr.length} chars): type=${msg.type}, hasAudio=${!!msg.audioBase64}, content=${(msg.content ?? "").slice(0, 50)}`);
620
- // Heartbeat ping — reply with pong immediately
773
+ // Heartbeat ping — reply with pong immediately.
774
+ // The clientLastActive update above already covers liveness.
621
775
  if (msg.type === "ping") {
622
776
  sendTo(ws, { type: "pong" });
623
777
  return;
@@ -672,11 +826,37 @@ end tell`)?.trim() ?? "";
672
826
  break;
673
827
  }
674
828
  }
829
+ // Extract the session ID that PAILot says this message belongs to.
830
+ // This is the iTerm session ID the user was viewing when they typed/spoke.
831
+ // We use it for routing instead of guessing from activeItermSessionId.
832
+ const pailotSessionId = typeof msg.sessionId === "string" ? msg.sessionId : undefined;
833
+ // Helper: resolve the routing target for this message.
834
+ // Prefers the explicit PAILot session ID; falls back to active session.
835
+ const routeTarget = pailotSessionId || activeItermSessionId;
836
+ // If PAILot told us the session, switch iTerm to it (for stdin routing)
837
+ // and record it in the reply map so outbound replies go to the same session.
838
+ if (pailotSessionId) {
839
+ pailotReplyMap.set(pailotSessionId, pailotSessionId);
840
+ setLastRoutedSessionId(pailotSessionId);
841
+ // Switch iTerm to the correct session if it differs from the active one
842
+ if (pailotSessionId !== activeItermSessionId) {
843
+ setActiveItermSessionId(pailotSessionId);
844
+ if (hybridManager) {
845
+ const sessions = hybridManager.listSessions();
846
+ const idx = sessions.findIndex(s => s.backendSessionId === pailotSessionId);
847
+ if (idx >= 0)
848
+ hybridManager.switchToIndex(idx + 1);
849
+ }
850
+ }
851
+ }
675
852
  // Voice message — transcribe with Whisper then route
676
853
  if (msg.type === "voice" && msg.audioBase64) {
677
854
  dbg(`Voice message received, audioBase64 length: ${msg.audioBase64.length}`);
678
- broadcast({ type: "typing", typing: true });
855
+ broadcast({ type: "typing", typing: true, ...(routeTarget && { sessionId: routeTarget }) });
679
856
  const voiceMsgId = typeof msg.messageId === "string" ? msg.messageId : undefined;
857
+ // Capture the routing session for this batch (first chunk wins)
858
+ if (!voiceBatchSessionId && routeTarget)
859
+ voiceBatchSessionId = routeTarget;
680
860
  transcribeAndRoute(msg.audioBase64, onMessage, voiceMsgId).catch((err) => {
681
861
  log(`[PAILot] voice transcription error: ${err}`);
682
862
  });
@@ -698,20 +878,40 @@ end tell`)?.trim() ?? "";
698
878
  const routeText = caption
699
879
  ? `${caption} (image at ${imgPath})`
700
880
  : `(image at ${imgPath})`;
701
- setMessageSource("pailot");
702
- onMessage(routeText, Date.now());
703
- setMessageSource("whatsapp");
881
+ if (!pailotSessionId)
882
+ setLastRoutedSessionId(activeItermSessionId);
883
+ const imgBridge = getAibpBridge();
884
+ if (imgBridge && routeTarget) {
885
+ imgBridge.routeFromMobile(routeTarget, routeText, "IMAGE", {
886
+ imageBase64: msg.imageBase64,
887
+ mimeType: msg.mimeType ?? "image/jpeg",
888
+ });
889
+ }
890
+ else {
891
+ setMessageSource("pailot");
892
+ onMessage(routeText, Date.now());
893
+ setMessageSource("whatsapp");
894
+ }
704
895
  return;
705
896
  }
706
- // Plain text message — route through handleMessage
897
+ // Plain text message — route through AIBP bridge (or legacy fallback)
707
898
  const text = msg.content ?? "";
708
899
  if (!text.trim())
709
900
  return;
710
901
  log(`[PAILot] ← ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`);
711
- broadcast({ type: "typing", typing: true });
712
- setMessageSource("pailot");
713
- onMessage(text, Date.now());
714
- setMessageSource("whatsapp");
902
+ broadcast({ type: "typing", typing: true, ...(routeTarget && { sessionId: routeTarget }) });
903
+ if (!pailotSessionId)
904
+ setLastRoutedSessionId(activeItermSessionId);
905
+ const bridge = getAibpBridge();
906
+ if (bridge && routeTarget) {
907
+ bridge.routeFromMobile(routeTarget, text);
908
+ }
909
+ else {
910
+ // Fallback to legacy routing
911
+ setMessageSource("pailot");
912
+ onMessage(text, Date.now());
913
+ setMessageSource("whatsapp");
914
+ }
715
915
  }
716
916
  catch {
717
917
  log(`[PAILot] Invalid message from ${addr}`);
@@ -720,10 +920,14 @@ end tell`)?.trim() ?? "";
720
920
  ws.on("close", () => {
721
921
  log(`PAILot client disconnected from ${addr}`);
722
922
  clients.delete(ws);
923
+ clientLastActive.delete(ws);
924
+ clientActiveSession.delete(ws);
723
925
  });
724
926
  ws.on("error", (err) => {
725
927
  log(`[PAILot] WebSocket error: ${err.message}`);
726
928
  clients.delete(ws);
929
+ clientLastActive.delete(ws);
930
+ clientActiveSession.delete(ws);
727
931
  });
728
932
  // Welcome — outbox drains after client sends "sync" command
729
933
  // (so activeSessionId is set before messages arrive)
@@ -738,8 +942,19 @@ end tell`)?.trim() ?? "";
738
942
  * @param sessionId — iTerm session ID of the originating Claude session
739
943
  */
740
944
  function resolveSessionId(sessionId) {
741
- if (sessionId)
945
+ // If an explicit sessionId was passed (from MCP detectSessionId), check the reply map
946
+ // first — the reply map records which session the user was actually talking to,
947
+ // which may differ from whatever iTerm tab is currently focused on the Mac.
948
+ if (sessionId) {
949
+ const mapped = pailotReplyMap.get(sessionId);
950
+ if (mapped)
951
+ return mapped;
742
952
  return sessionId;
953
+ }
954
+ // Prefer the session that last received user input from PAILot —
955
+ // this survives session switches that happen while Claude is thinking.
956
+ if (lastRoutedSessionId)
957
+ return lastRoutedSessionId;
743
958
  if (activeItermSessionId)
744
959
  return activeItermSessionId;
745
960
  // Last resort: ask hybrid manager for the active session's backend ID
@@ -747,7 +962,7 @@ function resolveSessionId(sessionId) {
747
962
  }
748
963
  export function broadcastText(text, sessionId) {
749
964
  const resolvedSession = resolveSessionId(sessionId);
750
- broadcast({ type: "typing", typing: false });
965
+ broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) });
751
966
  broadcast({ type: "text", content: text, ...(resolvedSession && { sessionId: resolvedSession }) });
752
967
  }
753
968
  /**
@@ -757,7 +972,7 @@ export function broadcastText(text, sessionId) {
757
972
  */
758
973
  export async function broadcastVoice(audioBuffer, transcript, sessionId) {
759
974
  const resolvedSession = resolveSessionId(sessionId);
760
- broadcast({ type: "typing", typing: false });
975
+ broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) });
761
976
  let sendBuffer = audioBuffer;
762
977
  // Convert OGG Opus → M4A for iOS compatibility
763
978
  try {