agent-relay 3.1.1 → 3.1.2

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.
Files changed (42) hide show
  1. package/package.json +8 -8
  2. package/packages/acp-bridge/package.json +2 -2
  3. package/packages/config/package.json +1 -1
  4. package/packages/hooks/package.json +4 -4
  5. package/packages/memory/package.json +2 -2
  6. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts +2 -0
  7. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts.map +1 -0
  8. package/packages/openclaw/dist/__tests__/gateway-control.test.js +250 -0
  9. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -0
  10. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +617 -0
  11. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
  12. package/packages/openclaw/dist/__tests__/spawn-manager.test.js +29 -0
  13. package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -1
  14. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts +2 -0
  15. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts.map +1 -0
  16. package/packages/openclaw/dist/__tests__/ws-client.test.js +324 -0
  17. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -0
  18. package/packages/openclaw/dist/cli.js +1 -1
  19. package/packages/openclaw/dist/cli.js.map +1 -1
  20. package/packages/openclaw/dist/gateway.d.ts +33 -7
  21. package/packages/openclaw/dist/gateway.d.ts.map +1 -1
  22. package/packages/openclaw/dist/gateway.js +101 -50
  23. package/packages/openclaw/dist/gateway.js.map +1 -1
  24. package/packages/openclaw/dist/types.d.ts +5 -1
  25. package/packages/openclaw/dist/types.d.ts.map +1 -1
  26. package/packages/openclaw/package.json +2 -2
  27. package/packages/openclaw/skill/SKILL.md +35 -13
  28. package/packages/openclaw/src/__tests__/SPEC-ws-client-testing.md +192 -0
  29. package/packages/openclaw/src/__tests__/gateway-control.test.ts +288 -0
  30. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +746 -0
  31. package/packages/openclaw/src/__tests__/spawn-manager.test.ts +37 -0
  32. package/packages/openclaw/src/__tests__/ws-client.test.ts +395 -0
  33. package/packages/openclaw/src/cli.ts +1 -1
  34. package/packages/openclaw/src/gateway.ts +129 -56
  35. package/packages/openclaw/src/types.ts +5 -1
  36. package/packages/policy/package.json +2 -2
  37. package/packages/sdk/package.json +2 -2
  38. package/packages/sdk-py/pyproject.toml +1 -1
  39. package/packages/telemetry/package.json +1 -1
  40. package/packages/trajectory/package.json +2 -2
  41. package/packages/user-directory/package.json +2 -2
  42. package/packages/utils/package.json +2 -2
