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,614 @@
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 { runAppleScript, sendKeystrokeToSession, sendEscapeSequenceToSession, pasteTextIntoSession, snapshotAllSessions } from "../iterm/core.js";
30
+ import { hybridManager } from "../../core/hybrid.js";
31
+ const WS_PORT = parseInt(process.env.PAILOT_PORT ?? "8765", 10);
32
+ // --- State ---
33
+ let wss = null;
34
+ const clients = new Set();
35
+ // Reference to the screenshot handler — set via setScreenshotHandler()
36
+ // to avoid circular imports (screenshot.ts imports from state.ts which
37
+ // would create a cycle if we imported it here directly).
38
+ let screenshotHandler = null;
39
+ /**
40
+ * Provide the screenshot handler so ws-gateway can trigger screenshots
41
+ * for navigation commands without a circular import.
42
+ */
43
+ export function setScreenshotHandler(handler) {
44
+ screenshotHandler = handler;
45
+ }
46
+ // --- Structured command handling ---
47
+ /**
48
+ * Filter: include only Claude-related sessions.
49
+ * A session qualifies if it has paiName, name contains "claude",
50
+ * or is not at shell prompt (has a process running — likely Claude).
51
+ */
52
+ function isClaudeRelated(snap) {
53
+ if (snap.paiName)
54
+ return true;
55
+ const name = (snap.tabTitle ?? snap.name).toLowerCase();
56
+ if (name.includes("claude"))
57
+ return true;
58
+ if (!snap.atPrompt)
59
+ return true;
60
+ return false;
61
+ }
62
+ /** Detect which iTerm2 session is currently focused and sync the hybrid manager to it. */
63
+ function handleSyncCommand(ws) {
64
+ if (!hybridManager) {
65
+ handleSessionsCommand(ws);
66
+ return;
67
+ }
68
+ // Auto-discover Claude-related iTerm2 tabs so freshly-started daemons can match
69
+ const liveSnapshots = snapshotAllSessions();
70
+ const liveIds = new Set(liveSnapshots.map(s => s.id));
71
+ hybridManager.pruneDeadVisualSessions(liveIds);
72
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
73
+ const seenTabs = new Set();
74
+ for (const snap of liveSnapshots) {
75
+ if (!isClaudeRelated(snap))
76
+ continue;
77
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
78
+ if (seenTabs.has(displayName))
79
+ continue;
80
+ seenTabs.add(displayName);
81
+ if (!knownIds.has(snap.id)) {
82
+ hybridManager.registerVisualSession(displayName, "", snap.id);
83
+ }
84
+ }
85
+ // Ask iTerm2 which session is focused right now
86
+ const focusedId = runAppleScript(`tell application "iTerm2"
87
+ try
88
+ return id of current session of current tab of current window
89
+ on error
90
+ return ""
91
+ end try
92
+ end tell`)?.trim() ?? "";
93
+ if (focusedId) {
94
+ // Find this session in the hybrid manager and activate it
95
+ const sessions = hybridManager.listSessions();
96
+ const idx = sessions.findIndex(s => s.backendSessionId === focusedId);
97
+ if (idx >= 0) {
98
+ hybridManager.switchToIndex(idx + 1);
99
+ setActiveItermSessionId(focusedId);
100
+ log(`[PAILot] sync: activated focused session "${sessions[idx].name}" (${focusedId.slice(0, 8)}...)`);
101
+ }
102
+ else {
103
+ log(`[PAILot] sync: focused session ${focusedId.slice(0, 8)}... not registered`);
104
+ }
105
+ }
106
+ // Return sessions with updated active state
107
+ handleSessionsCommand(ws);
108
+ }
109
+ function handleSessionsCommand(ws) {
110
+ if (!hybridManager) {
111
+ sendTo(ws, { type: "sessions", sessions: [] });
112
+ return;
113
+ }
114
+ // Prune visual sessions whose iTerm2 tabs have been closed
115
+ const liveSnapshots = snapshotAllSessions();
116
+ const liveIds = new Set(liveSnapshots.map(s => s.id));
117
+ hybridManager.pruneDeadVisualSessions(liveIds);
118
+ // Auto-discover Claude-related iTerm2 tabs not yet in the hybrid manager,
119
+ // and sync names of existing sessions from live iTerm state.
120
+ // Deduplicate by tab title — only register first session per tab.
121
+ const knownIds = new Set(hybridManager.listSessions().map(s => s.backendSessionId));
122
+ const seenTabs = new Set();
123
+ for (const snap of liveSnapshots) {
124
+ if (!isClaudeRelated(snap))
125
+ continue;
126
+ const displayName = snap.tabTitle ?? snap.paiName ?? snap.name;
127
+ if (seenTabs.has(displayName))
128
+ continue; // skip split panes in same tab
129
+ seenTabs.add(displayName);
130
+ if (!knownIds.has(snap.id)) {
131
+ hybridManager.registerVisualSession(displayName, "", snap.id);
132
+ }
133
+ else {
134
+ // Sync name from iTerm (handles double-click renames)
135
+ hybridManager.updateName(snap.id, displayName);
136
+ }
137
+ }
138
+ const hybridSessions = hybridManager.listSessions();
139
+ const active = hybridManager.activeSession;
140
+ const sessions = hybridSessions.map((s, i) => ({
141
+ index: i + 1,
142
+ name: s.name,
143
+ type: "claude",
144
+ kind: s.kind,
145
+ isActive: active ? s.id === active.id : false,
146
+ id: s.backendSessionId,
147
+ }));
148
+ // Sort: active session first, then preserve registration order
149
+ sessions.sort((a, b) => (a.isActive === b.isActive ? 0 : a.isActive ? -1 : 1));
150
+ const payload = JSON.stringify({ type: "sessions", sessions });
151
+ if (ws.readyState === WebSocket.OPEN) {
152
+ ws.send(payload);
153
+ }
154
+ }
155
+ function handleSwitchCommand(ws, args) {
156
+ const sessionIndex = args.index;
157
+ const sessionId = args.sessionId;
158
+ const newName = args.name;
159
+ if (!hybridManager) {
160
+ sendTo(ws, { type: "error", message: "No session manager" });
161
+ return;
162
+ }
163
+ // Resolve which session to switch to (prefer index, fall back to sessionId lookup)
164
+ let targetIndex;
165
+ if (sessionIndex) {
166
+ targetIndex = sessionIndex;
167
+ }
168
+ else if (sessionId) {
169
+ const sessions = hybridManager.listSessions();
170
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
171
+ if (idx >= 0)
172
+ targetIndex = idx + 1;
173
+ }
174
+ if (!targetIndex) {
175
+ sendTo(ws, { type: "error", message: "Missing session index or ID" });
176
+ return;
177
+ }
178
+ const session = hybridManager.switchToIndex(targetIndex);
179
+ if (!session) {
180
+ sendTo(ws, { type: "error", message: "Session not found — it may have closed." });
181
+ return;
182
+ }
183
+ // For visual sessions, also focus the iTerm2 tab
184
+ if (session.kind === "visual") {
185
+ setActiveItermSessionId(session.backendSessionId);
186
+ const escapedId = session.backendSessionId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
187
+ runAppleScript(`tell application "iTerm2"
188
+ repeat with aWindow in windows
189
+ repeat with aTab in tabs of aWindow
190
+ repeat with aSession in sessions of aTab
191
+ if id of aSession is "${escapedId}" then
192
+ select aSession
193
+ return "focused"
194
+ end if
195
+ end repeat
196
+ end repeat
197
+ end repeat
198
+ end tell`);
199
+ }
200
+ if (newName) {
201
+ session.name = newName;
202
+ if (session.kind === "visual") {
203
+ setItermSessionVar(session.backendSessionId, newName);
204
+ setItermTabName(session.backendSessionId, newName);
205
+ }
206
+ }
207
+ sendTo(ws, { type: "session_switched", name: session.name, sessionId: session.backendSessionId });
208
+ log(`[PAILot] switched to ${session.kind} session "${session.name}" (${session.id})`);
209
+ }
210
+ function handleRenameCommand(ws, args) {
211
+ const sessionId = args.sessionId;
212
+ const name = args.name;
213
+ if (!sessionId || !name || !hybridManager) {
214
+ sendTo(ws, { type: "error", message: "Missing sessionId or name" });
215
+ return;
216
+ }
217
+ // Find the hybrid session by backendSessionId
218
+ const sessions = hybridManager.listSessions();
219
+ const session = sessions.find(s => s.backendSessionId === sessionId);
220
+ if (session) {
221
+ session.name = name;
222
+ if (session.kind === "visual") {
223
+ setItermSessionVar(sessionId, name);
224
+ setItermTabName(sessionId, name);
225
+ }
226
+ }
227
+ sendTo(ws, { type: "session_renamed", sessionId, name });
228
+ // Send updated sessions list so PAILot refreshes the header
229
+ handleSessionsCommand(ws);
230
+ log(`[PAILot] renamed session ${sessionId} to "${name}"`);
231
+ }
232
+ function handleRemoveCommand(ws, args) {
233
+ const sessionId = args.sessionId;
234
+ if (!sessionId || !hybridManager) {
235
+ sendTo(ws, { type: "error", message: "Missing sessionId" });
236
+ return;
237
+ }
238
+ // Find session by backendSessionId
239
+ const sessions = hybridManager.listSessions();
240
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
241
+ if (idx < 0) {
242
+ sendTo(ws, { type: "error", message: "Session not found" });
243
+ return;
244
+ }
245
+ const target = sessions[idx];
246
+ // Kill the iTerm2 session if it's visual
247
+ if (target.kind === "visual" && target.backendSessionId) {
248
+ killSession(target.backendSessionId);
249
+ }
250
+ const removed = hybridManager.removeByIndex(idx + 1);
251
+ if (removed) {
252
+ log(`[PAILot] removed ${removed.kind} session "${removed.name}" (${removed.id})`);
253
+ }
254
+ // Send updated session list
255
+ handleSessionsCommand(ws);
256
+ }
257
+ function handleCreateCommand(ws) {
258
+ const sessionId = createClaudeSession();
259
+ if (!sessionId) {
260
+ sendTo(ws, { type: "error", message: "Failed to create new session" });
261
+ return;
262
+ }
263
+ // Tag it with paiName so it shows up in filtering
264
+ setItermSessionVar(sessionId, "Claude");
265
+ setItermTabName(sessionId, "Claude");
266
+ // Register in hybrid manager
267
+ if (hybridManager) {
268
+ hybridManager.registerVisualSession("Claude", "", sessionId);
269
+ // Switch to the new session
270
+ const sessions = hybridManager.listSessions();
271
+ const idx = sessions.findIndex(s => s.backendSessionId === sessionId);
272
+ if (idx >= 0) {
273
+ hybridManager.switchToIndex(idx + 1);
274
+ setActiveItermSessionId(sessionId);
275
+ }
276
+ }
277
+ log(`[PAILot] created new Claude session (${sessionId.slice(0, 8)}...)`);
278
+ sendTo(ws, { type: "session_switched", name: "Claude", sessionId });
279
+ handleSessionsCommand(ws);
280
+ }
281
+ async function handleNavCommand(ws, args) {
282
+ const key = args.key;
283
+ if (!key)
284
+ return;
285
+ // Guard: nav commands only work with visual sessions
286
+ if (hybridManager?.activeSession?.kind === "api") {
287
+ sendTo(ws, { type: "error", message: "Keyboard commands need a visual session." });
288
+ return;
289
+ }
290
+ const targetSession = activeItermSessionId;
291
+ if (!targetSession) {
292
+ sendTo(ws, { type: "error", message: "No active session" });
293
+ return;
294
+ }
295
+ // Map key names to actions
296
+ // sendKeystrokeToSession takes ASCII code: 13=enter, 9=tab, 27=escape
297
+ // sendEscapeSequenceToSession takes ANSI direction char: A=up, B=down, C=right, D=left
298
+ const keyMap = {
299
+ up: () => sendEscapeSequenceToSession(targetSession, "A"),
300
+ down: () => sendEscapeSequenceToSession(targetSession, "B"),
301
+ left: () => sendEscapeSequenceToSession(targetSession, "D"),
302
+ right: () => sendEscapeSequenceToSession(targetSession, "C"),
303
+ enter: () => sendKeystrokeToSession(targetSession, 13),
304
+ tab: () => sendKeystrokeToSession(targetSession, 9),
305
+ escape: () => sendKeystrokeToSession(targetSession, 27),
306
+ "ctrl-c": () => {
307
+ // Send Ctrl+C (ETX, ASCII 3)
308
+ runAppleScript(`tell application "iTerm2"
309
+ repeat with w in windows
310
+ repeat with t in tabs of w
311
+ repeat with s in sessions of t
312
+ if id of s is "${targetSession}" then
313
+ tell s to write text (ASCII character 3)
314
+ return
315
+ end if
316
+ end repeat
317
+ end repeat
318
+ end repeat
319
+ end tell`);
320
+ },
321
+ };
322
+ const action = keyMap[key];
323
+ if (action) {
324
+ action();
325
+ }
326
+ else {
327
+ // Fallback: send as literal text (vi keys like "dd", "0", "G", etc.)
328
+ pasteTextIntoSession(targetSession, key);
329
+ }
330
+ log(`[PAILot] nav: sent ${key} to session ${targetSession.slice(0, 8)}...`);
331
+ // Auto-screenshot after navigation key with a brief delay for render
332
+ if (screenshotHandler) {
333
+ await new Promise((r) => setTimeout(r, 600));
334
+ await triggerScreenshotForPailot();
335
+ }
336
+ }
337
+ async function triggerScreenshotForPailot() {
338
+ if (!screenshotHandler)
339
+ return;
340
+ // Only send to PAILot — this is triggered by PAILot commands
341
+ await screenshotHandler("pailot");
342
+ }
343
+ // --- Helpers ---
344
+ function sendTo(ws, msg) {
345
+ if (ws.readyState === WebSocket.OPEN) {
346
+ ws.send(JSON.stringify(msg));
347
+ }
348
+ }
349
+ function broadcast(msg) {
350
+ if (clients.size === 0)
351
+ return;
352
+ const payload = JSON.stringify(msg);
353
+ for (const ws of clients) {
354
+ if (ws.readyState === WebSocket.OPEN) {
355
+ ws.send(payload);
356
+ }
357
+ }
358
+ }
359
+ // --- Voice message batching ---
360
+ // When multiple voice messages arrive within BATCH_WINDOW_MS, we combine their
361
+ // transcripts into a single onMessage call so Claude sees them as one input.
362
+ const BATCH_WINDOW_MS = 3000;
363
+ let voiceBatchTimer = null;
364
+ let voiceBatchTranscripts = [];
365
+ let voiceBatchOnMessage = null;
366
+ function flushVoiceBatch() {
367
+ if (voiceBatchTranscripts.length === 0)
368
+ return;
369
+ const combined = voiceBatchTranscripts.join(" ");
370
+ const handler = voiceBatchOnMessage;
371
+ voiceBatchTranscripts = [];
372
+ voiceBatchOnMessage = null;
373
+ voiceBatchTimer = null;
374
+ if (handler) {
375
+ log(`[PAILot] Flushing voice batch (${combined.length} chars)`);
376
+ setMessageSource("pailot");
377
+ handler(`[PAILot:voice] ${combined}`, Date.now());
378
+ setMessageSource("whatsapp");
379
+ }
380
+ }
381
+ // --- Voice transcription for PAILot ---
382
+ const execFileAsync = promisify(execFile);
383
+ async function transcribeAndRoute(audioBase64, onMessage) {
384
+ const base = `pailot-voice-${Date.now()}-${randomUUID().slice(0, 8)}`;
385
+ const audioFile = join(tmpdir(), `${base}.m4a`);
386
+ const filesToClean = [
387
+ audioFile,
388
+ join(tmpdir(), `${base}.txt`),
389
+ join(tmpdir(), `${base}.json`),
390
+ join(tmpdir(), `${base}.vtt`),
391
+ join(tmpdir(), `${base}.srt`),
392
+ join(tmpdir(), `${base}.tsv`),
393
+ ];
394
+ try {
395
+ dbg(`transcribeAndRoute: base64 length=${audioBase64.length}`);
396
+ const buffer = Buffer.from(audioBase64, "base64");
397
+ writeFileSync(audioFile, buffer);
398
+ dbg(`Audio saved: ${audioFile} (${buffer.length} bytes)`);
399
+ log(`[PAILot] Voice note saved (${buffer.length} bytes), running Whisper...`);
400
+ await execFileAsync(WHISPER_BIN, [audioFile, "--model", WHISPER_MODEL, "--output_format", "txt", "--output_dir", tmpdir(), "--verbose", "False"], {
401
+ timeout: 120_000,
402
+ env: {
403
+ ...process.env,
404
+ PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || "/usr/bin:/bin"}`,
405
+ },
406
+ });
407
+ const txtPath = join(tmpdir(), `${base}.txt`);
408
+ if (!existsSync(txtPath)) {
409
+ log(`[PAILot] Whisper did not produce output`);
410
+ return;
411
+ }
412
+ const transcript = readFileSync(txtPath, "utf-8").trim();
413
+ if (!transcript) {
414
+ log(`[PAILot] Empty transcript`);
415
+ return;
416
+ }
417
+ log(`[PAILot] Transcription: ${transcript.slice(0, 80)}${transcript.length > 80 ? "..." : ""}`);
418
+ // Batch: accumulate transcripts and reset the timer
419
+ voiceBatchTranscripts.push(transcript);
420
+ voiceBatchOnMessage = onMessage;
421
+ if (voiceBatchTimer)
422
+ clearTimeout(voiceBatchTimer);
423
+ voiceBatchTimer = setTimeout(flushVoiceBatch, BATCH_WINDOW_MS);
424
+ }
425
+ catch (err) {
426
+ log(`[PAILot] Whisper transcription failed: ${err}`);
427
+ }
428
+ finally {
429
+ for (const f of filesToClean) {
430
+ try {
431
+ unlinkSync(f);
432
+ }
433
+ catch { /* ignore */ }
434
+ }
435
+ }
436
+ }
437
+ // --- Public API ---
438
+ /**
439
+ * Start the WebSocket gateway.
440
+ * @param onMessage — the same handleMessage(text, timestamp) used by the transport
441
+ */
442
+ export function startWsGateway(onMessage) {
443
+ wss = new WebSocketServer({ port: WS_PORT });
444
+ wss.on("listening", () => {
445
+ log(`WebSocket gateway listening on ws://0.0.0.0:${WS_PORT}`);
446
+ });
447
+ wss.on("connection", (ws, req) => {
448
+ const addr = req.socket.remoteAddress ?? "unknown";
449
+ log(`PAILot client connected from ${addr}`);
450
+ clients.add(ws);
451
+ ws.on("message", (raw) => {
452
+ try {
453
+ const rawStr = raw.toString();
454
+ const msg = JSON.parse(rawStr);
455
+ dbg(`RAW msg (${rawStr.length} chars): type=${msg.type}, hasAudio=${!!msg.audioBase64}, content=${(msg.content ?? "").slice(0, 50)}`);
456
+ // Structured commands from PAILot app
457
+ if (msg.type === "command") {
458
+ const command = msg.command;
459
+ const args = (msg.args ?? {});
460
+ log(`[PAILot] ← command: ${command}`);
461
+ switch (command) {
462
+ case "sessions":
463
+ handleSessionsCommand(ws);
464
+ return;
465
+ case "sync":
466
+ handleSyncCommand(ws);
467
+ return;
468
+ case "switch":
469
+ handleSwitchCommand(ws, args);
470
+ return;
471
+ case "rename":
472
+ handleRenameCommand(ws, args);
473
+ return;
474
+ case "remove":
475
+ handleRemoveCommand(ws, args);
476
+ return;
477
+ case "create":
478
+ handleCreateCommand(ws);
479
+ return;
480
+ case "screenshot":
481
+ // For API sessions, send text status instead of screenshot
482
+ if (hybridManager?.activeSession?.kind === "api") {
483
+ const status = hybridManager.formatActiveStatus();
484
+ if (status) {
485
+ sendTo(ws, { type: "text", content: status });
486
+ }
487
+ }
488
+ else {
489
+ triggerScreenshotForPailot().catch((err) => {
490
+ log(`[PAILot] screenshot error: ${err}`);
491
+ });
492
+ }
493
+ return;
494
+ case "nav":
495
+ handleNavCommand(ws, args).catch((err) => {
496
+ log(`[PAILot] nav error: ${err}`);
497
+ });
498
+ return;
499
+ default:
500
+ break;
501
+ }
502
+ }
503
+ // Voice message — transcribe with Whisper then route
504
+ if (msg.type === "voice" && msg.audioBase64) {
505
+ dbg(`Voice message received, audioBase64 length: ${msg.audioBase64.length}`);
506
+ transcribeAndRoute(msg.audioBase64, onMessage).catch((err) => {
507
+ log(`[PAILot] voice transcription error: ${err}`);
508
+ });
509
+ return;
510
+ }
511
+ // Plain text message — route through handleMessage
512
+ const text = msg.content ?? "";
513
+ if (!text.trim())
514
+ return;
515
+ log(`[PAILot] ← ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`);
516
+ // Set source so commands handler uses [PAILot] prefix
517
+ setMessageSource("pailot");
518
+ onMessage(text, Date.now());
519
+ setMessageSource("whatsapp");
520
+ }
521
+ catch {
522
+ log(`[PAILot] Invalid message from ${addr}`);
523
+ }
524
+ });
525
+ ws.on("close", () => {
526
+ log(`PAILot client disconnected from ${addr}`);
527
+ clients.delete(ws);
528
+ });
529
+ ws.on("error", (err) => {
530
+ log(`[PAILot] WebSocket error: ${err.message}`);
531
+ clients.delete(ws);
532
+ });
533
+ // Welcome
534
+ sendTo(ws, { type: "text", content: "Connected to PAILot gateway." });
535
+ });
536
+ wss.on("error", (err) => {
537
+ log(`WebSocket gateway error: ${err.message}`);
538
+ });
539
+ }
540
+ /**
541
+ * Broadcast a text message to all connected PAILot clients.
542
+ */
543
+ export function broadcastText(text) {
544
+ broadcast({ type: "text", content: text });
545
+ }
546
+ /**
547
+ * Broadcast a voice note to all connected PAILot clients.
548
+ * Converts OGG Opus to M4A (AAC) since iOS can't play OGG natively.
549
+ */
550
+ export async function broadcastVoice(audioBuffer, transcript) {
551
+ let sendBuffer = audioBuffer;
552
+ // Convert OGG Opus → M4A for iOS compatibility
553
+ try {
554
+ const uid = randomUUID().slice(0, 8);
555
+ const oggPath = join(tmpdir(), `pailot-conv-${uid}.ogg`);
556
+ const m4aPath = join(tmpdir(), `pailot-conv-${uid}.m4a`);
557
+ writeFileSync(oggPath, audioBuffer);
558
+ await execFileAsync("/opt/homebrew/bin/ffmpeg", [
559
+ "-y", "-i", oggPath, "-c:a", "aac", "-b:a", "128k", m4aPath,
560
+ ]);
561
+ if (existsSync(m4aPath)) {
562
+ sendBuffer = readFileSync(m4aPath);
563
+ try {
564
+ unlinkSync(oggPath);
565
+ unlinkSync(m4aPath);
566
+ }
567
+ catch { /* ignore */ }
568
+ }
569
+ }
570
+ catch (err) {
571
+ log(`[PAILot] OGG→M4A conversion failed, sending raw: ${err}`);
572
+ }
573
+ broadcast({
574
+ type: "voice",
575
+ content: transcript,
576
+ audioBase64: sendBuffer.toString("base64"),
577
+ });
578
+ }
579
+ /**
580
+ * Broadcast a screenshot/image to all connected PAILot clients.
581
+ */
582
+ export function broadcastImage(imageBuffer, caption) {
583
+ broadcast({
584
+ type: "image",
585
+ imageBase64: imageBuffer.toString("base64"),
586
+ caption: caption ?? "Screenshot",
587
+ });
588
+ }
589
+ /**
590
+ * Broadcast a status change to all connected PAILot clients.
591
+ * Used to signal compaction, reconnection, etc.
592
+ */
593
+ export function broadcastStatus(status) {
594
+ broadcast({ type: "status", status });
595
+ }
596
+ /**
597
+ * Returns true if any PAILot clients are connected.
598
+ */
599
+ export function hasPailotClients() {
600
+ return clients.size > 0;
601
+ }
602
+ /**
603
+ * Stop the WebSocket gateway.
604
+ */
605
+ export function stopWsGateway() {
606
+ if (wss) {
607
+ for (const ws of clients)
608
+ ws.close();
609
+ clients.clear();
610
+ wss.close();
611
+ wss = null;
612
+ }
613
+ }
614
+ //# sourceMappingURL=gateway.js.map
@@ -0,0 +1,48 @@
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
+ /**
14
+ * Provide the screenshot handler so ws-gateway can trigger screenshots
15
+ * for navigation commands without a circular import.
16
+ */
17
+ export declare function setScreenshotHandler(handler: (source?: "whatsapp" | "pailot") => Promise<void>): void;
18
+ /**
19
+ * Start the WebSocket gateway.
20
+ * @param onMessage — the same handleMessage(text, timestamp) used by the transport
21
+ */
22
+ export declare function startWsGateway(onMessage: (text: string, timestamp: number) => void | Promise<void>): void;
23
+ export declare function broadcastText(text: string, sessionId?: string): void;
24
+ /**
25
+ * Broadcast a voice note to all connected PAILot clients.
26
+ * Converts OGG Opus to M4A (AAC) since iOS can't play OGG natively.
27
+ * @param sessionId — iTerm session ID of the originating Claude session
28
+ */
29
+ export declare function broadcastVoice(audioBuffer: Buffer, transcript: string, sessionId?: string): Promise<void>;
30
+ /**
31
+ * Broadcast a screenshot/image to all connected PAILot clients.
32
+ * @param sessionId — iTerm session ID of the originating Claude session
33
+ */
34
+ export declare function broadcastImage(imageBuffer: Buffer, caption?: string, sessionId?: string): void;
35
+ /**
36
+ * Broadcast a status change to all connected PAILot clients.
37
+ * Used to signal compaction, reconnection, etc.
38
+ */
39
+ export declare function broadcastStatus(status: string): void;
40
+ /**
41
+ * Returns true if any PAILot clients are connected.
42
+ */
43
+ export declare function hasPailotClients(): boolean;
44
+ /**
45
+ * Stop the WebSocket gateway.
46
+ */
47
+ export declare function stopWsGateway(): void;
48
+ //# sourceMappingURL=gateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../../src/adapters/pailot/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AA+CH;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAErG;AAmbD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAgIzG;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEhD;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB3F;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAM1E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEpD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAOpC"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../../src/adapters/pailot/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AA+CH;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAErG;AAsbD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CA2GzG;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEhD;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB3F;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAM1E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEpD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAOpC"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../../src/adapters/pailot/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAoGH;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAErG;AAugBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAmLzG;AAaD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAIpE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B/G;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAQ9F;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEpD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAOpC"}