@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 +7 -0
- package/package.json +4 -2
- package/src/channel.ts +126 -9
- package/src/relay-client.ts +115 -14
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.
|
|
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": [
|
|
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
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
});
|
package/src/relay-client.ts
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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;
|