@clawhive/openclaw-plugin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +217 -0
  2. package/dist/api.d.ts +6 -0
  3. package/dist/api.js +6 -0
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.js +324 -0
  6. package/dist/runtime-api.d.ts +1 -0
  7. package/dist/runtime-api.js +1 -0
  8. package/dist/setup-entry.d.ts +4 -0
  9. package/dist/setup-entry.js +3 -0
  10. package/dist/src/agency-install/claimLoop.d.ts +41 -0
  11. package/dist/src/agency-install/claimLoop.js +188 -0
  12. package/dist/src/agent-panel/claimLoop.d.ts +32 -0
  13. package/dist/src/agent-panel/claimLoop.js +118 -0
  14. package/dist/src/agent-panel/executor.d.ts +8 -0
  15. package/dist/src/agent-panel/executor.js +368 -0
  16. package/dist/src/agent-panel/plan.d.ts +20 -0
  17. package/dist/src/agent-panel/plan.js +111 -0
  18. package/dist/src/agent-panel/prompts.d.ts +38 -0
  19. package/dist/src/agent-panel/prompts.js +87 -0
  20. package/dist/src/agent-panel/types.d.ts +45 -0
  21. package/dist/src/agent-panel/types.js +1 -0
  22. package/dist/src/agents.d.ts +44 -0
  23. package/dist/src/agents.js +195 -0
  24. package/dist/src/authorization.d.ts +34 -0
  25. package/dist/src/authorization.js +183 -0
  26. package/dist/src/channel.d.ts +5 -0
  27. package/dist/src/channel.js +93 -0
  28. package/dist/src/claimPolling.d.ts +9 -0
  29. package/dist/src/claimPolling.js +13 -0
  30. package/dist/src/client.d.ts +386 -0
  31. package/dist/src/client.js +595 -0
  32. package/dist/src/config.d.ts +28 -0
  33. package/dist/src/config.js +94 -0
  34. package/dist/src/defaults.d.ts +40 -0
  35. package/dist/src/defaults.js +52 -0
  36. package/dist/src/deviceCode.d.ts +16 -0
  37. package/dist/src/deviceCode.js +65 -0
  38. package/dist/src/heartbeat.d.ts +25 -0
  39. package/dist/src/heartbeat.js +65 -0
  40. package/dist/src/imageAttachments.d.ts +14 -0
  41. package/dist/src/imageAttachments.js +81 -0
  42. package/dist/src/inbound.d.ts +10 -0
  43. package/dist/src/inbound.js +140 -0
  44. package/dist/src/installMutex.d.ts +4 -0
  45. package/dist/src/installMutex.js +18 -0
  46. package/dist/src/market-install/claimLoop.d.ts +41 -0
  47. package/dist/src/market-install/claimLoop.js +183 -0
  48. package/dist/src/market-install/downloader.d.ts +36 -0
  49. package/dist/src/market-install/downloader.js +133 -0
  50. package/dist/src/market-install/executor.d.ts +28 -0
  51. package/dist/src/market-install/executor.js +352 -0
  52. package/dist/src/market-install/types.d.ts +62 -0
  53. package/dist/src/market-install/types.js +1 -0
  54. package/dist/src/market-install/verifier.d.ts +32 -0
  55. package/dist/src/market-install/verifier.js +60 -0
  56. package/dist/src/market-publish/packager.d.ts +34 -0
  57. package/dist/src/market-publish/packager.js +168 -0
  58. package/dist/src/market-publish/publishFlow.d.ts +70 -0
  59. package/dist/src/market-publish/publishFlow.js +107 -0
  60. package/dist/src/market-publish/uploader.d.ts +73 -0
  61. package/dist/src/market-publish/uploader.js +132 -0
  62. package/dist/src/openclawVersion.d.ts +4 -0
  63. package/dist/src/openclawVersion.js +13 -0
  64. package/dist/src/outbound.d.ts +13 -0
  65. package/dist/src/outbound.js +41 -0
  66. package/dist/src/pairing.d.ts +32 -0
  67. package/dist/src/pairing.js +64 -0
  68. package/dist/src/runtime.d.ts +52 -0
  69. package/dist/src/runtime.js +26 -0
  70. package/dist/src/telemetry.d.ts +34 -0
  71. package/dist/src/telemetry.js +89 -0
  72. package/dist/src/transport/realtime.d.ts +49 -0
  73. package/dist/src/transport/realtime.js +273 -0
  74. package/dist/src/transport.d.ts +38 -0
  75. package/dist/src/transport.js +15 -0
  76. package/dist/src/wake.d.ts +31 -0
  77. package/dist/src/wake.js +101 -0
  78. package/openclaw.config.example.yaml +10 -0
  79. package/openclaw.plugin.json +122 -0
  80. package/package.json +84 -0
