@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "1.0.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.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
- await client.sendMessage(config.org_id, chatId, {
71
- message_type: "text",
72
- content: { text: replyText },
73
- agent_run_id: runId,
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
- if (data.changes && typeof data.changes.type === "string") {
205
- 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"]);
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
- await dispatchToAgent({
298
- core,
299
- cfg: ctx.cfg,
300
- client,
301
- config,
302
- agentUserId,
303
- accountId: ctx.accountId,
304
- chatId,
305
- messageId: data.id,
306
- inboundCtx,
307
- log,
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
- * 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,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: state?.activeRunId,
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
- 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) {