@parall/parall 1.12.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 ADDED
@@ -0,0 +1,1222 @@
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 { ParallClient, ParallWs, MENTION_ALL_USER_ID } from "@parall/sdk";
7
+ import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData, TaskAssignedData, Task } from "@parall/sdk";
8
+ import type { ParallChannelConfig } from "./types.js";
9
+ import * as crypto from "node:crypto";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { resolveParallAccount } from "./accounts.js";
13
+ import {
14
+ getParallRuntime,
15
+ setParallAccountState,
16
+ getParallAccountState,
17
+ removeParallAccountState,
18
+ setSessionChatId,
19
+ setSessionMessageId,
20
+ setDispatchMessageId,
21
+ setDispatchGroupKey,
22
+ clearDispatchGroupKey,
23
+ } from "./runtime.js";
24
+ import type { ParallEvent, DispatchState, ForkResult } from "./runtime.js";
25
+ import { buildOrchestratorSessionKey } from "./session.js";
26
+ import type { ResolvedParallAccount } from "./types.js";
27
+ import { fetchAndApplyPlatformConfig } from "./config-manager.js";
28
+ import { startWikiHelper } from "./wiki-helper.js";
29
+ import { routeTrigger, defaultRoutingStrategy } from "./routing.js";
30
+ import { forkOrchestratorSession, cleanupForkSession, resolveTranscriptFile } from "./fork.js";
31
+
32
+ function resolveWsUrl(account: ResolvedParallAccount): string {
33
+ if (account.config.ws_url) return account.config.ws_url;
34
+ const base = account.config.parall_url.replace(/\/$/, "");
35
+ const wsBase = base.replace(/^http/, "ws");
36
+ return `${wsBase}/ws`;
37
+ }
38
+
39
+ type ChatInfo = { type: Chat["type"]; name: string | null; agentRoutingMode: Chat["agent_routing_mode"] };
40
+
41
+ /**
42
+ * Fetch all chats with pagination and populate the info map (type + name).
43
+ */
44
+ async function fetchAllChats(
45
+ client: ParallClient,
46
+ orgId: string,
47
+ chatInfoMap: Map<string, ChatInfo>,
48
+ ): Promise<number> {
49
+ let cursor: string | undefined;
50
+ let total = 0;
51
+ do {
52
+ const res = await client.getChats(orgId, { limit: 100, cursor });
53
+ for (const c of res.data) {
54
+ chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null, agentRoutingMode: c.agent_routing_mode });
55
+ }
56
+ total += res.data.length;
57
+ cursor = res.has_more ? res.next_cursor : undefined;
58
+ } while (cursor);
59
+ return total;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Event body formatting
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function buildEventBody(event: ParallEvent): string {
67
+ const lines: string[] = [];
68
+ if (event.type === "message") {
69
+ lines.push(`[Event: message.new]`);
70
+ const chatLabel = event.targetName
71
+ ? `"${event.targetName}" (${event.targetId})`
72
+ : event.targetId;
73
+ lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? "unknown"}]`);
74
+ lines.push(`[From: ${event.senderName} (${event.senderId})]`);
75
+ lines.push(`[Message ID: ${event.messageId}]`);
76
+ if (event.threadRootId) lines.push(`[Thread: ${event.threadRootId}]`);
77
+ if (event.noReply) lines.push(`[Hint: no_reply]`);
78
+ if (event.mediaFields?.MediaUrl) {
79
+ lines.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
80
+ }
81
+ lines.push("", event.body);
82
+ } else {
83
+ lines.push(`[Event: task.assigned]`);
84
+ const taskLabel = event.targetName
85
+ ? `${event.targetName} (${event.targetId})`
86
+ : event.targetId;
87
+ lines.push(`[Task: ${taskLabel} | type: ${event.targetType ?? "task"}]`);
88
+ lines.push(`[Assigned by: ${event.senderName} (${event.senderId})]`);
89
+ lines.push("", event.body);
90
+ }
91
+ return lines.join("\n");
92
+ }
93
+
94
+ function buildForkResultPrefix(results: ForkResult[]): string {
95
+ if (!results.length) return "";
96
+ const blocks = results.map((r) => {
97
+ const lines = [`[Fork result]`];
98
+ lines.push(`[Handled: ${r.sourceEvent.type} — ${r.sourceEvent.summary}]`);
99
+ if (r.actions.length) lines.push(`[Actions: ${r.actions.join("; ")}]`);
100
+ return lines.join("\n");
101
+ });
102
+ return blocks.join("\n\n") + "\n\n---\n\n";
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Dispatch to OpenClaw agent (orchestrator mode — all text suppressed)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ async function dispatchToAgent(opts: {
110
+ core: PluginRuntime;
111
+ cfg: Record<string, unknown>;
112
+ client: ParallClient;
113
+ config: ParallChannelConfig;
114
+ agentUserId: string;
115
+ accountId: string;
116
+ sessionKey: string;
117
+ messageId: string;
118
+ inboundCtx: Record<string, unknown>;
119
+ triggerType?: string;
120
+ triggerRef?: Record<string, unknown>;
121
+ chatId?: string;
122
+ defaultTargetType?: string;
123
+ defaultTargetId?: string;
124
+ senderId?: string;
125
+ senderName?: string;
126
+ messageBody?: string;
127
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
128
+ }) {
129
+ const { core, cfg, client, config, agentUserId, accountId, sessionKey, messageId, inboundCtx, log } = opts;
130
+ const triggerType = opts.triggerType ?? "mention";
131
+ const triggerRef = opts.triggerRef ?? { message_id: messageId };
132
+
133
+ // Resolve session ID from account state
134
+ const state = getParallAccountState(accountId);
135
+ const sessionId = state?.activeSessionId;
136
+
137
+ // Entire dispatch lifecycle in one try/finally: active-set → input step → dispatch → idle.
138
+ // Note: fork-on-busy routing guarantees serial dispatch per session — concurrent
139
+ // dispatches to the same session should not happen in the orchestrator model.
140
+ let reasoningBuffer = "";
141
+ let turnGroupKey = "";
142
+ try {
143
+ // 1. Transition session to active FIRST (before any steps)
144
+ if (sessionId) {
145
+ try {
146
+ await client.updateAgentSession(config.org_id, agentUserId, sessionId, { status: "active" });
147
+ } catch (err) {
148
+ log?.warn(`parall[${accountId}]: failed to set session active: ${String(err)}`);
149
+ }
150
+ }
151
+
152
+ // 2. Create input step — captures the user message as a step in the session
153
+ if (sessionId) {
154
+ const targetType = opts.chatId ? "chat" : opts.defaultTargetType ?? "";
155
+ const targetId = opts.chatId ?? opts.defaultTargetId;
156
+ try {
157
+ await client.createAgentStep(config.org_id, agentUserId, sessionId, {
158
+ step_type: "input",
159
+ target_type: targetType,
160
+ target_id: targetId,
161
+ content: {
162
+ trigger_type: triggerType,
163
+ trigger_ref: triggerRef,
164
+ sender_id: opts.senderId ?? "",
165
+ sender_name: opts.senderName ?? "",
166
+ summary: opts.messageBody?.substring(0, 200) ?? "",
167
+ },
168
+ });
169
+ } catch (err) {
170
+ log?.warn(`parall[${accountId}]: failed to create input step: ${String(err)}`);
171
+ }
172
+ }
173
+
174
+ // 3. Dispatch to agent
175
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
176
+ ctx: inboundCtx,
177
+ cfg,
178
+ dispatcherOptions: {
179
+ deliver: async (payload: { text?: string }) => {
180
+ // Orchestrator mode: ALL text output is suppressed — agent uses tools to interact.
181
+ // Record as internal step for observability.
182
+ const replyText = payload.text?.trim();
183
+ if (!replyText || !sessionId) return;
184
+ const targetType = opts.chatId ? "chat" : opts.defaultTargetType ?? "";
185
+ const targetId = opts.chatId ?? opts.defaultTargetId;
186
+ try {
187
+ await client.createAgentStep(config.org_id, agentUserId, sessionId, {
188
+ step_type: "text",
189
+ target_type: targetType,
190
+ target_id: targetId,
191
+ content: { text: replyText, suppressed: true },
192
+ });
193
+ } catch (err) {
194
+ log?.warn(`parall[${accountId}]: failed to create suppressed text step: ${String(err)}`);
195
+ }
196
+ },
197
+ onReplyStart: () => {
198
+ // Generate a new group key per LLM response turn — shared across thinking + tool_call steps
199
+ turnGroupKey = crypto.randomUUID();
200
+ setDispatchGroupKey(sessionKey, turnGroupKey);
201
+ },
202
+ },
203
+ replyOptions: {
204
+ onReasoningStream: (payload: { text?: string }) => {
205
+ reasoningBuffer += payload.text ?? "";
206
+ },
207
+ onReasoningEnd: async () => {
208
+ if (sessionId && reasoningBuffer) {
209
+ const targetType = opts.chatId ? "chat" : opts.defaultTargetType ?? "";
210
+ const targetId = opts.chatId ?? opts.defaultTargetId;
211
+ try {
212
+ await client.createAgentStep(config.org_id, agentUserId, sessionId, {
213
+ step_type: "thinking",
214
+ target_type: targetType,
215
+ target_id: targetId,
216
+ content: { text: reasoningBuffer },
217
+ group_key: turnGroupKey || undefined,
218
+ });
219
+ } catch (err) {
220
+ log?.warn(`parall[${accountId}]: failed to create thinking step: ${String(err)}`);
221
+ }
222
+ }
223
+ reasoningBuffer = "";
224
+ },
225
+ },
226
+ });
227
+ return true;
228
+ } catch (err) {
229
+ log?.error(`parall[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
230
+ return false;
231
+ } finally {
232
+ // Transition session back to idle after dispatch completes (or fails)
233
+ if (sessionId) {
234
+ try {
235
+ await client.updateAgentSession(config.org_id, agentUserId, sessionId, { status: "idle" });
236
+ } catch (err) {
237
+ log?.warn(`parall[${accountId}]: failed to set session idle: ${String(err)}`);
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Gateway
245
+ // ---------------------------------------------------------------------------
246
+
247
+ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
248
+ startAccount: async (ctx) => {
249
+ const account = resolveParallAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
250
+ if (!account.enabled) return;
251
+ if (!account.configured) throw new Error("Parall account is not configured: parall_url/api_key/org_id required");
252
+
253
+ const { config } = account;
254
+ const core = getParallRuntime();
255
+ const log = ctx.log;
256
+
257
+ const client = new ParallClient({
258
+ baseUrl: config.parall_url,
259
+ token: config.api_key,
260
+ });
261
+
262
+ // Resolve agent's own user ID
263
+ const me = await client.getMe();
264
+ const agentUserId = me.id;
265
+ log?.info(`parall[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
266
+
267
+ // Apply platform config (models, defaults)
268
+ const stateDir = process.env.OPENCLAW_STATE_DIR
269
+ || path.join(process.env.HOME || "/data", ".openclaw");
270
+ const openclawConfigPath = path.join(stateDir, "openclaw.json");
271
+ const previousEnv = {
272
+ PRLL_API_URL: process.env.PRLL_API_URL,
273
+ PRLL_API_KEY: process.env.PRLL_API_KEY,
274
+ PRLL_ORG_ID: process.env.PRLL_ORG_ID,
275
+ PRLL_AGENT_ID: process.env.PRLL_AGENT_ID,
276
+ };
277
+ process.env.PRLL_API_URL = config.parall_url;
278
+ process.env.PRLL_API_KEY = config.api_key;
279
+ process.env.PRLL_ORG_ID = config.org_id;
280
+ process.env.PRLL_AGENT_ID = agentUserId;
281
+
282
+ const configManagerOpts = {
283
+ client,
284
+ stateDir,
285
+ configPath: openclawConfigPath,
286
+ credentials: { api_key: config.api_key, parall_url: config.parall_url },
287
+ log,
288
+ };
289
+
290
+ try {
291
+ await fetchAndApplyPlatformConfig(configManagerOpts);
292
+ } catch (err) {
293
+ log?.warn(`parall[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
294
+ }
295
+
296
+ let stopWikiHelper: (() => void) | null = null;
297
+ let wikiMountRoot: string | undefined;
298
+ try {
299
+ const wikiHelper = await startWikiHelper({
300
+ accountId: ctx.accountId,
301
+ parallUrl: config.parall_url,
302
+ apiKey: config.api_key,
303
+ orgId: config.org_id,
304
+ agentId: agentUserId,
305
+ stateDir,
306
+ log,
307
+ });
308
+ wikiMountRoot = wikiHelper.mountRoot;
309
+ stopWikiHelper = wikiHelper.stop;
310
+ } catch (err) {
311
+ log?.warn(`parall[${ctx.accountId}]: wiki helper startup failed: ${String(err)}`);
312
+ }
313
+
314
+ // Connect WebSocket via ticket auth (reconnection enabled by default in ParallWs)
315
+ const wsUrl = resolveWsUrl(account);
316
+ const ws = new ParallWs({
317
+ getTicket: () => client.getWsTicket(),
318
+ wsUrl,
319
+ });
320
+
321
+ // Orchestrator session key — single session for all Parall events
322
+ const orchestratorKey = buildOrchestratorSessionKey(ctx.accountId);
323
+
324
+ // Chat info lookup — populated on hello, used for structured event bodies
325
+ const chatInfoMap = new Map<string, ChatInfo>();
326
+
327
+ // Session ID from hello — used for heartbeat
328
+ let sessionId = "";
329
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
330
+ // Typing indicator management — reference-counted per chat
331
+ const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
332
+ // Dedupe task dispatches
333
+ const dispatchedTasks = new Set<string>();
334
+ // Dedupe message dispatches — shared by live handler and catch-up.
335
+ // Bounded: evict oldest entries when cap is reached.
336
+ const dispatchedMessages = new Set<string>();
337
+ const DISPATCHED_MESSAGES_CAP = 5000;
338
+ // Tracks whether the agent has had at least one successful hello in this process.
339
+ let hadSuccessfulHello = false;
340
+ const COLD_START_WINDOW_MS = 5 * 60_000;
341
+ // Heartbeat watchdog
342
+ let lastHeartbeatAt = Date.now();
343
+
344
+ /** Atomically claim a message ID for processing. Returns false if already claimed. */
345
+ function tryClaimMessage(id: string): boolean {
346
+ if (dispatchedMessages.has(id)) return false;
347
+ if (dispatchedMessages.size >= DISPATCHED_MESSAGES_CAP) {
348
+ let toEvict = Math.floor(DISPATCHED_MESSAGES_CAP / 4);
349
+ for (const old of dispatchedMessages) {
350
+ dispatchedMessages.delete(old);
351
+ if (--toEvict <= 0) break;
352
+ }
353
+ }
354
+ dispatchedMessages.add(id);
355
+ return true;
356
+ }
357
+
358
+ // Sessions dir for fork transcript resolution.
359
+ // Session key "agent:main:parall:..." → agentId is "main" → store at agents/main/sessions/
360
+ const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Dispatch state for fork-on-busy routing
364
+ // ---------------------------------------------------------------------------
365
+ const dispatchState: DispatchState = {
366
+ mainDispatching: false,
367
+ activeForks: new Map(),
368
+ pendingForkResults: [],
369
+ mainBuffer: [],
370
+ };
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Per-fork queue state (internal to gateway, not exposed via DispatchState)
374
+ // ---------------------------------------------------------------------------
375
+ type ForkQueueItem = {
376
+ event: ParallEvent;
377
+ resolve: (dispatched: boolean) => void;
378
+ };
379
+
380
+ type ActiveForkState = {
381
+ sessionKey: string;
382
+ sessionFile: string;
383
+ targetId: string;
384
+ queue: ForkQueueItem[];
385
+ processedEvents: ParallEvent[];
386
+ };
387
+
388
+ const forkStates = new Map<string, ActiveForkState>();
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Typing indicator helpers
392
+ // ---------------------------------------------------------------------------
393
+ function startTyping(chatId: string) {
394
+ const existing = activeDispatches.get(chatId);
395
+ if (existing) {
396
+ existing.count++;
397
+ } else {
398
+ if (ws.state === "connected") ws.sendTyping(chatId, "start");
399
+ const typingRefresh = setInterval(() => {
400
+ if (ws.state === "connected") ws.sendTyping(chatId, "start");
401
+ }, 2000);
402
+ activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
403
+ }
404
+ }
405
+
406
+ function stopTyping(chatId: string) {
407
+ const dispatch = activeDispatches.get(chatId);
408
+ if (!dispatch) return;
409
+ dispatch.count--;
410
+ if (dispatch.count <= 0) {
411
+ clearInterval(dispatch.typingTimer);
412
+ activeDispatches.delete(chatId);
413
+ if (ws.state === "connected") ws.sendTyping(chatId, "stop");
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Fork drain loop — processes queued events on a forked session
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Process all queued events on a fork session with coalescing, then clean up.
423
+ * Fire-and-forget: callers await per-event promises, not this function.
424
+ *
425
+ * Coalescing: instead of dispatching events one-by-one, all queued events are
426
+ * merged into a single prompt and dispatched once — same pattern as drainMainBuffer.
427
+ *
428
+ * Invariant: between the while-loop empty check and cleanup, there is no
429
+ * await, so JS single-threaded execution guarantees no new events can be
430
+ * pushed to the queue between the check and the cleanup.
431
+ */
432
+ async function runForkDrainLoop(fork: ActiveForkState) {
433
+ try {
434
+ while (fork.queue.length > 0) {
435
+ // Take ALL queued items at once and coalesce via InboundHistory
436
+ const items = fork.queue.splice(0);
437
+ const events = items.map((it) => it.event);
438
+ const last = events[events.length - 1];
439
+ const earlier = events.slice(0, -1);
440
+ if (earlier.length) await createInputStepsForEarlierEvents(earlier);
441
+ const bodyForAgent = buildEventBody(last);
442
+ const inboundHistory = earlier.length ? buildInboundHistory(earlier) : undefined;
443
+ try {
444
+ await runDispatch(last, fork.sessionKey, bodyForAgent, inboundHistory);
445
+ fork.processedEvents.push(...events);
446
+ for (const it of items) it.resolve(true);
447
+ } catch (err) {
448
+ log?.error(`parall[${ctx.accountId}]: fork dispatch failed for ${last.messageId}: ${String(err)}`);
449
+ for (const it of items) it.resolve(false);
450
+ break;
451
+ }
452
+ }
453
+ // Resolve remaining items as not-dispatched (fork stopped after error).
454
+ // These events are NOT acked — dispatch retains them for catch-up replay.
455
+ for (const remaining of fork.queue.splice(0)) {
456
+ remaining.resolve(false);
457
+ }
458
+ } finally {
459
+ // Collect fork result for injection into main session's next turn
460
+ if (fork.processedEvents.length > 0) {
461
+ const first = fork.processedEvents[0];
462
+ dispatchState.pendingForkResults.push({
463
+ forkSessionKey: fork.sessionKey,
464
+ sourceEvent: {
465
+ type: first.type,
466
+ targetId: fork.targetId,
467
+ summary: fork.processedEvents.length === 1
468
+ ? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
469
+ : `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
470
+ },
471
+ actions: [],
472
+ });
473
+ }
474
+ // Remove from tracking maps and clean up session files
475
+ forkStates.delete(fork.targetId);
476
+ dispatchState.activeForks.delete(fork.targetId);
477
+ cleanupForkSession({ sessionFile: fork.sessionFile, sessionKey: fork.sessionKey, sessionsDir });
478
+ }
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Build OpenClaw inbound context
483
+ // ---------------------------------------------------------------------------
484
+ function buildInboundCtx(
485
+ event: ParallEvent,
486
+ sessionKey: string,
487
+ bodyForAgent: string,
488
+ inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>,
489
+ ): Record<string, unknown> {
490
+ return core.channel.reply.finalizeInboundContext({
491
+ Body: event.body,
492
+ BodyForAgent: bodyForAgent,
493
+ RawBody: event.body,
494
+ CommandBody: event.body,
495
+ ...(event.mediaFields ?? {}),
496
+ ...(inboundHistory?.length ? { InboundHistory: inboundHistory } : {}),
497
+ From: `parall:${event.senderId}`,
498
+ To: `parall:orchestrator`,
499
+ SessionKey: sessionKey,
500
+ AccountId: ctx.accountId,
501
+ ChatType: event.targetType ?? "unknown",
502
+ SenderName: event.senderName,
503
+ SenderId: event.senderId,
504
+ Provider: "parall" as const,
505
+ Surface: "parall" as const,
506
+ MessageSid: event.messageId,
507
+ Timestamp: Date.now(),
508
+ CommandAuthorized: true,
509
+ OriginatingChannel: "parall" as const,
510
+ OriginatingTo: `parall:orchestrator`,
511
+ });
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Input step + InboundHistory helpers for coalesced events
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /** Create input steps for earlier events in a coalesced batch (observability). */
519
+ async function createInputStepsForEarlierEvents(events: ParallEvent[]) {
520
+ const state = getParallAccountState(ctx.accountId);
521
+ const sessionId = state?.activeSessionId;
522
+ if (!sessionId) return;
523
+ await Promise.allSettled(events.map((ev) => {
524
+ const targetType = ev.targetId.startsWith("cht_") ? "chat" : ev.type === "task" ? "task" : "";
525
+ const triggerType = ev.type === "task" ? "task_assign" : "mention";
526
+ const triggerRef = ev.type === "task" ? { task_id: ev.targetId } : { message_id: ev.messageId };
527
+ return client.createAgentStep(config.org_id, agentUserId, sessionId, {
528
+ step_type: "input",
529
+ target_type: targetType,
530
+ target_id: ev.targetId.startsWith("cht_") ? ev.targetId : ev.type === "task" ? ev.targetId : undefined,
531
+ content: {
532
+ trigger_type: triggerType,
533
+ trigger_ref: triggerRef,
534
+ sender_id: ev.senderId,
535
+ sender_name: ev.senderName,
536
+ summary: ev.body.substring(0, 200),
537
+ },
538
+ }).catch((err: unknown) => {
539
+ log?.warn(`parall[${ctx.accountId}]: failed to create input step for ${ev.messageId}: ${String(err)}`);
540
+ });
541
+ }));
542
+ }
543
+
544
+ /** Build InboundHistory array from earlier events (for OpenClaw native context injection). */
545
+ function buildInboundHistory(events: ParallEvent[]): Array<{ sender: string; body: string }> {
546
+ return events.map((ev) => {
547
+ // Include metadata that would otherwise be lost in the plain body
548
+ const meta: string[] = [];
549
+ meta.push(`[Message ID: ${ev.messageId}]`);
550
+ if (ev.noReply) meta.push(`[Hint: no_reply]`);
551
+ if (ev.threadRootId) meta.push(`[Thread: ${ev.threadRootId}]`);
552
+ if (ev.mediaFields?.MediaUrl) {
553
+ meta.push(`[Attachment: ${ev.mediaFields.MediaType ?? "file"} ${ev.mediaFields.MediaUrl}]`);
554
+ }
555
+ return {
556
+ sender: ev.senderName,
557
+ body: meta.length ? `${meta.join(" ")}\n${ev.body}` : ev.body,
558
+ };
559
+ });
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Core dispatch: route event to main or fork session
564
+ // ---------------------------------------------------------------------------
565
+ async function runDispatch(
566
+ event: ParallEvent,
567
+ sessionKey: string,
568
+ bodyForAgent: string,
569
+ inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>,
570
+ ) {
571
+ // Set provenance maps for wiki tools
572
+ setSessionChatId(sessionKey, event.targetId);
573
+ setSessionMessageId(sessionKey, event.messageId);
574
+ setDispatchMessageId(sessionKey, event.messageId);
575
+
576
+ const inboundCtx = buildInboundCtx(event, sessionKey, bodyForAgent, inboundHistory);
577
+ const triggerType = event.type === "task" ? "task_assign" : "mention";
578
+ const triggerRef = event.type === "task"
579
+ ? { task_id: event.targetId }
580
+ : { message_id: event.messageId };
581
+ const isTask = event.type === "task";
582
+
583
+ const ok = await dispatchToAgent({
584
+ core,
585
+ cfg: ctx.cfg,
586
+ client,
587
+ config,
588
+ agentUserId,
589
+ accountId: ctx.accountId,
590
+ sessionKey,
591
+ messageId: event.messageId,
592
+ inboundCtx,
593
+ triggerType,
594
+ triggerRef,
595
+ chatId: event.targetId.startsWith("cht_") ? event.targetId : undefined,
596
+ defaultTargetType: isTask ? "task" : undefined,
597
+ defaultTargetId: isTask ? event.targetId : undefined,
598
+ senderId: event.senderId,
599
+ senderName: event.senderName,
600
+ messageBody: event.body,
601
+ log,
602
+ });
603
+ if (!ok) throw new Error(`dispatch failed for ${event.messageId}`);
604
+ }
605
+
606
+ /**
607
+ * Drain buffered events into main session with coalescing.
608
+ * When multiple events accumulate while main is busy, they are merged into a
609
+ * single combined prompt and dispatched once (one AgentRun), so the agent sees
610
+ * the full context instead of replying to stale messages one by one.
611
+ *
612
+ * IMPORTANT: Caller must hold mainDispatching=true. This function keeps the flag
613
+ * raised throughout the drain to prevent re-entrance from concurrent event handlers.
614
+ * The flag is only lowered when the buffer is fully exhausted.
615
+ */
616
+ let draining = false;
617
+ async function drainMainBuffer() {
618
+ // Re-entrance guard: if we're already draining (e.g. fork completed during
619
+ // a drain iteration), the outer loop will pick up new items naturally.
620
+ if (draining) return;
621
+ draining = true;
622
+ try {
623
+ while (dispatchState.mainBuffer.length > 0 || dispatchState.pendingForkResults.length > 0) {
624
+ // If we only have fork results but no events, create a synthetic wake-up
625
+ if (dispatchState.mainBuffer.length === 0 && dispatchState.pendingForkResults.length > 0) {
626
+ const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
627
+ const syntheticEvent: ParallEvent = {
628
+ type: "message",
629
+ targetId: "_orchestrator",
630
+ targetType: "system",
631
+ senderId: "system",
632
+ senderName: "system",
633
+ messageId: `synthetic-${Date.now()}`,
634
+ body: "[Orchestrator: fork session(s) completed — review results above]",
635
+ };
636
+ const bodyForAgent = forkPrefix + buildEventBody(syntheticEvent);
637
+ dispatchState.mainCurrentTargetId = undefined;
638
+ await runDispatch(syntheticEvent, orchestratorKey, bodyForAgent);
639
+ continue;
640
+ }
641
+
642
+ // Coalesce same-target events; leave other targets for the next iteration.
643
+ // Under backpressure (max forks), the buffer may contain mixed targets —
644
+ // coalescing across targets would couple unrelated provenance/noReply.
645
+ const targetId = dispatchState.mainBuffer[0].targetId;
646
+ const events: ParallEvent[] = [];
647
+ const rest: ParallEvent[] = [];
648
+ for (const ev of dispatchState.mainBuffer.splice(0)) {
649
+ (ev.targetId === targetId ? events : rest).push(ev);
650
+ }
651
+ dispatchState.mainBuffer.push(...rest);
652
+
653
+ const last = events[events.length - 1];
654
+ const earlier = events.slice(0, -1);
655
+
656
+ // Create input steps for earlier events (observability), then dispatch
657
+ // last event with InboundHistory populated from earlier events.
658
+ if (earlier.length) await createInputStepsForEarlierEvents(earlier);
659
+
660
+ const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
661
+ const bodyForAgent = forkPrefix + buildEventBody(last);
662
+ const inboundHistory = earlier.length ? buildInboundHistory(earlier) : undefined;
663
+
664
+ dispatchState.mainCurrentTargetId = last.targetId;
665
+ await runDispatch(last, orchestratorKey, bodyForAgent, inboundHistory);
666
+ // Ack all coalesced events
667
+ for (const ev of events) {
668
+ const sourceType = ev.type === "task" ? "task_activity" : "message";
669
+ client.ackDispatch(config.org_id, { source_type: sourceType, source_id: ev.messageId }).catch(() => {});
670
+ }
671
+ }
672
+ } finally {
673
+ draining = false;
674
+ dispatchState.mainDispatching = false;
675
+ dispatchState.mainCurrentTargetId = undefined;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Route and dispatch an inbound event.
681
+ * Returns true if the event was dispatched (main or fork), false if only buffered.
682
+ * Callers should only ack dispatch when this returns true — buffered events may be
683
+ * lost on crash and need to be replayed from dispatch on the next catch-up.
684
+ */
685
+ async function handleInboundEvent(event: ParallEvent): Promise<boolean> {
686
+ const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
687
+
688
+ switch (disposition.action) {
689
+ case "main": {
690
+ // Main session is idle — dispatch directly.
691
+ // Set mainDispatching BEFORE the await and keep it raised through
692
+ // drainMainBuffer() so no concurrent event can enter this case.
693
+ // drainMainBuffer() lowers the flag when the buffer is fully exhausted.
694
+ const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
695
+ const bodyForAgent = forkPrefix + buildEventBody(event);
696
+
697
+ dispatchState.mainDispatching = true;
698
+ dispatchState.mainCurrentTargetId = event.targetId;
699
+ try {
700
+ await runDispatch(event, orchestratorKey, bodyForAgent);
701
+ } finally {
702
+ // Drain any events that buffered while we were dispatching.
703
+ // mainDispatching stays true — drainMainBuffer owns releasing it.
704
+ await drainMainBuffer();
705
+ }
706
+ return true;
707
+ }
708
+
709
+ case "buffer-main":
710
+ dispatchState.mainBuffer.push(event);
711
+ return false;
712
+
713
+ case "buffer-fork": {
714
+ // Push event into the existing fork's queue. The fork drain loop will
715
+ // process it and resolve the promise when done. If the fork was cleaned
716
+ // up between the routing decision and here (race), fallback to buffer-main.
717
+ const activeFork = forkStates.get(event.targetId);
718
+ if (!activeFork) {
719
+ dispatchState.mainBuffer.push(event);
720
+ return false;
721
+ }
722
+ return new Promise<boolean>((resolve) => {
723
+ activeFork.queue.push({ event, resolve });
724
+ });
725
+ }
726
+
727
+ case "new-fork": {
728
+ // Fork the orchestrator's transcript and dispatch in parallel.
729
+ const transcriptFile = resolveTranscriptFile(sessionsDir, orchestratorKey);
730
+ const fork = transcriptFile
731
+ ? forkOrchestratorSession({
732
+ orchestratorSessionKey: orchestratorKey,
733
+ accountId: ctx.accountId,
734
+ transcriptFile,
735
+ sessionsDir,
736
+ })
737
+ : null;
738
+
739
+ if (fork) {
740
+ const activeFork: ActiveForkState = {
741
+ sessionKey: fork.sessionKey,
742
+ sessionFile: fork.sessionFile,
743
+ targetId: event.targetId,
744
+ queue: [],
745
+ processedEvents: [],
746
+ };
747
+ forkStates.set(event.targetId, activeFork);
748
+ dispatchState.activeForks.set(event.targetId, fork.sessionKey);
749
+
750
+ // Create promise for the first event, then start the drain loop
751
+ const firstEventPromise = new Promise<boolean>((resolve) => {
752
+ activeFork.queue.push({ event, resolve });
753
+ });
754
+ // Fire-and-forget — drain loop runs concurrently, callers await per-event promises
755
+ runForkDrainLoop(activeFork).catch((err) => {
756
+ log?.error(`parall[${ctx.accountId}]: fork drain loop error: ${String(err)}`);
757
+ });
758
+ return firstEventPromise;
759
+ } else {
760
+ log?.warn(`parall[${ctx.accountId}]: fork failed, buffering event for main session`);
761
+ dispatchState.mainBuffer.push(event);
762
+ return false;
763
+ }
764
+ }
765
+ }
766
+ return false;
767
+ }
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // WebSocket event handlers
771
+ // ---------------------------------------------------------------------------
772
+
773
+ ws.onStateChange((state) => {
774
+ log?.info(`parall[${ctx.accountId}]: connection state → ${state}`);
775
+ });
776
+
777
+ ws.on("hello", async (data: HelloData) => {
778
+ sessionId = data.session_id ?? "";
779
+ const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
780
+ try {
781
+ const count = await fetchAllChats(client, config.org_id, chatInfoMap);
782
+ log?.info(`parall[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
783
+
784
+ // Create AgentSession on first hello (session lives across dispatches).
785
+ // On WS reconnect, preserve the existing session — only create if none exists.
786
+ const existingState = getParallAccountState(ctx.accountId);
787
+ let activeSessionId: string | undefined = existingState?.activeSessionId;
788
+ if (!activeSessionId) {
789
+ try {
790
+ const session = await client.createAgentSession(config.org_id, agentUserId, {
791
+ runtime_type: "openclaw",
792
+ runtime_key: orchestratorKey,
793
+ runtime_ref: { hostname: os.hostname(), pid: process.pid },
794
+ });
795
+ activeSessionId = session.id;
796
+ log?.info(`parall[${ctx.accountId}]: created agent session ${session.id}`);
797
+ } catch (err) {
798
+ log?.warn(`parall[${ctx.accountId}]: failed to create agent session: ${String(err)}`);
799
+ }
800
+ }
801
+
802
+ setParallAccountState(ctx.accountId, {
803
+ client,
804
+ orgId: config.org_id,
805
+ agentUserId,
806
+ activeSessionId,
807
+ wikiMountRoot,
808
+ ws,
809
+ orchestratorSessionKey: orchestratorKey,
810
+ });
811
+
812
+ // (Re)start heartbeat
813
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
814
+ lastHeartbeatAt = Date.now();
815
+ heartbeatTimer = setInterval(() => {
816
+ const now = Date.now();
817
+ const expectedMs = intervalSec * 1000;
818
+ const drift = now - lastHeartbeatAt - expectedMs;
819
+ if (drift > 15000) {
820
+ log?.warn(`parall[${ctx.accountId}]: heartbeat drift ${drift}ms — event loop may be blocked`);
821
+ }
822
+ lastHeartbeatAt = now;
823
+ if (ws.state !== "connected") return;
824
+ ws.sendAgentHeartbeat(sessionId, {
825
+ hostname: os.hostname(),
826
+ cores: os.cpus().length,
827
+ mem_total: os.totalmem(),
828
+ mem_free: os.freemem(),
829
+ uptime: os.uptime(),
830
+ });
831
+ }, intervalSec * 1000);
832
+
833
+ // Dispatch catch-up: fetch pending dispatch events (FIFO).
834
+ // On cold start, skip items older than COLD_START_WINDOW_MS to avoid
835
+ // replaying historical backlog from a previous process.
836
+ const isFirstHello = !hadSuccessfulHello;
837
+ hadSuccessfulHello = true;
838
+ catchUpFromDispatch(isFirstHello).catch((err) => {
839
+ log?.warn(`parall[${ctx.accountId}]: dispatch catch-up failed: ${String(err)}`);
840
+ });
841
+ } catch (err) {
842
+ log?.error(`parall[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
843
+ }
844
+ });
845
+
846
+ ws.on("chat.update", (data: ChatUpdateData) => {
847
+ const changes = data.changes as Record<string, unknown> | undefined;
848
+ if (!changes) return;
849
+ const existing = chatInfoMap.get(data.chat_id);
850
+ if (existing) {
851
+ chatInfoMap.set(data.chat_id, {
852
+ ...existing,
853
+ ...(typeof changes.type === "string" ? { type: changes.type as Chat["type"] } : {}),
854
+ ...(typeof changes.name === "string" ? { name: changes.name } : {}),
855
+ ...(typeof changes.agent_routing_mode === "string" ? { agentRoutingMode: changes.agent_routing_mode as Chat["agent_routing_mode"] } : {}),
856
+ });
857
+ } else if (typeof changes.type === "string") {
858
+ chatInfoMap.set(data.chat_id, {
859
+ type: changes.type as Chat["type"],
860
+ name: (typeof changes.name === "string" ? changes.name : null),
861
+ agentRoutingMode: (typeof changes.agent_routing_mode === "string" ? changes.agent_routing_mode : "passive") as Chat["agent_routing_mode"],
862
+ });
863
+ }
864
+ });
865
+
866
+ // Handle inbound messages (text + file)
867
+ ws.on("message.new", async (data: MessageNewData) => {
868
+ if (data.sender_id === agentUserId) return;
869
+ if (data.message_type !== "text" && data.message_type !== "file") return;
870
+ // Dedupe: claim this message so catch-up won't re-dispatch it
871
+ if (!tryClaimMessage(data.id)) return;
872
+
873
+ const chatId = data.chat_id;
874
+
875
+ // Resolve chat info — query API for unknown chats
876
+ let chatInfo = chatInfoMap.get(chatId);
877
+ if (!chatInfo) {
878
+ try {
879
+ const chat = await client.getChat(config.org_id, chatId);
880
+ chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
881
+ chatInfoMap.set(chatId, chatInfo);
882
+ } catch (err) {
883
+ log?.warn(`parall[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
884
+ return;
885
+ }
886
+ }
887
+
888
+ // Build body text and optional media fields
889
+ let body = "";
890
+ let mediaFields: Record<string, string | undefined> = {};
891
+
892
+ if (data.message_type === "text") {
893
+ const content = data.content as TextContent;
894
+ body = content.text?.trim() ?? "";
895
+ if (!body) return;
896
+
897
+ // In group chats, respond based on agent_routing_mode:
898
+ // - passive (default): only when @mentioned or @all
899
+ // - active: all messages dispatched via live handler
900
+ // - smart: skip live handler — server decides routing via inbox items
901
+ if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
902
+ const mentions = content.mentions ?? [];
903
+ const isMentioned = mentions.some(
904
+ (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
905
+ );
906
+ if (!isMentioned) {
907
+ // Release claim so catch-up can process this via dispatch events
908
+ dispatchedMessages.delete(data.id);
909
+ return;
910
+ }
911
+ }
912
+ } else {
913
+ const content = data.content as MediaContent;
914
+ if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
915
+ dispatchedMessages.delete(data.id);
916
+ return;
917
+ }
918
+ body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
919
+ if (content.attachment_id) {
920
+ try {
921
+ const fileRes = await client.getFileUrl(content.attachment_id);
922
+ mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
923
+ } catch (err) {
924
+ log?.warn(`parall[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
925
+ }
926
+ }
927
+ }
928
+
929
+ const event: ParallEvent = {
930
+ type: "message",
931
+ targetId: chatId,
932
+ targetName: chatInfo.name ?? undefined,
933
+ targetType: chatInfo.type,
934
+ senderId: data.sender_id,
935
+ senderName: data.sender?.display_name ?? data.sender_id,
936
+ messageId: data.id,
937
+ body,
938
+ threadRootId: data.thread_root_id ?? undefined,
939
+ noReply: data.hints?.no_reply ?? false,
940
+ mediaFields: Object.keys(mediaFields).length > 0 ? mediaFields : undefined,
941
+ };
942
+
943
+ // Start typing for UX feedback. For buffer-main events (main is busy with
944
+ // same target), typing persists until the buffer drains.
945
+ // For fork events, typing persists until the fork dispatch completes.
946
+ // Both handleInboundEvent paths await their dispatches, so finally works correctly.
947
+ const willDispatch = !dispatchState.mainDispatching || dispatchState.mainCurrentTargetId !== chatId;
948
+ if (willDispatch) startTyping(chatId);
949
+ try {
950
+ const dispatched = await handleInboundEvent(event);
951
+ if (dispatched) {
952
+ client.ackDispatch(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
953
+ } else {
954
+ // Not dispatched (buffered or fork failed) — release claim so catch-up can retry
955
+ dispatchedMessages.delete(data.id);
956
+ }
957
+ } catch (err) {
958
+ log?.error(`parall[${ctx.accountId}]: event dispatch failed for ${data.id}: ${String(err)}`);
959
+ dispatchedMessages.delete(data.id);
960
+ } finally {
961
+ if (willDispatch) stopTyping(chatId);
962
+ }
963
+ });
964
+
965
+ // Re-apply platform config on server push
966
+ ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
967
+ log?.info(`parall[${ctx.accountId}]: config update notification (version=${data.version})`);
968
+ try {
969
+ await fetchAndApplyPlatformConfig(configManagerOpts);
970
+ } catch (err) {
971
+ log?.warn(`parall[${ctx.accountId}]: config update failed: ${String(err)}`);
972
+ }
973
+ });
974
+
975
+ // Task assignment handler
976
+ async function handleTaskAssignment(task: Task): Promise<boolean> {
977
+ const dedupeKey = `${task.id}:${task.updated_at}`;
978
+ if (dispatchedTasks.has(dedupeKey)) {
979
+ log?.info(`parall[${ctx.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
980
+ return false;
981
+ }
982
+ dispatchedTasks.add(dedupeKey);
983
+ log?.info(`parall[${ctx.accountId}]: task assigned: ${task.identifier ?? task.id} "${task.title}"`);
984
+
985
+ const parts = [`Title: ${task.title}`];
986
+ parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
987
+ if (task.description) parts.push("", task.description);
988
+
989
+ const event: ParallEvent = {
990
+ type: "task",
991
+ targetId: task.id,
992
+ targetName: task.identifier ?? undefined,
993
+ targetType: "task",
994
+ senderId: task.creator_id,
995
+ senderName: "system",
996
+ messageId: task.id,
997
+ body: parts.join("\n"),
998
+ };
999
+
1000
+ const dispatched = await handleInboundEvent(event);
1001
+ if (!dispatched) {
1002
+ // Buffered, not yet dispatched — release dedupe so catch-up can retry
1003
+ dispatchedTasks.delete(dedupeKey);
1004
+ }
1005
+ return dispatched;
1006
+ }
1007
+
1008
+ // Server event buffer overflowed — events were lost, trigger full catch-up
1009
+ ws.on("recovery.overflow", () => {
1010
+ log?.warn(`parall[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
1011
+ catchUpFromDispatch().catch((err) =>
1012
+ log?.warn(`parall[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
1013
+ });
1014
+
1015
+ ws.on("task.assigned", async (data: TaskAssignedData) => {
1016
+ if (data.assignee_id !== agentUserId) return;
1017
+ if (data.status !== "todo" && data.status !== "in_progress") return;
1018
+ try {
1019
+ const dispatched = await handleTaskAssignment(data);
1020
+ if (dispatched) {
1021
+ client.ackDispatch(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
1022
+ }
1023
+ } catch (err) {
1024
+ log?.error(`parall[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
1025
+ }
1026
+ });
1027
+
1028
+ // ── Dispatch catch-up ──
1029
+ // Replays missed events via the dispatch API (agent-only endpoint, FIFO order).
1030
+ // Each dispatch event is a source-pointer; we re-fetch the entity to build a ParallEvent.
1031
+ //
1032
+ // Cold-start window: On a fresh process start (not reconnect), events older than
1033
+ // COLD_START_WINDOW_MS are acked without processing. This is an intentional operator
1034
+ // decision: a newly started agent should not replay unbounded historical backlog from
1035
+ // a previous process lifetime. Reconnects (non-cold-start) replay all pending events.
1036
+ //
1037
+ // NOTE: RouteToAgents (active/smart group routing) inserts dispatch rows asynchronously
1038
+ // after the message.new WS event. A race exists where the live ack-by-source fires
1039
+ // before the dispatch row is inserted, leaving an orphan pending row. This is benign:
1040
+ // catch-up will process it on next cycle (duplicate delivery, not data loss).
1041
+ // TODO: resolve by creating dispatch rows before publishing the live event, or by
1042
+ // consuming dispatch.new in the live path.
1043
+ async function catchUpFromDispatch(coldStart = false) {
1044
+ const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
1045
+ let cursor: string | undefined;
1046
+ let processed = 0;
1047
+ let skippedOld = 0;
1048
+
1049
+ do {
1050
+ const page = await client.getDispatch(config.org_id, {
1051
+ limit: 50,
1052
+ cursor,
1053
+ });
1054
+
1055
+ for (const item of page.data ?? []) {
1056
+ if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
1057
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
1058
+ skippedOld++;
1059
+ continue;
1060
+ }
1061
+
1062
+ processed++;
1063
+ try {
1064
+ let dispatched = false;
1065
+ if (item.event_type === "task_assign" && item.task_id) {
1066
+ // Task catch-up: fetch full task and dispatch
1067
+ let task: Awaited<ReturnType<typeof client.getTask>> | null = null;
1068
+ let taskFetchFailed = false;
1069
+ try {
1070
+ task = await client.getTask(config.org_id, item.task_id);
1071
+ } catch (err: unknown) {
1072
+ // Distinguish 404 (task deleted) from transient failures
1073
+ const status = (err as { status?: number })?.status;
1074
+ if (status === 404) {
1075
+ task = null; // task genuinely deleted
1076
+ } else {
1077
+ taskFetchFailed = true;
1078
+ log?.warn(`parall[${ctx.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
1079
+ }
1080
+ }
1081
+ if (taskFetchFailed) {
1082
+ continue; // leave pending for next catch-up cycle
1083
+ }
1084
+ if (task) {
1085
+ // Defense-in-depth (D2): verify task is still assigned to this agent
1086
+ if (task.assignee_id !== agentUserId) {
1087
+ log?.info(`parall[${ctx.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
1088
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
1089
+ continue;
1090
+ }
1091
+ dispatched = await handleTaskAssignment(task);
1092
+ } else {
1093
+ dispatched = true; // task genuinely deleted — ack to prevent infinite retry
1094
+ }
1095
+ } else if (item.event_type === "message" && item.source_id && item.chat_id) {
1096
+ // Message catch-up: build ParallEvent from dispatch source pointer
1097
+ if (!tryClaimMessage(item.source_id)) continue;
1098
+ let msg: Awaited<ReturnType<typeof client.getMessage>> | null = null;
1099
+ let msgFetchFailed = false;
1100
+ try {
1101
+ msg = await client.getMessage(item.source_id);
1102
+ } catch (err: unknown) {
1103
+ const status = (err as { status?: number })?.status;
1104
+ if (status === 404) {
1105
+ msg = null; // message genuinely deleted
1106
+ } else {
1107
+ msgFetchFailed = true;
1108
+ log?.warn(`parall[${ctx.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
1109
+ }
1110
+ }
1111
+ if (msgFetchFailed) {
1112
+ dispatchedMessages.delete(item.source_id);
1113
+ continue; // leave pending for next catch-up cycle
1114
+ }
1115
+ if (!msg || msg.sender_id === agentUserId) {
1116
+ dispatchedMessages.delete(item.source_id);
1117
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
1118
+ continue;
1119
+ }
1120
+
1121
+ let chatInfo = chatInfoMap.get(item.chat_id);
1122
+ if (!chatInfo) {
1123
+ try {
1124
+ const chat = await client.getChat(config.org_id, item.chat_id);
1125
+ chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
1126
+ chatInfoMap.set(item.chat_id, chatInfo);
1127
+ } catch {
1128
+ dispatchedMessages.delete(item.source_id);
1129
+ continue;
1130
+ }
1131
+ }
1132
+
1133
+ let body = "";
1134
+ let catchUpMediaFields: Record<string, string | undefined> = {};
1135
+ if (msg.message_type === "text") {
1136
+ body = (msg.content as TextContent).text?.trim() ?? "";
1137
+ } else if (msg.message_type === "file") {
1138
+ const fc = msg.content as MediaContent;
1139
+ body = fc.caption?.trim() || `[file: ${fc.file_name || "attachment"}]`;
1140
+ if (fc.attachment_id) {
1141
+ try {
1142
+ const fileRes = await client.getFileUrl(fc.attachment_id);
1143
+ catchUpMediaFields = { MediaUrl: fileRes.url, MediaType: fc.mime_type };
1144
+ } catch { /* degrade gracefully */ }
1145
+ }
1146
+ }
1147
+ if (!body) {
1148
+ dispatchedMessages.delete(item.source_id);
1149
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
1150
+ continue;
1151
+ }
1152
+
1153
+ const event: ParallEvent = {
1154
+ type: "message",
1155
+ targetId: item.chat_id,
1156
+ targetName: chatInfo.name ?? undefined,
1157
+ targetType: chatInfo.type,
1158
+ senderId: msg.sender_id,
1159
+ senderName: msg.sender?.display_name ?? msg.sender_id,
1160
+ messageId: msg.id,
1161
+ body,
1162
+ threadRootId: msg.thread_root_id ?? undefined,
1163
+ noReply: msg.hints?.no_reply ?? false,
1164
+ mediaFields: Object.keys(catchUpMediaFields).length > 0 ? catchUpMediaFields : undefined,
1165
+ };
1166
+ dispatched = await handleInboundEvent(event);
1167
+ }
1168
+ if (dispatched) {
1169
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
1170
+ }
1171
+ } catch (err) {
1172
+ log?.warn(`parall[${ctx.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
1173
+ }
1174
+ }
1175
+ cursor = page.has_more ? page.next_cursor : undefined;
1176
+ } while (cursor);
1177
+
1178
+ if (processed > 0 || skippedOld > 0) {
1179
+ log?.info(`parall[${ctx.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
1180
+ }
1181
+ }
1182
+
1183
+ log?.info(`parall[${ctx.accountId}]: connecting to ${wsUrl}...`);
1184
+ await ws.connect();
1185
+
1186
+ // Keep the gateway alive until aborted; clean up before resolving
1187
+ return new Promise<void>((resolve) => {
1188
+ ctx.abortSignal.addEventListener("abort", async () => {
1189
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
1190
+ for (const [, dispatch] of activeDispatches) {
1191
+ clearInterval(dispatch.typingTimer);
1192
+ }
1193
+ activeDispatches.clear();
1194
+ stopWikiHelper?.();
1195
+
1196
+ // Complete the agent session
1197
+ const currentState = getParallAccountState(ctx.accountId);
1198
+ if (currentState?.activeSessionId) {
1199
+ try {
1200
+ await client.updateAgentSession(config.org_id, agentUserId, currentState.activeSessionId, {
1201
+ status: "completed",
1202
+ });
1203
+ } catch (err) {
1204
+ log?.warn(`parall[${ctx.accountId}]: failed to complete session: ${String(err)}`);
1205
+ }
1206
+ }
1207
+
1208
+ ws.disconnect();
1209
+ removeParallAccountState(ctx.accountId);
1210
+ for (const [key, value] of Object.entries(previousEnv)) {
1211
+ if (value === undefined) {
1212
+ delete process.env[key];
1213
+ } else {
1214
+ process.env[key] = value;
1215
+ }
1216
+ }
1217
+ log?.info(`parall[${ctx.accountId}]: disconnected`);
1218
+ resolve();
1219
+ });
1220
+ });
1221
+ },
1222
+ };