@firstperson/firstperson 2026.1.33 → 2026.1.34

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.1.34
4
+
5
+ - Improve relay connection stability for production use:
6
+ - Add client-side heartbeat ping every 30s to keep connections alive and detect dead connections faster
7
+ - Reset reconnect backoff counter after 60s of stable connection
8
+ - Remove max reconnect attempt limit - gateway will always keep trying to reconnect
9
+
3
10
  ## 2026.1.33
4
11
 
5
12
  - Fix npm package name to `@firstperson/firstperson` (must match plugin ID)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstperson/firstperson",
3
- "version": "2026.1.33",
3
+ "version": "2026.1.34",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for the First Person iOS app",
6
6
  "main": "index.ts",
@@ -142,9 +142,23 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
142
142
 
143
143
  let ws: WebSocket | null = null;
144
144
  let reconnectAttempts = 0;
145
- const maxReconnectAttempts = 10;
146
145
  const baseReconnectDelay = 1000;
147
146
 
147
+ // Timers for heartbeat and stability tracking
148
+ let pingInterval: ReturnType<typeof setInterval> | null = null;
149
+ let stabilityTimer: ReturnType<typeof setTimeout> | null = null;
150
+
151
+ const clearTimers = () => {
152
+ if (pingInterval) {
153
+ clearInterval(pingInterval);
154
+ pingInterval = null;
155
+ }
156
+ if (stabilityTimer) {
157
+ clearTimeout(stabilityTimer);
158
+ stabilityTimer = null;
159
+ }
160
+ };
161
+
148
162
  const connect = () => {
149
163
  if (abortSignal.aborted) {
150
164
  log("info", "[relay-client] connect() called but abortSignal already aborted");
@@ -156,8 +170,27 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
156
170
 
157
171
  ws.on("open", () => {
158
172
  log("info", `[relay-client] WebSocket opened, readyState: ${ws?.readyState}`);
159
- reconnectAttempts = 0;
160
173
  activeWs = ws; // Store as active connection for sending
174
+
175
+ // Clear any existing timers from previous connection attempts
176
+ clearTimers();
177
+
178
+ // Start client-side heartbeat: send ping every 30s to keep connection alive
179
+ pingInterval = setInterval(() => {
180
+ if (ws?.readyState === WebSocket.OPEN) {
181
+ log("info", "[relay-client] Sending heartbeat ping");
182
+ ws.send(JSON.stringify({ type: "ping" }));
183
+ }
184
+ }, 30000);
185
+
186
+ // Reset reconnect counter after connection is stable for 60s
187
+ stabilityTimer = setTimeout(() => {
188
+ if (ws?.readyState === WebSocket.OPEN && reconnectAttempts > 0) {
189
+ log("info", `[relay-client] Connection stable for 60s, resetting reconnect counter (was ${reconnectAttempts})`);
190
+ reconnectAttempts = 0;
191
+ }
192
+ }, 60000);
193
+
161
194
  onConnected();
162
195
  });
163
196
 
@@ -172,6 +205,11 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
172
205
  return;
173
206
  }
174
207
 
208
+ if (msg.type === "pong") {
209
+ // Server acknowledged our heartbeat ping - connection is healthy
210
+ return;
211
+ }
212
+
175
213
  // Handle send acknowledgments for replies sent through this connection
176
214
  if ((msg.type === "message_sent" || msg.type === "ack") && msg.messageId) {
177
215
  const pending = pendingReplies.get(msg.messageId);
@@ -213,6 +251,9 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
213
251
  );
214
252
  log("info", `[relay-client] abortSignal.aborted: ${abortSignal.aborted}`);
215
253
 
254
+ // Clear timers on disconnect
255
+ clearTimers();
256
+
216
257
  // Clear active connection and reject pending replies
217
258
  if (activeWs === ws) {
218
259
  activeWs = null;
@@ -228,19 +269,15 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
228
269
  return;
229
270
  }
230
271
 
272
+ // Always reconnect - never give up (exponential backoff capped at 30s)
231
273
  reconnectAttempts++;
232
- if (reconnectAttempts <= maxReconnectAttempts) {
233
- const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000);
234
- log(
235
- "info",
236
- `[relay-client] Reconnecting in ${delay}ms (attempt ${reconnectAttempts} of ${maxReconnectAttempts})`
237
- );
238
- setTimeout(connect, delay);
239
- onDisconnected(new Error(`Connection closed, reconnecting in ${delay}ms...`));
240
- } else {
241
- log("warn", "[relay-client] Max reconnection attempts reached");
242
- onDisconnected(new Error("Max reconnection attempts reached"));
243
- }
274
+ const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000);
275
+ log(
276
+ "info",
277
+ `[relay-client] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`
278
+ );
279
+ setTimeout(connect, delay);
280
+ onDisconnected(new Error(`Connection closed, reconnecting in ${delay}ms...`));
244
281
  });
245
282
 
246
283
  ws.on("error", (err) => {
@@ -252,6 +289,7 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
252
289
  // Handle abort signal
253
290
  abortSignal.addEventListener("abort", () => {
254
291
  log("info", "[relay-client] Abort signal received, closing WebSocket");
292
+ clearTimers();
255
293
  if (activeWs === ws) {
256
294
  activeWs = null;
257
295
  activeLogger = null;