@pnds/pond 1.8.0 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "1.8.0",
3
+ "version": "1.10.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/sdk": "1.8.0",
19
- "@pnds/cli": "1.8.0"
18
+ "@pnds/cli": "1.10.0",
19
+ "@pnds/sdk": "1.10.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
package/src/gateway.ts CHANGED
@@ -6,6 +6,7 @@ type ChannelGatewayAdapter<T = unknown> = NonNullable<ChannelPlugin<T>["gateway"
6
6
  import { PondClient, PondWs, MENTION_ALL_USER_ID } from "@pnds/sdk";
7
7
  import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData, TaskAssignedData, Task } from "@pnds/sdk";
8
8
  import type { PondChannelConfig } from "./types.js";
9
+ import * as crypto from "node:crypto";
9
10
  import * as os from "node:os";
10
11
  import * as path from "node:path";
11
12
  import { resolvePondAccount } from "./accounts.js";
@@ -18,6 +19,8 @@ import {
18
19
  setSessionMessageId,
19
20
  setDispatchMessageId,
20
21
  setDispatchNoReply,
22
+ setDispatchGroupKey,
23
+ clearDispatchGroupKey,
21
24
  } from "./runtime.js";
22
25
  import type { PondEvent, DispatchState, ForkResult } from "./runtime.js";
23
26
  import { buildOrchestratorSessionKey } from "./session.js";
@@ -95,7 +98,6 @@ function buildForkResultPrefix(results: ForkResult[]): string {
95
98
  const lines = [`[Fork result]`];
96
99
  lines.push(`[Handled: ${r.sourceEvent.type} — ${r.sourceEvent.summary}]`);
97
100
  if (r.actions.length) lines.push(`[Actions: ${r.actions.join("; ")}]`);
98
- if (r.agentRunId) lines.push(`[Agent run: ${r.agentRunId}]`);
99
101
  return lines.join("\n");
100
102
  });
101
103
  return blocks.join("\n\n") + "\n\n---\n\n";
