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.
- package/README.md +263 -104
- package/dist/adapters/iterm/iterm2-api.d.ts +20 -0
- package/dist/adapters/iterm/iterm2-api.d.ts.map +1 -0
- package/dist/adapters/iterm/iterm2-api.js +244 -0
- package/dist/adapters/iterm/iterm2-api.js.map +1 -0
- package/dist/adapters/iterm/sessions.d.ts +1 -0
- package/dist/adapters/iterm/sessions.d.ts.map +1 -1
- package/dist/adapters/iterm/sessions.js +26 -2
- package/dist/adapters/iterm/sessions.js.map +1 -1
- package/dist/adapters/kokoro/media.js +2 -2
- package/dist/adapters/kokoro/media.js.map +1 -1
- package/dist/adapters/pailot/gateway.d.ts +5 -6
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +575 -34
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/aibp/bridge.d.ts +123 -0
- package/dist/aibp/bridge.d.ts.map +1 -0
- package/dist/aibp/bridge.js +363 -0
- package/dist/aibp/bridge.js.map +1 -0
- package/dist/aibp/envelope.d.ts +26 -0
- package/dist/aibp/envelope.d.ts.map +1 -0
- package/dist/aibp/envelope.js +101 -0
- package/dist/aibp/envelope.js.map +1 -0
- package/dist/aibp/index.d.ts +11 -0
- package/dist/aibp/index.d.ts.map +1 -0
- package/dist/aibp/index.js +10 -0
- package/dist/aibp/index.js.map +1 -0
- package/dist/aibp/registry.d.ts +71 -0
- package/dist/aibp/registry.d.ts.map +1 -0
- package/dist/aibp/registry.js +408 -0
- package/dist/aibp/registry.js.map +1 -0
- package/dist/aibp/types.d.ts +91 -0
- package/dist/aibp/types.d.ts.map +1 -0
- package/dist/aibp/types.js +8 -0
- package/dist/aibp/types.js.map +1 -0
- package/dist/core/hybrid.d.ts +2 -0
- package/dist/core/hybrid.d.ts.map +1 -1
- package/dist/core/hybrid.js +8 -0
- package/dist/core/hybrid.js.map +1 -1
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +34 -0
- package/dist/core/state.js.map +1 -1
- package/dist/core/status-cache.d.ts +51 -0
- package/dist/core/status-cache.d.ts.map +1 -0
- package/dist/core/status-cache.js +62 -0
- package/dist/core/status-cache.js.map +1 -0
- package/dist/daemon/adapter-registry.d.ts +5 -0
- package/dist/daemon/adapter-registry.d.ts.map +1 -1
- package/dist/daemon/adapter-registry.js +94 -4
- package/dist/daemon/adapter-registry.js.map +1 -1
- package/dist/daemon/cli.d.ts +1 -0
- package/dist/daemon/cli.d.ts.map +1 -1
- package/dist/daemon/cli.js +95 -3
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/command-context.d.ts +28 -0
- package/dist/daemon/command-context.d.ts.map +1 -0
- package/dist/daemon/command-context.js +13 -0
- package/dist/daemon/command-context.js.map +1 -0
- package/dist/daemon/commands.d.ts +22 -0
- package/dist/daemon/commands.d.ts.map +1 -0
- package/dist/daemon/commands.js +849 -0
- package/dist/daemon/commands.js.map +1 -0
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +758 -3
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/create-adapter.js +2 -1
- package/dist/daemon/create-adapter.js.map +1 -1
- package/dist/daemon/image-context.d.ts +56 -0
- package/dist/daemon/image-context.d.ts.map +1 -0
- package/dist/daemon/image-context.js +116 -0
- package/dist/daemon/image-context.js.map +1 -0
- package/dist/daemon/image-gen/index.d.ts +22 -0
- package/dist/daemon/image-gen/index.d.ts.map +1 -0
- package/dist/daemon/image-gen/index.js +129 -0
- package/dist/daemon/image-gen/index.js.map +1 -0
- package/dist/daemon/image-gen/providers/cloudflare.d.ts +13 -0
- package/dist/daemon/image-gen/providers/cloudflare.d.ts.map +1 -0
- package/dist/daemon/image-gen/providers/cloudflare.js +63 -0
- package/dist/daemon/image-gen/providers/cloudflare.js.map +1 -0
- package/dist/daemon/image-gen/providers/huggingface.d.ts +12 -0
- package/dist/daemon/image-gen/providers/huggingface.d.ts.map +1 -0
- package/dist/daemon/image-gen/providers/huggingface.js +58 -0
- package/dist/daemon/image-gen/providers/huggingface.js.map +1 -0
- package/dist/daemon/image-gen/providers/pollinations.d.ts +11 -0
- package/dist/daemon/image-gen/providers/pollinations.d.ts.map +1 -0
- package/dist/daemon/image-gen/providers/pollinations.js +39 -0
- package/dist/daemon/image-gen/providers/pollinations.js.map +1 -0
- package/dist/daemon/image-gen/providers/replicate.d.ts +9 -0
- package/dist/daemon/image-gen/providers/replicate.d.ts.map +1 -0
- package/dist/daemon/image-gen/providers/replicate.js +158 -0
- package/dist/daemon/image-gen/providers/replicate.js.map +1 -0
- package/dist/daemon/image-gen/types.d.ts +41 -0
- package/dist/daemon/image-gen/types.d.ts.map +1 -0
- package/dist/daemon/image-gen/types.js +5 -0
- package/dist/daemon/image-gen/types.js.map +1 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +260 -6
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/screenshot.d.ts +12 -0
- package/dist/daemon/screenshot.d.ts.map +1 -0
- package/dist/daemon/screenshot.js +252 -0
- package/dist/daemon/screenshot.js.map +1 -0
- package/dist/daemon/session-content.d.ts +27 -0
- package/dist/daemon/session-content.d.ts.map +1 -0
- package/dist/daemon/session-content.js +76 -0
- package/dist/daemon/session-content.js.map +1 -0
- package/dist/daemon/vision.d.ts +46 -0
- package/dist/daemon/vision.d.ts.map +1 -0
- package/dist/daemon/vision.js +176 -0
- package/dist/daemon/vision.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/ipc/validate.d.ts +52 -0
- package/dist/ipc/validate.d.ts.map +1 -0
- package/dist/ipc/validate.js +129 -0
- package/dist/ipc/validate.js.map +1 -0
- package/dist/mcp/index.d.ts +23 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +787 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/types/broker.d.ts +3 -1
- package/dist/types/broker.d.ts.map +1 -1
- package/dist/types/broker.js.map +1 -1
- package/package.json +5 -2
- package/templates/adapter/ONBOARDING_PROMPT.md +51 -29
- package/templates/adapter/README.md.tmpl +14 -31
- package/templates/adapter/package.json.tmpl +1 -1
- package/templates/adapter/src/watcher/commands.ts.tmpl +24 -126
- package/templates/adapter/src/watcher/index.ts.tmpl +112 -88
- 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
|
-
|
|
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
|
-
/**
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
249
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
/**
|