fathom-mcp 0.4.12 → 0.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.4.12",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.26.0",
26
- "js-yaml": "^4.1.0"
26
+ "js-yaml": "^4.1.0",
27
+ "ws": "^8.18.0"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@eslint/js": "^9.39.3",
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
 
21
21
  import { resolveConfig } from "./config.js";
22
22
  import { createClient } from "./server-client.js";
23
+ import { createWSConnection } from "./ws-connection.js";
23
24
  import {
24
25
  handleVaultWrite,
25
26
  handleVaultAppend,
@@ -32,6 +33,7 @@ import {
32
33
 
33
34
  const config = resolveConfig();
34
35
  const client = createClient(config);
36
+ let wsConn = null;
35
37
 
36
38
  // --- Tool definitions --------------------------------------------------------
37
39
 
@@ -227,9 +229,8 @@ const tools = [
227
229
  "to the latest message. Default: 60 minutes before the latest message. Use start to look " +
228
230
  "further back. Example: minutes=15, start=120 returns 15 minutes of conversation starting " +
229
231
  "2 hours before the latest message. Response includes window metadata with has_older flag " +
230
- "for pseudo-pagination. For virtual rooms (dm:*, mentions:*), messages are consumed on read " +
231
- "deleted after being returned. Use mark_read=false to peek without consuming. " +
232
- "For regular rooms, automatically marks as read unless mark_read=false.",
232
+ "for pseudo-pagination. All rooms are persistent messages are never deleted on read. " +
233
+ "Automatically marks the room as read unless mark_read=false.",
233
234
  inputSchema: {
234
235
  type: "object",
235
236
  properties: {
@@ -246,8 +247,8 @@ const tools = [
246
247
  description:
247
248
  "List all rooms with activity summary — message count, last activity time, last sender, " +
248
249
  "description, and per-room unread_count for this workspace. Use to discover active rooms " +
249
- "and see which have new messages. Virtual rooms (dm:{workspace}, mentions:{workspace}) " +
250
- "appear only for the owning workspace and disappear when empty.",
250
+ "and see which have new messages. DM rooms (dm:a+b) are filtered by workspace param — " +
251
+ "only participants see them. Mention rooms (mentions:{workspace}) visible only to the target workspace.",
251
252
  inputSchema: {
252
253
  type: "object",
253
254
  properties: {},
@@ -282,10 +283,9 @@ const tools = [
282
283
  {
283
284
  name: "fathom_send",
284
285
  description:
285
- "Send a message to another workspace's agent instance — stored in their dm:{workspace} " +
286
- "virtual room. Use fathom_workspaces first to discover valid targets. Recipients see DMs " +
287
- "via fathom_room_list and consume them with fathom_room_read. " +
288
- "Message format: 'Message from workspace ({from}): {message}'",
286
+ "Send a message to another workspace's agent instance — stored in a shared dm:a+b room " +
287
+ "visible to both participants. Use fathom_workspaces first to discover valid targets. " +
288
+ "DMs are persistent and appear in both participants' room lists via fathom_room_list.",
289
289
  inputSchema: {
290
290
  type: "object",
291
291
  properties: {
@@ -344,6 +344,99 @@ const tools = [
344
344
  required: [],
345
345
  },
346
346
  },
347
+ {
348
+ name: "fathom_routine_list",
349
+ description:
350
+ "List all ping routines for a workspace with their status, intervals, " +
351
+ "enabled state, and next fire time. Use this to see what routines exist " +
352
+ "before updating or deleting them.",
353
+ inputSchema: {
354
+ type: "object",
355
+ properties: {
356
+ workspace: WORKSPACE_PROP,
357
+ },
358
+ required: [],
359
+ },
360
+ },
361
+ {
362
+ name: "fathom_routine_update",
363
+ description:
364
+ "Update an existing ping routine. Only provided fields are changed — " +
365
+ "omitted fields keep their current values. Use fathom_routine_list first " +
366
+ "to find the routine_id.",
367
+ inputSchema: {
368
+ type: "object",
369
+ properties: {
370
+ routine_id: { type: "string", description: "The routine ID to update. Use fathom_routine_list to find IDs." },
371
+ name: { type: "string", description: "New routine name." },
372
+ enabled: { type: "boolean", description: "Enable or disable the routine." },
373
+ interval_minutes: { type: "integer", description: "New interval in minutes.", minimum: 1 },
374
+ single_fire: { type: "boolean", description: "Auto-disable after firing once." },
375
+ workspace: WORKSPACE_PROP,
376
+ context_sources: {
377
+ type: "object",
378
+ description: "What to inject on each ping.",
379
+ properties: {
380
+ time: { type: "boolean", description: "Include current time/date. Default: true." },
381
+ scripts: {
382
+ type: "array",
383
+ description: "Shell commands to run and inject output.",
384
+ items: {
385
+ type: "object",
386
+ properties: {
387
+ label: { type: "string" },
388
+ command: { type: "string" },
389
+ enabled: { type: "boolean" },
390
+ },
391
+ },
392
+ },
393
+ texts: {
394
+ type: "array",
395
+ description: "Static text blocks to inject.",
396
+ items: {
397
+ type: "object",
398
+ properties: {
399
+ label: { type: "string" },
400
+ content: { type: "string" },
401
+ enabled: { type: "boolean" },
402
+ },
403
+ },
404
+ },
405
+ },
406
+ },
407
+ },
408
+ required: ["routine_id"],
409
+ },
410
+ },
411
+ {
412
+ name: "fathom_routine_delete",
413
+ description:
414
+ "Delete a ping routine permanently. Use fathom_routine_list first to find " +
415
+ "the routine_id. This cannot be undone — create a new routine if needed.",
416
+ inputSchema: {
417
+ type: "object",
418
+ properties: {
419
+ routine_id: { type: "string", description: "The routine ID to delete." },
420
+ workspace: WORKSPACE_PROP,
421
+ },
422
+ required: ["routine_id"],
423
+ },
424
+ },
425
+ {
426
+ name: "fathom_routine_fire",
427
+ description:
428
+ "Fire a ping routine immediately, regardless of its schedule. Non-blocking — " +
429
+ "returns immediately while the routine fires in the background. The routine's " +
430
+ "next scheduled fire time is not affected.",
431
+ inputSchema: {
432
+ type: "object",
433
+ properties: {
434
+ routine_id: { type: "string", description: "The routine ID to fire." },
435
+ workspace: WORKSPACE_PROP,
436
+ },
437
+ required: ["routine_id"],
438
+ },
439
+ },
347
440
  ];
348
441
 
349
442
  // --- Vault routing by mode ---------------------------------------------------
@@ -381,6 +474,77 @@ function resolveVault(args) {
381
474
  }
382
475
  }
383
476
 
477
+ // --- Telegram tools (primary agent only) -------------------------------------
478
+
479
+ const telegramTools = [
480
+ {
481
+ name: "fathom_telegram_contacts",
482
+ description:
483
+ "List Telegram contacts who have messaged. Returns name, username, chat_id, " +
484
+ "last_message time, and unread count.",
485
+ inputSchema: { type: "object", properties: {} },
486
+ },
487
+ {
488
+ name: "fathom_telegram_read",
489
+ description:
490
+ "Read recent Telegram messages from a contact. Same windowing as fathom_room_read — " +
491
+ "anchored to latest message, default 60 minutes. Use start to look further back.",
492
+ inputSchema: {
493
+ type: "object",
494
+ properties: {
495
+ contact: { type: "string", description: "Contact name, @username, or chat_id" },
496
+ minutes: { type: "number", description: "Window duration in minutes. Default: 60." },
497
+ start: { type: "number", description: "Offset in minutes from latest message. Default: 0." },
498
+ mark_read: { type: "boolean", description: "Mark messages as read. Default: true." },
499
+ },
500
+ required: ["contact"],
501
+ },
502
+ },
503
+ {
504
+ name: "fathom_telegram_send",
505
+ description:
506
+ "Send a Telegram message to a contact via the persistent Telethon client.",
507
+ inputSchema: {
508
+ type: "object",
509
+ properties: {
510
+ contact: { type: "string", description: "Contact name, @username, or chat_id" },
511
+ message: { type: "string", description: "Message text to send" },
512
+ },
513
+ required: ["contact", "message"],
514
+ },
515
+ },
516
+ {
517
+ name: "fathom_telegram_image",
518
+ description:
519
+ "Read a Telegram message's attached image and return it as base64 so Claude can perceive it. " +
520
+ "Use after fathom_telegram_read shows a message has media: true. " +
521
+ "Supports jpg, jpeg, png, gif, webp. Max 5MB.",
522
+ inputSchema: {
523
+ type: "object",
524
+ properties: {
525
+ message_id: { type: "number", description: "The message ID from fathom_telegram_read results" },
526
+ },
527
+ required: ["message_id"],
528
+ },
529
+ },
530
+ {
531
+ name: "fathom_telegram_send_image",
532
+ description:
533
+ "Send an image to a Telegram contact via the persistent Telethon client. " +
534
+ "Provide an absolute file path to a local image. Optionally include a caption. " +
535
+ "Contact can be a name, @username, or chat_id number.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ contact: { type: "string", description: "Contact name, @username, or chat_id" },
540
+ file_path: { type: "string", description: "Absolute path to the image file to send" },
541
+ caption: { type: "string", description: "Optional caption text for the image (max 1024 chars)" },
542
+ },
543
+ required: ["contact", "file_path"],
544
+ },
545
+ },
546
+ ];
547
+
384
548
  // --- Server setup & dispatch -------------------------------------------------
385
549
 
386
550
  const server = new Server(
@@ -388,7 +552,19 @@ const server = new Server(
388
552
  { capabilities: { tools: {} } },
389
553
  );
390
554
 
391
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
555
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
556
+ // Telegram tools only visible to the primary (default) workspace
557
+ let isPrimary = false;
558
+ try {
559
+ const settings = await client.getSettings();
560
+ const primaryAgent = settings.default_workspace;
561
+ isPrimary = config.workspace === primaryAgent;
562
+ } catch {
563
+ // If settings unavailable, hide telegram tools
564
+ }
565
+ const allTools = [...tools, ...(isPrimary ? telegramTools : [])];
566
+ return { tools: allTools };
567
+ });
392
568
 
393
569
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
394
570
  const { name, arguments: args } = request.params;
@@ -543,6 +719,118 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
543
719
  result = await client.createRoutine(routineParams, args.workspace || config.workspace);
544
720
  break;
545
721
  }
722
+ case "fathom_routine_list":
723
+ result = await client.listRoutines(args.workspace || config.workspace);
724
+ break;
725
+ case "fathom_routine_update": {
726
+ const updateParams = {};
727
+ if (args.name != null) updateParams.name = args.name;
728
+ if (args.enabled != null) updateParams.enabled = args.enabled;
729
+ if (args.interval_minutes != null) updateParams.intervalMinutes = args.interval_minutes;
730
+ if (args.single_fire != null) updateParams.singleFire = args.single_fire;
731
+ if (args.context_sources != null) updateParams.contextSources = args.context_sources;
732
+ result = await client.updateRoutine(args.routine_id, updateParams, args.workspace || config.workspace);
733
+ break;
734
+ }
735
+ case "fathom_routine_delete":
736
+ result = await client.deleteRoutine(args.routine_id, args.workspace || config.workspace);
737
+ break;
738
+ case "fathom_routine_fire":
739
+ result = await client.fireRoutine(args.routine_id, args.workspace || config.workspace);
740
+ break;
741
+ // --- Telegram ---
742
+ case "fathom_telegram_contacts":
743
+ result = await client.telegramContacts(config.workspace);
744
+ break;
745
+ case "fathom_telegram_read": {
746
+ // Resolve contact name to chat_id via contacts list
747
+ const contacts = await client.telegramContacts(config.workspace);
748
+ const contactList = contacts?.contacts || [];
749
+ const contactArg = (args.contact || "").trim();
750
+ let chatId = parseInt(contactArg, 10);
751
+ if (isNaN(chatId)) {
752
+ const lower = contactArg.toLowerCase().replace(/^@/, "");
753
+ const match = contactList.find(c =>
754
+ (c.username || "").toLowerCase() === lower ||
755
+ (c.first_name || "").toLowerCase() === lower ||
756
+ (c.first_name || "").toLowerCase().includes(lower)
757
+ );
758
+ chatId = match ? match.chat_id : null;
759
+ }
760
+ if (!chatId) {
761
+ result = { error: `Contact not found: ${contactArg}. Use fathom_telegram_contacts to list known contacts.` };
762
+ } else {
763
+ result = await client.telegramRead(
764
+ chatId, args.minutes, args.start,
765
+ args.mark_read !== false ? config.workspace : undefined,
766
+ args.mark_read,
767
+ );
768
+ }
769
+ break;
770
+ }
771
+ case "fathom_telegram_send": {
772
+ const sendContacts = await client.telegramContacts(config.workspace);
773
+ const sendList = sendContacts?.contacts || [];
774
+ const sendArg = (args.contact || "").trim();
775
+ let sendChatId = parseInt(sendArg, 10);
776
+ if (isNaN(sendChatId)) {
777
+ const lower = sendArg.toLowerCase().replace(/^@/, "");
778
+ const match = sendList.find(c =>
779
+ (c.username || "").toLowerCase() === lower ||
780
+ (c.first_name || "").toLowerCase() === lower ||
781
+ (c.first_name || "").toLowerCase().includes(lower)
782
+ );
783
+ sendChatId = match ? match.chat_id : null;
784
+ }
785
+ if (!sendChatId) {
786
+ result = { error: `Contact not found: ${sendArg}. Use fathom_telegram_contacts to list known contacts.` };
787
+ } else {
788
+ result = await client.telegramSend(sendChatId, args.message);
789
+ }
790
+ break;
791
+ }
792
+ case "fathom_telegram_image": {
793
+ const msgId = args.message_id;
794
+ if (!msgId) {
795
+ result = { error: "message_id is required" };
796
+ } else {
797
+ // Check local WebSocket cache first (avoids HTTP round-trip for pushed images)
798
+ const cached = wsConn?.getCachedImage(msgId);
799
+ if (cached) {
800
+ result = { _image: true, data: cached.data, mimeType: cached.mimeType };
801
+ } else {
802
+ result = await client.telegramImage(msgId);
803
+ if (result?.data && result?.mimeType) {
804
+ result = { _image: true, data: result.data, mimeType: result.mimeType };
805
+ }
806
+ }
807
+ }
808
+ break;
809
+ }
810
+ case "fathom_telegram_send_image": {
811
+ const imgContactArg = args.contact;
812
+ if (!imgContactArg) { result = { error: "contact is required" }; break; }
813
+ if (!args.file_path) { result = { error: "file_path is required" }; break; }
814
+
815
+ const imgContacts = await client.telegramContacts(config.workspace);
816
+ const imgList = imgContacts?.contacts || [];
817
+ let imgChatId = parseInt(imgContactArg, 10);
818
+ if (isNaN(imgChatId)) {
819
+ const lower = imgContactArg.toLowerCase().replace(/^@/, "");
820
+ const match = imgList.find(c =>
821
+ (c.username || "").toLowerCase() === lower ||
822
+ (c.first_name || "").toLowerCase() === lower ||
823
+ (c.first_name || "").toLowerCase().includes(lower)
824
+ );
825
+ imgChatId = match ? match.chat_id : null;
826
+ }
827
+ if (!imgChatId) {
828
+ result = { error: `Contact not found: ${imgContactArg}. Use fathom_telegram_contacts to list known contacts.` };
829
+ } else {
830
+ result = await client.telegramSendImage(imgChatId, args.file_path, args.caption);
831
+ }
832
+ break;
833
+ }
546
834
  default:
547
835
  result = { error: `Unknown tool: ${name}` };
548
836
  }
@@ -628,7 +916,12 @@ async function main() {
628
916
  // Startup sync for synced mode (fire-and-forget)
629
917
  startupSync().catch(() => {});
630
918
 
631
- // Heartbeat report liveness to server every 30s
919
+ // WebSocket push channel receives server-pushed messages
920
+ if (config.server && config.workspace && config.apiKey) {
921
+ wsConn = createWSConnection(config);
922
+ }
923
+
924
+ // Heartbeat — report liveness to server every 30s (kept for backwards compat)
632
925
  if (config.server && config.workspace) {
633
926
  const beat = () =>
634
927
  client
@@ -205,6 +205,37 @@ export function createClient(config) {
205
205
  });
206
206
  }
207
207
 
208
+ async function listRoutines(ws) {
209
+ return request("GET", "/api/activation/ping/routines", {
210
+ params: { workspace: ws },
211
+ });
212
+ }
213
+
214
+ async function updateRoutine(routineId, params, ws) {
215
+ const body = {};
216
+ if (params.name != null) body.name = params.name;
217
+ if (params.enabled != null) body.enabled = params.enabled;
218
+ if (params.intervalMinutes != null) body.intervalMinutes = params.intervalMinutes;
219
+ if (params.singleFire != null) body.singleFire = params.singleFire;
220
+ if (params.contextSources != null) body.contextSources = params.contextSources;
221
+ return request("POST", `/api/activation/ping/routines/${encodeURIComponent(routineId)}`, {
222
+ params: { workspace: ws },
223
+ body,
224
+ });
225
+ }
226
+
227
+ async function deleteRoutine(routineId, ws) {
228
+ return request("DELETE", `/api/activation/ping/routines/${encodeURIComponent(routineId)}`, {
229
+ params: { workspace: ws },
230
+ });
231
+ }
232
+
233
+ async function fireRoutine(routineId, ws) {
234
+ return request("POST", `/api/activation/ping/routines/${encodeURIComponent(routineId)}/now`, {
235
+ params: { workspace: ws },
236
+ });
237
+ }
238
+
208
239
  // --- Heartbeat -------------------------------------------------------------
209
240
 
210
241
  async function heartbeat(ws, agent, vaultMode) {
@@ -214,6 +245,46 @@ export function createClient(config) {
214
245
  return request("POST", `/api/workspaces/${encodeURIComponent(ws)}/heartbeat`, { body });
215
246
  }
216
247
 
248
+ // --- Telegram --------------------------------------------------------------
249
+
250
+ async function telegramContacts(ws) {
251
+ return request("GET", "/api/telegram/contacts", {
252
+ params: { workspace: ws },
253
+ });
254
+ }
255
+
256
+ async function telegramRead(chatId, minutes, start, ws, markRead) {
257
+ return request("GET", `/api/telegram/messages/${chatId}`, {
258
+ params: { minutes, start, workspace: ws, mark_read: markRead },
259
+ });
260
+ }
261
+
262
+ async function telegramSend(chatId, message) {
263
+ return request("POST", `/api/telegram/send/${chatId}`, {
264
+ body: { message },
265
+ });
266
+ }
267
+
268
+ async function telegramImage(messageId) {
269
+ return request("GET", `/api/telegram/image/${messageId}`);
270
+ }
271
+
272
+ async function telegramSendImage(chatId, filePath, caption) {
273
+ return request("POST", `/api/telegram/send-image/${chatId}`, {
274
+ body: { file_path: filePath, caption: caption || "" },
275
+ });
276
+ }
277
+
278
+ async function telegramStatus() {
279
+ return request("GET", "/api/telegram/status");
280
+ }
281
+
282
+ // --- Settings --------------------------------------------------------------
283
+
284
+ async function getSettings() {
285
+ return request("GET", "/api/settings");
286
+ }
287
+
217
288
  // --- Auth ------------------------------------------------------------------
218
289
 
219
290
  async function getApiKey() {
@@ -254,7 +325,18 @@ export function createClient(config) {
254
325
  pushFile,
255
326
  syncManifest,
256
327
  createRoutine,
328
+ listRoutines,
329
+ updateRoutine,
330
+ deleteRoutine,
331
+ fireRoutine,
257
332
  heartbeat,
333
+ telegramContacts,
334
+ telegramRead,
335
+ telegramSend,
336
+ telegramImage,
337
+ telegramSendImage,
338
+ telegramStatus,
339
+ getSettings,
258
340
  getApiKey,
259
341
  healthCheck,
260
342
  };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * WebSocket push channel — receives server-pushed messages and handles them locally.
3
+ *
4
+ * Connects to fathom-server's /ws/agent/{workspace} endpoint. Receives:
5
+ * - inject / ping_fire → tmux send-keys into local pane
6
+ * - image → cache base64 data to .fathom/telegram-cache/
7
+ * - ping → respond with pong
8
+ *
9
+ * Auto-reconnects with exponential backoff (1s → 60s cap).
10
+ * HTTP heartbeat still runs separately for backwards compat with old servers.
11
+ */
12
+
13
+ import { execSync } from "child_process";
14
+ import fs from "fs";
15
+ import os from "os";
16
+ import path from "path";
17
+ import WebSocket from "ws";
18
+
19
+ const KEEPALIVE_INTERVAL_MS = 30_000;
20
+ const INITIAL_RECONNECT_MS = 1_000;
21
+ const MAX_RECONNECT_MS = 60_000;
22
+ const IMAGE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
23
+
24
+ /**
25
+ * @param {object} config — resolved config from config.js
26
+ * @returns {{ getCachedImage: (messageId: number) => {data: string, mimeType: string} | null, close: () => void }}
27
+ */
28
+ export function createWSConnection(config) {
29
+ const workspace = config.workspace;
30
+ const agent = config.agents?.[0] || "unknown";
31
+ const vaultMode = config.vaultMode || "local";
32
+
33
+ // Derive WS URL from HTTP server URL
34
+ const serverUrl = config.server || "http://localhost:4243";
35
+ const wsUrl = serverUrl
36
+ .replace(/^http:/, "ws:")
37
+ .replace(/^https:/, "wss:")
38
+ + `/ws/agent/${encodeURIComponent(workspace)}`
39
+ + `?token=${encodeURIComponent(config.apiKey || "")}`;
40
+
41
+ // Image cache directory
42
+ const cacheDir = path.join(os.homedir(), ".fathom", "telegram-cache");
43
+
44
+ let ws = null;
45
+ let reconnectDelay = INITIAL_RECONNECT_MS;
46
+ let keepaliveTimer = null;
47
+ let closed = false;
48
+
49
+ // Clean up old cached images on startup
50
+ cleanupImageCache();
51
+
52
+ connect();
53
+
54
+ function connect() {
55
+ if (closed) return;
56
+
57
+ try {
58
+ ws = new WebSocket(wsUrl);
59
+ } catch {
60
+ scheduleReconnect();
61
+ return;
62
+ }
63
+
64
+ ws.on("open", () => {
65
+ reconnectDelay = INITIAL_RECONNECT_MS;
66
+
67
+ // Send hello handshake
68
+ ws.send(JSON.stringify({
69
+ type: "hello",
70
+ agent,
71
+ vault_mode: vaultMode,
72
+ }));
73
+
74
+ // Start keepalive pong timer
75
+ startKeepalive();
76
+ });
77
+
78
+ ws.on("message", (raw) => {
79
+ let msg;
80
+ try {
81
+ msg = JSON.parse(raw.toString());
82
+ } catch {
83
+ return;
84
+ }
85
+
86
+ switch (msg.type) {
87
+ case "welcome":
88
+ break;
89
+
90
+ case "inject":
91
+ case "ping_fire":
92
+ injectToTmux(msg.text || "");
93
+ break;
94
+
95
+ case "image":
96
+ cacheImage(msg);
97
+ break;
98
+
99
+ case "ping":
100
+ safeSend({ type: "pong" });
101
+ break;
102
+
103
+ case "error":
104
+ // Server rejected us — don't reconnect immediately
105
+ reconnectDelay = MAX_RECONNECT_MS;
106
+ break;
107
+ }
108
+ });
109
+
110
+ ws.on("close", () => {
111
+ stopKeepalive();
112
+ if (!closed) scheduleReconnect();
113
+ });
114
+
115
+ ws.on("error", () => {
116
+ // Error always followed by close event — reconnect handled there
117
+ stopKeepalive();
118
+ });
119
+ }
120
+
121
+ function safeSend(obj) {
122
+ try {
123
+ if (ws && ws.readyState === WebSocket.OPEN) {
124
+ ws.send(JSON.stringify(obj));
125
+ }
126
+ } catch {
127
+ // Swallow — close event will trigger reconnect
128
+ }
129
+ }
130
+
131
+ function startKeepalive() {
132
+ stopKeepalive();
133
+ keepaliveTimer = setInterval(() => {
134
+ safeSend({ type: "pong" });
135
+ }, KEEPALIVE_INTERVAL_MS);
136
+ }
137
+
138
+ function stopKeepalive() {
139
+ if (keepaliveTimer) {
140
+ clearInterval(keepaliveTimer);
141
+ keepaliveTimer = null;
142
+ }
143
+ }
144
+
145
+ function scheduleReconnect() {
146
+ if (closed) return;
147
+ setTimeout(connect, reconnectDelay);
148
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_MS);
149
+ }
150
+
151
+ // ── tmux injection ──────────────────────────────────────────────────────────
152
+
153
+ function injectToTmux(text) {
154
+ if (!text) return;
155
+ const pane = resolvePaneTarget();
156
+
157
+ try {
158
+ // Send the text literally, then press Enter
159
+ execSync(`tmux send-keys -t ${shellEscape(pane)} -l ${shellEscape(text)}`, {
160
+ timeout: 5000,
161
+ stdio: "ignore",
162
+ });
163
+ execSync(`tmux send-keys -t ${shellEscape(pane)} Enter`, {
164
+ timeout: 5000,
165
+ stdio: "ignore",
166
+ });
167
+ } catch {
168
+ // tmux not available or pane not found — non-fatal
169
+ }
170
+ }
171
+
172
+ function resolvePaneTarget() {
173
+ // Check for explicit pane ID file
174
+ const paneIdFile = path.join(os.homedir(), ".config", "fathom", `${workspace}-pane-id`);
175
+ try {
176
+ const paneId = fs.readFileSync(paneIdFile, "utf-8").trim();
177
+ if (paneId) return paneId;
178
+ } catch {
179
+ // Fall through to default
180
+ }
181
+ return `${workspace}_fathom-session`;
182
+ }
183
+
184
+ function shellEscape(s) {
185
+ // Escape for shell — wrap in single quotes, escape internal single quotes
186
+ return "'" + s.replace(/'/g, "'\\''") + "'";
187
+ }
188
+
189
+ // ── Image cache ─────────────────────────────────────────────────────────────
190
+
191
+ function cacheImage(msg) {
192
+ if (!msg.message_id || !msg.data) return;
193
+
194
+ try {
195
+ fs.mkdirSync(cacheDir, { recursive: true });
196
+ const ext = (msg.filename || "").split(".").pop() || "jpg";
197
+ const filename = `${msg.message_id}.${ext}`;
198
+ const filePath = path.join(cacheDir, filename);
199
+ fs.writeFileSync(filePath, Buffer.from(msg.data, "base64"));
200
+ } catch {
201
+ // Cache write failure is non-fatal
202
+ }
203
+ }
204
+
205
+ function getCachedImage(messageId) {
206
+ try {
207
+ const dir = fs.readdirSync(cacheDir);
208
+ const match = dir.find(f => f.startsWith(`${messageId}.`));
209
+ if (!match) return null;
210
+
211
+ const filePath = path.join(cacheDir, match);
212
+ const data = fs.readFileSync(filePath);
213
+ const ext = path.extname(match).slice(1).toLowerCase();
214
+ const mimeMap = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
215
+ return {
216
+ data: data.toString("base64"),
217
+ mimeType: mimeMap[ext] || "image/jpeg",
218
+ };
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function cleanupImageCache() {
225
+ try {
226
+ if (!fs.existsSync(cacheDir)) return;
227
+ const now = Date.now();
228
+ for (const file of fs.readdirSync(cacheDir)) {
229
+ const filePath = path.join(cacheDir, file);
230
+ const stat = fs.statSync(filePath);
231
+ if (now - stat.mtimeMs > IMAGE_CACHE_MAX_AGE_MS) {
232
+ fs.unlinkSync(filePath);
233
+ }
234
+ }
235
+ } catch {
236
+ // Cleanup failure is non-fatal
237
+ }
238
+ }
239
+
240
+ function close() {
241
+ closed = true;
242
+ stopKeepalive();
243
+ if (ws) {
244
+ try { ws.close(); } catch { /* ignore */ }
245
+ ws = null;
246
+ }
247
+ }
248
+
249
+ return { getCachedImage, close };
250
+ }