@pnds/pond 1.0.0 → 1.0.1
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 +2 -2
- package/src/gateway.ts +108 -27
- package/src/hooks.ts +40 -29
- package/src/outbound.ts +13 -2
- package/src/runtime.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "OpenClaw channel plugin for Pond IM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"openclaw.plugin.json"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pnds/sdk": "1.0.
|
|
17
|
+
"@pnds/sdk": "1.0.1"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"typescript": "^5.7.0"
|
package/src/gateway.ts
CHANGED
|
@@ -49,29 +49,48 @@ async function dispatchToAgent(opts: {
|
|
|
49
49
|
config: PondChannelConfig;
|
|
50
50
|
agentUserId: string;
|
|
51
51
|
accountId: string;
|
|
52
|
+
sessionKey: string;
|
|
52
53
|
chatId: string;
|
|
53
54
|
messageId: string;
|
|
54
55
|
inboundCtx: Record<string, unknown>;
|
|
55
56
|
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
56
57
|
}) {
|
|
57
|
-
const { core, cfg, client, config, agentUserId, accountId, chatId, messageId, inboundCtx, log } = opts;
|
|
58
|
+
const { core, cfg, client, config, agentUserId, accountId, sessionKey, chatId, messageId, inboundCtx, log } = opts;
|
|
59
|
+
const sessionKeyLower = sessionKey.toLowerCase();
|
|
58
60
|
let thinkingSent = false;
|
|
59
61
|
let runIdPromise: Promise<string | undefined> | undefined;
|
|
62
|
+
let reasoningBuffer = "";
|
|
60
63
|
try {
|
|
61
64
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
62
65
|
ctx: inboundCtx,
|
|
63
66
|
cfg,
|
|
64
67
|
dispatcherOptions: {
|
|
65
|
-
deliver: async (payload: { text?: string }) => {
|
|
68
|
+
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
66
69
|
const replyText = payload.text?.trim();
|
|
67
70
|
if (!replyText) return;
|
|
68
71
|
// Wait for run creation to complete before sending
|
|
69
72
|
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
|
|
74
|
+
if (info.kind === "final") {
|
|
75
|
+
// Final reply → chat message
|
|
76
|
+
await client.sendMessage(config.org_id, chatId, {
|
|
77
|
+
message_type: "text",
|
|
78
|
+
content: { text: replyText },
|
|
79
|
+
agent_run_id: runId,
|
|
80
|
+
});
|
|
81
|
+
} else if (info.kind === "block" && runId) {
|
|
82
|
+
// Intermediate text → step with chat_projection hint (server decides)
|
|
83
|
+
try {
|
|
84
|
+
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
85
|
+
step_type: "text",
|
|
86
|
+
content: { text: replyText },
|
|
87
|
+
chat_projection: true,
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// kind === "tool" → ignore (handled by hooks)
|
|
75
94
|
},
|
|
76
95
|
onReplyStart: () => {
|
|
77
96
|
if (thinkingSent) return;
|
|
@@ -83,18 +102,34 @@ async function dispatchToAgent(opts: {
|
|
|
83
102
|
trigger_ref: { message_id: messageId },
|
|
84
103
|
chat_id: chatId,
|
|
85
104
|
});
|
|
86
|
-
const state = getPondAccountState(accountId);
|
|
87
|
-
if (state) state.activeRunId = run.id;
|
|
88
|
-
await client.createAgentStep(config.org_id, agentUserId, run.id, {
|
|
89
|
-
step_type: "thinking",
|
|
90
|
-
content: { text: "" },
|
|
91
|
-
});
|
|
92
105
|
return run.id;
|
|
93
106
|
} catch (err) {
|
|
94
107
|
log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
|
|
95
108
|
return undefined;
|
|
96
109
|
}
|
|
97
110
|
})();
|
|
111
|
+
// Store promise immediately so hooks can await it before the run is created
|
|
112
|
+
const state = getPondAccountState(accountId);
|
|
113
|
+
if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
replyOptions: {
|
|
117
|
+
onReasoningStream: (payload: { text?: string }) => {
|
|
118
|
+
reasoningBuffer += payload.text ?? "";
|
|
119
|
+
},
|
|
120
|
+
onReasoningEnd: async () => {
|
|
121
|
+
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
122
|
+
if (runId && reasoningBuffer) {
|
|
123
|
+
try {
|
|
124
|
+
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
125
|
+
step_type: "thinking",
|
|
126
|
+
content: { text: reasoningBuffer },
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
log?.warn(`pond[${accountId}]: failed to create thinking step: ${String(err)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
reasoningBuffer = "";
|
|
98
133
|
},
|
|
99
134
|
},
|
|
100
135
|
});
|
|
@@ -161,6 +196,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
161
196
|
let sessionId = "";
|
|
162
197
|
// Heartbeat interval handle — created inside hello handler with server-provided interval
|
|
163
198
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
199
|
+
// Track active dispatches per chat for typing indicator management
|
|
200
|
+
const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
|
|
201
|
+
// Heartbeat watchdog: detect event loop blocking during long CC runs
|
|
202
|
+
let lastHeartbeatAt = Date.now();
|
|
164
203
|
|
|
165
204
|
// Log connection state changes (covers reconnection)
|
|
166
205
|
ws.onStateChange((state) => {
|
|
@@ -180,11 +219,20 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
180
219
|
client,
|
|
181
220
|
orgId: config.org_id,
|
|
182
221
|
agentUserId,
|
|
222
|
+
activeRuns: new Map(),
|
|
183
223
|
});
|
|
184
224
|
|
|
185
225
|
// (Re)start heartbeat with server-provided interval
|
|
186
226
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
227
|
+
lastHeartbeatAt = Date.now();
|
|
187
228
|
heartbeatTimer = setInterval(() => {
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
const expectedMs = intervalSec * 1000;
|
|
231
|
+
const drift = now - lastHeartbeatAt - expectedMs;
|
|
232
|
+
if (drift > 15000) {
|
|
233
|
+
log?.warn(`pond[${ctx.accountId}]: heartbeat drift ${drift}ms — event loop may be blocked`);
|
|
234
|
+
}
|
|
235
|
+
lastHeartbeatAt = now;
|
|
188
236
|
if (ws.state !== "connected") return;
|
|
189
237
|
ws.sendAgentHeartbeat(sessionId, {
|
|
190
238
|
hostname: os.hostname(),
|
|
@@ -201,8 +249,9 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
201
249
|
|
|
202
250
|
// Track chat type changes (new chats, renames, etc.)
|
|
203
251
|
ws.on("chat.update", (data: ChatUpdateData) => {
|
|
204
|
-
|
|
205
|
-
|
|
252
|
+
const changes = data.changes as Record<string, unknown> | undefined;
|
|
253
|
+
if (changes && typeof changes.type === "string") {
|
|
254
|
+
chatTypeMap.set(data.chat_id, changes.type as Chat["type"]);
|
|
206
255
|
}
|
|
207
256
|
});
|
|
208
257
|
|
|
@@ -294,18 +343,45 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
294
343
|
OriginatingTo: `pond:${chatId}`,
|
|
295
344
|
});
|
|
296
345
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
346
|
+
// Manage typing indicator so the user sees the agent is working.
|
|
347
|
+
// Reference-counted: concurrent dispatches for the same chat share one timer.
|
|
348
|
+
const existingDispatch = activeDispatches.get(chatId);
|
|
349
|
+
if (existingDispatch) {
|
|
350
|
+
existingDispatch.count++;
|
|
351
|
+
} else {
|
|
352
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
353
|
+
// Frontend auto-clears typing after 3s, so refresh at 2s to avoid flicker
|
|
354
|
+
const typingRefresh = setInterval(() => {
|
|
355
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
356
|
+
}, 2000);
|
|
357
|
+
activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
await dispatchToAgent({
|
|
362
|
+
core,
|
|
363
|
+
cfg: ctx.cfg,
|
|
364
|
+
client,
|
|
365
|
+
config,
|
|
366
|
+
agentUserId,
|
|
367
|
+
accountId: ctx.accountId,
|
|
368
|
+
sessionKey,
|
|
369
|
+
chatId,
|
|
370
|
+
messageId: data.id,
|
|
371
|
+
inboundCtx,
|
|
372
|
+
log,
|
|
373
|
+
});
|
|
374
|
+
} finally {
|
|
375
|
+
const dispatch = activeDispatches.get(chatId);
|
|
376
|
+
if (dispatch) {
|
|
377
|
+
dispatch.count--;
|
|
378
|
+
if (dispatch.count <= 0) {
|
|
379
|
+
clearInterval(dispatch.typingTimer);
|
|
380
|
+
activeDispatches.delete(chatId);
|
|
381
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
309
385
|
});
|
|
310
386
|
|
|
311
387
|
// Re-apply platform config when server pushes an update notification
|
|
@@ -324,6 +400,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
324
400
|
// Clean up on abort
|
|
325
401
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
326
402
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
403
|
+
// Clean up all active typing timers to prevent leaks
|
|
404
|
+
for (const [, dispatch] of activeDispatches) {
|
|
405
|
+
clearInterval(dispatch.typingTimer);
|
|
406
|
+
}
|
|
407
|
+
activeDispatches.clear();
|
|
327
408
|
ws.disconnect();
|
|
328
409
|
removePondAccountState(ctx.accountId);
|
|
329
410
|
// Clear injected env vars so stale credentials don't leak to future subprocesses
|
package/src/hooks.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { extractAccountIdFromSessionKey } from "./session.js";
|
|
3
|
-
import { getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
3
|
+
import { getActiveRunId, getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Resolve the PondClient + orgId + runId for a hook context.
|
|
7
|
-
*
|
|
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).
|
|
8
9
|
*/
|
|
9
|
-
function resolveClientForHook(sessionKey: string | undefined) {
|
|
10
|
+
async function resolveClientForHook(sessionKey: string | undefined) {
|
|
10
11
|
if (!sessionKey) return undefined;
|
|
11
12
|
// Use stored mapping to recover original (case-sensitive) chat ID,
|
|
12
13
|
// because openclaw normalizes session keys to lowercase.
|
|
@@ -17,7 +18,10 @@ function resolveClientForHook(sessionKey: string | undefined) {
|
|
|
17
18
|
|
|
18
19
|
const state = getPondAccountState(accountId);
|
|
19
20
|
if (!state) return undefined;
|
|
20
|
-
|
|
21
|
+
|
|
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 };
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
const POND_CLI_CONTEXT = `## Pond CLI
|
|
@@ -48,33 +52,38 @@ export function registerPondHooks(api: OpenClawPluginApi) {
|
|
|
48
52
|
});
|
|
49
53
|
|
|
50
54
|
// before_tool_call -> send tool_call step to AgentRun
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
// Fire-and-forget: before_tool_call is an awaited hook in OpenClaw (can block/modify
|
|
56
|
+
// tool params), so we must not block tool execution waiting for run creation.
|
|
57
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
58
|
+
void (async () => {
|
|
59
|
+
const resolved = await resolveClientForHook(ctx.sessionKey);
|
|
60
|
+
if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
|
|
61
|
+
try {
|
|
62
|
+
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
63
|
+
step_type: "tool_call",
|
|
64
|
+
content: {
|
|
65
|
+
call_id: event.toolCallId,
|
|
66
|
+
tool_name: event.toolName,
|
|
67
|
+
tool_input: event.params ?? {},
|
|
68
|
+
status: "running",
|
|
69
|
+
started_at: new Date().toISOString(),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
// after_tool_call -> send tool_result step to AgentRun
|
|
70
79
|
api.on("after_tool_call", async (event, ctx) => {
|
|
71
|
-
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
72
|
-
if (!resolved || !resolved.activeRunId) return;
|
|
80
|
+
const resolved = await resolveClientForHook(ctx.sessionKey);
|
|
81
|
+
if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
|
|
73
82
|
try {
|
|
74
83
|
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
75
84
|
step_type: "tool_result",
|
|
76
85
|
content: {
|
|
77
|
-
|
|
86
|
+
call_id: event.toolCallId,
|
|
78
87
|
tool_name: event.toolName,
|
|
79
88
|
status: event.error ? "error" : "success",
|
|
80
89
|
output: event.error ?? (typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? "")),
|
|
@@ -89,23 +98,25 @@ export function registerPondHooks(api: OpenClawPluginApi) {
|
|
|
89
98
|
|
|
90
99
|
// agent_end -> complete the AgentRun
|
|
91
100
|
api.on("agent_end", async (_event, ctx) => {
|
|
92
|
-
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
101
|
+
const resolved = await resolveClientForHook(ctx.sessionKey);
|
|
93
102
|
if (!resolved) {
|
|
94
103
|
log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
|
|
95
104
|
return;
|
|
96
105
|
}
|
|
97
106
|
if (!resolved.activeRunId) return;
|
|
107
|
+
const accountId = extractAccountIdFromSessionKey(ctx.sessionKey ?? "");
|
|
108
|
+
const state = accountId ? getPondAccountState(accountId) : undefined;
|
|
98
109
|
try {
|
|
99
110
|
await resolved.client.updateAgentRun(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
100
111
|
status: "completed",
|
|
101
112
|
});
|
|
102
|
-
// Clear the active run
|
|
103
|
-
const state = getPondAccountState(
|
|
104
|
-
extractAccountIdFromSessionKey(ctx.sessionKey ?? "") ?? "",
|
|
105
|
-
);
|
|
106
|
-
if (state) state.activeRunId = undefined;
|
|
107
113
|
} catch (err) {
|
|
108
114
|
log?.warn(`pond hook agent_end failed: ${String(err)}`);
|
|
115
|
+
} finally {
|
|
116
|
+
// Always clear local state — even if the server call failed, the run is over locally
|
|
117
|
+
if (state && ctx.sessionKey) {
|
|
118
|
+
state.activeRuns.delete(ctx.sessionKey.toLowerCase());
|
|
119
|
+
}
|
|
109
120
|
}
|
|
110
121
|
});
|
|
111
122
|
}
|
package/src/outbound.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
|
2
2
|
import { resolvePondAccount } from "./accounts.js";
|
|
3
|
-
import { getPondAccountState } from "./runtime.js";
|
|
3
|
+
import { getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
4
4
|
import { PondClient } from "@pnds/sdk";
|
|
5
5
|
import type { SendMessageRequest } from "@pnds/sdk";
|
|
6
6
|
|
|
@@ -22,10 +22,21 @@ export const pondOutbound: ChannelOutboundAdapter = {
|
|
|
22
22
|
const orgId = state?.orgId ?? account.config.org_id;
|
|
23
23
|
const chatId = to;
|
|
24
24
|
|
|
25
|
+
// Match active run by chatId (outbound path has no sessionKey, but has chatId via `to`)
|
|
26
|
+
let agentRunId: string | undefined;
|
|
27
|
+
if (state) {
|
|
28
|
+
for (const [sessionKey, runPromise] of state.activeRuns.entries()) {
|
|
29
|
+
if (getSessionChatId(sessionKey) === chatId) {
|
|
30
|
+
agentRunId = await runPromise;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
const req: SendMessageRequest = {
|
|
26
37
|
message_type: "text",
|
|
27
38
|
content: { text },
|
|
28
|
-
agent_run_id:
|
|
39
|
+
agent_run_id: agentRunId,
|
|
29
40
|
};
|
|
30
41
|
const msg = await client.sendMessage(orgId, chatId, req);
|
|
31
42
|
return { channel: "pond", messageId: msg.id, channelId: chatId };
|
package/src/runtime.ts
CHANGED
|
@@ -19,9 +19,18 @@ export type PondAccountState = {
|
|
|
19
19
|
client: PondClient;
|
|
20
20
|
orgId: string;
|
|
21
21
|
agentUserId: string;
|
|
22
|
-
|
|
22
|
+
activeRuns: Map<string, Promise<string | undefined>>; // sessionKey (lowercased) → runId promise
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/** Resolve run ID for a session, awaiting if the run is still being created. */
|
|
26
|
+
export async function getActiveRunId(
|
|
27
|
+
state: PondAccountState,
|
|
28
|
+
sessionKey: string,
|
|
29
|
+
): Promise<string | undefined> {
|
|
30
|
+
const promise = state.activeRuns.get(sessionKey.toLowerCase());
|
|
31
|
+
return promise ? await promise : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
const accountStates = new Map<string, PondAccountState>();
|
|
26
35
|
|
|
27
36
|
export function setPondAccountState(accountId: string, state: PondAccountState) {
|