@parall/parall 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gateway.ts CHANGED
@@ -3,8 +3,13 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
3
3
 
4
4
  /** Extract ChannelGatewayAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
5
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";
6
+ import {
7
+ ParallAgentGateway,
8
+ type DispatchAdapter,
9
+ type ParallEvent,
10
+ type RuntimeEvent,
11
+ } from "@parall/agent-core";
12
+ import { ParallClient, ParallWs } from "@parall/sdk";
8
13
  import type { ParallChannelConfig } from "./types.js";
9
14
  import * as crypto from "node:crypto";
10
15
  import * as os from "node:os";
@@ -12,22 +17,15 @@ import * as path from "node:path";
12
17
  import { resolveParallAccount } from "./accounts.js";
13
18
  import {
14
19
  getParallRuntime,
15
- setParallAccountState,
16
- getParallAccountState,
17
20
  removeParallAccountState,
18
- setSessionChatId,
19
- setSessionMessageId,
20
- setDispatchMessageId,
21
21
  setDispatchGroupKey,
22
- clearDispatchGroupKey,
22
+ setParallAccountState,
23
23
  } from "./runtime.js";
24
- import type { ParallEvent, DispatchState, ForkResult } from "./runtime.js";
25
24
  import { buildOrchestratorSessionKey } from "./session.js";
26
25
  import type { ResolvedParallAccount } from "./types.js";
27
26
  import { fetchAndApplyPlatformConfig } from "./config-manager.js";
28
27
  import { startWikiHelper } from "./wiki-helper.js";
29
- import { routeTrigger, defaultRoutingStrategy } from "./routing.js";
30
- import { forkOrchestratorSession, cleanupForkSession, resolveTranscriptFile } from "./fork.js";
28
+ import { cleanupForkSession, forkOrchestratorSession, resolveTranscriptFile } from "./fork.js";
31
29
 
32
30
  function resolveWsUrl(account: ResolvedParallAccount): string {
33
31
  if (account.config.ws_url) return account.config.ws_url;
@@ -36,213 +34,186 @@ function resolveWsUrl(account: ResolvedParallAccount): string {
36
34
  return `${wsBase}/ws`;
37
35
  }
38
36
 
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;
37
+ function buildInboundCtx(
38
+ core: PluginRuntime,
39
+ accountId: string,
40
+ event: ParallEvent,
41
+ sessionKey: string,
42
+ bodyForAgent: string,
43
+ earlierEvents: ParallEvent[] = [],
44
+ ): Record<string, unknown> {
45
+ const inboundHistory = earlierEvents.length > 0 ? buildInboundHistory(earlierEvents) : undefined;
46
+ return core.channel.reply.finalizeInboundContext({
47
+ Body: event.body,
48
+ BodyForAgent: bodyForAgent,
49
+ RawBody: event.body,
50
+ CommandBody: event.body,
51
+ ...(event.mediaFields ?? {}),
52
+ ...(inboundHistory?.length ? { InboundHistory: inboundHistory } : {}),
53
+ From: `parall:${event.senderId}`,
54
+ To: `parall:orchestrator`,
55
+ SessionKey: sessionKey,
56
+ AccountId: accountId,
57
+ ChatType: event.targetType ?? "unknown",
58
+ SenderName: event.senderName,
59
+ SenderId: event.senderId,
60
+ Provider: "parall" as const,
61
+ Surface: "parall" as const,
62
+ MessageSid: event.messageId,
63
+ Timestamp: Date.now(),
64
+ CommandAuthorized: true,
65
+ OriginatingChannel: "parall" as const,
66
+ OriginatingTo: `parall:orchestrator`,
67
+ });
60
68
  }
61
69
 
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]`);
70
+ function buildInboundHistory(events: ParallEvent[]): Array<{ sender: string; body: string }> {
71
+ return events.map((event) => {
72
+ const meta: string[] = [];
73
+ meta.push(`[Message ID: ${event.messageId}]`);
74
+ if (event.noReply) meta.push(`[Hint: no_reply]`);
75
+ if (event.threadRootId) meta.push(`[Thread: ${event.threadRootId}]`);
78
76
  if (event.mediaFields?.MediaUrl) {
79
- lines.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
77
+ meta.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
80
78
  }
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");
79
+ return {
80
+ sender: event.senderName,
81
+ body: meta.length > 0 ? `${meta.join(" ")}\n${event.body}` : event.body,
82
+ };
101
83
  });
102
- return blocks.join("\n\n") + "\n\n---\n\n";
103
84
  }
104
85
 
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)}`);
86
+ function createRuntimeEventStream() {
87
+ const queue: RuntimeEvent[] = [];
88
+ const waiters: Array<{
89
+ resolve: (value: RuntimeEvent | undefined) => void;
90
+ reject: (reason?: unknown) => void;
91
+ }> = [];
92
+ let done = false;
93
+ let failure: unknown;
94
+
95
+ function flush() {
96
+ while (waiters.length > 0) {
97
+ if (failure) {
98
+ waiters.shift()!.reject(failure);
99
+ continue;
100
+ }
101
+ if (queue.length > 0) {
102
+ waiters.shift()!.resolve(queue.shift());
103
+ continue;
104
+ }
105
+ if (done) {
106
+ waiters.shift()!.resolve(undefined);
107
+ continue;
149
108
  }
109
+ break;
150
110
  }
111
+ }
151
112
 
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
- },
113
+ return {
114
+ push(event: RuntimeEvent) {
115
+ queue.push(event);
116
+ flush();
117
+ },
118
+ end() {
119
+ done = true;
120
+ flush();
121
+ },
122
+ fail(err: unknown) {
123
+ failure = err;
124
+ flush();
125
+ },
126
+ async *stream(): AsyncGenerator<RuntimeEvent> {
127
+ while (true) {
128
+ if (queue.length > 0) {
129
+ yield queue.shift()!;
130
+ continue;
131
+ }
132
+ if (failure) throw failure;
133
+ if (done) return;
134
+
135
+ const next = await new Promise<RuntimeEvent | undefined>((resolve, reject) => {
136
+ waiters.push({ resolve, reject });
168
137
  });
169
- } catch (err) {
170
- log?.warn(`parall[${accountId}]: failed to create input step: ${String(err)}`);
138
+ if (!next) return;
139
+ yield next;
171
140
  }
172
- }
141
+ },
142
+ };
143
+ }
173
144
 
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 },
145
+ function createOpenClawDispatchAdapter(opts: {
146
+ core: PluginRuntime;
147
+ cfg: Record<string, unknown>;
148
+ accountId: string;
149
+ sessionsDir: string;
150
+ }): DispatchAdapter {
151
+ return {
152
+ async *dispatch({ event, earlierEvents = [], bodyForAgent, sessionKey }) {
153
+ const stream = createRuntimeEventStream();
154
+ let reasoningBuffer = "";
155
+ let turnGroupKey = "";
156
+
157
+ const run = opts.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
158
+ ctx: buildInboundCtx(opts.core, opts.accountId, event, sessionKey, bodyForAgent, earlierEvents),
159
+ cfg: opts.cfg,
160
+ dispatcherOptions: {
161
+ deliver: async (payload: { text?: string }) => {
162
+ const replyText = payload.text?.trim();
163
+ if (!replyText) return;
164
+ stream.push({
165
+ type: "text",
166
+ text: replyText,
167
+ project: false,
168
+ groupKey: turnGroupKey || undefined,
192
169
  });
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 ?? "";
170
+ },
171
+ onReplyStart: () => {
172
+ turnGroupKey = crypto.randomUUID();
173
+ setDispatchGroupKey(sessionKey, turnGroupKey);
174
+ },
206
175
  },
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 = "";
176
+ replyOptions: {
177
+ onReasoningStream: (payload: { text?: string }) => {
178
+ reasoningBuffer += payload.text ?? "";
179
+ },
180
+ onReasoningEnd: async () => {
181
+ if (!reasoningBuffer) return;
182
+ stream.push({
183
+ type: "thinking",
184
+ text: reasoningBuffer,
185
+ groupKey: turnGroupKey || undefined,
186
+ });
187
+ reasoningBuffer = "";
188
+ },
224
189
  },
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
- }
190
+ });
242
191
 
243
- // ---------------------------------------------------------------------------
244
- // Gateway
245
- // ---------------------------------------------------------------------------
192
+ run.then(() => stream.end()).catch((err) => stream.fail(err));
193
+ yield* stream.stream();
194
+ },
195
+
196
+ forkSession({ sessionKey }) {
197
+ const transcriptFile = resolveTranscriptFile(opts.sessionsDir, sessionKey);
198
+ if (!transcriptFile) return null;
199
+ return forkOrchestratorSession({
200
+ orchestratorSessionKey: sessionKey,
201
+ accountId: opts.accountId,
202
+ transcriptFile,
203
+ sessionsDir: opts.sessionsDir,
204
+ });
205
+ },
206
+
207
+ cleanupFork({ fork }) {
208
+ if (typeof fork.sessionFile !== "string") return;
209
+ cleanupForkSession({
210
+ sessionFile: fork.sessionFile,
211
+ sessionKey: fork.sessionKey,
212
+ sessionsDir: opts.sessionsDir,
213
+ });
214
+ },
215
+ };
216
+ }
246
217
 
247
218
  export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
248
219
  startAccount: async (ctx) => {
@@ -259,25 +230,13 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
259
230
  token: config.api_key,
260
231
  });
261
232
 
262
- // Resolve agent's own user ID
263
233
  const me = await client.getMe();
264
234
  const agentUserId = me.id;
265
235
  log?.info(`parall[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
266
236
 
267
- // Apply platform config (models, defaults)
268
237
  const stateDir = process.env.OPENCLAW_STATE_DIR
269
238
  || path.join(process.env.HOME || "/data", ".openclaw");
270
239
  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
240
 
282
241
  const configManagerOpts = {
283
242
  client,
@@ -311,496 +270,44 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
311
270
  log?.warn(`parall[${ctx.accountId}]: wiki helper startup failed: ${String(err)}`);
312
271
  }
313
272
 
314
- // Connect WebSocket via ticket auth (reconnection enabled by default in ParallWs)
315
273
  const wsUrl = resolveWsUrl(account);
316
274
  const ws = new ParallWs({
317
275
  getTicket: () => client.getWsTicket(),
318
276
  wsUrl,
319
277
  });
320
-
321
- // Orchestrator session key — single session for all Parall events
322
278
  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
279
  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}`);
280
+ const dispatchAdapter = createOpenClawDispatchAdapter({
281
+ core,
282
+ cfg: ctx.cfg,
283
+ accountId: ctx.accountId,
284
+ sessionsDir,
775
285
  });
776
286
 
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
-
287
+ const gateway = new ParallAgentGateway({
288
+ accountId: ctx.accountId,
289
+ client,
290
+ ws,
291
+ connectionLabel: wsUrl,
292
+ config: {
293
+ parall_url: config.parall_url,
294
+ api_key: config.api_key,
295
+ org_id: config.org_id,
296
+ },
297
+ agentUserId,
298
+ runtimeType: "openclaw",
299
+ runtimeKey: orchestratorKey,
300
+ runtimeRef: { hostname: os.hostname(), pid: process.pid },
301
+ dispatchAdapter,
302
+ log,
303
+ onConfigUpdate: async () => {
304
+ await fetchAndApplyPlatformConfig(configManagerOpts);
305
+ },
306
+ onSessionReady: async ({ activeSessionId }) => {
802
307
  setParallAccountState(ctx.accountId, {
803
308
  client,
309
+ apiUrl: config.parall_url,
310
+ apiKey: config.api_key,
804
311
  orgId: config.org_id,
805
312
  agentUserId,
806
313
  activeSessionId,
@@ -808,415 +315,20 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
808
315
  ws,
809
316
  orchestratorSessionKey: orchestratorKey,
810
317
  });
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
- }
318
+ },
319
+ onBeforeDisconnect: async () => {
320
+ stopWikiHelper?.();
321
+ removeParallAccountState(ctx.accountId);
322
+ },
1026
323
  });
1027
324
 
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();
325
+ try {
326
+ await gateway.run(ctx.abortSignal);
327
+ } finally {
328
+ if (!ctx.abortSignal.aborted) {
1194
329
  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
330
  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
- });
331
+ }
332
+ }
1221
333
  },
1222
334
  };