aibroker 0.5.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 (133) hide show
  1. package/README.md +263 -104
  2. package/dist/adapters/iterm/iterm2-api.d.ts +20 -0
  3. package/dist/adapters/iterm/iterm2-api.d.ts.map +1 -0
  4. package/dist/adapters/iterm/iterm2-api.js +244 -0
  5. package/dist/adapters/iterm/iterm2-api.js.map +1 -0
  6. package/dist/adapters/iterm/sessions.d.ts +1 -0
  7. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  8. package/dist/adapters/iterm/sessions.js +26 -2
  9. package/dist/adapters/iterm/sessions.js.map +1 -1
  10. package/dist/adapters/kokoro/media.js +2 -2
  11. package/dist/adapters/kokoro/media.js.map +1 -1
  12. package/dist/adapters/pailot/gateway.d.ts +5 -6
  13. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  14. package/dist/adapters/pailot/gateway.js +575 -34
  15. package/dist/adapters/pailot/gateway.js.map +1 -1
  16. package/dist/aibp/bridge.d.ts +123 -0
  17. package/dist/aibp/bridge.d.ts.map +1 -0
  18. package/dist/aibp/bridge.js +363 -0
  19. package/dist/aibp/bridge.js.map +1 -0
  20. package/dist/aibp/envelope.d.ts +26 -0
  21. package/dist/aibp/envelope.d.ts.map +1 -0
  22. package/dist/aibp/envelope.js +101 -0
  23. package/dist/aibp/envelope.js.map +1 -0
  24. package/dist/aibp/index.d.ts +11 -0
  25. package/dist/aibp/index.d.ts.map +1 -0
  26. package/dist/aibp/index.js +10 -0
  27. package/dist/aibp/index.js.map +1 -0
  28. package/dist/aibp/registry.d.ts +71 -0
  29. package/dist/aibp/registry.d.ts.map +1 -0
  30. package/dist/aibp/registry.js +408 -0
  31. package/dist/aibp/registry.js.map +1 -0
  32. package/dist/aibp/types.d.ts +91 -0
  33. package/dist/aibp/types.d.ts.map +1 -0
  34. package/dist/aibp/types.js +8 -0
  35. package/dist/aibp/types.js.map +1 -0
  36. package/dist/core/hybrid.d.ts +2 -0
  37. package/dist/core/hybrid.d.ts.map +1 -1
  38. package/dist/core/hybrid.js +8 -0
  39. package/dist/core/hybrid.js.map +1 -1
  40. package/dist/core/state.d.ts +12 -0
  41. package/dist/core/state.d.ts.map +1 -1
  42. package/dist/core/state.js +34 -0
  43. package/dist/core/state.js.map +1 -1
  44. package/dist/core/status-cache.d.ts +51 -0
  45. package/dist/core/status-cache.d.ts.map +1 -0
  46. package/dist/core/status-cache.js +62 -0
  47. package/dist/core/status-cache.js.map +1 -0
  48. package/dist/daemon/adapter-registry.d.ts +5 -0
  49. package/dist/daemon/adapter-registry.d.ts.map +1 -1
  50. package/dist/daemon/adapter-registry.js +94 -4
  51. package/dist/daemon/adapter-registry.js.map +1 -1
  52. package/dist/daemon/cli.d.ts +1 -0
  53. package/dist/daemon/cli.d.ts.map +1 -1
  54. package/dist/daemon/cli.js +95 -3
  55. package/dist/daemon/cli.js.map +1 -1
  56. package/dist/daemon/command-context.d.ts +28 -0
  57. package/dist/daemon/command-context.d.ts.map +1 -0
  58. package/dist/daemon/command-context.js +13 -0
  59. package/dist/daemon/command-context.js.map +1 -0
  60. package/dist/daemon/commands.d.ts +22 -0
  61. package/dist/daemon/commands.d.ts.map +1 -0
  62. package/dist/daemon/commands.js +849 -0
  63. package/dist/daemon/commands.js.map +1 -0
  64. package/dist/daemon/core-handlers.d.ts.map +1 -1
  65. package/dist/daemon/core-handlers.js +758 -3
  66. package/dist/daemon/core-handlers.js.map +1 -1
  67. package/dist/daemon/create-adapter.js +2 -1
  68. package/dist/daemon/create-adapter.js.map +1 -1
  69. package/dist/daemon/image-context.d.ts +56 -0
  70. package/dist/daemon/image-context.d.ts.map +1 -0
  71. package/dist/daemon/image-context.js +116 -0
  72. package/dist/daemon/image-context.js.map +1 -0
  73. package/dist/daemon/image-gen/index.d.ts +22 -0
  74. package/dist/daemon/image-gen/index.d.ts.map +1 -0
  75. package/dist/daemon/image-gen/index.js +129 -0
  76. package/dist/daemon/image-gen/index.js.map +1 -0
  77. package/dist/daemon/image-gen/providers/cloudflare.d.ts +13 -0
  78. package/dist/daemon/image-gen/providers/cloudflare.d.ts.map +1 -0
  79. package/dist/daemon/image-gen/providers/cloudflare.js +63 -0
  80. package/dist/daemon/image-gen/providers/cloudflare.js.map +1 -0
  81. package/dist/daemon/image-gen/providers/huggingface.d.ts +12 -0
  82. package/dist/daemon/image-gen/providers/huggingface.d.ts.map +1 -0
  83. package/dist/daemon/image-gen/providers/huggingface.js +58 -0
  84. package/dist/daemon/image-gen/providers/huggingface.js.map +1 -0
  85. package/dist/daemon/image-gen/providers/pollinations.d.ts +11 -0
  86. package/dist/daemon/image-gen/providers/pollinations.d.ts.map +1 -0
  87. package/dist/daemon/image-gen/providers/pollinations.js +39 -0
  88. package/dist/daemon/image-gen/providers/pollinations.js.map +1 -0
  89. package/dist/daemon/image-gen/providers/replicate.d.ts +9 -0
  90. package/dist/daemon/image-gen/providers/replicate.d.ts.map +1 -0
  91. package/dist/daemon/image-gen/providers/replicate.js +158 -0
  92. package/dist/daemon/image-gen/providers/replicate.js.map +1 -0
  93. package/dist/daemon/image-gen/types.d.ts +41 -0
  94. package/dist/daemon/image-gen/types.d.ts.map +1 -0
  95. package/dist/daemon/image-gen/types.js +5 -0
  96. package/dist/daemon/image-gen/types.js.map +1 -0
  97. package/dist/daemon/index.d.ts.map +1 -1
  98. package/dist/daemon/index.js +260 -6
  99. package/dist/daemon/index.js.map +1 -1
  100. package/dist/daemon/screenshot.d.ts +12 -0
  101. package/dist/daemon/screenshot.d.ts.map +1 -0
  102. package/dist/daemon/screenshot.js +252 -0
  103. package/dist/daemon/screenshot.js.map +1 -0
  104. package/dist/daemon/session-content.d.ts +27 -0
  105. package/dist/daemon/session-content.d.ts.map +1 -0
  106. package/dist/daemon/session-content.js +76 -0
  107. package/dist/daemon/session-content.js.map +1 -0
  108. package/dist/daemon/vision.d.ts +46 -0
  109. package/dist/daemon/vision.d.ts.map +1 -0
  110. package/dist/daemon/vision.js +176 -0
  111. package/dist/daemon/vision.js.map +1 -0
  112. package/dist/index.d.ts +6 -1
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +4 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/ipc/validate.d.ts +52 -0
  117. package/dist/ipc/validate.d.ts.map +1 -0
  118. package/dist/ipc/validate.js +129 -0
  119. package/dist/ipc/validate.js.map +1 -0
  120. package/dist/mcp/index.d.ts +23 -0
  121. package/dist/mcp/index.d.ts.map +1 -0
  122. package/dist/mcp/index.js +787 -0
  123. package/dist/mcp/index.js.map +1 -0
  124. package/dist/types/broker.d.ts +3 -1
  125. package/dist/types/broker.d.ts.map +1 -1
  126. package/dist/types/broker.js.map +1 -1
  127. package/package.json +5 -2
  128. package/templates/adapter/ONBOARDING_PROMPT.md +51 -29
  129. package/templates/adapter/README.md.tmpl +14 -31
  130. package/templates/adapter/package.json.tmpl +1 -1
  131. package/templates/adapter/src/watcher/commands.ts.tmpl +24 -126
  132. package/templates/adapter/src/watcher/index.ts.tmpl +112 -88
  133. package/templates/adapter/src/watcher/ipc-server.ts.tmpl +27 -3
