@clawhive/openclaw-plugin 0.2.1 → 0.2.3

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/dist/index.js CHANGED
@@ -151,6 +151,18 @@ async function startRuntime(api) {
151
151
  pluginNodeId,
152
152
  userId: resolvedUserId,
153
153
  onWake: (event) => {
154
+ if (event.taskType === "single_agent_message") {
155
+ const sessionId = typeof event.payload.sessionId === "string"
156
+ ? event.payload.sessionId
157
+ : null;
158
+ if (sessionId) {
159
+ const sequence = typeof event.payload.sequence === "number"
160
+ ? event.payload.sequence
161
+ : 1;
162
+ void transport.catchUpSession(sessionId, Math.max(sequence - 1, 0));
163
+ }
164
+ return;
165
+ }
154
166
  if (event.taskType === "agent_panel_turn") {
155
167
  agentPanelLoop.wake();
156
168
  return;
@@ -304,8 +316,10 @@ const clawhiveEntry = defineChannelPluginEntry({
304
316
  name: "ClawHive",
305
317
  description: "ClawHive channel plugin",
306
318
  plugin: clawhiveChannelPlugin,
307
- registerFull(api) {
319
+ registerCliMetadata(api) {
308
320
  registerClawHiveCli(api);
321
+ },
322
+ registerFull(api) {
309
323
  api.on("gateway_start", async () => {
310
324
  try {
311
325
  await startRuntime(api);
@@ -28,15 +28,19 @@ export declare class RealtimeTransport implements TransportAdapter {
28
28
  private reconnectTimer;
29
29
  private session;
30
30
  private readonly sessionHighWaterMark;
31
+ private readonly inFlightMessageSequences;
31
32
  constructor(options: RealtimeTransportOptions);
32
33
  getStatus(): TransportStatus;
33
34
  getClient(): SupabaseClient;
34
35
  start(handlers: TransportHandlers): Promise<void>;
35
36
  stop(): Promise<void>;
36
37
  private connect;
37
- private handleRow;
38
+ private handleMessageBroadcast;
38
39
  private dispatchInbound;
39
- private catchUpSession;
40
+ catchUpSession(sessionId: string, afterSequence: number, beforeSequence?: number): Promise<void>;
41
+ recoverPendingUserMessages(): Promise<void>;
42
+ private recoverPendingSessionMessages;
43
+ private dispatchUserMessageOnce;
40
44
  private ensureFreshSession;
41
45
  /**
42
46
  * Recover from an invalidated refresh token by re-registering the plugin node.
@@ -3,6 +3,8 @@ import WebSocket from "ws";
3
3
  import { inboundMessageFromRow, } from "../transport.js";
4
4
  import { addPluginBreadcrumb, capturePluginError } from "../telemetry.js";
5
5
  const DEFAULT_MAX_BACKOFF_MS = 30_000;
6
+ const PENDING_SESSION_RECOVERY_LIMIT = 25;
7
+ const PENDING_MESSAGE_LOOKBACK_LIMIT = 50;
6
8
  const NodeWebSocketTransport = WebSocket;
7
9
  export class RealtimeTransport {
8
10
  id = "clawhive-realtime";
@@ -16,6 +18,7 @@ export class RealtimeTransport {
16
18
  reconnectTimer = null;
17
19
  session;
18
20
  sessionHighWaterMark = new Map();
21
+ inFlightMessageSequences = new Set();
19
22
  constructor(options) {
20
23
  this.options = options;
21
24
  this.session = options.userSession;
@@ -79,14 +82,11 @@ export class RealtimeTransport {
79
82
  });
80
83
  await this.client.realtime.setAuth(this.session.access_token);
81
84
  const channel = this.client
82
- .channel(`plugin:clawhive:${this.options.userId}`)
83
- .on("postgres_changes", {
84
- event: "INSERT",
85
- schema: "public",
86
- table: "messages",
87
- filter: `user_id=eq.${this.options.userId}`,
88
- }, (payload) => {
89
- void this.handleRow(payload.new);
85
+ .channel(`user:${this.options.userId}`, {
86
+ config: { private: true },
87
+ })
88
+ .on("broadcast", { event: "message_insert" }, (payload) => {
89
+ void this.handleMessageBroadcast(payload);
90
90
  });
91
91
  this.channel = channel;
92
92
  await new Promise((resolve, reject) => {
@@ -98,6 +98,7 @@ export class RealtimeTransport {
98
98
  void (async () => {
99
99
  try {
100
100
  await this.options.onClientReady?.(this.getClient());
101
+ await this.recoverPendingUserMessages();
101
102
  }
102
103
  catch (error) {
103
104
  reject(error);
@@ -138,20 +139,23 @@ export class RealtimeTransport {
138
139
  });
139
140
  });
140
141
  }
141
- async handleRow(row) {
142
+ async handleMessageBroadcast(envelope) {
142
143
  if (!this.handlers)
143
144
  return;
144
- if (row.role !== "user")
145
+ const messageRef = parseMessageBroadcastPayload(envelope);
146
+ if (!messageRef)
145
147
  return;
146
- const previousSequence = this.sessionHighWaterMark.get(row.session_id) ?? 0;
147
- if (row.sequence <= previousSequence)
148
+ if (messageRef.userId !== this.options.userId)
148
149
  return;
149
- const gap = row.sequence - previousSequence;
150
- if (previousSequence > 0 && gap > 1) {
151
- await this.catchUpSession(row.session_id, previousSequence, row.sequence);
152
- }
153
- await this.dispatchInbound(inboundMessageFromRow(row));
154
- this.sessionHighWaterMark.set(row.session_id, row.sequence);
150
+ if (messageRef.role !== "user")
151
+ return;
152
+ const previousSequence = this.sessionHighWaterMark.get(messageRef.sessionId) ?? 0;
153
+ if (messageRef.sequence <= previousSequence)
154
+ return;
155
+ const afterSequence = previousSequence > 0
156
+ ? previousSequence
157
+ : Math.max(0, messageRef.sequence - 1);
158
+ await this.catchUpSession(messageRef.sessionId, afterSequence);
155
159
  }
156
160
  async dispatchInbound(message) {
157
161
  try {
@@ -178,8 +182,7 @@ export class RealtimeTransport {
178
182
  continue;
179
183
  if (item.role !== "user")
180
184
  continue;
181
- await this.dispatchInbound(inboundMessageFromRow(item));
182
- this.sessionHighWaterMark.set(item.session_id, item.sequence);
185
+ await this.dispatchUserMessageOnce(item);
183
186
  }
184
187
  }
185
188
  catch (error) {
@@ -188,6 +191,73 @@ export class RealtimeTransport {
188
191
  capturePluginError(error, "realtime-catch-up");
189
192
  }
190
193
  }
194
+ async recoverPendingUserMessages() {
195
+ if (!this.client || !this.handlers)
196
+ return;
197
+ try {
198
+ const { data: sessions, error } = await this.client
199
+ .from("sessions")
200
+ .select("id")
201
+ .eq("user_id", this.options.userId)
202
+ .eq("session_type", "single_agent")
203
+ .is("archived_at", null)
204
+ .order("last_message_at", { ascending: false, nullsFirst: false })
205
+ .limit(PENDING_SESSION_RECOVERY_LIMIT);
206
+ if (error) {
207
+ throw error;
208
+ }
209
+ for (const session of sessions ?? []) {
210
+ if (!isRecord(session) || typeof session.id !== "string") {
211
+ continue;
212
+ }
213
+ await this.recoverPendingSessionMessages(session.id);
214
+ }
215
+ }
216
+ catch (error) {
217
+ const reason = error instanceof Error ? error.message : String(error);
218
+ this.options.logger?.warn(`ClawHive realtime pending-message recovery failed: ${reason}`);
219
+ capturePluginError(error, "realtime-pending-message-recovery");
220
+ }
221
+ }
222
+ async recoverPendingSessionMessages(sessionId) {
223
+ if (!this.client || !this.handlers)
224
+ return;
225
+ const { data, error } = await this.client
226
+ .from("messages")
227
+ .select("id, session_id, agent_id, user_id, sequence, client_message_id, role, type, content, metadata, created_at")
228
+ .eq("session_id", sessionId)
229
+ .order("sequence", { ascending: false })
230
+ .limit(PENDING_MESSAGE_LOOKBACK_LIMIT);
231
+ if (error) {
232
+ throw error;
233
+ }
234
+ const rows = (data ?? [])
235
+ .filter(isAppendedMessage)
236
+ .reverse();
237
+ let lastNonUserIndex = -1;
238
+ for (let index = 0; index < rows.length; index += 1) {
239
+ if (rows[index].role !== "user") {
240
+ lastNonUserIndex = index;
241
+ }
242
+ }
243
+ const pendingUserMessages = rows.slice(lastNonUserIndex + 1)
244
+ .filter((row) => row.role === "user");
245
+ for (const item of pendingUserMessages) {
246
+ await this.dispatchUserMessageOnce(item);
247
+ }
248
+ }
249
+ async dispatchUserMessageOnce(item) {
250
+ const currentHighWater = this.sessionHighWaterMark.get(item.session_id) ?? 0;
251
+ if (item.sequence <= currentHighWater)
252
+ return;
253
+ const inFlightKey = `${item.session_id}:${item.sequence}`;
254
+ if (this.inFlightMessageSequences.has(inFlightKey))
255
+ return;
256
+ this.inFlightMessageSequences.add(inFlightKey);
257
+ await this.dispatchInbound(inboundMessageFromRow(item));
258
+ this.inFlightMessageSequences.delete(inFlightKey);
259
+ this.sessionHighWaterMark.set(item.session_id, Math.max(this.sessionHighWaterMark.get(item.session_id) ?? 0, item.sequence));
260
+ }
191
261
  async ensureFreshSession() {
192
262
  const expiresAt = this.session.expires_at;
193
263
  const nowSec = Math.floor(Date.now() / 1000);
@@ -271,3 +341,43 @@ export class RealtimeTransport {
271
341
  this.handlers?.onStatusChange?.(status);
272
342
  }
273
343
  }
344
+ function parseMessageBroadcastPayload(envelope) {
345
+ if (!isRecord(envelope)) {
346
+ return null;
347
+ }
348
+ const payload = envelope.payload;
349
+ if (!isRecord(payload)) {
350
+ return null;
351
+ }
352
+ const { messageId, sessionId, userId, sequence, role, createdAt } = payload;
353
+ if (typeof messageId !== "string" ||
354
+ typeof sessionId !== "string" ||
355
+ typeof userId !== "string" ||
356
+ typeof sequence !== "number" ||
357
+ typeof role !== "string" ||
358
+ typeof createdAt !== "string" ||
359
+ !isMessageRole(role)) {
360
+ return null;
361
+ }
362
+ return { messageId, sessionId, userId, sequence, role, createdAt };
363
+ }
364
+ function isMessageRole(role) {
365
+ return role === "user" || role === "agent" || role === "system";
366
+ }
367
+ function isAppendedMessage(value) {
368
+ if (!isRecord(value)) {
369
+ return false;
370
+ }
371
+ return (typeof value.id === "string" &&
372
+ typeof value.session_id === "string" &&
373
+ typeof value.agent_id === "string" &&
374
+ typeof value.user_id === "string" &&
375
+ typeof value.sequence === "number" &&
376
+ typeof value.role === "string" &&
377
+ typeof value.type === "string" &&
378
+ typeof value.content === "string" &&
379
+ typeof value.created_at === "string");
380
+ }
381
+ function isRecord(value) {
382
+ return typeof value === "object" && value !== null;
383
+ }
@@ -1,5 +1,5 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- export type PluginWakeTaskType = "agent_panel_turn" | "market_install" | "agency_install";
2
+ export type PluginWakeTaskType = "single_agent_message" | "agent_panel_turn" | "market_install" | "agency_install";
3
3
  export type PluginWakeEvent = {
4
4
  id: string;
5
5
  payload: Record<string, unknown>;
package/dist/src/wake.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const VALID_TASK_TYPES = new Set([
2
+ "single_agent_message",
2
3
  "agent_panel_turn",
3
4
  "market_install",
4
5
  "agency_install",
@@ -16,14 +17,11 @@ export class PluginWakeSubscription {
16
17
  return this.startPromise;
17
18
  }
18
19
  const channel = this.options.client
19
- .channel(`plugin-wake:${this.options.pluginNodeId}`)
20
- .on("postgres_changes", {
21
- event: "INSERT",
22
- schema: "public",
23
- table: "plugin_wake_events",
24
- filter: `plugin_node_id=eq.${this.options.pluginNodeId}`,
25
- }, (payload) => {
26
- this.handleWakeRow(payload.new);
20
+ .channel(`plugin-wake:${this.options.pluginNodeId}`, {
21
+ config: { private: true },
22
+ })
23
+ .on("broadcast", { event: "wake" }, (payload) => {
24
+ this.handleWakeRow(payload.payload);
27
25
  });
28
26
  this.channel = channel;
29
27
  this.startPromise = new Promise((resolve) => {
@@ -64,13 +62,13 @@ export class PluginWakeSubscription {
64
62
  if (!isRecord(row)) {
65
63
  return;
66
64
  }
67
- const taskType = row.task_type;
65
+ const taskType = readString(row, "taskType", "task_type");
68
66
  if (typeof taskType !== "string" || !VALID_TASK_TYPES.has(taskType)) {
69
67
  return;
70
68
  }
71
- const taskId = row.task_id;
72
- const pluginNodeId = row.plugin_node_id;
73
- const userId = row.user_id;
69
+ const taskId = readString(row, "taskId", "task_id");
70
+ const pluginNodeId = readString(row, "pluginNodeId", "plugin_node_id");
71
+ const userId = readString(row, "userId", "user_id");
74
72
  if (!isNonEmptyString(taskId) || !isNonEmptyString(pluginNodeId) || !isNonEmptyString(userId)) {
75
73
  return;
76
74
  }
@@ -99,3 +97,6 @@ function isPlainObject(value) {
99
97
  function isNonEmptyString(value) {
100
98
  return typeof value === "string" && value.length > 0;
101
99
  }
100
+ function readString(row, camelCaseKey, snakeCaseKey) {
101
+ return row[camelCaseKey] ?? row[snakeCaseKey];
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawhive/openclaw-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "ClawHive channel plugin for OpenClaw",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/optimalAIs/clawhive#readme",