@xmtp/convos-cli 0.1.0 → 0.2.0

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.
@@ -1,4 +1,62 @@
1
+ import type { EncodedContent } from "@xmtp/node-bindings";
1
2
  import { ConvosBaseCommand } from "../../baseCommand.js";
3
+ /**
4
+ * Stdin command types the agent can send.
5
+ */
6
+ interface SendCommand {
7
+ type: "send";
8
+ text: string;
9
+ replyTo?: string;
10
+ }
11
+ interface ReactCommand {
12
+ type: "react";
13
+ messageId: string;
14
+ emoji: string;
15
+ action?: "add" | "remove";
16
+ }
17
+ interface AttachCommand {
18
+ type: "attach";
19
+ file: string;
20
+ mimeType?: string;
21
+ replyTo?: string;
22
+ }
23
+ interface RemoteAttachCommand {
24
+ type: "remote-attach";
25
+ url: string;
26
+ contentDigest: string;
27
+ secret: string;
28
+ salt: string;
29
+ nonce: string;
30
+ contentLength: number;
31
+ filename?: string;
32
+ scheme?: string;
33
+ }
34
+ interface RenameCommand {
35
+ type: "rename";
36
+ name: string;
37
+ }
38
+ interface LockCommand {
39
+ type: "lock";
40
+ }
41
+ interface UnlockCommand {
42
+ type: "unlock";
43
+ }
44
+ interface ExplodeCommand {
45
+ type: "explode";
46
+ scheduled?: string;
47
+ }
48
+ interface StopCommand {
49
+ type: "stop";
50
+ }
51
+ export type AgentCommand = SendCommand | ReactCommand | AttachCommand | RemoteAttachCommand | RenameCommand | LockCommand | UnlockCommand | ExplodeCommand | StopCommand;
52
+ /**
53
+ * Encode an ExplodeSettings message matching the iOS content type.
54
+ *
55
+ * Content type: convos.org/explode_settings:1.0
56
+ * Payload: JSON-encoded { expiresAt: ISO8601 string }
57
+ * Fallback: "Conversation expires at {date}"
58
+ */
59
+ export declare function encodeExplodeSettings(expiresAt: Date): EncodedContent;
2
60
  export default class AgentServe extends ConvosBaseCommand {
3
61
  static description: string;
4
62
  static examples: {
@@ -16,6 +74,7 @@ export default class AgentServe extends ConvosBaseCommand {
16
74
  identity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
75
  label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
76
  "no-invite": import("@oclif/core/interfaces").BooleanFlag<boolean>;
77
+ heartbeat: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
19
78
  "log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
79
  "structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
80
  "app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -27,6 +86,19 @@ export default class AgentServe extends ConvosBaseCommand {
27
86
  };
28
87
  private streams;
29
88
  private shutdownResolve?;
89
+ private heartbeatInterval?;
90
+ /** Timestamp (ns) of the last message we emitted — used for catchup on reconnect. */
91
+ private lastMessageTimestampNs;
92
+ /** Timestamp (ns) of the last DM message we processed — used for catchup on reconnect. */
93
+ private lastDmTimestampNs;
94
+ /** IDs of recently emitted messages — used to deduplicate catchup vs live stream. */
95
+ private recentMessageIds;
96
+ /** Guards against concurrent catchup operations during connection flapping. */
97
+ private isCatchingUpMessages;
98
+ private isCatchingUpDms;
99
+ /** Serializes command execution to prevent concurrent appData read-modify-write. */
100
+ private commandQueue;
101
+ private static readonly MAX_RECENT_IDS;
30
102
  /**
31
103
  * Emit a JSON event to stdout (one line).
32
104
  */
@@ -35,6 +107,10 @@ export default class AgentServe extends ConvosBaseCommand {
35
107
  * Emit an error event.
36
108
  */
37
109
  private emitError;
110
+ /**
111
+ * Track a message ID as emitted. Returns false if already seen (duplicate).
112
+ */
113
+ private trackMessageId;
38
114
  /**
39
115
  * Process a single DM message as a potential join request.
40
116
  */
@@ -43,12 +119,22 @@ export default class AgentServe extends ConvosBaseCommand {
43
119
  * Process any pending DM join requests (batch, on startup).
44
120
  */
45
121
  private processPendingJoinRequests;
122
+ /**
123
+ * Catch up on DM join requests that may have arrived while disconnected.
124
+ * Guarded against concurrent invocations during connection flapping.
125
+ */
126
+ private catchUpDmJoinRequests;
46
127
  /**
47
128
  * Start streaming DM messages for join request processing.
48
129
  */
49
130
  private startJoinRequestStream;
50
131
  /**
51
- * Start streaming conversation messages.
132
+ * Fetch and emit any messages that arrived while the stream was disconnected.
133
+ * Guarded against concurrent invocations during connection flapping.
134
+ */
135
+ private catchUpMessages;
136
+ /**
137
+ * Start streaming conversation messages with reconnection catchup.
52
138
  */
53
139
  private startMessageStream;
54
140
  /**
@@ -59,9 +145,14 @@ export default class AgentServe extends ConvosBaseCommand {
59
145
  * Handle a parsed stdin command.
60
146
  */
61
147
  private handleCommand;
148
+ /**
149
+ * Start emitting periodic heartbeat events.
150
+ */
151
+ private startHeartbeat;
62
152
  /**
63
153
  * Trigger graceful shutdown.
64
154
  */
65
155
  private shutdown;
66
156
  run(): Promise<void>;
67
157
  }
158
+ export {};
@@ -15,6 +15,29 @@ import { parseAppData, serializeAppData, upsertProfile } from "../../utils/metad
15
15
  import { randomAlphanumeric } from "../../utils/random.js";
16
16
  import { getUploadProvider, INLINE_ATTACHMENT_MAX_BYTES, } from "../../utils/upload.js";
17
17
  import { buildProfileMap, getAccountAddress, isDisplayableMessage, jsonStringify, normalizeMessageContent, requireGroup, } from "../../utils/xmtp.js";
18
+ /**
19
+ * Encode an ExplodeSettings message matching the iOS content type.
20
+ *
21
+ * Content type: convos.org/explode_settings:1.0
22
+ * Payload: JSON-encoded { expiresAt: ISO8601 string }
23
+ * Fallback: "Conversation expires at {date}"
24
+ */
25
+ export function encodeExplodeSettings(expiresAt) {
26
+ const payload = JSON.stringify({
27
+ expiresAt: expiresAt.toISOString(),
28
+ });
29
+ return {
30
+ type: {
31
+ authorityId: "convos.org",
32
+ typeId: "explode_settings",
33
+ versionMajor: 1,
34
+ versionMinor: 0,
35
+ },
36
+ parameters: {},
37
+ fallback: `Conversation expires at ${expiresAt.toISOString()}`,
38
+ content: new TextEncoder().encode(payload),
39
+ };
40
+ }
18
41
  export default class AgentServe extends ConvosBaseCommand {
19
42
  static description = `Run an agent server for a conversation.
20
43
 
@@ -35,6 +58,11 @@ STDIN commands (one JSON object per line):
35
58
  {"type":"attach","file":"./photo.jpg"} Send a file attachment
36
59
  {"type":"attach","file":"./img.jpg","replyTo":"<id>"} Reply with attachment
37
60
  {"type":"remote-attach","url":"https://...","contentDigest":"...","secret":"...","salt":"...","nonce":"...","contentLength":123}
61
+ {"type":"rename","name":"New Name"} Rename the conversation
62
+ {"type":"lock"} Lock (prevent new joins)
63
+ {"type":"unlock"} Unlock (allow new joins)
64
+ {"type":"explode"} Explode immediately
65
+ {"type":"explode","scheduled":"2025-03-01T00:00:00Z"} Schedule explosion
38
66
  {"type":"stop"} Graceful shutdown
39
67
 
40
68
  STDOUT events (one JSON object per line):
@@ -42,6 +70,7 @@ STDOUT events (one JSON object per line):
42
70
  {"event":"message",...} New message received
43
71
  {"event":"member_joined",...} A member joined via invite
44
72
  {"event":"sent",...} Message sent confirmation
73
+ {"event":"heartbeat",...} Periodic health check
45
74
  {"event":"error",...} Error occurred
46
75
 
47
76
  STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
@@ -96,9 +125,27 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
96
125
  description: "Skip generating an invite (attach mode only)",
97
126
  default: false,
98
127
  }),
128
+ heartbeat: Flags.integer({
129
+ description: "Emit heartbeat events every N seconds (0 to disable)",
130
+ helpValue: "<seconds>",
131
+ default: 0,
132
+ }),
99
133
  };
100
134
  streams = [];
101
135
  shutdownResolve;
136
+ heartbeatInterval;
137
+ /** Timestamp (ns) of the last message we emitted — used for catchup on reconnect. */
138
+ lastMessageTimestampNs = 0n;
139
+ /** Timestamp (ns) of the last DM message we processed — used for catchup on reconnect. */
140
+ lastDmTimestampNs = 0n;
141
+ /** IDs of recently emitted messages — used to deduplicate catchup vs live stream. */
142
+ recentMessageIds = new Set();
143
+ /** Guards against concurrent catchup operations during connection flapping. */
144
+ isCatchingUpMessages = false;
145
+ isCatchingUpDms = false;
146
+ /** Serializes command execution to prevent concurrent appData read-modify-write. */
147
+ commandQueue = Promise.resolve();
148
+ static MAX_RECENT_IDS = 1000;
102
149
  /**
103
150
  * Emit a JSON event to stdout (one line).
104
151
  */
@@ -111,6 +158,20 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
111
158
  emitError(message, details) {
112
159
  this.emit({ event: "error", message, ...details });
113
160
  }
161
+ /**
162
+ * Track a message ID as emitted. Returns false if already seen (duplicate).
163
+ */
164
+ trackMessageId(id) {
165
+ if (this.recentMessageIds.has(id))
166
+ return false;
167
+ this.recentMessageIds.add(id);
168
+ // Evict oldest entries when the set grows too large
169
+ if (this.recentMessageIds.size > AgentServe.MAX_RECENT_IDS) {
170
+ const first = this.recentMessageIds.values().next().value;
171
+ this.recentMessageIds.delete(first);
172
+ }
173
+ return true;
174
+ }
114
175
  /**
115
176
  * Process a single DM message as a potential join request.
116
177
  */
@@ -213,17 +274,85 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
213
274
  this.emitError(`Failed to process pending join requests: ${error instanceof Error ? error.message : "unknown"}`);
214
275
  }
215
276
  }
277
+ /**
278
+ * Catch up on DM join requests that may have arrived while disconnected.
279
+ * Guarded against concurrent invocations during connection flapping.
280
+ */
281
+ async catchUpDmJoinRequests(client, identity, sinceNs) {
282
+ if (sinceNs === 0n)
283
+ return;
284
+ if (this.isCatchingUpDms)
285
+ return;
286
+ this.isCatchingUpDms = true;
287
+ try {
288
+ await client.conversations.sync();
289
+ const dms = await client.conversations.list({
290
+ conversationType: 0 /* ConversationType.Dm */,
291
+ consentStates: [0 /* ConsentState.Unknown */],
292
+ });
293
+ for (const dm of dms) {
294
+ try {
295
+ await dm.sync();
296
+ const messages = await dm.messages({
297
+ sentAfterNs: sinceNs,
298
+ direction: 0 /* SortDirection.Ascending */,
299
+ limit: 10,
300
+ });
301
+ for (const message of messages) {
302
+ try {
303
+ const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
304
+ const result = await this.processJoinMessage(message, client, identity);
305
+ if (result) {
306
+ if (sentAtNs > this.lastDmTimestampNs) {
307
+ this.lastDmTimestampNs = sentAtNs;
308
+ }
309
+ this.emit({
310
+ event: "member_joined",
311
+ inboxId: result.joinerInboxId,
312
+ conversationId: result.conversationId,
313
+ timestamp: new Date().toISOString(),
314
+ catchup: true,
315
+ });
316
+ break;
317
+ }
318
+ }
319
+ catch {
320
+ // skip individual message errors
321
+ }
322
+ }
323
+ }
324
+ catch {
325
+ // skip individual DM errors
326
+ }
327
+ }
328
+ }
329
+ catch (error) {
330
+ this.emitError(`Failed to catch up DM join requests: ${error instanceof Error ? error.message : "unknown"}`);
331
+ }
332
+ finally {
333
+ this.isCatchingUpDms = false;
334
+ }
335
+ }
216
336
  /**
217
337
  * Start streaming DM messages for join request processing.
218
338
  */
219
339
  async startJoinRequestStream(client, identity) {
220
340
  try {
221
- const stream = await client.conversations.streamAllDmMessages();
341
+ const stream = await client.conversations.streamAllDmMessages({
342
+ onRestart: () => {
343
+ void this.catchUpDmJoinRequests(client, identity, this.lastDmTimestampNs);
344
+ },
345
+ });
222
346
  this.streams.push(stream);
223
347
  (async () => {
224
348
  try {
225
349
  for await (const message of stream) {
226
350
  try {
351
+ // Track last DM timestamp for catchup on reconnect
352
+ const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
353
+ if (sentAtNs > this.lastDmTimestampNs) {
354
+ this.lastDmTimestampNs = sentAtNs;
355
+ }
227
356
  const result = await this.processJoinMessage(message, client, identity);
228
357
  if (result) {
229
358
  this.emit({
@@ -249,11 +378,63 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
249
378
  }
250
379
  }
251
380
  /**
252
- * Start streaming conversation messages.
381
+ * Fetch and emit any messages that arrived while the stream was disconnected.
382
+ * Guarded against concurrent invocations during connection flapping.
383
+ */
384
+ async catchUpMessages(conversation, client, sinceNs) {
385
+ if (sinceNs === 0n)
386
+ return;
387
+ if (this.isCatchingUpMessages)
388
+ return;
389
+ this.isCatchingUpMessages = true;
390
+ try {
391
+ await conversation.sync();
392
+ const missed = await conversation.messages({
393
+ sentAfterNs: sinceNs,
394
+ direction: 0 /* SortDirection.Ascending */,
395
+ });
396
+ for (const message of missed) {
397
+ if (message.senderInboxId === client.inboxId)
398
+ continue;
399
+ if (!isDisplayableMessage(message))
400
+ continue;
401
+ if (!this.trackMessageId(message.id))
402
+ continue;
403
+ const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
404
+ if (sentAtNs > this.lastMessageTimestampNs) {
405
+ this.lastMessageTimestampNs = sentAtNs;
406
+ }
407
+ const profiles = buildProfileMap(conversation.appData ?? "");
408
+ this.emit({
409
+ event: "message",
410
+ id: message.id,
411
+ senderInboxId: message.senderInboxId,
412
+ contentType: message.contentType,
413
+ content: normalizeMessageContent(message, profiles),
414
+ sentAt: message.sentAt.toISOString(),
415
+ deliveryStatus: message.deliveryStatus,
416
+ catchup: true,
417
+ });
418
+ }
419
+ }
420
+ catch (error) {
421
+ this.emitError(`Failed to catch up messages: ${error instanceof Error ? error.message : "unknown"}`);
422
+ }
423
+ finally {
424
+ this.isCatchingUpMessages = false;
425
+ }
426
+ }
427
+ /**
428
+ * Start streaming conversation messages with reconnection catchup.
253
429
  */
254
430
  async startMessageStream(conversation, client) {
255
431
  try {
256
- const stream = await conversation.stream();
432
+ const stream = await conversation.stream({
433
+ onRestart: () => {
434
+ // On reconnect, fetch missed messages
435
+ void this.catchUpMessages(conversation, client, this.lastMessageTimestampNs);
436
+ },
437
+ });
257
438
  this.streams.push(stream);
258
439
  (async () => {
259
440
  try {
@@ -264,6 +445,14 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
264
445
  // Skip content types we can't display cleanly
265
446
  if (!isDisplayableMessage(message))
266
447
  continue;
448
+ // Skip if already emitted by catchup
449
+ if (!this.trackMessageId(message.id))
450
+ continue;
451
+ // Track last message timestamp for catchup on reconnect
452
+ const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
453
+ if (sentAtNs > this.lastMessageTimestampNs) {
454
+ this.lastMessageTimestampNs = sentAtNs;
455
+ }
267
456
  // Rebuild profiles each time so newly-joined members are resolved
268
457
  const profiles = buildProfileMap(conversation.appData ?? "");
269
458
  this.emit({
@@ -289,7 +478,7 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
289
478
  /**
290
479
  * Read and process stdin commands.
291
480
  */
292
- async startStdinReader(conversation) {
481
+ async startStdinReader(conversation, client, identity) {
293
482
  // If stdin is a TTY, skip the reader (no piped input)
294
483
  if (process.stdin.isTTY)
295
484
  return;
@@ -309,7 +498,8 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
309
498
  this.emitError("Invalid JSON on stdin", { input: trimmed });
310
499
  return;
311
500
  }
312
- void this.handleCommand(cmd, conversation);
501
+ // Serialize commands to prevent concurrent appData read-modify-write
502
+ this.commandQueue = this.commandQueue.then(() => this.handleCommand(cmd, conversation, client, identity));
313
503
  });
314
504
  rl.on("close", () => {
315
505
  // stdin closed — trigger shutdown
@@ -319,7 +509,7 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
319
509
  /**
320
510
  * Handle a parsed stdin command.
321
511
  */
322
- async handleCommand(cmd, conversation) {
512
+ async handleCommand(cmd, conversation, client, identity) {
323
513
  try {
324
514
  switch (cmd.type) {
325
515
  case "send": {
@@ -478,6 +668,139 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
478
668
  });
479
669
  break;
480
670
  }
671
+ case "rename": {
672
+ if (!cmd.name) {
673
+ this.emitError("rename command requires 'name' field");
674
+ return;
675
+ }
676
+ await conversation.updateName(cmd.name);
677
+ // Also update the identity store label
678
+ const store = createIdentityStore();
679
+ store.update(identity.id, { label: cmd.name });
680
+ this.emit({
681
+ event: "sent",
682
+ type: "rename",
683
+ name: cmd.name,
684
+ conversationId: conversation.id,
685
+ timestamp: new Date().toISOString(),
686
+ });
687
+ break;
688
+ }
689
+ case "lock": {
690
+ // Step 1: Rotate the invite tag to invalidate all existing invites
691
+ let appData = "";
692
+ try {
693
+ appData = conversation.appData ?? "";
694
+ }
695
+ catch {
696
+ // no appData
697
+ }
698
+ const lockMetadata = parseAppData(appData);
699
+ lockMetadata.tag = randomAlphanumeric(10);
700
+ await conversation.updateAppData(serializeAppData(lockMetadata));
701
+ // Step 2: Set addMember permission to deny
702
+ await conversation.updatePermission(0 /* PermissionUpdateType.AddMember */, 1 /* PermissionPolicy.Deny */);
703
+ this.emit({
704
+ event: "sent",
705
+ type: "lock",
706
+ conversationId: conversation.id,
707
+ timestamp: new Date().toISOString(),
708
+ });
709
+ break;
710
+ }
711
+ case "unlock": {
712
+ // Rotate invite tag first
713
+ let appData = "";
714
+ try {
715
+ appData = conversation.appData ?? "";
716
+ }
717
+ catch {
718
+ // no appData
719
+ }
720
+ const unlockMetadata = parseAppData(appData);
721
+ unlockMetadata.tag = randomAlphanumeric(10);
722
+ await conversation.updateAppData(serializeAppData(unlockMetadata));
723
+ // Restore addMember permission
724
+ await conversation.updatePermission(0 /* PermissionUpdateType.AddMember */, 0 /* PermissionPolicy.Allow */);
725
+ this.emit({
726
+ event: "sent",
727
+ type: "unlock",
728
+ conversationId: conversation.id,
729
+ timestamp: new Date().toISOString(),
730
+ });
731
+ break;
732
+ }
733
+ case "explode": {
734
+ // Determine expiration time
735
+ let expiresAt;
736
+ const isImmediate = !cmd.scheduled;
737
+ if (cmd.scheduled) {
738
+ expiresAt = new Date(cmd.scheduled);
739
+ if (isNaN(expiresAt.getTime())) {
740
+ this.emitError(`Invalid scheduled date: ${cmd.scheduled}`);
741
+ return;
742
+ }
743
+ if (expiresAt <= new Date()) {
744
+ this.emitError("Scheduled date must be in the future");
745
+ return;
746
+ }
747
+ }
748
+ else {
749
+ expiresAt = new Date();
750
+ }
751
+ // Step 1: Send ExplodeSettings message
752
+ const encodedContent = encodeExplodeSettings(expiresAt);
753
+ await conversation.send(encodedContent, { shouldPush: true });
754
+ // Step 2: Update group metadata with expiresAtUnix
755
+ try {
756
+ let appData = "";
757
+ try {
758
+ appData = conversation.appData ?? "";
759
+ }
760
+ catch {
761
+ // no appData
762
+ }
763
+ const explodeMetadata = parseAppData(appData);
764
+ explodeMetadata.expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
765
+ await conversation.updateAppData(serializeAppData(explodeMetadata));
766
+ }
767
+ catch {
768
+ // Non-fatal: metadata update is secondary to the message
769
+ }
770
+ if (isImmediate) {
771
+ // Step 3: Remove all other members
772
+ const members = await conversation.members();
773
+ const others = members.filter((m) => m.inboxId !== client.inboxId);
774
+ if (others.length > 0) {
775
+ await conversation.removeMembers(others.map((m) => m.inboxId));
776
+ }
777
+ // Step 4: Delete local identity
778
+ const store = createIdentityStore();
779
+ store.remove(identity.id);
780
+ this.emit({
781
+ event: "sent",
782
+ type: "explode",
783
+ conversationId: conversation.id,
784
+ identityDestroyed: identity.id,
785
+ membersRemoved: others.length,
786
+ expiresAt: expiresAt.toISOString(),
787
+ timestamp: new Date().toISOString(),
788
+ });
789
+ // Explode triggers shutdown
790
+ this.shutdown();
791
+ }
792
+ else {
793
+ this.emit({
794
+ event: "sent",
795
+ type: "explode",
796
+ scheduled: true,
797
+ conversationId: conversation.id,
798
+ expiresAt: expiresAt.toISOString(),
799
+ timestamp: new Date().toISOString(),
800
+ });
801
+ }
802
+ break;
803
+ }
481
804
  case "stop":
482
805
  this.shutdown();
483
806
  break;
@@ -489,10 +812,31 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
489
812
  this.emitError(`Command failed: ${error instanceof Error ? error.message : "unknown"}`, { command: cmd });
490
813
  }
491
814
  }
815
+ /**
816
+ * Start emitting periodic heartbeat events.
817
+ */
818
+ startHeartbeat(intervalSeconds, conversationId) {
819
+ if (intervalSeconds <= 0)
820
+ return;
821
+ this.heartbeatInterval = setInterval(() => {
822
+ this.emit({
823
+ event: "heartbeat",
824
+ conversationId,
825
+ activeStreams: this.streams.length,
826
+ timestamp: new Date().toISOString(),
827
+ });
828
+ }, intervalSeconds * 1000);
829
+ // Don't let the heartbeat timer keep the process alive on its own
830
+ this.heartbeatInterval.unref();
831
+ }
492
832
  /**
493
833
  * Trigger graceful shutdown.
494
834
  */
495
835
  shutdown() {
836
+ if (this.heartbeatInterval) {
837
+ clearInterval(this.heartbeatInterval);
838
+ this.heartbeatInterval = undefined;
839
+ }
496
840
  if (this.shutdownResolve) {
497
841
  this.shutdownResolve();
498
842
  }
@@ -641,7 +985,11 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
641
985
  // Start all concurrent streams
642
986
  await this.startMessageStream(group, client);
643
987
  await this.startJoinRequestStream(client, identity);
644
- this.startStdinReader(group);
988
+ this.startStdinReader(group, client, identity);
989
+ // Start heartbeat if configured
990
+ if (flags.heartbeat && flags.heartbeat > 0) {
991
+ this.startHeartbeat(flags.heartbeat, conversationId);
992
+ }
645
993
  // Keep running until shutdown
646
994
  await new Promise((resolve) => {
647
995
  this.shutdownResolve = resolve;