@@ -13,24 +13,151 @@
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";
17
- const DEBUG_LOG = "/tmp/pailot-ws-debug.log";
16
+ import { tmpdir, homedir } from "node:os";
17
+ const DEBUG_LOG = process.env.PAILOT_DEBUG ? "/tmp/pailot-ws-debug.log" : null;
18
18
  function dbg(msg) {
19
- appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`);
19
+ if (DEBUG_LOG)
20
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`);
20
21
  }
21
22
  import { randomUUID } from "node:crypto";
22
23
  import { promisify } from "node:util";
23
24
  import { execFile } from "node:child_process";
24
25
  import { log } from "../../core/log.js";
25
26
  import { WHISPER_BIN, WHISPER_MODEL } from "../kokoro/media.js";
26
- import { setMessageSource, activeItermSessionId, setActiveItermSessionId, } from "../../core/state.js";
27
- import { setItermSessionVar, setItermTabName } 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
+ import { listPaiProjects, launchPaiProject } from "../../daemon/pai-projects.js";
28
30
  import { runAppleScript, sendKeystrokeToSession, sendEscapeSequenceToSession, pasteTextIntoSession, snapshotAllSessions } from "../iterm/core.js";
29
31
  import { hybridManager } from "../../core/hybrid.js";
30
32
  const WS_PORT = parseInt(process.env.PAILOT_PORT ?? "8765", 10);
