@firstperson/firstperson 2026.1.33 → 2026.1.35

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.35",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for the First Person iOS app",
6
6
  "main": "index.ts",
@@ -19,7 +19,9 @@
19
19
  "ai-assistant"
20
20
  ],
21
21
  "openclaw": {
22
- "extensions": ["./index.ts"],
22
+ "extensions": [
23
+ "./index.ts"
24
+ ],
23
25
  "channel": {
24
26
  "id": "firstperson",
25
27
  "label": "First Person",
package/src/channel.ts CHANGED
@@ -294,7 +294,7 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
294
294
  lastError: null,
295
295
  });
296
296
 
297
- const { startRelayConnection } = await import("./relay-client.js");
297
+ const { startRelayConnection, sendStatusUpdate } = await import("./relay-client.js");
298
298
 
299
299
  return startRelayConnection({
300
300
  relayUrl: account.relayUrl,
@@ -325,6 +325,9 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
325
325
  onMessage: async (message) => {
326
326
  log?.info(`[fp] 1. received: ${message.messageId} from ${message.deviceId}`);
327
327
 
328
+ // Track timing for status updates
329
+ const dispatchStartTime = Date.now();
330
+
328
331
  try {
329
332
  const globalRuntime = getFirstPersonRuntime();
330
333
  const channelApi = (globalRuntime as any).channel;
@@ -417,6 +420,13 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
417
420
 
418
421
  if (!route) {
419
422
  log?.warn(`[fp] no route - check bindings for ${message.deviceId}`);
423
+ // Send error status to client
424
+ sendStatusUpdate({
425
+ to: message.deviceId,
426
+ messageId: message.messageId,
427
+ status: "failed",
428
+ message: "No agent route configured for this device",
429
+ });
420
430
  return;
421
431
  }
422
432
 
@@ -477,6 +487,17 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
477
487
  });
478
488
  log?.info(`[fp] 8. session recorded, starting dispatch...`);
479
489
 
490
+ // ========== SEND "PROCESSING" STATUS ==========
491
+ sendStatusUpdate({
492
+ to: message.deviceId,
493
+ messageId: message.messageId,
494
+ status: "processing",
495
+ message: "Your message is being processed",
496
+ details: {
497
+ startedAt: Date.now(),
498
+ },
499
+ });
500
+
480
501
  // Create dispatcher for replies
481
502
  const { sendTextMessage } = await import("./relay-client.js");
482
503
  const { dispatcher, replyOptions } = channelApi.reply.createReplyDispatcherWithTyping({
@@ -493,23 +514,83 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
493
514
  },
494
515
  onError: (err: Error) => {
495
516
  log?.error(`[fp] reply delivery failed: ${err.message}`);
517
+ // Send error status to client
518
+ sendStatusUpdate({
519
+ to: message.deviceId,
520
+ messageId: message.messageId,
521
+ status: "failed",
522
+ message: `Failed to deliver reply: ${err.message}`,
523
+ });
496
524
  },
497
525
  });
498
526
 
499
- // Dispatch to agent
500
- await channelApi.reply.dispatchReplyFromConfig({
501
- ctx: ctxPayload,
502
- cfg,
503
- dispatcher,
504
- replyOptions,
505
- });
506
- log?.info(`[fp] 11. dispatch complete`);
527
+ // ========== DISPATCH WITH ENHANCED REPLY OPTIONS ==========
528
+ // Add callbacks to track agent progress
529
+ const enhancedReplyOptions = {
530
+ ...replyOptions,
531
+ onAgentRunStart: (runId: string) => {
532
+ log?.info(`[fp] agent run started: ${runId}`);
533
+ },
534
+ onToolResult: (payload: { name?: string }) => {
535
+ log?.info(`[fp] tool executed: ${payload.name}`);
536
+ },
537
+ };
538
+
539
+ try {
540
+ // Dispatch to agent
541
+ await channelApi.reply.dispatchReplyFromConfig({
542
+ ctx: ctxPayload,
543
+ cfg,
544
+ dispatcher,
545
+ replyOptions: enhancedReplyOptions,
546
+ });
547
+
548
+ const dispatchDuration = Date.now() - dispatchStartTime;
549
+ log?.info(`[fp] 11. dispatch complete (took ${dispatchDuration}ms)`);
550
+
551
+ // Send completion status
552
+ sendStatusUpdate({
553
+ to: message.deviceId,
554
+ messageId: message.messageId,
555
+ status: "completed",
556
+ message: "Response delivered",
557
+ details: {
558
+ durationMs: dispatchDuration,
559
+ },
560
+ });
561
+ } catch (dispatchError) {
562
+ const err = dispatchError as Error;
563
+ log?.error(`[fp] dispatch failed: ${err.message}`);
564
+
565
+ // Send error status to client
566
+ sendStatusUpdate({
567
+ to: message.deviceId,
568
+ messageId: message.messageId,
569
+ status: "failed",
570
+ message: err.message.includes("timed out")
571
+ ? "Request timed out. The AI is taking longer than expected."
572
+ : `Error: ${err.message}`,
573
+ details: {
574
+ errorType: err.message.includes("timed out") ? "timeout" : "error",
575
+ durationMs: Date.now() - dispatchStartTime,
576
+ },
577
+ });
578
+ }
507
579
  return;
508
580
  }