@@ -3,7 +3,15 @@ import { createServer, type Server as HttpServer, type IncomingMessage, type Ser
3
3
 
4
4
  import type { SendMessageInput } from '@agent-relay/sdk';
5
5
  import { RelayCast, type AgentClient } from '@relaycast/sdk';
6
- import type { MessageCreatedEvent, MessageWithMeta, ThreadReplyEvent } from '@relaycast/sdk';
6
+ import type {
7
+ MessageCreatedEvent,
8
+ ThreadReplyEvent,
9
+ DmReceivedEvent,
10
+ GroupDmReceivedEvent,
11
+ CommandInvokedEvent,
12
+ ReactionAddedEvent,
13
+ ReactionRemovedEvent,
14
+ } from '@relaycast/sdk';
7
15
  import WebSocket from 'ws';
8
16
 
9
17
  import type { GatewayConfig, InboundMessage, DeliveryResult } from './types.js';
@@ -106,7 +114,8 @@ interface PendingRpc {
106
114
  timer: ReturnType<typeof setTimeout>;
107
115
  }
108
116
 
109
- class OpenClawGatewayClient {
117
+ /** @internal */
118
+ export class OpenClawGatewayClient {
110
119
  private ws: WebSocket | null = null;
111
120
  private authenticated = false;
112
121
  private device: DeviceIdentity;
@@ -419,15 +428,12 @@ export class InboundGateway {
419
428
  private relayAgentClient: AgentClient | null = null;
420
429
  private readonly relaycast: RelayCast;
421
430
  private readonly config: GatewayConfig;
422
- private readonly fallbackPollMs: number;
423
431
  private readonly dedupeTtlMs: number;
424
432
 
425
433
  private running = false;
426
- private pollTimer: ReturnType<typeof setInterval> | null = null;
427
434
  private unsubscribeHandlers: Array<() => void> = [];
428
435
  private seenMessageIds = new Map<string, number>();
429
436
  private processingMessageIds = new Set<string>();
430
- private channelCursor = new Map<string, string>();
431
437
 
432
438
  /** Persistent WebSocket client for the local OpenClaw gateway. */
433
439
  private openclawClient: OpenClawGatewayClient | null = null;
@@ -453,11 +459,6 @@ export class InboundGateway {
453
459
  baseUrl: this.config.baseUrl,
454
460
  });
455
461
 
456
- const fallbackPollMs = Number(process.env.RELAYCAST_FALLBACK_POLL_MS ?? 15000);
457
- this.fallbackPollMs = Number.isFinite(fallbackPollMs) && fallbackPollMs >= 1000
458
- ? Math.floor(fallbackPollMs)
459
- : 15000;
460
-
461
462
  const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
462
463
  this.dedupeTtlMs = Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000
463
464
  ? Math.floor(dedupeTtlMs)
@@ -467,7 +468,7 @@ export class InboundGateway {
467
468
  this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
468
469
  }
469
470
 
470
- /** Start the gateway — register agent, subscribe for realtime events, and run fallback polling. */
471
+ /** Start the gateway — register agent and subscribe for realtime events. */
471
472
  async start(): Promise<void> {
472
473
  if (this.running) return;
473
474
  this.running = true;
@@ -521,6 +522,36 @@ export class InboundGateway {
521
522
  void this.handleRealtimeThreadReply(event);
522
523
  }),
523
524
  );
525
+ this.unsubscribeHandlers.push(
526
+ this.relayAgentClient.on.dmReceived((event: DmReceivedEvent) => {
527
+ console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
528
+ void this.handleRealtimeDm(event);
529
+ }),
530
+ );
531
+ this.unsubscribeHandlers.push(
532
+ this.relayAgentClient.on.groupDmReceived((event: GroupDmReceivedEvent) => {
533
+ console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
534
+ void this.handleRealtimeGroupDm(event);
535
+ }),
536
+ );
537
+ this.unsubscribeHandlers.push(
538
+ this.relayAgentClient.on.commandInvoked((event: CommandInvokedEvent) => {
539
+ console.log(`[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`);
540
+ void this.handleRealtimeCommand(event);
541
+ }),
542
+ );
543
+ this.unsubscribeHandlers.push(
544
+ this.relayAgentClient.on.reactionAdded((event: ReactionAddedEvent) => {
545
+ console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`);
546
+ void this.handleRealtimeReaction(event, 'added');
547
+ }),
548
+ );
549
+ this.unsubscribeHandlers.push(
550
+ this.relayAgentClient.on.reactionRemoved((event: ReactionRemovedEvent) => {
551
+ console.log(`[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`);
552
+ void this.handleRealtimeReaction(event, 'removed');
553
+ }),
554
+ );
524
555
  this.unsubscribeHandlers.push(
525
556
  this.relayAgentClient.on.reconnecting((attempt: number) => {
526
557
  console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
@@ -547,14 +578,6 @@ export class InboundGateway {
547
578
  // Will subscribe on next connected event
548
579
  }
549
580
 
550
- // Initial catch-up in case messages arrived before realtime subscription was active.
551
- await this.pollMessages();
552
-
553
- // Keep a low-frequency poll as recovery/backfill only.
554
- this.pollTimer = setInterval(() => {
555
- void this.pollMessages();
556
- }, this.fallbackPollMs);
557
-
558
581
  console.log(
559
582
  `[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`,
560
583
  );
@@ -567,11 +590,6 @@ export class InboundGateway {
567
590
  async stop(): Promise<void> {
568
591
  this.running = false;
569
592
 
570
- if (this.pollTimer) {
571
- clearInterval(this.pollTimer);
572
- this.pollTimer = null;
573
- }
574
-
575
593
  for (const unsubscribe of this.unsubscribeHandlers) {
576
594
  try {
577
595
  unsubscribe();
@@ -603,7 +621,6 @@ export class InboundGateway {
603
621
  await this.spawnManager.releaseAll();
604
622
 
605
623
  this.processingMessageIds.clear();
606
- this.channelCursor.clear();
607
624
  this.seenMessageIds.clear();
608
625
  }
609
626
 
@@ -681,40 +698,87 @@ export class InboundGateway {
681
698
  await this.handleInbound(inbound);
682
699
  }
683
700
 
684
- private normalizePolledMessage(channel: string, message: MessageWithMeta): InboundMessage {
685
- return {
686
- id: message.id,
687
- channel,
688
- from: message.agentName,
689
- text: message.text,
690
- timestamp: message.createdAt,
701
+ private async handleRealtimeDm(event: DmReceivedEvent): Promise<void> {
702
+ const messageId = event.message?.id;
703
+ if (!messageId) return;
704
+
705
+ const inbound: InboundMessage = {
706
+ id: messageId,
707
+ channel: 'dm',
708
+ from: event.message.agentName,
709
+ text: event.message.text,
710
+ timestamp: new Date().toISOString(),
711
+ conversationId: event.conversationId,
712
+ kind: 'dm',
691
713
  };
714
+
715
+ await this.handleInbound(inbound);
692
716
  }
693
717
 
694
- /** Poll channels for catch-up/recovery only. */
695
- private async pollMessages(): Promise<void> {
696
- if (!this.running) return;
718
+ private async handleRealtimeGroupDm(event: GroupDmReceivedEvent): Promise<void> {
719
+ const messageId = event.message?.id;
720
+ if (!messageId) return;
697
721
 
698
- for (const channel of this.config.channels) {
699
- try {
700
- const after = this.channelCursor.get(channel);
701
- const query: { limit: number; after?: string } = { limit: 50 };
702
- if (after) {
703
- query.after = after;
704
- }
722
+ const inbound: InboundMessage = {
723
+ id: messageId,
724
+ channel: `groupdm:${event.conversationId}`,
725
+ from: event.message.agentName,
726
+ text: event.message.text,
727
+ timestamp: new Date().toISOString(),
728
+ conversationId: event.conversationId,
729
+ kind: 'groupdm',
730
+ };
731
+
732
+ await this.handleInbound(inbound);
733
+ }
705
734
 
706
- const messages = await this.relaycast.messages.list(channel, query);
707
- const ordered = [...messages].sort((a, b) =>
708
- a.createdAt.localeCompare(b.createdAt),
709
- );
735
+ private async handleRealtimeCommand(event: CommandInvokedEvent): Promise<void> {
736
+ const channel = normalizeChannelName(event.channel);
737
+ if (!this.config.channels.includes(channel)) return;
710
738
 
711
- for (const message of ordered) {
712
- await this.handleInbound(this.normalizePolledMessage(channel, message));
713
- }
714
- } catch (err) {
715
- console.warn(`[gateway] Poll error for #${channel}: ${err instanceof Error ? err.message : String(err)}`);
716
- }
717
- }
739
+ // Commands lack a server-assigned event ID, so we synthesize one.
740
+ // We include args + timestamp to avoid silently dropping legitimate
741
+ // repeat invocations (e.g. /deploy twice in 15 min). This means SDK
742
+ // reconnection replays may deliver a duplicate, but that's less
743
+ // harmful than silently swallowing a real command.
744
+ const argsSlug = event.args ? `_${event.args}` : '';
745
+ const syntheticId = `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
746
+ const argsText = event.args ? ` ${event.args}` : '';
747
+
748
+ const inbound: InboundMessage = {
749
+ id: syntheticId,
750
+ channel,
751
+ from: event.invokedBy,
752
+ text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`,
753
+ timestamp: new Date().toISOString(),
754
+ kind: 'command',
755
+ };
756
+
757
+ await this.handleInbound(inbound);
758
+ }
759
+
760
+ private async handleRealtimeReaction(
761
+ event: ReactionAddedEvent | ReactionRemovedEvent,
762
+ action: 'added' | 'removed',
763
+ ): Promise<void> {
764
+ // Include timestamp so add→remove→re-add of the same emoji isn't
765
+ // silently dropped within the 15-min dedup window. Reactions are soft
766
+ // notifications, so a rare duplicate on SDK reconnect is acceptable.
767
+ const syntheticId = `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
768
+ const text = action === 'added'
769
+ ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
770
+ : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
771
+
772
+ const inbound: InboundMessage = {
773
+ id: syntheticId,
774
+ channel: 'reaction',
775
+ from: event.agentName,
776
+ text,
777
+ timestamp: new Date().toISOString(),
778
+ kind: 'reaction',
779
+ };
780
+
781
+ await this.handleInbound(inbound);
718
782
  }
719
783
 
720
784
  private async handleInbound(message: InboundMessage): Promise<void> {
@@ -724,13 +788,13 @@ export class InboundGateway {
724
788
  // Avoid echo loops — skip messages from this claw or its viewer identity.
725
789
  const viewerName = `viewer-${this.config.clawName}`;
726
790
  if (message.from === this.config.clawName || message.from === viewerName) {
727
- this.channelCursor.set(normalizeChannelName(message.channel), message.id);
791
+ // Only update cursor for real channels with real (non-synthetic) message IDs.
728
792
  this.markSeen(message.id);
729
793
  return;
730
794
  }
731
795
 
732
796
  // Mark as seen immediately to prevent duplicate delivery from concurrent
733
- // realtime + poll paths processing the same message.
797
+ // realtime events processing the same message.
734
798
  this.markSeen(message.id);
735
799
  this.processingMessageIds.add(message.id);
736
800
 
@@ -738,7 +802,6 @@ export class InboundGateway {
738
802
  try {
739
803
  const result = await this.onMessage(message);
740
804
  console.log(`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`);
741
- this.channelCursor.set(normalizeChannelName(message.channel), message.id);
742
805
  } finally {
743
806
  this.processingMessageIds.delete(message.id);
744
807
  }
@@ -746,6 +809,16 @@ export class InboundGateway {
746
809
 
747
810
  /** Format delivery text with channel, sender, and optional thread prefix. */
748
811
  private formatDeliveryText(message: InboundMessage): string {
812
+ // Pre-formatted kinds (command, reaction) already have the full text.
813
+ if (message.kind === 'command' || message.kind === 'reaction') {
814
+ return message.text;
815
+ }
816
+ if (message.kind === 'dm') {
817
+ return `[relaycast:dm] @${message.from}: ${message.text}`;
818
+ }
819
+ if (message.kind === 'groupdm') {
820
+ return `[relaycast:groupdm] @${message.from}: ${message.text}`;
821
+ }
749
822
  const threadPrefix = message.threadParentId ? '[thread] ' : '';
750
823
  return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
751
824
  }
@@ -16,7 +16,7 @@ export interface GatewayConfig {
16
16
  export interface InboundMessage {
17
17
  /** Relaycast message ID. */
18
18
  id: string;
19
- /** Channel the message was posted to. */
19
+ /** Channel the message was posted to. Synthetic for DMs (e.g. "dm", "groupdm:{id}"). */
20
20
  channel: string;
21
21
  /** Agent name of the sender. */
22
22
  from: string;
@@ -26,6 +26,10 @@ export interface InboundMessage {
26
26
  timestamp: string;
27
27
  /** Parent message ID when this is a thread reply. */
28
28
  threadParentId?: string;
29
+ /** Conversation ID for DMs / group DMs. */
30
+ conversationId?: string;
31
+ /** Message kind hint for formatting. */
32
+ kind?: 'channel' | 'thread' | 'dm' | 'groupdm' | 'command' | 'reaction';
29
33
  }
30
34
 
31
35
  export interface DeliveryResult {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/policy",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Agent policy management with multi-level fallback (repo, local PRPM, cloud workspace)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test:watch": "vitest"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-relay/config": "3.1.1"
25
+ "@agent-relay/config": "3.1.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.19.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/sdk",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -81,7 +81,7 @@
81
81
  "typescript": "^5.7.3"
82
82
  },
83
83
  "dependencies": {
84
- "@agent-relay/config": "3.1.1",
84
+ "@agent-relay/config": "3.1.2",
85
85
  "@relaycast/sdk": "^0.4.0",
86
86
  "yaml": "^2.7.0"
87
87
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-relay-sdk"
7
- version = "3.1.1"
7
+ version = "3.1.2"
8
8
  description = "Python SDK for Agent Relay workflows"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/telemetry",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Anonymous telemetry for Agent Relay usage analytics",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/trajectory",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Trajectory integration utilities (trail/PDERO) for Relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test:watch": "vitest"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-relay/config": "3.1.1"
25
+ "@agent-relay/config": "3.1.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.19.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/user-directory",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "User directory service for agent-relay (per-user credential storage)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test:watch": "vitest"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-relay/utils": "3.1.1"
25
+ "@agent-relay/utils": "3.1.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.19.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/utils",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Shared utilities for agent-relay: logging, name generation, command resolution, update checking",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -112,7 +112,7 @@
112
112
  "vitest": "^3.2.4"
113
113
  },
114
114
  "dependencies": {
115
- "@agent-relay/config": "3.1.1",
115
+ "@agent-relay/config": "3.1.2",
116
116
  "compare-versions": "^6.1.1"
117
117
  },
118
118
  "publishConfig": {