aibroker 0.2.6 → 0.6.1

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 (170) hide show
  1. package/README.md +164 -4
  2. package/dist/adapters/iterm/core.d.ts +2 -0
  3. package/dist/adapters/iterm/core.d.ts.map +1 -1
  4. package/dist/adapters/iterm/core.js +13 -5
  5. package/dist/adapters/iterm/core.js.map +1 -1
  6. package/dist/adapters/iterm/iterm2-api.d.ts +20 -0
  7. package/dist/adapters/iterm/iterm2-api.d.ts.map +1 -0
  8. package/dist/adapters/iterm/iterm2-api.js +244 -0
  9. package/dist/adapters/iterm/iterm2-api.js.map +1 -0
  10. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  11. package/dist/adapters/iterm/sessions.js +3 -2
  12. package/dist/adapters/iterm/sessions.js.map +1 -1
  13. package/dist/adapters/kokoro/media.d.ts +2 -1
  14. package/dist/adapters/kokoro/media.d.ts.map +1 -1
  15. package/dist/adapters/kokoro/media.js +53 -5
  16. package/dist/adapters/kokoro/media.js.map +1 -1
  17. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).d.ts +49 -0
  18. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).d.ts.map +1 -0
  19. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).js +632 -0
  20. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-56).js.map +1 -0
  21. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-13-59).js +632 -0
  22. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).d.ts +49 -0
  23. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +1 -0
  24. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).js +614 -0
  25. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-36).js.map +1 -0
  26. package/dist/adapters/pailot/gateway (SFConflict mnott 2026-03-06-21-15-46).js +614 -0
  27. package/dist/adapters/pailot/gateway.d.ts +48 -0
  28. package/dist/adapters/pailot/gateway.d.ts (SFConflict mnott 2026-03-06-21-13-59).map +1 -0
  29. package/dist/adapters/pailot/gateway.d.ts (SFConflict mnott 2026-03-06-21-15-46).map +1 -0
  30. package/dist/adapters/pailot/gateway.d.ts.map +1 -0
  31. package/dist/adapters/pailot/gateway.js +828 -0
  32. package/dist/adapters/pailot/gateway.js (SFConflict mnott 2026-03-06-21-13-59).map +1 -0
  33. package/dist/adapters/pailot/gateway.js (SFConflict mnott 2026-03-06-21-15-46).map +1 -0
  34. package/dist/adapters/pailot/gateway.js.map +1 -0
  35. package/dist/backend/api.d.ts +5 -1
  36. package/dist/backend/api.d.ts.map +1 -1
  37. package/dist/backend/api.js +74 -3
  38. package/dist/backend/api.js.map +1 -1
  39. package/dist/core/hybrid.d.ts +7 -0
  40. package/dist/core/hybrid.d.ts.map +1 -1
  41. package/dist/core/hybrid.js +33 -0
  42. package/dist/core/hybrid.js.map +1 -1
  43. package/dist/core/state.d.ts +3 -0
  44. package/dist/core/state.d.ts.map +1 -1
  45. package/dist/core/state.js +4 -0
  46. package/dist/core/state.js.map +1 -1
  47. package/dist/core/status-cache.d.ts +51 -0
  48. package/dist/core/status-cache.d.ts.map +1 -0
  49. package/dist/core/status-cache.js +62 -0
  50. package/dist/core/status-cache.js.map +1 -0
  51. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).d.ts +63 -0
  52. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +1 -0
  53. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).js +229 -0
  54. package/dist/daemon/adapter-registry (SFConflict mnott 2026-03-06-21-15-36).js.map +1 -0
  55. package/dist/daemon/adapter-registry.d.ts +63 -0
  56. package/dist/daemon/adapter-registry.d.ts.map +1 -0
  57. package/dist/daemon/adapter-registry.js +240 -0
  58. package/dist/daemon/adapter-registry.js.map +1 -0
  59. package/dist/daemon/cli.d.ts +14 -0
  60. package/dist/daemon/cli.d.ts.map +1 -0
  61. package/dist/daemon/cli.js +150 -0
  62. package/dist/daemon/cli.js.map +1 -0
  63. package/dist/daemon/command-context.d.ts +24 -0
  64. package/dist/daemon/command-context.d.ts.map +1 -0
  65. package/dist/daemon/command-context.js +13 -0
  66. package/dist/daemon/command-context.js.map +1 -0
  67. package/dist/daemon/commands.d.ts +22 -0
  68. package/dist/daemon/commands.d.ts.map +1 -0
  69. package/dist/daemon/commands.js +632 -0
  70. package/dist/daemon/commands.js.map +1 -0
  71. package/dist/daemon/core-handlers.d.ts +24 -0
  72. package/dist/daemon/core-handlers.d.ts.map +1 -0
  73. package/dist/daemon/core-handlers.js +640 -0
  74. package/dist/daemon/core-handlers.js.map +1 -0
  75. package/dist/daemon/create-adapter.d.ts +22 -0
  76. package/dist/daemon/create-adapter.d.ts.map +1 -0
  77. package/dist/daemon/create-adapter.js +153 -0
  78. package/dist/daemon/create-adapter.js.map +1 -0
  79. package/dist/daemon/image-gen.d.ts +28 -0
  80. package/dist/daemon/image-gen.d.ts.map +1 -0
  81. package/dist/daemon/image-gen.js +97 -0
  82. package/dist/daemon/image-gen.js.map +1 -0
  83. package/dist/daemon/index.d.ts +12 -0
  84. package/dist/daemon/index.d.ts.map +1 -0
  85. package/dist/daemon/index.js +184 -0
  86. package/dist/daemon/index.js.map +1 -0
  87. package/dist/daemon/pai-projects.d.ts +68 -0
  88. package/dist/daemon/pai-projects.d.ts.map +1 -0
  89. package/dist/daemon/pai-projects.js +174 -0
  90. package/dist/daemon/pai-projects.js.map +1 -0
  91. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).d.ts +12 -0
  92. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).d.ts.map +1 -0
  93. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).js +252 -0
  94. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-56).js.map +1 -0
  95. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-13-59).js +252 -0
  96. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).d.ts +12 -0
  97. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +1 -0
  98. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).js +240 -0
  99. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-36).js.map +1 -0
  100. package/dist/daemon/screenshot (SFConflict mnott 2026-03-06-21-15-46).js +240 -0
  101. package/dist/daemon/screenshot.d.ts +12 -0
  102. package/dist/daemon/screenshot.d.ts (SFConflict mnott 2026-03-06-21-13-59).map +1 -0
  103. package/dist/daemon/screenshot.d.ts (SFConflict mnott 2026-03-06-21-15-46).map +1 -0
  104. package/dist/daemon/screenshot.d.ts.map +1 -0
  105. package/dist/daemon/screenshot.js +252 -0
  106. package/dist/daemon/screenshot.js (SFConflict mnott 2026-03-06-21-13-59).map +1 -0
  107. package/dist/daemon/screenshot.js (SFConflict mnott 2026-03-06-21-15-46).map +1 -0
  108. package/dist/daemon/screenshot.js.map +1 -0
  109. package/dist/daemon/session-content.d.ts +27 -0
  110. package/dist/daemon/session-content.d.ts.map +1 -0
  111. package/dist/daemon/session-content.js +76 -0
  112. package/dist/daemon/session-content.js.map +1 -0
  113. package/dist/daemon/vision.d.ts +46 -0
  114. package/dist/daemon/vision.d.ts.map +1 -0
  115. package/dist/daemon/vision.js +176 -0
  116. package/dist/daemon/vision.js.map +1 -0
  117. package/dist/index.d.ts +16 -2
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +12 -1
  120. package/dist/index.js.map +1 -1
  121. package/dist/ipc/client.d.ts +4 -1
  122. package/dist/ipc/client.d.ts.map +1 -1
  123. package/dist/ipc/client.js +10 -1
  124. package/dist/ipc/client.js.map +1 -1
  125. package/dist/ipc/validate.d.ts +52 -0
  126. package/dist/ipc/validate.d.ts.map +1 -0
  127. package/dist/ipc/validate.js +129 -0
  128. package/dist/ipc/validate.js.map +1 -0
  129. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).d.ts +23 -0
  130. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).d.ts.map +1 -0
  131. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).js +595 -0
  132. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-56).js.map +1 -0
  133. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-13-59).js +595 -0
  134. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).d.ts +23 -0
  135. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).d.ts.map +1 -0
  136. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).js +592 -0
  137. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-36).js.map +1 -0
  138. package/dist/mcp/index (SFConflict mnott 2026-03-06-21-15-46).js +592 -0
  139. package/dist/mcp/index.d.ts +23 -0
  140. package/dist/mcp/index.d.ts.map +1 -0
  141. package/dist/mcp/index.js +660 -0
  142. package/dist/mcp/index.js (SFConflict mnott 2026-03-06-21-13-59).map +1 -0
  143. package/dist/mcp/index.js (SFConflict mnott 2026-03-06-21-15-46).map +1 -0
  144. package/dist/mcp/index.js.map +1 -0
  145. package/dist/types/adapter.d.ts +41 -0
  146. package/dist/types/adapter.d.ts.map +1 -0
  147. package/dist/types/adapter.js +2 -0
  148. package/dist/types/adapter.js.map +1 -0
  149. package/dist/types/backend.d.ts +29 -1
  150. package/dist/types/backend.d.ts.map +1 -1
  151. package/dist/types/broker.d.ts +47 -0
  152. package/dist/types/broker.d.ts.map +1 -0
  153. package/dist/types/broker.js +21 -0
  154. package/dist/types/broker.js.map +1 -0
  155. package/dist/types/index.d.ts +2 -0
  156. package/dist/types/index.d.ts.map +1 -1
  157. package/dist/types/index.js +2 -0
  158. package/dist/types/index.js.map +1 -1
  159. package/package.json +12 -2
  160. package/templates/adapter/ONBOARDING_PROMPT.md +309 -0
  161. package/templates/adapter/README.md.tmpl +81 -0
  162. package/templates/adapter/package.json.tmpl +23 -0
  163. package/templates/adapter/src/watcher/cli.ts.tmpl +12 -0
  164. package/templates/adapter/src/watcher/commands.ts.tmpl +44 -0
  165. package/templates/adapter/src/watcher/connection.ts.tmpl +59 -0
  166. package/templates/adapter/src/watcher/index.ts.tmpl +201 -0
  167. package/templates/adapter/src/watcher/ipc-server.ts.tmpl +250 -0
  168. package/templates/adapter/src/watcher/send.ts.tmpl +62 -0
  169. package/templates/adapter/src/watcher/state.ts.tmpl +39 -0
  170. package/templates/adapter/tsconfig.json.tmpl +14 -0
