@pnds/pond 1.1.0 → 1.2.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/src/gateway.ts CHANGED
@@ -1,14 +1,31 @@
1
- import type { ChannelGatewayAdapter, PluginRuntime } from "openclaw/plugin-sdk";
2
- import { PondClient, PondWs, MENTION_ALL_USER_ID, WS_EVENTS } from "@pnds/sdk";
3
- import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData } from "@pnds/sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
3
+
4
+ /** Extract ChannelGatewayAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
5
+ type ChannelGatewayAdapter<T = unknown> = NonNullable<ChannelPlugin<T>["gateway"]>;
6
+ import { PondClient, PondWs, MENTION_ALL_USER_ID } from "@pnds/sdk";
7
+ import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData, TaskAssignedData, Task } from "@pnds/sdk";
4
8
  import type { PondChannelConfig } from "./types.js";
5
9
  import * as os from "node:os";
6
10
  import * as path from "node:path";
7
11
  import { resolvePondAccount } from "./accounts.js";
8
- import { getPondRuntime, setPondAccountState, getPondAccountState, removePondAccountState, setSessionChatId } from "./runtime.js";
9
- import { buildSessionKey } from "./session.js";
12
+ import {
13
+ getPondRuntime,
14
+ setPondAccountState,
15
+ getPondAccountState,
16
+ removePondAccountState,
17
+ setSessionChatId,
18
+ setSessionMessageId,
19
+ setDispatchMessageId,
20
+ setDispatchNoReply,
21
+ } from "./runtime.js";
22
+ import type { PondEvent, DispatchState, ForkResult } from "./runtime.js";
23
+ import { buildOrchestratorSessionKey } from "./session.js";
10
24
  import type { ResolvedPondAccount } from "./types.js";
11
25
  import { fetchAndApplyPlatformConfig } from "./config-manager.js";
26
+ import { startWikiHelper } from "./wiki-helper.js";
27
+ import { routeTrigger, defaultRoutingStrategy } from "./routing.js";
28
+ import { forkOrchestratorSession, cleanupForkSession, resolveTranscriptFile } from "./fork.js";
12
29
 
13
30
  function resolveWsUrl(account: ResolvedPondAccount): string {
14
31
  if (account.config.ws_url) return account.config.ws_url;
@@ -17,20 +34,22 @@ function resolveWsUrl(account: ResolvedPondAccount): string {
17
34
  return `${wsBase}/ws`;
18
35
  }
19
36
 
37
+ type ChatInfo = { type: Chat["type"]; name: string | null };
38
+
20
39
  /**
21
- * Fetch all chats with pagination and populate the type map.
40
+ * Fetch all chats with pagination and populate the info map (type + name).
22
41
  */
23
42
  async function fetchAllChats(
24
43
  client: PondClient,
25
44
  orgId: string,
26
- chatTypeMap: Map<string, Chat["type"]>,
45
+ chatInfoMap: Map<string, ChatInfo>,
27
46
  ): Promise<number> {
28
47
  let cursor: string | undefined;
29
48
  let total = 0;
30
49
  do {
31
50
  const res = await client.getChats(orgId, { limit: 100, cursor });
32
51
  for (const c of res.data) {
33
- chatTypeMap.set(c.id, c.type);
52
+ chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null });
34
53
  }
35
54
  total += res.data.length;
36
55
  cursor = res.has_more ? res.next_cursor : undefined;
@@ -38,64 +57,54 @@ async function fetchAllChats(
38
57
  return total;
39
58
  }
