aibroker 0.5.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +263 -104
  2. package/dist/adapters/iterm/iterm2-api.d.ts +20 -0
  3. package/dist/adapters/iterm/iterm2-api.d.ts.map +1 -0
  4. package/dist/adapters/iterm/iterm2-api.js +244 -0
  5. package/dist/adapters/iterm/iterm2-api.js.map +1 -0
  6. package/dist/adapters/iterm/sessions.d.ts +1 -0
  7. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  8. package/dist/adapters/iterm/sessions.js +26 -2
  9. package/dist/adapters/iterm/sessions.js.map +1 -1
  10. package/dist/adapters/kokoro/media.js +2 -2
  11. package/dist/adapters/kokoro/media.js.map +1 -1
  12. package/dist/adapters/pailot/gateway.d.ts +5 -6
  13. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  14. package/dist/adapters/pailot/gateway.js +575 -34
  15. package/dist/adapters/pailot/gateway.js.map +1 -1
  16. package/dist/aibp/bridge.d.ts +123 -0
  17. package/dist/aibp/bridge.d.ts.map +1 -0
  18. package/dist/aibp/bridge.js +363 -0
  19. package/dist/aibp/bridge.js.map +1 -0
  20. package/dist/aibp/envelope.d.ts +26 -0
  21. package/dist/aibp/envelope.d.ts.map +1 -0
  22. package/dist/aibp/envelope.js +101 -0
  23. package/dist/aibp/envelope.js.map +1 -0
  24. package/dist/aibp/index.d.ts +11 -0
  25. package/dist/aibp/index.d.ts.map +1 -0
  26. package/dist/aibp/index.js +10 -0
  27. package/dist/aibp/index.js.map +1 -0
  28. package/dist/aibp/registry.d.ts +71 -0
  29. package/dist/aibp/registry.d.ts.map +1 -0
  30. package/dist/aibp/registry.js +408 -0
  31. package/dist/aibp/registry.js.map +1 -0
  32. package/dist/aibp/types.d.ts +91 -0
  33. package/dist/aibp/types.d.ts.map +1 -0
  34. package/dist/aibp/types.js +8 -0
  35. package/dist/aibp/types.js.map +1 -0
  36. package/dist/core/hybrid.d.ts +2 -0
  37. package/dist/core/hybrid.d.ts.map +1 -1
  38. package/dist/core/hybrid.js +8 -0
  39. package/dist/core/hybrid.js.map +1 -1
  40. package/dist/core/state.d.ts +12 -0
  41. package/dist/core/state.d.ts.map +1 -1
  42. package/dist/core/state.js +34 -0
  43. package/dist/core/state.js.map +1 -1
  44. package/dist/core/status-cache.d.ts +51 -0
  45. package/dist/core/status-cache.d.ts.map +1 -0
  46. package/dist/core/status-cache.js +62 -0
  47. package/dist/core/status-cache.js.map +1 -0
  48. package/dist/daemon/adapter-registry.d.ts +5 -0
  49. package/dist/daemon/adapter-registry.d.ts.map +1 -1
  50. package/dist/daemon/adapter-registry.js +94 -4
  51. package/dist/daemon/adapter-registry.js.map +1 -1
  52. package/dist/daemon/cli.d.ts +1 -0
  53. package/dist/daemon/cli.d.ts.map +1 -1
  54. package/dist/daemon/cli.js +95 -3
  55. package/dist/daemon/cli.js.map +1 -1
  56. package/dist/daemon/command-context.d.ts +28 -0
  57. package/dist/daemon/command-context.d.ts.map +1 -0
  58. package/dist/daemon/command-context.js +13 -0
  59. package/dist/daemon/command-context.js.map +1 -0
  60. package/dist/daemon/commands.d.ts +22 -0
  61. package/dist/daemon/commands.d.ts.map +1 -0
  62. package/dist/daemon/commands.js +849 -0
  63. package/dist/daemon/commands.js.map +1 -0
  64. package/dist/daemon/core-handlers.d.ts.map +1 -1
  65. package/dist/daemon/core-handlers.js +758 -3
  66. package/dist/daemon/core-handlers.js.map +1 -1
  67. package/dist/daemon/create-adapter.js +2 -1
  68. package/dist/daemon/create-adapter.js.map +1 -1
  69. package/dist/daemon/image-context.d.ts +56 -0
  70. package/dist/daemon/image-context.d.ts.map +1 -0
  71. package/dist/daemon/image-context.js +116 -0
  72. package/dist/daemon/image-context.js.map +1 -0
  73. package/dist/daemon/image-gen/index.d.ts +22 -0
  74. package/dist/daemon/image-gen/index.d.ts.map +1 -0
  75. package/dist/daemon/image-gen/index.js +129 -0
  76. package/dist/daemon/image-gen/index.js.map +1 -0
  77. package/dist/daemon/image-gen/providers/cloudflare.d.ts +13 -0
  78. package/dist/daemon/image-gen/providers/cloudflare.d.ts.map +1 -0
  79. package/dist/daemon/image-gen/providers/cloudflare.js +63 -0
  80. package/dist/daemon/image-gen/providers/cloudflare.js.map +1 -0
  81. package/dist/daemon/image-gen/providers/huggingface.d.ts +12 -0
  82. package/dist/daemon/image-gen/providers/huggingface.d.ts.map +1 -0
  83. package/dist/daemon/image-gen/providers/huggingface.js +58 -0
  84. package/dist/daemon/image-gen/providers/huggingface.js.map +1 -0
  85. package/dist/daemon/image-gen/providers/pollinations.d.ts +11 -0
  86. package/dist/daemon/image-gen/providers/pollinations.d.ts.map +1 -0
  87. package/dist/daemon/image-gen/providers/pollinations.js +39 -0
  88. package/dist/daemon/image-gen/providers/pollinations.js.map +1 -0
  89. package/dist/daemon/image-gen/providers/replicate.d.ts +9 -0
  90. package/dist/daemon/image-gen/providers/replicate.d.ts.map +1 -0
  91. package/dist/daemon/image-gen/providers/replicate.js +158 -0
  92. package/dist/daemon/image-gen/providers/replicate.js.map +1 -0
  93. package/dist/daemon/image-gen/types.d.ts +41 -0
  94. package/dist/daemon/image-gen/types.d.ts.map +1 -0
  95. package/dist/daemon/image-gen/types.js +5 -0
  96. package/dist/daemon/image-gen/types.js.map +1 -0
  97. package/dist/daemon/index.d.ts.map +1 -1
  98. package/dist/daemon/index.js +260 -6
  99. package/dist/daemon/index.js.map +1 -1
  100. package/dist/daemon/screenshot.d.ts +12 -0
  101. package/dist/daemon/screenshot.d.ts.map +1 -0
  102. package/dist/daemon/screenshot.js +252 -0
  103. package/dist/daemon/screenshot.js.map +1 -0
  104. package/dist/daemon/session-content.d.ts +27 -0
  105. package/dist/daemon/session-content.d.ts.map +1 -0
  106. package/dist/daemon/session-content.js +76 -0
  107. package/dist/daemon/session-content.js.map +1 -0
  108. package/dist/daemon/vision.d.ts +46 -0
  109. package/dist/daemon/vision.d.ts.map +1 -0
  110. package/dist/daemon/vision.js +176 -0
  111. package/dist/daemon/vision.js.map +1 -0
  112. package/dist/index.d.ts +6 -1
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +4 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/ipc/validate.d.ts +52 -0
  117. package/dist/ipc/validate.d.ts.map +1 -0
  118. package/dist/ipc/validate.js +129 -0
  119. package/dist/ipc/validate.js.map +1 -0
  120. package/dist/mcp/index.d.ts +23 -0
  121. package/dist/mcp/index.d.ts.map +1 -0
  122. package/dist/mcp/index.js +787 -0
  123. package/dist/mcp/index.js.map +1 -0
  124. package/dist/types/broker.d.ts +3 -1
  125. package/dist/types/broker.d.ts.map +1 -1
  126. package/dist/types/broker.js.map +1 -1
  127. package/package.json +5 -2
  128. package/templates/adapter/ONBOARDING_PROMPT.md +51 -29
  129. package/templates/adapter/README.md.tmpl +14 -31
  130. package/templates/adapter/package.json.tmpl +1 -1
  131. package/templates/adapter/src/watcher/commands.ts.tmpl +24 -126
  132. package/templates/adapter/src/watcher/index.ts.tmpl +112 -88
  133. package/templates/adapter/src/watcher/ipc-server.ts.tmpl +27 -3
