@pnds/pond 1.7.0 → 1.9.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/gateway.ts +107 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "OpenClaw channel plugin for Pond IM",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,8 +15,8 @@
15
15
  "openclaw.plugin.json"
16
16
  ],
17
17
  "dependencies": {
18
- "@pnds/cli": "1.7.0",
19
- "@pnds/sdk": "1.7.0"
18
+ "@pnds/cli": "1.9.0",
19
+ "@pnds/sdk": "1.9.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
package/src/gateway.ts CHANGED
@@ -34,7 +34,7 @@ function resolveWsUrl(account: ResolvedPondAccount): string {
34
34
  return `${wsBase}/ws`;
35
35
  }
36
36
 
37
- type ChatInfo = { type: Chat["type"]; name: string | null };
37
+ type ChatInfo = { type: Chat["type"]; name: string | null; agentRoutingMode: Chat["agent_routing_mode"] };
38
38
 
39
39
  /**
40
40
  * Fetch all chats with pagination and populate the info map (type + name).
@@ -49,7 +49,7 @@ async function fetchAllChats(
49
49
  do {
50
50
  const res = await client.getChats(orgId, { limit: 100, cursor });
51
51
  for (const c of res.data) {
52
- chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null });
52
+ chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null, agentRoutingMode: c.agent_routing_mode });
53
53
  }
54
54
  total += res.data.length;
55
55
  cursor = res.has_more ? res.next_cursor : undefined;
@@ -402,7 +402,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
402
402
  }
403
403
  }
404
404
  // Resolve remaining items as not-dispatched (fork stopped after error).
405
- // These events are NOT acked — inbox retains them for catch-up replay.
405
+ // These events are NOT acked — dispatch retains them for catch-up replay.
406
406
  for (const remaining of fork.queue.splice(0)) {
407
407
  remaining.resolve(false);
408
408
  }
@@ -535,10 +535,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
535
535
 
536
536
  dispatchState.mainCurrentTargetId = event.targetId;
537
537
  await runDispatch(event, orchestratorKey, bodyForAgent);
538
- // Ack inbox after successful drain dispatch — these events were buffered
538
+ // Ack dispatch after successful drain — these events were buffered
539
539
  // without ack, so this is the first time they're confirmed processed.
540
540
  const sourceType = event.type === "task" ? "task_activity" : "message";
541
- client.ackInbox(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
541
+ client.ackDispatch(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
542
542
  }
543
543
  } finally {
544
544
  draining = false;
@@ -550,8 +550,8 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
550
550
  /**
551
551
  * Route and dispatch an inbound event.
552
552
  * Returns true if the event was dispatched (main or fork), false if only buffered.
553
- * Callers should only ack inbox when this returns true — buffered events may be
554
- * lost on crash and need to be replayed from inbox on the next catch-up.
553
+ * Callers should only ack dispatch when this returns true — buffered events may be
554
+ * lost on crash and need to be replayed from dispatch on the next catch-up.
555
555
  */
556
556
  async function handleInboundEvent(event: PondEvent): Promise<boolean> {
557
557
  const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
@@ -683,13 +683,13 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
683
683
  });
684
684
  }, intervalSec * 1000);
685
685
 
686
- // Unified inbox catch-up (replaces separate mention/DM/task catch-up).
686
+ // Dispatch catch-up: fetch pending dispatch events (FIFO).
687
687
  // On cold start, skip items older than COLD_START_WINDOW_MS to avoid
688
688
  // replaying historical backlog from a previous process.
689
689
  const isFirstHello = !hadSuccessfulHello;
690
690
  hadSuccessfulHello = true;