@@ -118,60 +120,72 @@ async function dispatchToAgent(opts: {
118
120
  triggerType?: string;
119
121
  triggerRef?: Record<string, unknown>;
120
122
  chatId?: string;
121
- /** For task dispatches: bind the run to a task target for Redis presence + fallback comments. */
122
123
  defaultTargetType?: string;
123
124
  defaultTargetId?: string;
125
+ senderId?: string;
126
+ senderName?: string;
127
+ messageBody?: string;
124
128
  log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
125
129
  }) {
126
130
  const { core, cfg, client, config, agentUserId, accountId, sessionKey, messageId, inboundCtx, log } = opts;
127
131
  const triggerType = opts.triggerType ?? "mention";
128
132
  const triggerRef = opts.triggerRef ?? { message_id: messageId };
129
- const sessionKeyLower = sessionKey.toLowerCase();
130
- let thinkingSent = false;
131
- let runIdPromise: Promise<string | undefined> | undefined;
133
+
134
+ // Resolve session ID from account state
135
+ const state = getPondAccountState(accountId);
136
+ const sessionId = state?.activeSessionId;
137
+
138
+ // Create input step — captures the user message as a step in the session
139
+ if (sessionId) {
140
+ const targetType = opts.chatId ? "chat" : opts.defaultTargetType ?? "";
141
+ const targetId = opts.chatId ?? opts.defaultTargetId;
142
+ try {
143
+ await client.createAgentStep(config.org_id, agentUserId, sessionId, {
144
+ step_type: "input",
145
+ target_type: targetType,
146
+ target_id: targetId,
147
+ content: {
148
+ trigger_type: triggerType,
149
+ trigger_ref: triggerRef,
150
+ sender_id: opts.senderId ?? "",
151
+ sender_name: opts.senderName ?? "",
152
+ summary: opts.messageBody?.substring(0, 200) ?? "",
153
+ },
154
+ });
155
+ } catch (err) {
156
+ log?.warn(`pond[${accountId}]: failed to create input step: ${String(err)}`);
157
+ }
158
+ }
159
+
132
160
  let reasoningBuffer = "";
161
+ let turnGroupKey = "";
133
162
  try {
134
163
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
135
164
  ctx: inboundCtx,
136
165
  cfg,
137
166
  dispatcherOptions: {
138
- deliver: async (payload: { text?: string }, info: { kind: string }) => {
167
+ deliver: async (payload: { text?: string }) => {
139
168
  // Orchestrator mode: ALL text output is suppressed — agent uses tools to interact.
140
169
  // Record as internal step for observability.
141
170
  const replyText = payload.text?.trim();
142
- if (!replyText) return;
143
- const runId = runIdPromise ? await runIdPromise : undefined;
144
- if (runId) {
145
- try {
146
- await client.createAgentStep(config.org_id, agentUserId, runId, {
147
- step_type: "text",
148
- content: { text: replyText, suppressed: true },
149
- ...(info.kind === "block" ? { chat_projection: false } : {}),
150
- });
151
- } catch (err) {
152
- log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
153
- }
171
+ if (!replyText || !sessionId) return;
172
+ const targetType = opts.chatId ? "chat" : opts.defaultTargetType ?? "";
173
+ const targetId = opts.chatId ?? opts.defaultTargetId;
174
+ try {
175
+ await client.createAgentStep(config.org_id, agentUserId, sessionId, {
176
+ step_type: "text",
177
+ target_type: targetType,
178
+ target_id: targetId,
179
+ content: { text: replyText, suppressed: true },
180
+ });
181
+ } catch (err) {
182
+ log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
154
183
  }
155
184
  },
156
185
  onReplyStart: () => {
157
- if (thinkingSent) return;
158
- thinkingSent = true;
159
- runIdPromise = (async () => {
160
- try {
161
- const run = await client.createAgentRun(config.org_id, agentUserId, {
162
- trigger_type: triggerType,
163
- trigger_ref: triggerRef,
164
- ...(opts.chatId ? { chat_id: opts.chatId } : {}),
165
- ...(opts.defaultTargetType ? { default_target_type: opts.defaultTargetType, default_target_id: opts.defaultTargetId } : {}),
166
- });
167
- return run.id;
168
- } catch (err) {
169
- log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
170
- return undefined;
171
- }
172
- })();
173
- const state = getPondAccountState(accountId);
174
- if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
186
+ // Generate a new group key per LLM response turn — shared across thinking + tool_call steps
187
+ turnGroupKey = crypto.randomUUID();
188
+ setDispatchGroupKey(sessionKey, turnGroupKey);
175
189
  },
176
190
  },
177
191
  replyOptions: {
@@ -179,12 +193,16 @@ async function dispatchToAgent(opts: {
179
193
  reasoningBuffer += payload.text ?? "";
180
194
  },
181
195
  onReasoningEnd: async () => {
182
- const runId = runIdPromise ? await runIdPromise : undefined;
183
- if (runId && reasoningBuffer) {
196
+ if (sessionId && reasoningBuffer) {
197
+ const targetType = opts.chatId ? "chat" : opts.defaultTargetType ?? "";
198
+ const targetId = opts.chatId ?? opts.defaultTargetId;
184
199
  try {
185
- await client.createAgentStep(config.org_id, agentUserId, runId, {
200
+ await client.createAgentStep(config.org_id, agentUserId, sessionId, {
186
201
  step_type: "thinking",
202
+ target_type: targetType,
203
+ target_id: targetId,
187
204
  content: { text: reasoningBuffer },
205
+ group_key: turnGroupKey || undefined,
188
206
  });
189
207
  } catch (err) {
190
208
  log?.warn(`pond[${accountId}]: failed to create thinking step: ${String(err)}`);
@@ -402,7 +420,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
402
420
  }
403
421
  }
404
422
  // Resolve remaining items as not-dispatched (fork stopped after error).
405
- // These events are NOT acked — inbox retains them for catch-up replay.
423
+ // These events are NOT acked — dispatch retains them for catch-up replay.
406
424
  for (const remaining of fork.queue.splice(0)) {
407
425
  remaining.resolve(false);
408
426
  }
@@ -420,7 +438,6 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
420
438
  : `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
421
439
  },
422
440
  actions: [],
423
- agentRunId: undefined,
424
441
  });
425
442
  }
426
443
  // Remove from tracking maps and clean up session files
@@ -489,6 +506,9 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
489
506
  chatId: event.targetId.startsWith("cht_") ? event.targetId : undefined,
490
507
  defaultTargetType: isTask ? "task" : undefined,
491
508
  defaultTargetId: isTask ? event.targetId : undefined,
509
+ senderId: event.senderId,
510
+ senderName: event.senderName,
511
+ messageBody: event.body,
492
512
  log,
493
513
  });
494
514
  if (!ok) throw new Error(`dispatch failed for ${event.messageId}`);
@@ -535,10 +555,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
535
555
 
536
556
  dispatchState.mainCurrentTargetId = event.targetId;
537
557
  await runDispatch(event, orchestratorKey, bodyForAgent);
538
- // Ack inbox after successful drain dispatch — these events were buffered
558
+ // Ack dispatch after successful drain — these events were buffered
539
559
  // without ack, so this is the first time they're confirmed processed.
540
560
  const sourceType = event.type === "task" ? "task_activity" : "message";
541
- client.ackInbox(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
561
+ client.ackDispatch(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
542
562
  }
543
563
  } finally {
544
564
  draining = false;
@@ -550,8 +570,8 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
550
570
  /**
551
571
  * Route and dispatch an inbound event.
552
572
  * 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.
573
+ * Callers should only ack dispatch when this returns true — buffered events may be
574
+ * lost on crash and need to be replayed from dispatch on the next catch-up.
555
575
  */
556
576
  async function handleInboundEvent(event: PondEvent): Promise<boolean> {
557
577
  const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
@@ -652,11 +672,25 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
652
672
  const count = await fetchAllChats(client, config.org_id, chatInfoMap);
653
673
  log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
654
674
 
675
+ // Create AgentSession on first hello (session lives across dispatches)
676
+ let activeSessionId: string | undefined;
677
+ try {
678
+ const session = await client.createAgentSession(config.org_id, agentUserId, {
679
+ runtime_type: "openclaw",
680
+ runtime_key: orchestratorKey,
681
+ runtime_ref: { hostname: os.hostname(), pid: process.pid },
682
+ });
683
+ activeSessionId = session.id;
684
+ log?.info(`pond[${ctx.accountId}]: created agent session ${session.id}`);
685
+ } catch (err) {
686
+ log?.warn(`pond[${ctx.accountId}]: failed to create agent session: ${String(err)}`);
687
+ }
688
+
655
689
  setPondAccountState(ctx.accountId, {
656
690
  client,
657
691
  orgId: config.org_id,
658
692
  agentUserId,
659
- activeRuns: new Map(),
693
+ activeSessionId,
660
694
  wikiMountRoot,
661
695
  ws,
662
696
  orchestratorSessionKey: orchestratorKey,
@@ -683,13 +717,13 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
683
717
  });
684
718
  }, intervalSec * 1000);
685
719
 
686
- // Unified inbox catch-up (replaces separate mention/DM/task catch-up).
720
+ // Dispatch catch-up: fetch pending dispatch events (FIFO).
687
721
  // On cold start, skip items older than COLD_START_WINDOW_MS to avoid
688
722
  // replaying historical backlog from a previous process.
689
723
  const isFirstHello = !hadSuccessfulHello;
690
724
  hadSuccessfulHello = true;
691
- catchUpFromInbox(isFirstHello).catch((err) => {
692
- log?.warn(`pond[${ctx.accountId}]: inbox catch-up failed: ${String(err)}`);
725
+ catchUpFromDispatch(isFirstHello).catch((err) => {
726
+ log?.warn(`pond[${ctx.accountId}]: dispatch catch-up failed: ${String(err)}`);
693
727
  });
694
728
  } catch (err) {
695
729
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
@@ -757,7 +791,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
757
791
  (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
758
792
  );
759
793
  if (!isMentioned) {
760
- // Release claim so catch-up can process this via agent_route inbox items
794
+ // Release claim so catch-up can process this via dispatch events
761
795
  dispatchedMessages.delete(data.id);
762
796
  return;
763
797
  }
@@ -802,7 +836,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
802
836
  try {
803
837
  const dispatched = await handleInboundEvent(event);
804
838
  if (dispatched) {
805
- client.ackInbox(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
839
+ client.ackDispatch(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
806
840
  } else {
807
841
  // Not dispatched (buffered or fork failed) — release claim so catch-up can retry
808
842
  dispatchedMessages.delete(data.id);
@@ -861,7 +895,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
861
895
  // Server event buffer overflowed — events were lost, trigger full catch-up
862
896
  ws.on("recovery.overflow", () => {
863
897
  log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
864
- catchUpFromInbox().catch((err) =>
898
+ catchUpFromDispatch().catch((err) =>
865
899
  log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
866
900
  });
867
901
 
@@ -871,33 +905,43 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
871
905
  try {
872
906
  const dispatched = await handleTaskAssignment(data);
873
907
  if (dispatched) {
874
- client.ackInbox(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
908
+ client.ackDispatch(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
875
909
  }
876
910
  } catch (err) {
877
911
  log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
878
912
  }
879
913
  });
880
914
 
881
- // ── Unified inbox catch-up ──
882
- // Replays missed mentions, DMs, and task assignments via the inbox API.
883
- // Adapts each item into a PondEvent and routes through handleInboundEvent.
884
- async function catchUpFromInbox(coldStart = false) {
915
+ // ── Dispatch catch-up ──
916
+ // Replays missed events via the dispatch API (agent-only endpoint, FIFO order).
917
+ // Each dispatch event is a source-pointer; we re-fetch the entity to build a PondEvent.
918
+ //
919
+ // Cold-start window: On a fresh process start (not reconnect), events older than
920
+ // COLD_START_WINDOW_MS are acked without processing. This is an intentional operator
921
+ // decision: a newly started agent should not replay unbounded historical backlog from
922
+ // a previous process lifetime. Reconnects (non-cold-start) replay all pending events.
923
+ //
924
+ // NOTE: RouteToAgents (active/smart group routing) inserts dispatch rows asynchronously
925
+ // after the message.new WS event. A race exists where the live ack-by-source fires
926
+ // before the dispatch row is inserted, leaving an orphan pending row. This is benign:
927
+ // catch-up will process it on next cycle (duplicate delivery, not data loss).
928
+ // TODO: resolve by creating dispatch rows before publishing the live event, or by
929
+ // consuming dispatch.new in the live path.
930
+ async function catchUpFromDispatch(coldStart = false) {
885
931
  const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
886
932
  let cursor: string | undefined;
887
933
  let processed = 0;
888
934
  let skippedOld = 0;
889
935
 
890
936
  do {
891
- const page = await client.getInbox(config.org_id, {
892
- type: "mention,agent_dm,task_assign,agent_route",
893
- read: "false",
937
+ const page = await client.getDispatch(config.org_id, {
894
938
  limit: 50,
895
939
  cursor,
896
940
  });
897
941
 
898
942
  for (const item of page.data ?? []) {
899
- if (minAge > 0 && new Date(item.updated_at).getTime() < minAge) {
900
- client.markInboxRead(config.org_id, item.id).catch(() => {});
943
+ if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
944
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
901
945
  skippedOld++;
902
946
  continue;
903
947
  }
@@ -905,21 +949,59 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
905
949
  processed++;
906
950
  try {
907
951
  let dispatched = false;
908
- if (item.type === "task_assign" && item.source_id) {
952
+ if (item.event_type === "task_assign" && item.task_id) {
909
953
  // Task catch-up: fetch full task and dispatch
910
- const task = await client.getTask(config.org_id, item.source_id);
954
+ let task: Awaited<ReturnType<typeof client.getTask>> | null = null;
955
+ let taskFetchFailed = false;
956
+ try {
957
+ task = await client.getTask(config.org_id, item.task_id);
958
+ } catch (err: unknown) {
959
+ // Distinguish 404 (task deleted) from transient failures
960
+ const status = (err as { status?: number })?.status;
961
+ if (status === 404) {
962
+ task = null; // task genuinely deleted
963
+ } else {
964
+ taskFetchFailed = true;
965
+ log?.warn(`pond[${ctx.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
966
+ }
967
+ }
968
+ if (taskFetchFailed) {
969
+ continue; // leave pending for next catch-up cycle
970
+ }
911
971
  if (task) {
972
+ // Defense-in-depth (D2): verify task is still assigned to this agent
973
+ if (task.assignee_id !== agentUserId) {
974
+ log?.info(`pond[${ctx.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
975
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
976
+ continue;
977
+ }
912
978
  dispatched = await handleTaskAssignment(task);
913
979
  } else {
914
- dispatched = true; // task deleted — mark read to prevent infinite retry
980
+ dispatched = true; // task genuinely deleted — ack to prevent infinite retry
915
981
  }
916
- } else if (item.source_id && item.chat_id) {
917
- // Message catch-up (mention or agent_dm): build PondEvent from inbox item
982
+ } else if (item.event_type === "message" && item.source_id && item.chat_id) {
983
+ // Message catch-up: build PondEvent from dispatch source pointer
918
984
  if (!tryClaimMessage(item.source_id)) continue;
919
- const msg = await client.getMessage(item.source_id).catch(() => null);
985
+ let msg: Awaited<ReturnType<typeof client.getMessage>> | null = null;
986
+ let msgFetchFailed = false;
987
+ try {
988
+ msg = await client.getMessage(item.source_id);
989
+ } catch (err: unknown) {
990
+ const status = (err as { status?: number })?.status;
991
+ if (status === 404) {
992
+ msg = null; // message genuinely deleted
993
+ } else {
994
+ msgFetchFailed = true;
995
+ log?.warn(`pond[${ctx.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
996
+ }
997
+ }
998
+ if (msgFetchFailed) {
999
+ dispatchedMessages.delete(item.source_id);
1000
+ continue; // leave pending for next catch-up cycle
1001
+ }
920
1002
  if (!msg || msg.sender_id === agentUserId) {
921
1003
  dispatchedMessages.delete(item.source_id);
922
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1004
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
923
1005
  continue;
924
1006
  }
925
1007
 
@@ -951,7 +1033,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
951
1033
  }
952
1034
  if (!body) {
953
1035
  dispatchedMessages.delete(item.source_id);
954
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1036
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
955
1037
  continue;
956
1038
  }
957
1039
 
@@ -971,46 +1053,57 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
971
1053
  dispatched = await handleInboundEvent(event);
972
1054
  }
973
1055
  if (dispatched) {
974
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1056
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
975
1057
  }
976
1058
  } catch (err) {
977
- log?.warn(`pond[${ctx.accountId}]: catch-up item ${item.id} (${item.type}) failed: ${String(err)}`);
1059
+ log?.warn(`pond[${ctx.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
978
1060
  }
979
1061
  }
980
1062
  cursor = page.has_more ? page.next_cursor : undefined;
981
1063
  } while (cursor);
982
1064
 
983
1065
  if (processed > 0 || skippedOld > 0) {
984
- log?.info(`pond[${ctx.accountId}]: inbox catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
1066
+ log?.info(`pond[${ctx.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
985
1067
  }
986
1068
  }
987
1069
 
988
1070
  log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
989
1071
  await ws.connect();
990
1072
 
991
- // Clean up on abort
992
- ctx.abortSignal.addEventListener("abort", () => {
993
- if (heartbeatTimer) clearInterval(heartbeatTimer);
994
- for (const [, dispatch] of activeDispatches) {
995
- clearInterval(dispatch.typingTimer);
996
- }
997
- activeDispatches.clear();
998
- stopWikiHelper?.();
999
- ws.disconnect();
1000
- removePondAccountState(ctx.accountId);
1001
- for (const [key, value] of Object.entries(previousEnv)) {
1002
- if (value === undefined) {
1003
- delete process.env[key];
1004
- } else {
1005
- process.env[key] = value;
1073
+ // Keep the gateway alive until aborted; clean up before resolving
1074
+ return new Promise<void>((resolve) => {
1075
+ ctx.abortSignal.addEventListener("abort", async () => {
1076
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
1077
+ for (const [, dispatch] of activeDispatches) {
1078
+ clearInterval(dispatch.typingTimer);
1006
1079
  }
1007
- }
1008
- log?.info(`pond[${ctx.accountId}]: disconnected`);
1009
- });
1080
+ activeDispatches.clear();
1081
+ stopWikiHelper?.();
1010
1082
 
1011
- // Keep the gateway alive until aborted
1012
- return new Promise<void>((resolve) => {
1013
- ctx.abortSignal.addEventListener("abort", () => resolve());
1083
+ // Complete the agent session
1084
+ const currentState = getPondAccountState(ctx.accountId);
1085
+ if (currentState?.activeSessionId) {
1086
+ try {
1087
+ await client.updateAgentSession(config.org_id, agentUserId, currentState.activeSessionId, {
1088
+ status: "completed",
1089
+ });
1090
+ } catch (err) {
1091
+ log?.warn(`pond[${ctx.accountId}]: failed to complete session: ${String(err)}`);
1092
+ }
1093
+ }
1094
+
1095
+ ws.disconnect();
1096
+ removePondAccountState(ctx.accountId);
1097
+ for (const [key, value] of Object.entries(previousEnv)) {
1098
+ if (value === undefined) {
1099
+ delete process.env[key];
1100
+ } else {
1101
+ process.env[key] = value;
1102
+ }
1103
+ }
1104
+ log?.info(`pond[${ctx.accountId}]: disconnected`);
1105
+ resolve();
1106
+ });
1014
1107
  });
1015
1108
  },
1016
1109
  };
package/src/hooks.ts CHANGED
@@ -1,16 +1,13 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { extractAccountIdFromSessionKey } from "./session.js";
3
- import { clearDispatchMessageId, clearSessionMessageId, getActiveRunId, getDispatchMessageId, getDispatchNoReply, getPondAccountState, getSessionChatId } from "./runtime.js";
3
+ import { clearDispatchGroupKey, clearDispatchMessageId, clearSessionMessageId, getDispatchGroupKey, getDispatchMessageId, getDispatchNoReply, getPondAccountState, getSessionChatId } from "./runtime.js";
4
4
 
5
5
  /**
6
- * Resolve the PondClient + orgId + runId for a hook context.
7
- * Awaits the run ID promise so hooks that fire before createAgentRun
8
- * completes will block until the run ID is available (not silently drop).
6
+ * Resolve the PondClient + orgId + sessionId for a hook context.
7
+ * Returns undefined if the account/session is not active.
9
8
  */
10
- async function resolveClientForHook(sessionKey: string | undefined) {
9
+ function resolveClientForHook(sessionKey: string | undefined) {
11
10
  if (!sessionKey) return undefined;
12
- // Use stored mapping to recover original (case-sensitive) chat ID,
13
- // because openclaw normalizes session keys to lowercase.
14
11
  const chatId = getSessionChatId(sessionKey);
15
12
  if (!chatId) return undefined;
16
13
  const accountId = extractAccountIdFromSessionKey(sessionKey);
@@ -19,9 +16,7 @@ async function resolveClientForHook(sessionKey: string | undefined) {
19
16
  const state = getPondAccountState(accountId);
20
17
  if (!state) return undefined;
21
18
 
22
- // Await the run ID promise — may block briefly if the run is still being created
23
- const activeRunId = await getActiveRunId(state, sessionKey);
24
- return { ...state, chatId, activeRunId };
19
+ return { ...state, chatId, sessionId: state.activeSessionId };
25
20
  }
26
21
 
27
22
  const POND_CHANNEL_CONTEXT = `## Pond Channel — Message Delivery
@@ -46,17 +41,20 @@ export function registerPondHooks(api: OpenClawPluginApi) {
46
41
  return { appendSystemContext: POND_CHANNEL_CONTEXT };
47
42
  });
48
43
 
49
- // before_tool_call -> (1) fire-and-forget step creation, (2) ENV injection for exec tool
44
+ // before_tool_call -> (1) await step creation to get step ID, (2) ENV injection for exec tool
50
45
  api.on("before_tool_call", async (event, ctx) => {
51
46
  const sessionKey = ctx.sessionKey;
52
47
 
53
- // (1) Fire-and-forget step creation (all tools)
54
- void (async () => {
55
- const resolved = await resolveClientForHook(sessionKey);
56
- if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
48
+ // (1) Create tool_call step and await to get step ID
49
+ let stepId: string | undefined;
50
+ const resolved = resolveClientForHook(sessionKey);
51
+ if (resolved?.sessionId && event.toolCallId) {
52
+ const groupKey = sessionKey ? getDispatchGroupKey(sessionKey) : undefined;
57
53
  try {
58
- await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
54
+ const step = await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.sessionId, {
59
55
  step_type: "tool_call",
56
+ target_type: resolved.chatId.startsWith("cht_") ? "chat" : resolved.chatId.startsWith("tsk_") ? "task" : "",
57
+ target_id: resolved.chatId || undefined,
60
58
  content: {
61
59
  call_id: event.toolCallId,
62
60
  tool_name: event.toolName,
@@ -64,11 +62,14 @@ export function registerPondHooks(api: OpenClawPluginApi) {
64
62
  status: "running",
65
63
  started_at: new Date().toISOString(),
66
64
  },
65
+ group_key: groupKey,
66
+ runtime_key: event.toolCallId,
67
67
  });
68
+ stepId = step.id;
68
69
  } catch (err) {
69
70
  log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
70
71
  }
71
- })();
72
+ }
72
73
 
73
74
  // (2) ENV injection — only for the exec (Bash) tool
74
75
  if (event.toolName !== "exec") return;
@@ -79,13 +80,13 @@ export function registerPondHooks(api: OpenClawPluginApi) {
79
80
  if (!state) return;
80
81
 
81
82
  // Dynamic per-dispatch context (changes each dispatch)
82
- const runId = await getActiveRunId(state, sessionKey);
83
83
  const chatId = getSessionChatId(sessionKey);
84
84
  const triggerMsgId = getDispatchMessageId(sessionKey);
85
85
  const noReply = getDispatchNoReply(sessionKey);
86
86
 
87
87
  const injectedEnv: Record<string, string> = {};
88
- if (runId) injectedEnv.POND_RUN_ID = runId;
88
+ if (state.activeSessionId) injectedEnv.POND_SESSION_ID = state.activeSessionId;
89
+ if (stepId) injectedEnv.POND_STEP_ID = stepId;
89
90
  if (chatId) injectedEnv.POND_CHAT_ID = chatId;
90
91
  if (triggerMsgId) injectedEnv.POND_TRIGGER_MESSAGE_ID = triggerMsgId;
91
92
  if (noReply) injectedEnv.POND_NO_REPLY = "1";
@@ -105,13 +106,15 @@ export function registerPondHooks(api: OpenClawPluginApi) {
105
106
  };
106
107
  });
107
108
 
108
- // after_tool_call -> send tool_result step to AgentRun
109
+ // after_tool_call -> send tool_result step to AgentSession
109
110
  api.on("after_tool_call", async (event, ctx) => {
110
- const resolved = await resolveClientForHook(ctx.sessionKey);
111
- if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
111
+ const resolved = resolveClientForHook(ctx.sessionKey);
112
+ if (!resolved?.sessionId || !event.toolCallId) return;
112
113
  try {
113
- await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
114
+ await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.sessionId, {
114
115
  step_type: "tool_result",
116
+ target_type: resolved.chatId.startsWith("cht_") ? "chat" : resolved.chatId.startsWith("tsk_") ? "task" : "",
117
+ target_id: resolved.chatId || undefined,
115
118
  content: {
116
119
  call_id: event.toolCallId,
117
120
  tool_name: event.toolName,
@@ -126,31 +129,35 @@ export function registerPondHooks(api: OpenClawPluginApi) {
126
129
  }
127
130
  });
128
131
 
129
- // agent_end -> complete the AgentRun
130
- api.on("agent_end", async (_event, ctx) => {
131
- const resolved = await resolveClientForHook(ctx.sessionKey);
132
- if (!resolved) {
133
- log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
134
- return;
135
- }
136
- if (!resolved.activeRunId) return;
137
- const accountId = extractAccountIdFromSessionKey(ctx.sessionKey ?? "");
138
- const state = accountId ? getPondAccountState(accountId) : undefined;
139
- try {
140
- await resolved.client.updateAgentRun(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
141
- status: "completed",
142
- });
143
- } catch (err) {
144
- log?.warn(`pond hook agent_end failed: ${String(err)}`);
145
- } finally {
146
- // Always clear local state — even if the server call failed, the run is over locally
147
- if (ctx.sessionKey) {
148
- clearSessionMessageId(ctx.sessionKey);
149
- clearDispatchMessageId(ctx.sessionKey);
150
- }
151
- if (state && ctx.sessionKey) {
152
- state.activeRuns.delete(ctx.sessionKey.toLowerCase());
132
+ // agent_end -> end of a dispatch cycle, NOT end of session.
133
+ // Session lives across dispatches — don't update session status here.
134
+ // On error: emit a visible error step so the chat user sees the failure.
135
+ api.on("agent_end", async (event, ctx) => {
136
+ // Emit error step if the dispatch failed
137
+ if (event.error && ctx.sessionKey) {
138
+ const accountId = extractAccountIdFromSessionKey(ctx.sessionKey);
139
+ const state = accountId ? getPondAccountState(accountId) : undefined;
140
+ const chatId = getSessionChatId(ctx.sessionKey);
141
+ if (state?.activeSessionId && chatId) {
142
+ try {
143
+ await state.client.createAgentStep(state.orgId, state.agentUserId, state.activeSessionId, {
144
+ step_type: "text",
145
+ target_type: "chat",
146
+ target_id: chatId,
147
+ content: { text: `Dispatch failed: ${String(event.error)}`, suppressed: false },
148
+ projection: true,
149
+ });
150
+ } catch (err) {
151
+ log?.warn(`pond hook agent_end: failed to emit error step: ${String(err)}`);
152
+ }
153
153
  }
154
154
  }
155
+
156
+ // Clean up dispatch-specific state
157
+ if (ctx.sessionKey) {
158
+ clearSessionMessageId(ctx.sessionKey);
159
+ clearDispatchMessageId(ctx.sessionKey);
160
+ clearDispatchGroupKey(ctx.sessionKey);
161
+ }
155
162
  });
156
163
  }
package/src/outbound.ts CHANGED
@@ -3,7 +3,7 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
3
3
  /** Extract ChannelOutboundAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
4
4
  type ChannelOutboundAdapter = NonNullable<ChannelPlugin["outbound"]>;
5
5
  import { resolvePondAccount } from "./accounts.js";
6
- import { getPondAccountState, getSessionChatId } from "./runtime.js";
6
+ import { getPondAccountState } from "./runtime.js";
7
7
  import { PondClient } from "@pnds/sdk";
8
8
  import type { SendMessageRequest } from "@pnds/sdk";
9
9
 
@@ -25,21 +25,11 @@ export const pondOutbound: ChannelOutboundAdapter = {
25
25
  const orgId = state?.orgId ?? account.config.org_id;
26
26
  const chatId = to;
27
27
 
28
- // Match active run by chatId (outbound path has no sessionKey, but has chatId via `to`)
29
- let agentRunId: string | undefined;
30
- if (state) {
31
- for (const [sessionKey, runPromise] of state.activeRuns.entries()) {
32
- if (getSessionChatId(sessionKey) === chatId) {
33
- agentRunId = await runPromise;
34
- break;
35
- }
36
- }
37
- }
38
-
28
+ // Outbound path has no step context send without agent_step_id.
29
+ // Step linkage happens via the CLI's ENV-injected POND_STEP_ID in the normal dispatch path.
39
30
  const req: SendMessageRequest = {
40
31
  message_type: "text",
41
32
  content: { text },
42
- agent_run_id: agentRunId,
43
33
  };
44
34
  const msg = await client.sendMessage(orgId, chatId, req);
45
35
  return { channel: "pond", messageId: msg.id, channelId: chatId };
package/src/runtime.ts CHANGED
@@ -19,21 +19,12 @@ export type PondAccountState = {
19
19
  client: PondClient;
20
20
  orgId: string;
21
21
  agentUserId: string;
22
- activeRuns: Map<string, Promise<string | undefined>>; // sessionKey (lowercased) → runId promise
22
+ activeSessionId?: string;
23
23
  wikiMountRoot?: string;
24
24
  ws?: PondWs;
25
25
  orchestratorSessionKey?: string;
26
26
  };
27
27
 
28
- /** Resolve run ID for a session, awaiting if the run is still being created. */
29
- export async function getActiveRunId(
30
- state: PondAccountState,
31
- sessionKey: string,
32
- ): Promise<string | undefined> {
33
- const promise = state.activeRuns.get(sessionKey.toLowerCase());
34
- return promise ? await promise : undefined;
35
- }
36
-
37
28
  const accountStates = new Map<string, PondAccountState>();
38
29
 
39
30
  export function setPondAccountState(accountId: string, state: PondAccountState) {
@@ -94,6 +85,22 @@ export function clearDispatchMessageId(sessionKey: string) {
94
85
  dispatchMessageIdMap.delete(sessionKey.toLowerCase());
95
86
  }
96
87
 
88
+ // Per-dispatch group key — shared between thinking + tool_call steps for Session Panel grouping.
89
+ // Set per LLM response turn in gateway.ts, read in hooks.ts.
90
+ const dispatchGroupKeyMap = new Map<string, string>();
91
+
92
+ export function setDispatchGroupKey(sessionKey: string, groupKey: string) {
93
+ dispatchGroupKeyMap.set(sessionKey.toLowerCase(), groupKey);
94
+ }
95
+
96
+ export function getDispatchGroupKey(sessionKey: string): string | undefined {
97
+ return dispatchGroupKeyMap.get(sessionKey.toLowerCase());
98
+ }
99
+
100
+ export function clearDispatchGroupKey(sessionKey: string) {
101
+ dispatchGroupKeyMap.delete(sessionKey.toLowerCase());
102
+ }
103
+
97
104
  // Per-dispatch noReply flag — deterministic suppression for agent-to-agent loop prevention.
98
105
  // Injected as POND_NO_REPLY=1 into Bash env; the CLI checks and suppresses sends.
99
106
  const dispatchNoReplyMap = new Map<string, boolean>();
@@ -115,7 +122,6 @@ export type ForkResult = {
115
122
  forkSessionKey: string;
116
123
  sourceEvent: { type: string; targetId: string; summary: string };
117
124
  actions: string[]; // human-readable list of actions taken (e.g. "replied to cht_xxx")
118
- agentRunId?: string;
119
125
  };
120
126
 
121
127
  /** Mutable dispatch state for the orchestrator gateway. Per-account, managed by gateway.ts. */