31
33
  // --- State ---
32
34
  let wss = null;
33
35
  const clients = new Set();
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
57
+ let missedImageCount = 0;
58
+ function addToOutbox(msg) {
59
+ 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();
77
+ }
78
+ function drainOutbox(ws) {
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)
94
+ return;
95
+ const otherCount = totalEntries - textCount - voiceCount;
96
+ const parts = [];
97
+ if (textCount > 0)
98
+ parts.push(`${textCount} text message(s)`);
99
+ if (voiceCount > 0)
100
+ parts.push(`${voiceCount} voice note(s)`);
101
+ if (missedImageCount > 0)
102
+ parts.push(`${missedImageCount} image(s)`);
103
+ if (otherCount > 0)
104
+ parts.push(`${otherCount} other`);
105
+ sendTo(ws, {
106
+ type: "text",
107
+ content: `📬 While you were away: ${parts.join(", ")}`,
108
+ });
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) {
115
+ sendTo(ws, entry.msg);
116
+ }
117
+ // Clear outbox
118
+ outboxMap.clear();
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;
160
+ }
34
161
  // Reference to the screenshot handler — set via setScreenshotHandler()
35
162
  // to avoid circular imports (screenshot.ts imports from state.ts which
36
163
  // would create a cycle if we imported it here directly).
@@ -43,13 +170,82 @@ export function setScreenshotHandler(handler) {
43
170
  screenshotHandler = handler;
44
171
  }
45
172
  // --- Structured command handling ---
46
- /** Detect which iTerm2 session is currently focused and sync the hybrid manager to it. */
47
- function handleSyncCommand(ws) {
173
+ /**
174
+ * Filter: include only Claude-related sessions.
175
+ * A session qualifies if it has paiName, name contains "claude",
176
+ * or is not at shell prompt (has a process running — likely Claude).
177
+ */
178
+ function isClaudeRelated(snap) {
179
+ if (snap.paiName)
180
+ return true;
181
+ const name = (snap.tabTitle ?? snap.name).toLowerCase();
182
+ if (name.includes("claude"))
183
+ return true;
184
+ if (!snap.atPrompt)
185
+ return true;
186
+ return false;
187
+ }
188
+ /** Detect which iTerm2 session is currently focused and sync the hybrid manager to it.
189
+ * If the client passes activeSessionId, preserve that selection instead of
190
+ * jumping to whatever iTerm has focused on the Mac.
191
+ */
192
+ function handleSyncCommand(ws, args) {
48
193
  if (!hybridManager) {
49
194
  handleSessionsCommand(ws);
195
+ drainOutbox(ws);
50
196
  return;
51
197
  }
52
- // Ask iTerm2 which session is focused right now
198
+ const clientActiveId = typeof args?.activeSessionId === "string" ? args.activeSessionId : undefined;
199
+ // Auto-discover Claude-related iTerm2 tabs so freshly-started daemons can match
200
+ const liveSnapshots = snapshotAllSessions();
201
+ const liveIds = new Set(liveSnapshots.map(s => s.id));
202
+ hybridManager.pruneDeadVisualSessions(liveIds);
203
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
204
+ const seenTabs = new Set();
205
+ for (const snap of liveSnapshots) {
206
+ if (!isClaudeRelated(snap))
207
+ continue;
208
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
209
+ if (seenTabs.has(displayName))
210
+ continue;
211
+ seenTabs.add(displayName);
212
+ if (!knownIds.has(snap.id)) {
213
+ hybridManager.registerVisualSession(displayName, "", snap.id);
214
+ }
215
+ }
216
+ // If the client had a session open, try to restore it
217
+ if (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
+ }
235
+ if (idx >= 0) {
236
+ hybridManager.switchToIndex(idx + 1);
237
+ setActiveItermSessionId(clientActiveId);
238
+ setLastRoutedSessionId(clientActiveId);
239
+ clientActiveSession.set(ws, clientActiveId);
240
+ log(`[PAILot] sync: restored client session "${sessions[idx].name}" (${clientActiveId.slice(0, 8)}...)`);
241
+ handleSessionsCommand(ws);
242
+ drainOutbox(ws);
243
+ return;
244
+ }
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`);
247
+ }
248
+ // No client preference (or client's session is gone) — ask iTerm2 which session is focused
53
249
  const focusedId = runAppleScript(`tell application "iTerm2"
54
250
  try
55
251
  return id of current session of current tab of current window
@@ -58,20 +254,45 @@ function handleSyncCommand(ws) {
58
254
  end try
59
255
  end tell`)?.trim() ?? "";
60
256
  if (focusedId) {
61
- // Find this session in the hybrid manager and activate it
62
- const sessions = hybridManager.listSessions();
63
- 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
+ }
64
271
  if (idx >= 0) {
65
272
  hybridManager.switchToIndex(idx + 1);
66
273
  setActiveItermSessionId(focusedId);
274
+ setLastRoutedSessionId(focusedId);
275
+ clientActiveSession.set(ws, focusedId);
67
276
  log(`[PAILot] sync: activated focused session "${sessions[idx].name}" (${focusedId.slice(0, 8)}...)`);
68
277
  }
69
278
  else {
70
- 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}"`);
71
291
  }
72
292
  }