691
- catchUpFromInbox(isFirstHello).catch((err) => {
692
- log?.warn(`pond[${ctx.accountId}]: inbox catch-up failed: ${String(err)}`);
691
+ catchUpFromDispatch(isFirstHello).catch((err) => {
692
+ log?.warn(`pond[${ctx.accountId}]: dispatch catch-up failed: ${String(err)}`);
693
693
  });
694
694
  } catch (err) {
695
695
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
@@ -700,13 +700,19 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
700
700
  const changes = data.changes as Record<string, unknown> | undefined;
701
701
  if (!changes) return;
702
702
  const existing = chatInfoMap.get(data.chat_id);
703
- if (typeof changes.type === "string") {
703
+ if (existing) {
704
+ chatInfoMap.set(data.chat_id, {
705
+ ...existing,
706
+ ...(typeof changes.type === "string" ? { type: changes.type as Chat["type"] } : {}),
707
+ ...(typeof changes.name === "string" ? { name: changes.name } : {}),
708
+ ...(typeof changes.agent_routing_mode === "string" ? { agentRoutingMode: changes.agent_routing_mode as Chat["agent_routing_mode"] } : {}),
709
+ });
710
+ } else if (typeof changes.type === "string") {
704
711
  chatInfoMap.set(data.chat_id, {
705
712
  type: changes.type as Chat["type"],
706
- name: (typeof changes.name === "string" ? changes.name : existing?.name) ?? null,
713
+ name: (typeof changes.name === "string" ? changes.name : null),
714
+ agentRoutingMode: (typeof changes.agent_routing_mode === "string" ? changes.agent_routing_mode : "passive") as Chat["agent_routing_mode"],
707
715
  });
708
- } else if (typeof changes.name === "string" && existing) {
709
- chatInfoMap.set(data.chat_id, { ...existing, name: changes.name });
710
716
  }
711
717
  });
712
718
 
@@ -724,7 +730,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
724
730
  if (!chatInfo) {
725
731
  try {
726
732
  const chat = await client.getChat(config.org_id, chatId);
727
- chatInfo = { type: chat.type, name: chat.name ?? null };
733
+ chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
728
734
  chatInfoMap.set(chatId, chatInfo);
729
735
  } catch (err) {
730
736
  log?.warn(`pond[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
@@ -741,17 +747,27 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
741
747
  body = content.text?.trim() ?? "";
742
748
  if (!body) return;
743
749
 
744
- // In group chats, only respond when @mentioned or @all
745
- if (chatInfo.type === "group") {
750
+ // In group chats, respond based on agent_routing_mode:
751
+ // - passive (default): only when @mentioned or @all
752
+ // - active: all messages dispatched via live handler
753
+ // - smart: skip live handler — server decides routing via inbox items
754
+ if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
746
755
  const mentions = content.mentions ?? [];
747
756
  const isMentioned = mentions.some(
748
757
  (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
749
758
  );
750
- if (!isMentioned) return;
759
+ if (!isMentioned) {
760
+ // Release claim so catch-up can process this via dispatch events
761
+ dispatchedMessages.delete(data.id);
762
+ return;
763
+ }
751
764
  }
752
765
  } else {
753
766
  const content = data.content as MediaContent;
754
- if (chatInfo.type === "group") return;
767
+ if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
768
+ dispatchedMessages.delete(data.id);
769
+ return;
770
+ }
755
771
  body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
756
772
  if (content.attachment_id) {
757
773
  try {
@@ -786,7 +802,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
786
802
  try {
787
803
  const dispatched = await handleInboundEvent(event);
788
804
  if (dispatched) {
789
- client.ackInbox(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
805
+ client.ackDispatch(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
790
806
  } else {
791
807
  // Not dispatched (buffered or fork failed) — release claim so catch-up can retry
792
808
  dispatchedMessages.delete(data.id);
@@ -845,7 +861,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
845
861
  // Server event buffer overflowed — events were lost, trigger full catch-up
846
862
  ws.on("recovery.overflow", () => {
847
863
  log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
848
- catchUpFromInbox().catch((err) =>
864
+ catchUpFromDispatch().catch((err) =>
849
865
  log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
850
866
  });
851
867
 
@@ -855,33 +871,43 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
855
871
  try {
856
872
  const dispatched = await handleTaskAssignment(data);
857
873
  if (dispatched) {
858
- client.ackInbox(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
874
+ client.ackDispatch(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
859
875
  }
860
876
  } catch (err) {
861
877
  log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
862
878
  }
863
879
  });
864
880
 
865
- // ── Unified inbox catch-up ──
866
- // Replays missed mentions, DMs, and task assignments via the inbox API.
867
- // Adapts each item into a PondEvent and routes through handleInboundEvent.
868
- async function catchUpFromInbox(coldStart = false) {
881
+ // ── Dispatch catch-up ──
882
+ // Replays missed events via the dispatch API (agent-only endpoint, FIFO order).
883
+ // Each dispatch event is a source-pointer; we re-fetch the entity to build a PondEvent.
884
+ //
885
+ // Cold-start window: On a fresh process start (not reconnect), events older than
886
+ // COLD_START_WINDOW_MS are acked without processing. This is an intentional operator
887
+ // decision: a newly started agent should not replay unbounded historical backlog from
888
+ // a previous process lifetime. Reconnects (non-cold-start) replay all pending events.
889
+ //
890
+ // NOTE: RouteToAgents (active/smart group routing) inserts dispatch rows asynchronously
891
+ // after the message.new WS event. A race exists where the live ack-by-source fires
892
+ // before the dispatch row is inserted, leaving an orphan pending row. This is benign:
893
+ // catch-up will process it on next cycle (duplicate delivery, not data loss).
894
+ // TODO: resolve by creating dispatch rows before publishing the live event, or by
895
+ // consuming dispatch.new in the live path.
896
+ async function catchUpFromDispatch(coldStart = false) {
869
897
  const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
870
898
  let cursor: string | undefined;
871
899
  let processed = 0;
872
900
  let skippedOld = 0;
873
901
 
874
902
  do {
875
- const page = await client.getInbox(config.org_id, {
876
- type: "mention,agent_dm,task_assign",
877
- read: "false",
903
+ const page = await client.getDispatch(config.org_id, {
878
904
  limit: 50,
879
905
  cursor,
880
906
  });
881
907
 
882
908
  for (const item of page.data ?? []) {
883
- if (minAge > 0 && new Date(item.updated_at).getTime() < minAge) {
884
- client.markInboxRead(config.org_id, item.id).catch(() => {});
909
+ if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
910
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
885
911
  skippedOld++;
886
912
  continue;
887
913
  }
@@ -889,21 +915,59 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
889
915
  processed++;
890
916
  try {
891
917
  let dispatched = false;
892
- if (item.type === "task_assign" && item.source_id) {
918
+ if (item.event_type === "task_assign" && item.task_id) {
893
919
  // Task catch-up: fetch full task and dispatch
894
- const task = await client.getTask(config.org_id, item.source_id);
920
+ let task: Awaited<ReturnType<typeof client.getTask>> | null = null;
921
+ let taskFetchFailed = false;
922
+ try {
923
+ task = await client.getTask(config.org_id, item.task_id);
924
+ } catch (err: unknown) {
925
+ // Distinguish 404 (task deleted) from transient failures
926
+ const status = (err as { status?: number })?.status;
927
+ if (status === 404) {
928
+ task = null; // task genuinely deleted
929
+ } else {
930
+ taskFetchFailed = true;
931
+ log?.warn(`pond[${ctx.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
932
+ }
933
+ }
934
+ if (taskFetchFailed) {
935
+ continue; // leave pending for next catch-up cycle
936
+ }
895
937
  if (task) {
938
+ // Defense-in-depth (D2): verify task is still assigned to this agent
939
+ if (task.assignee_id !== agentUserId) {
940
+ log?.info(`pond[${ctx.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
941
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
942
+ continue;
943
+ }
896
944
  dispatched = await handleTaskAssignment(task);
897
945
  } else {
898
- dispatched = true; // task deleted — mark read to prevent infinite retry
946
+ dispatched = true; // task genuinely deleted — ack to prevent infinite retry
899
947
  }
900
- } else if (item.source_id && item.chat_id) {
901
- // Message catch-up (mention or agent_dm): build PondEvent from inbox item
948
+ } else if (item.event_type === "message" && item.source_id && item.chat_id) {
949
+ // Message catch-up: build PondEvent from dispatch source pointer
902
950
  if (!tryClaimMessage(item.source_id)) continue;
903
- const msg = await client.getMessage(item.source_id).catch(() => null);
951
+ let msg: Awaited<ReturnType<typeof client.getMessage>> | null = null;
952
+ let msgFetchFailed = false;
953
+ try {
954
+ msg = await client.getMessage(item.source_id);
955
+ } catch (err: unknown) {
956
+ const status = (err as { status?: number })?.status;
957
+ if (status === 404) {
958
+ msg = null; // message genuinely deleted
959
+ } else {
960
+ msgFetchFailed = true;
961
+ log?.warn(`pond[${ctx.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
962
+ }
963
+ }
964
+ if (msgFetchFailed) {
965
+ dispatchedMessages.delete(item.source_id);
966
+ continue; // leave pending for next catch-up cycle
967
+ }
904
968
  if (!msg || msg.sender_id === agentUserId) {
905
969
  dispatchedMessages.delete(item.source_id);
906
- client.markInboxRead(config.org_id, item.id).catch(() => {});
970
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
907
971
  continue;
908
972
  }
909
973
 
@@ -911,7 +975,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
911
975
  if (!chatInfo) {
912
976
  try {
913
977
  const chat = await client.getChat(config.org_id, item.chat_id);
914
- chatInfo = { type: chat.type, name: chat.name ?? null };
978
+ chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
915
979
  chatInfoMap.set(item.chat_id, chatInfo);
916
980
  } catch {
917
981
  dispatchedMessages.delete(item.source_id);
@@ -935,7 +999,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
935
999
  }
936
1000
  if (!body) {
937
1001
  dispatchedMessages.delete(item.source_id);
938
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1002
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
939
1003
  continue;
940
1004
  }
941
1005
 
@@ -955,17 +1019,17 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
955
1019
  dispatched = await handleInboundEvent(event);
956
1020
  }
957
1021
  if (dispatched) {
958
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1022
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
959
1023
  }
960
1024
  } catch (err) {
961
- log?.warn(`pond[${ctx.accountId}]: catch-up item ${item.id} (${item.type}) failed: ${String(err)}`);
1025
+ log?.warn(`pond[${ctx.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
962
1026
  }
963
1027
  }
964
1028
  cursor = page.has_more ? page.next_cursor : undefined;
965
1029
  } while (cursor);
966
1030
 
967
1031
  if (processed > 0 || skippedOld > 0) {
968
- log?.info(`pond[${ctx.accountId}]: inbox catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
1032
+ log?.info(`pond[${ctx.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
969
1033
  }
970
1034
  }
971
1035