509
581
 
510
582
  // Fallback: agent.enqueueInbound
511
583
  if ((globalRuntime as any).agent?.enqueueInbound) {
512
584
  log?.info(`[fp] fallback: using agent.enqueueInbound`);
585
+
586
+ // Send processing status
587
+ sendStatusUpdate({
588
+ to: message.deviceId,
589
+ messageId: message.messageId,
590
+ status: "processing",
591
+ message: "Your message is being processed",
592
+ });
593
+
513
594
  const { sendTextMessage } = await import("./relay-client.js");
514
595
  await (globalRuntime as any).agent.enqueueInbound({
515
596
  channel: "firstperson",
@@ -532,6 +613,13 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
532
613
  text,
533
614
  replyTo: message.messageId,
534
615
  });
616
+ // Send completion status
617
+ sendStatusUpdate({
618
+ to: message.deviceId,
619
+ messageId: message.messageId,
620
+ status: "completed",
621
+ message: "Response delivered",
622
+ });
535
623
  },
536
624
  });
537
625
  return;
@@ -540,6 +628,15 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
540
628
  // Fallback: gateway.handleInbound
541
629
  if ((globalRuntime as any).gateway?.handleInbound) {
542
630
  log?.info(`[fp] fallback: using gateway.handleInbound`);
631
+
632
+ // Send processing status
633
+ sendStatusUpdate({
634
+ to: message.deviceId,
635
+ messageId: message.messageId,
636
+ status: "processing",
637
+ message: "Your message is being processed",
638
+ });
639
+
543
640
  const { sendTextMessage } = await import("./relay-client.js");
544
641
  await (globalRuntime as any).gateway.handleInbound({
545
642
  channel: "firstperson",
@@ -557,15 +654,35 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
557
654
  text,
558
655
  replyTo: message.messageId,
559
656
  });
657
+ // Send completion status
658
+ sendStatusUpdate({
659
+ to: message.deviceId,
660
+ messageId: message.messageId,
661
+ status: "completed",
662
+ message: "Response delivered",
663
+ });
560
664
  },
561
665
  });
562
666
  return;
563
667
  }
564
668
 
565
669
  log?.error(`[fp] ERROR: no dispatch method available on runtime`);
670
+ sendStatusUpdate({
671
+ to: message.deviceId,
672
+ messageId: message.messageId,
673
+ status: "failed",
674
+ message: "No dispatch method available",
675
+ });
566
676
 
567
677
  } catch (err) {
568
678
  log?.error(`[fp] ERROR processing message: ${err}`);
679
+ // Send error status to client
680
+ sendStatusUpdate({
681
+ to: message.deviceId,
682
+ messageId: message.messageId,
683
+ status: "failed",
684
+ message: `Error processing message: ${(err as Error).message}`,
685
+ });
569
686
  }
570
687
  },
571
688
  });
@@ -29,6 +29,30 @@ export interface SendMessageResult {
29
29
  chatId: string;
30
30
  }
31
31
 
32
+ /**
33
+ * Message status types matching the relay protocol
34
+ */
35
+ export type MessageStatus =
36
+ | "received"
37
+ | "forwarded"
38
+ | "processing"
39
+ | "completed"
40
+ | "failed"
41
+ | "gateway_offline";
42
+
43
+ /**
44
+ * Status message sent to relay for forwarding to iOS client
45
+ */
46
+ export interface StatusUpdate {
47
+ type: "message_status";
48
+ messageId: string;
49
+ to: string;
50
+ status: MessageStatus;
51
+ message?: string;
52
+ timestamp: number;
53
+ details?: Record<string, unknown>;
54
+ }
55
+
32
56
  // Store active connection and pending reply callbacks
33
57
  let activeWs: WebSocket | null = null;
34
58
  let activeLogger: Logger | null = null;
@@ -43,6 +67,45 @@ function log(level: "info" | "warn" | "error", msg: string): void {
43
67
  }
44
68
  }
45
69
 