@@ -0,0 +1,828 @@
1
+ /**
2
+ * adapters/pailot/gateway.ts — WebSocket gateway for PAILot app connections.
3
+ *
4
+ * Runs alongside any transport's watcher (Whazaa, Telex, etc.). When the
5
+ * PAILot iOS app connects via WebSocket, incoming messages are routed through
6
+ * the same handleMessage() path as the transport's native messages. Outbound
7
+ * messages from Claude are broadcast to all connected clients.
8
+ *
9
+ * The gateway also supports structured commands (sessions, screenshot,
10
+ * navigation keys) so the app can interact with the watcher without
11
+ * going through text-based slash commands.
12
+ */
13
+ import { WebSocketServer, WebSocket } from "ws";
14
+ import { join } from "node:path";
15
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, appendFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ const DEBUG_LOG = process.env.PAILOT_DEBUG ? "/tmp/pailot-ws-debug.log" : null;
18
+ function dbg(msg) {
19
+ if (DEBUG_LOG)
20
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`);
21
+ }
22
+ import { randomUUID } from "node:crypto";
23
+ import { promisify } from "node:util";
24
+ import { execFile } from "node:child_process";
25
+ import { log } from "../../core/log.js";
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";
29
+ import { listPaiProjects, launchPaiProject } from "../../daemon/pai-projects.js";
30
+ import { runAppleScript, sendKeystrokeToSession, sendEscapeSequenceToSession, pasteTextIntoSession, snapshotAllSessions } from "../iterm/core.js";
31
+ import { hybridManager } from "../../core/hybrid.js";
32
+ const WS_PORT = parseInt(process.env.PAILOT_PORT ?? "8765", 10);
33
+ // --- State ---
34
+ let wss = null;
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;
42
+ let missedImageCount = 0;
43
+ function addToOutbox(msg) {
44
+ const type = msg.type;
45
+ // Skip typing indicators — not useful to replay
46
+ if (type === "typing")
47
+ return;
48
+ // Count but don't buffer screenshots (very large, less important)
49
+ if (type === "image") {
50
+ missedImageCount++;
51
+ return;
52
+ }
53
+ // Buffer text, voice, sessions, errors, etc.
54
+ outbox.push({ msg, timestamp: Date.now() });
55
+ if (outbox.length > MAX_OUTBOX)
56
+ outbox.shift();
57
+ }
58
+ function drainOutbox(ws) {
59
+ if (outbox.length === 0 && missedVoiceCount === 0 && missedImageCount === 0)
60
+ 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;
65
+ const parts = [];
66
+ if (textCount > 0)
67
+ parts.push(`${textCount} text message(s)`);
68
+ if (voiceCount > 0)
69
+ parts.push(`${voiceCount} voice note(s)`);
70
+ if (missedImageCount > 0)
71
+ parts.push(`${missedImageCount} image(s)`);
72
+ if (otherCount > 0)
73
+ parts.push(`${otherCount} other`);
74
+ sendTo(ws, {
75
+ type: "text",
76
+ content: `📬 While you were away: ${parts.join(", ")}`,
77
+ });
78
+ // Replay buffered messages
79
+ for (const entry of outbox) {
80
+ sendTo(ws, entry.msg);
81
+ }
82
+ // Clear outbox
83
+ outbox.length = 0;
84
+ missedVoiceCount = 0;
85
+ missedImageCount = 0;
86
+ }
87
+ // Reference to the screenshot handler — set via setScreenshotHandler()
88
+ // to avoid circular imports (screenshot.ts imports from state.ts which
89
+ // would create a cycle if we imported it here directly).
90
+ let screenshotHandler = null;
91
+ /**
92
+ * Provide the screenshot handler so ws-gateway can trigger screenshots
93
+ * for navigation commands without a circular import.
94
+ */
95
+ export function setScreenshotHandler(handler) {
96
+ screenshotHandler = handler;
97
+ }
98
+ // --- Structured command handling ---
99
+ /**
100
+ * Filter: include only Claude-related sessions.
101
+ * A session qualifies if it has paiName, name contains "claude",
102
+ * or is not at shell prompt (has a process running — likely Claude).
103
+ */
104
+ function isClaudeRelated(snap) {
105
+ if (snap.paiName)
106
+ return true;
107
+ const name = (snap.tabTitle ?? snap.name).toLowerCase();
108
+ if (name.includes("claude"))
109
+ return true;
110
+ if (!snap.atPrompt)
111
+ return true;
112
+ return false;
113
+ }
114
+ /** Detect which iTerm2 session is currently focused and sync the hybrid manager to it.
115
+ * If the client passes activeSessionId, preserve that selection instead of
116
+ * jumping to whatever iTerm has focused on the Mac.
117
+ */
118
+ function handleSyncCommand(ws, args) {
119
+ if (!hybridManager) {
120
+ handleSessionsCommand(ws);
121
+ drainOutbox(ws);
122
+ return;
123
+ }
124
+ const clientActiveId = typeof args?.activeSessionId === "string" ? args.activeSessionId : undefined;
125
+ // Auto-discover Claude-related iTerm2 tabs so freshly-started daemons can match
126
+ const liveSnapshots = snapshotAllSessions();
127
+ const liveIds = new Set(liveSnapshots.map(s => s.id));
128
+ hybridManager.pruneDeadVisualSessions(liveIds);
129
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
130
+ const seenTabs = new Set();
131
+ for (const snap of liveSnapshots) {
132
+ if (!isClaudeRelated(snap))
133
+ continue;
134
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
135
+ if (seenTabs.has(displayName))
136
+ continue;
137
+ seenTabs.add(displayName);
138
+ if (!knownIds.has(snap.id)) {
139
+ hybridManager.registerVisualSession(displayName, "", snap.id);
140
+ }
141
+ }
142
+ // If the client had a session open, try to restore it
143
+ if (clientActiveId) {
144
+ const sessions = hybridManager.listSessions();
145
+ const idx = sessions.findIndex(s => s.backendSessionId === clientActiveId);
146
+ if (idx >= 0) {
147
+ hybridManager.switchToIndex(idx + 1);
148
+ setActiveItermSessionId(clientActiveId);
149
+ log(`[PAILot] sync: restored client session "${sessions[idx].name}" (${clientActiveId.slice(0, 8)}...)`);
150
+ handleSessionsCommand(ws);
151
+ drainOutbox(ws);
152
+ return;
153
+ }
154
+ // Client's session no longer exists — fall through to iTerm focus
155
+ }
156
+ // No client preference — ask iTerm2 which session is focused right now
157
+ const focusedId = runAppleScript(`tell application "iTerm2"
158
+ try
159
+ return id of current session of current tab of current window
160
+ on error
161
+ return ""
162
+ end try
163
+ end tell`)?.trim() ?? "";
164
+ 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);
168
+ if (idx >= 0) {
169
+ hybridManager.switchToIndex(idx + 1);
170
+ setActiveItermSessionId(focusedId);
171
+ log(`[PAILot] sync: activated focused session "${sessions[idx].name}" (${focusedId.slice(0, 8)}...)`);
172
+ }
173
+ else {
174
+ log(`[PAILot] sync: focused session ${focusedId.slice(0, 8)}... not registered`);
175
+ }
176
+ }
177
+ // Return sessions with updated active state, then drain buffered messages
178
+ handleSessionsCommand(ws);
179
+ drainOutbox(ws);
180
+ }
181
+ function handleSessionsCommand(ws) {
182
+ if (!hybridManager) {
183
+ sendTo(ws, { type: "sessions", sessions: [] });
184
+ return;
185
+ }
186
+ // Prune visual sessions whose iTerm2 tabs have been closed
187
+ const liveSnapshots = snapshotAllSessions();
188
+ const liveIds = new Set(liveSnapshots.map(s => s.id));
189
+ hybridManager.pruneDeadVisualSessions(liveIds);
190
+ // Auto-discover Claude-related iTerm2 tabs not yet in the hybrid manager,
191
+ // and sync names of existing sessions from live iTerm state.
192
+ // Deduplicate by tab title — only register first session per tab.
193
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
194
+ const seenTabs = new Set();
195
+ for (const snap of liveSnapshots) {
196
+ if (!isClaudeRelated(snap))
197
+ continue;
198
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
199
+ if (seenTabs.has(displayName))
200
+ continue; // skip split panes in same tab
201
+ seenTabs.add(displayName);
202
+ if (!knownIds.has(snap.id)) {
203
+ hybridManager.registerVisualSession(displayName, "", snap.id);
204
+ }
205
+ else {
206
+ // Sync name from iTerm (handles double-click renames)
207
+ hybridManager.updateName(snap.id, displayName);
208
+ }
209
+ }
210
+ const hybridSessions = hybridManager.listSessions();
211
+ const active = hybridManager.activeSession;
212
+ const sessions = hybridSessions.map((s, i) => ({
213
+ index: i + 1,
214
+ name: s.name,
215
+ type: "claude",
216
+ kind: s.kind,
217
+ isActive: active ? s.id === active.id : false,
218
+ id: s.backendSessionId,
219
+ }));
220
+ const payload = JSON.stringify({ type: "sessions", sessions });
221
+ if (ws.readyState === WebSocket.OPEN) {
222
+ ws.send(payload);
223
+ }
224
+ }
225
+ function handleSwitchCommand(ws, args) {
226
+ const sessionIndex = args.index;
227
+ const sessionId = args.sessionId;
228
+ const newName = args.name;
229
+ if (!hybridManager) {
230
+ sendTo(ws, { type: "error", message: "No session manager" });
231
+ return;
232
+ }
233
+ // Resolve which session to switch to (prefer index, fall back to sessionId lookup)
234
+ let targetIndex;
235
+ if (sessionIndex) {
236
+ targetIndex = sessionIndex;
237
+ }
238
+ else if (sessionId) {
239
+ const sessions = hybridManager.listSessions();
240
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
241
+ if (idx >= 0)
242
+ targetIndex = idx + 1;
243
+ }
244
+ if (!targetIndex) {
245
+ sendTo(ws, { type: "error", message: "Missing session index or ID" });
246
+ return;
247
+ }
248
+ const session = hybridManager.switchToIndex(targetIndex);
249
+ if (!session) {
250
+ sendTo(ws, { type: "error", message: "Session not found — it may have closed." });
251
+ return;
252
+ }
253
+ // For visual sessions, also focus the iTerm2 tab
254
+ if (session.kind === "visual") {
255
+ setActiveItermSessionId(session.backendSessionId);
256
+ const escapedId = session.backendSessionId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
257
+ runAppleScript(`tell application "iTerm2"
258
+ repeat with aWindow in windows
259
+ repeat with aTab in tabs of aWindow
260
+ repeat with aSession in sessions of aTab
261
+ if id of aSession is "${escapedId}" then
262
+ select aSession
263
+ return "focused"
264
+ end if
265
+ end repeat
266
+ end repeat
267
+ end repeat
268
+ end tell`);
269
+ }
270
+ if (newName) {
271
+ session.name = newName;
272
+ if (session.kind === "visual") {
273
+ setItermSessionVar(session.backendSessionId, newName);
274
+ setItermTabName(session.backendSessionId, newName);
275
+ }
276
+ }
277
+ sendTo(ws, { type: "session_switched", name: session.name, sessionId: session.backendSessionId });
278
+ log(`[PAILot] switched to ${session.kind} session "${session.name}" (${session.id})`);
279
+ }
280
+ function handleRenameCommand(ws, args) {
281
+ const sessionId = args.sessionId;
282
+ const name = args.name;
283
+ if (!sessionId || !name || !hybridManager) {
284
+ sendTo(ws, { type: "error", message: "Missing sessionId or name" });
285
+ return;
286
+ }
287
+ // Find the hybrid session by backendSessionId
288
+ const sessions = hybridManager.listSessions();
289
+ const session = sessions.find(s => s.backendSessionId === sessionId);
290
+ if (session) {
291
+ session.name = name;
292
+ if (session.kind === "visual") {
293
+ setItermSessionVar(sessionId, name);
294
+ setItermTabName(sessionId, name);
295
+ }
296
+ }
297
+ sendTo(ws, { type: "session_renamed", sessionId, name });
298
+ // Send updated sessions list so PAILot refreshes the header
299
+ handleSessionsCommand(ws);
300
+ log(`[PAILot] renamed session ${sessionId} to "${name}"`);
301
+ }
302
+ function handleRemoveCommand(ws, args) {
303
+ const sessionId = args.sessionId;
304
+ if (!sessionId || !hybridManager) {
305
+ sendTo(ws, { type: "error", message: "Missing sessionId" });
306
+ return;
307
+ }
308
+ // Find session by backendSessionId
309
+ const sessions = hybridManager.listSessions();
310
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
311
+ if (idx < 0) {
312
+ sendTo(ws, { type: "error", message: "Session not found" });
313
+ return;
314
+ }
315
+ const target = sessions[idx];
316
+ // Kill the iTerm2 session if it's visual
317
+ if (target.kind === "visual" && target.backendSessionId) {
318
+ killSession(target.backendSessionId);
319
+ }
320
+ const removed = hybridManager.removeByIndex(idx + 1);
321
+ if (removed) {
322
+ log(`[PAILot] removed ${removed.kind} session "${removed.name}" (${removed.id})`);
323
+ }
324
+ // Send updated session list
325
+ handleSessionsCommand(ws);
326
+ }
327
+ function handleCreateCommand(ws, args = {}) {
328
+ const projectName = args.project;
329
+ const path = args.path;
330
+ // PAI project launch — async path
331
+ if (projectName) {
332
+ handleCreateFromProject(ws, projectName);
333
+ return;
334
+ }
335
+ // Custom path — cd then claude
336
+ const command = path ? `cd ${path.replace(/"/g, '\\"')} && claude` : "claude";
337
+ const name = path ? path.split("/").filter(Boolean).pop() ?? "Claude" : "Claude";
338
+ const sessionId = createClaudeSession(command);
339
+ if (!sessionId) {
340
+ sendTo(ws, { type: "error", message: "Failed to create new session" });
341
+ return;
342
+ }
343
+ setItermSessionVar(sessionId, name);
344
+ setItermTabName(sessionId, name);
345
+ if (hybridManager) {
346
+ hybridManager.registerVisualSession(name, "", sessionId);
347
+ const sessions = hybridManager.listSessions();
348
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
349
+ if (idx >= 0) {
350
+ hybridManager.switchToIndex(idx + 1);
351
+ setActiveItermSessionId(sessionId);
352
+ }
353
+ }
354
+ log(`[PAILot] created new session "${name}" (${sessionId.slice(0, 8)}...)`);
355
+ sendTo(ws, { type: "session_switched", name, sessionId });
356
+ handleSessionsCommand(ws);
357
+ }
358
+ async function handleCreateFromProject(ws, projectName) {
359
+ try {
360
+ const { itermSessionId } = await launchPaiProject(projectName);
361
+ const displayName = projectName;
362
+ if (hybridManager) {
363
+ hybridManager.registerVisualSession(displayName, "", itermSessionId);
364
+ const sessions = hybridManager.listSessions();
365
+ const idx = sessions.findIndex(s => s.backendSessionId === itermSessionId);
366
+ if (idx >= 0) {
367
+ hybridManager.switchToIndex(idx + 1);
368
+ setActiveItermSessionId(itermSessionId);
369
+ }
370
+ }
371
+ log(`[PAILot] launched PAI project "${projectName}" (${itermSessionId.slice(0, 8)}...)`);
372
+ sendTo(ws, { type: "session_switched", name: displayName, sessionId: itermSessionId });
373
+ handleSessionsCommand(ws);
374
+ }
375
+ catch (err) {
376
+ log(`[PAILot] project launch failed: ${err}`);
377
+ sendTo(ws, { type: "error", message: `Failed to launch project: ${err instanceof Error ? err.message : String(err)}` });
378
+ }
379
+ }
380
+ async function handleProjectsCommand(ws) {
381
+ try {
382
+ const projects = await listPaiProjects();
383
+ const list = projects.map(p => ({
384
+ name: p.displayName || p.name,
385
+ slug: p.slug,
386
+ path: p.rootPath,
387
+ sessions: p.sessionCount,
388
+ }));
389
+ sendTo(ws, { type: "projects", projects: list });
390
+ }
391
+ catch (err) {
392
+ log(`[PAILot] projects list failed: ${err}`);
393
+ sendTo(ws, { type: "projects", projects: [] });
394
+ }
395
+ }
396
+ async function handleNavCommand(ws, args) {
397
+ const key = args.key;
398
+ if (!key)
399
+ return;
400
+ // Guard: nav commands only work with visual sessions
401
+ if (hybridManager?.activeSession?.kind === "api") {
402
+ sendTo(ws, { type: "error", message: "Keyboard commands need a visual session." });
403
+ return;
404
+ }
405
+ const targetSession = activeItermSessionId;
406
+ if (!targetSession) {
407
+ sendTo(ws, { type: "error", message: "No active session" });
408
+ return;
409
+ }
410
+ // Map key names to actions
411
+ // sendKeystrokeToSession takes ASCII code: 13=enter, 9=tab, 27=escape
412
+ // sendEscapeSequenceToSession takes ANSI direction char: A=up, B=down, C=right, D=left
413
+ const keyMap = {
414
+ up: () => sendEscapeSequenceToSession(targetSession, "A"),
415
+ down: () => sendEscapeSequenceToSession(targetSession, "B"),
416
+ left: () => sendEscapeSequenceToSession(targetSession, "D"),
417
+ right: () => sendEscapeSequenceToSession(targetSession, "C"),
418
+ enter: () => sendKeystrokeToSession(targetSession, 13),
419
+ tab: () => sendKeystrokeToSession(targetSession, 9),
420
+ escape: () => sendKeystrokeToSession(targetSession, 27),
421
+ "ctrl-c": () => {
422
+ // Send Ctrl+C (ETX, ASCII 3)
423
+ runAppleScript(`tell application "iTerm2"
424
+ repeat with w in windows
425
+ repeat with t in tabs of w
426
+ repeat with s in sessions of t
427
+ if id of s is "${targetSession}" then
428
+ tell s to write text (ASCII character 3)
429
+ return
430
+ end if
431
+ end repeat
432
+ end repeat
433
+ end repeat
434
+ end tell`);
435
+ },
436
+ };
437
+ const action = keyMap[key];
438
+ if (action) {
439
+ action();
440
+ }
441
+ else {
442
+ // Fallback: send as literal text (vi keys like "dd", "0", "G", etc.)
443
+ pasteTextIntoSession(targetSession, key);
444
+ }
445
+ log(`[PAILot] nav: sent ${key} to session ${targetSession.slice(0, 8)}...`);
446
+ // Auto-screenshot after navigation key with a brief delay for render
447
+ if (screenshotHandler) {
448
+ await new Promise((r) => setTimeout(r, 600));
449
+ await triggerScreenshotForPailot();
450
+ }
451
+ }
452
+ async function triggerScreenshotForPailot() {
453
+ if (!screenshotHandler)
454
+ return;
455
+ // Only send to PAILot — this is triggered by PAILot commands
456
+ await screenshotHandler("pailot");
457
+ }
458
+ // --- Helpers ---
459
+ function sendTo(ws, msg) {
460
+ if (ws.readyState === WebSocket.OPEN) {
461
+ ws.send(JSON.stringify(msg));
462
+ }
463
+ }
464
+ function broadcast(msg) {
465
+ // Check if any client is actually ready to receive
466
+ let delivered = false;
467
+ const payload = JSON.stringify(msg);
468
+ for (const ws of clients) {
469
+ if (ws.readyState === WebSocket.OPEN) {
470
+ ws.send(payload);
471
+ delivered = true;
472
+ }
473
+ }
474
+ // No connected clients — buffer for later
475
+ if (!delivered) {
476
+ addToOutbox(msg);
477
+ }
478
+ }
479
+ // --- Voice message batching ---
480
+ // When multiple voice messages arrive within BATCH_WINDOW_MS, we combine their
481
+ // transcripts into a single onMessage call so Claude sees them as one input.
482
+ const BATCH_WINDOW_MS = 3000;
483
+ let voiceBatchTimer = null;
484
+ let voiceBatchTranscripts = [];
485
+ let voiceBatchOnMessage = null;
486
+ function flushVoiceBatch() {
487
+ if (voiceBatchTranscripts.length === 0)
488
+ return;
489
+ const combined = voiceBatchTranscripts.join(" ");
490
+ const handler = voiceBatchOnMessage;
491
+ voiceBatchTranscripts = [];
492
+ voiceBatchOnMessage = null;
493
+ voiceBatchTimer = null;
494
+ if (handler) {
495
+ log(`[PAILot] Flushing voice batch (${combined.length} chars)`);
496
+ setMessageSource("pailot");
497
+ handler(`[PAILot:voice] ${combined}`, Date.now());
498
+ setMessageSource("whatsapp");
499
+ }
500
+ }
501
+ // --- Voice transcription for PAILot ---
502
+ const execFileAsync = promisify(execFile);
503
+ async function transcribeAndRoute(audioBase64, onMessage, messageId) {
504
+ const base = `pailot-voice-${Date.now()}-${randomUUID().slice(0, 8)}`;
505
+ const audioFile = join(tmpdir(), `${base}.m4a`);
506
+ const filesToClean = [
507
+ audioFile,
508
+ join(tmpdir(), `${base}.txt`),
509
+ join(tmpdir(), `${base}.json`),
510
+ join(tmpdir(), `${base}.vtt`),
511
+ join(tmpdir(), `${base}.srt`),
512
+ join(tmpdir(), `${base}.tsv`),
513
+ ];
514
+ try {
515
+ dbg(`transcribeAndRoute: base64 length=${audioBase64.length}`);
516
+ const buffer = Buffer.from(audioBase64, "base64");
517
+ writeFileSync(audioFile, buffer);
518
+ dbg(`Audio saved: ${audioFile} (${buffer.length} bytes)`);
519
+ log(`[PAILot] Voice note saved (${buffer.length} bytes), running Whisper...`);
520
+ await execFileAsync(WHISPER_BIN, [audioFile, "--model", WHISPER_MODEL, "--output_format", "txt", "--output_dir", tmpdir(), "--verbose", "False"], {
521
+ timeout: 120_000,
522
+ env: {
523
+ ...process.env,
524
+ PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || "/usr/bin:/bin"}`,
525
+ },
526
+ });
527
+ const txtPath = join(tmpdir(), `${base}.txt`);
528
+ if (!existsSync(txtPath)) {
529
+ log(`[PAILot] Whisper did not produce output`);
530
+ return;
531
+ }
532
+ const transcript = readFileSync(txtPath, "utf-8").trim();
533
+ if (!transcript) {
534
+ log(`[PAILot] Empty transcript`);
535
+ return;
536
+ }
537
+ log(`[PAILot] Transcription: ${transcript.slice(0, 80)}${transcript.length > 80 ? "..." : ""}`);
538
+ // Reflect transcript back to the app so the voice bubble shows text
539
+ if (messageId) {
540
+ broadcast({ type: "transcript", messageId, content: transcript });
541
+ }
542
+ // Batch: accumulate transcripts and reset the timer
543
+ voiceBatchTranscripts.push(transcript);
544
+ voiceBatchOnMessage = onMessage;
545
+ if (voiceBatchTimer)
546
+ clearTimeout(voiceBatchTimer);
547
+ voiceBatchTimer = setTimeout(flushVoiceBatch, BATCH_WINDOW_MS);
548
+ }
549
+ catch (err) {
550
+ log(`[PAILot] Whisper transcription failed: ${err}`);
551
+ }
552
+ finally {
553
+ for (const f of filesToClean) {
554
+ try {
555
+ unlinkSync(f);
556
+ }
557
+ catch { /* ignore */ }
558
+ }
559
+ }
560
+ }
561
+ // --- Public API ---
562
+ /**
563
+ * Start the WebSocket gateway.
564
+ * @param onMessage — the same handleMessage(text, timestamp) used by the transport
565
+ */
566
+ export function startWsGateway(onMessage) {
567
+ wss = new WebSocketServer({ port: WS_PORT });
568
+ wss.on("listening", () => {
569
+ log(`WebSocket gateway listening on ws://0.0.0.0:${WS_PORT}`);
570
+ // Pre-populate hybrid manager with live iTerm sessions so messages
571
+ // can be tagged with sessionId even before a PAILot client connects.
572
+ if (hybridManager) {
573
+ try {
574
+ const liveSnapshots = snapshotAllSessions();
575
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
576
+ const seenTabs = new Set();
577
+ for (const snap of liveSnapshots) {
578
+ if (!isClaudeRelated(snap))
579
+ continue;
580
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
581
+ if (seenTabs.has(displayName))
582
+ continue;
583
+ seenTabs.add(displayName);
584
+ if (!knownIds.has(snap.id)) {
585
+ hybridManager.registerVisualSession(displayName, "", snap.id);
586
+ }
587
+ }
588
+ // Also set the active session based on current iTerm focus
589
+ const focusedId = runAppleScript(`tell application "iTerm2"
590
+ try
591
+ return id of current session of current tab of current window
592
+ on error
593
+ return ""
594
+ end try
595
+ end tell`)?.trim() ?? "";
596
+ if (focusedId) {
597
+ const sessions = hybridManager.listSessions();
598
+ const idx = sessions.findIndex(s => s.backendSessionId === focusedId);
599
+ if (idx >= 0) {
600
+ hybridManager.switchToIndex(idx + 1);
601
+ setActiveItermSessionId(focusedId);
602
+ }
603
+ }
604
+ log(`Pre-registered ${hybridManager.listSessions().length} session(s) from live iTerm`);
605
+ }
606
+ catch (err) {
607
+ log(`Failed to pre-register sessions: ${err}`);
608
+ }
609
+ }
610
+ });
611
+ wss.on("connection", (ws, req) => {
612
+ const addr = req.socket.remoteAddress ?? "unknown";
613
+ log(`PAILot client connected from ${addr}`);
614
+ clients.add(ws);
615
+ ws.on("message", (raw) => {
616
+ try {
617
+ const rawStr = raw.toString();
618
+ const msg = JSON.parse(rawStr);
619
+ 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
621
+ if (msg.type === "ping") {
622
+ sendTo(ws, { type: "pong" });
623
+ return;
624
+ }
625
+ // Structured commands from PAILot app
626
+ if (msg.type === "command") {
627
+ const command = msg.command;
628
+ const args = (msg.args ?? {});
629
+ log(`[PAILot] ← command: ${command}`);
630
+ switch (command) {
631
+ case "sessions":
632
+ handleSessionsCommand(ws);
633
+ return;
634
+ case "sync":
635
+ handleSyncCommand(ws, args);
636
+ return;
637
+ case "switch":
638
+ handleSwitchCommand(ws, args);
639
+ return;
640
+ case "rename":
641
+ handleRenameCommand(ws, args);
642
+ return;
643
+ case "remove":
644
+ handleRemoveCommand(ws, args);
645
+ return;
646
+ case "create":
647
+ handleCreateCommand(ws, args);
648
+ return;
649
+ case "projects":
650
+ handleProjectsCommand(ws);
651
+ return;
652
+ case "screenshot":
653
+ // For API sessions, send text status instead of screenshot
654
+ if (hybridManager?.activeSession?.kind === "api") {
655
+ const status = hybridManager.formatActiveStatus();
656
+ if (status) {
657
+ sendTo(ws, { type: "text", content: status });
658
+ }
659
+ }
660
+ else {
661
+ triggerScreenshotForPailot().catch((err) => {
662
+ log(`[PAILot] screenshot error: ${err}`);
663
+ });
664
+ }
665
+ return;
666
+ case "nav":
667
+ handleNavCommand(ws, args).catch((err) => {
668
+ log(`[PAILot] nav error: ${err}`);
669
+ });
670
+ return;
671
+ default:
672
+ break;
673
+ }
674
+ }
675
+ // Voice message — transcribe with Whisper then route
676
+ if (msg.type === "voice" && msg.audioBase64) {
677
+ dbg(`Voice message received, audioBase64 length: ${msg.audioBase64.length}`);
678
+ broadcast({ type: "typing", typing: true });
679
+ const voiceMsgId = typeof msg.messageId === "string" ? msg.messageId : undefined;
680
+ transcribeAndRoute(msg.audioBase64, onMessage, voiceMsgId).catch((err) => {
681
+ log(`[PAILot] voice transcription error: ${err}`);
682
+ });
683
+ return;
684
+ }
685
+ // Image message — save to temp file, route caption as text
686
+ // NOTE: Do NOT send the file path to Claude Code — it tries to read .jpg files
687
+ // as images, which corrupts the conversation context with unprocessable image data.
688
+ if (msg.type === "image" && msg.imageBase64) {
689
+ const ext = (msg.mimeType ?? "image/jpeg").includes("png") ? "png" : "jpg";
690
+ const imgPath = join(tmpdir(), `pailot-img-${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
691
+ const imgBuf = Buffer.from(msg.imageBase64, "base64");
692
+ writeFileSync(imgPath, imgBuf);
693
+ log(`[PAILot] Image saved (${imgBuf.length} bytes) → ${imgPath}`);
694
+ const caption = msg.caption || "";
695
+ // Embed the path inside parentheses so Claude Code doesn't auto-attach
696
+ // the .jpg as an image (which corrupts the session if the API rejects it).
697
+ // Claude can still use the Read tool to view it.
698
+ const routeText = caption
699
+ ? `${caption} (image at ${imgPath})`
700
+ : `(image at ${imgPath})`;
701
+ setMessageSource("pailot");
702
+ onMessage(routeText, Date.now());
703
+ setMessageSource("whatsapp");
704
+ return;
705
+ }
706
+ // Plain text message — route through handleMessage
707
+ const text = msg.content ?? "";
708
+ if (!text.trim())
709
+ return;
710
+ 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");
715
+ }
716
+ catch {
717
+ log(`[PAILot] Invalid message from ${addr}`);
718
+ }
719
+ });
720
+ ws.on("close", () => {
721
+ log(`PAILot client disconnected from ${addr}`);
722
+ clients.delete(ws);
723
+ });
724
+ ws.on("error", (err) => {
725
+ log(`[PAILot] WebSocket error: ${err.message}`);
726
+ clients.delete(ws);
727
+ });
728
+ // Welcome — outbox drains after client sends "sync" command
729
+ // (so activeSessionId is set before messages arrive)
730
+ sendTo(ws, { type: "text", content: "Connected to PAILot gateway." });
731
+ });
732
+ wss.on("error", (err) => {
733
+ log(`WebSocket gateway error: ${err.message}`);
734
+ });
735
+ }
736
+ /**
737
+ * Broadcast a text message to all connected PAILot clients.
738
+ * @param sessionId — iTerm session ID of the originating Claude session
739
+ */
740
+ function resolveSessionId(sessionId) {
741
+ if (sessionId)
742
+ return sessionId;
743
+ if (activeItermSessionId)
744
+ return activeItermSessionId;
745
+ // Last resort: ask hybrid manager for the active session's backend ID
746
+ return hybridManager?.activeSession?.backendSessionId || undefined;
747
+ }
748
+ export function broadcastText(text, sessionId) {
749
+ const resolvedSession = resolveSessionId(sessionId);
750
+ broadcast({ type: "typing", typing: false });
751
+ broadcast({ type: "text", content: text, ...(resolvedSession && { sessionId: resolvedSession }) });
752
+ }
753
+ /**
754
+ * Broadcast a voice note to all connected PAILot clients.
755
+ * Converts OGG Opus to M4A (AAC) since iOS can't play OGG natively.
756
+ * @param sessionId — iTerm session ID of the originating Claude session
757
+ */
758
+ export async function broadcastVoice(audioBuffer, transcript, sessionId) {
759
+ const resolvedSession = resolveSessionId(sessionId);
760
+ broadcast({ type: "typing", typing: false });
761
+ let sendBuffer = audioBuffer;
762
+ // Convert OGG Opus → M4A for iOS compatibility
763
+ try {
764
+ const uid = randomUUID().slice(0, 8);
765
+ const oggPath = join(tmpdir(), `pailot-conv-${uid}.ogg`);
766
+ const m4aPath = join(tmpdir(), `pailot-conv-${uid}.m4a`);
767
+ writeFileSync(oggPath, audioBuffer);
768
+ await execFileAsync("/opt/homebrew/bin/ffmpeg", [
769
+ "-y", "-i", oggPath, "-c:a", "aac", "-b:a", "128k", m4aPath,
770
+ ]);
771
+ if (existsSync(m4aPath)) {
772
+ sendBuffer = readFileSync(m4aPath);
773
+ try {
774
+ unlinkSync(oggPath);
775
+ unlinkSync(m4aPath);
776
+ }
777
+ catch { /* ignore */ }
778
+ }
779
+ }
780
+ catch (err) {
781
+ log(`[PAILot] OGG→M4A conversion failed, sending raw: ${err}`);
782
+ }
783
+ broadcast({
784
+ type: "voice",
785
+ content: transcript,
786
+ audioBase64: sendBuffer.toString("base64"),
787
+ ...(resolvedSession && { sessionId: resolvedSession }),
788
+ });
789
+ }
790
+ /**
791
+ * Broadcast a screenshot/image to all connected PAILot clients.
792
+ * @param sessionId — iTerm session ID of the originating Claude session
793
+ */
794
+ export function broadcastImage(imageBuffer, caption, sessionId) {
795
+ const resolvedSession = resolveSessionId(sessionId);
796
+ broadcast({
797
+ type: "image",
798
+ imageBase64: imageBuffer.toString("base64"),
799
+ caption: caption ?? "Screenshot",
800
+ ...(resolvedSession && { sessionId: resolvedSession }),
801
+ });
802
+ }
803
+ /**
804
+ * Broadcast a status change to all connected PAILot clients.
805
+ * Used to signal compaction, reconnection, etc.
806
+ */
807
+ export function broadcastStatus(status) {
808
+ broadcast({ type: "status", status });
809
+ }
810
+ /**
811
+ * Returns true if any PAILot clients are connected.
812
+ */
813
+ export function hasPailotClients() {
814
+ return clients.size > 0;
815
+ }
816
+ /**
817
+ * Stop the WebSocket gateway.
818
+ */
819
+ export function stopWsGateway() {
820
+ if (wss) {
821
+ for (const ws of clients)
822
+ ws.close();
823
+ clients.clear();
824
+ wss.close();
825
+ wss = null;
826
+ }
827
+ }
828
+ //# sourceMappingURL=gateway.js.map