@pnds/pond 1.0.1 → 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,10 +57,54 @@ async function fetchAllChats(
38
57
  return total;
39
58
  }
40
59
 
41
- /**
42
- * Dispatch an inbound message to the OpenClaw agent and handle the reply.
43
- * Shared by both text and file message handlers to avoid duplication.
44
- */
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}]`);
78
+ }
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);
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+
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
+
45
108
  async function dispatchToAgent(opts: {
46
109
  core: PluginRuntime;
47
110
  cfg: Record<string, unknown>;
@@ -50,12 +113,19 @@ async function dispatchToAgent(opts: {
50
113
  agentUserId: string;
51
114
  accountId: string;
52
115
  sessionKey: string;
53
- chatId: string;
54
116
  messageId: string;
55
117
  inboundCtx: Record<string, unknown>;
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;
56
124
  log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
57
125
  }) {
58
- const { core, cfg, client, config, agentUserId, accountId, sessionKey, chatId, messageId, inboundCtx, 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 };
59
129
  const sessionKeyLower = sessionKey.toLowerCase();
60
130
  let thinkingSent = false;
61
131
  let runIdPromise: Promise<string | undefined> | undefined;
@@ -66,31 +136,22 @@ async function dispatchToAgent(opts: {
66
136
  cfg,
67
137
  dispatcherOptions: {
68
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.
69
141
  const replyText = payload.text?.trim();
70
142
  if (!replyText) return;
71
- // Wait for run creation to complete before sending
72
143
  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)
144
+ if (runId) {
83
145
  try {
84
146
  await client.createAgentStep(config.org_id, agentUserId, runId, {
85
147
  step_type: "text",
86
- content: { text: replyText },
87
- chat_projection: true,
148
+ content: { text: replyText, suppressed: true },
149
+ ...(info.kind === "block" ? { chat_projection: false } : {}),
88
150
  });
89
151
  } catch (err) {
90
- log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
152
+ log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
91
153
  }
92
154
  }
93
- // kind === "tool" → ignore (handled by hooks)
94
155
  },
95
156
  onReplyStart: () => {
96
157
  if (thinkingSent) return;
@@ -98,9 +159,10 @@ async function dispatchToAgent(opts: {
98
159
  runIdPromise = (async () => {
99
160
  try {
100
161
  const run = await client.createAgentRun(config.org_id, agentUserId, {
101
- trigger_type: "mention",
102
- trigger_ref: { message_id: messageId },
103
- 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 } : {}),
104
166
  });
105
167
  return run.id;
106
168
  } catch (err) {
@@ -108,7 +170,6 @@ async function dispatchToAgent(opts: {
108
170
  return undefined;
109
171
  }
110
172
  })();
111
- // Store promise immediately so hooks can await it before the run is created
112
173
  const state = getPondAccountState(accountId);
113
174
  if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
114
175
  },
@@ -133,11 +194,17 @@ async function dispatchToAgent(opts: {
133
194
  },
134
195
  },
135
196
  });
197
+ return true;
136
198
  } catch (err) {
137
199
  log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
200
+ return false;
138
201
  }
139
202
  }
140
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // Gateway
206
+ // ---------------------------------------------------------------------------
207
+
141
208
  export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
142
209
  startAccount: async (ctx) => {
143
210
  const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
@@ -158,16 +225,21 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
158
225
  const agentUserId = me.id;
159
226
  log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
160
227
 
161
- // Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
162
- process.env.POND_API_URL = config.pond_url;
163
- process.env.POND_API_KEY = config.api_key;
164
- process.env.POND_ORG_ID = config.org_id;
165
-
166
228
  // Apply platform config (models, defaults)
167
- // Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
168
229
  const stateDir = process.env.OPENCLAW_STATE_DIR
169
230
  || path.join(process.env.HOME || "/data", ".openclaw");
170
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
+
171
243
  const configManagerOpts = {
172
244
  client,
173
245
  stateDir,
@@ -182,6 +254,24 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
182
254
  log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
183
255
  }
184
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
+
185
275
  // Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
186
276
  const wsUrl = resolveWsUrl(account);
187
277
  const ws = new PondWs({
@@ -189,40 +279,390 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
189
279
  wsUrl,
190
280
  });
191
281
 
192
- // Chat type lookuppopulated on hello, used by message handler
193
- 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>();
194
287
 
195
288
  // Session ID from hello — used for heartbeat
196
289
  let sessionId = "";
197
- // Heartbeat interval handle — created inside hello handler with server-provided interval
198
290
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
199
- // Track active dispatches per chat for typing indicator management
291
+ // Typing indicator management — reference-counted per chat
200
292
  const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
201
- // 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
202
303
  let lastHeartbeatAt = Date.now();
203
304
 
204
- // 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
+
205
644
  ws.onStateChange((state) => {
206
645
  log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
207
646
  });
208
647
 
209
- // On hello, cache chat types and start heartbeat with server-provided interval
210
648
  ws.on("hello", async (data: HelloData) => {
211
649
  sessionId = data.session_id ?? "";
212
650
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
213
651
  try {
214
- const count = await fetchAllChats(client, config.org_id, chatTypeMap);
652
+ const count = await fetchAllChats(client, config.org_id, chatInfoMap);
215
653
  log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
216
654
 
217
- // Cache client state only after successful connection
218
655
  setPondAccountState(ctx.accountId, {
219
656
  client,
220
657
  orgId: config.org_id,
221
658
  agentUserId,
222
659
  activeRuns: new Map(),
660
+ wikiMountRoot,
661
+ ws,
662
+ orchestratorSessionKey: orchestratorKey,
223
663
  });
224
664
 
225
- // (Re)start heartbeat with server-provided interval
665
+ // (Re)start heartbeat
226
666
  if (heartbeatTimer) clearInterval(heartbeatTimer);
227
667
  lastHeartbeatAt = Date.now();
228
668
  heartbeatTimer = setInterval(() => {
@@ -242,16 +682,31 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
242
682
  uptime: os.uptime(),
243
683
  });
244
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
+ });
245
694
  } catch (err) {
246
695
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
247
696
  }
248
697
  });
249
698
 
250
- // Track chat type changes (new chats, renames, etc.)
251
699
  ws.on("chat.update", (data: ChatUpdateData) => {
252
700
  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"]);
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 });
255
710
  }
256
711
  });
257
712
 
@@ -259,23 +714,25 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
259
714
  ws.on("message.new", async (data: MessageNewData) => {
260
715
  if (data.sender_id === agentUserId) return;
261
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;
262
719
 
263
720
  const chatId = data.chat_id;
264
721
 
265
- // Resolve chat type — query API for unknown chats
266
- let chatType = chatTypeMap.get(chatId);
267
- if (!chatType) {
722
+ // Resolve chat info — query API for unknown chats
723
+ let chatInfo = chatInfoMap.get(chatId);
724
+ if (!chatInfo) {
268
725
  try {
269
726
  const chat = await client.getChat(config.org_id, chatId);
270
- chatType = chat.type;
271
- chatTypeMap.set(chatId, chatType);
727
+ chatInfo = { type: chat.type, name: chat.name ?? null };
728
+ chatInfoMap.set(chatId, chatInfo);
272
729
  } catch (err) {
273
- 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)}`);
274
731
  return;
