@pnds/pond 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "0.2.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": "^0.1.1"
17
+ "@pnds/sdk": "1.0.1"
18
18
  },
19
19
  "devDependencies": {
20
20
  "typescript": "^5.7.0"
package/src/gateway.ts CHANGED
@@ -49,46 +49,87 @@ 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;
61
+ let runIdPromise: Promise<string | undefined> | undefined;
62
+ let reasoningBuffer = "";
59
63
  try {
60
64
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
61
65
  ctx: inboundCtx,
62
66
  cfg,
63
67
  dispatcherOptions: {
64
- deliver: async (payload: { text?: string }) => {
68
+ deliver: async (payload: { text?: string }, info: { kind: string }) => {
65
69
  const replyText = payload.text?.trim();
66
70
  if (!replyText) return;
67
- await client.sendMessage(config.org_id, chatId, {
68
- message_type: "text",
69
- content: { text: replyText },
70
- });
71
+ // Wait for run creation to complete before sending
72
+ const runId = runIdPromise ? await runIdPromise : undefined;
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)
71
94
  },
72
95
  onReplyStart: () => {
73
96
  if (thinkingSent) return;
74
97
  thinkingSent = true;
75
- (async () => {
98
+ runIdPromise = (async () => {
76
99
  try {
77
100
  const run = await client.createAgentRun(config.org_id, agentUserId, {
78
101
  trigger_type: "mention",
79
102
  trigger_ref: { message_id: messageId },
80
103
  chat_id: chatId,
81
104
  });
82
- const state = getPondAccountState(accountId);
83
- if (state) state.activeRunId = run.id;
84
- await client.createAgentStep(config.org_id, agentUserId, run.id, {
85
- step_type: "thinking",
86
- content: { text: "" },
87
- });
105
+ return run.id;
88
106
  } catch (err) {
89
107
  log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
108
+ return undefined;
90
109
  }
91
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 = "";
92
133
  },
93
134
  },
94
135
  });
@@ -155,6 +196,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
155
196
  let sessionId = "";
156
197
  // Heartbeat interval handle — created inside hello handler with server-provided interval
157
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();
158
203
 
159
204
  // Log connection state changes (covers reconnection)
160
205
  ws.onStateChange((state) => {
@@ -174,11 +219,20 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
174
219
  client,
175
220
  orgId: config.org_id,
176
221
  agentUserId,
222
+ activeRuns: new Map(),
177
223
  });
178
224
 
179
225
  // (Re)start heartbeat with server-provided interval
180
226
  if (heartbeatTimer) clearInterval(heartbeatTimer);
227
+ lastHeartbeatAt = Date.now();
181
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;
182
236
  if (ws.state !== "connected") return;
183
237
  ws.sendAgentHeartbeat(sessionId, {
184
238
  hostname: os.hostname(),
@@ -195,8 +249,9 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
195
249
 
196
250
  // Track chat type changes (new chats, renames, etc.)
197
251
  ws.on("chat.update", (data: ChatUpdateData) => {
198
- if (data.changes && typeof data.changes.type === "string") {
199
- chatTypeMap.set(data.chat_id, data.changes.type as Chat["type"]);
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"]);
200
255
  }
201
256
  });
202
257
 
@@ -288,18 +343,45 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
288
343
  OriginatingTo: `pond:${chatId}`,
289
344
  });
290
345
 
291
- await dispatchToAgent({
292
- core,
293
- cfg: ctx.cfg,
294
- client,
295
- config,
296
- agentUserId,
297
- accountId: ctx.accountId,
298
- chatId,
299
- messageId: data.id,
300
- inboundCtx,
301
- log,
302
- });
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
+ }
303
385
  });
304
386
 
305
387
  // Re-apply platform config when server pushes an update notification
@@ -318,6 +400,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
318
400
  // Clean up on abort
319
401
  ctx.abortSignal.addEventListener("abort", () => {
320
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();
321
408
  ws.disconnect();
322
409
  removePondAccountState(ctx.accountId);
323
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
- * Matches by accountId extracted from the session key.
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
- return { ...state, chatId };
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
- api.on("before_tool_call", async (event, ctx) => {
52
- const resolved = resolveClientForHook(ctx.sessionKey);
53
- if (!resolved || !resolved.activeRunId) return;
54
- try {
55
- await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
56
- step_type: "tool_call",
57
- content: {
58
- tool_name: event.toolName,
59
- tool_input: event.params ?? {},
60
- status: "running",
61
- started_at: new Date().toISOString(),
62
- },
63
- });
64
- } catch (err) {
65
- log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
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
- tool_call_id: event.toolCallId ?? "",
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,9 +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 },
39
+ agent_run_id: agentRunId,
28
40
  };
29
41
  const msg = await client.sendMessage(orgId, chatId, req);
30
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
- activeRunId?: string; // current AgentRun ID, set by gateway on reply start
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) {