73
- // Return sessions with updated active state
293
+ // Return sessions with updated active state, then drain buffered messages
74
294
  handleSessionsCommand(ws);
295
+ drainOutbox(ws);
75
296
  }
76
297
  function handleSessionsCommand(ws) {
77
298
  if (!hybridManager) {
@@ -82,12 +303,32 @@ function handleSessionsCommand(ws) {
82
303
  const liveSnapshots = snapshotAllSessions();
83
304
  const liveIds = new Set(liveSnapshots.map(s => s.id));
84
305
  hybridManager.pruneDeadVisualSessions(liveIds);
306
+ // Auto-discover Claude-related iTerm2 tabs not yet in the hybrid manager,
307
+ // and sync names of existing sessions from live iTerm state.
308
+ // Deduplicate by tab title — only register first session per tab.
309
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
310
+ const seenTabs = new Set();
311
+ for (const snap of liveSnapshots) {
312
+ if (!isClaudeRelated(snap))
313
+ continue;
314
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
315
+ if (seenTabs.has(displayName))
316
+ continue; // skip split panes in same tab
317
+ seenTabs.add(displayName);
318
+ if (!knownIds.has(snap.id)) {
319
+ hybridManager.registerVisualSession(displayName, "", snap.id);
320
+ }
321
+ else {
322
+ // Sync name from iTerm (handles double-click renames)
323
+ hybridManager.updateName(snap.id, displayName);
324
+ }
325
+ }
85
326
  const hybridSessions = hybridManager.listSessions();
86
327
  const active = hybridManager.activeSession;
87
328
  const sessions = hybridSessions.map((s, i) => ({
88
329
  index: i + 1,
89
330
  name: s.name,
90
- type: s.kind === "visual" ? "claude" : "claude",
331
+ type: "claude",
91
332
  kind: s.kind,
92
333
  isActive: active ? s.id === active.id : false,
93
334
  id: s.backendSessionId,
@@ -147,8 +388,13 @@ end tell`);
147
388
  if (session.kind === "visual") {
148
389
  setItermSessionVar(session.backendSessionId, newName);
149
390
  setItermTabName(session.backendSessionId, newName);
391
+ setItermBadge(session.backendSessionId, newName);
150
392
  }
151
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);
152
398
  sendTo(ws, { type: "session_switched", name: session.name, sessionId: session.backendSessionId });
153
399
  log(`[PAILot] switched to ${session.kind} session "${session.name}" (${session.id})`);
154
400
  }
@@ -167,11 +413,110 @@ function handleRenameCommand(ws, args) {
167
413
  if (session.kind === "visual") {
168
414
  setItermSessionVar(sessionId, name);
169
415
  setItermTabName(sessionId, name);
416
+ setItermBadge(sessionId, name);
170
417
  }
171
418
  }
172
419
  sendTo(ws, { type: "session_renamed", sessionId, name });
420
+ // Send updated sessions list so PAILot refreshes the header
421
+ handleSessionsCommand(ws);
173
422
  log(`[PAILot] renamed session ${sessionId} to "${name}"`);
174
423
  }
424
+ function handleRemoveCommand(ws, args) {
425
+ const sessionId = args.sessionId;
426
+ if (!sessionId || !hybridManager) {
427
+ sendTo(ws, { type: "error", message: "Missing sessionId" });
428
+ return;
429
+ }
430
+ // Find session by backendSessionId
431
+ const sessions = hybridManager.listSessions();
432
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
433
+ if (idx < 0) {
434
+ sendTo(ws, { type: "error", message: "Session not found" });
435
+ return;
436
+ }
437
+ const target = sessions[idx];
438
+ // Kill the iTerm2 session if it's visual
439
+ if (target.kind === "visual" && target.backendSessionId) {
440
+ killSession(target.backendSessionId);
441
+ }
442
+ const removed = hybridManager.removeByIndex(idx + 1);
443
+ if (removed) {
444
+ log(`[PAILot] removed ${removed.kind} session "${removed.name}" (${removed.id})`);
445
+ }
446
+ // Send updated session list
447
+ handleSessionsCommand(ws);
448
+ }
449
+ function handleCreateCommand(ws, args = {}) {
450
+ const projectName = args.project;
451
+ const path = args.path;
452
+ // PAI project launch — async path
453
+ if (projectName) {
454
+ handleCreateFromProject(ws, projectName);
455
+ return;
456
+ }
457
+ // Custom path — cd then claude
458
+ const command = path ? `cd ${path.replace(/"/g, '\\"')} && claude` : "claude";
459
+ const name = path ? path.split("/").filter(Boolean).pop() ?? "Claude" : "Claude";
460
+ const sessionId = createClaudeSession(command);
461
+ if (!sessionId) {
462
+ sendTo(ws, { type: "error", message: "Failed to create new session" });
463
+ return;
464
+ }
465
+ setItermSessionVar(sessionId, name);
466
+ setItermTabName(sessionId, name);
467
+ setItermBadge(sessionId, name);
468
+ if (hybridManager) {
469
+ hybridManager.registerVisualSession(name, "", sessionId);
470
+ const sessions = hybridManager.listSessions();
471
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
472
+ if (idx >= 0) {
473
+ hybridManager.switchToIndex(idx + 1);
474
+ setActiveItermSessionId(sessionId);
475
+ setLastRoutedSessionId(sessionId);
476
+ }
477
+ }
478
+ log(`[PAILot] created new session "${name}" (${sessionId.slice(0, 8)}...)`);
479
+ sendTo(ws, { type: "session_switched", name, sessionId });
480
+ handleSessionsCommand(ws);
481
+ }
482
+ async function handleCreateFromProject(ws, projectName) {
483
+ try {
484
+ const { itermSessionId } = await launchPaiProject(projectName);
485
+ const displayName = projectName;
486
+ if (hybridManager) {
487
+ hybridManager.registerVisualSession(displayName, "", itermSessionId);
488
+ const sessions = hybridManager.listSessions();
489
+ const idx = sessions.findIndex(s => s.backendSessionId === itermSessionId);
490
+ if (idx >= 0) {
491
+ hybridManager.switchToIndex(idx + 1);
492
+ setActiveItermSessionId(itermSessionId);
493
+ }
494
+ }
495
+ log(`[PAILot] launched PAI project "${projectName}" (${itermSessionId.slice(0, 8)}...)`);
496
+ sendTo(ws, { type: "session_switched", name: displayName, sessionId: itermSessionId });
497
+ handleSessionsCommand(ws);
498
+ }
499
+ catch (err) {
500
+ log(`[PAILot] project launch failed: ${err}`);
501
+ sendTo(ws, { type: "error", message: `Failed to launch project: ${err instanceof Error ? err.message : String(err)}` });
502
+ }
503
+ }
504
+ async function handleProjectsCommand(ws) {
505
+ try {
506
+ const projects = await listPaiProjects();
507
+ const list = projects.map(p => ({
508
+ name: p.displayName || p.name,
509
+ slug: p.slug,
510
+ path: p.rootPath,
511
+ sessions: p.sessionCount,
512
+ }));
513
+ sendTo(ws, { type: "projects", projects: list });
514
+ }
515
+ catch (err) {
516
+ log(`[PAILot] projects list failed: ${err}`);
517
+ sendTo(ws, { type: "projects", projects: [] });
518
+ }
519
+ }
175
520
  async function handleNavCommand(ws, args) {
176
521
  const key = args.key;
177
522
  if (!key)
@@ -241,13 +586,30 @@ function sendTo(ws, msg) {
241
586
  }
242
587
  }
243
588
  function broadcast(msg) {
244
- if (clients.size === 0)
245
- return;
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;
596
+ let delivered = false;
246
597
  const payload = JSON.stringify(msg);
247
598
  for (const ws of clients) {
248
- if (ws.readyState === WebSocket.OPEN) {
249
- ws.send(payload);
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;
250
606
  }
607
+ ws.send(payload);
608
+ delivered = true;
609
+ }
610
+ // No live clients — buffer for later
611
+ if (!delivered) {
612
+ addToOutbox(msg);
251
613
  }
252
614
  }
253
615
  // --- Voice message batching ---
@@ -257,16 +619,29 @@ const BATCH_WINDOW_MS = 3000;
257
619
  let voiceBatchTimer = null;
258
620
  let voiceBatchTranscripts = [];
259
621
  let voiceBatchOnMessage = null;
622
+ /** iTerm session ID resolved when the first voice chunk of this batch arrived. */
623
+ let voiceBatchSessionId = "";
260
624
  function flushVoiceBatch() {
261
625
  if (voiceBatchTranscripts.length === 0)
262
626
  return;
263
627
  const combined = voiceBatchTranscripts.join(" ");
264
628
  const handler = voiceBatchOnMessage;
629
+ const batchSession = voiceBatchSessionId;
265
630
  voiceBatchTranscripts = [];
266
631
  voiceBatchOnMessage = null;
632
+ voiceBatchSessionId = "";
267
633
  voiceBatchTimer = null;
268
- if (handler) {
269
- 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) {
270
645
  setMessageSource("pailot");
271
646
  handler(`[PAILot:voice] ${combined}`, Date.now());
272
647
  setMessageSource("whatsapp");
@@ -274,7 +649,7 @@ function flushVoiceBatch() {
274
649
  }
275
650
  // --- Voice transcription for PAILot ---
276
651
  const execFileAsync = promisify(execFile);
277
- async function transcribeAndRoute(audioBase64, onMessage) {
652
+ async function transcribeAndRoute(audioBase64, onMessage, messageId) {
278
653
  const base = `pailot-voice-${Date.now()}-${randomUUID().slice(0, 8)}`;
279
654
  const audioFile = join(tmpdir(), `${base}.m4a`);
280
655
  const filesToClean = [
@@ -309,6 +684,10 @@ async function transcribeAndRoute(audioBase64, onMessage) {
309
684
  return;
310
685
  }
311
686
  log(`[PAILot] Transcription: ${transcript.slice(0, 80)}${transcript.length > 80 ? "..." : ""}`);
687
+ // Reflect transcript back to the app so the voice bubble shows text
688
+ if (messageId) {
689
+ broadcast({ type: "transcript", messageId, content: transcript });
690
+ }
312
691
  // Batch: accumulate transcripts and reset the timer
313
692
  voiceBatchTranscripts.push(transcript);
314
693
  voiceBatchOnMessage = onMessage;
@@ -335,18 +714,68 @@ async function transcribeAndRoute(audioBase64, onMessage) {
335
714
  */
336
715
  export function startWsGateway(onMessage) {
337
716
  wss = new WebSocketServer({ port: WS_PORT });
717
+ // Restore any buffered messages from a previous daemon run
718
+ restoreOutbox();
338
719
  wss.on("listening", () => {
339
720
  log(`WebSocket gateway listening on ws://0.0.0.0:${WS_PORT}`);
721
+ // Pre-populate hybrid manager with live iTerm sessions so messages
722
+ // can be tagged with sessionId even before a PAILot client connects.
723
+ if (hybridManager) {
724
+ try {
725
+ const liveSnapshots = snapshotAllSessions();
726
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
727
+ const seenTabs = new Set();
728
+ for (const snap of liveSnapshots) {
729
+ if (!isClaudeRelated(snap))
730
+ continue;
731
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
732
+ if (seenTabs.has(displayName))
733
+ continue;
734
+ seenTabs.add(displayName);
735
+ if (!knownIds.has(snap.id)) {
736
+ hybridManager.registerVisualSession(displayName, "", snap.id);
737
+ }
738
+ }
739
+ // Also set the active session based on current iTerm focus
740
+ const focusedId = runAppleScript(`tell application "iTerm2"
741
+ try
742
+ return id of current session of current tab of current window
743
+ on error
744
+ return ""
745
+ end try
746
+ end tell`)?.trim() ?? "";
747
+ if (focusedId) {
748
+ const sessions = hybridManager.listSessions();
749
+ const idx = sessions.findIndex(s => s.backendSessionId === focusedId);
750
+ if (idx >= 0) {
751
+ hybridManager.switchToIndex(idx + 1);
752
+ setActiveItermSessionId(focusedId);
753
+ }
754
+ }
755
+ log(`Pre-registered ${hybridManager.listSessions().length} session(s) from live iTerm`);
756
+ }
757
+ catch (err) {
758
+ log(`Failed to pre-register sessions: ${err}`);
759
+ }
760
+ }
340
761
  });
341
762
  wss.on("connection", (ws, req) => {
342
763
  const addr = req.socket.remoteAddress ?? "unknown";
343
764
  log(`PAILot client connected from ${addr}`);
344
765
  clients.add(ws);
766
+ clientLastActive.set(ws, Date.now());
345
767
  ws.on("message", (raw) => {
768
+ clientLastActive.set(ws, Date.now());
346
769
  try {
347
770
  const rawStr = raw.toString();
348
- dbg(`RAW msg (${rawStr.length} chars): type=${JSON.parse(rawStr).type}, hasAudio=${!!JSON.parse(rawStr).audioBase64}, content=${(JSON.parse(rawStr).content ?? "").slice(0, 50)}`);
349
771
  const msg = JSON.parse(rawStr);
772
+ dbg(`RAW msg (${rawStr.length} chars): type=${msg.type}, hasAudio=${!!msg.audioBase64}, content=${(msg.content ?? "").slice(0, 50)}`);
773
+ // Heartbeat ping — reply with pong immediately.
774
+ // The clientLastActive update above already covers liveness.
775
+ if (msg.type === "ping") {
776
+ sendTo(ws, { type: "pong" });
777
+ return;
778
+ }
350
779
  // Structured commands from PAILot app
351
780
  if (msg.type === "command") {
352
781
  const command = msg.command;
@@ -357,7 +786,7 @@ export function startWsGateway(onMessage) {
357
786
  handleSessionsCommand(ws);
358
787
  return;
359
788
  case "sync":
360
- handleSyncCommand(ws);
789
+ handleSyncCommand(ws, args);
361
790
  return;
362
791
  case "switch":
363
792
  handleSwitchCommand(ws, args);
@@ -365,6 +794,15 @@ export function startWsGateway(onMessage) {
365
794
  case "rename":
366
795
  handleRenameCommand(ws, args);
367
796
  return;
797
+ case "remove":
798
+ handleRemoveCommand(ws, args);
799
+ return;
800
+ case "create":
801
+ handleCreateCommand(ws, args);
802
+ return;
803
+ case "projects":
804
+ handleProjectsCommand(ws);
805
+ return;
368
806
  case "screenshot":
369
807
  // For API sessions, send text status instead of screenshot
370
808
  if (hybridManager?.activeSession?.kind === "api") {
@@ -388,23 +826,92 @@ export function startWsGateway(onMessage) {
388
826
  break;
389
827
  }
390
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
+ }
391
852
  // Voice message — transcribe with Whisper then route
392
853
  if (msg.type === "voice" && msg.audioBase64) {
393
854
  dbg(`Voice message received, audioBase64 length: ${msg.audioBase64.length}`);
394
- transcribeAndRoute(msg.audioBase64, onMessage).catch((err) => {
855
+ broadcast({ type: "typing", typing: true, ...(routeTarget && { sessionId: routeTarget }) });
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;
860
+ transcribeAndRoute(msg.audioBase64, onMessage, voiceMsgId).catch((err) => {
395
861
  log(`[PAILot] voice transcription error: ${err}`);
396
862
  });
397
863
  return;
398
864
  }
399
- // Plain text message — route through handleMessage
865
+ // Image message — save to temp file, route caption as text
866
+ // NOTE: Do NOT send the file path to Claude Code — it tries to read .jpg files
867
+ // as images, which corrupts the conversation context with unprocessable image data.
868
+ if (msg.type === "image" && msg.imageBase64) {
869
+ const ext = (msg.mimeType ?? "image/jpeg").includes("png") ? "png" : "jpg";
870
+ const imgPath = join(tmpdir(), `pailot-img-${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
871
+ const imgBuf = Buffer.from(msg.imageBase64, "base64");
872
+ writeFileSync(imgPath, imgBuf);
873
+ log(`[PAILot] Image saved (${imgBuf.length} bytes) → ${imgPath}`);
874
+ const caption = msg.caption || "";
875
+ // Embed the path inside parentheses so Claude Code doesn't auto-attach
876
+ // the .jpg as an image (which corrupts the session if the API rejects it).
877
+ // Claude can still use the Read tool to view it.
878
+ const routeText = caption
879
+ ? `${caption} (image at ${imgPath})`
880
+ : `(image at ${imgPath})`;
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
+ }
895
+ return;
896
+ }
897
+ // Plain text message — route through AIBP bridge (or legacy fallback)
400
898
  const text = msg.content ?? "";
401
899
  if (!text.trim())
402
900
  return;
403
901
  log(`[PAILot] ← ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`);
404
- // Set source so commands handler uses [PAILot] prefix
405
- setMessageSource("pailot");
406
- onMessage(text, Date.now());
407
- 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
+ }
408
915
  }
409
916
  catch {
410
917
  log(`[PAILot] Invalid message from ${addr}`);
@@ -413,12 +920,17 @@ export function startWsGateway(onMessage) {
413
920
  ws.on("close", () => {
414
921
  log(`PAILot client disconnected from ${addr}`);
415
922
  clients.delete(ws);
923
+ clientLastActive.delete(ws);
924
+ clientActiveSession.delete(ws);
416
925
  });
417
926
  ws.on("error", (err) => {
418
927
  log(`[PAILot] WebSocket error: ${err.message}`);
419
928
  clients.delete(ws);
929
+ clientLastActive.delete(ws);
930
+ clientActiveSession.delete(ws);
420
931
  });
421
- // Welcome
932
+ // Welcome — outbox drains after client sends "sync" command
933
+ // (so activeSessionId is set before messages arrive)
422
934
  sendTo(ws, { type: "text", content: "Connected to PAILot gateway." });
423
935
  });
424
936
  wss.on("error", (err) => {
@@ -427,15 +939,40 @@ export function startWsGateway(onMessage) {
427
939
  }
428
940
  /**
429
941
  * Broadcast a text message to all connected PAILot clients.
942
+ * @param sessionId — iTerm session ID of the originating Claude session
430
943
  */
431
- export function broadcastText(text) {
432
- broadcast({ type: "text", content: text });
944
+ function resolveSessionId(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;
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;
958
+ if (activeItermSessionId)
959
+ return activeItermSessionId;
960
+ // Last resort: ask hybrid manager for the active session's backend ID
961
+ return hybridManager?.activeSession?.backendSessionId || undefined;
962
+ }
963
+ export function broadcastText(text, sessionId) {
964
+ const resolvedSession = resolveSessionId(sessionId);
965
+ broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) });
966
+ broadcast({ type: "text", content: text, ...(resolvedSession && { sessionId: resolvedSession }) });
433
967
  }
434
968
  /**
435
969
  * Broadcast a voice note to all connected PAILot clients.
436
970
  * Converts OGG Opus to M4A (AAC) since iOS can't play OGG natively.
971
+ * @param sessionId — iTerm session ID of the originating Claude session
437
972
  */
438
- export async function broadcastVoice(audioBuffer, transcript) {
973
+ export async function broadcastVoice(audioBuffer, transcript, sessionId) {
974
+ const resolvedSession = resolveSessionId(sessionId);
975
+ broadcast({ type: "typing", typing: false, ...(resolvedSession && { sessionId: resolvedSession }) });
439
976
  let sendBuffer = audioBuffer;
440
977
  // Convert OGG Opus → M4A for iOS compatibility
441
978
  try {
@@ -462,16 +999,20 @@ export async function broadcastVoice(audioBuffer, transcript) {
462
999
  type: "voice",
463
1000
  content: transcript,
464
1001
  audioBase64: sendBuffer.toString("base64"),
1002
+ ...(resolvedSession && { sessionId: resolvedSession }),
465
1003
  });
466
1004
  }
467
1005
  /**
468
1006
  * Broadcast a screenshot/image to all connected PAILot clients.
1007
+ * @param sessionId — iTerm session ID of the originating Claude session
469
1008
  */
470
- export function broadcastImage(imageBuffer, caption) {
1009
+ export function broadcastImage(imageBuffer, caption, sessionId) {
1010
+ const resolvedSession = resolveSessionId(sessionId);
471
1011
  broadcast({
472
1012
  type: "image",
473
1013
  imageBase64: imageBuffer.toString("base64"),
474
1014
  caption: caption ?? "Screenshot",
1015
+ ...(resolvedSession && { sessionId: resolvedSession }),
475
1016
  });
476
1017
  }
477
1018
  /**