@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 +3 -3
- package/src/gateway.ts +187 -94
- package/src/hooks.ts +54 -47
- package/src/outbound.ts +3 -13
- package/src/runtime.ts +17 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "1.
|
|
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/
|
|
19
|
-
"@pnds/
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 }
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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,
|
|
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 —
|
|
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
|
|
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.
|
|
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
|
|
554
|
-
* lost on crash and need to be replayed from
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
692
|
-
log?.warn(`pond[${ctx.accountId}]:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
// ──
|
|
882
|
-
// Replays missed
|
|
883
|
-
//
|
|
884
|
-
|
|
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.
|
|
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.
|
|
900
|
-
client.
|
|
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.
|
|
952
|
+
if (item.event_type === "task_assign" && item.task_id) {
|
|
909
953
|
// Task catch-up: fetch full task and dispatch
|
|
910
|
-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1056
|
+
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
975
1057
|
}
|
|
976
1058
|
} catch (err) {
|
|
977
|
-
log?.warn(`pond[${ctx.accountId}]: catch-up
|
|
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}]:
|
|
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
|
-
//
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1009
|
-
});
|
|
1080
|
+
activeDispatches.clear();
|
|
1081
|
+
stopWikiHelper?.();
|
|
1010
1082
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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,
|
|
3
|
+
import { clearDispatchGroupKey, clearDispatchMessageId, clearSessionMessageId, getDispatchGroupKey, getDispatchMessageId, getDispatchNoReply, getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Resolve the PondClient + orgId +
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
109
|
+
// after_tool_call -> send tool_result step to AgentSession
|
|
109
110
|
api.on("after_tool_call", async (event, ctx) => {
|
|
110
|
-
const resolved =
|
|
111
|
-
if (!resolved || !
|
|
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.
|
|
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 ->
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
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. */
|