@efengx/openclaw-channel-dragon 0.3.7 → 0.4.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.
@@ -0,0 +1,16 @@
1
+ import { type DragonBridgeClient } from "../../ws.js";
2
+ import { IComponent } from "../../core/IComponent.js";
3
+ export declare class BridgeComponent implements IComponent {
4
+ private options;
5
+ client: DragonBridgeClient | undefined;
6
+ constructor(options: {
7
+ port: number;
8
+ token: string;
9
+ agentId: string;
10
+ gatewayPort: number;
11
+ gatewayToken: string;
12
+ logger?: any;
13
+ });
14
+ start(): Promise<void>;
15
+ stop(): Promise<void>;
16
+ }
@@ -0,0 +1,44 @@
1
+ import { connectToDragonBridge } from "../../ws.js";
2
+ export class BridgeComponent {
3
+ options;
4
+ client;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ async start() {
9
+ const { port, token, agentId, gatewayPort, gatewayToken, logger } = this.options;
10
+ this.client = connectToDragonBridge({
11
+ port,
12
+ token,
13
+ onConnected: () => {
14
+ logger?.info?.("dragon channel: connected to dragon bridge");
15
+ this.client?.sendJson({
16
+ type: "hello",
17
+ channel: "dragon",
18
+ agentId,
19
+ port: gatewayPort,
20
+ token: gatewayToken,
21
+ version: "0.1.27",
22
+ timestamp: Date.now()
23
+ });
24
+ },
25
+ onDisconnected: () => {
26
+ logger?.info?.("dragon channel: disconnected from dragon bridge");
27
+ },
28
+ onMessage: (msg) => {
29
+ const t = msg?.type;
30
+ if (t === "ping") {
31
+ this.client?.sendJson({ type: "pong", t: Date.now() });
32
+ return;
33
+ }
34
+ if (t === "hot_control") {
35
+ const { command } = msg;
36
+ logger?.info?.(`dragon channel: received hot control: ${command}`);
37
+ }
38
+ },
39
+ });
40
+ }
41
+ async stop() {
42
+ // WebSocket close logic if needed
43
+ }
44
+ }
@@ -0,0 +1,13 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ export interface HttpOptions {
3
+ baseURL: string;
4
+ authToken?: string;
5
+ logger?: any;
6
+ }
7
+ export declare class HttpComponent implements IComponent {
8
+ private options;
9
+ constructor(options: HttpOptions);
10
+ start(): Promise<void>;
11
+ stop(): Promise<void>;
12
+ fetch(path: string, init?: RequestInit): Promise<Response>;
13
+ }
@@ -0,0 +1,27 @@
1
+ export class HttpComponent {
2
+ options;
3
+ constructor(options) {
4
+ this.options = options;
5
+ }
6
+ async start() {
7
+ this.options.logger?.info?.(`[Dragon-Channel] HttpComponent started for ${this.options.baseURL}`);
8
+ }
9
+ async stop() { }
10
+ async fetch(path, init) {
11
+ const url = `${this.options.baseURL}${path}`;
12
+ const headers = {
13
+ ...(init?.headers || {}),
14
+ };
15
+ if (this.options.authToken) {
16
+ headers['Authorization'] = `Bearer ${this.options.authToken}`;
17
+ }
18
+ const res = await fetch(url, {
19
+ ...init,
20
+ headers
21
+ });
22
+ if (!res.ok && res.status !== 404) {
23
+ this.options.logger?.warn?.(`[Dragon-Channel] HTTP Request failed: ${res.status} ${url}`);
24
+ }
25
+ return res;
26
+ }
27
+ }
@@ -0,0 +1,20 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ import { HttpComponent } from "../http/HttpComponent.js";
3
+ export declare class PollingComponent implements IComponent {
4
+ private http;
5
+ private options;
6
+ private pollInterval;
7
+ constructor(http: HttpComponent, options: {
8
+ agentId: string;
9
+ accountId: string;
10
+ abortSignal: AbortSignal;
11
+ logger?: any;
12
+ deliverToOpenClaw: (content: string, sessionId?: string, modelId?: string, attachments?: any[], messageId?: string | number) => Promise<void>;
13
+ });
14
+ start(): Promise<void>;
15
+ stop(): Promise<void>;
16
+ private consumePendingMessages;
17
+ private startLoop;
18
+ private connectOnce;
19
+ private connectHttp2;
20
+ }
@@ -0,0 +1,143 @@
1
+ import * as http2 from "node:http2";
2
+ export class PollingComponent {
3
+ http;
4
+ options;
5
+ pollInterval = null;
6
+ constructor(http, options) {
7
+ this.http = http;
8
+ this.options = options;
9
+ }
10
+ async start() {
11
+ this.startLoop();
12
+ // Safety: Periodic polling fallback every 60 seconds
13
+ this.pollInterval = setInterval(() => {
14
+ void this.consumePendingMessages();
15
+ }, 60_000);
16
+ }
17
+ async stop() {
18
+ if (this.pollInterval) {
19
+ clearInterval(this.pollInterval);
20
+ this.pollInterval = null;
21
+ }
22
+ }
23
+ async consumePendingMessages() {
24
+ const { agentId, logger, deliverToOpenClaw } = this.options;
25
+ try {
26
+ const res = await this.http.fetch(`/api/agents/${agentId}/messages/poll`);
27
+ if (res.ok) {
28
+ const data = (await res.json());
29
+ const messages = data.messages || [];
30
+ if (messages.length > 0) {
31
+ logger?.info?.(`dragon channel: [RECOVERY] Consuming ${messages.length} pending messages via polling.`);
32
+ for (const m of messages) {
33
+ await deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ catch (e) {
39
+ logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
40
+ }
41
+ }
42
+ async startLoop() {
43
+ const { logger, abortSignal } = this.options;
44
+ let attempt = 0;
45
+ while (!abortSignal.aborted) {
46
+ try {
47
+ attempt += 1;
48
+ logger?.debug?.(`dragon channel: SSE connecting (attempt ${attempt})`);
49
+ await this.connectOnce();
50
+ }
51
+ catch (e) {
52
+ if (e?.name === 'AbortError')
53
+ break;
54
+ logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`);
55
+ const delayMs = Math.min(10_000, 500 + attempt * 500);
56
+ await new Promise((r) => setTimeout(r, delayMs));
57
+ }
58
+ }
59
+ }
60
+ async connectOnce() {
61
+ const { agentId, logger, abortSignal, deliverToOpenClaw } = this.options;
62
+ const ssePath = `/api/agents/events`;
63
+ void this.consumePendingMessages();
64
+ const handleSseText = async (chunkText, bufRef) => {
65
+ bufRef.buf += chunkText;
66
+ let idx;
67
+ while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
68
+ const raw = bufRef.buf.slice(0, idx);
69
+ bufRef.buf = bufRef.buf.slice(idx + 2);
70
+ if (!raw.trim() || raw.trim().startsWith(':'))
71
+ continue;
72
+ const dataLines = raw
73
+ .split('\n')
74
+ .filter((l) => l.startsWith('data:'))
75
+ .map((l) => l.slice('data:'.length).trim());
76
+ if (!dataLines.length)
77
+ continue;
78
+ const dataStr = dataLines.join('\n');
79
+ try {
80
+ const evt = JSON.parse(dataStr);
81
+ if (evt?.type === 'WORKBENCH_MESSAGE' && evt.agentId === agentId) {
82
+ await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id);
83
+ }
84
+ }
85
+ catch (e) { }
86
+ }
87
+ };
88
+ const res = await this.http.fetch(ssePath, {
89
+ signal: abortSignal,
90
+ headers: { Accept: 'text/event-stream' },
91
+ });
92
+ if (res.status === 404) {
93
+ // HTTP/2 fallback logic (simplified for brevity here, original has full impl)
94
+ // Note: connectHttp2 should ideally also use HttpComponent logic, but it uses raw node http2
95
+ // We'll pass the auth token to it manually if needed.
96
+ // await this.connectHttp2(ssePath, handleSseText);
97
+ return;
98
+ }
99
+ if (!res.ok)
100
+ throw new Error(`Orchestrator SSE failed: ${res.status}`);
101
+ const body = res.body;
102
+ const reader = body.getReader();
103
+ const decoder = new TextDecoder();
104
+ const bufRef = { buf: '' };
105
+ while (!abortSignal.aborted) {
106
+ const { value, done } = await reader.read();
107
+ if (done)
108
+ break;
109
+ await handleSseText(decoder.decode(value, { stream: true }), bufRef);
110
+ }
111
+ }
112
+ async connectHttp2(sseUrl, handleSseText) {
113
+ const { logger, abortSignal } = this.options;
114
+ const u = new URL(sseUrl);
115
+ const origin = `${u.protocol}//${u.host}`;
116
+ const pathWithQuery = `${u.pathname}${u.search || ''}`;
117
+ const bufRef = { buf: '' };
118
+ return new Promise((resolve, reject) => {
119
+ const session = http2.connect(origin);
120
+ let done = false;
121
+ const finish = (err) => {
122
+ if (done)
123
+ return;
124
+ done = true;
125
+ try {
126
+ session.close();
127
+ }
128
+ catch { }
129
+ if (err)
130
+ reject(err);
131
+ else
132
+ resolve();
133
+ };
134
+ abortSignal?.addEventListener?.('abort', () => finish({ name: 'AbortError' }));
135
+ session.on('error', (err) => finish(err));
136
+ const req = session.request({ ':method': 'GET', ':path': pathWithQuery, accept: 'text/event-stream' });
137
+ req.setEncoding('utf8');
138
+ req.on('data', (chunk) => handleSseText(chunk, bufRef));
139
+ req.on('end', () => finish());
140
+ req.end();
141
+ });
142
+ }
143
+ }
@@ -0,0 +1,16 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ import { HttpComponent } from "../http/HttpComponent.js";
3
+ export declare class TelemetryComponent implements IComponent {
4
+ private http;
5
+ private agentId;
6
+ constructor(http: HttpComponent, agentId: string);
7
+ start(): Promise<void>;
8
+ stop(): Promise<void>;
9
+ reportReply(payload: {
10
+ content: string;
11
+ sessionId: string;
12
+ tool_calls?: any[];
13
+ reasoning_content?: string;
14
+ }): Promise<void>;
15
+ reportEvent(stream: string, data: any, ts: number): Promise<void>;
16
+ }
@@ -0,0 +1,54 @@
1
+ export class TelemetryComponent {
2
+ http;
3
+ agentId;
4
+ constructor(http, agentId) {
5
+ this.http = http;
6
+ this.agentId = agentId;
7
+ }
8
+ async start() { }
9
+ async stop() { }
10
+ async reportReply(payload) {
11
+ try {
12
+ await this.http.fetch(`/api/agents/${this.agentId}/messages/reply`, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(payload),
16
+ });
17
+ }
18
+ catch (e) {
19
+ // Error logged by HttpComponent
20
+ }
21
+ }
22
+ async reportEvent(stream, data, ts) {
23
+ try {
24
+ let telemetryPayload = null;
25
+ if (stream === "thinking") {
26
+ telemetryPayload = { content: "", ts, metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" } };
27
+ }
28
+ else if (stream === "tool" || stream === "item") {
29
+ const toolName = data?.name || data?.title || "unknown tool";
30
+ telemetryPayload = {
31
+ content: data?.output ? `[Tool Result] ${toolName}` : `[Tool Call] ${toolName}...`,
32
+ ts,
33
+ metadata: {
34
+ isTelemetry: true,
35
+ toolName,
36
+ toolInput: data?.input,
37
+ toolOutput: data?.output || data?.summary,
38
+ status: data?.output ? "completed" : "running",
39
+ stream: "tool"
40
+ }
41
+ };
42
+ }
43
+ if (telemetryPayload) {
44
+ this.http.fetch(`/api/agents/${this.agentId}/messages/reply`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify(telemetryPayload),
48
+ }).catch(() => { });
49
+ }
50
+ }
51
+ catch (e) {
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,4 @@
1
+ export interface IComponent {
2
+ start(): Promise<void>;
3
+ stop(): Promise<void>;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import { IComponent } from './IComponent.js';
2
+ export declare class ServiceContainer {
3
+ private components;
4
+ private started;
5
+ register(name: string, component: IComponent): IComponent;
6
+ get<T extends IComponent>(name: string): T;
7
+ startAll(): Promise<void>;
8
+ stopAll(): Promise<void>;
9
+ }
@@ -0,0 +1,30 @@
1
+ export class ServiceContainer {
2
+ components = new Map();
3
+ started = false;
4
+ register(name, component) {
5
+ this.components.set(name, component);
6
+ return component;
7
+ }
8
+ get(name) {
9
+ const component = this.components.get(name);
10
+ if (!component)
11
+ throw new Error(`Component ${name} not found`);
12
+ return component;
13
+ }
14
+ async startAll() {
15
+ if (this.started)
16
+ return;
17
+ for (const [name, component] of this.components) {
18
+ await component.start();
19
+ }
20
+ this.started = true;
21
+ }
22
+ async stopAll() {
23
+ if (!this.started)
24
+ return;
25
+ for (const [name, component] of this.components) {
26
+ await component.stop();
27
+ }
28
+ this.started = false;
29
+ }
30
+ }
package/dist/index.js CHANGED
@@ -1,497 +1,155 @@
1
- import * as http2 from "node:http2";
2
- import { defineChannelPluginEntry, createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugin-sdk/core";
1
+ import { defineChannelPluginEntry, createChatChannelPlugin, createChannelPluginBase } from "openclaw/plugin-sdk/core";
3
2
  import { createRawChannelSendResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
4
- // @ts-ignore
5
3
  import * as InfraRuntime from "openclaw/plugin-sdk/infra-runtime";
6
- // onAgentEvent is now accessed via runtime.events in registerFull
7
- // const onAgentEvent = (InfraRuntime as any).onAgentEvent;
8
- import { connectToDragonBridge } from "./ws.js";
9
- let bridgeClient;
10
- let cachedRuntime;
11
- let globalDispatcher;
12
- console.log("[Dragon] Plugin script is being evaluated by OpenClaw gateway...");
4
+ import { ServiceContainer } from "./core/ServiceContainer.js";
5
+ import { BridgeComponent } from "./components/bridge/BridgeComponent.js";
6
+ import { PollingComponent } from "./components/sync/PollingComponent.js";
7
+ import { TelemetryComponent } from "./components/telemetry/TelemetryComponent.js";
8
+ import { HttpComponent } from "./components/http/HttpComponent.js";
13
9
  const channelId = "dragon";
10
+ let cachedRuntime;
11
+ const containers = new Map();
14
12
  const processedMessageIds = new Set();
15
- async function ensureConnection(account, runtimeLogger) {
16
- if (bridgeClient)
17
- return;
18
- const port = parseInt(account.bridgePort || "18799", 10);
19
- const token = account.bridgeToken || "";
20
- const client = connectToDragonBridge({
21
- port,
22
- token,
23
- onConnected: () => {
24
- runtimeLogger?.info?.("dragon channel: connected to dragon bridge");
25
- client.sendJson({
26
- type: "hello",
27
- channel: channelId,
28
- agentId: account.agentId,
29
- port: parseInt(account.gatewayPort || "18789", 10),
30
- token: account.gatewayToken || "",
31
- version: "0.1.27",
32
- timestamp: Date.now()
33
- });
34
- },
35
- onDisconnected: () => {
36
- runtimeLogger?.info?.("dragon channel: disconnected from dragon bridge");
37
- },
38
- onMessage: (msg) => {
39
- const t = msg?.type;
40
- if (t === "ping") {
41
- client.sendJson({ type: "pong", t: Date.now() });
42
- return;
43
- }
44
- // Instructions from Dragon Client UI
45
- if (t === "hot_control") {
46
- const { command } = msg;
47
- runtimeLogger?.info?.(`dragon channel: received hot control: ${command}`);
48
- // Model switching is now handled directly via local openclaw.json / CLI by Dragon Client
49
- }
50
- },
51
- });
52
- bridgeClient = client;
53
- runtimeLogger?.info?.(`dragon channel: bridge client initialized on port ${port}`);
54
- }
55
- async function startPolling(ctx) {
56
- const account = ctx.account;
57
- const agentId = account.agentId;
58
- const orchestratorUrl = account.orchestratorUrl;
13
+ async function getOrCreateContainer(account, ctx) {
14
+ const key = `${account.agentId}:${account.orchestratorUrl}`;
15
+ if (containers.has(key))
16
+ return containers.get(key);
59
17
  const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
60
- if (!agentId) {
61
- logger?.error?.("dragon channel: agentId is missing in config, skipping polling");
62
- return;
63
- }
64
- logger?.info?.(`dragon channel: starting SSE loop for agent ${agentId} at ${orchestratorUrl}`);
65
- const replyDispatcher = ctx.channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
66
- if (typeof replyDispatcher !== 'function') {
67
- logger?.error?.("dragon channel: could not find reply.dispatchReplyWithBufferedBlockDispatcher in ctx.channelRuntime");
68
- if (ctx.channelRuntime) {
69
- logger?.info?.(`dragon channel: channelRuntime keys: ${Object.keys(ctx.channelRuntime).join(', ')}`);
70
- }
71
- return;
72
- }
18
+ const container = new ServiceContainer();
19
+ const http = container.register('http', new HttpComponent({
20
+ baseURL: account.orchestratorUrl,
21
+ authToken: account.orchestratorAuthToken || process.env.DRAGON_ORCHESTRATOR_AUTH_TOKEN || process.env.DRAGON_GATEWAY_TOKEN,
22
+ logger
23
+ }));
24
+ const bridge = container.register('bridge', new BridgeComponent({
25
+ port: parseInt(account.bridgePort || "18799", 10),
26
+ token: account.bridgeToken || "",
27
+ agentId: account.agentId,
28
+ gatewayPort: parseInt(account.gatewayPort || "18789", 10),
29
+ gatewayToken: account.gatewayToken || "",
30
+ logger
31
+ }));
32
+ const telemetry = container.register('telemetry', new TelemetryComponent(http, account.agentId));
73
33
  const deliverToOpenClaw = async (content, sessionId = 'default', modelId, attachments, messageId) => {
74
- const msg = String(content || '').trim();
75
- if (!msg && (!attachments || attachments.length === 0))
76
- return;
77
- // Deduplication check
78
- if (messageId && processedMessageIds.has(messageId)) {
34
+ if (messageId && processedMessageIds.has(messageId))
79
35
  return;
80
- }
81
36
  if (messageId) {
82
37
  processedMessageIds.add(messageId);
83
- // Keep cache size manageable
84
38
  if (processedMessageIds.size > 1000) {
85
39
  const first = processedMessageIds.values().next().value;
86
40
  if (first !== undefined)
87
41
  processedMessageIds.delete(first);
88
42
  }
89
43
  }
90
- // sessionKey format: channel:agentId:direct:peerId
91
- // peerId can be the sessionId for multi-session support
44
+ const replyDispatcher = ctx.channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
45
+ if (typeof replyDispatcher !== 'function')
46
+ return;
92
47
  const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
93
- logger?.info?.(`dragon channel: [LATENCY_CHECK] Dispatching to OpenClaw core... Session=${sessionId}`);
94
48
  await replyDispatcher({
95
49
  ctx: {
96
- Body: msg,
97
- BodyForAgent: msg,
98
- BodyForCommands: msg,
50
+ Body: content,
99
51
  From: sessionId === 'default' ? "workbench-user" : `workbench-user-${sessionId}`,
100
- SenderName: "Workbench User",
101
- SenderId: "workbench-user",
102
52
  To: account.agentId,
103
53
  ChatType: "direct",
104
54
  Provider: channelId,
105
55
  ChannelId: channelId,
106
- Surface: "dragon-workbench",
107
56
  AccountId: account.accountId,
108
57
  SessionKey: sessionKey,
109
- CommandSource: "native",
110
- CommandAuthorized: true,
111
58
  Timestamp: Date.now(),
112
59
  Model: modelId,
113
- Attachments: attachments, // Pass raw attachments for reference
60
+ Attachments: attachments,
114
61
  },
115
- // Building OpenClaw-native multi-modal blocks if images are present
116
- customBlocks: (attachments || [])
117
- .filter((a) => a.type === 'image')
118
- .map((a) => ({
119
- kind: 'image',
120
- data: a.data, // Should be base64 or URL
121
- mime: a.mime || 'image/png'
122
- })),
123
62
  cfg: ctx.cfg,
124
63
  dispatcherOptions: {
125
64
  deliver: async (payload) => {
126
65
  const text = payload?.text || "";
127
- const toolCalls = payload?.tool_calls || [];
128
- const reasoning = payload?.reasoning_content || "";
129
- if (!text && !toolCalls.length && !reasoning)
66
+ if (!text && !payload?.tool_calls?.length)
130
67
  return;
131
- logger?.info?.(`dragon channel: deliver callback [Session: ${sessionId}]. textLen=${text.length}, toolCalls=${toolCalls.length}, hasReasoning=${!!reasoning}`);
132
- // 1. Send to local bridge (WebSocket) for real-time UI
133
- if (bridgeClient) {
134
- bridgeClient.sendJson({
68
+ if (bridge.client) {
69
+ bridge.client.sendJson({
135
70
  type: "outbound_text",
136
71
  channel: channelId,
137
- messageId: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
138
72
  text,
139
- tool_calls: toolCalls,
140
- reasoning_content: reasoning,
141
- route: {
142
- agentId: account.agentId,
143
- accountId: account.accountId,
144
- peer: { kind: "direct", id: sessionId },
145
- sessionKey: sessionKey,
146
- },
73
+ tool_calls: payload?.tool_calls,
74
+ reasoning_content: payload?.reasoning_content,
75
+ route: { agentId: account.agentId, accountId: account.accountId, peer: { kind: "direct", id: sessionId }, sessionKey },
147
76
  });
148
77
  }
149
- // 2. Also send to Orchestrator (HTTP POST) for trajectory tracking
150
- try {
151
- const replyUrl = `${orchestratorUrl}/api/agents/${account.agentId}/messages/reply`;
152
- await fetch(replyUrl, {
153
- method: "POST",
154
- headers: { "Content-Type": "application/json" },
155
- body: JSON.stringify({
156
- content: text,
157
- sessionId,
158
- tool_calls: toolCalls,
159
- reasoning_content: reasoning,
160
- }),
161
- });
162
- }
163
- catch (e) {
164
- logger?.error?.(`dragon channel: failed to post deliver-reply to orchestrator: ${e.message}`);
165
- }
78
+ await telemetry.reportReply({
79
+ content: text,
80
+ sessionId,
81
+ tool_calls: payload?.tool_calls,
82
+ reasoning_content: payload?.reasoning_content,
83
+ });
166
84
  }
167
85
  }
168
86
  });
169
87
  };
170
- const consumePendingMessages = async () => {
171
- try {
172
- const pollUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/poll`;
173
- const res = await fetch(pollUrl);
174
- if (res.ok) {
175
- const data = (await res.json());
176
- const messages = data.messages || [];
177
- if (messages.length > 0) {
178
- logger?.info?.(`dragon channel: [RECOVERY] Consuming ${messages.length} pending messages via polling.`);
179
- for (const m of messages) {
180
- await deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id // Pass DB ID for deduplication
181
- );
182
- }
183
- }
184
- }
185
- }
186
- catch (e) {
187
- logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
188
- }
189
- };
190
- const connectOnce = async () => {
191
- const sseUrl = `${orchestratorUrl}/api/agents/events`;
192
- // Recovery: Catch up on any messages missed during downtime
193
- void consumePendingMessages();
194
- const handleSseText = async (chunkText, bufRef) => {
195
- // Diagnostic: log first chunk of SSE if it's not a heartbeat comment
196
- const isHeartbeat = chunkText.trim().startsWith(':');
197
- if (!bufRef.buf && chunkText.trim() && !isHeartbeat) {
198
- logger?.info?.(`dragon channel: SSE received first message chunk (len=${chunkText.length})`);
199
- }
200
- bufRef.buf += chunkText;
201
- let idx;
202
- while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
203
- const raw = bufRef.buf.slice(0, idx);
204
- bufRef.buf = bufRef.buf.slice(idx + 2);
205
- if (!raw.trim() || raw.trim().startsWith(':'))
206
- continue;
207
- const dataLines = raw
208
- .split('\n')
209
- .filter((l) => typeof l === 'string' && l.startsWith('data:'))
210
- .map((l) => l.slice('data:'.length).trim());
211
- if (!dataLines.length)
212
- continue;
213
- const dataStr = dataLines.join('\n');
214
- try {
215
- const evt = JSON.parse(dataStr);
216
- if (!evt)
217
- continue;
218
- if (evt.type === 'WORKBENCH_MESSAGE') {
219
- const sendTs = evt.payload?.ts || 0;
220
- const latency = sendTs ? (Date.now() - sendTs) : 'unknown';
221
- // Critical Log: Confirm receipt of SSE message
222
- logger?.info?.(`dragon channel: [SSE] RECEIVED WORKBENCH_MESSAGE. agentId=${evt.agentId} (Current Config: ${agentId}), sessionId=${evt.payload?.sessionId}, latency=${latency}ms`);
223
- if (evt.agentId !== agentId) {
224
- logger?.warn?.(`dragon channel: [SSE] ID Mismatch! Event ID "${evt.agentId}" !== Config ID "${agentId}". Ignoring message.`);
225
- continue;
226
- }
227
- logger?.info?.(`dragon channel: [DEBUG] Received WORKBENCH_MESSAGE via SSE. AgentID=${evt.agentId}, ContentLen=${evt.payload?.content?.length}, Latency=${latency}ms`);
228
- logger?.info?.(`dragon channel: [DEBUG] Payload Content: "${evt.payload?.content?.substring(0, 500)}"`);
229
- logger?.info?.(`dragon channel: [DEBUG] Current Account Config: ${JSON.stringify(account)}`);
230
- await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id // Pass DB ID for deduplication
231
- );
232
- }
233
- else if (evt.type === 'FETCH_HISTORY') {
234
- const { sessionId } = evt.payload;
235
- logger?.info?.(`dragon channel: [DEBUG] Received FETCH_HISTORY for session ${sessionId}`);
236
- try {
237
- // Retrieve history from OpenClaw core via channelRuntime
238
- // sessionKey format: dragon:agentId:direct:sessionId
239
- const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
240
- // Standard OpenClaw 2026.4.12+ history retrieval API
241
- const history = await ctx.channelRuntime?.history?.getMessages?.({
242
- sessionKey,
243
- limit: 50
244
- }) || [];
245
- logger?.info?.(`dragon channel: [DEBUG] Retrieved ${history.length} messages from OpenClaw core for sync.`);
246
- // Post back to orchestrator
247
- const syncUrl = `${orchestratorUrl}/api/agents/${account.agentId}/messages/sync-context`;
248
- await fetch(syncUrl, {
249
- method: "POST",
250
- headers: { "Content-Type": "application/json" },
251
- body: JSON.stringify({
252
- sessionId,
253
- messages: history.map((m) => ({
254
- role: m.role || (m.from === 'workbench-user' ? 'user' : 'assistant'),
255
- content: m.body || m.text || '',
256
- ts: m.timestamp || Date.now()
257
- }))
258
- }),
259
- });
260
- logger?.info?.(`dragon channel: [DEBUG] Successfully synced ${history.length} messages back to orchestrator.`);
261
- }
262
- catch (e) {
263
- logger?.error?.(`dragon channel: [ERROR] FETCH_HISTORY failed: ${e.message}`);
264
- }
265
- }
266
- }
267
- catch (e) {
268
- logger?.error?.(`dragon channel: failed to parse SSE data: ${e?.message || e}`, e?.stack);
269
- }
270
- }
271
- };
272
- // Diagnostic: log connection attempt
273
- logger?.info?.(`dragon channel: connecting to SSE (no-env-diag)...`);
274
- // First try: standard fetch (HTTP/1.1 in Node).
275
- const res = await fetch(sseUrl, {
276
- signal: ctx.abortSignal,
277
- headers: { Accept: 'text/event-stream' },
278
- });
279
- logger?.info?.(`dragon channel: fetch(${sseUrl}) status: ${res.status} ${res.statusText}`);
280
- // Some reverse proxy stacks may only route this endpoint over HTTP/2.
281
- // If we see a 404 from the edge, fall back to a native HTTP/2 client.
282
- if (res.status === 404) {
283
- const u = new URL(sseUrl);
284
- const origin = `${u.protocol}//${u.host}`;
285
- const pathWithQuery = `${u.pathname}${u.search || ''}`;
286
- const bufRef = { buf: '' };
287
- const decoder = new TextDecoder();
288
- await new Promise((resolve, reject) => {
289
- const session = http2.connect(origin);
290
- let done = false;
291
- const finish = (err) => {
292
- if (done)
293
- return;
294
- done = true;
295
- try {
296
- session.close();
297
- }
298
- catch { }
299
- if (err)
300
- reject(err);
301
- else
302
- resolve();
303
- };
304
- const onAbort = () => finish({ name: 'AbortError' });
305
- try {
306
- ctx.abortSignal?.addEventListener?.('abort', onAbort);
307
- }
308
- catch { }
309
- session.on('error', (err) => finish(err));
310
- const req = session.request({
311
- ':method': 'GET',
312
- ':path': pathWithQuery,
313
- accept: 'text/event-stream',
314
- });
315
- req.setEncoding('utf8');
316
- req.on('response', (headers) => {
317
- const status = Number(headers[':status'] || 0);
318
- if (status !== 200) {
319
- finish(new Error(`Orchestrator SSE failed: ${status}`));
320
- return;
321
- }
322
- });
323
- req.on('data', async (chunk) => {
324
- try {
325
- await handleSseText(chunk, bufRef);
326
- }
327
- catch (e) {
328
- finish(e);
329
- }
330
- });
331
- req.on('end', () => finish());
332
- req.on('close', () => finish());
333
- req.end();
334
- });
335
- return;
336
- }
337
- if (!res.ok)
338
- throw new Error(`Orchestrator SSE failed: ${res.status}`);
339
- const body = res.body;
340
- if (!body || typeof body.getReader !== 'function') {
341
- throw new Error('Orchestrator SSE: response body not readable');
342
- }
343
- const reader = body.getReader();
344
- const decoder = new TextDecoder();
345
- const bufRef = { buf: '' };
346
- while (!ctx.abortSignal.aborted) {
347
- const { value, done } = await reader.read();
348
- if (done)
349
- break;
350
- await handleSseText(decoder.decode(value, { stream: true }), bufRef);
351
- }
352
- };
353
- let attempt = 0;
354
- // Safety: Periodic polling fallback every 60 seconds
355
- const pollInterval = setInterval(() => {
356
- void consumePendingMessages();
357
- }, 60_000);
358
- while (!ctx.abortSignal.aborted) {
359
- try {
360
- attempt += 1;
361
- logger?.info?.(`dragon channel: SSE connecting (attempt ${attempt})`);
362
- await connectOnce();
363
- }
364
- catch (e) {
365
- if (e?.name === 'AbortError')
366
- break;
367
- logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`, e?.stack);
368
- // reconnect delay (cap 10s)
369
- const delayMs = Math.min(10_000, 500 + attempt * 500);
370
- await new Promise((r) => setTimeout(r, delayMs));
371
- }
372
- }
373
- clearInterval(pollInterval);
374
- logger?.info?.({ agentId }, "dragon channel: SSE loop terminated");
88
+ container.register('polling', new PollingComponent(http, {
89
+ agentId: account.agentId,
90
+ accountId: account.accountId,
91
+ abortSignal: ctx.abortSignal,
92
+ logger,
93
+ deliverToOpenClaw
94
+ }));
95
+ await container.startAll();
96
+ containers.set(key, container);
97
+ return container;
375
98
  }
376
99
  const base = createChannelPluginBase({
377
100
  id: channelId,
378
- meta: {
379
- label: "Dragon Workbench",
380
- selectionLabel: "Dragon Workbench",
381
- docsPath: "https://github.com/efengx/dragon",
382
- blurb: "Real-time communication bridge between OpenClaw and Dragon Workbench.",
383
- },
384
- capabilities: {
385
- chatTypes: ["direct", "group"],
386
- },
101
+ meta: { label: "Dragon Workbench", selectionLabel: "Dragon Workbench" },
102
+ capabilities: { chatTypes: ["direct", "group"] },
387
103
  setup: {
388
- async validate(_ctx) {
389
- return { ok: true };
390
- },
391
- async finalize(ctx) {
392
- const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
393
- await ensureConnection(ctx.account, logger);
394
- return { ok: true };
395
- },
104
+ validate: async () => ({ ok: true }),
105
+ finalize: async () => ({ ok: true })
396
106
  },
397
107
  config: {
398
- listAccountIds: (cfg) => {
399
- const channelConfig = cfg.channels?.[channelId];
400
- if (!channelConfig?.accounts)
401
- return ["default"];
402
- return Object.keys(channelConfig.accounts);
403
- },
404
108
  resolveAccount: (cfg, accountId = "default") => {
405
109
  const accountConfig = cfg.channels?.[channelId]?.accounts?.[accountId] || {};
406
- const agentId = accountConfig.agentId || accountId;
407
- const orchestratorUrl = accountConfig.orchestratorUrl || "http://127.0.0.1:4000";
408
- const bridgePort = accountConfig.bridgePort || "18799";
409
- const bridgeToken = accountConfig.bridgeToken || "";
410
- const gatewayPort = accountConfig.gatewayPort || "18789";
411
- const gatewayToken = accountConfig.gatewayToken || "";
412
- const now = Date.now();
413
- const lastLog = global.lastDragonAccountLogTime?.[accountId] || 0;
414
- if (now - lastLog > 10 * 60 * 1000) {
415
- console.log(`[Dragon] Resolved account ${accountId}: agentId=${agentId}, url=${orchestratorUrl}`);
416
- if (!global.lastDragonAccountLogTime)
417
- global.lastDragonAccountLogTime = {};
418
- global.lastDragonAccountLogTime[accountId] = now;
419
- }
420
110
  return {
421
111
  accountId,
422
- agentId,
423
- orchestratorUrl,
424
- bridgePort,
425
- bridgeToken,
426
- gatewayPort,
427
- gatewayToken,
112
+ agentId: accountConfig.agentId || accountId,
113
+ orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
114
+ orchestratorAuthToken: accountConfig.orchestratorAuthToken || accountConfig.authToken,
115
+ bridgePort: accountConfig.bridgePort,
116
+ bridgeToken: accountConfig.bridgeToken,
117
+ gatewayPort: accountConfig.gatewayPort,
118
+ gatewayToken: accountConfig.gatewayToken,
428
119
  };
429
120
  },
430
121
  },
431
122
  });
432
- const gateway = {
433
- startAccount: async (ctx) => {
434
- const account = ctx.account;
435
- const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
436
- logger?.info?.(`dragon channel: starting account gateway for agent ${account.agentId} at ${account.orchestratorUrl}`);
437
- // Background poll loop - AWAIT it to keep the account active in the gateway
438
- try {
439
- await startPolling(ctx);
440
- }
441
- catch (err) {
442
- logger?.error?.(`dragon channel: fatal poll error: ${err.message}`, err.stack);
443
- }
444
- },
445
- };
446
123
  const plugin = createChatChannelPlugin({
447
124
  base: {
448
125
  ...base,
449
- gateway,
126
+ capabilities: { chatTypes: ["direct", "group"] },
127
+ gateway: {
128
+ startAccount: async (ctx) => {
129
+ const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
130
+ await getOrCreateContainer(account, ctx);
131
+ }
132
+ },
450
133
  },
451
134
  outbound: {
452
135
  attachedResults: createRawChannelSendResultAdapter({
453
136
  channel: channelId,
454
137
  async sendText(ctx) {
455
- const text = ctx?.text ?? ctx?.payload?.text ?? "";
456
- const messageId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
457
- const account = ctx.account;
458
- const agentId = account?.agentId;
459
- const orchestratorUrl = account?.orchestratorUrl;
460
- const sessionKey = ctx?.ctx?.SessionKey ?? ctx?.sessionKey;
461
- const logger = cachedRuntime?.logger ?? cachedRuntime?.log;
462
- logger?.info?.(`dragon channel: sendText invoked. agentId=${agentId}, orchestratorUrl=${orchestratorUrl}, text="${text.substring(0, 30)}..."`);
463
- // 1. Send to local bridge (WebSocket)
464
- if (bridgeClient) {
465
- bridgeClient.sendJson({
138
+ const text = ctx?.text || "";
139
+ const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
140
+ const container = await getOrCreateContainer(account, ctx);
141
+ const bridge = container.get('bridge');
142
+ const telemetry = container.get('telemetry');
143
+ if (bridge.client) {
144
+ bridge.client.sendJson({
466
145
  type: "outbound_text",
467
146
  channel: channelId,
468
- messageId,
469
147
  text,
470
- route: {
471
- agentId,
472
- accountId: ctx?.accountId,
473
- peer: ctx?.peer,
474
- sessionKey,
475
- },
148
+ route: { agentId: account.agentId, accountId: account.accountId, peer: ctx?.peer, sessionKey: ctx?.ctx?.SessionKey },
476
149
  });
477
150
  }
478
- // 2. Send to Orchestrator (HTTP POST)
479
- if (agentId && orchestratorUrl) {
480
- try {
481
- const replyUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
482
- logger?.info?.(`dragon channel: posting reply to ${replyUrl}`);
483
- const res = await fetch(replyUrl, {
484
- method: "POST",
485
- headers: { "Content-Type": "application/json" },
486
- body: JSON.stringify({ content: text }),
487
- });
488
- logger?.info?.(`dragon channel: reply post status: ${res.status}`);
489
- }
490
- catch (e) {
491
- logger?.error?.(`dragon channel: failed to post reply to orchestrator: ${e.message}`);
492
- }
493
- }
494
- return { ok: true, messageId };
151
+ await telemetry.reportReply({ content: text, sessionId: ctx?.peer?.id || 'default' });
152
+ return { ok: true, messageId: Date.now().toString() };
495
153
  },
496
154
  }),
497
155
  },
@@ -500,145 +158,35 @@ const entry = defineChannelPluginEntry({
500
158
  id: channelId,
501
159
  name: "Dragon Workbench Channel",
502
160
  description: "Connect OpenClaw to the Dragon agent-client workbench chat.",
503
- configSchema: {
504
- type: "object",
505
- properties: {
506
- enabled: { type: "boolean" },
507
- activated: { type: "boolean" }
508
- }
509
- },
510
161
  plugin,
511
162
  registerFull(api) {
512
- console.log("[Dragon] registerFull() hook called by OpenClaw gateway");
513
163
  cachedRuntime = api.runtime;
514
- // Subscribe to real-time events (thinking, tool calls, etc.)
515
- // v2026.4.12+ : runtime.events.onAgentEvent
516
164
  const agentEventHandler = api.runtime?.events?.onAgentEvent || InfraRuntime.onAgentEvent;
517
165
  if (typeof agentEventHandler === 'function') {
518
- agentEventHandler((evt) => {
519
- const logger = cachedRuntime?.logger ?? cachedRuntime?.log;
520
- // Filter by sessionKey to ensure we only send events belonging to this channel
166
+ agentEventHandler(async (evt) => {
521
167
  const sessionKey = evt.sessionKey;
522
168
  if (!sessionKey || !sessionKey.startsWith(`${channelId}:`))
523
169
  return;
524
- // Extract details
525
- const { stream, data, runId } = evt;
526
- // Debug log for troubleshooting missing tool calls
527
- logger?.debug?.(`[Dragon] Received agent event: stream=${stream}, runId=${runId}, dataKeys=${Object.keys(data || {})}`);
528
- // We care about certain streams for the UI "Thinking" status
529
- if (stream === "thinking" || stream === "tool" || stream === "item" || stream === "plan") {
530
- // Extract agentId from sessionKey "dragon:agent-xxx:..."
531
- const agentId = sessionKey.split(':')[1] || "";
532
- const accountId = sessionKey.split(':')[2] || "default";
533
- const logger = cachedRuntime?.logger ?? cachedRuntime?.log;
534
- // 1. Report to Orchestrator (Async) for Web UI / Trajectory
535
- try {
536
- const cfg = api.runtime?.cfg;
537
- if (cfg) {
538
- const account = entry.resolveAccount(cfg, accountId);
539
- if (account?.orchestratorUrl) {
540
- const telemetryUrl = `${account.orchestratorUrl}/api/agents/${agentId}/messages/reply`;
541
- // Decide what to send to orchestrator
542
- let telemetryPayload = null;
543
- if (stream === "thinking") {
544
- telemetryPayload = {
545
- content: "", // Content can be empty for pure reasoning update
546
- ts: evt.ts,
547
- metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" }
548
- };
549
- }
550
- else if (stream === "tool" || (stream === "item" && data?.kind === "tool")) {
551
- const toolName = data?.name || data?.title || "unknown tool";
552
- if (data?.status === "completed" || data?.phase === "end" || data?.output) {
553
- telemetryPayload = {
554
- content: `[Tool Result] ${toolName}`,
555
- ts: evt.ts,
556
- metadata: {
557
- isTelemetry: true,
558
- toolName: toolName,
559
- toolInput: data?.input,
560
- toolOutput: data?.output || data?.summary,
561
- toolCallId: data?.toolCallId || runId,
562
- status: "completed",
563
- stream: "tool"
564
- }
565
- };
566
- }
567
- else {
568
- // Initial tool call info
569
- telemetryPayload = {
570
- content: `[Tool Call] ${toolName}...`,
571
- ts: evt.ts,
572
- metadata: {
573
- isTelemetry: true,
574
- toolName: toolName,
575
- toolInput: data?.input,
576
- status: "running",
577
- stream: "tool"
578
- }
579
- };
580
- }
581
- }
582
- else if (stream === "plan") {
583
- telemetryPayload = {
584
- content: data?.explanation || `Plan Update: ${data?.title}`,
585
- ts: evt.ts,
586
- metadata: {
587
- isTelemetry: true,
588
- planTitle: data?.title,
589
- planSteps: data?.steps,
590
- stream: "plan"
591
- }
592
- };
593
- }
594
- if (telemetryPayload) {
595
- void fetch(telemetryUrl, {
596
- method: "POST",
597
- headers: { "Content-Type": "application/json" },
598
- body: JSON.stringify(telemetryPayload),
599
- }).catch(e => logger?.error?.(`[Dragon] Failed to sync telemetry: ${e.message}`));
600
- }
601
- }
602
- }
603
- }
604
- catch (e) {
605
- logger?.error?.(`[Dragon] Telemetry resolution failed: ${e.message}`);
606
- }
607
- // 2. Also send to local bridge (WebSocket)
608
- if (bridgeClient) {
609
- bridgeClient.sendJson({
610
- type: "agent_event",
611
- channel: channelId,
612
- agentId,
613
- runId,
614
- stream,
615
- data,
616
- sessionKey
617
- });
618
- }
170
+ const agentId = sessionKey.split(':')[1] || "";
171
+ const accountId = sessionKey.split(':')[2] || "default";
172
+ const account = base.config.resolveAccount(api.runtime.cfg, accountId);
173
+ const container = await getOrCreateContainer(account, { cfg: api.runtime.cfg, abortSignal: new AbortController().signal });
174
+ const bridge = container.get('bridge');
175
+ const telemetry = container.get('telemetry');
176
+ if (bridge.client) {
177
+ bridge.client.sendJson({
178
+ type: "agent_event",
179
+ channel: channelId,
180
+ agentId,
181
+ runId: evt.runId,
182
+ stream: evt.stream,
183
+ data: evt.data,
184
+ sessionKey
185
+ });
619
186
  }
187
+ await telemetry.reportEvent(evt.stream, evt.data, evt.ts);
620
188
  });
621
189
  }
622
- else {
623
- console.warn("[Dragon] onAgentEvent not found in runtime. Real-time status updates may be disabled.");
624
- }
625
- api.registerGatewayMethod("dragon.activate", async ({ respond, payload }) => {
626
- const logger = cachedRuntime?.logger ?? cachedRuntime?.log ?? cachedRuntime;
627
- // Resolve account for activation
628
- const accountId = payload?.accountId || "default";
629
- const account = entry.resolveAccount(api.runtime.cfg, accountId);
630
- await ensureConnection(account, logger);
631
- respond(true, { active: true });
632
- });
633
- api.registerGatewayMethod("dragon.status", async ({ respond }) => {
634
- respond(true, {
635
- active: !!bridgeClient,
636
- connected: !!bridgeClient,
637
- });
638
- });
639
- },
640
- setRuntime: (runtime) => {
641
- cachedRuntime = runtime;
642
- },
190
+ }
643
191
  });
644
192
  export default entry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efengx/openclaw-channel-dragon",
3
- "version": "0.3.7",
3
+ "version": "0.4.3",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",