forge-remote 0.1.21 → 0.1.22

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": "forge-remote",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/cli.js CHANGED
@@ -140,7 +140,12 @@ program
140
140
  // Clean up any orphaned sessions from a previous relay run.
141
141
  await cleanupOrphanedSessions(desktopId);
142
142
 
143
- const heartbeat = startHeartbeat(desktopId);
143
+ const heartbeat = startHeartbeat(desktopId, {
144
+ onConnectionLost: () => {
145
+ log.info("Re-establishing Firestore listeners...");
146
+ listenForCommands(desktopId);
147
+ },
148
+ });
144
149
 
145
150
  listenForCommands(desktopId);
146
151
 
package/src/desktop.js CHANGED
@@ -54,17 +54,45 @@ export async function markOffline(desktopId) {
54
54
 
55
55
  /**
56
56
  * Start a heartbeat interval that updates lastHeartbeat every 30s.
57
+ * Also monitors Firestore connectivity — if heartbeats fail consecutively,
58
+ * it signals the relay to restart listeners.
57
59
  */
58
- export function startHeartbeat(desktopId) {
60
+ export function startHeartbeat(desktopId, { onConnectionLost } = {}) {
61
+ let consecutiveFailures = 0;
62
+ const MAX_FAILURES = 5; // ~2.5 minutes of failures before action
63
+
59
64
  const interval = setInterval(async () => {
60
65
  try {
61
66
  const db = getDb();
62
- await db.collection("desktops").doc(desktopId).update({
67
+
68
+ // Use a timeout to detect hung Firestore connections (e.g., stale TCP).
69
+ const heartbeatPromise = db.collection("desktops").doc(desktopId).update({
63
70
  lastHeartbeat: FieldValue.serverTimestamp(),
64
71
  });
72
+ const timeoutPromise = new Promise((_, reject) =>
73
+ setTimeout(
74
+ () => reject(new Error("Heartbeat timed out (15s)")),
75
+ 15_000,
76
+ ),
77
+ );
78
+ await Promise.race([heartbeatPromise, timeoutPromise]);
79
+
80
+ if (consecutiveFailures > 0) {
81
+ log.info(`Heartbeat recovered after ${consecutiveFailures} failure(s)`);
82
+ }
83
+ consecutiveFailures = 0;
65
84
  log.heartbeat();
66
85
  } catch (e) {
67
- log.error(`Heartbeat failed: ${e.message}`);
86
+ consecutiveFailures++;
87
+ log.error(
88
+ `Heartbeat failed (${consecutiveFailures}/${MAX_FAILURES}): ${e.message}`,
89
+ );
90
+
91
+ if (consecutiveFailures >= MAX_FAILURES && onConnectionLost) {
92
+ log.error("Firestore connection appears dead — triggering reconnect");
93
+ consecutiveFailures = 0; // Reset so it doesn't fire again immediately.
94
+ onConnectionLost();
95
+ }
68
96
  }
69
97
  }, 30_000);
70
98
 
@@ -224,11 +224,26 @@ function isRateLimited() {
224
224
  // Command listeners
225
225
  // ---------------------------------------------------------------------------
226
226
 
227
+ // Track active listener unsubscribe functions so we can tear them down
228
+ // before re-subscribing (e.g., after a Firestore reconnect).
229
+ let activeListenerUnsubs = [];
230
+
227
231
  export function listenForCommands(desktopId) {
232
+ // Clean up previous listeners to avoid duplicate command handling.
233
+ for (const unsub of activeListenerUnsubs) {
234
+ try {
235
+ unsub();
236
+ } catch {
237
+ /* already dead */
238
+ }
239
+ }
240
+ activeListenerUnsubs = [];
241
+
228
242
  const db = getDb();
229
243
 
230
244
  // Desktop-level commands (start_session, etc.).
231
- db.collection("desktops")
245
+ const unsub1 = db
246
+ .collection("desktops")
232
247
  .doc(desktopId)
233
248
  .collection("commands")
234
249
  .where("status", "==", "pending")
@@ -239,15 +254,18 @@ export function listenForCommands(desktopId) {
239
254
  }
240
255
  }
241
256
  });
257
+ activeListenerUnsubs.push(unsub1);
242
258
 
243
259
  // Watch for existing sessions → subscribe to their commands.
244
- db.collection("sessions")
260
+ const unsub2 = db
261
+ .collection("sessions")
245
262
  .where("desktopId", "==", desktopId)
246
263
  .onSnapshot((snap) => {
247
264
  for (const doc of snap.docs) {
248
265
  watchSessionCommands(doc.id);
249
266
  }
250
267
  });
268
+ activeListenerUnsubs.push(unsub2);
251
269
  }
252
270
 
253
271
  // ---------------------------------------------------------------------------