@@ -0,0 +1,64 @@
1
+ import qrcodeTerminal from "qrcode-terminal";
2
+ import { createClient } from "@supabase/supabase-js";
3
+ function buildPayloadUrl(projectUrl, pairToken) {
4
+ const params = new URLSearchParams({
5
+ token: pairToken,
6
+ project: projectUrl.replace(/\/$/, ""),
7
+ });
8
+ return `clawhive://pair?${params.toString()}`;
9
+ }
10
+ function shortCodeFromToken(token) {
11
+ // First 8 hex/alphanumeric chars, uppercased — easy to dictate over voice if scanning fails.
12
+ return token.replace(/[^0-9a-zA-Z]/g, "").slice(0, 8).toUpperCase();
13
+ }
14
+ export async function generatePairingPayload(api, pluginNodeId, ttlSeconds = 300) {
15
+ const result = await api.createPairingCode(pluginNodeId, ttlSeconds);
16
+ return {
17
+ pairToken: result.pair_token,
18
+ expiresAt: result.expires_at,
19
+ payloadUrl: buildPayloadUrl(api.baseUrl, result.pair_token),
20
+ };
21
+ }
22
+ export async function renderQrText(payloadUrl) {
23
+ return await new Promise((resolve) => {
24
+ qrcodeTerminal.generate(payloadUrl, { small: true }, (qr) => resolve(qr));
25
+ });
26
+ }
27
+ export async function printPairingQr(options) {
28
+ const payload = await generatePairingPayload(options.api, options.pluginNodeId, options.ttlSeconds ?? 300);
29
+ const qrText = await renderQrText(payload.payloadUrl);
30
+ const lines = [
31
+ options.banner ?? "ClawHive: scan this QR with the ClawHive mobile app to pair.",
32
+ qrText,
33
+ `URL: ${payload.payloadUrl}`,
34
+ `Short code: ${shortCodeFromToken(payload.pairToken)}`,
35
+ `Expires: ${payload.expiresAt}`,
36
+ ];
37
+ for (const line of lines) {
38
+ (options.ascii ?? ((text) => process.stdout.write(text + "\n")))(line);
39
+ }
40
+ return { ...payload, qrText };
41
+ }
42
+ /**
43
+ * Return the number of active paired devices for the current user.
44
+ * Uses the user-scoped session minted by plugin-register so the query
45
+ * passes RLS without bringing service-role credentials into the plugin.
46
+ */
47
+ export async function getPairedDeviceCount(params) {
48
+ const client = createClient(params.projectUrl, params.anonKey, {
49
+ auth: { autoRefreshToken: false, persistSession: false },
50
+ global: {
51
+ headers: {
52
+ Authorization: `Bearer ${params.registration.user_session.access_token}`,
53
+ },
54
+ },
55
+ });
56
+ const { count, error } = await client
57
+ .from("devices")
58
+ .select("id", { count: "exact", head: true })
59
+ .eq("is_active", true);
60
+ if (error) {
61
+ throw new Error(`Failed to check paired devices: ${error.message}`);
62
+ }
63
+ return count ?? 0;
64
+ }
@@ -0,0 +1,52 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import type { ClawHiveCloudApi } from "./client.js";
3
+ import type { AgentPanelClaimLoop } from "./agent-panel/claimLoop.js";
4
+ import type { AgencyInstallClaimLoop } from "./agency-install/claimLoop.js";
5
+ import type { AgentSyncLoop } from "./agents.js";
6
+ import type { HeartbeatLoop } from "./heartbeat.js";
7
+ import type { InstallClaimLoop } from "./market-install/claimLoop.js";
8
+ import type { ActiveInstallJobState } from "./market-install/types.js";
9
+ import type { TransportAdapter } from "./transport.js";
10
+ import type { PluginWakeSubscription } from "./wake.js";
11
+ export type SessionBinding = {
12
+ sessionId: string;
13
+ cloudAgentId: string;
14
+ localAgentId: string;
15
+ userId: string;
16
+ sessionKey: string;
17
+ lastSeenSequence: number;
18
+ };
19
+ export type ClawHiveRuntime = {
20
+ api: ClawHiveCloudApi;
21
+ transport: TransportAdapter;
22
+ heartbeat: HeartbeatLoop;
23
+ agentSyncLoop: AgentSyncLoop;
24
+ agentPanelLoop: AgentPanelClaimLoop;
25
+ installLoop: InstallClaimLoop;
26
+ agencyInstallLoop: AgencyInstallClaimLoop;
27
+ wakeSubscription: PluginWakeSubscription | null;
28
+ pluginNodeId: string;
29
+ userId: string;
30
+ pluginApi: OpenClawPluginApi;
31
+ sessionIndex: Map<string, SessionBinding>;
32
+ agentIndex: Map<string, string>;
33
+ activeInstall: ActiveInstallJobState | null;
34
+ };
35
+ export declare const clawhiveRuntimeStore: {
36
+ setRuntime: (next: ClawHiveRuntime) => void;
37
+ clearRuntime: () => void;
38
+ tryGetRuntime: () => ClawHiveRuntime | null;
39
+ getRuntime: () => ClawHiveRuntime;
40
+ };
41
+ export declare function indexSession(runtime: ClawHiveRuntime, binding: SessionBinding): void;
42
+ export declare function lookupSession(runtime: ClawHiveRuntime, key: string): SessionBinding | undefined;
43
+ export declare function buildClawHiveSessionKey(sessionId: string): string;
44
+ export declare function indexSyncedAgents(runtime: ClawHiveRuntime, items: Array<{
45
+ id: string;
46
+ openclaw_agent_id: string;
47
+ }>): void;
48
+ export declare function resolveLocalAgentId(runtime: ClawHiveRuntime, cloudAgentId: string): string | null;
49
+ /**
50
+ * Track the currently executing market install job inside the shared runtime store.
51
+ */
52
+ export declare function setActiveInstall(runtime: ClawHiveRuntime, activeInstall: ActiveInstallJobState | null): void;
@@ -0,0 +1,26 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+ export const clawhiveRuntimeStore = createPluginRuntimeStore("ClawHive plugin runtime has not been initialized.");
3
+ export function indexSession(runtime, binding) {
4
+ runtime.sessionIndex.set(binding.sessionId, binding);
5
+ runtime.sessionIndex.set(binding.sessionKey, binding);
6
+ }
7
+ export function lookupSession(runtime, key) {
8
+ return runtime.sessionIndex.get(key);
9
+ }
10
+ export function buildClawHiveSessionKey(sessionId) {
11
+ return `clawhive:default:${sessionId}`;
12
+ }
13
+ export function indexSyncedAgents(runtime, items) {
14
+ for (const item of items) {
15
+ runtime.agentIndex.set(item.id, item.openclaw_agent_id);
16
+ }
17
+ }
18
+ export function resolveLocalAgentId(runtime, cloudAgentId) {
19
+ return runtime.agentIndex.get(cloudAgentId) ?? null;
20
+ }
21
+ /**
22
+ * Track the currently executing market install job inside the shared runtime store.
23
+ */
24
+ export function setActiveInstall(runtime, activeInstall) {
25
+ runtime.activeInstall = activeInstall;
26
+ }
@@ -0,0 +1,34 @@
1
+ import type { MarketInstallClaimedJob, MarketInstallReasonKind } from "./market-install/types.js";
2
+ /**
3
+ * Initialize plugin telemetry from environment variables without breaking local boot.
4
+ */
5
+ export declare function initPluginTelemetry(): void;
6
+ /**
7
+ * Add a plugin breadcrumb for runtime and reconnect lifecycle events.
8
+ */
9
+ export declare function addPluginBreadcrumb(input: {
10
+ category: string;
11
+ message: string;
12
+ data?: Record<string, unknown>;
13
+ }): void;
14
+ /**
15
+ * Capture plugin exceptions with a stable runtime tag.
16
+ */
17
+ export declare function capturePluginError(error: unknown, area: string): void;
18
+ /**
19
+ * Add an install-specific breadcrumb so claim/execution failures are visible in telemetry.
20
+ */
21
+ export declare function addInstallBreadcrumb(input: {
22
+ stage: string;
23
+ job: Pick<MarketInstallClaimedJob, "jobId" | "listingId" | "releaseId" | "targetNodeId" | "version">;
24
+ reasonKind?: MarketInstallReasonKind;
25
+ data?: Record<string, unknown>;
26
+ }): void;
27
+ /**
28
+ * Capture install failures with stable market-install tags for triage.
29
+ */
30
+ export declare function captureInstallFailure(input: {
31
+ error: unknown;
32
+ reasonKind: MarketInstallReasonKind;
33
+ job: Pick<MarketInstallClaimedJob, "jobId" | "listingId" | "releaseId" | "targetNodeId" | "version">;
34
+ }): void;
@@ -0,0 +1,89 @@
1
+ import * as Sentry from "@sentry/node";
2
+ let initialized = false;
3
+ function resolvePluginRelease() {
4
+ return process.env.SENTRY_RELEASE_PLUGIN ?? process.env.SENTRY_RELEASE;
5
+ }
6
+ /**
7
+ * Initialize plugin telemetry from environment variables without breaking local boot.
8
+ */
9
+ export function initPluginTelemetry() {
10
+ const dsn = process.env.SENTRY_DSN_PLUGIN;
11
+ if (!dsn || initialized) {
12
+ return;
13
+ }
14
+ Sentry.init({
15
+ dsn,
16
+ environment: process.env.SENTRY_ENVIRONMENT ?? "development",
17
+ release: resolvePluginRelease(),
18
+ tracesSampleRate: 0,
19
+ });
20
+ initialized = true;
21
+ }
22
+ /**
23
+ * Add a plugin breadcrumb for runtime and reconnect lifecycle events.
24
+ */
25
+ export function addPluginBreadcrumb(input) {
26
+ if (!initialized) {
27
+ return;
28
+ }
29
+ Sentry.addBreadcrumb({
30
+ category: input.category,
31
+ message: input.message,
32
+ data: input.data,
33
+ level: "info",
34
+ });
35
+ }
36
+ /**
37
+ * Capture plugin exceptions with a stable runtime tag.
38
+ */
39
+ export function capturePluginError(error, area) {
40
+ if (!initialized) {
41
+ return;
42
+ }
43
+ Sentry.captureException(error, {
44
+ tags: {
45
+ area,
46
+ runtime: "plugin",
47
+ },
48
+ });
49
+ }
50
+ /**
51
+ * Add an install-specific breadcrumb so claim/execution failures are visible in telemetry.
52
+ */
53
+ export function addInstallBreadcrumb(input) {
54
+ addPluginBreadcrumb({
55
+ category: "market-install",
56
+ message: `install.${input.stage}`,
57
+ data: {
58
+ jobId: input.job.jobId,
59
+ listingId: input.job.listingId,
60
+ releaseId: input.job.releaseId,
61
+ targetNodeId: input.job.targetNodeId,
62
+ version: input.job.version,
63
+ reasonKind: input.reasonKind ?? null,
64
+ ...(input.data ?? {}),
65
+ },
66
+ });
67
+ }
68
+ /**
69
+ * Capture install failures with stable market-install tags for triage.
70
+ */
71
+ export function captureInstallFailure(input) {
72
+ if (!initialized) {
73
+ return;
74
+ }
75
+ Sentry.captureException(input.error, {
76
+ tags: {
77
+ area: "market-install",
78
+ runtime: "plugin",
79
+ reasonKind: input.reasonKind,
80
+ },
81
+ extra: {
82
+ jobId: input.job.jobId,
83
+ listingId: input.job.listingId,
84
+ releaseId: input.job.releaseId,
85
+ targetNodeId: input.job.targetNodeId,
86
+ version: input.job.version,
87
+ },
88
+ });
89
+ }
@@ -0,0 +1,49 @@
1
+ import { type SupabaseClient } from "@supabase/supabase-js";
2
+ import type { ClawHiveCloudApi, UserSession } from "../client.js";
3
+ import { type TransportAdapter, type TransportHandlers, type TransportStatus } from "../transport.js";
4
+ export type RealtimeTransportLogger = {
5
+ info: (msg: string) => void;
6
+ warn: (msg: string) => void;
7
+ error: (msg: string) => void;
8
+ };
9
+ export type RealtimeTransportOptions = {
10
+ api: ClawHiveCloudApi;
11
+ anonKey: string;
12
+ userSession: UserSession;
13
+ userId: string;
14
+ logger?: RealtimeTransportLogger;
15
+ onClientReady?: (client: SupabaseClient) => Promise<void> | void;
16
+ /** Max backoff between reconnect attempts. Default 30000ms. */
17
+ maxBackoffMs?: number;
18
+ };
19
+ export declare class RealtimeTransport implements TransportAdapter {
20
+ readonly id = "clawhive-realtime";
21
+ private readonly options;
22
+ private client;
23
+ private channel;
24
+ private handlers;
25
+ private status;
26
+ private stopRequested;
27
+ private reconnectAttempts;
28
+ private reconnectTimer;
29
+ private session;
30
+ private readonly sessionHighWaterMark;
31
+ constructor(options: RealtimeTransportOptions);
32
+ getStatus(): TransportStatus;
33
+ getClient(): SupabaseClient;
34
+ start(handlers: TransportHandlers): Promise<void>;
35
+ stop(): Promise<void>;
36
+ private connect;
37
+ private handleRow;
38
+ private dispatchInbound;
39
+ private catchUpSession;
40
+ private ensureFreshSession;
41
+ /**
42
+ * Recover from an invalidated refresh token by re-registering the plugin node.
43
+ */
44
+ private recoverSessionAfterRefreshFailure;
45
+ private scheduleReconnect;
46
+ private reconnect;
47
+ private detachChannel;
48
+ private setStatus;
49
+ }
@@ -0,0 +1,273 @@
1
+ import { createClient, } from "@supabase/supabase-js";
2
+ import WebSocket from "ws";
3
+ import { inboundMessageFromRow, } from "../transport.js";
4
+ import { addPluginBreadcrumb, capturePluginError } from "../telemetry.js";
5
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
6
+ const NodeWebSocketTransport = WebSocket;
7
+ export class RealtimeTransport {
8
+ id = "clawhive-realtime";
9
+ options;
10
+ client = null;
11
+ channel = null;
12
+ handlers = null;
13
+ status = { state: "idle" };
14
+ stopRequested = false;
15
+ reconnectAttempts = 0;
16
+ reconnectTimer = null;
17
+ session;
18
+ sessionHighWaterMark = new Map();
19
+ constructor(options) {
20
+ this.options = options;
21
+ this.session = options.userSession;
22
+ }
23
+ getStatus() {
24
+ return this.status;
25
+ }
26
+ getClient() {
27
+ if (!this.client) {
28
+ throw new Error("RealtimeTransport client is not initialized.");
29
+ }
30
+ return this.client;
31
+ }
32
+ async start(handlers) {
33
+ if (this.handlers) {
34
+ throw new Error("RealtimeTransport already started");
35
+ }
36
+ this.handlers = handlers;
37
+ this.stopRequested = false;
38
+ await this.connect();
39
+ }
40
+ async stop() {
41
+ this.stopRequested = true;
42
+ if (this.reconnectTimer) {
43
+ clearTimeout(this.reconnectTimer);
44
+ this.reconnectTimer = null;
45
+ }
46
+ await this.detachChannel();
47
+ this.client?.realtime.disconnect();
48
+ this.client = null;
49
+ this.setStatus({ state: "idle" });
50
+ }
51
+ async connect() {
52
+ this.setStatus({ state: "connecting" });
53
+ // Provide the user session through the official accessToken callback so
54
+ // Realtime can rehydrate auth consistently across socket reconnects.
55
+ this.client = createClient(this.options.api.baseUrl, this.options.anonKey, {
56
+ auth: {
57
+ persistSession: false,
58
+ autoRefreshToken: false,
59
+ },
60
+ accessToken: async () => this.session.access_token,
61
+ realtime: {
62
+ // Force a stable Node WebSocket implementation so hosted wss
63
+ // connections do not depend on the OpenClaw host runtime globals.
64
+ transport: NodeWebSocketTransport,
65
+ params: { eventsPerSecond: 10 },
66
+ worker: false,
67
+ logger: (kind, msg, data) => {
68
+ if (kind !== "error") {
69
+ return;
70
+ }
71
+ const details = data === undefined
72
+ ? ""
73
+ : typeof data === "string"
74
+ ? data
75
+ : JSON.stringify(data);
76
+ this.options.logger?.warn(`ClawHive realtime client error msg=${msg}${details ? ` details=${details}` : ""}`);
77
+ },
78
+ },
79
+ });
80
+ await this.client.realtime.setAuth(this.session.access_token);
81
+ 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);
90
+ });
91
+ this.channel = channel;
92
+ await new Promise((resolve, reject) => {
93
+ channel.subscribe((status, channelError) => {
94
+ if (channel !== this.channel) {
95
+ return;
96
+ }
97
+ if (status === "SUBSCRIBED") {
98
+ void (async () => {
99
+ try {
100
+ await this.options.onClientReady?.(this.getClient());
101
+ }
102
+ catch (error) {
103
+ reject(error);
104
+ return;
105
+ }
106
+ this.reconnectAttempts = 0;
107
+ this.setStatus({ state: "ready" });
108
+ this.options.logger?.info(`ClawHive realtime subscribed user=${this.options.userId} tokenExpiresAt=${this.session.expires_at ?? "unknown"}`);
109
+ addPluginBreadcrumb({
110
+ category: "realtime",
111
+ message: "subscription.subscribed",
112
+ data: { userId: this.options.userId },
113
+ });
114
+ resolve();
115
+ })();
116
+ }
117
+ else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
118
+ const error = new Error(`Realtime subscription failed: ${status}`);
119
+ this.setStatus({ state: "disconnected", reason: status });
120
+ const details = channelError && typeof channelError === "object"
121
+ ? JSON.stringify(channelError)
122
+ : String(channelError ?? "");
123
+ this.options.logger?.warn(`ClawHive realtime subscription status=${status} user=${this.options.userId} tokenExpiresAt=${this.session.expires_at ?? "unknown"} details=${details}`);
124
+ capturePluginError(error, "realtime-subscribe");
125
+ reject(error);
126
+ this.scheduleReconnect();
127
+ }
128
+ else if (status === "CLOSED" && !this.stopRequested) {
129
+ this.setStatus({ state: "disconnected", reason: "CLOSED" });
130
+ this.options.logger?.warn(`ClawHive realtime subscription closed user=${this.options.userId} tokenExpiresAt=${this.session.expires_at ?? "unknown"}`);
131
+ addPluginBreadcrumb({
132
+ category: "realtime",
133
+ message: "subscription.closed",
134
+ data: { userId: this.options.userId },
135
+ });
136
+ this.scheduleReconnect();
137
+ }
138
+ });
139
+ });
140
+ }
141
+ async handleRow(row) {
142
+ if (!this.handlers)
143
+ return;
144
+ if (row.role !== "user")
145
+ return;
146
+ const previousSequence = this.sessionHighWaterMark.get(row.session_id) ?? 0;
147
+ if (row.sequence <= previousSequence)
148
+ 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);
155
+ }
156
+ async dispatchInbound(message) {
157
+ try {
158
+ await this.handlers.onInboundMessage(message);
159
+ }
160
+ catch (error) {
161
+ const reason = error instanceof Error ? error.message : String(error);
162
+ this.options.logger?.error(`ClawHive realtime inbound dispatch failed session=${message.sessionId} msg=${message.id}: ${reason}`);
163
+ }
164
+ }
165
+ async catchUpSession(sessionId, afterSequence, beforeSequence) {
166
+ try {
167
+ const session = await this.ensureFreshSession();
168
+ const result = await this.options.api.syncMessages({
169
+ accessToken: session.access_token,
170
+ sessionId,
171
+ afterSequence,
172
+ limit: 100,
173
+ });
174
+ for (const item of result.items) {
175
+ // Skip the current realtime row during gap recovery so the plugin
176
+ // does not dispatch the same inbound user message twice.
177
+ if (beforeSequence && item.sequence >= beforeSequence)
178
+ continue;
179
+ if (item.role !== "user")
180
+ continue;
181
+ await this.dispatchInbound(inboundMessageFromRow(item));
182
+ this.sessionHighWaterMark.set(item.session_id, item.sequence);
183
+ }
184
+ }
185
+ catch (error) {
186
+ const reason = error instanceof Error ? error.message : String(error);
187
+ this.options.logger?.warn(`ClawHive realtime catch-up failed session=${sessionId}: ${reason}`);
188
+ capturePluginError(error, "realtime-catch-up");
189
+ }
190
+ }
191
+ async ensureFreshSession() {
192
+ const expiresAt = this.session.expires_at;
193
+ const nowSec = Math.floor(Date.now() / 1000);
194
+ if (expiresAt && expiresAt - nowSec > 30) {
195
+ return this.session;
196
+ }
197
+ try {
198
+ const refreshed = await this.options.api.refreshUserSession(this.session.refresh_token);
199
+ this.options.logger?.info(`ClawHive realtime refreshed session oldExpiresAt=${expiresAt ?? "unknown"} newExpiresAt=${refreshed.expires_at ?? "unknown"}`);
200
+ this.session = refreshed;
201
+ if (this.client) {
202
+ await this.client.realtime.setAuth(refreshed.access_token);
203
+ }
204
+ return refreshed;
205
+ }
206
+ catch (error) {
207
+ return this.recoverSessionAfterRefreshFailure(error);
208
+ }
209
+ }
210
+ /**
211
+ * Recover from an invalidated refresh token by re-registering the plugin node.
212
+ */
213
+ async recoverSessionAfterRefreshFailure(cause) {
214
+ const reason = cause instanceof Error ? cause.message : String(cause);
215
+ this.options.logger?.warn(`ClawHive realtime refresh failed; attempting plugin-register recovery: ${reason}`);
216
+ const registration = await this.options.api.registerPluginNode();
217
+ const recovered = registration.user_session;
218
+ this.options.logger?.info(`ClawHive realtime recovered session via plugin-register newExpiresAt=${recovered.expires_at ?? "unknown"}`);
219
+ this.session = recovered;
220
+ if (this.client) {
221
+ await this.client.realtime.setAuth(recovered.access_token);
222
+ }
223
+ return recovered;
224
+ }
225
+ scheduleReconnect() {
226
+ if (this.stopRequested)
227
+ return;
228
+ if (this.reconnectTimer)
229
+ return;
230
+ this.reconnectAttempts += 1;
231
+ const base = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS);
232
+ const jitter = Math.floor(Math.random() * 500);
233
+ const delay = base + jitter;
234
+ this.options.logger?.info(`ClawHive realtime reconnect scheduled attempt=${this.reconnectAttempts} delay=${delay}ms`);
235
+ addPluginBreadcrumb({
236
+ category: "realtime",
237
+ message: "reconnect.scheduled",
238
+ data: { attempt: this.reconnectAttempts, delay },
239
+ });
240
+ this.reconnectTimer = setTimeout(() => {
241
+ this.reconnectTimer = null;
242
+ void this.reconnect();
243
+ }, delay);
244
+ }
245
+ async reconnect() {
246
+ if (this.stopRequested)
247
+ return;
248
+ await this.detachChannel();
249
+ this.client?.realtime.disconnect();
250
+ this.client = null;
251
+ try {
252
+ await this.ensureFreshSession();
253
+ await this.connect();
254
+ }
255
+ catch (error) {
256
+ const reason = error instanceof Error ? error.message : String(error);
257
+ this.options.logger?.warn(`ClawHive realtime reconnect failed: ${reason}`);
258
+ capturePluginError(error, "realtime-reconnect");
259
+ this.scheduleReconnect();
260
+ }
261
+ }
262
+ async detachChannel() {
263
+ const channel = this.channel;
264
+ this.channel = null;
265
+ if (channel && this.client) {
266
+ await this.client.removeChannel(channel);
267
+ }
268
+ }
269
+ setStatus(status) {
270
+ this.status = status;
271
+ this.handlers?.onStatusChange?.(status);
272
+ }
273
+ }
@@ -0,0 +1,38 @@
1
+ import type { AppendedMessage } from "./client.js";
2
+ export type InboundMessage = {
3
+ id: string;
4
+ sessionId: string;
5
+ agentId: string;
6
+ userId: string;
7
+ sequence: number;
8
+ role: "user" | "agent" | "system";
9
+ type: string;
10
+ content: string;
11
+ clientMessageId?: string | null;
12
+ createdAt: string;
13
+ metadata?: Record<string, unknown>;
14
+ };
15
+ export declare function inboundMessageFromRow(row: AppendedMessage): InboundMessage;
16
+ export type TransportStatus = {
17
+ state: "idle";
18
+ } | {
19
+ state: "connecting";
20
+ } | {
21
+ state: "ready";
22
+ } | {
23
+ state: "disconnected";
24
+ reason?: string;
25
+ } | {
26
+ state: "error";
27
+ error: unknown;
28
+ };
29
+ export type TransportHandlers = {
30
+ onInboundMessage: (message: InboundMessage) => Promise<void> | void;
31
+ onStatusChange?: (status: TransportStatus) => void;
32
+ };
33
+ export type TransportAdapter = {
34
+ readonly id: string;
35
+ start(handlers: TransportHandlers): Promise<void>;
36
+ stop(): Promise<void>;
37
+ getStatus(): TransportStatus;
38
+ };
@@ -0,0 +1,15 @@
1
+ export function inboundMessageFromRow(row) {
2
+ return {
3
+ id: row.id,
4
+ sessionId: row.session_id,
5
+ agentId: row.agent_id,
6
+ userId: row.user_id,
7
+ sequence: row.sequence,
8
+ role: row.role,
9
+ type: row.type,
10
+ content: row.content,
11
+ clientMessageId: row.client_message_id ?? null,
12
+ createdAt: row.created_at,
13
+ metadata: row.metadata ?? {},
14
+ };
15
+ }
@@ -0,0 +1,31 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ export type PluginWakeTaskType = "agent_panel_turn" | "market_install" | "agency_install";
3
+ export type PluginWakeEvent = {
4
+ id: string;
5
+ payload: Record<string, unknown>;
6
+ pluginNodeId: string;
7
+ taskId: string;
8
+ taskType: PluginWakeTaskType;
9
+ userId: string;
10
+ };
11
+ export type PluginWakeSubscriptionLogger = {
12
+ info: (message: string) => void;
13
+ warn: (message: string) => void;
14
+ };
15
+ export type PluginWakeSubscriptionOptions = {
16
+ client: Pick<SupabaseClient, "channel" | "removeChannel">;
17
+ logger?: PluginWakeSubscriptionLogger;
18
+ pluginNodeId: string;
19
+ userId: string;
20
+ onWake: (event: PluginWakeEvent) => void;
21
+ };
22
+ export declare class PluginWakeSubscription {
23
+ private readonly options;
24
+ private channel;
25
+ private startResolve;
26
+ private startPromise;
27
+ constructor(options: PluginWakeSubscriptionOptions);
28
+ start(): Promise<void>;
29
+ stop(): Promise<void>;
30
+ private handleWakeRow;
31
+ }