@botcord/daemon 0.2.1 → 0.2.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.
@@ -113,9 +113,10 @@ export function toGatewayConfig(cfg, opts = {}) {
113
113
  const routes = (cfg.routes ?? []).map(mapRoute);
114
114
  // Synthesize a per-agent route for every bound agent and hand it to the
115
115
  // gateway via the managed-routes bucket (plan §10.1). User-authored
116
- // `cfg.routes[]` stay untouched so an explicit operator override still
117
- // wins on conflict the gateway matches `routes[] → managedRoutes →
118
- // defaultRoute` in that order.
116
+ // `cfg.routes[]` stay untouched. Match priority (see router.ts):
117
+ // `routes[] with explicit accountId managedRoutes other routes[] →
118
+ // defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
119
+ // chosen runtime — only routes that name the agent by `accountId` do.
119
120
  const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
120
121
  return {
121
122
  channels,
@@ -82,6 +82,17 @@ declare function normalizeInbox(msg: InboxMessage, options: {
82
82
  channelId: string;
83
83
  accountId: string;
84
84
  }): GatewayInboundMessage | null;
85
+ /**
86
+ * Shape of the `raw` field when the channel batches multiple messages into
87
+ * one envelope. Keeps the latest message's InboxMessage fields at top level
88
+ * so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
89
+ * work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
90
+ * reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
91
+ * per entry.
92
+ */
93
+ export interface BatchedInboxRaw extends InboxMessage {
94
+ batch: InboxMessage[];
95
+ }
85
96
  /**
86
97
  * Construct a BotCord channel adapter.
87
98
  *
@@ -113,6 +113,50 @@ function normalizeInbox(msg, options) {
113
113
  trace: { id: msg.hub_msg_id, streamable },
114
114
  };
115
115
  }
116
+ /**
117
+ * Normalize a group of InboxMessages for the same `(room, topic)` into a
118
+ * single `GatewayInboundMessage`. The envelope carries the latest msg's
119
+ * metadata (routing, session key, trace) and a `raw.batch` array the
120
+ * composer uses to render per-sender blocks.
121
+ *
122
+ * `mentioned` is sticky: true if ANY message in the group is a mention.
123
+ * Returns null if no message in the group is normalizable on its own.
124
+ */
125
+ function normalizeInboxBatch(msgs, options) {
126
+ if (msgs.length === 0)
127
+ return null;
128
+ if (msgs.length === 1)
129
+ return normalizeInbox(msgs[0], options);
130
+ const latest = msgs[msgs.length - 1];
131
+ const base = normalizeInbox(latest, options);
132
+ if (!base)
133
+ return null;
134
+ // Fold sibling metadata into the base envelope. `text` is kept non-empty
135
+ // when at least one batched member has a body, so the dispatcher's empty-
136
+ // text skip rule doesn't drop the whole batch just because the latest
137
+ // envelope was e.g. a zero-payload contact_request.
138
+ const anyMentioned = msgs.some((m) => m.mentioned === true);
139
+ let representativeText = base.text ?? "";
140
+ if (!representativeText.trim()) {
141
+ for (let i = msgs.length - 1; i >= 0; i--) {
142
+ const m = msgs[i];
143
+ const candidate = m.text ??
144
+ (typeof m.envelope?.payload?.text === "string"
145
+ ? m.envelope.payload.text
146
+ : "");
147
+ if (candidate && candidate.trim()) {
148
+ representativeText = candidate;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ return {
154
+ ...base,
155
+ text: representativeText,
156
+ mentioned: anyMentioned,
157
+ raw: { ...latest, batch: msgs },
158
+ };
159
+ }
116
160
  /**
117
161
  * Construct a BotCord channel adapter.
118
162
  *
@@ -161,9 +205,14 @@ export function createBotCordChannel(options) {
161
205
  log.info("botcord inbox drained", { count: msgs.length });
162
206
  if (msgs.length === 0)
163
207
  return;
208
+ // First pass: ack duplicates/skipped messages so Hub stops requeueing,
209
+ // and collect eligible messages preserving poll order. Grouping by
210
+ // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
211
+ // same conversation thread folds into one turn so the agent sees all
212
+ // new messages at once instead of running N turns back-to-back.
213
+ const eligible = [];
164
214
  for (const msg of msgs) {
165
215
  if (!rememberSeen(msg.hub_msg_id)) {
166
- // Already emitted; ack again so Hub stops requeueing.
167
216
  try {
168
217
  await client.ackMessages([msg.hub_msg_id]);
169
218
  }
@@ -177,7 +226,6 @@ export function createBotCordChannel(options) {
177
226
  accountId: options.accountId,
178
227
  });
179
228
  if (!normalized) {
180
- // Not eligible (wrong type, missing room, etc.) — ack so it drops.
181
229
  try {
182
230
  await client.ackMessages([msg.hub_msg_id]);
183
231
  }
@@ -186,16 +234,42 @@ export function createBotCordChannel(options) {
186
234
  }
187
235
  continue;
188
236
  }
237
+ eligible.push(msg);
238
+ }
239
+ if (eligible.length === 0)
240
+ return;
241
+ // Group by `(room_id, topic)`. Insertion order is the poll order, so
242
+ // iterating the map yields groups with the same external chronology.
243
+ const groups = new Map();
244
+ for (const msg of eligible) {
245
+ const topic = msg.topic_id ?? msg.topic ?? "";
246
+ const key = `${msg.room_id ?? ""}:${topic}`;
247
+ const list = groups.get(key);
248
+ if (list)
249
+ list.push(msg);
250
+ else
251
+ groups.set(key, [msg]);
252
+ }
253
+ for (const group of groups.values()) {
254
+ const normalized = normalizeInboxBatch(group, {
255
+ channelId: options.id,
256
+ accountId: options.accountId,
257
+ });
258
+ if (!normalized)
259
+ continue;
260
+ const hubIds = group.map((m) => m.hub_msg_id);
189
261
  const envelope = {
190
262
  message: normalized,
191
263
  ack: {
192
264
  accept: async () => {
193
265
  try {
194
- await client.ackMessages([msg.hub_msg_id]);
266
+ // Ack the entire batch together so Hub never re-delivers any
267
+ // member of this turn if the agent succeeds on the group.
268
+ await client.ackMessages(hubIds);
195
269
  }
196
270
  catch (err) {
197
271
  log.warn("botcord ack failed — relying on seen-cache dedup", {
198
- hubMsgId: msg.hub_msg_id,
272
+ hubMsgIds: hubIds,
199
273
  err: String(err),
200
274
  });
201
275
  }
@@ -207,7 +281,7 @@ export function createBotCordChannel(options) {
207
281
  }
208
282
  catch (err) {
209
283
  log.error("botcord emit threw", {
210
- hubMsgId: msg.hub_msg_id,
284
+ hubMsgIds: hubIds,
211
285
  err: String(err),
212
286
  });
213
287
  }
@@ -3,8 +3,13 @@ import type { GatewayConfig, GatewayInboundMessage, GatewayRoute, RouteMatch } f
3
3
  export declare function matchesRoute(message: GatewayInboundMessage, match: RouteMatch | undefined): boolean;
4
4
  /**
5
5
  * Picks the first matching route in priority order:
6
- * 1. `config.routes[]` (user-authored)
7
- * 2. `managedRoutes` (daemon-synthesized per-agent)
8
- * 3. `config.defaultRoute`
6
+ * 1. `config.routes[]` entries whose `match.accountId` names this message's
7
+ * accountId explicit operator override for a specific agent.
8
+ * 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
9
+ * the user picked when provisioning the agent). Broad user routes do
10
+ * NOT clobber this, because the agent's runtime is itself an explicit
11
+ * user choice — a catch-all prefix rule shouldn't silently downgrade it.
12
+ * 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
13
+ * 4. `config.defaultRoute`.
9
14
  */
10
15
  export declare function resolveRoute(message: GatewayInboundMessage, config: Pick<GatewayConfig, "defaultRoute" | "routes">, managedRoutes?: readonly GatewayRoute[]): GatewayRoute;
@@ -28,15 +28,21 @@ export function matchesRoute(message, match) {
28
28
  }
29
29
  /**
30
30
  * Picks the first matching route in priority order:
31
- * 1. `config.routes[]` (user-authored)
32
- * 2. `managedRoutes` (daemon-synthesized per-agent)
33
- * 3. `config.defaultRoute`
31
+ * 1. `config.routes[]` entries whose `match.accountId` names this message's
32
+ * accountId explicit operator override for a specific agent.
33
+ * 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
34
+ * the user picked when provisioning the agent). Broad user routes do
35
+ * NOT clobber this, because the agent's runtime is itself an explicit
36
+ * user choice — a catch-all prefix rule shouldn't silently downgrade it.
37
+ * 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
38
+ * 4. `config.defaultRoute`.
34
39
  */
35
40
  export function resolveRoute(message, config, managedRoutes) {
36
41
  const routes = config.routes ?? [];
37
42
  for (const route of routes) {
38
- if (matchesRoute(message, route.match))
43
+ if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
39
44
  return route;
45
+ }
40
46
  }
41
47
  if (managedRoutes) {
42
48
  for (const route of managedRoutes) {
@@ -44,5 +50,9 @@ export function resolveRoute(message, config, managedRoutes) {
44
50
  return route;
45
51
  }
46
52
  }
53
+ for (const route of routes) {
54
+ if (matchesRoute(message, route.match))
55
+ return route;
56
+ }
47
57
  return config.defaultRoute;
48
58
  }
package/dist/turn-text.js CHANGED
@@ -1,4 +1,4 @@
1
- import { sanitizeSenderName } from "./gateway/index.js";
1
+ import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
2
2
  import { classifyActivitySender } from "./sender-classify.js";
3
3
  const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
4
4
  'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
@@ -18,6 +18,39 @@ function readEnvelopeType(raw) {
18
18
  const t = env.type;
19
19
  return typeof t === "string" ? t : undefined;
20
20
  }
21
+ /**
22
+ * Read the `raw.batch` array emitted by the BotCord channel when inbox
23
+ * drain groups multiple messages for the same `(room, topic)`. Returns the
24
+ * list when present and well-shaped, else null. Single-message envelopes
25
+ * have no `batch` field and fall through to the single-message path.
26
+ */
27
+ function readBatch(raw) {
28
+ if (!raw || typeof raw !== "object")
29
+ return null;
30
+ const b = raw.batch;
31
+ if (!Array.isArray(b) || b.length < 2)
32
+ return null;
33
+ return b;
34
+ }
35
+ function entryFromLabel(e) {
36
+ const envType = typeof e.envelope?.type === "string" ? e.envelope.type : undefined;
37
+ const isHuman = e.source_type === "dashboard_human_room" ||
38
+ (typeof e.envelope?.from === "string" && e.envelope.from.startsWith("hu_"));
39
+ const fromId = typeof e.envelope?.from === "string" ? e.envelope.from : "unknown";
40
+ const label = isHuman
41
+ ? typeof e.source_user_name === "string" && e.source_user_name
42
+ ? e.source_user_name
43
+ : "User"
44
+ : fromId;
45
+ return { label, kind: isHuman ? "human" : "agent", envelopeType: envType };
46
+ }
47
+ function entryText(e) {
48
+ if (typeof e.text === "string")
49
+ return e.text;
50
+ if (typeof e.envelope?.payload?.text === "string")
51
+ return e.envelope.payload.text;
52
+ return "";
53
+ }
21
54
  /**
22
55
  * Compose the user-turn text for a BotCord inbound message.
23
56
  *
@@ -37,6 +70,10 @@ export function composeBotCordUserTurn(msg) {
37
70
  // system-context handles context; wrapping here would just add noise.
38
71
  if (sender.kind === "owner")
39
72
  return trimmed;
73
+ const batch = readBatch(msg.raw);
74
+ if (batch) {
75
+ return composeBatchedTurn(msg, batch);
76
+ }
40
77
  const conversation = msg.conversation;
41
78
  const isGroup = conversation.kind === "group";
42
79
  const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
@@ -85,3 +122,53 @@ export function composeBotCordUserTurn(msg) {
85
122
  }
86
123
  return lines.join("\n");
87
124
  }
125
+ /**
126
+ * Render a batched turn (≥2 messages from the same room/topic folded into
127
+ * one envelope by `botcord.ts:normalizeInboxBatch`). Mirrors plugin's
128
+ * `handleA2AGroup` output shape so Claude Code sees the same prompt
129
+ * whether driven by OpenClaw or by daemon.
130
+ */
131
+ function composeBatchedTurn(msg, batch) {
132
+ const conversation = msg.conversation;
133
+ const isGroup = conversation.kind === "group";
134
+ const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
135
+ const header = [
136
+ `[BotCord Messages (${batch.length} new)]`,
137
+ `to: ${msg.accountId}`,
138
+ ];
139
+ if (isGroup && roomTitle) {
140
+ const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
141
+ header.push(`room: ${safeRoom}`);
142
+ }
143
+ if (msg.mentioned) {
144
+ header.push("mentioned: true");
145
+ }
146
+ const blocks = [];
147
+ const contactRequestSenders = [];
148
+ for (const entry of batch) {
149
+ const { label, kind, envelopeType } = entryFromLabel(entry);
150
+ const safeLabel = sanitizeSenderName(label);
151
+ const raw = entryText(entry);
152
+ // Owner-trust bypass is handled at the outer level — by the time we
153
+ // reach a batched turn the sender classifier has already returned
154
+ // non-owner. Still sanitize defensively.
155
+ const safeBody = sanitizeUntrustedContent(raw);
156
+ const tag = kind === "human" ? "human-message" : "agent-message";
157
+ blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`);
158
+ if (envelopeType === "contact_request") {
159
+ contactRequestSenders.push(safeLabel);
160
+ }
161
+ }
162
+ const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
163
+ const lines = [header.join(" | "), blocks.join("\n"), "", hint];
164
+ if (contactRequestSenders.length > 0) {
165
+ // Dedup + list — multiple distinct senders show as "A, B".
166
+ const unique = Array.from(new Set(contactRequestSenders));
167
+ lines.push("", "[You received a contact request from " +
168
+ unique.join(", ") +
169
+ ". Use the botcord_notify tool to inform your owner about this request so " +
170
+ "they can decide whether to accept or reject it. Include the sender's " +
171
+ "agent ID and any message they attached.]");
172
+ }
173
+ return lines.join("\n");
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -131,6 +131,117 @@ describe("composeBotCordUserTurn", () => {
131
131
  expect(out).not.toContain("contact request from");
132
132
  });
133
133
 
134
+ it("renders a multi-message batch as [BotCord Messages (N new)] with one block per sender", () => {
135
+ const batch = [
136
+ {
137
+ hub_msg_id: "m1",
138
+ text: "first message",
139
+ envelope: { from: "ag_alice", type: "message" },
140
+ },
141
+ {
142
+ hub_msg_id: "m2",
143
+ text: "second message",
144
+ envelope: { from: "ag_bob", type: "message" },
145
+ mentioned: true,
146
+ },
147
+ ];
148
+ const out = composeBotCordUserTurn(
149
+ makeMessage({
150
+ text: "second message",
151
+ sender: { id: "ag_bob", kind: "agent" },
152
+ conversation: { id: "rm_team", kind: "group", title: "Ouraca" },
153
+ mentioned: true,
154
+ raw: { batch, envelope: { type: "message", from: "ag_bob" } },
155
+ }),
156
+ );
157
+ expect(out).toContain("[BotCord Messages (2 new)]");
158
+ expect(out).toContain("room: Ouraca");
159
+ expect(out).toContain("mentioned: true");
160
+ expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
161
+ expect(out).toContain("first message");
162
+ expect(out).toContain('<agent-message sender="ag_bob" sender_kind="agent">');
163
+ expect(out).toContain("second message");
164
+ // Single-message header must NOT appear in batch mode.
165
+ expect(out).not.toContain("[BotCord Message]");
166
+ // Group hint still appears after the blocks.
167
+ expect(out).toContain("do NOT reply unless");
168
+ });
169
+
170
+ it("batched path tags dashboard_human_room senders as human-message", () => {
171
+ const batch = [
172
+ {
173
+ hub_msg_id: "m1",
174
+ text: "hi bot",
175
+ envelope: { from: "ag_me", type: "message" },
176
+ source_type: "dashboard_human_room",
177
+ source_user_name: "Alice",
178
+ },
179
+ {
180
+ hub_msg_id: "m2",
181
+ text: "你好",
182
+ envelope: { from: "ag_peer", type: "message" },
183
+ },
184
+ ];
185
+ const out = composeBotCordUserTurn(
186
+ makeMessage({
187
+ text: "你好",
188
+ sender: { id: "ag_peer", kind: "agent" },
189
+ conversation: { id: "rm_team", kind: "group" },
190
+ raw: { batch, envelope: { type: "message", from: "ag_peer" } },
191
+ }),
192
+ );
193
+ expect(out).toContain('<human-message sender="Alice" sender_kind="human">');
194
+ expect(out).toContain("hi bot");
195
+ expect(out).toContain('<agent-message sender="ag_peer" sender_kind="agent">');
196
+ });
197
+
198
+ it("batched path appends a single notify-owner hint listing every contact_request sender", () => {
199
+ const batch = [
200
+ {
201
+ hub_msg_id: "m1",
202
+ text: "please add me",
203
+ envelope: { from: "ag_stranger_a", type: "contact_request" },
204
+ },
205
+ {
206
+ hub_msg_id: "m2",
207
+ text: "add me too",
208
+ envelope: { from: "ag_stranger_b", type: "contact_request" },
209
+ },
210
+ {
211
+ hub_msg_id: "m3",
212
+ text: "normal reply",
213
+ envelope: { from: "ag_old_friend", type: "message" },
214
+ },
215
+ ];
216
+ const out = composeBotCordUserTurn(
217
+ makeMessage({
218
+ text: "normal reply",
219
+ sender: { id: "ag_old_friend", kind: "agent" },
220
+ conversation: { id: "rm_dm_x", kind: "direct" },
221
+ raw: { batch, envelope: { type: "message", from: "ag_old_friend" } },
222
+ }),
223
+ );
224
+ expect(out).toContain("contact request from ag_stranger_a, ag_stranger_b");
225
+ // Direct hint (not group) for a DM room.
226
+ expect(out).toContain("naturally concluded");
227
+ });
228
+
229
+ it("falls back to the single-message path when raw.batch has only one entry", () => {
230
+ const out = composeBotCordUserTurn(
231
+ makeMessage({
232
+ raw: {
233
+ batch: [
234
+ { hub_msg_id: "m1", text: "solo", envelope: { from: "ag_x", type: "message" } },
235
+ ],
236
+ envelope: { type: "message", from: "ag_x" },
237
+ },
238
+ }),
239
+ );
240
+ // batch length 1 → readBatch returns null → single-message header.
241
+ expect(out).toContain("[BotCord Message]");
242
+ expect(out).not.toContain("[BotCord Messages (");
243
+ });
244
+
134
245
  it("sanitizes room names so newline-based injection can't reshape the header", () => {
135
246
  const out = composeBotCordUserTurn(
136
247
  makeMessage({
@@ -150,9 +150,10 @@ export function toGatewayConfig(
150
150
 
151
151
  // Synthesize a per-agent route for every bound agent and hand it to the
152
152
  // gateway via the managed-routes bucket (plan §10.1). User-authored
153
- // `cfg.routes[]` stay untouched so an explicit operator override still
154
- // wins on conflict the gateway matches `routes[] → managedRoutes →
155
- // defaultRoute` in that order.
153
+ // `cfg.routes[]` stay untouched. Match priority (see router.ts):
154
+ // `routes[] with explicit accountId managedRoutes other routes[] →
155
+ // defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
156
+ // chosen runtime — only routes that name the agent by `accountId` do.
156
157
  const managedMap = buildManagedRoutes(
157
158
  agentIds,
158
159
  opts.agentRuntimes ?? {},
@@ -281,6 +281,69 @@ describe("createBotCordChannel — inbox normalization", () => {
281
281
  }
282
282
  });
283
283
 
284
+ it("groups two messages in the same room/topic into one batched envelope", async () => {
285
+ const server = await startAuthOkServer();
286
+ try {
287
+ const polled = [
288
+ makeInbox({
289
+ hub_msg_id: "m_b1",
290
+ room_id: "rm_team",
291
+ room_name: "Team",
292
+ text: "hi all",
293
+ envelope: { from: "ag_alice" } as InboxMessage["envelope"],
294
+ }),
295
+ makeInbox({
296
+ hub_msg_id: "m_b2",
297
+ room_id: "rm_team",
298
+ room_name: "Team",
299
+ text: "yeah",
300
+ envelope: { from: "ag_bob" } as InboxMessage["envelope"],
301
+ mentioned: true,
302
+ }),
303
+ ];
304
+ const client = makeClient({
305
+ pollInbox: vi.fn().mockResolvedValue({ messages: polled, count: 2, has_more: false }),
306
+ getHubUrl: vi.fn().mockReturnValue(server.url),
307
+ });
308
+ const channel = createBotCordChannel({
309
+ id: "botcord-main",
310
+ accountId: "ag_self",
311
+ agentId: "ag_self",
312
+ client,
313
+ hubBaseUrl: server.url,
314
+ });
315
+ const abort = new AbortController();
316
+ const emits: GatewayInboundEnvelope[] = [];
317
+ const startP = channel.start({
318
+ config: stubConfig,
319
+ accountId: "ag_self",
320
+ abortSignal: abort.signal,
321
+ log: silentLog,
322
+ emit: async (env) => {
323
+ emits.push(env);
324
+ },
325
+ setStatus: () => {},
326
+ });
327
+ await vi.waitFor(() => expect(emits).toHaveLength(1));
328
+ const env = emits[0]!.message;
329
+ // Last sender wins for representative metadata; mentioned is sticky.
330
+ expect(env.sender.id).toBe("ag_bob");
331
+ expect(env.mentioned).toBe(true);
332
+ const raw = env.raw as { batch?: Array<{ hub_msg_id: string }> };
333
+ expect(Array.isArray(raw.batch)).toBe(true);
334
+ expect(raw.batch!.map((m) => m.hub_msg_id)).toEqual(["m_b1", "m_b2"]);
335
+
336
+ // One accept() call acks BOTH hub ids together.
337
+ await emits[0]!.ack!.accept();
338
+ expect(client.ackMessages).toHaveBeenCalledWith(["m_b1", "m_b2"]);
339
+
340
+ abort.abort();
341
+ await startP;
342
+ } finally {
343
+ await server.close();
344
+ }
345
+ });
346
+
284
347
  it("sanitizes prompt-injection markers in untrusted text but not in owner-chat", async () => {
285
348
  const { emits, server } = await startWithInbox([
286
349
  makeInbox({
@@ -78,13 +78,39 @@ describe("resolveRoute", () => {
78
78
  });
79
79
 
80
80
  describe("managedRoutes", () => {
81
- it("user cfg.routes match wins over managed for same accountId", () => {
81
+ it("user route with explicit accountId wins over managed for same agent", () => {
82
82
  const user = makeRoute({ runtime: "user", match: { accountId: "ag_1" } });
83
83
  const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
84
84
  const msg = makeMessage({ accountId: "ag_1" });
85
85
  expect(resolveRoute(msg, { defaultRoute, routes: [user] }, [managed])).toBe(user);
86
86
  });
87
87
 
88
+ it("broad user route (no accountId) does NOT override managed per-agent route", () => {
89
+ const broad = makeRoute({
90
+ runtime: "broad",
91
+ match: { conversationPrefix: "rm_oc_" },
92
+ });
93
+ const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
94
+ const msg = makeMessage({
95
+ accountId: "ag_1",
96
+ conversation: { id: "rm_oc_abc", kind: "group" },
97
+ });
98
+ expect(resolveRoute(msg, { defaultRoute, routes: [broad] }, [managed])).toBe(managed);
99
+ });
100
+
101
+ it("broad user route applies when no managed route matches the agent", () => {
102
+ const broad = makeRoute({
103
+ runtime: "broad",
104
+ match: { conversationPrefix: "rm_oc_" },
105
+ });
106
+ const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_other" } });
107
+ const msg = makeMessage({
108
+ accountId: "ag_1",
109
+ conversation: { id: "rm_oc_abc", kind: "group" },
110
+ });
111
+ expect(resolveRoute(msg, { defaultRoute, routes: [broad] }, [managed])).toBe(broad);
112
+ });
113
+
88
114
  it("no user match + managed match → managed wins", () => {
89
115
  const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
90
116
  const msg = makeMessage({ accountId: "ag_1" });
@@ -196,6 +196,66 @@ function normalizeInbox(
196
196
  };
197
197
  }
198
198
 
199
+ /**
200
+ * Shape of the `raw` field when the channel batches multiple messages into
201
+ * one envelope. Keeps the latest message's InboxMessage fields at top level
202
+ * so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
203
+ * work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
204
+ * reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
205
+ * per entry.
206
+ */
207
+ export interface BatchedInboxRaw extends InboxMessage {
208
+ batch: InboxMessage[];
209
+ }
210
+
211
+ /**
212
+ * Normalize a group of InboxMessages for the same `(room, topic)` into a
213
+ * single `GatewayInboundMessage`. The envelope carries the latest msg's
214
+ * metadata (routing, session key, trace) and a `raw.batch` array the
215
+ * composer uses to render per-sender blocks.
216
+ *
217
+ * `mentioned` is sticky: true if ANY message in the group is a mention.
218
+ * Returns null if no message in the group is normalizable on its own.
219
+ */
220
+ function normalizeInboxBatch(
221
+ msgs: InboxMessage[],
222
+ options: { channelId: string; accountId: string },
223
+ ): GatewayInboundMessage | null {
224
+ if (msgs.length === 0) return null;
225
+ if (msgs.length === 1) return normalizeInbox(msgs[0]!, options);
226
+
227
+ const latest = msgs[msgs.length - 1]!;
228
+ const base = normalizeInbox(latest, options);
229
+ if (!base) return null;
230
+
231
+ // Fold sibling metadata into the base envelope. `text` is kept non-empty
232
+ // when at least one batched member has a body, so the dispatcher's empty-
233
+ // text skip rule doesn't drop the whole batch just because the latest
234
+ // envelope was e.g. a zero-payload contact_request.
235
+ const anyMentioned = msgs.some((m) => m.mentioned === true);
236
+ let representativeText = base.text ?? "";
237
+ if (!representativeText.trim()) {
238
+ for (let i = msgs.length - 1; i >= 0; i--) {
239
+ const m = msgs[i]!;
240
+ const candidate =
241
+ m.text ??
242
+ (typeof m.envelope?.payload?.text === "string"
243
+ ? (m.envelope.payload.text as string)
244
+ : "");
245
+ if (candidate && candidate.trim()) {
246
+ representativeText = candidate;
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ return {
252
+ ...base,
253
+ text: representativeText,
254
+ mentioned: anyMentioned,
255
+ raw: { ...latest, batch: msgs } satisfies BatchedInboxRaw,
256
+ };
257
+ }
258
+
199
259
  /**
200
260
  * Construct a BotCord channel adapter.
201
261
  *
@@ -250,9 +310,14 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
250
310
  log.info("botcord inbox drained", { count: msgs.length });
251
311
  if (msgs.length === 0) return;
252
312
 
313
+ // First pass: ack duplicates/skipped messages so Hub stops requeueing,
314
+ // and collect eligible messages preserving poll order. Grouping by
315
+ // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
316
+ // same conversation thread folds into one turn so the agent sees all
317
+ // new messages at once instead of running N turns back-to-back.
318
+ const eligible: InboxMessage[] = [];
253
319
  for (const msg of msgs) {
254
320
  if (!rememberSeen(msg.hub_msg_id)) {
255
- // Already emitted; ack again so Hub stops requeueing.
256
321
  try {
257
322
  await client.ackMessages([msg.hub_msg_id]);
258
323
  } catch (err) {
@@ -265,7 +330,6 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
265
330
  accountId: options.accountId,
266
331
  });
267
332
  if (!normalized) {
268
- // Not eligible (wrong type, missing room, etc.) — ack so it drops.
269
333
  try {
270
334
  await client.ackMessages([msg.hub_msg_id]);
271
335
  } catch (err) {
@@ -273,15 +337,41 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
273
337
  }
274
338
  continue;
275
339
  }
340
+ eligible.push(msg);
341
+ }
342
+
343
+ if (eligible.length === 0) return;
344
+
345
+ // Group by `(room_id, topic)`. Insertion order is the poll order, so
346
+ // iterating the map yields groups with the same external chronology.
347
+ const groups = new Map<string, InboxMessage[]>();
348
+ for (const msg of eligible) {
349
+ const topic = msg.topic_id ?? msg.topic ?? "";
350
+ const key = `${msg.room_id ?? ""}:${topic}`;
351
+ const list = groups.get(key);
352
+ if (list) list.push(msg);
353
+ else groups.set(key, [msg]);
354
+ }
355
+
356
+ for (const group of groups.values()) {
357
+ const normalized = normalizeInboxBatch(group, {
358
+ channelId: options.id,
359
+ accountId: options.accountId,
360
+ });
361
+ if (!normalized) continue;
362
+
363
+ const hubIds = group.map((m) => m.hub_msg_id);
276
364
  const envelope: GatewayInboundEnvelope = {
277
365
  message: normalized,
278
366
  ack: {
279
367
  accept: async () => {
280
368
  try {
281
- await client.ackMessages([msg.hub_msg_id]);
369
+ // Ack the entire batch together so Hub never re-delivers any
370
+ // member of this turn if the agent succeeds on the group.
371
+ await client.ackMessages(hubIds);
282
372
  } catch (err) {
283
373
  log.warn("botcord ack failed — relying on seen-cache dedup", {
284
- hubMsgId: msg.hub_msg_id,
374
+ hubMsgIds: hubIds,
285
375
  err: String(err),
286
376
  });
287
377
  }
@@ -292,7 +382,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
292
382
  await emit(envelope);
293
383
  } catch (err) {
294
384
  log.error("botcord emit threw", {
295
- hubMsgId: msg.hub_msg_id,
385
+ hubMsgIds: hubIds,
296
386
  err: String(err),
297
387
  });
298
388
  }
@@ -38,9 +38,14 @@ export function matchesRoute(
38
38
 
39
39
  /**
40
40
  * Picks the first matching route in priority order:
41
- * 1. `config.routes[]` (user-authored)
42
- * 2. `managedRoutes` (daemon-synthesized per-agent)
43
- * 3. `config.defaultRoute`
41
+ * 1. `config.routes[]` entries whose `match.accountId` names this message's
42
+ * accountId explicit operator override for a specific agent.
43
+ * 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
44
+ * the user picked when provisioning the agent). Broad user routes do
45
+ * NOT clobber this, because the agent's runtime is itself an explicit
46
+ * user choice — a catch-all prefix rule shouldn't silently downgrade it.
47
+ * 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
48
+ * 4. `config.defaultRoute`.
44
49
  */
45
50
  export function resolveRoute(
46
51
  message: GatewayInboundMessage,
@@ -48,13 +53,22 @@ export function resolveRoute(
48
53
  managedRoutes?: readonly GatewayRoute[],
49
54
  ): GatewayRoute {
50
55
  const routes = config.routes ?? [];
56
+
51
57
  for (const route of routes) {
52
- if (matchesRoute(message, route.match)) return route;
58
+ if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
59
+ return route;
60
+ }
53
61
  }
62
+
54
63
  if (managedRoutes) {
55
64
  for (const route of managedRoutes) {
56
65
  if (matchesRoute(message, route.match)) return route;
57
66
  }
58
67
  }
68
+
69
+ for (const route of routes) {
70
+ if (matchesRoute(message, route.match)) return route;
71
+ }
72
+
59
73
  return config.defaultRoute;
60
74
  }
package/src/turn-text.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  * model the context it needs.
25
25
  */
26
26
  import type { GatewayInboundMessage } from "./gateway/index.js";
27
- import { sanitizeSenderName } from "./gateway/index.js";
27
+ import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
28
28
  import { classifyActivitySender } from "./sender-classify.js";
29
29
 
30
30
  const GROUP_HINT =
@@ -47,6 +47,55 @@ function readEnvelopeType(raw: unknown): string | undefined {
47
47
  return typeof t === "string" ? t : undefined;
48
48
  }
49
49
 
50
+ /** Minimal shape of one batched inbound entry. Matches the BotCord channel
51
+ * `BatchedInboxRaw.batch[]` elements but expressed structurally so the
52
+ * composer doesn't import channel internals. */
53
+ interface BatchedEntry {
54
+ hub_msg_id?: unknown;
55
+ text?: unknown;
56
+ envelope?: { from?: unknown; type?: unknown; payload?: { text?: unknown } };
57
+ source_type?: unknown;
58
+ source_user_name?: unknown;
59
+ mentioned?: unknown;
60
+ }
61
+
62
+ /**
63
+ * Read the `raw.batch` array emitted by the BotCord channel when inbox
64
+ * drain groups multiple messages for the same `(room, topic)`. Returns the
65
+ * list when present and well-shaped, else null. Single-message envelopes
66
+ * have no `batch` field and fall through to the single-message path.
67
+ */
68
+ function readBatch(raw: unknown): BatchedEntry[] | null {
69
+ if (!raw || typeof raw !== "object") return null;
70
+ const b = (raw as { batch?: unknown }).batch;
71
+ if (!Array.isArray(b) || b.length < 2) return null;
72
+ return b as BatchedEntry[];
73
+ }
74
+
75
+ function entryFromLabel(e: BatchedEntry): {
76
+ label: string;
77
+ kind: "human" | "agent";
78
+ envelopeType: string | undefined;
79
+ } {
80
+ const envType = typeof e.envelope?.type === "string" ? e.envelope.type : undefined;
81
+ const isHuman =
82
+ e.source_type === "dashboard_human_room" ||
83
+ (typeof e.envelope?.from === "string" && e.envelope.from.startsWith("hu_"));
84
+ const fromId = typeof e.envelope?.from === "string" ? e.envelope.from : "unknown";
85
+ const label = isHuman
86
+ ? typeof e.source_user_name === "string" && e.source_user_name
87
+ ? e.source_user_name
88
+ : "User"
89
+ : fromId;
90
+ return { label, kind: isHuman ? "human" : "agent", envelopeType: envType };
91
+ }
92
+
93
+ function entryText(e: BatchedEntry): string {
94
+ if (typeof e.text === "string") return e.text;
95
+ if (typeof e.envelope?.payload?.text === "string") return e.envelope.payload.text;
96
+ return "";
97
+ }
98
+
50
99
  /**
51
100
  * Compose the user-turn text for a BotCord inbound message.
52
101
  *
@@ -67,6 +116,11 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
67
116
  // system-context handles context; wrapping here would just add noise.
68
117
  if (sender.kind === "owner") return trimmed;
69
118
 
119
+ const batch = readBatch(msg.raw);
120
+ if (batch) {
121
+ return composeBatchedTurn(msg, batch);
122
+ }
123
+
70
124
  const conversation = msg.conversation;
71
125
  const isGroup = conversation.kind === "group";
72
126
  const roomTitle =
@@ -121,3 +175,67 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
121
175
  }
122
176
  return lines.join("\n");
123
177
  }
178
+
179
+ /**
180
+ * Render a batched turn (≥2 messages from the same room/topic folded into
181
+ * one envelope by `botcord.ts:normalizeInboxBatch`). Mirrors plugin's
182
+ * `handleA2AGroup` output shape so Claude Code sees the same prompt
183
+ * whether driven by OpenClaw or by daemon.
184
+ */
185
+ function composeBatchedTurn(
186
+ msg: GatewayInboundMessage,
187
+ batch: BatchedEntry[],
188
+ ): string {
189
+ const conversation = msg.conversation;
190
+ const isGroup = conversation.kind === "group";
191
+ const roomTitle =
192
+ typeof conversation.title === "string" ? conversation.title : undefined;
193
+
194
+ const header: string[] = [
195
+ `[BotCord Messages (${batch.length} new)]`,
196
+ `to: ${msg.accountId}`,
197
+ ];
198
+ if (isGroup && roomTitle) {
199
+ const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
200
+ header.push(`room: ${safeRoom}`);
201
+ }
202
+ if (msg.mentioned) {
203
+ header.push("mentioned: true");
204
+ }
205
+
206
+ const blocks: string[] = [];
207
+ const contactRequestSenders: string[] = [];
208
+ for (const entry of batch) {
209
+ const { label, kind, envelopeType } = entryFromLabel(entry);
210
+ const safeLabel = sanitizeSenderName(label);
211
+ const raw = entryText(entry);
212
+ // Owner-trust bypass is handled at the outer level — by the time we
213
+ // reach a batched turn the sender classifier has already returned
214
+ // non-owner. Still sanitize defensively.
215
+ const safeBody = sanitizeUntrustedContent(raw);
216
+ const tag = kind === "human" ? "human-message" : "agent-message";
217
+ blocks.push(
218
+ `<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`,
219
+ );
220
+ if (envelopeType === "contact_request") {
221
+ contactRequestSenders.push(safeLabel);
222
+ }
223
+ }
224
+
225
+ const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
226
+ const lines: string[] = [header.join(" | "), blocks.join("\n"), "", hint];
227
+
228
+ if (contactRequestSenders.length > 0) {
229
+ // Dedup + list — multiple distinct senders show as "A, B".
230
+ const unique = Array.from(new Set(contactRequestSenders));
231
+ lines.push(
232
+ "",
233
+ "[You received a contact request from " +
234
+ unique.join(", ") +
235
+ ". Use the botcord_notify tool to inform your owner about this request so " +
236
+ "they can decide whether to accept or reject it. Include the sender's " +
237
+ "agent ID and any message they attached.]",
238
+ );
239
+ }
240
+ return lines.join("\n");
241
+ }