40
59
 
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
- }
60
+ // ---------------------------------------------------------------------------
61
+ // Event body formatting
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function buildEventBody(event: PondEvent): string {
65
+ const lines: string[] = [];
66
+ if (event.type === "message") {
67
+ lines.push(`[Event: message.new]`);
68
+ const chatLabel = event.targetName
69
+ ? `"${event.targetName}" (${event.targetId})`
70
+ : event.targetId;
71
+ lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? "unknown"}]`);
72
+ lines.push(`[From: ${event.senderName} (${event.senderId})]`);
73
+ lines.push(`[Message ID: ${event.messageId}]`);
74
+ if (event.threadRootId) lines.push(`[Thread: ${event.threadRootId}]`);
75
+ if (event.noReply) lines.push(`[Hint: no_reply]`);
76
+ if (event.mediaFields?.MediaUrl) {
77
+ lines.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
85
78
  }
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 "";
79
+ lines.push("", event.body);
80
+ } else {
81
+ lines.push(`[Event: task.assigned]`);
82
+ const taskLabel = event.targetName
83
+ ? `${event.targetName} (${event.targetId})`
84
+ : event.targetId;
85
+ lines.push(`[Task: ${taskLabel} | type: ${event.targetType ?? "task"}]`);
86
+ lines.push(`[Assigned by: ${event.senderName} (${event.senderId})]`);
87
+ lines.push("", event.body);
92
88
  }
89
+ return lines.join("\n");
93
90
  }
94
91
 
95
- /**
96
- * Dispatch an inbound message to the OpenClaw agent and handle the reply.
97
- * Shared by both text and file message handlers to avoid duplication.
98
- */
92
+ function buildForkResultPrefix(results: ForkResult[]): string {
93
+ if (!results.length) return "";
94
+ const blocks = results.map((r) => {
95
+ const lines = [`[Fork result]`];
96
+ lines.push(`[Handled: ${r.sourceEvent.type} — ${r.sourceEvent.summary}]`);
97
+ if (r.actions.length) lines.push(`[Actions: ${r.actions.join("; ")}]`);
98
+ if (r.agentRunId) lines.push(`[Agent run: ${r.agentRunId}]`);
99
+ return lines.join("\n");
100
+ });
101
+ return blocks.join("\n\n") + "\n\n---\n\n";
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Dispatch to OpenClaw agent (orchestrator mode — all text suppressed)
106
+ // ---------------------------------------------------------------------------
107
+
99
108
  async function dispatchToAgent(opts: {
100
109
  core: PluginRuntime;
101
110
  cfg: Record<string, unknown>;
@@ -104,13 +113,19 @@ async function dispatchToAgent(opts: {
104
113
  agentUserId: string;
105
114
  accountId: string;
106
115
  sessionKey: string;
107
- chatId: string;
108
116
  messageId: string;
109
117
  inboundCtx: Record<string, unknown>;
110
- noReply?: boolean;
118
+ triggerType?: string;
119
+ triggerRef?: Record<string, unknown>;
120
+ chatId?: string;
121
+ /** For task dispatches: bind the run to a task target for Redis presence + fallback comments. */
122
+ defaultTargetType?: string;
123
+ defaultTargetId?: string;
111
124
  log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
112
125
  }) {
113
- const { core, cfg, client, config, agentUserId, accountId, sessionKey, chatId, messageId, inboundCtx, noReply, log } = opts;
126
+ const { core, cfg, client, config, agentUserId, accountId, sessionKey, messageId, inboundCtx, log } = opts;
127
+ const triggerType = opts.triggerType ?? "mention";
128
+ const triggerRef = opts.triggerRef ?? { message_id: messageId };
114
129
  const sessionKeyLower = sessionKey.toLowerCase();
115
130
  let thinkingSent = false;
116
131
  let runIdPromise: Promise<string | undefined> | undefined;
@@ -121,47 +136,22 @@ async function dispatchToAgent(opts: {
121
136
  cfg,
122
137
  dispatcherOptions: {
123
138
  deliver: async (payload: { text?: string }, info: { kind: string }) => {
139
+ // Orchestrator mode: ALL text output is suppressed — agent uses tools to interact.
140
+ // Record as internal step for observability.
124
141
  const replyText = payload.text?.trim();
125
142
  if (!replyText) return;
126
- // Wait for run creation to complete before sending
127
143
  const runId = runIdPromise ? await runIdPromise : undefined;
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)
144
+ if (runId) {
154
145
  try {
155
146
  await client.createAgentStep(config.org_id, agentUserId, runId, {
156
147
  step_type: "text",
157
- content: { text: replyText },
158
- chat_projection: true,
148
+ content: { text: replyText, suppressed: true },
149
+ ...(info.kind === "block" ? { chat_projection: false } : {}),
159
150
  });
160
151
  } catch (err) {
161
- log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
152
+ log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
162
153
  }
163
154
  }
164
- // kind === "tool" → ignore (handled by hooks)
165
155
  },
166
156
  onReplyStart: () => {
167
157
  if (thinkingSent) return;
@@ -169,9 +159,10 @@ async function dispatchToAgent(opts: {
169
159
  runIdPromise = (async () => {
170
160
  try {
171
161
  const run = await client.createAgentRun(config.org_id, agentUserId, {
172
- trigger_type: "mention",
173
- trigger_ref: { message_id: messageId },
174
- chat_id: chatId,
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 } : {}),
175
166
  });
176
167
  return run.id;
177
168
  } catch (err) {
@@ -179,7 +170,6 @@ async function dispatchToAgent(opts: {
179
170
  return undefined;
180
171
  }
181
172
  })();
182
- // Store promise immediately so hooks can await it before the run is created
183
173
  const state = getPondAccountState(accountId);
184
174
  if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
185
175
  },
@@ -204,11 +194,17 @@ async function dispatchToAgent(opts: {
204
194
  },
205
195
  },
206
196
  });
197
+ return true;
207
198
  } catch (err) {
208
199
  log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
200
+ return false;
209
201
  }
210
202
  }
211
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // Gateway
206
+ // ---------------------------------------------------------------------------
207
+
212
208
  export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
213
209
  startAccount: async (ctx) => {
214
210
  const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
@@ -229,16 +225,21 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
229
225
  const agentUserId = me.id;
230
226
  log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
231
227
 
232
- // Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
233
- process.env.POND_API_URL = config.pond_url;
234
- process.env.POND_API_KEY = config.api_key;
235
- process.env.POND_ORG_ID = config.org_id;
236
-
237
228
  // Apply platform config (models, defaults)
238
- // Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
239
229
  const stateDir = process.env.OPENCLAW_STATE_DIR
240
230
  || path.join(process.env.HOME || "/data", ".openclaw");
241
231
  const openclawConfigPath = path.join(stateDir, "openclaw.json");
232
+ const previousEnv = {
233
+ POND_API_URL: process.env.POND_API_URL,
234
+ POND_API_KEY: process.env.POND_API_KEY,
235
+ POND_ORG_ID: process.env.POND_ORG_ID,
236
+ POND_AGENT_ID: process.env.POND_AGENT_ID,
237
+ };
238
+ process.env.POND_API_URL = config.pond_url;
239
+ process.env.POND_API_KEY = config.api_key;
240
+ process.env.POND_ORG_ID = config.org_id;
241
+ process.env.POND_AGENT_ID = agentUserId;
242
+
242
243
  const configManagerOpts = {
243
244
  client,
244
245
  stateDir,
@@ -253,6 +254,24 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
253
254
  log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
254
255
  }
255
256
 
257
+ let stopWikiHelper: (() => void) | null = null;
258
+ let wikiMountRoot: string | undefined;
259
+ try {
260
+ const wikiHelper = await startWikiHelper({
261
+ accountId: ctx.accountId,
262
+ pondUrl: config.pond_url,
263
+ apiKey: config.api_key,
264
+ orgId: config.org_id,
265
+ agentId: agentUserId,
266
+ stateDir,
267
+ log,
268
+ });
269
+ wikiMountRoot = wikiHelper.mountRoot;
270
+ stopWikiHelper = wikiHelper.stop;
271
+ } catch (err) {
272
+ log?.warn(`pond[${ctx.accountId}]: wiki helper startup failed: ${String(err)}`);
273
+ }
274
+
256
275
  // Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
257
276
  const wsUrl = resolveWsUrl(account);
258
277
  const ws = new PondWs({
@@ -260,40 +279,390 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
260
279
  wsUrl,
261
280
  });
262
281
 
263
- // Chat type lookuppopulated on hello, used by message handler
264
- const chatTypeMap = new Map<string, Chat["type"]>();
282
+ // Orchestrator session keysingle session for all Pond events
283
+ const orchestratorKey = buildOrchestratorSessionKey(ctx.accountId);
284
+
285
+ // Chat info lookup — populated on hello, used for structured event bodies
286
+ const chatInfoMap = new Map<string, ChatInfo>();
265
287
 
266
288
  // Session ID from hello — used for heartbeat
267
289
  let sessionId = "";
268
- // Heartbeat interval handle — created inside hello handler with server-provided interval
269
290
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
270
- // Track active dispatches per chat for typing indicator management
291
+ // Typing indicator management — reference-counted per chat
271
292
  const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
272
- // Heartbeat watchdog: detect event loop blocking during long CC runs
293
+ // Dedupe task dispatches
294
+ const dispatchedTasks = new Set<string>();
295
+ // Dedupe message dispatches — shared by live handler and catch-up.
296
+ // Bounded: evict oldest entries when cap is reached.
297
+ const dispatchedMessages = new Set<string>();
298
+ const DISPATCHED_MESSAGES_CAP = 5000;
299
+ // Tracks whether the agent has had at least one successful hello in this process.
300
+ let hadSuccessfulHello = false;
301
+ const COLD_START_WINDOW_MS = 5 * 60_000;
302
+ // Heartbeat watchdog
273
303
  let lastHeartbeatAt = Date.now();
274
304
 
275
- // Log connection state changes (covers reconnection)
305
+ /** Atomically claim a message ID for processing. Returns false if already claimed. */
306
+ function tryClaimMessage(id: string): boolean {
307
+ if (dispatchedMessages.has(id)) return false;
308
+ if (dispatchedMessages.size >= DISPATCHED_MESSAGES_CAP) {
309
+ let toEvict = Math.floor(DISPATCHED_MESSAGES_CAP / 4);
310
+ for (const old of dispatchedMessages) {
311
+ dispatchedMessages.delete(old);
312
+ if (--toEvict <= 0) break;
313
+ }
314
+ }
315
+ dispatchedMessages.add(id);
316
+ return true;
317
+ }
318
+
319
+ // Sessions dir for fork transcript resolution.
320
+ // Session key "agent:main:pond:..." → agentId is "main" → store at agents/main/sessions/
321
+ const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Dispatch state for fork-on-busy routing
325
+ // ---------------------------------------------------------------------------
326
+ const dispatchState: DispatchState = {
327
+ mainDispatching: false,
328
+ activeForks: new Map(),
329
+ pendingForkResults: [],
330
+ mainBuffer: [],
331
+ };
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Per-fork queue state (internal to gateway, not exposed via DispatchState)
335
+ // ---------------------------------------------------------------------------
336
+ type ForkQueueItem = {
337
+ event: PondEvent;
338
+ resolve: (dispatched: boolean) => void;
339
+ };
340
+
341
+ type ActiveForkState = {
342
+ sessionKey: string;
343
+ sessionFile: string;
344
+ targetId: string;
345
+ queue: ForkQueueItem[];
346
+ processedEvents: PondEvent[];
347
+ };
348
+
349
+ const forkStates = new Map<string, ActiveForkState>();
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Typing indicator helpers
353
+ // ---------------------------------------------------------------------------
354
+ function startTyping(chatId: string) {
355
+ const existing = activeDispatches.get(chatId);
356
+ if (existing) {
357
+ existing.count++;
358
+ } else {
359
+ if (ws.state === "connected") ws.sendTyping(chatId, "start");
360
+ const typingRefresh = setInterval(() => {
361
+ if (ws.state === "connected") ws.sendTyping(chatId, "start");
362
+ }, 2000);
363
+ activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
364
+ }
365
+ }
366
+
367
+ function stopTyping(chatId: string) {
368
+ const dispatch = activeDispatches.get(chatId);
369
+ if (!dispatch) return;
370
+ dispatch.count--;
371
+ if (dispatch.count <= 0) {
372
+ clearInterval(dispatch.typingTimer);
373
+ activeDispatches.delete(chatId);
374
+ if (ws.state === "connected") ws.sendTyping(chatId, "stop");
375
+ }
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Fork drain loop — processes queued events on a forked session
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Process all queued events on a fork session, then clean up.
384
+ * Fire-and-forget: callers await per-event promises, not this function.
385
+ *
386
+ * Invariant: between the while-loop empty check and cleanup, there is no
387
+ * await, so JS single-threaded execution guarantees no new events can be
388
+ * pushed to the queue between the check and the cleanup.
389
+ */
390
+ async function runForkDrainLoop(fork: ActiveForkState) {
391
+ try {
392
+ while (fork.queue.length > 0) {
393
+ const item = fork.queue.shift()!;
394
+ try {
395
+ await runDispatch(item.event, fork.sessionKey, buildEventBody(item.event));
396
+ fork.processedEvents.push(item.event);
397
+ item.resolve(true);
398
+ } catch (err) {
399
+ log?.error(`pond[${ctx.accountId}]: fork dispatch failed for ${item.event.messageId}: ${String(err)}`);
400
+ item.resolve(false);
401
+ break;
402
+ }
403
+ }
404
+ // Resolve remaining items as not-dispatched (fork stopped after error).
405
+ // These events are NOT acked — inbox retains them for catch-up replay.
406
+ for (const remaining of fork.queue.splice(0)) {
407
+ remaining.resolve(false);
408
+ }
409
+ } finally {
410
+ // Collect fork result for injection into main session's next turn
411
+ if (fork.processedEvents.length > 0) {
412
+ const first = fork.processedEvents[0];
413
+ dispatchState.pendingForkResults.push({
414
+ forkSessionKey: fork.sessionKey,
415
+ sourceEvent: {
416
+ type: first.type,
417
+ targetId: fork.targetId,
418
+ summary: fork.processedEvents.length === 1
419
+ ? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
420
+ : `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
421
+ },
422
+ actions: [],
423
+ agentRunId: undefined,
424
+ });
425
+ }
426
+ // Remove from tracking maps and clean up session files
427
+ forkStates.delete(fork.targetId);
428
+ dispatchState.activeForks.delete(fork.targetId);
429
+ cleanupForkSession({ sessionFile: fork.sessionFile, sessionKey: fork.sessionKey, sessionsDir });
430
+ }
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Build OpenClaw inbound context
435
+ // ---------------------------------------------------------------------------
436
+ function buildInboundCtx(event: PondEvent, sessionKey: string, bodyForAgent: string): Record<string, unknown> {
437
+ return core.channel.reply.finalizeInboundContext({
438
+ Body: event.body,
439
+ BodyForAgent: bodyForAgent,
440
+ RawBody: event.body,
441
+ CommandBody: event.body,
442
+ ...(event.mediaFields ?? {}),
443
+ From: `pond:${event.senderId}`,
444
+ To: `pond:orchestrator`,
445
+ SessionKey: sessionKey,
446
+ AccountId: ctx.accountId,
447
+ ChatType: event.targetType ?? "unknown",
448
+ SenderName: event.senderName,
449
+ SenderId: event.senderId,
450
+ Provider: "pond" as const,
451
+ Surface: "pond" as const,
452
+ MessageSid: event.messageId,
453
+ Timestamp: Date.now(),
454
+ CommandAuthorized: true,
455
+ OriginatingChannel: "pond" as const,
456
+ OriginatingTo: `pond:orchestrator`,
457
+ });
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Core dispatch: route event to main or fork session
462
+ // ---------------------------------------------------------------------------
463
+ async function runDispatch(event: PondEvent, sessionKey: string, bodyForAgent: string) {
464
+ // Set provenance maps for wiki tools + no_reply suppression flag
465
+ setSessionChatId(sessionKey, event.targetId);
466
+ setSessionMessageId(sessionKey, event.messageId);
467
+ setDispatchMessageId(sessionKey, event.messageId);
468
+ setDispatchNoReply(sessionKey, event.noReply ?? false);
469
+
470
+ const inboundCtx = buildInboundCtx(event, sessionKey, bodyForAgent);
471
+ const triggerType = event.type === "task" ? "task_assign" : "mention";
472
+ const triggerRef = event.type === "task"
473
+ ? { task_id: event.targetId }
474
+ : { message_id: event.messageId };
475
+ const isTask = event.type === "task";
476
+
477
+ const ok = await dispatchToAgent({
478
+ core,
479
+ cfg: ctx.cfg,
480
+ client,
481
+ config,
482
+ agentUserId,
483
+ accountId: ctx.accountId,
484
+ sessionKey,
485
+ messageId: event.messageId,
486
+ inboundCtx,
487
+ triggerType,
488
+ triggerRef,
489
+ chatId: event.targetId.startsWith("cht_") ? event.targetId : undefined,
490
+ defaultTargetType: isTask ? "task" : undefined,
491
+ defaultTargetId: isTask ? event.targetId : undefined,
492
+ log,
493
+ });
494
+ if (!ok) throw new Error(`dispatch failed for ${event.messageId}`);
495
+ }
496
+
497
+ /**
498
+ * Drain buffered events one-by-one into main session.
499
+ * Each event gets its own dispatch (and thus its own AgentRun) to keep provenance correct.
500
+ *
501
+ * IMPORTANT: Caller must hold mainDispatching=true. This function keeps the flag
502
+ * raised throughout the drain to prevent re-entrance from concurrent event handlers.
503
+ * The flag is only lowered when the buffer is fully exhausted.
504
+ */
505
+ let draining = false;
506
+ async function drainMainBuffer() {
507
+ // Re-entrance guard: if we're already draining (e.g. fork completed during
508
+ // a drain iteration), the outer loop will pick up new items naturally.
509
+ if (draining) return;
510
+ draining = true;
511
+ try {
512
+ while (dispatchState.mainBuffer.length > 0 || dispatchState.pendingForkResults.length > 0) {
513
+ // If we only have fork results but no events, create a synthetic wake-up
514
+ if (dispatchState.mainBuffer.length === 0 && dispatchState.pendingForkResults.length > 0) {
515
+ const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
516
+ const syntheticEvent: PondEvent = {
517
+ type: "message",
518
+ targetId: "_orchestrator",
519
+ targetType: "system",
520
+ senderId: "system",
521
+ senderName: "system",
522
+ messageId: `synthetic-${Date.now()}`,
523
+ body: "[Orchestrator: fork session(s) completed — review results above]",
524
+ };
525
+ const bodyForAgent = forkPrefix + buildEventBody(syntheticEvent);
526
+ dispatchState.mainCurrentTargetId = undefined;
527
+ await runDispatch(syntheticEvent, orchestratorKey, bodyForAgent);
528
+ continue;
529
+ }
530
+
531
+ // Take ONE event at a time for correct provenance
532
+ const event = dispatchState.mainBuffer.shift()!;
533
+ const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
534
+ const bodyForAgent = forkPrefix + buildEventBody(event);
535
+
536
+ dispatchState.mainCurrentTargetId = event.targetId;
537
+ await runDispatch(event, orchestratorKey, bodyForAgent);
538
+ // Ack inbox after successful drain dispatch — these events were buffered
539
+ // without ack, so this is the first time they're confirmed processed.
540
+ const sourceType = event.type === "task" ? "task_activity" : "message";
541
+ client.ackInbox(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
542
+ }
543
+ } finally {
544
+ draining = false;
545
+ dispatchState.mainDispatching = false;
546
+ dispatchState.mainCurrentTargetId = undefined;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Route and dispatch an inbound event.
552
+ * Returns true if the event was dispatched (main or fork), false if only buffered.
553
+ * Callers should only ack inbox when this returns true — buffered events may be
554
+ * lost on crash and need to be replayed from inbox on the next catch-up.
555
+ */
556
+ async function handleInboundEvent(event: PondEvent): Promise<boolean> {
557
+ const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
558
+
559
+ switch (disposition.action) {
560
+ case "main": {
561
+ // Main session is idle — dispatch directly.
562
+ // Set mainDispatching BEFORE the await and keep it raised through
563
+ // drainMainBuffer() so no concurrent event can enter this case.
564
+ // drainMainBuffer() lowers the flag when the buffer is fully exhausted.
565
+ const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
566
+ const bodyForAgent = forkPrefix + buildEventBody(event);
567
+
568
+ dispatchState.mainDispatching = true;
569
+ dispatchState.mainCurrentTargetId = event.targetId;
570
+ try {
571
+ await runDispatch(event, orchestratorKey, bodyForAgent);
572
+ } finally {
573
+ // Drain any events that buffered while we were dispatching.
574
+ // mainDispatching stays true — drainMainBuffer owns releasing it.
575
+ await drainMainBuffer();
576
+ }
577
+ return true;
578
+ }
579
+
580
+ case "buffer-main":
581
+ dispatchState.mainBuffer.push(event);
582
+ return false;
583
+
584
+ case "buffer-fork": {
585
+ // Push event into the existing fork's queue. The fork drain loop will
586
+ // process it and resolve the promise when done. If the fork was cleaned
587
+ // up between the routing decision and here (race), fallback to buffer-main.
588
+ const activeFork = forkStates.get(event.targetId);
589
+ if (!activeFork) {
590
+ dispatchState.mainBuffer.push(event);
591
+ return false;
592
+ }
593
+ return new Promise<boolean>((resolve) => {
594
+ activeFork.queue.push({ event, resolve });
595
+ });
596
+ }
597
+
598
+ case "new-fork": {
599
+ // Fork the orchestrator's transcript and dispatch in parallel.
600
+ const transcriptFile = resolveTranscriptFile(sessionsDir, orchestratorKey);
601
+ const fork = transcriptFile
602
+ ? forkOrchestratorSession({
603
+ orchestratorSessionKey: orchestratorKey,
604
+ accountId: ctx.accountId,
605
+ transcriptFile,
606
+ sessionsDir,
607
+ })
608
+ : null;
609
+
610
+ if (fork) {
611
+ const activeFork: ActiveForkState = {
612
+ sessionKey: fork.sessionKey,
613
+ sessionFile: fork.sessionFile,
614
+ targetId: event.targetId,
615
+ queue: [],
616
+ processedEvents: [],
617
+ };
618
+ forkStates.set(event.targetId, activeFork);
619
+ dispatchState.activeForks.set(event.targetId, fork.sessionKey);
620
+
621
+ // Create promise for the first event, then start the drain loop
622
+ const firstEventPromise = new Promise<boolean>((resolve) => {
623
+ activeFork.queue.push({ event, resolve });
624
+ });
625
+ // Fire-and-forget — drain loop runs concurrently, callers await per-event promises
626
+ runForkDrainLoop(activeFork).catch((err) => {
627
+ log?.error(`pond[${ctx.accountId}]: fork drain loop error: ${String(err)}`);
628
+ });
629
+ return firstEventPromise;
630
+ } else {
631
+ log?.warn(`pond[${ctx.accountId}]: fork failed, buffering event for main session`);
632
+ dispatchState.mainBuffer.push(event);
633
+ return false;
634
+ }
635
+ }
636
+ }
637
+ return false;
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // WebSocket event handlers
642
+ // ---------------------------------------------------------------------------
643
+
276
644
  ws.onStateChange((state) => {
277
645
  log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
278
646
  });
279
647
 
280
- // On hello, cache chat types and start heartbeat with server-provided interval
281
648
  ws.on("hello", async (data: HelloData) => {
282
649
  sessionId = data.session_id ?? "";
283
650
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
284
651
  try {
285
- const count = await fetchAllChats(client, config.org_id, chatTypeMap);
652
+ const count = await fetchAllChats(client, config.org_id, chatInfoMap);
286
653
  log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
287
654
 
288
- // Cache client state only after successful connection
289
655
  setPondAccountState(ctx.accountId, {
290
656
  client,
291
657
  orgId: config.org_id,
292
658
  agentUserId,
293
659
  activeRuns: new Map(),
660
+ wikiMountRoot,
661
+ ws,
662
+ orchestratorSessionKey: orchestratorKey,
294
663
  });
295
664
 
296
- // (Re)start heartbeat with server-provided interval
665
+ // (Re)start heartbeat
297
666
  if (heartbeatTimer) clearInterval(heartbeatTimer);
298
667
  lastHeartbeatAt = Date.now();
299
668
  heartbeatTimer = setInterval(() => {
@@ -313,16 +682,31 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
313
682
  uptime: os.uptime(),
314
683
  });
315
684
  }, intervalSec * 1000);
685
+
686
+ // Unified inbox catch-up (replaces separate mention/DM/task catch-up).
687
+ // On cold start, skip items older than COLD_START_WINDOW_MS to avoid
688
+ // replaying historical backlog from a previous process.
689
+ const isFirstHello = !hadSuccessfulHello;
690
+ hadSuccessfulHello = true;
691
+ catchUpFromInbox(isFirstHello).catch((err) => {
692
+ log?.warn(`pond[${ctx.accountId}]: inbox catch-up failed: ${String(err)}`);
693
+ });
316
694
  } catch (err) {
317
695
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
318
696
  }
319
697
  });
320
698
 
321
- // Track chat type changes (new chats, renames, etc.)
322
699
  ws.on("chat.update", (data: ChatUpdateData) => {
323
700
  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"]);
701
+ if (!changes) return;
702
+ const existing = chatInfoMap.get(data.chat_id);
703
+ if (typeof changes.type === "string") {
704
+ chatInfoMap.set(data.chat_id, {
705
+ type: changes.type as Chat["type"],
706
+ name: (typeof changes.name === "string" ? changes.name : existing?.name) ?? null,
707
+ });
708
+ } else if (typeof changes.name === "string" && existing) {
709
+ chatInfoMap.set(data.chat_id, { ...existing, name: changes.name });
326
710
  }
327
711
  });
328
712
 
@@ -330,23 +714,25 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
330
714
  ws.on("message.new", async (data: MessageNewData) => {
331
715
  if (data.sender_id === agentUserId) return;
332
716
  if (data.message_type !== "text" && data.message_type !== "file") return;
717
+ // Dedupe: claim this message so catch-up won't re-dispatch it
718
+ if (!tryClaimMessage(data.id)) return;
333
719
 
334
720
  const chatId = data.chat_id;
335
721
 
336
- // Resolve chat type — query API for unknown chats
337
- let chatType = chatTypeMap.get(chatId);
338
- if (!chatType) {
722
+ // Resolve chat info — query API for unknown chats
723
+ let chatInfo = chatInfoMap.get(chatId);
724
+ if (!chatInfo) {
339
725
  try {
340
726
  const chat = await client.getChat(config.org_id, chatId);
341
- chatType = chat.type;
342
- chatTypeMap.set(chatId, chatType);
727
+ chatInfo = { type: chat.type, name: chat.name ?? null };
728
+ chatInfoMap.set(chatId, chatInfo);
343
729
  } catch (err) {
344
- log?.warn(`pond[${ctx.accountId}]: failed to resolve chat type for ${chatId}, skipping message: ${String(err)}`);
730
+ log?.warn(`pond[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
345
731
  return;
346
732
  }
347
733
  }
348
734
 
349
- // Build body text and optional media fields based on message type
735
+ // Build body text and optional media fields
350
736
  let body = "";
351
737
  let mediaFields: Record<string, string | undefined> = {};
352
738
 
@@ -356,118 +742,64 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
356
742
  if (!body) return;
357
743
 
358
744
  // In group chats, only respond when @mentioned or @all
359
- if (chatType === "group") {
745
+ if (chatInfo.type === "group") {
360
746
  const mentions = content.mentions ?? [];
361
747
  const isMentioned = mentions.some(
362
- (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
748
+ (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
363
749
  );
364
750
  if (!isMentioned) return;
365
751
  }
366
752
  } else {
367
- // file message
368
753
  const content = data.content as MediaContent;
369
-
370
- // In group chats, file messages have no mentions field — skip
371
- if (chatType === "group") return;
372
-
373
- body = content.caption?.trim()
374
- || `[file: ${content.file_name || "attachment"}]`;
375
-
376
- // Resolve presigned download URL for the attachment
754
+ if (chatInfo.type === "group") return;
755
+ body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
377
756
  if (content.attachment_id) {
378
757
  try {
379
758
  const fileRes = await client.getFileUrl(content.attachment_id);
380
- mediaFields = {
381
- MediaUrl: fileRes.url,
382
- MediaType: content.mime_type,
383
- };
759
+ mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
384
760
  } catch (err) {
385
761
  log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
386
- // Degrade gracefully — agent still gets the body text
387
762
  }
388
763
  }
389
764
  }
390
765
 
391
- const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
392
- setSessionChatId(sessionKey, chatId);
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
-
416
- // Build inbound context for OpenClaw agent
417
- const inboundCtx = core.channel.reply.finalizeInboundContext({
418
- Body: body,
419
- BodyForAgent: historyPrefix ? `${historyPrefix}${body}` : body,
420
- RawBody: body,
421
- CommandBody: body,
422
- ...mediaFields,
423
- From: `pond:${data.sender_id}`,
424
- To: `pond:${chatId}`,
425
- SessionKey: sessionKey,
426
- AccountId: ctx.accountId,
427
- ChatType: chatType,
428
- SenderName: data.sender?.display_name ?? data.sender_id,
429
- SenderId: data.sender_id,
430
- Provider: "pond" as const,
431
- Surface: "pond" as const,
432
- MessageSid: data.id,
433
- Timestamp: Date.now(),
434
- CommandAuthorized: true,
435
- OriginatingChannel: "pond" as const,
436
- OriginatingTo: `pond:${chatId}`,
437
- });
438
-
439
- // Extract no_reply hint from message
440
- const noReply = data.hints?.no_reply ?? false;
441
-
766
+ const event: PondEvent = {
767
+ type: "message",
768
+ targetId: chatId,
769
+ targetName: chatInfo.name ?? undefined,
770
+ targetType: chatInfo.type,
771
+ senderId: data.sender_id,
772
+ senderName: data.sender?.display_name ?? data.sender_id,
773
+ messageId: data.id,
774
+ body,
775
+ threadRootId: data.thread_root_id ?? undefined,
776
+ noReply: data.hints?.no_reply ?? false,
777
+ mediaFields: Object.keys(mediaFields).length > 0 ? mediaFields : undefined,
778
+ };
779
+
780
+ // Start typing for UX feedback. For buffer-main events (main is busy with
781
+ // same target), typing persists until the buffer drains.
782
+ // For fork events, typing persists until the fork dispatch completes.
783
+ // Both handleInboundEvent paths await their dispatches, so finally works correctly.
784
+ const willDispatch = !dispatchState.mainDispatching || dispatchState.mainCurrentTargetId !== chatId;
785
+ if (willDispatch) startTyping(chatId);
442
786
  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
- }
787
+ const dispatched = await handleInboundEvent(event);
788
+ if (dispatched) {
789
+ client.ackInbox(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
790
+ } else {
791
+ // Not dispatched (buffered or fork failed) — release claim so catch-up can retry
792
+ dispatchedMessages.delete(data.id);
466
793
  }
794
+ } catch (err) {
795
+ log?.error(`pond[${ctx.accountId}]: event dispatch failed for ${data.id}: ${String(err)}`);
796
+ dispatchedMessages.delete(data.id);
797
+ } finally {
798
+ if (willDispatch) stopTyping(chatId);
467
799
  }
468
800
  });
469
801
 
470
- // Re-apply platform config when server pushes an update notification
802
+ // Re-apply platform config on server push
471
803
  ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
472
804
  log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
473
805
  try {
@@ -477,23 +809,186 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
477
809
  }
478
810
  });
479
811
 
812
+ // Task assignment handler
813
+ async function handleTaskAssignment(task: Task): Promise<boolean> {
814
+ const dedupeKey = `${task.id}:${task.updated_at}`;
815
+ if (dispatchedTasks.has(dedupeKey)) {
816
+ log?.info(`pond[${ctx.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
817
+ return false;
818
+ }
819
+ dispatchedTasks.add(dedupeKey);
820
+ log?.info(`pond[${ctx.accountId}]: task assigned: ${task.identifier ?? task.id} "${task.title}"`);
821
+
822
+ const parts = [`Title: ${task.title}`];
823
+ parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
824
+ if (task.description) parts.push("", task.description);
825
+
826
+ const event: PondEvent = {
827
+ type: "task",
828
+ targetId: task.id,
829
+ targetName: task.identifier ?? undefined,
830
+ targetType: "task",
831
+ senderId: task.creator_id,
832
+ senderName: "system",
833
+ messageId: task.id,
834
+ body: parts.join("\n"),
835
+ };
836
+
837
+ const dispatched = await handleInboundEvent(event);
838
+ if (!dispatched) {
839
+ // Buffered, not yet dispatched — release dedupe so catch-up can retry
840
+ dispatchedTasks.delete(dedupeKey);
841
+ }
842
+ return dispatched;
843
+ }
844
+
845
+ // Server event buffer overflowed — events were lost, trigger full catch-up
846
+ ws.on("recovery.overflow", () => {
847
+ log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
848
+ catchUpFromInbox().catch((err) =>
849
+ log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
850
+ });
851
+
852
+ ws.on("task.assigned", async (data: TaskAssignedData) => {
853
+ if (data.assignee_id !== agentUserId) return;
854
+ if (data.status !== "todo" && data.status !== "in_progress") return;
855
+ try {
856
+ const dispatched = await handleTaskAssignment(data);
857
+ if (dispatched) {
858
+ client.ackInbox(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
859
+ }
860
+ } catch (err) {
861
+ log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
862
+ }
863
+ });
864
+
865
+ // ── Unified inbox catch-up ──
866
+ // Replays missed mentions, DMs, and task assignments via the inbox API.
867
+ // Adapts each item into a PondEvent and routes through handleInboundEvent.
868
+ async function catchUpFromInbox(coldStart = false) {
869
+ const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
870
+ let cursor: string | undefined;
871
+ let processed = 0;
872
+ let skippedOld = 0;
873
+
874
+ do {
875
+ const page = await client.getInbox(config.org_id, {
876
+ type: "mention,agent_dm,task_assign",
877
+ read: "false",
878
+ limit: 50,
879
+ cursor,
880
+ });
881
+
882
+ for (const item of page.data ?? []) {
883
+ if (minAge > 0 && new Date(item.updated_at).getTime() < minAge) {
884
+ client.markInboxRead(config.org_id, item.id).catch(() => {});
885
+ skippedOld++;
886
+ continue;
887
+ }
888
+
889
+ processed++;
890
+ try {
891
+ let dispatched = false;
892
+ if (item.type === "task_assign" && item.source_id) {
893
+ // Task catch-up: fetch full task and dispatch
894
+ const task = await client.getTask(config.org_id, item.source_id);
895
+ if (task) {
896
+ dispatched = await handleTaskAssignment(task);
897
+ } else {
898
+ dispatched = true; // task deleted — mark read to prevent infinite retry
899
+ }
900
+ } else if (item.source_id && item.chat_id) {
901
+ // Message catch-up (mention or agent_dm): build PondEvent from inbox item
902
+ if (!tryClaimMessage(item.source_id)) continue;
903
+ const msg = await client.getMessage(item.source_id).catch(() => null);
904
+ if (!msg || msg.sender_id === agentUserId) {
905
+ dispatchedMessages.delete(item.source_id);
906
+ client.markInboxRead(config.org_id, item.id).catch(() => {});
907
+ continue;
908
+ }
909
+
910
+ let chatInfo = chatInfoMap.get(item.chat_id);
911
+ if (!chatInfo) {
912
+ try {
913
+ const chat = await client.getChat(config.org_id, item.chat_id);
914
+ chatInfo = { type: chat.type, name: chat.name ?? null };
915
+ chatInfoMap.set(item.chat_id, chatInfo);
916
+ } catch {
917
+ dispatchedMessages.delete(item.source_id);
918
+ continue;
919
+ }
920
+ }
921
+
922
+ let body = "";
923
+ let catchUpMediaFields: Record<string, string | undefined> = {};
924
+ if (msg.message_type === "text") {
925
+ body = (msg.content as TextContent).text?.trim() ?? "";
926
+ } else if (msg.message_type === "file") {
927
+ const fc = msg.content as MediaContent;
928
+ body = fc.caption?.trim() || `[file: ${fc.file_name || "attachment"}]`;
929
+ if (fc.attachment_id) {
930
+ try {
931
+ const fileRes = await client.getFileUrl(fc.attachment_id);
932
+ catchUpMediaFields = { MediaUrl: fileRes.url, MediaType: fc.mime_type };
933
+ } catch { /* degrade gracefully */ }
934
+ }
935
+ }
936
+ if (!body) {
937
+ dispatchedMessages.delete(item.source_id);
938
+ client.markInboxRead(config.org_id, item.id).catch(() => {});
939
+ continue;
940
+ }
941
+
942
+ const event: PondEvent = {
943
+ type: "message",
944
+ targetId: item.chat_id,
945
+ targetName: chatInfo.name ?? undefined,
946
+ targetType: chatInfo.type,
947
+ senderId: msg.sender_id,
948
+ senderName: msg.sender?.display_name ?? msg.sender_id,
949
+ messageId: msg.id,
950
+ body,
951
+ threadRootId: msg.thread_root_id ?? undefined,
952
+ noReply: msg.hints?.no_reply ?? false,
953
+ mediaFields: Object.keys(catchUpMediaFields).length > 0 ? catchUpMediaFields : undefined,
954
+ };
955
+ dispatched = await handleInboundEvent(event);
956
+ }
957
+ if (dispatched) {
958
+ client.markInboxRead(config.org_id, item.id).catch(() => {});
959
+ }
960
+ } catch (err) {
961
+ log?.warn(`pond[${ctx.accountId}]: catch-up item ${item.id} (${item.type}) failed: ${String(err)}`);
962
+ }
963
+ }
964
+ cursor = page.has_more ? page.next_cursor : undefined;
965
+ } while (cursor);
966
+
967
+ if (processed > 0 || skippedOld > 0) {
968
+ log?.info(`pond[${ctx.accountId}]: inbox catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
969
+ }
970
+ }
971
+
480
972
  log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
481
973
  await ws.connect();
482
974
 
483
975
  // Clean up on abort
484
976
  ctx.abortSignal.addEventListener("abort", () => {
485
977
  if (heartbeatTimer) clearInterval(heartbeatTimer);
486
- // Clean up all active typing timers to prevent leaks
487
978
  for (const [, dispatch] of activeDispatches) {
488
979
  clearInterval(dispatch.typingTimer);
489
980
  }
490
981
  activeDispatches.clear();
982
+ stopWikiHelper?.();
491
983
  ws.disconnect();
492
984
  removePondAccountState(ctx.accountId);
493
- // Clear injected env vars so stale credentials don't leak to future subprocesses
494
- delete process.env.POND_API_URL;
495
- delete process.env.POND_API_KEY;
496
- delete process.env.POND_ORG_ID;
985
+ for (const [key, value] of Object.entries(previousEnv)) {
986
+ if (value === undefined) {
987
+ delete process.env[key];
988
+ } else {
989
+ process.env[key] = value;
990
+ }
991
+ }
497
992
  log?.info(`pond[${ctx.accountId}]: disconnected`);
498
993
  });
499
994