@efengx/openclaw-channel-dragon 0.4.9 → 0.5.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.
@@ -4,6 +4,7 @@ export declare class BridgeComponent implements IComponent {
4
4
  private options;
5
5
  client: DragonBridgeClient | undefined;
6
6
  constructor(options: {
7
+ host?: string;
7
8
  port: number;
8
9
  token: string;
9
10
  agentId: string;
@@ -6,8 +6,9 @@ export class BridgeComponent {
6
6
  this.options = options;
7
7
  }
8
8
  async start() {
9
- const { port, token, agentId, gatewayPort, gatewayToken, logger } = this.options;
9
+ const { host, port, token, agentId, gatewayPort, gatewayToken, logger } = this.options;
10
10
  this.client = connectToDragonBridge({
11
+ host,
11
12
  port,
12
13
  token,
13
14
  onConnected: () => {
@@ -27,6 +27,7 @@ export class ChannelComponent {
27
27
  return;
28
28
  const { accountId, agentId, cfg, logger } = this.options;
29
29
  const sessionKey = `dragon:${agentId}:direct:${sessionId}`;
30
+ logger?.info?.(`[Dragon Plugin] Sending to OpenClaw: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}" [Session: ${sessionId}]`);
30
31
  await replyDispatcher({
31
32
  ctx: {
32
33
  Body: content,
@@ -69,7 +70,8 @@ export class ChannelComponent {
69
70
  };
70
71
  handleOutboundText = async (ctx) => {
71
72
  const text = ctx?.text || "";
72
- const { accountId, agentId } = this.options;
73
+ const { accountId, agentId, logger } = this.options;
74
+ logger?.info?.(`[Dragon Plugin] Outbound Text (Action): "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
73
75
  if (this.bridge.client) {
74
76
  this.bridge.client.sendJson({
75
77
  type: "outbound_text",
@@ -5,7 +5,7 @@ export interface HttpOptions {
5
5
  logger?: any;
6
6
  }
7
7
  export declare class HttpComponent implements IComponent {
8
- private options;
8
+ options: HttpOptions;
9
9
  constructor(options: HttpOptions);
10
10
  start(): Promise<void>;
11
11
  stop(): Promise<void>;
@@ -6,16 +6,16 @@ export declare class PollingComponent implements IComponent {
6
6
  private channel;
7
7
  private options;
8
8
  private pollInterval;
9
+ private heartbeatInterval;
9
10
  constructor(http: HttpComponent, channel: ChannelComponent, options: {
10
11
  agentId: string;
11
12
  accountId: string;
13
+ channelRuntime?: any;
12
14
  abortSignal: AbortSignal;
13
15
  logger?: any;
14
16
  });
15
17
  start(): Promise<void>;
16
18
  stop(): Promise<void>;
17
19
  private consumePendingMessages;
18
- private startLoop;
19
- private connectOnce;
20
- private connectHttp2;
20
+ private sendHeartbeat;
21
21
  }
@@ -1,26 +1,31 @@
1
- import * as http2 from "node:http2";
2
1
  export class PollingComponent {
3
2
  http;
4
3
  channel;
5
4
  options;
6
5
  pollInterval = null;
6
+ heartbeatInterval = null;
7
7
  constructor(http, channel, options) {
8
8
  this.http = http;
9
9
  this.channel = channel;
10
10
  this.options = options;
11
11
  }
12
12
  async start() {
13
- this.startLoop();
14
- // Safety: Periodic polling fallback every 60 seconds
13
+ // 1. Initial consume
14
+ void this.consumePendingMessages();
15
+ // 2. Periodic recovery polling (every 60s)
15
16
  this.pollInterval = setInterval(() => {
16
17
  void this.consumePendingMessages();
17
18
  }, 60_000);
19
+ // 3. Heartbeat for OpenClaw keep-alive (every 30s)
20
+ this.heartbeatInterval = setInterval(() => {
21
+ void this.sendHeartbeat();
22
+ }, 30_000);
18
23
  }
19
24
  async stop() {
20
- if (this.pollInterval) {
25
+ if (this.pollInterval)
21
26
  clearInterval(this.pollInterval);
22
- this.pollInterval = null;
23
- }
27
+ if (this.heartbeatInterval)
28
+ clearInterval(this.heartbeatInterval);
24
29
  }
25
30
  async consumePendingMessages() {
26
31
  const { agentId, logger } = this.options;
@@ -30,7 +35,7 @@ export class PollingComponent {
30
35
  const data = (await res.json());
31
36
  const messages = data.messages || [];
32
37
  if (messages.length > 0) {
33
- logger?.info?.(`dragon channel: [RECOVERY] Consuming ${messages.length} pending messages via polling.`);
38
+ logger?.debug?.(`[Dragon Plugin] [Recovery] Polled ${messages.length} pending messages.`);
34
39
  for (const m of messages) {
35
40
  await this.channel.deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id);
36
41
  }
@@ -38,104 +43,26 @@ export class PollingComponent {
38
43
  }
39
44
  }
40
45
  catch (e) {
41
- logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
46
+ // Quiet fail for recovery polling
42
47
  }
43
48
  }
44
- async startLoop() {
45
- const { logger, abortSignal } = this.options;
46
- let attempt = 0;
47
- while (!abortSignal.aborted) {
48
- try {
49
- attempt += 1;
50
- logger?.debug?.(`dragon channel: SSE connecting (attempt ${attempt})`);
51
- await this.connectOnce();
52
- }
53
- catch (e) {
54
- if (e?.name === 'AbortError')
55
- break;
56
- logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`);
57
- const delayMs = Math.min(10_000, 500 + attempt * 500);
58
- await new Promise((r) => setTimeout(r, delayMs));
59
- }
60
- }
61
- }
62
- async connectOnce() {
63
- const { agentId, logger, abortSignal } = this.options;
64
- const ssePath = `/api/agents/events`;
65
- void this.consumePendingMessages();
66
- const handleSseText = async (chunkText, bufRef) => {
67
- bufRef.buf += chunkText;
68
- let idx;
69
- while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
70
- const raw = bufRef.buf.slice(0, idx);
71
- bufRef.buf = bufRef.buf.slice(idx + 2);
72
- if (!raw.trim() || raw.trim().startsWith(':'))
73
- continue;
74
- const dataLines = raw
75
- .split('\n')
76
- .filter((l) => l.startsWith('data:'))
77
- .map((l) => l.slice('data:'.length).trim());
78
- if (!dataLines.length)
79
- continue;
80
- const dataStr = dataLines.join('\n');
81
- try {
82
- const evt = JSON.parse(dataStr);
83
- if (evt?.type === 'WORKBENCH_MESSAGE' && evt.agentId === agentId) {
84
- await this.channel.deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id);
85
- }
86
- }
87
- catch (e) { }
49
+ async sendHeartbeat() {
50
+ const { agentId, logger, channelRuntime } = this.options;
51
+ try {
52
+ // Orchestrator Heartbeat
53
+ await this.http.fetch(`/api/agents/${agentId}/telemetry`, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({ type: "plugin_heartbeat", value: { timestamp: Date.now() } }),
57
+ });
58
+ // OpenClaw Runtime Activity Report (if supported by SDK)
59
+ if (typeof channelRuntime?.reportActivity === 'function') {
60
+ channelRuntime.reportActivity();
88
61
  }
89
- };
90
- const res = await this.http.fetch(ssePath, {
91
- signal: abortSignal,
92
- headers: { Accept: 'text/event-stream' },
93
- });
94
- if (res.status === 404) {
95
- return;
62
+ logger?.debug?.(`[Dragon Plugin] Heartbeat sent for ${agentId}`);
96
63
  }
97
- if (!res.ok)
98
- throw new Error(`Orchestrator SSE failed: ${res.status}`);
99
- const body = res.body;
100
- const reader = body.getReader();
101
- const decoder = new TextDecoder();
102
- const bufRef = { buf: '' };
103
- while (!abortSignal.aborted) {
104
- const { value, done } = await reader.read();
105
- if (done)
106
- break;
107
- await handleSseText(decoder.decode(value, { stream: true }), bufRef);
64
+ catch (e) {
65
+ logger?.warn?.(`[Dragon Plugin] Heartbeat failed: ${e.message}`);
108
66
  }
109
67
  }
110
- async connectHttp2(sseUrl, handleSseText) {
111
- const { logger, abortSignal } = this.options;
112
- const u = new URL(sseUrl);
113
- const origin = `${u.protocol}//${u.host}`;
114
- const pathWithQuery = `${u.pathname}${u.search || ''}`;
115
- const bufRef = { buf: '' };
116
- return new Promise((resolve, reject) => {
117
- const session = http2.connect(origin);
118
- let done = false;
119
- const finish = (err) => {
120
- if (done)
121
- return;
122
- done = true;
123
- try {
124
- session.close();
125
- }
126
- catch { }
127
- if (err)
128
- reject(err);
129
- else
130
- resolve();
131
- };
132
- abortSignal?.addEventListener?.('abort', () => finish({ name: 'AbortError' }));
133
- session.on('error', (err) => finish(err));
134
- const req = session.request({ ':method': 'GET', ':path': pathWithQuery, accept: 'text/event-stream' });
135
- req.setEncoding('utf8');
136
- req.on('data', (chunk) => handleSseText(chunk, bufRef));
137
- req.on('end', () => finish());
138
- req.end();
139
- });
140
- }
141
68
  }
@@ -0,0 +1,21 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ import { HttpComponent } from "../http/HttpComponent.js";
3
+ import { ChannelComponent } from "../channel/ChannelComponent.js";
4
+ export declare class SseComponent implements IComponent {
5
+ private http;
6
+ private channel;
7
+ private options;
8
+ private eventSource;
9
+ private shouldReconnect;
10
+ private reconnectTimer;
11
+ constructor(http: HttpComponent, channel: ChannelComponent, options: {
12
+ agentId: string;
13
+ logger?: any;
14
+ });
15
+ start(): Promise<void>;
16
+ stop(): Promise<void>;
17
+ private connect;
18
+ private startSseReader;
19
+ private handleEvent;
20
+ private scheduleReconnect;
21
+ }
@@ -0,0 +1,104 @@
1
+ export class SseComponent {
2
+ http;
3
+ channel;
4
+ options;
5
+ eventSource;
6
+ shouldReconnect = true;
7
+ reconnectTimer;
8
+ constructor(http, channel, options) {
9
+ this.http = http;
10
+ this.channel = channel;
11
+ this.options = options;
12
+ }
13
+ async start() {
14
+ this.connect();
15
+ }
16
+ async stop() {
17
+ this.shouldReconnect = false;
18
+ if (this.reconnectTimer)
19
+ clearTimeout(this.reconnectTimer);
20
+ this.eventSource?.close();
21
+ }
22
+ connect() {
23
+ if (!this.shouldReconnect)
24
+ return;
25
+ const { agentId, logger } = this.options;
26
+ const url = `${this.http.options.baseURL}/api/agents/events`;
27
+ logger?.info?.(`[Dragon Plugin] Connecting to Orchestrator SSE: ${url}`);
28
+ try {
29
+ // In Node 22, we might need a fetch-based SSE or a library,
30
+ // but for simplicity we'll use the same pattern as Bridge if possible.
31
+ // Actually, standard EventSource is not in Node globals yet.
32
+ // We'll use a simple fetch-based stream reader.
33
+ this.startSseReader(url);
34
+ }
35
+ catch (err) {
36
+ logger?.error?.(`[Dragon Plugin] SSE Init Failed: ${err.message}`);
37
+ this.scheduleReconnect();
38
+ }
39
+ }
40
+ async startSseReader(url) {
41
+ try {
42
+ const response = await fetch(url, {
43
+ headers: {
44
+ 'Accept': 'text/event-stream',
45
+ 'Authorization': `Bearer ${this.http.options.authToken || ''}`
46
+ }
47
+ });
48
+ if (!response.ok) {
49
+ throw new Error(`SSE connection failed with status ${response.status}`);
50
+ }
51
+ const reader = response.body?.getReader();
52
+ if (!reader)
53
+ throw new Error("Response body is null");
54
+ this.options.logger?.debug?.(`[Dragon Plugin] SSE Stream Established.`);
55
+ const decoder = new TextDecoder();
56
+ let buffer = "";
57
+ while (this.shouldReconnect) {
58
+ const { value, done } = await reader.read();
59
+ if (done)
60
+ break;
61
+ buffer += decoder.decode(value, { stream: true });
62
+ const lines = buffer.split("\n");
63
+ buffer = lines.pop() || "";
64
+ for (const line of lines) {
65
+ if (line.startsWith("data: ")) {
66
+ try {
67
+ const data = JSON.parse(line.slice(6));
68
+ this.handleEvent(data);
69
+ }
70
+ catch (e) { }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ catch (err) {
76
+ if (this.shouldReconnect) {
77
+ this.options.logger?.warn?.(`[Dragon Plugin] SSE Stream Disconnected: ${err.message}`);
78
+ this.scheduleReconnect();
79
+ }
80
+ }
81
+ }
82
+ handleEvent(data) {
83
+ const { agentId, logger } = this.options;
84
+ // 1. Filter for events belonging to this agent
85
+ if (data.agentId && data.agentId !== agentId)
86
+ return;
87
+ // 2. Handle specific types
88
+ if (data.type === 'WORKBENCH_MESSAGE') {
89
+ const { content, sessionId, attachments, id } = data.payload || {};
90
+ logger?.info?.(`[Dragon Plugin] [SSE] Received from Workbench: "${content?.substring(0, 50)}${content?.length > 50 ? '...' : ''}" [Session: ${sessionId}]`);
91
+ this.channel.deliverToOpenClaw(content, sessionId, undefined, attachments, id);
92
+ }
93
+ else if (data.type === 'FETCH_HISTORY') {
94
+ const { sessionId } = data.payload || {};
95
+ logger?.info?.(`[Dragon Plugin] Triggering history sync for session: ${sessionId}`);
96
+ // Handle history sync if needed, but deliverToOpenClaw usually handles normal chat
97
+ }
98
+ }
99
+ scheduleReconnect() {
100
+ if (this.reconnectTimer)
101
+ clearTimeout(this.reconnectTimer);
102
+ this.reconnectTimer = setTimeout(() => this.connect(), 5000);
103
+ }
104
+ }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { PollingComponent } from "./components/sync/PollingComponent.js";
7
7
  import { TelemetryComponent } from "./components/telemetry/TelemetryComponent.js";
8
8
  import { HttpComponent } from "./components/http/HttpComponent.js";
9
9
  import { ChannelComponent } from "./components/channel/ChannelComponent.js";
10
+ import { SseComponent } from "./components/sync/SseComponent.js";
10
11
  const channelId = "dragon";
11
12
  let cachedRuntime;
12
13
  const containers = new Map();
@@ -24,6 +25,7 @@ async function getOrCreateContainer(account, ctx) {
24
25
  }));
25
26
  const telemetry = container.register('telemetry', new TelemetryComponent(http, account.agentId));
26
27
  const bridge = container.register('bridge', new BridgeComponent({
28
+ host: account.bridgeHost,
27
29
  port: parseInt(account.bridgePort || "18799", 10),
28
30
  token: account.bridgeToken || "",
29
31
  agentId: account.agentId,
@@ -39,10 +41,15 @@ async function getOrCreateContainer(account, ctx) {
39
41
  cfg: ctx.cfg,
40
42
  logger
41
43
  }, bridge, telemetry));
42
- // 3. Polling Infrastructure
44
+ // 3. Sync Infrastructure (SSE + Polling)
45
+ container.register('sse', new SseComponent(http, channel, {
46
+ agentId: account.agentId,
47
+ logger
48
+ }));
43
49
  container.register('polling', new PollingComponent(http, channel, {
44
50
  agentId: account.agentId,
45
51
  accountId: account.accountId,
52
+ channelRuntime: ctx.channelRuntime,
46
53
  abortSignal: ctx.abortSignal,
47
54
  logger
48
55
  }));
@@ -64,11 +71,13 @@ const base = createChannelPluginBase({
64
71
  },
65
72
  resolveAccount: (cfg, accountId = "default") => {
66
73
  const accountConfig = cfg.channels?.[channelId]?.accounts?.[accountId] || {};
74
+ const host = accountConfig.bridgeHost || accountConfig.host;
67
75
  return {
68
76
  accountId,
69
77
  agentId: accountConfig.agentId || accountId,
70
78
  orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
71
79
  orchestratorAuthToken: accountConfig.orchestratorAuthToken || accountConfig.authToken,
80
+ bridgeHost: host,
72
81
  bridgePort: accountConfig.bridgePort,
73
82
  bridgeToken: accountConfig.bridgeToken,
74
83
  gatewayPort: accountConfig.gatewayPort,
package/dist/ws.d.ts CHANGED
@@ -7,6 +7,7 @@ export type DragonBridgeClient = {
7
7
  close: () => Promise<void>;
8
8
  };
9
9
  export declare function connectToDragonBridge(params: {
10
+ host?: string;
10
11
  port: number;
11
12
  token?: string;
12
13
  onConnected?: () => void;
package/dist/ws.js CHANGED
@@ -6,7 +6,8 @@ export function connectToDragonBridge(params) {
6
6
  if (!shouldReconnect)
7
7
  return;
8
8
  // Node 22 global WebSocket
9
- const url = `ws://127.0.0.1:${params.port}/__dragon/ws?token=${params.token || ""}`;
9
+ const host = params.host || '127.0.0.1';
10
+ const url = `ws://${host}:${params.port}/__dragon/ws?token=${params.token || ""}`;
10
11
  console.log(`[Dragon Plugin] Connecting to bridge at ${url}...`);
11
12
  try {
12
13
  ws = new global.WebSocket(url);
@@ -2,7 +2,7 @@
2
2
  "id": "dragon",
3
3
  "name": "Dragon Workbench Channel",
4
4
  "description": "Connect OpenClaw to the Dragon agent-client workbench chat.",
5
- "version": "0.4.9",
5
+ "version": "0.5.1",
6
6
  "enabledByDefault": true,
7
7
  "activation": {
8
8
  "onCapabilities": ["hook"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efengx/openclaw-channel-dragon",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",
@@ -11,6 +11,10 @@
11
11
  "openclaw.plugin.json",
12
12
  "README.md"
13
13
  ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "lint": "echo \"(skip)\""
17
+ },
14
18
  "dependencies": {
15
19
  "openclaw": "^2026.4.12"
16
20
  },
@@ -32,9 +36,5 @@
32
36
  },
33
37
  "publishConfig": {
34
38
  "access": "public"
35
- },
36
- "scripts": {
37
- "build": "tsc -p tsconfig.json",
38
- "lint": "echo \"(skip)\""
39
39
  }
40
- }
40
+ }