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