fathom-mcp 0.4.13 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/scripts/fathom-start.sh +220 -0
- package/src/cli.js +35 -1
- package/src/index.js +80 -1
- package/src/server-client.js +12 -0
- package/src/ws-connection.js +250 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fathom-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# fathom-start.sh — Launch an agent in a correctly-named tmux session.
|
|
4
|
+
#
|
|
5
|
+
# Reads .fathom.json for workspace name and agent, creates
|
|
6
|
+
# {workspace}_fathom-session, saves pane ID for WebSocket push injection.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# fathom-start.sh Start agent, save pane ID, attach
|
|
10
|
+
# fathom-start.sh --detach Start agent, save pane ID, don't attach
|
|
11
|
+
# fathom-start.sh --agent X Override agent (claude-code|codex|gemini|opencode)
|
|
12
|
+
# fathom-start.sh --kill Kill existing session
|
|
13
|
+
# fathom-start.sh --status Show session status
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
# ── Defaults ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
ATTACH=true
|
|
20
|
+
AGENT_OVERRIDE=""
|
|
21
|
+
ACTION="start"
|
|
22
|
+
|
|
23
|
+
# ── Parse flags ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
while [[ $# -gt 0 ]]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--detach) ATTACH=false; shift ;;
|
|
28
|
+
--attach) ATTACH=true; shift ;;
|
|
29
|
+
--agent) AGENT_OVERRIDE="$2"; shift 2 ;;
|
|
30
|
+
--kill) ACTION="kill"; shift ;;
|
|
31
|
+
--status) ACTION="status"; shift ;;
|
|
32
|
+
-h|--help)
|
|
33
|
+
echo "Usage: fathom-start.sh [--attach] [--detach] [--agent NAME] [--kill] [--status]"
|
|
34
|
+
echo ""
|
|
35
|
+
echo " (default) Start agent in tmux, save pane ID, attach"
|
|
36
|
+
echo " --detach Start but don't attach"
|
|
37
|
+
echo " --agent X Override agent: claude-code, codex, gemini, opencode"
|
|
38
|
+
echo " --kill Kill existing session"
|
|
39
|
+
echo " --status Show if session is running"
|
|
40
|
+
exit 0
|
|
41
|
+
;;
|
|
42
|
+
*)
|
|
43
|
+
echo "Unknown flag: $1" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
;;
|
|
46
|
+
esac
|
|
47
|
+
done
|
|
48
|
+
|
|
49
|
+
# ── Find .fathom.json ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
find_config() {
|
|
52
|
+
local dir="$PWD"
|
|
53
|
+
while [[ "$dir" != "/" ]]; do
|
|
54
|
+
if [[ -f "$dir/.fathom.json" ]]; then
|
|
55
|
+
echo "$dir/.fathom.json"
|
|
56
|
+
return 0
|
|
57
|
+
fi
|
|
58
|
+
dir="$(dirname "$dir")"
|
|
59
|
+
done
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
CONFIG_FILE=$(find_config) || {
|
|
64
|
+
echo "Error: No .fathom.json found (searched from $PWD to /)" >&2
|
|
65
|
+
echo "Run 'npx fathom-mcp init' first." >&2
|
|
66
|
+
exit 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
PROJECT_DIR="$(dirname "$CONFIG_FILE")"
|
|
70
|
+
|
|
71
|
+
# ── Parse config ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
read_json_field() {
|
|
74
|
+
local file="$1" field="$2"
|
|
75
|
+
if command -v jq &>/dev/null; then
|
|
76
|
+
jq -r ".$field // empty" "$file" 2>/dev/null
|
|
77
|
+
else
|
|
78
|
+
# Fallback: simple grep/sed for flat string fields
|
|
79
|
+
sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" | head -1
|
|
80
|
+
fi
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
read_json_array_first() {
|
|
84
|
+
local file="$1" field="$2"
|
|
85
|
+
if command -v jq &>/dev/null; then
|
|
86
|
+
jq -r ".$field[0] // empty" "$file" 2>/dev/null
|
|
87
|
+
else
|
|
88
|
+
# Fallback: grab first quoted string after the array field
|
|
89
|
+
sed -n "/\"$field\"/,/\]/{ s/.*\"\([^\"]*\)\".*/\1/p; }" "$file" | head -1
|
|
90
|
+
fi
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
WORKSPACE=$(read_json_field "$CONFIG_FILE" "workspace")
|
|
94
|
+
if [[ -z "$WORKSPACE" ]]; then
|
|
95
|
+
WORKSPACE="$(basename "$PROJECT_DIR")"
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
SESSION="${WORKSPACE}_fathom-session"
|
|
99
|
+
PANE_DIR="$HOME/.config/fathom"
|
|
100
|
+
PANE_FILE="$PANE_DIR/${WORKSPACE}-pane-id"
|
|
101
|
+
|
|
102
|
+
# ── Resolve agent command ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
resolve_agent_cmd() {
|
|
105
|
+
local agent="${AGENT_OVERRIDE:-}"
|
|
106
|
+
if [[ -z "$agent" ]]; then
|
|
107
|
+
agent=$(read_json_array_first "$CONFIG_FILE" "agents")
|
|
108
|
+
fi
|
|
109
|
+
if [[ -z "$agent" ]]; then
|
|
110
|
+
agent="claude-code"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
case "$agent" in
|
|
114
|
+
claude-code)
|
|
115
|
+
echo "claude --model opus --permission-mode bypassPermissions"
|
|
116
|
+
;;
|
|
117
|
+
codex)
|
|
118
|
+
echo "codex"
|
|
119
|
+
;;
|
|
120
|
+
gemini)
|
|
121
|
+
echo "gemini"
|
|
122
|
+
;;
|
|
123
|
+
opencode)
|
|
124
|
+
echo "opencode"
|
|
125
|
+
;;
|
|
126
|
+
*)
|
|
127
|
+
echo "Warning: Unknown agent '$agent', falling back to claude" >&2
|
|
128
|
+
echo "claude"
|
|
129
|
+
;;
|
|
130
|
+
esac
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# ── Save pane ID ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
save_pane_id() {
|
|
136
|
+
local pane_id
|
|
137
|
+
pane_id=$(tmux list-panes -t "$SESSION" -F '#{pane_id}' 2>/dev/null | head -1)
|
|
138
|
+
if [[ -n "$pane_id" ]]; then
|
|
139
|
+
mkdir -p "$PANE_DIR"
|
|
140
|
+
echo "$pane_id" > "$PANE_FILE"
|
|
141
|
+
echo "Pane ID: $pane_id → $PANE_FILE"
|
|
142
|
+
fi
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# ── Session check ─────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
session_exists() {
|
|
148
|
+
tmux has-session -t "$SESSION" 2>/dev/null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# ── Actions ───────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
do_status() {
|
|
154
|
+
echo "Workspace: $WORKSPACE"
|
|
155
|
+
echo "Session: $SESSION"
|
|
156
|
+
if session_exists; then
|
|
157
|
+
echo "Status: running"
|
|
158
|
+
if [[ -f "$PANE_FILE" ]]; then
|
|
159
|
+
echo "Pane ID: $(cat "$PANE_FILE")"
|
|
160
|
+
fi
|
|
161
|
+
else
|
|
162
|
+
echo "Status: not running"
|
|
163
|
+
fi
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
do_kill() {
|
|
167
|
+
if session_exists; then
|
|
168
|
+
tmux kill-session -t "$SESSION"
|
|
169
|
+
rm -f "$PANE_FILE"
|
|
170
|
+
echo "Killed session: $SESSION"
|
|
171
|
+
else
|
|
172
|
+
echo "Session not running: $SESSION"
|
|
173
|
+
fi
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
do_start() {
|
|
177
|
+
if session_exists; then
|
|
178
|
+
echo "Session already running: $SESSION"
|
|
179
|
+
save_pane_id
|
|
180
|
+
if [[ "$ATTACH" == true ]]; then
|
|
181
|
+
exec tmux attach-session -t "$SESSION"
|
|
182
|
+
fi
|
|
183
|
+
return 0
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
local agent_cmd
|
|
187
|
+
agent_cmd=$(resolve_agent_cmd)
|
|
188
|
+
|
|
189
|
+
echo "Starting: $SESSION"
|
|
190
|
+
echo "Agent: $agent_cmd"
|
|
191
|
+
echo "Dir: $PROJECT_DIR"
|
|
192
|
+
|
|
193
|
+
# Unset CLAUDECODE to avoid nested session detection
|
|
194
|
+
unset CLAUDECODE 2>/dev/null || true
|
|
195
|
+
|
|
196
|
+
# Create detached tmux session running the agent
|
|
197
|
+
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR" $agent_cmd
|
|
198
|
+
|
|
199
|
+
# Wait briefly for session to stabilize
|
|
200
|
+
sleep 2
|
|
201
|
+
|
|
202
|
+
if session_exists; then
|
|
203
|
+
save_pane_id
|
|
204
|
+
echo "Session started."
|
|
205
|
+
if [[ "$ATTACH" == true ]]; then
|
|
206
|
+
exec tmux attach-session -t "$SESSION"
|
|
207
|
+
fi
|
|
208
|
+
else
|
|
209
|
+
echo "Error: Session failed to start" >&2
|
|
210
|
+
exit 1
|
|
211
|
+
fi
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
case "$ACTION" in
|
|
217
|
+
status) do_status ;;
|
|
218
|
+
kill) do_kill ;;
|
|
219
|
+
start) do_start ;;
|
|
220
|
+
esac
|
package/src/cli.js
CHANGED
|
@@ -845,6 +845,35 @@ async function runUpdate() {
|
|
|
845
845
|
console.log(" Restart your agent session to pick up changes.\n");
|
|
846
846
|
}
|
|
847
847
|
|
|
848
|
+
// --- Start command -----------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
function runStart(argv) {
|
|
851
|
+
// Find the installed fathom-start.sh script
|
|
852
|
+
const found = findConfigFile(process.cwd());
|
|
853
|
+
const projectDir = found?.dir || process.cwd();
|
|
854
|
+
|
|
855
|
+
// Check .fathom/scripts/ first (installed by init/update), then package scripts/
|
|
856
|
+
const localScript = path.join(projectDir, ".fathom", "scripts", "fathom-start.sh");
|
|
857
|
+
const packageScript = path.join(SCRIPTS_DIR, "fathom-start.sh");
|
|
858
|
+
const script = fs.existsSync(localScript) ? localScript : packageScript;
|
|
859
|
+
|
|
860
|
+
if (!fs.existsSync(script)) {
|
|
861
|
+
console.error(" Error: fathom-start.sh not found. Run `npx fathom-mcp update` first.");
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Pass remaining args through to the shell script
|
|
866
|
+
try {
|
|
867
|
+
execFileSync("bash", [script, ...argv], {
|
|
868
|
+
cwd: projectDir,
|
|
869
|
+
stdio: "inherit",
|
|
870
|
+
});
|
|
871
|
+
} catch (e) {
|
|
872
|
+
// Script already printed its own errors; just propagate exit code
|
|
873
|
+
process.exit(e.status || 1);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
848
877
|
// --- Main --------------------------------------------------------------------
|
|
849
878
|
|
|
850
879
|
// Guard: only run CLI when this module is the entry point (not when imported by tests)
|
|
@@ -871,12 +900,14 @@ if (isMain) {
|
|
|
871
900
|
console.error(`Error: ${e.message}`);
|
|
872
901
|
process.exit(1);
|
|
873
902
|
});
|
|
903
|
+
} else if (command === "start") {
|
|
904
|
+
runStart(process.argv.slice(3));
|
|
874
905
|
} else if (!command || command === "serve") {
|
|
875
906
|
// Default: start MCP server
|
|
876
907
|
import("./index.js");
|
|
877
908
|
} else {
|
|
878
909
|
console.error(`Unknown command: ${command}`);
|
|
879
|
-
console.error(`Usage: fathom-mcp [init|status|update|serve]
|
|
910
|
+
console.error(`Usage: fathom-mcp [init|status|update|start|serve]
|
|
880
911
|
|
|
881
912
|
fathom-mcp init Interactive setup
|
|
882
913
|
fathom-mcp init -y --api-key KEY Non-interactive setup
|
|
@@ -884,6 +915,9 @@ if (isMain) {
|
|
|
884
915
|
fathom-mcp init -y --api-key KEY --workspace NAME Custom workspace name
|
|
885
916
|
fathom-mcp status Check connection status
|
|
886
917
|
fathom-mcp update Update hooks + version
|
|
918
|
+
fathom-mcp start Start agent in tmux session
|
|
919
|
+
fathom-mcp start --detach Start without attaching
|
|
920
|
+
fathom-mcp start --kill Kill agent session
|
|
887
921
|
fathom-mcp Start MCP server`);
|
|
888
922
|
process.exit(1);
|
|
889
923
|
}
|
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
|
|
|
@@ -511,6 +513,36 @@ const telegramTools = [
|
|
|
511
513
|
required: ["contact", "message"],
|
|
512
514
|
},
|
|
513
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
|
+
},
|
|
514
546
|
];
|
|
515
547
|
|
|
516
548
|
// --- Server setup & dispatch -------------------------------------------------
|
|
@@ -757,6 +789,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
757
789
|
}
|
|
758
790
|
break;
|
|
759
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
|
+
}
|
|
760
834
|
default:
|
|
761
835
|
result = { error: `Unknown tool: ${name}` };
|
|
762
836
|
}
|
|
@@ -842,7 +916,12 @@ async function main() {
|
|
|
842
916
|
// Startup sync for synced mode (fire-and-forget)
|
|
843
917
|
startupSync().catch(() => {});
|
|
844
918
|
|
|
845
|
-
//
|
|
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)
|
|
846
925
|
if (config.server && config.workspace) {
|
|
847
926
|
const beat = () =>
|
|
848
927
|
client
|
package/src/server-client.js
CHANGED
|
@@ -265,6 +265,16 @@ export function createClient(config) {
|
|
|
265
265
|
});
|
|
266
266
|
}
|
|
267
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
|
+
|
|
268
278
|
async function telegramStatus() {
|
|
269
279
|
return request("GET", "/api/telegram/status");
|
|
270
280
|
}
|
|
@@ -323,6 +333,8 @@ export function createClient(config) {
|
|
|
323
333
|
telegramContacts,
|
|
324
334
|
telegramRead,
|
|
325
335
|
telegramSend,
|
|
336
|
+
telegramImage,
|
|
337
|
+
telegramSendImage,
|
|
326
338
|
telegramStatus,
|
|
327
339
|
getSettings,
|
|
328
340
|
getApiKey,
|
|
@@ -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
|
+
}
|