@relaymessenger/openclaw-plugin 0.4.7-staging.34 → 0.4.7-staging.36

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Relay for OpenClaw
2
2
 
3
3
  `@relaymessenger/openclaw-plugin` is the native Relay channel for OpenClaw
4
- `2026.8.1`.
4
+ `2026.8.1` through `2026.9.6`, the versions its gateway harness runs against.
5
5
 
6
6
  Source is maintained in
7
7
  [`RelayMessenger/Relay-SDK`](https://github.com/RelayMessenger/Relay-SDK/tree/main/packages/openclaw)
@@ -42,6 +42,15 @@ go first and the payment card follows as its own Message.
42
42
  openclaw plugins install @relaymessenger/openclaw-plugin
43
43
  ```
44
44
 
45
+ OpenClaw asks two questions for a plugin from npm: whether you trust a source
46
+ outside ClawHub, and whether to accept the capabilities the plugin declares.
47
+ This plugin declares one capability, the `relay` channel. Where no terminal
48
+ can answer, pass both answers:
49
+
50
+ ```bash
51
+ openclaw plugins install @relaymessenger/openclaw-plugin --force --accept-capabilities
52
+ ```
53
+
45
54
  Configure the default account:
46
55
 
47
56
  ```json
@@ -130,6 +139,16 @@ Without `allowFrom`, any user or agent Contact whose Message Relay delivers
130
139
  to this agent can start a direct turn, while the group activation rules above
131
140
  still apply.
132
141
 
142
+ ## Messages from another agent
143
+
144
+ Another agent's call reaches this agent as a Message, and Relay gives the
145
+ caller the answer whose `reply_to` names its Message. So every answer to
146
+ another agent names the Message it answers. When the same agent sends a second
147
+ Message while a turn is still running in that Chat, the plugin holds it until
148
+ the turn ends, then gives it a turn of its own. OpenClaw would otherwise steer
149
+ it into the running turn, and the second caller would get no answer. A
150
+ person's Messages keep OpenClaw's own queue and reply behavior.
151
+
133
152
  ## Durable delivery
134
153
 
135
154
  For every WebSocket event, the plugin:
@@ -177,10 +196,12 @@ exact SHA selected from the `staging` branch, the matching
177
196
  validated tarball and publishes that same digest with npm provenance; its
178
197
  publish job is also bound to the `staging` GitHub environment.
179
198
 
180
- `gateway:harness` packs the plugin, installs the tarball with OpenClaw
181
- `2026.8.1`, inspects the managed installation, starts a real OpenClaw gateway,
182
- connects to a loopback Relay WebSocket, receives one Message, and proves the
183
- durable ACK and idempotent REST reply.
199
+ `gateway:harness` packs the plugin, installs the tarball with the OpenClaw
200
+ version in `devDependencies`, inspects the managed installation, starts a real
201
+ OpenClaw gateway, connects to a loopback Relay WebSocket, receives one Message,
202
+ and proves the durable ACK and idempotent REST reply. Its `--overlap` run sends
203
+ two Messages from one agent, the second while the model still answers the
204
+ first, and requires two answers, each naming its own Message.
184
205
 
185
206
  ## Contract lock
186
207
 
@@ -7,9 +7,9 @@
7
7
  },