275
732
  }
276
733
  }
277
734
 
278
- // Build body text and optional media fields based on message type
735
+ // Build body text and optional media fields
279
736
  let body = "";
280
737
  let mediaFields: Record<string, string | undefined> = {};
281
738
 
@@ -285,106 +742,64 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
285
742
  if (!body) return;
286
743
 
287
744
  // In group chats, only respond when @mentioned or @all
288
- if (chatType === "group") {
745
+ if (chatInfo.type === "group") {
289
746
  const mentions = content.mentions ?? [];
290
747
  const isMentioned = mentions.some(
291
- (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,
292
749
  );
293
750
  if (!isMentioned) return;
294
751
  }
295
752
  } else {
296
- // file message
297
753
  const content = data.content as MediaContent;
298
-
299
- // In group chats, file messages have no mentions field — skip
300
- if (chatType === "group") return;
301
-
302
- body = content.caption?.trim()
303
- || `[file: ${content.file_name || "attachment"}]`;
304
-
305
- // Resolve presigned download URL for the attachment
754
+ if (chatInfo.type === "group") return;
755
+ body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
306
756
  if (content.attachment_id) {
307
757
  try {
308
758
  const fileRes = await client.getFileUrl(content.attachment_id);
309
- mediaFields = {
310
- MediaUrl: fileRes.url,
311
- MediaType: content.mime_type,
312
- };
759
+ mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
313
760
  } catch (err) {
314
761
  log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
315
- // Degrade gracefully — agent still gets the body text
316
762
  }
317
763
  }
318
764
  }
319
765
 
320
- const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
321
- setSessionChatId(sessionKey, chatId);
322
-
323
- // Build inbound context for OpenClaw agent
324
- const inboundCtx = core.channel.reply.finalizeInboundContext({
325
- Body: body,
326
- BodyForAgent: body,
327
- RawBody: body,
328
- CommandBody: body,
329
- ...mediaFields,
330
- From: `pond:${data.sender_id}`,
331
- To: `pond:${chatId}`,
332
- SessionKey: sessionKey,
333
- AccountId: ctx.accountId,
334
- ChatType: chatType,
335
- SenderName: data.sender?.display_name ?? data.sender_id,
336
- SenderId: data.sender_id,
337
- Provider: "pond" as const,
338
- Surface: "pond" as const,
339
- MessageSid: data.id,
340
- Timestamp: Date.now(),
341
- CommandAuthorized: true,
342
- OriginatingChannel: "pond" as const,
343
- OriginatingTo: `pond:${chatId}`,
344
- });
345
-
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
-
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);
360
786
  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
- }
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);
383
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);
384
799
  }
385
800
  });
386
801
 
387
- // Re-apply platform config when server pushes an update notification
802
+ // Re-apply platform config on server push
388
803
  ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
389
804
  log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
390
805
  try {
@@ -394,23 +809,186 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
394
809
  }
395
810
  });
396
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
+
397
972
  log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
398
973
  await ws.connect();
399
974
 
400
975
  // Clean up on abort
401
976
  ctx.abortSignal.addEventListener("abort", () => {
402
977
  if (heartbeatTimer) clearInterval(heartbeatTimer);
403
- // Clean up all active typing timers to prevent leaks
404
978
  for (const [, dispatch] of activeDispatches) {
405
979
  clearInterval(dispatch.typingTimer);
406
980
  }
407
981
  activeDispatches.clear();
982
+ stopWikiHelper?.();
408
983
  ws.disconnect();
409
984
  removePondAccountState(ctx.accountId);
410
- // Clear injected env vars so stale credentials don't leak to future subprocesses
411
- delete process.env.POND_API_URL;
412
- delete process.env.POND_API_KEY;
413
- 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
+ }
414
992
  log?.info(`pond[${ctx.accountId}]: disconnected`);
415
993
  });
416
994