70
+ /**
71
+ * Send a status update for a message to the relay.
72
+ * The relay will forward this to the iOS client.
73
+ */
74
+ export function sendStatusUpdate(params: {
75
+ to: string;
76
+ messageId: string;
77
+ status: MessageStatus;
78
+ message?: string;
79
+ details?: Record<string, unknown>;
80
+ }): void {
81
+ const { to, messageId, status, message, details } = params;
82
+
83
+ if (!activeWs || activeWs.readyState !== WebSocket.OPEN) {
84
+ log("warn", `[relay-client] Cannot send status update - no active connection`);
85
+ return;
86
+ }
87
+
88
+ const statusMsg: StatusUpdate = {
89
+ type: "message_status",
90
+ messageId,
91
+ to,
92
+ status,
93
+ timestamp: Date.now(),
94
+ ...(message && { message }),
95
+ ...(details && { details }),
96
+ };
97
+
98
+ activeWs.send(JSON.stringify(statusMsg));
99
+ log("info", `[relay-client] Sent status update: ${status} for message ${messageId} to ${to}`);
100
+ }
101
+
102
+ /**
103
+ * Check if the relay connection is active
104
+ */
105
+ export function isRelayConnected(): boolean {
106
+ return activeWs !== null && activeWs.readyState === WebSocket.OPEN;
107
+ }
108
+
46
109
  export async function sendTextMessage(params: {
47
110
  relayUrl: string;
48
111
  token: string;
@@ -142,9 +205,23 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
142
205
 
143
206
  let ws: WebSocket | null = null;
144
207
  let reconnectAttempts = 0;
145
- const maxReconnectAttempts = 10;
146
208
  const baseReconnectDelay = 1000;
147
209
 
210
+ // Timers for heartbeat and stability tracking
211
+ let pingInterval: ReturnType<typeof setInterval> | null = null;
212
+ let stabilityTimer: ReturnType<typeof setTimeout> | null = null;
213
+
214
+ const clearTimers = () => {
215
+ if (pingInterval) {
216
+ clearInterval(pingInterval);
217
+ pingInterval = null;
218
+ }
219
+ if (stabilityTimer) {
220
+ clearTimeout(stabilityTimer);
221
+ stabilityTimer = null;
222
+ }
223
+ };
224
+
148
225
  const connect = () => {
149
226
  if (abortSignal.aborted) {
150
227
  log("info", "[relay-client] connect() called but abortSignal already aborted");
@@ -156,8 +233,27 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
156
233
 
157
234
  ws.on("open", () => {
158
235
  log("info", `[relay-client] WebSocket opened, readyState: ${ws?.readyState}`);
159
- reconnectAttempts = 0;
160
236
  activeWs = ws; // Store as active connection for sending
237
+
238
+ // Clear any existing timers from previous connection attempts
239
+ clearTimers();
240
+
241
+ // Start client-side heartbeat: send ping every 30s to keep connection alive
242
+ pingInterval = setInterval(() => {
243
+ if (ws?.readyState === WebSocket.OPEN) {
244
+ log("info", "[relay-client] Sending heartbeat ping");
245
+ ws.send(JSON.stringify({ type: "ping" }));
246
+ }
247
+ }, 30000);
248
+
249
+ // Reset reconnect counter after connection is stable for 60s
250
+ stabilityTimer = setTimeout(() => {
251
+ if (ws?.readyState === WebSocket.OPEN && reconnectAttempts > 0) {
252
+ log("info", `[relay-client] Connection stable for 60s, resetting reconnect counter (was ${reconnectAttempts})`);
253
+ reconnectAttempts = 0;
254
+ }
255
+ }, 60000);
256
+
161
257
  onConnected();
162
258
  });
163
259
 
@@ -172,6 +268,11 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
172
268
  return;
173
269
  }
174
270
 
271
+ if (msg.type === "pong") {
272
+ // Server acknowledged our heartbeat ping - connection is healthy
273
+ return;
274
+ }
275
+
175
276
  // Handle send acknowledgments for replies sent through this connection
176
277
  if ((msg.type === "message_sent" || msg.type === "ack") && msg.messageId) {
177
278
  const pending = pendingReplies.get(msg.messageId);
@@ -213,6 +314,9 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
213
314
  );
214
315
  log("info", `[relay-client] abortSignal.aborted: ${abortSignal.aborted}`);
215
316
 
317
+ // Clear timers on disconnect
318
+ clearTimers();
319
+
216
320
  // Clear active connection and reject pending replies
217
321
  if (activeWs === ws) {
218
322
  activeWs = null;
@@ -228,19 +332,15 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
228
332
  return;
229
333
  }
230
334
 
335
+ // Always reconnect - never give up (exponential backoff capped at 30s)
231
336
  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
- }
337
+ const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000);
338
+ log(
339
+ "info",
340
+ `[relay-client] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`
341
+ );
342
+ setTimeout(connect, delay);
343
+ onDisconnected(new Error(`Connection closed, reconnecting in ${delay}ms...`));
244
344
  });
245
345
 
246
346
  ws.on("error", (err) => {
@@ -252,6 +352,7 @@ export async function startRelayConnection(params: RelayConnectionParams): Promi
252
352
  // Handle abort signal
253
353
  abortSignal.addEventListener("abort", () => {
254
354
  log("info", "[relay-client] Abort signal received, closing WebSocket");
355
+ clearTimers();
255
356
  if (activeWs === ws) {
256
357
  activeWs = null;
257
358
  activeLogger = null;