8
8
  "relaySdk": {
9
9
  "package": "@relaymessenger/sdk",
10
- "version": "0.3.6-staging.37",
11
- "integrity": "sha512-BpAVxmaQZpQqSeIBEkv2MSoesvv+qR/BeEBJ044eb6yza1jYeG8Ibw95qKe1dg9lWSgNzEKMCn2+jEqYvTRoTw==",
12
- "operationsSha256": "dc65744bdac51c47863b72536ae7ac7914a72d882f58f8ccc7d1cd3e5a54bec6",
10
+ "version": "0.3.6-staging.42",
11
+ "integrity": "sha512-pSZjl2+6aNSrx8jn6xOU+eG2gzd4bkeFPdDKW2rwLuPPJjAB0rsgY8DEp+ukXG+BjSTyKolKwzFLPf55DKFXYw==",
12
+ "operationsSha256": "bb36ba7380042a0d7ceb5064fd15e883e1e6fe11a28fc0d5157bb0df501111e3",
13
13
  "workspaceOpenapiSha256": "3ac33f08a16f83be44585a34df34d7067f9157a8971e63686ab41f44374ce5f8",
14
14
  "usedOperations": [
15
15
  {
@@ -3,6 +3,7 @@ import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, }
3
3
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
4
4
  import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
5
5
  import { buildRelayInboundFacts } from "./inbound.js";
6
+ import { waitForIdleChat } from "./turns.js";
6
7
  function isReplyToAgentMessage(message, chatId) {
7
8
  return message.chat_id === chatId && message.is_from_me === true;
8
9
  }
@@ -38,6 +39,21 @@ export async function resolveRelayTurnActivation(params) {
38
39
  implicitMentionKinds: ["reply_to_bot"],
39
40
  };
40
41
  }
42
+ /**
43
+ * The answer to another agent names the Message it answers: the model's own
44
+ * reply target when it chose one, else the agent's Message. Where no reply may
45
+ * point (a Message opening with buttons or a selection), OpenClaw's implicit
46
+ * current-message reply is removed.
47
+ */
48
+ export function agentReplyPayload(payload, facts) {
49
+ if (facts.agentReplyLink) {
50
+ return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
51
+ }
52
+ if (payload.replyToId !== facts.messageId)
53
+ return payload;
54
+ const { replyToId: _unlinked, ...rest } = payload;
55
+ return rest;
56
+ }
41
57
  export async function dispatchRelayEvent(params) {
42
58
  const facts = buildRelayInboundFacts(params.event);
43
59
  if (!facts) {
@@ -137,6 +153,11 @@ export async function dispatchRelayEvent(params) {
137
153
  params.warn?.(`relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`);
138
154
  return;
139
155
  }
156
+ // An agent's reply target is only its own Message (agentReplyLink); a
157
+ // person's is the Message they replied from, as before.
158
+ const replyTarget = facts.fromAgent
159
+ ? facts.agentReplyLink
160
+ : facts.replyAnchorId ?? facts.replyToId;
140
161
  const body = buildEnvelope({
141
162
  channel: "Relay",
142
163
  from: `${facts.displayName} (@${facts.handle})`,
@@ -171,9 +192,7 @@ export async function dispatchRelayEvent(params) {
171
192
  reply: {
172
193
  to: facts.chatId,
173
194
  originatingTo: facts.chatId,
174
- ...((facts.replyAnchorId ?? facts.replyToId)
175
- ? { replyToId: facts.replyAnchorId ?? facts.replyToId }
176
- : {}),
195
+ ...(replyTarget ? { replyToId: replyTarget } : {}),
177
196
  },
178
197
  message: {
179
198
  inboundEventKind: "user_request",
@@ -200,6 +219,15 @@ export async function dispatchRelayEvent(params) {
200
219
  },
201
220
  },
202
221
  });
222
+ // Another agent's Message waits for the turn running in its Chat, so it
223
+ // gets a turn and an answer of its own (turns.ts).
224
+ if (facts.fromAgent) {
225
+ await waitForIdleChat({
226
+ turns: params.turns,
227
+ chatId: facts.chatId,
228
+ lifecycle: params.lifecycle,
229
+ });
230
+ }
203
231
  await Promise.allSettled([
204
232
  params.relay.chats.markAsRead(facts.chatId),
205
233
  params.relay.chats.startTyping(facts.chatId),
@@ -212,7 +240,7 @@ export async function dispatchRelayEvent(params) {
212
240
  });
213
241
  let deliveryError;
214
242
  try {
215
- await params.runtime.channel.inbound.dispatch({
243
+ await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
216
244
  cfg: params.cfg,
217
245
  channel: "relay",
218
246
  accountId: params.account.accountId,
@@ -225,9 +253,17 @@ export async function dispatchRelayEvent(params) {
225
253
  delivery: {
226
254
  durable: {
227
255
  to: facts.chatId,
228
- replyToId: null,
256
+ replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
229
257
  requiredCapabilities: { reconcileUnknownSend: true },
230
258
  },
259
+ // Every answer to another agent names its Message, whatever
260
+ // `replyToMode` the operator chose; OpenClaw's own implicit
261
+ // current-message reply is dropped where no reply may point.
262
+ ...(facts.fromAgent
263
+ ? {
264
+ preparePayload: (payload) => agentReplyPayload(payload, facts),
265
+ }
266
+ : {}),
231
267
  deliver: async (_payload, info) => {
232
268
  if (info.kind === "final") {
233
269
  throw new Error("relay: durable final Message delivery was unavailable");
@@ -250,7 +286,7 @@ export async function dispatchRelayEvent(params) {
250
286
  : new Error(`relay: session record failed: ${String(error)}`);
251
287
  },
252
288
  },
253
- });
289
+ }));
254
290
  if (deliveryError) {
255
291
  throw deliveryError instanceof Error
256
292
  ? deliveryError
@@ -4,6 +4,7 @@ import { dispatchRelayEvent } from "./dispatch.js";
4
4
  import { commitRelayFullSync } from "./full-sync.js";
5
5
  import { createRelayIngressMonitor } from "./ingress.js";
6
6
  import { createRelaySdkClient } from "./outbound.js";
7
+ import { createRelayChatTurns } from "./turns.js";
7
8
  import { getRelayRuntime } from "./runtime.js";
8
9
  import { openRelayStateStore, } from "./state.js";
9
10
  const runningCredentials = new Map();
@@ -58,6 +59,7 @@ export async function startRelayAccount(ctx) {
58
59
  accountId: transportId,
59
60
  });
60
61
  const relay = createRelaySdkClient(account);
62
+ const turns = createRelayChatTurns();
61
63
  const ingress = createRelayIngressMonitor({
62
64
  queue: openIngressQueue({
63
65
  transportId,
@@ -80,6 +82,7 @@ export async function startRelayAccount(ctx) {
80
82
  cfg: ctx.cfg,
81
83
  relay,
82
84
  runtime,
85
+ turns,
83
86
  warn,
84
87
  });
85
88
  },
@@ -56,9 +56,22 @@ export function buildRelayInboundFacts(event) {
56
56
  const timestampValue = event.data.sent_at ?? event.created_at;
57
57
  const timestamp = Date.parse(timestampValue);
58
58
  const selection = selectionReply(event.data.parts, event.data.reply_to);
59
+ const fromAgent = event.data.sender_handle.kind === "agent";
60
+ // Another agent's Message is named by the answer, as Relay's CLI bridges
61
+ // do (packages/cli/src/bridge-turn.ts, PR 366): Relay's A2A door gives a
62
+ // calling agent only the answer whose reply_to names its Message
63
+ // (Relay-Server a2a.ts replyTo). A Message that opens with buttons or a
64
+ // selection is not named: an agent may not reply to those parts, and a
65
+ // reply names part 0.
66
+ const opening = event.data.parts[0]?.type;
67
+ const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection"
68
+ ? event.data.id
69
+ : undefined;
59
70
  return {
60
71
  ...(selection ? { selection } : {}),
61
72
  ...(richMessage ? { richMessage } : {}),
73
+ fromAgent,
74
+ ...(agentReplyLink ? { agentReplyLink } : {}),
62
75
  eventId: event.event_id,
63
76
  messageId: event.data.id,
64
77
  chatId: event.data.chat.id,
@@ -78,8 +91,9 @@ export function buildRelayInboundFacts(event) {
78
91
  // The answer quotes the person's message, the one it answers (a bot's
79
92
  // reply_to in Telegram and Discord names the person's message). A
80
93
  // tap's reply_to names the agent's buttons part, which no reply may
81
- // target, so this is also what keeps a tap answerable.
82
- replyAnchorId: event.data.id,
94
+ // target, so this is also what keeps a tap answerable. An agent's
95
+ // Message is quoted only through agentReplyLink.
96
+ ...(fromAgent ? {} : { replyAnchorId: event.data.id }),
83
97
  }
84
98
  : {}),
85
99
  ...(Number.isFinite(timestamp) ? { timestamp } : {}),
@@ -0,0 +1,69 @@
1
+ export function createRelayChatTurns() {
2
+ const running = new Map();
3
+ return {
4
+ track(chatId, work) {
5
+ const turns = running.get(chatId) ?? new Set();
6
+ running.set(chatId, turns);
7
+ const turn = work();
8
+ turns.add(turn);
9
+ const settle = () => {
10
+ turns.delete(turn);
11
+ if (turns.size === 0 && running.get(chatId) === turns)
12
+ running.delete(chatId);
13
+ };
14
+ turn.then(settle, settle);
15
+ return turn;
16
+ },
17
+ busy: (chatId) => Boolean(running.get(chatId)?.size),
18
+ async idle(chatId, signal) {
19
+ for (;;) {
20
+ signal?.throwIfAborted();
21
+ const turns = running.get(chatId);
22
+ if (!turns?.size)
23
+ return;
24
+ const settled = Promise.allSettled([...turns]);
25
+ if (!signal) {
26
+ await settled;
27
+ continue;
28
+ }
29
+ await new Promise((resolve, reject) => {
30
+ const abort = () => reject(signal.reason);
31
+ signal.addEventListener("abort", abort, { once: true });
32
+ void settled.then(() => {
33
+ signal.removeEventListener("abort", abort);
34
+ resolve();
35
+ });
36
+ });
37
+ }
38
+ },
39
+ };
40
+ }
41
+ /**
42
+ * Hold a claimed Relay event until its Chat is idle. The claim is handed off
43
+ * as deferred and kept alive with the drain's own heartbeat
44
+ * (`ChannelIngressDispatchLifecycle.onDeferred` / `onDeferredHeartbeat`,
45
+ * docs/plugins/sdk-channel-outbound.md "Deferred claim heartbeats"), so the
46
+ * adoption watchdog does not retry a Message that is only waiting its turn. On
47
+ * shutdown the wait rejects before adoption, and the drain keeps the event for
48
+ * the next start.
49
+ */
50
+ export async function waitForIdleChat(params) {
51
+ const { lifecycle } = params;
52
+ if (!params.turns.busy(params.chatId))
53
+ return;
54
+ const signal = lifecycle.abortSignal;
55
+ lifecycle.onDeferred?.();
56
+ const interval = lifecycle.deferredHeartbeatIntervalMs;
57
+ const heartbeat = lifecycle.onDeferredHeartbeat && interval && interval > 0
58
+ ? setInterval(() => lifecycle.onDeferredHeartbeat?.(), interval)
59
+ : undefined;
60
+ heartbeat?.unref?.();
61
+ try {
62
+ await params.turns.idle(params.chatId, signal);
63
+ }
64
+ finally {
65
+ if (heartbeat)
66
+ clearInterval(heartbeat);
67
+ }
68
+ }
69
+ //# sourceMappingURL=turns.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/openclaw-plugin",
3
- "version": "0.4.7-staging.34",
3
+ "version": "0.4.7-staging.36",
4
4
  "description": "Native Relay channel plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,11 +33,11 @@
33
33
  "contract:verify": "node scripts/verify-contract-provenance.mjs",
34
34
  "contract:test": "node --test test/*.test.mjs",
35
35
  "pack:smoke": "node scripts/pack-smoke.mjs",
36
- "gateway:harness": "node scripts/gateway-harness.mjs",
36
+ "gateway:harness": "node scripts/gateway-harness.mjs && node scripts/gateway-harness.mjs --overlap",
37
37
  "prepack": "npm run build"
38
38
  },
39
39
  "dependencies": {
40
- "@relaymessenger/sdk": "0.3.6-staging.37"
40
+ "@relaymessenger/sdk": "0.3.6-staging.42"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.6.2",
package/src/dispatch.ts CHANGED
@@ -11,9 +11,11 @@ import {
11
11
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
12
12
  import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
13
13
  import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
14
+ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
14
15
  import { buildRelayInboundFacts } from "./inbound.js";
15
16
  import type { RelayIngressLifecycle } from "./ingress.js";
16
17
  import type { PluginRuntime } from "./runtime.js";
18
+ import { type RelayChatTurns, waitForIdleChat } from "./turns.js";
17
19
  import type {
18
20
  RelayCoreConfig,
19
21
  RelayInboundFacts,
@@ -86,6 +88,24 @@ export async function resolveRelayTurnActivation(params: {
86
88
  };
87
89
  }
88
90
 
91
+ /**
92
+ * The answer to another agent names the Message it answers: the model's own
93
+ * reply target when it chose one, else the agent's Message. Where no reply may
94
+ * point (a Message opening with buttons or a selection), OpenClaw's implicit
95
+ * current-message reply is removed.
96
+ */
97
+ export function agentReplyPayload(
98
+ payload: ReplyPayload,
99
+ facts: Pick<RelayInboundFacts, "messageId" | "agentReplyLink">,
100
+ ): ReplyPayload {
101
+ if (facts.agentReplyLink) {
102
+ return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
103
+ }
104
+ if (payload.replyToId !== facts.messageId) return payload;
105
+ const { replyToId: _unlinked, ...rest } = payload;
106
+ return rest;
107
+ }
108
+
89
109
  export async function dispatchRelayEvent(params: {
90
110
  event: RelayWebhookEvent;
91
111
  lifecycle: RelayIngressLifecycle;
@@ -93,6 +113,7 @@ export async function dispatchRelayEvent(params: {
93
113
  cfg: RelayCoreConfig;
94
114
  relay: Pick<Relay, "chats" | "messages">;
95
115
  runtime: PluginRuntime;
116
+ turns: RelayChatTurns;
96
117
  warn?: (message: string) => void;
97
118
  }): Promise<void> {
98
119
  const facts = buildRelayInboundFacts(params.event);
@@ -203,6 +224,11 @@ export async function dispatchRelayEvent(params: {
203
224
  return;
204
225
  }
205
226
 
227
+ // An agent's reply target is only its own Message (agentReplyLink); a
228
+ // person's is the Message they replied from, as before.
229
+ const replyTarget = facts.fromAgent
230
+ ? facts.agentReplyLink
231
+ : facts.replyAnchorId ?? facts.replyToId;
206
232
  const body = buildEnvelope({
207
233
  channel: "Relay",
208
234
  from: `${facts.displayName} (@${facts.handle})`,
@@ -237,9 +263,7 @@ export async function dispatchRelayEvent(params: {
237
263
  reply: {
238
264
  to: facts.chatId,
239
265
  originatingTo: facts.chatId,
240
- ...((facts.replyAnchorId ?? facts.replyToId)
241
- ? { replyToId: facts.replyAnchorId ?? facts.replyToId }
242
- : {}),
266
+ ...(replyTarget ? { replyToId: replyTarget } : {}),
243
267
  },
244
268
  message: {
245
269
  inboundEventKind: "user_request",
@@ -267,6 +291,16 @@ export async function dispatchRelayEvent(params: {
267
291
  },
268
292
  });
269
293
 
294
+ // Another agent's Message waits for the turn running in its Chat, so it
295
+ // gets a turn and an answer of its own (turns.ts).
296
+ if (facts.fromAgent) {
297
+ await waitForIdleChat({
298
+ turns: params.turns,
299
+ chatId: facts.chatId,
300
+ lifecycle: params.lifecycle,
301
+ });
302
+ }
303
+
270
304
  await Promise.allSettled([
271
305
  params.relay.chats.markAsRead(facts.chatId),
272
306
  params.relay.chats.startTyping(facts.chatId),
@@ -280,7 +314,7 @@ export async function dispatchRelayEvent(params: {
280
314
 
281
315
  let deliveryError: unknown;
282
316
  try {
283
- await params.runtime.channel.inbound.dispatch({
317
+ await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
284
318
  cfg: params.cfg as OpenClawConfig,
285
319
  channel: "relay",
286
320
  accountId: params.account.accountId,
@@ -293,9 +327,18 @@ export async function dispatchRelayEvent(params: {
293
327
  delivery: {
294
328
  durable: {
295
329
  to: facts.chatId,
296
- replyToId: null,
330
+ replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
297
331
  requiredCapabilities: { reconcileUnknownSend: true },
298
332
  },
333
+ // Every answer to another agent names its Message, whatever
334
+ // `replyToMode` the operator chose; OpenClaw's own implicit
335
+ // current-message reply is dropped where no reply may point.
336
+ ...(facts.fromAgent
337
+ ? {
338
+ preparePayload: (payload: ReplyPayload) =>
339
+ agentReplyPayload(payload, facts),
340
+ }
341
+ : {}),
299
342
  deliver: async (_payload, info) => {
300
343
  if (info.kind === "final") {
301
344
  throw new Error(
@@ -320,7 +363,7 @@ export async function dispatchRelayEvent(params: {
320
363
  : new Error(`relay: session record failed: ${String(error)}`);
321
364
  },
322
365
  },
323
- });
366
+ }));
324
367
  if (deliveryError) {
325
368
  throw deliveryError instanceof Error
326
369
  ? deliveryError
package/src/gateway.ts CHANGED
@@ -10,6 +10,7 @@ import { dispatchRelayEvent } from "./dispatch.js";
10
10
  import { commitRelayFullSync } from "./full-sync.js";
11
11
  import { createRelayIngressMonitor } from "./ingress.js";
12
12
  import { createRelaySdkClient } from "./outbound.js";
13
+ import { createRelayChatTurns } from "./turns.js";
13
14
  import { getRelayRuntime } from "./runtime.js";
14
15
  import {
15
16
  openRelayStateStore,
@@ -96,6 +97,7 @@ export async function startRelayAccount(
96
97
  accountId: transportId,
97
98
  });
98
99
  const relay = createRelaySdkClient(account);
100
+ const turns = createRelayChatTurns();
99
101
  const ingress = createRelayIngressMonitor({
100
102
  queue: openIngressQueue({
101
103
  transportId,
@@ -119,6 +121,7 @@ export async function startRelayAccount(
119
121
  cfg: ctx.cfg as RelayCoreConfig,
120
122
  relay,
121
123
  runtime,
124
+ turns,
122
125
  warn,
123
126
  });
124
127
  },
package/src/inbound.ts CHANGED
@@ -78,9 +78,22 @@ export function buildRelayInboundFacts(
78
78
  const timestampValue = event.data.sent_at ?? event.created_at;
79
79
  const timestamp = Date.parse(timestampValue);
80
80
  const selection = selectionReply(event.data.parts, event.data.reply_to);
81
+ const fromAgent = event.data.sender_handle.kind === "agent";
82
+ // Another agent's Message is named by the answer, as Relay's CLI bridges
83
+ // do (packages/cli/src/bridge-turn.ts, PR 366): Relay's A2A door gives a
84
+ // calling agent only the answer whose reply_to names its Message
85
+ // (Relay-Server a2a.ts replyTo). A Message that opens with buttons or a
86
+ // selection is not named: an agent may not reply to those parts, and a
87
+ // reply names part 0.
88
+ const opening = event.data.parts[0]?.type;
89
+ const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection"
90
+ ? event.data.id
91
+ : undefined;
81
92
  return {
82
93
  ...(selection ? { selection } : {}),
83
94
  ...(richMessage ? { richMessage } : {}),
95
+ fromAgent,
96
+ ...(agentReplyLink ? { agentReplyLink } : {}),
84
97
  eventId: event.event_id,
85
98
  messageId: event.data.id,
86
99
  chatId: event.data.chat.id,
@@ -101,8 +114,9 @@ export function buildRelayInboundFacts(
101
114
  // The answer quotes the person's message, the one it answers (a bot's
102
115
  // reply_to in Telegram and Discord names the person's message). A
103
116
  // tap's reply_to names the agent's buttons part, which no reply may
104
- // target, so this is also what keeps a tap answerable.
105
- replyAnchorId: event.data.id,
117
+ // target, so this is also what keeps a tap answerable. An agent's
118
+ // Message is quoted only through agentReplyLink.
119
+ ...(fromAgent ? {} : { replyAnchorId: event.data.id }),
106
120
  }
107
121
  : {}),
108
122
  ...(Number.isFinite(timestamp) ? { timestamp } : {}),
package/src/turns.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { RelayIngressLifecycle } from "./ingress.js";
2
+
3
+ /**
4
+ * The OpenClaw turns running in each Chat of one Relay account, so another
5
+ * agent's Message can wait for them instead of steering into them.
6
+ *
7
+ * OpenClaw steers a Message that arrives mid-turn into the running turn by
8
+ * default (`messages.queue.mode` "steer", docs/concepts/queue.md), and that
9
+ * turn's answer names the first Message. With `followup` the queued turn's
10
+ * answer names none. Relay's A2A door gives each calling agent only the answer
11
+ * whose `reply_to` names its Message (Relay-Server `a2a.ts` `replyTo`), so the
12
+ * second of two overlapping calls got no answer. A channel plugin "may
13
+ * preserve ordering ... before a message enters the session queue"
14
+ * (docs/concepts/messages.md, Queueing and followups); this is that ordering,
15
+ * the same rule Relay's CLI bridges follow (`replacesLiveTurn`, PR 366): an
16
+ * agent's Message waits its turn, a person's Message is left to OpenClaw.
17
+ */
18
+ export type RelayChatTurns = {
19
+ /** Run one dispatch into OpenClaw, recorded as running in its Chat until it settles. */
20
+ track<T>(chatId: string, work: () => Promise<T>): Promise<T>;
21
+ /** Whether a turn runs in the Chat now. */
22
+ busy(chatId: string): boolean;
23
+ /** Resolve once no turn runs in the Chat; reject with the signal's reason. */
24
+ idle(chatId: string, signal?: AbortSignal): Promise<void>;
25
+ };
26
+
27
+ export function createRelayChatTurns(): RelayChatTurns {
28
+ const running = new Map<string, Set<Promise<unknown>>>();
29
+ return {
30
+ track(chatId, work) {
31
+ const turns = running.get(chatId) ?? new Set<Promise<unknown>>();
32
+ running.set(chatId, turns);
33
+ const turn = work();
34
+ turns.add(turn);
35
+ const settle = () => {
36
+ turns.delete(turn);
37
+ if (turns.size === 0 && running.get(chatId) === turns) running.delete(chatId);
38
+ };
39
+ turn.then(settle, settle);
40
+ return turn;
41
+ },
42
+ busy: (chatId) => Boolean(running.get(chatId)?.size),
43
+ async idle(chatId, signal) {
44
+ for (;;) {
45
+ signal?.throwIfAborted();
46
+ const turns = running.get(chatId);
47
+ if (!turns?.size) return;
48
+ const settled = Promise.allSettled([...turns]);
49
+ if (!signal) {
50
+ await settled;
51
+ continue;
52
+ }
53
+ await new Promise<void>((resolve, reject) => {
54
+ const abort = () => reject(signal.reason);
55
+ signal.addEventListener("abort", abort, { once: true });
56
+ void settled.then(() => {
57
+ signal.removeEventListener("abort", abort);
58
+ resolve();
59
+ });
60
+ });
61
+ }
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Hold a claimed Relay event until its Chat is idle. The claim is handed off
68
+ * as deferred and kept alive with the drain's own heartbeat
69
+ * (`ChannelIngressDispatchLifecycle.onDeferred` / `onDeferredHeartbeat`,
70
+ * docs/plugins/sdk-channel-outbound.md "Deferred claim heartbeats"), so the
71
+ * adoption watchdog does not retry a Message that is only waiting its turn. On
72
+ * shutdown the wait rejects before adoption, and the drain keeps the event for
73
+ * the next start.
74
+ */
75
+ export async function waitForIdleChat(params: {
76
+ turns: RelayChatTurns;
77
+ chatId: string;
78
+ lifecycle: Partial<RelayIngressLifecycle>;
79
+ }): Promise<void> {
80
+ const { lifecycle } = params;
81
+ if (!params.turns.busy(params.chatId)) return;
82
+ const signal = lifecycle.abortSignal;
83
+ lifecycle.onDeferred?.();
84
+ const interval = lifecycle.deferredHeartbeatIntervalMs;
85
+ const heartbeat = lifecycle.onDeferredHeartbeat && interval && interval > 0
86
+ ? setInterval(() => lifecycle.onDeferredHeartbeat?.(), interval)
87
+ : undefined;
88
+ heartbeat?.unref?.();
89
+ try {
90
+ await params.turns.idle(params.chatId, signal);
91
+ } finally {
92
+ if (heartbeat) clearInterval(heartbeat);
93
+ }
94
+ }
package/src/types.ts CHANGED
@@ -83,5 +83,12 @@ export type RelayInboundFacts = {
83
83
  * agent's buttons part, which no reply may target.
84
84
  */
85
85
  replyAnchorId?: string;
86
+ /** Whether another agent sent the Message. */
87
+ fromAgent: boolean;
88
+ /**
89
+ * The Message every answer names when another agent sent it: this one,
90
+ * unless it opens with buttons or a selection, which no reply may target.
91
+ */
92
+ agentReplyLink?: string;
86
93
  timestamp?: number;
87
94
  };