@@ -16,10 +16,34 @@
16
16
  * voice_config — get/set TTS config
17
17
  * status — hub health/connection summary
18
18
  */
19
- import { broadcastStatus } from "../adapters/pailot/gateway.js";
19
+ import { createBrokerMessage } from "../types/broker.js";
20
+ import { broadcastStatus, broadcastVoice, broadcastImage, broadcastText } from "../adapters/pailot/gateway.js";
21
+ import { WatcherClient } from "../ipc/client.js";
20
22
  import { saveVoiceConfig } from "../core/persistence.js";
21
- import { voiceConfig, setVoiceConfig } from "../core/state.js";
23
+ import { voiceConfig, setVoiceConfig, activeItermSessionId, lastRoutedSessionId, getAibpBridge, depositToSessionMailbox, drainSessionMailbox } from "../core/state.js";
24
+ import { splitIntoChunks } from "../adapters/kokoro/media.js";
25
+ import { stripMarkdown } from "../core/markdown.js";
22
26
  import { listPaiProjects, findPaiProject, launchPaiProject } from "./pai-projects.js";
27
+ import { readSessionContent, readAllSessionContent } from "./session-content.js";
28
+ import { statusCache, hashContent } from "../core/status-cache.js";
29
+ import { snapshotAllSessions, typeIntoSession } from "../adapters/iterm/core.js";
30
+ import { setItermSessionVar, setItermTabName, setItermBadge } from "../adapters/iterm/sessions.js";
31
+ import { log } from "../core/log.js";
32
+ import { readFileSync } from "node:fs";
33
+ import { join, dirname } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
37
+ function getPackageVersion() {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
40
+ return pkg.version ?? "unknown";
41
+ }
42
+ catch {
43
+ return "unknown";
44
+ }
45
+ }
46
+ const HUB_VERSION = getPackageVersion();
23
47
  export function registerCoreHandlers(server, registry, _apiBackend, manager) {
24
48
  server.on("register_adapter", async (req) => {
25
49
  const { name, socketPath } = req.params;
@@ -84,7 +108,7 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
84
108
  return {
85
109
  ok: true,
86
110
  result: {
87
- version: "1.0.0",
111
+ version: HUB_VERSION,
88
112
  adapters: registry.list().map(a => a.name),
89
113
  activeSessions: manager.listSessions().length,
90
114
  activeSession: manager.activeSession?.name ?? null,
@@ -92,6 +116,125 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
92
116
  },
93
117
  };
94
118
  });
119
+ /**
120
+ * ping — Lightweight heartbeat for adapter health checks.
121
+ * Returns immediately with the hub uptime. No side effects.
122
+ */
123
+ server.on("ping", async (_req) => {
124
+ return { ok: true, result: { pong: true, uptime: process.uptime() } };
125
+ });
126
+ // ── TTS / Voice Pipeline ──
127
+ /**
128
+ * tts — Convert text to voice note and deliver to requesting adapter.
129
+ *
130
+ * The hub generates the audio (Kokoro TTS) and sends the OGG buffer
131
+ * back to the adapter that requested it (via the "source" field).
132
+ */
133
+ server.on("tts", async (req) => {
134
+ const { text, voice, source, recipient } = req.params;
135
+ if (!text)
136
+ return { ok: false, error: "text is required" };
137
+ const resolvedVoice = voice ?? voiceConfig.defaultVoice;
138
+ try {
139
+ const { textToVoiceNote } = await import("../adapters/kokoro/tts.js");
140
+ const audioBuffer = await textToVoiceNote(text, resolvedVoice);
141
+ // If a source adapter is specified, deliver the voice note through it
142
+ if (source) {
143
+ const adapter = registry.get(source);
144
+ if (adapter) {
145
+ const msg = createBrokerMessage("hub", "voice", {
146
+ buffer: audioBuffer.toString("base64"),
147
+ text: text.slice(0, 100),
148
+ recipient,
149
+ metadata: { voice: resolvedVoice },
150
+ });
151
+ await registry.deliverToAdapter(adapter, msg);
152
+ }
153
+ }
154
+ // Also broadcast to PAILot clients
155
+ const bridge = getAibpBridge();
156
+ if (bridge) {
157
+ bridge.routeToMobile("", text.slice(0, 200), "VOICE", {
158
+ audioBase64: audioBuffer.toString("base64"),
159
+ });
160
+ }
161
+ else {
162
+ broadcastVoice(audioBuffer, text.slice(0, 200));
163
+ }
164
+ return { ok: true, result: { generated: true, voice: resolvedVoice, bytes: audioBuffer.length } };
165
+ }
166
+ catch (err) {
167
+ return { ok: false, error: `TTS failed: ${err instanceof Error ? err.message : String(err)}` };
168
+ }
169
+ });
170
+ /**
171
+ * speak — Play text locally via afplay (no network delivery).
172
+ */
173
+ server.on("speak", async (req) => {
174
+ const { text, voice } = req.params;
175
+ if (!text)
176
+ return { ok: false, error: "text is required" };
177
+ try {
178
+ const { speakLocally } = await import("../adapters/kokoro/tts.js");
179
+ await speakLocally(text, voice ?? voiceConfig.defaultVoice);
180
+ return { ok: true, result: { speaking: true } };
181
+ }
182
+ catch (err) {
183
+ return { ok: false, error: `Speak failed: ${err instanceof Error ? err.message : String(err)}` };
184
+ }
185
+ });
186
+ /**
187
+ * dictate — Record from mic and transcribe via Whisper.
188
+ */
189
+ server.on("dictate", async (req) => {
190
+ const { maxDuration } = req.params;
191
+ try {
192
+ const { recordFromMic, transcribeLocalAudio } = await import("../adapters/iterm/dictation.js");
193
+ const audioPath = await recordFromMic(maxDuration ?? 30);
194
+ const text = await transcribeLocalAudio(audioPath);
195
+ return { ok: true, result: { text, audioPath } };
196
+ }
197
+ catch (err) {
198
+ return { ok: false, error: `Dictation failed: ${err instanceof Error ? err.message : String(err)}` };
199
+ }
200
+ });
201
+ /**
202
+ * transcribe — Transcribe an audio buffer via Whisper.
203
+ */
204
+ server.on("transcribe", async (req) => {
205
+ const { audioBase64, mimetype } = req.params;
206
+ if (!audioBase64)
207
+ return { ok: false, error: "audioBase64 is required" };
208
+ try {
209
+ const { transcribeAudio, mimetypeToExt } = await import("../adapters/kokoro/media.js");
210
+ const { writeFileSync, unlinkSync } = await import("node:fs");
211
+ const { tmpdir } = await import("node:os");
212
+ const { join } = await import("node:path");
213
+ const ext = mimetypeToExt(mimetype ?? "audio/ogg");
214
+ const tmpPath = join(tmpdir(), `aibroker-transcribe-${Date.now()}.${ext}`);
215
+ writeFileSync(tmpPath, Buffer.from(audioBase64, "base64"));
216
+ try {
217
+ const text = await transcribeAudio(tmpPath);
218
+ return { ok: true, result: { text } };
219
+ }
220
+ finally {
221
+ try {
222
+ unlinkSync(tmpPath);
223
+ }
224
+ catch { /* ignore */ }
225
+ }
226
+ }
227
+ catch (err) {
228
+ return { ok: false, error: `Transcription failed: ${err instanceof Error ? err.message : String(err)}` };
229
+ }
230
+ });
231
+ /**
232
+ * list_voices — List available TTS voices.
233
+ */
234
+ server.on("list_voices", async (_req) => {
235
+ const { listVoices } = await import("../adapters/kokoro/tts.js");
236
+ return { ok: true, result: { voices: listVoices() } };
237
+ });
95
238
  // ── PAI Named Sessions ──
96
239
  server.on("pai_projects", async (_req) => {
97
240
  const projects = await listPaiProjects();
@@ -124,6 +267,618 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
124
267
  manager.registerVisualSession(displayName, project?.rootPath ?? "", itermSessionId);
125
268
  return { ok: true, result: { itermSessionId, sessionId, name } };
126
269
  });
270
+ // ── Phase 6: Image Generation ──
271
+ /**
272
+ * generate_image — Generate an image from a text prompt.
273
+ *
274
+ * Optionally sends an "on it..." ack and delivers the generated image
275
+ * back to the requesting adapter.
276
+ */
277
+ server.on("generate_image", async (req) => {
278
+ const { prompt, source, recipient, ack, width, height } = req.params;
279
+ if (!prompt)
280
+ return { ok: false, error: "prompt is required" };
281
+ // Send "on it..." ack to the requesting adapter
282
+ if (ack !== false && source) {
283
+ const adapter = registry.get(source);
284
+ if (adapter) {
285
+ const ackMsg = createBrokerMessage("hub", "text", {
286
+ text: "On it... generating your image.",
287
+ recipient,
288
+ });
289
+ registry.deliverToAdapter(adapter, ackMsg).catch(() => { });
290
+ }
291
+ }
292
+ try {
293
+ const { generateImage } = await import("./image-gen/index.js");
294
+ const result = await generateImage({ prompt, width, height });
295
+ // Deliver image to requesting adapter
296
+ if (source && result.images.length > 0) {
297
+ const adapter = registry.get(source);
298
+ if (adapter) {
299
+ const imgMsg = createBrokerMessage("hub", "image", {
300
+ buffer: result.images[0].toString("base64"),
301
+ caption: prompt.slice(0, 200),
302
+ recipient,
303
+ metadata: { model: result.model, durationMs: result.durationMs },
304
+ });
305
+ await registry.deliverToAdapter(adapter, imgMsg);
306
+ }
307
+ }
308
+ // Also broadcast to PAILot clients
309
+ if (result.images.length > 0) {
310
+ const bridge = getAibpBridge();
311
+ if (bridge) {
312
+ bridge.routeToMobile("", prompt.slice(0, 200), "IMAGE", {
313
+ imageBase64: result.images[0].toString("base64"),
314
+ mimeType: "image/png",
315
+ });
316
+ }
317
+ else {
318
+ broadcastImage(result.images[0], prompt.slice(0, 200));
319
+ }
320
+ }
321
+ return {
322
+ ok: true,
323
+ result: {
324
+ generated: true,
325
+ model: result.model,
326
+ durationMs: result.durationMs,
327
+ imageCount: result.images.length,
328
+ bytes: result.images.reduce((s, b) => s + b.length, 0),
329
+ },
330
+ };
331
+ }
332
+ catch (err) {
333
+ return { ok: false, error: `Image generation failed: ${err instanceof Error ? err.message : String(err)}` };
334
+ }
335
+ });
336
+ // ── Phase 7: Vision & Understanding ──
337
+ /**
338
+ * analyze_image — Save image and deliver to active Claude Code session.
339
+ *
340
+ * The image is saved to ~/.aibroker/media/ and the path is routed through
341
+ * the command handler to the active iTerm2 session. Claude Code in that
342
+ * session reads the image with its Read tool (covered by Max plan).
343
+ */
344
+ server.on("analyze_image", async (req) => {
345
+ const { imageBase64, mimetype, prompt, source, recipient } = req.params;
346
+ if (!imageBase64)
347
+ return { ok: false, error: "imageBase64 is required" };
348
+ try {
349
+ const { saveReceivedImage } = await import("./vision.js");
350
+ const imageBuffer = Buffer.from(imageBase64, "base64");
351
+ const { path, sizeBytes } = saveReceivedImage(imageBuffer, mimetype);
352
+ // Route through the command handler → active iTerm2 session
353
+ const userPrompt = prompt ?? "Analyze this image.";
354
+ const messageText = `[Image: ${path}] ${userPrompt}`;
355
+ const sourceAdapter = source ? registry.get(source) : undefined;
356
+ const msg = createBrokerMessage(source ?? "hub", "command", {
357
+ text: messageText,
358
+ recipient,
359
+ });
360
+ await registry.route(msg);
361
+ return { ok: true, result: { saved: true, path, sizeBytes } };
362
+ }
363
+ catch (err) {
364
+ return { ok: false, error: `Image analysis failed: ${err instanceof Error ? err.message : String(err)}` };
365
+ }
366
+ });
367
+ /**
368
+ * analyze_video — Analyze a video using Gemini 2.0 Flash (free tier).
369
+ *
370
+ * Video can't be read by Claude Code's Read tool, so we use Gemini's
371
+ * native video understanding and deliver the text result back.
372
+ */
373
+ server.on("analyze_video", async (req) => {
374
+ const { videoBase64, mimetype, prompt, source, recipient } = req.params;
375
+ if (!videoBase64)
376
+ return { ok: false, error: "videoBase64 is required" };
377
+ // Ack — video analysis takes longer
378
+ if (source) {
379
+ const adapter = registry.get(source);
380
+ if (adapter) {
381
+ const ackMsg = createBrokerMessage("hub", "text", {
382
+ text: "Analyzing your video...",
383
+ recipient,
384
+ });
385
+ registry.deliverToAdapter(adapter, ackMsg).catch(() => { });
386
+ }
387
+ }
388
+ try {
389
+ const { analyzeVideo, saveReceivedVideo } = await import("./vision.js");
390
+ const videoBuffer = Buffer.from(videoBase64, "base64");
391
+ const { path } = saveReceivedVideo(videoBuffer, mimetype);
392
+ const result = await analyzeVideo({ videoBuffer, mimetype, prompt });
393
+ // Deliver the analysis text to the active session
394
+ if (result.text) {
395
+ const analysisText = `[Video analysis of ${path}]\n\n${result.text}`;
396
+ const msg = createBrokerMessage(source ?? "hub", "command", {
397
+ text: analysisText,
398
+ recipient,
399
+ });
400
+ await registry.route(msg);
401
+ }
402
+ return { ok: true, result: { text: result.text, model: result.model, durationMs: result.durationMs, path } };
403
+ }
404
+ catch (err) {
405
+ return { ok: false, error: `Video analysis failed: ${err instanceof Error ? err.message : String(err)}` };
406
+ }
407
+ });
408
+ // ── Session Orchestration (Phase 1) ──
409
+ /**
410
+ * session_content — Read raw terminal content from iTerm2 sessions.
411
+ *
412
+ * If sessionId is provided, reads that specific session.
413
+ * If omitted, reads all sessions. Returns raw content + busy/idle flag
414
+ * + whether content has changed since last probe (via content hash).
415
+ */
416
+ server.on("session_content", async (req) => {
417
+ const { sessionId, lines } = req.params;
418
+ const lineCount = lines ?? 100;
419
+ if (sessionId) {
420
+ const content = readSessionContent(sessionId, lineCount);
421
+ if (!content)
422
+ return { ok: false, error: `Session ${sessionId} not found in iTerm2` };
423
+ const contentHash = hashContent(content.content);
424
+ const changed = statusCache.hasChanged(sessionId, contentHash);
425
+ const cached = statusCache.get(sessionId);
426
+ if (!changed) {
427
+ statusCache.touch(sessionId);
428
+ }
429
+ return {
430
+ ok: true,
431
+ result: {
432
+ session: {
433
+ ...content,
434
+ contentHash,
435
+ changed,
436
+ cachedSummary: cached?.summary ?? null,
437
+ cachedAt: cached?.timestamp ?? null,
438
+ },
439
+ },
440
+ };
441
+ }
442
+ // All sessions
443
+ const contents = readAllSessionContent(lineCount);
444
+ const sessions = contents.map((c) => {
445
+ const contentHash = hashContent(c.content);
446
+ const changed = statusCache.hasChanged(c.sessionId, contentHash);
447
+ const cached = statusCache.get(c.sessionId);
448
+ if (!changed)
449
+ statusCache.touch(c.sessionId);
450
+ return {
451
+ ...c,
452
+ contentHash,
453
+ changed,
454
+ cachedSummary: cached?.summary ?? null,
455
+ cachedAt: cached?.timestamp ?? null,
456
+ };
457
+ });
458
+ return { ok: true, result: { sessions } };
459
+ });
460
+ /**
461
+ * cache_status — Store a parsed summary for a session.
462
+ *
463
+ * Called by the requesting session's AI after parsing raw terminal content.
464
+ * The summary is cached with the content hash so future probes can skip parsing
465
+ * if content hasn't changed.
466
+ */
467
+ server.on("cache_status", async (req) => {
468
+ const { sessionId, sessionName, summary, contentHash, state } = req.params;
469
+ if (!sessionId)
470
+ return { ok: false, error: "sessionId is required" };
471
+ if (!summary)
472
+ return { ok: false, error: "summary is required" };
473
+ statusCache.set(sessionId, {
474
+ sessionId,
475
+ sessionName: sessionName ?? sessionId,
476
+ timestamp: Date.now(),
477
+ state: state ?? "idle",
478
+ summary,
479
+ contentHash: contentHash ?? "",
480
+ lastProbeAt: Date.now(),
481
+ });
482
+ return { ok: true, result: { cached: true, sessionId } };
483
+ });
484
+ /**
485
+ * get_cached_status — Retrieve cached session summaries without re-probing.
486
+ *
487
+ * If sessionId is provided, returns that session's cached snapshot.
488
+ * If omitted, returns all cached snapshots.
489
+ */
490
+ server.on("get_cached_status", async (req) => {
491
+ const { sessionId } = req.params;
492
+ if (sessionId) {
493
+ const cached = statusCache.get(sessionId);
494
+ if (!cached)
495
+ return { ok: true, result: { snapshot: null } };
496
+ return { ok: true, result: { snapshot: cached } };
497
+ }
498
+ return { ok: true, result: { snapshots: statusCache.getAll() } };
499
+ });
500
+ // ── AIBP Protocol Support ──
501
+ /**
502
+ * aibp_register — Register an MCP process as an AIBP plugin.
503
+ * Called once when the MCP server starts. Returns the resolved session
504
+ * so the MCP doesn't need TTY detection for routing.
505
+ */
506
+ server.on("aibp_register", async (req) => {
507
+ const { pluginId, sessionEnvId } = req.params;
508
+ if (!pluginId)
509
+ return { ok: false, error: "pluginId is required" };
510
+ const bridge = getAibpBridge();
511
+ if (!bridge)
512
+ return { ok: false, error: "AIBP bridge not initialized" };
513
+ const result = bridge.registerMcp(pluginId, sessionEnvId);
514
+ return {
515
+ ok: true,
516
+ result: {
517
+ address: result.address,
518
+ resolvedSession: result.resolvedSession,
519
+ },
520
+ };
521
+ });
522
+ /**
523
+ * aibp_send — Send a message from one session to another via AIBP.
524
+ * Enables cross-session messaging: session A can send text to session B.
525
+ */
526
+ server.on("aibp_send", async (req) => {
527
+ const { fromSession, toSession, content, type } = req.params;
528
+ if (!toSession)
529
+ return { ok: false, error: "toSession is required" };
530
+ if (!content)
531
+ return { ok: false, error: "content is required" };
532
+ const bridge = getAibpBridge();
533
+ if (!bridge)
534
+ return { ok: false, error: "AIBP bridge not initialized" };
535
+ bridge.routeBetweenSessions(fromSession ?? "unknown", toSession, content, type ?? "TEXT");
536
+ return { ok: true, result: {} };
537
+ });
538
+ /**
539
+ * aibp_status — Query AIBP registry state (plugins, channels, commands).
540
+ */
541
+ server.on("aibp_status", async () => {
542
+ const bridge = getAibpBridge();
543
+ if (!bridge)
544
+ return { ok: false, error: "AIBP bridge not initialized" };
545
+ // Build iTerm session ID → name lookup from HybridSessionManager
546
+ const sessionNames = new Map();
547
+ for (const s of manager.listSessions()) {
548
+ sessionNames.set(s.backendSessionId, s.name);
549
+ }
550
+ // Enrich plugin list with session names for MCP plugins
551
+ const plugins = bridge.registry.listPlugins().map(p => {
552
+ const info = {
553
+ address: p.address,
554
+ type: p.spec.type,
555
+ name: p.spec.name,
556
+ };
557
+ // For MCP plugins, resolve the session name from the iTerm UUID
558
+ if (p.spec.type === "mcp") {
559
+ const sessionChannel = Array.from(p.joinedChannels).find(ch => ch.startsWith("session:"));
560
+ if (sessionChannel) {
561
+ const itermId = sessionChannel.slice(8);
562
+ const sessionName = sessionNames.get(itermId);
563
+ if (sessionName)
564
+ info.sessionName = sessionName;
565
+ }
566
+ }
567
+ return info;
568
+ });
569
+ // Session snapshots (iTerm sessions with idle/busy status)
570
+ const snapshots = snapshotAllSessions();
571
+ const sessions = snapshots.map((snap, i) => {
572
+ const label = snap.paiName ?? snap.tabTitle ?? snap.name;
573
+ const isActive = snap.id === activeItermSessionId;
574
+ const cached = statusCache.get(snap.id);
575
+ const hasFreshSummary = cached?.summary && Date.now() - cached.timestamp < 5 * 60 * 1000;
576
+ return {
577
+ index: i + 1,
578
+ id: snap.id,
579
+ name: label,
580
+ atPrompt: snap.atPrompt,
581
+ active: isActive,
582
+ summary: hasFreshSummary ? cached.summary : undefined,
583
+ };
584
+ });
585
+ return {
586
+ ok: true,
587
+ result: {
588
+ sessions,
589
+ plugins,
590
+ channels: bridge.registry.listChannels().map(ch => ({
591
+ name: ch.channel,
592
+ members: Array.from(ch.members),
593
+ outboxSize: ch.outbox.length,
594
+ })),
595
+ commands: bridge.listCommands().map(c => ({
596
+ name: c.name,
597
+ owner: c.owner,
598
+ description: c.spec?.description,
599
+ })),
600
+ },
601
+ };
602
+ });
603
+ // ── Inter-Session Communication ──
604
+ /**
605
+ * send_to_session — Type a message into a target iTerm2 session.
606
+ *
607
+ * Resolves the target by:
608
+ * 1. Number → session index (1-based) from snapshotAllSessions
609
+ * 2. iTerm UUID (contains hyphens and matches length) → used directly
610
+ * 3. String → case-insensitive match against paiName or session name
611
+ *
612
+ * Calls typeIntoSession which writes text + Enter into the session's stdin.
613
+ */
614
+ server.on("send_to_session", async (req) => {
615
+ const { target, message } = req.params;
616
+ if (!target)
617
+ return { ok: false, error: "target is required" };
618
+ if (!message)
619
+ return { ok: false, error: "message is required" };
620
+ const snapshots = snapshotAllSessions();
621
+ let itermSessionId = null;
622
+ let resolvedName = null;
623
+ const asNumber = parseInt(target, 10);
624
+ if (!Number.isNaN(asNumber) && String(asNumber) === target.trim()) {
625
+ // Numeric index (1-based)
626
+ const snap = snapshots[asNumber - 1];
627
+ if (snap) {
628
+ itermSessionId = snap.id;
629
+ resolvedName = snap.paiName ?? snap.name;
630
+ }
631
+ }
632
+ else if (/^[0-9A-Fa-f-]{20,}$/.test(target)) {
633
+ // Looks like an iTerm UUID — use directly if it exists
634
+ const snap = snapshots.find((s) => s.id === target);
635
+ if (snap) {
636
+ itermSessionId = snap.id;
637
+ resolvedName = snap.paiName ?? snap.name;
638
+ }
639
+ else {
640
+ // Trust the caller — they may have a valid ID not yet in the snapshot
641
+ itermSessionId = target;
642
+ resolvedName = target;
643
+ }
644
+ }
645
+ else {
646
+ // Name match (case-insensitive, prefers paiName, falls back to session name)
647
+ const lower = target.toLowerCase();
648
+ const snap = snapshots.find((s) => (s.paiName ?? s.name).toLowerCase().includes(lower));
649
+ if (snap) {
650
+ itermSessionId = snap.id;
651
+ resolvedName = snap.paiName ?? snap.name;
652
+ }
653
+ }
654
+ if (!itermSessionId) {
655
+ return {
656
+ ok: false,
657
+ error: `Session "${target}" not found. Available sessions: ${snapshots.map((s, i) => `${i + 1}:${s.paiName ?? s.name}`).join(", ")}`,
658
+ };
659
+ }
660
+ // Resolve the sender's name for the mailbox "from" label.
661
+ // Normalize "w0t0p0:UUID" → "UUID" before snapshot lookup.
662
+ const rawSenderId = req.itermSessionId;
663
+ const senderItermId = rawSenderId
664
+ ? (rawSenderId.includes(":") ? rawSenderId.split(":").pop() : rawSenderId)
665
+ : undefined;
666
+ const senderSnap = senderItermId
667
+ ? snapshots.find((s) => s.id === senderItermId)
668
+ : undefined;
669
+ const senderLabel = senderSnap
670
+ ? (senderSnap.paiName ?? senderSnap.name)
671
+ : (senderItermId ?? req.sessionId ?? "unknown");
672
+ // Deposit into the target session's mailbox (structured receive)
673
+ depositToSessionMailbox(itermSessionId, senderLabel, message);
674
+ // Prefix with session routing tag so the receiving Claude knows to route the response back
675
+ // This is analogous to [Whazaa], [PAILot], [Telex] prefixes for other channels
676
+ const prefixedMessage = `[Session:${senderLabel}] ${message}`;
677
+ // Also type into the terminal (ensures text appears even if target isn't polling mailbox)
678
+ const success = typeIntoSession(itermSessionId, prefixedMessage);
679
+ if (!success) {
680
+ return { ok: false, error: `Failed to type into session "${resolvedName}" (${itermSessionId})` };
681
+ }
682
+ return { ok: true, result: { sent: true, sessionId: itermSessionId, name: resolvedName } };
683
+ });
684
+ /**
685
+ * session_mailbox_receive — Drain the calling session's message mailbox.
686
+ *
687
+ * Returns all pending messages deposited by send_to_session from other sessions.
688
+ * The queue is cleared on read (drain semantics). Returns empty array if no messages.
689
+ *
690
+ * The caller's iTerm session ID is taken from req.itermSessionId (set by IPC server
691
+ * from the session context) or from the explicit sessionId param as a fallback.
692
+ *
693
+ * iTerm2 session IDs in env vars have the form "w0t0p0:UUID". We normalize to just
694
+ * the UUID so mailbox keys match snapshot IDs.
695
+ */
696
+ server.on("session_mailbox_receive", async (req) => {
697
+ const { sessionId: explicitSessionId } = req.params;
698
+ const rawId = req.itermSessionId ?? explicitSessionId ?? req.sessionId;
699
+ if (!rawId) {
700
+ return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
701
+ }
702
+ // Normalize "w0t0p0:UUID" → "UUID"
703
+ const itermSessionId = rawId.includes(":") ? rawId.split(":").pop() : rawId;
704
+ const messages = drainSessionMailbox(itermSessionId);
705
+ return { ok: true, result: { messages, sessionId: itermSessionId } };
706
+ });
707
+ // ── Unified MCP Support ──
708
+ /**
709
+ * adapter_call — Proxy an IPC call to a named adapter through the hub.
710
+ * The unified MCP server uses this to reach adapter-specific methods
711
+ * (send, receive, contacts, history, etc.) without knowing socket paths.
712
+ */
713
+ server.on("adapter_call", async (req) => {
714
+ const { adapter, method, params } = req.params;
715
+ if (!adapter)
716
+ return { ok: false, error: "adapter is required" };
717
+ if (!method)
718
+ return { ok: false, error: "method is required" };
719
+ const desc = registry.get(adapter);
720
+ if (!desc) {
721
+ return { ok: false, error: `Adapter '${adapter}' not registered. Is the ${adapter} daemon running?` };
722
+ }
723
+ try {
724
+ const client = new WatcherClient(desc.socketPath);
725
+ const forwardParams = { ...(params ?? {}), sessionId: req.sessionId };
726
+ if (req.itermSessionId)
727
+ forwardParams.itermSessionId = req.itermSessionId;
728
+ const result = await client.call_raw(method, forwardParams);
729
+ return { ok: true, result };
730
+ }
731
+ catch (e) {
732
+ const msg = e instanceof Error ? e.message : String(e);
733
+ return { ok: false, error: `adapter_call to ${adapter}.${method} failed: ${msg}` };
734
+ }
735
+ });
736
+ /**
737
+ * pailot_send — Send text or voice to PAILot app clients via WS gateway.
738
+ */
739
+ server.on("pailot_send", async (req) => {
740
+ const { text, voice, voiceName, sessionId: callerSessionId } = req.params;
741
+ if (!text)
742
+ return { ok: false, error: "text is required" };
743
+ // MCP server may not have ITERM_SESSION_ID — fall back to session that last
744
+ // received user input from PAILot (survives session switches during processing)
745
+ const sessionId = callerSessionId || lastRoutedSessionId || activeItermSessionId || undefined;
746
+ log(`[pailot_send] callerSession=${callerSessionId?.slice(0, 8) ?? "none"} lastRouted=${lastRoutedSessionId?.slice(0, 8) ?? "none"} activeIterm=${activeItermSessionId?.slice(0, 8) ?? "none"} → resolved=${sessionId?.slice(0, 8) ?? "none"}`);
747
+ try {
748
+ const bridge = getAibpBridge();
749
+ if (voice) {
750
+ const { textToVoiceNote } = await import("../adapters/kokoro/tts.js");
751
+ const resolvedVoice = voiceName ?? voiceConfig.defaultVoice;
752
+ const plainText = stripMarkdown(text);
753
+ const chunks = splitIntoChunks(plainText);
754
+ for (let i = 0; i < chunks.length; i++) {
755
+ if (i > 0)
756
+ await new Promise((r) => setTimeout(r, 1000));
757
+ const audioBuffer = await textToVoiceNote(chunks[i], resolvedVoice);
758
+ const transcript = i === 0 ? plainText : "";
759
+ if (bridge) {
760
+ bridge.routeToMobile(sessionId ?? "", transcript, "VOICE", {
761
+ audioBase64: audioBuffer.toString("base64"),
762
+ });
763
+ }
764
+ else {
765
+ await broadcastVoice(audioBuffer, transcript, sessionId);
766
+ }
767
+ }
768
+ return { ok: true, result: { sent: true, chunks: chunks.length } };
769
+ }
770
+ else {
771
+ if (bridge) {
772
+ bridge.routeToMobile(sessionId ?? "", text);
773
+ }
774
+ else {
775
+ broadcastText(text, sessionId);
776
+ }
777
+ }
778
+ return { ok: true, result: { sent: true } };
779
+ }
780
+ catch (e) {
781
+ return { ok: false, error: `pailot_send failed: ${e instanceof Error ? e.message : String(e)}` };
782
+ }
783
+ });
784
+ /**
785
+ * pailot_receive — Drain the PAILot message queue.
786
+ * Currently proxied to whazaa adapter's receive with from='pailot'.
787
+ */
788
+ server.on("pailot_receive", async (req) => {
789
+ const adapterName = registry.get("whazaa") ? "whazaa" : "telex";
790
+ const desc = registry.get(adapterName);
791
+ if (!desc)
792
+ return { ok: true, result: { messages: [] } };
793
+ try {
794
+ const client = new WatcherClient(desc.socketPath);
795
+ const result = await client.call_raw("receive", {
796
+ from: "pailot",
797
+ sessionId: req.sessionId,
798
+ });
799
+ return { ok: true, result };
800
+ }
801
+ catch {
802
+ return { ok: true, result: { messages: [] } };
803
+ }
804
+ });
805
+ /**
806
+ * rename — Rename session: update registry, tab title, badge, and session variable.
807
+ *
808
+ * Resolves the caller's iTerm2 session from req.itermSessionId (set by IPC client
809
+ * from ITERM_SESSION_ID env var). This works for both MCP callers (Claude Code sessions)
810
+ * and adapter-forwarded renames.
811
+ */
812
+ server.on("rename", async (req) => {
813
+ const { name } = req.params;
814
+ if (!name)
815
+ return { ok: false, error: "name is required" };
816
+ // Resolve caller's iTerm2 session UUID from "w0t0p0:UUID" format
817
+ const rawItermId = req.itermSessionId;
818
+ const itermSessionId = rawItermId
819
+ ? (rawItermId.includes(":") ? rawItermId.split(":").pop() : rawItermId)
820
+ : undefined;
821
+ // Update in hub's session manager
822
+ if (itermSessionId) {
823
+ // updateName searches by backendSessionId (iTerm2 UUID)
824
+ manager.updateName(itermSessionId, name);
825
+ }
826
+ else {
827
+ const session = manager.activeSession;
828
+ if (session)
829
+ manager.updateName(session.id, name);
830
+ }
831
+ // Set iTerm2 visuals directly if we know the session
832
+ if (itermSessionId) {
833
+ setItermSessionVar(itermSessionId, name);
834
+ setItermTabName(itermSessionId, name);
835
+ setItermBadge(itermSessionId, name);
836
+ }
837
+ // Forward to all adapters (best effort — for PAILot session list sync)
838
+ for (const adapter of registry.list()) {
839
+ try {
840
+ const client = new WatcherClient(adapter.socketPath);
841
+ await client.call_raw("rename", { name, sessionId: req.sessionId });
842
+ }
843
+ catch { /* best effort */ }
844
+ }
845
+ return { ok: true, result: { success: true, name } };
846
+ });
847
+ /**
848
+ * discover — Proxy to first available adapter for iTerm2 session scan.
849
+ */
850
+ server.on("discover", async (req) => {
851
+ const adapters = registry.list();
852
+ if (adapters.length === 0)
853
+ return { ok: false, error: "No adapters registered" };
854
+ try {
855
+ const client = new WatcherClient(adapters[0].socketPath);
856
+ const result = await client.call_raw("discover", { sessionId: req.sessionId });
857
+ return { ok: true, result };
858
+ }
859
+ catch (e) {
860
+ return { ok: false, error: `discover failed: ${e instanceof Error ? e.message : String(e)}` };
861
+ }
862
+ });
863
+ /**
864
+ * command — Execute a slash command through the hub command handler.
865
+ */
866
+ server.on("command", async (req) => {
867
+ const { text } = req.params;
868
+ if (!text)
869
+ return { ok: false, error: "text is required" };
870
+ // Try the hub's command handler first
871
+ const adapters = registry.list();
872
+ if (adapters.length > 0) {
873
+ try {
874
+ const client = new WatcherClient(adapters[0].socketPath);
875
+ const result = await client.call_raw("command", { text, sessionId: req.sessionId });
876
+ return { ok: true, result };
877
+ }
878
+ catch { /* fall through */ }
879
+ }
880
+ return { ok: true, result: { executed: true, command: text } };
881
+ });
127
882
  // ── Phase 2: Message Routing ──
128
883
  /**
129
884
  * route_message — Adapters send messages to the hub for routing.