@pnds/pond 1.0.0 → 1.1.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.0.0",
3
+ "version": "1.1.0",
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.1.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "typescript": "^5.7.0"
package/src/gateway.ts CHANGED
@@ -38,6 +38,60 @@ async function fetchAllChats(
38
38
  return total;
39
39
  }
40
40
 
41
+ /**
42
+ * Fetch recent messages from a chat (or thread) and format them as a history context block.
43
+ * Pass threadRootId to scope to a thread; omit (or null) to fetch top-level messages only.
44
+ * Returns empty string on failure or timeout (non-fatal).
45
+ */
46
+ async function buildChatHistoryContext(
47
+ client: PondClient,
48
+ orgId: string,
49
+ chatId: string,
50
+ beforeMessageId: string,
51
+ agentUserId: string,
52
+ limit: number = 30,
53
+ log?: { warn: (msg: string) => void },
54
+ threadRootId?: string | null,
55
+ ): Promise<string> {
56
+ try {
57
+ const threadParams = threadRootId
58
+ ? { thread_root_id: threadRootId }
59
+ : { top_level: true as const };
60
+ const res = await client.getMessages(orgId, chatId, { before: beforeMessageId, limit, ...threadParams });
61
+ if (!res.data.length) return "";
62
+
63
+ const escapeHistoryValue = (value: string): string =>
64
+ value
65
+ .replace(/\r?\n/g, "\\n")
66
+ .replace(/\[Recent chat history\]|\[End of chat history\]/g, (m) => `\\${m}`);
67
+
68
+ const lines: string[] = [];
69
+ for (const msg of res.data) {
70
+ if (msg.message_type !== "text" && msg.message_type !== "file") continue;
71
+ const senderLabel = escapeHistoryValue(msg.sender?.display_name ?? msg.sender_id);
72
+ const role = msg.sender_id === agentUserId ? "You" : senderLabel;
73
+
74
+ if (msg.message_type === "text") {
75
+ const content = msg.content as TextContent;
76
+ const text = escapeHistoryValue(content.text?.trim() ?? "");
77
+ if (text) lines.push(`${role}: ${text}`);
78
+ } else {
79
+ const content = msg.content as MediaContent;
80
+ const caption = escapeHistoryValue(
81
+ content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`,
82
+ );
83
+ lines.push(`${role}: ${caption}`);
84
+ }
85
+ }
86
+
87
+ if (!lines.length) return "";
88
+ return `[Recent chat history]\n${lines.join("\n")}\n[End of chat history]\n\n`;
89
+ } catch (err) {
90
+ log?.warn(`pond: failed to fetch chat history for context: ${String(err)}`);
91
+ return "";
92
+ }
93
+ }
94
+
41
95
  /**
42
96
  * Dispatch an inbound message to the OpenClaw agent and handle the reply.
43
97
  * Shared by both text and file message handlers to avoid duplication.
@@ -49,29 +103,65 @@ async function dispatchToAgent(opts: {
49
103
  config: PondChannelConfig;
50
104
  agentUserId: string;
51
105
  accountId: string;
106
+ sessionKey: string;
52
107
  chatId: string;
53
108
  messageId: string;
54
109
  inboundCtx: Record<string, unknown>;
110
+ noReply?: boolean;
55
111
  log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
56
112
  }) {
57
- const { core, cfg, client, config, agentUserId, accountId, chatId, messageId, inboundCtx, log } = opts;
113
+ const { core, cfg, client, config, agentUserId, accountId, sessionKey, chatId, messageId, inboundCtx, noReply, log } = opts;
114
+ const sessionKeyLower = sessionKey.toLowerCase();
58
115
  let thinkingSent = false;
59
116
  let runIdPromise: Promise<string | undefined> | undefined;
117
+ let reasoningBuffer = "";
60
118
  try {
61
119
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
62
120
  ctx: inboundCtx,
63
121
  cfg,
64
122
  dispatcherOptions: {
65
- deliver: async (payload: { text?: string }) => {
123
+ deliver: async (payload: { text?: string }, info: { kind: string }) => {
66
124
  const replyText = payload.text?.trim();
67
125
  if (!replyText) return;
68
126
  // Wait for run creation to complete before sending
69
127
  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
- });
128
+
129
+ if (info.kind === "final") {
130
+ if (noReply) {
131
+ // no_reply: unconditionally suppress chat output; runId only affects step recording
132
+ if (runId) {
133
+ try {
134
+ await client.createAgentStep(config.org_id, agentUserId, runId, {
135
+ step_type: "text",
136
+ content: { text: replyText, suppressed: true },
137
+ });
138
+ } catch (err) {
139
+ log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
140
+ }
141
+ } else {
142
+ log?.warn(`pond[${accountId}]: suppressing final reply but no runId available`);
143
+ }
144
+ return;
145
+ }
146
+ // Normal: final reply → chat message
147
+ await client.sendMessage(config.org_id, chatId, {
148
+ message_type: "text",
149
+ content: { text: replyText },
150
+ agent_run_id: runId,
151
+ });
152
+ } else if (info.kind === "block" && runId) {
153
+ // Intermediate text → step with chat_projection hint (server decides)
154
+ try {
155
+ await client.createAgentStep(config.org_id, agentUserId, runId, {
156
+ step_type: "text",
157
+ content: { text: replyText },
158
+ chat_projection: true,
159
+ });
160
+ } catch (err) {
161
+ log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
162
+ }
163
+ }
164
+ // kind === "tool" → ignore (handled by hooks)
75
165
  },
76
166
  onReplyStart: () => {
77
167
  if (thinkingSent) return;
@@ -83,18 +173,34 @@ async function dispatchToAgent(opts: {
83
173
  trigger_ref: { message_id: messageId },
84
174
  chat_id: chatId,
85
175
  });
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
176
  return run.id;
93
177
  } catch (err) {
94
178
  log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
95
179
  return undefined;
96
180
  }
97
181
  })();
182
+ // Store promise immediately so hooks can await it before the run is created
183
+ const state = getPondAccountState(accountId);
184
+ if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
185
+ },
186
+ },
187
+ replyOptions: {
188
+ onReasoningStream: (payload: { text?: string }) => {
189
+ reasoningBuffer += payload.text ?? "";
190
+ },
191
+ onReasoningEnd: async () => {
192
+ const runId = runIdPromise ? await runIdPromise : undefined;
193
+ if (runId && reasoningBuffer) {
194
+ try {
195
+ await client.createAgentStep(config.org_id, agentUserId, runId, {
196
+ step_type: "thinking",
197
+ content: { text: reasoningBuffer },
198
+ });
199
+ } catch (err) {
200
+ log?.warn(`pond[${accountId}]: failed to create thinking step: ${String(err)}`);
201
+ }
202
+ }
203
+ reasoningBuffer = "";
98
204
  },
99
205
  },
100
206
  });
@@ -161,6 +267,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
161
267
  let sessionId = "";
162
268
  // Heartbeat interval handle — created inside hello handler with server-provided interval
163
269
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
270
+ // Track active dispatches per chat for typing indicator management
271
+ const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
272
+ // Heartbeat watchdog: detect event loop blocking during long CC runs
273
+ let lastHeartbeatAt = Date.now();
164
274
 
165
275
  // Log connection state changes (covers reconnection)
166
276
  ws.onStateChange((state) => {
@@ -180,11 +290,20 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
180
290
  client,
181
291
  orgId: config.org_id,
182
292
  agentUserId,
293
+ activeRuns: new Map(),
183
294
  });
184
295
 
185
296
  // (Re)start heartbeat with server-provided interval
186
297
  if (heartbeatTimer) clearInterval(heartbeatTimer);
298
+ lastHeartbeatAt = Date.now();
187
299
  heartbeatTimer = setInterval(() => {
300
+ const now = Date.now();
301
+ const expectedMs = intervalSec * 1000;
302
+ const drift = now - lastHeartbeatAt - expectedMs;
303
+ if (drift > 15000) {
304
+ log?.warn(`pond[${ctx.accountId}]: heartbeat drift ${drift}ms — event loop may be blocked`);
305
+ }
306
+ lastHeartbeatAt = now;
188
307
  if (ws.state !== "connected") return;
189
308
  ws.sendAgentHeartbeat(sessionId, {
190
309
  hostname: os.hostname(),
@@ -201,8 +320,9 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
201
320
 
202
321
  // Track chat type changes (new chats, renames, etc.)
203
322
  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"]);
323
+ const changes = data.changes as Record<string, unknown> | undefined;
324
+ if (changes && typeof changes.type === "string") {
325
+ chatTypeMap.set(data.chat_id, changes.type as Chat["type"]);
206
326
  }
207
327
  });
208
328
 
@@ -271,10 +391,32 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
271
391
  const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
272
392
  setSessionChatId(sessionKey, chatId);
273
393
 
394
+ // Start typing indicator immediately before any async work so the user
395
+ // sees feedback right away. Reference-counted for concurrent dispatches.
396
+ const existingDispatchEarly = activeDispatches.get(chatId);
397
+ if (existingDispatchEarly) {
398
+ existingDispatchEarly.count++;
399
+ } else {
400
+ if (ws.state === "connected") ws.sendTyping(chatId, "start");
401
+ // Frontend auto-clears typing after 3s, so refresh at 2s to avoid flicker
402
+ const typingRefresh = setInterval(() => {
403
+ if (ws.state === "connected") ws.sendTyping(chatId, "start");
404
+ }, 2000);
405
+ activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
406
+ }
407
+
408
+ // Fetch history with a 2s timeout — degrade gracefully to empty on slow API
409
+ const historyTimeout = new Promise<string>((resolve) => setTimeout(() => resolve(""), 2000));
410
+ const historyFetch = buildChatHistoryContext(
411
+ client, config.org_id, chatId, data.id, agentUserId, 30, log,
412
+ data.thread_root_id,
413
+ );
414
+ const historyPrefix = await Promise.race([historyFetch, historyTimeout]);
415
+
274
416
  // Build inbound context for OpenClaw agent
275
417
  const inboundCtx = core.channel.reply.finalizeInboundContext({
276
418
  Body: body,
277
- BodyForAgent: body,
419
+ BodyForAgent: historyPrefix ? `${historyPrefix}${body}` : body,
278
420
  RawBody: body,
279
421
  CommandBody: body,
280
422
  ...mediaFields,
@@ -294,18 +436,35 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
294
436
  OriginatingTo: `pond:${chatId}`,
295
437
  });
296
438
 
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
- });
439
+ // Extract no_reply hint from message
440
+ const noReply = data.hints?.no_reply ?? false;
441
+
442
+ try {
443
+ await dispatchToAgent({
444
+ core,
445
+ cfg: ctx.cfg,
446
+ client,
447
+ config,
448
+ agentUserId,
449
+ accountId: ctx.accountId,
450
+ sessionKey,
451
+ chatId,
452
+ messageId: data.id,
453
+ inboundCtx,
454
+ noReply,
455
+ log,
456
+ });
457
+ } finally {
458
+ const dispatch = activeDispatches.get(chatId);
459
+ if (dispatch) {
460
+ dispatch.count--;
461
+ if (dispatch.count <= 0) {
462
+ clearInterval(dispatch.typingTimer);
463
+ activeDispatches.delete(chatId);
464
+ if (ws.state === "connected") ws.sendTyping(chatId, "stop");
465
+ }
466
+ }
467
+ }
309
468
  });
310
469
 
311
470
  // Re-apply platform config when server pushes an update notification
@@ -324,6 +483,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
324
483
  // Clean up on abort
325
484
  ctx.abortSignal.addEventListener("abort", () => {
326
485
  if (heartbeatTimer) clearInterval(heartbeatTimer);
486
+ // Clean up all active typing timers to prevent leaks
487
+ for (const [, dispatch] of activeDispatches) {
488
+ clearInterval(dispatch.typingTimer);
489
+ }
490
+ activeDispatches.clear();
327
491
  ws.disconnect();
328
492
  removePondAccountState(ctx.accountId);
329
493
  // 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
@@ -26,9 +30,13 @@ You have access to the Pond platform via the \`@pnds/cli\` CLI. Auth is pre-conf
26
30
 
27
31
  \`\`\`
28
32
  npx @pnds/cli@latest whoami # Check your identity
33
+ npx @pnds/cli@latest agents list # List all agents (name, model, status)
34
+ npx @pnds/cli@latest dm <name-or-id> --text "..." # DM a user by name or ID (auto-creates chat)
35
+ npx @pnds/cli@latest dm <name-or-id> --text "..." --no-reply # DM, signal no reply expected
29
36
  npx @pnds/cli@latest chats list # List chats
30
37
  npx @pnds/cli@latest messages list <chatId> # Read chat history
31
38
  npx @pnds/cli@latest messages send <chatId> --text "..." # Send a message
39
+ npx @pnds/cli@latest messages send <chatId> --text "..." --no-reply # Send, no reply expected
32
40
  npx @pnds/cli@latest tasks list [--status ...] # List tasks
33
41
  npx @pnds/cli@latest tasks create --title "..." # Create a task
34
42
  npx @pnds/cli@latest tasks update <taskId> --status in_progress
@@ -37,7 +45,14 @@ npx @pnds/cli@latest users search <query> # Find users
37
45
  npx @pnds/cli@latest members list # List org members
38
46
  \`\`\`
39
47
 
40
- Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.`;
48
+ Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.
49
+
50
+ ## Agent-to-Agent Interaction
51
+
52
+ When you receive a message from another agent (not a human):
53
+ - If the message has a no_reply hint, you may still reason and use tools, but your response will not be sent to chat.
54
+ - If you have nothing meaningful to add to the conversation, produce an empty response to avoid unnecessary back-and-forth.
55
+ - Use --no-reply when sending messages that don't require a response (e.g., delivering results, FYI notifications).`;
41
56
 
42
57
  export function registerPondHooks(api: OpenClawPluginApi) {
43
58
  const log = api.logger;
@@ -48,33 +63,38 @@ export function registerPondHooks(api: OpenClawPluginApi) {
48
63
  });
49
64
 
50
65
  // 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
- }
66
+ // Fire-and-forget: before_tool_call is an awaited hook in OpenClaw (can block/modify
67
+ // tool params), so we must not block tool execution waiting for run creation.
68
+ api.on("before_tool_call", (event, ctx) => {
69
+ void (async () => {
70
+ const resolved = await resolveClientForHook(ctx.sessionKey);
71
+ if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
72
+ try {
73
+ await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
74
+ step_type: "tool_call",
75
+ content: {
76
+ call_id: event.toolCallId,
77
+ tool_name: event.toolName,
78
+ tool_input: event.params ?? {},
79
+ status: "running",
80
+ started_at: new Date().toISOString(),
81
+ },
82
+ });
83
+ } catch (err) {
84
+ log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
85
+ }
86
+ })();
67
87
  });
68
88
 
69
89
  // after_tool_call -> send tool_result step to AgentRun
70
90
  api.on("after_tool_call", async (event, ctx) => {
71
- const resolved = resolveClientForHook(ctx.sessionKey);
72
- if (!resolved || !resolved.activeRunId) return;
91
+ const resolved = await resolveClientForHook(ctx.sessionKey);
92
+ if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
73
93
  try {
74
94
  await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
75
95
  step_type: "tool_result",
76
96
  content: {
77
- tool_call_id: event.toolCallId ?? "",
97
+ call_id: event.toolCallId,
78
98
  tool_name: event.toolName,
79
99
  status: event.error ? "error" : "success",
80
100
  output: event.error ?? (typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? "")),
@@ -89,23 +109,25 @@ export function registerPondHooks(api: OpenClawPluginApi) {
89
109
 
90
110
  // agent_end -> complete the AgentRun
91
111
  api.on("agent_end", async (_event, ctx) => {
92
- const resolved = resolveClientForHook(ctx.sessionKey);
112
+ const resolved = await resolveClientForHook(ctx.sessionKey);
93
113
  if (!resolved) {
94
114
  log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
95
115
  return;
96
116
  }
97
117
  if (!resolved.activeRunId) return;
118
+ const accountId = extractAccountIdFromSessionKey(ctx.sessionKey ?? "");
119
+ const state = accountId ? getPondAccountState(accountId) : undefined;
98
120
  try {
99
121
  await resolved.client.updateAgentRun(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
100
122
  status: "completed",
101
123
  });
102
- // Clear the active run
103
- const state = getPondAccountState(
104
- extractAccountIdFromSessionKey(ctx.sessionKey ?? "") ?? "",
105
- );
106
- if (state) state.activeRunId = undefined;
107
124
  } catch (err) {
108
125
  log?.warn(`pond hook agent_end failed: ${String(err)}`);
126
+ } finally {
127
+ // Always clear local state — even if the server call failed, the run is over locally
128
+ if (state && ctx.sessionKey) {
129
+ state.activeRuns.delete(ctx.sessionKey.toLowerCase());
130
+ }
109
131
  }
110
132
  });
111
133
  }
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) {