@efengx/openclaw-channel-dragon 0.3.7 → 0.4.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.
@@ -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,19 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ export declare class PollingComponent implements IComponent {
3
+ private options;
4
+ private pollInterval;
5
+ constructor(options: {
6
+ agentId: string;
7
+ orchestratorUrl: string;
8
+ accountId: string;
9
+ abortSignal: AbortSignal;
10
+ logger?: any;
11
+ deliverToOpenClaw: (content: string, sessionId?: string, modelId?: string, attachments?: any[], messageId?: string | number) => Promise<void>;
12
+ });
13
+ start(): Promise<void>;
14
+ stop(): Promise<void>;
15
+ private consumePendingMessages;
16
+ private startLoop;
17
+ private connectOnce;
18
+ private connectHttp2;
19
+ }
@@ -0,0 +1,140 @@
1
+ import * as http2 from "node:http2";
2
+ export class PollingComponent {
3
+ options;
4
+ pollInterval = null;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ async start() {
9
+ this.startLoop();
10
+ // Safety: Periodic polling fallback every 60 seconds
11
+ this.pollInterval = setInterval(() => {
12
+ void this.consumePendingMessages();
13
+ }, 60_000);
14
+ }
15
+ async stop() {
16
+ if (this.pollInterval) {
17
+ clearInterval(this.pollInterval);
18
+ this.pollInterval = null;
19
+ }
20
+ }
21
+ async consumePendingMessages() {
22
+ const { agentId, orchestratorUrl, logger, deliverToOpenClaw } = this.options;
23
+ try {
24
+ const pollUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/poll`;
25
+ const res = await fetch(pollUrl);
26
+ if (res.ok) {
27
+ const data = (await res.json());
28
+ const messages = data.messages || [];
29
+ if (messages.length > 0) {
30
+ logger?.info?.(`dragon channel: [RECOVERY] Consuming ${messages.length} pending messages via polling.`);
31
+ for (const m of messages) {
32
+ await deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ catch (e) {
38
+ logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
39
+ }
40
+ }
41
+ async startLoop() {
42
+ const { agentId, orchestratorUrl, logger, abortSignal } = this.options;
43
+ let attempt = 0;
44
+ while (!abortSignal.aborted) {
45
+ try {
46
+ attempt += 1;
47
+ logger?.debug?.(`dragon channel: SSE connecting (attempt ${attempt})`);
48
+ await this.connectOnce();
49
+ }
50
+ catch (e) {
51
+ if (e?.name === 'AbortError')
52
+ break;
53
+ logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`);
54
+ const delayMs = Math.min(10_000, 500 + attempt * 500);
55
+ await new Promise((r) => setTimeout(r, delayMs));
56
+ }
57
+ }
58
+ }
59
+ async connectOnce() {
60
+ const { agentId, orchestratorUrl, logger, abortSignal, deliverToOpenClaw } = this.options;
61
+ const sseUrl = `${orchestratorUrl}/api/agents/events`;
62
+ void this.consumePendingMessages();
63
+ const handleSseText = async (chunkText, bufRef) => {
64
+ bufRef.buf += chunkText;
65
+ let idx;
66
+ while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
67
+ const raw = bufRef.buf.slice(0, idx);
68
+ bufRef.buf = bufRef.buf.slice(idx + 2);
69
+ if (!raw.trim() || raw.trim().startsWith(':'))
70
+ continue;
71
+ const dataLines = raw
72
+ .split('\n')
73
+ .filter((l) => l.startsWith('data:'))
74
+ .map((l) => l.slice('data:'.length).trim());
75
+ if (!dataLines.length)
76
+ continue;
77
+ const dataStr = dataLines.join('\n');
78
+ try {
79
+ const evt = JSON.parse(dataStr);
80
+ if (evt?.type === 'WORKBENCH_MESSAGE' && evt.agentId === agentId) {
81
+ await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id);
82
+ }
83
+ }
84
+ catch (e) { }
85
+ }
86
+ };
87
+ const res = await fetch(sseUrl, {
88
+ signal: abortSignal,
89
+ headers: { Accept: 'text/event-stream' },
90
+ });
91
+ if (res.status === 404) {
92
+ // HTTP/2 fallback logic (simplified for brevity here, original has full impl)
93
+ await this.connectHttp2(sseUrl, handleSseText);
94
+ return;
95
+ }
96
+ if (!res.ok)
97
+ throw new Error(`Orchestrator SSE failed: ${res.status}`);
98
+ const body = res.body;
99
+ const reader = body.getReader();
100
+ const decoder = new TextDecoder();
101
+ const bufRef = { buf: '' };
102
+ while (!abortSignal.aborted) {
103
+ const { value, done } = await reader.read();
104
+ if (done)
105
+ break;
106
+ await handleSseText(decoder.decode(value, { stream: true }), bufRef);
107
+ }
108
+ }
109
+ async connectHttp2(sseUrl, handleSseText) {
110
+ const { logger, abortSignal } = this.options;
111
+ const u = new URL(sseUrl);
112
+ const origin = `${u.protocol}//${u.host}`;
113
+ const pathWithQuery = `${u.pathname}${u.search || ''}`;
114
+ const bufRef = { buf: '' };
115
+ return new Promise((resolve, reject) => {
116
+ const session = http2.connect(origin);
117
+ let done = false;
118
+ const finish = (err) => {
119
+ if (done)
120
+ return;
121
+ done = true;
122
+ try {
123
+ session.close();
124
+ }
125
+ catch { }
126
+ if (err)
127
+ reject(err);
128
+ else
129
+ resolve();
130
+ };
131
+ abortSignal?.addEventListener?.('abort', () => finish({ name: 'AbortError' }));
132
+ session.on('error', (err) => finish(err));
133
+ const req = session.request({ ':method': 'GET', ':path': pathWithQuery, accept: 'text/event-stream' });
134
+ req.setEncoding('utf8');
135
+ req.on('data', (chunk) => handleSseText(chunk, bufRef));
136
+ req.on('end', () => finish());
137
+ req.end();
138
+ });
139
+ }
140
+ }
@@ -0,0 +1,18 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ export declare class TelemetryComponent implements IComponent {
3
+ private options;
4
+ constructor(options: {
5
+ agentId: string;
6
+ orchestratorUrl: string;
7
+ logger?: any;
8
+ });
9
+ start(): Promise<void>;
10
+ stop(): Promise<void>;
11
+ reportReply(payload: {
12
+ content: string;
13
+ sessionId: string;
14
+ tool_calls?: any[];
15
+ reasoning_content?: string;
16
+ }): Promise<void>;
17
+ reportEvent(stream: string, data: any, ts: number): Promise<void>;
18
+ }
@@ -0,0 +1,57 @@
1
+ export class TelemetryComponent {
2
+ options;
3
+ constructor(options) {
4
+ this.options = options;
5
+ }
6
+ async start() { }
7
+ async stop() { }
8
+ async reportReply(payload) {
9
+ const { agentId, orchestratorUrl, logger } = this.options;
10
+ try {
11
+ const replyUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
12
+ await fetch(replyUrl, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(payload),
16
+ });
17
+ }
18
+ catch (e) {
19
+ logger?.error?.(`dragon channel: failed to post reply to orchestrator: ${e.message}`);
20
+ }
21
+ }
22
+ async reportEvent(stream, data, ts) {
23
+ const { agentId, orchestratorUrl, logger } = this.options;
24
+ try {
25
+ const telemetryUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
26
+ let telemetryPayload = null;
27
+ if (stream === "thinking") {
28
+ telemetryPayload = { content: "", ts, metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" } };
29
+ }
30
+ else if (stream === "tool" || stream === "item") {
31
+ const toolName = data?.name || data?.title || "unknown tool";
32
+ telemetryPayload = {
33
+ content: data?.output ? `[Tool Result] ${toolName}` : `[Tool Call] ${toolName}...`,
34
+ ts,
35
+ metadata: {
36
+ isTelemetry: true,
37
+ toolName,
38
+ toolInput: data?.input,
39
+ toolOutput: data?.output || data?.summary,
40
+ status: data?.output ? "completed" : "running",
41
+ stream: "tool"
42
+ }
43
+ };
44
+ }
45
+ if (telemetryPayload) {
46
+ void fetch(telemetryUrl, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify(telemetryPayload),
50
+ }).catch(e => logger?.error?.(`[Dragon] Failed to sync telemetry: ${e.message}`));
51
+ }
52
+ }
53
+ catch (e) {
54
+ logger?.error?.(`[Dragon] Telemetry resolution failed: ${e.message}`);
55
+ }
56
+ }
57
+ }
@@ -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,153 @@
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";
13
8
  const channelId = "dragon";
9
+ let cachedRuntime;
10
+ const containers = new Map();
14
11
  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;
12
+ async function getOrCreateContainer(account, ctx) {
13
+ const key = `${account.agentId}:${account.orchestratorUrl}`;
14
+ if (containers.has(key))
15
+ return containers.get(key);
59
16
  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
- }
17
+ const container = new ServiceContainer();
18
+ const bridge = container.register('bridge', new BridgeComponent({
19
+ port: parseInt(account.bridgePort || "18799", 10),
20
+ token: account.bridgeToken || "",
21
+ agentId: account.agentId,
22
+ gatewayPort: parseInt(account.gatewayPort || "18789", 10),
23
+ gatewayToken: account.gatewayToken || "",
24
+ logger
25
+ }));
26
+ const telemetry = container.register('telemetry', new TelemetryComponent({
27
+ agentId: account.agentId,
28
+ orchestratorUrl: account.orchestratorUrl,
29
+ logger
30
+ }));
73
31
  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)) {
32
+ if (messageId && processedMessageIds.has(messageId))
79
33
  return;
80
- }
81
34
  if (messageId) {
82
35
  processedMessageIds.add(messageId);
83
- // Keep cache size manageable
84
36
  if (processedMessageIds.size > 1000) {
85
37
  const first = processedMessageIds.values().next().value;
86
38
  if (first !== undefined)
87
39
  processedMessageIds.delete(first);
88
40
  }
89
41
  }
90
- // sessionKey format: channel:agentId:direct:peerId
91
- // peerId can be the sessionId for multi-session support
42
+ const replyDispatcher = ctx.channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
43
+ if (typeof replyDispatcher !== 'function')
44
+ return;
92
45
  const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
93
- logger?.info?.(`dragon channel: [LATENCY_CHECK] Dispatching to OpenClaw core... Session=${sessionId}`);
94
46
  await replyDispatcher({
95
47
  ctx: {
96
- Body: msg,
97
- BodyForAgent: msg,
98
- BodyForCommands: msg,
48
+ Body: content,
99
49
  From: sessionId === 'default' ? "workbench-user" : `workbench-user-${sessionId}`,
100
- SenderName: "Workbench User",
101
- SenderId: "workbench-user",
102
50
  To: account.agentId,
103
51
  ChatType: "direct",
104
52
  Provider: channelId,
105
53
  ChannelId: channelId,
106
- Surface: "dragon-workbench",
107
54
  AccountId: account.accountId,
108
55
  SessionKey: sessionKey,
109
- CommandSource: "native",
110
- CommandAuthorized: true,
111
56
  Timestamp: Date.now(),
112
57
  Model: modelId,
113
- Attachments: attachments, // Pass raw attachments for reference
58
+ Attachments: attachments,
114
59
  },
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
60
  cfg: ctx.cfg,
124
61
  dispatcherOptions: {
125
62
  deliver: async (payload) => {
126
63
  const text = payload?.text || "";
127
- const toolCalls = payload?.tool_calls || [];
128
- const reasoning = payload?.reasoning_content || "";
129
- if (!text && !toolCalls.length && !reasoning)
64
+ if (!text && !payload?.tool_calls?.length)
130
65
  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({
66
+ if (bridge.client) {
67
+ bridge.client.sendJson({
135
68
  type: "outbound_text",
136
69
  channel: channelId,
137
- messageId: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
138
70
  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
- },
71
+ tool_calls: payload?.tool_calls,
72
+ reasoning_content: payload?.reasoning_content,
73
+ route: { agentId: account.agentId, accountId: account.accountId, peer: { kind: "direct", id: sessionId }, sessionKey },
147
74
  });
148
75
  }
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
- }
76
+ await telemetry.reportReply({
77
+ content: text,
78
+ sessionId,
79
+ tool_calls: payload?.tool_calls,
80
+ reasoning_content: payload?.reasoning_content,
81
+ });
166
82
  }
167
83
  }
168
84
  });
169
85
  };
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");
86
+ container.register('polling', new PollingComponent({
87
+ agentId: account.agentId,
88
+ orchestratorUrl: account.orchestratorUrl,
89
+ accountId: account.accountId,
90
+ abortSignal: ctx.abortSignal,
91
+ logger,
92
+ deliverToOpenClaw
93
+ }));
94
+ await container.startAll();
95
+ containers.set(key, container);
96
+ return container;
375
97
  }
376
98
  const base = createChannelPluginBase({
377
99
  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
- },
100
+ meta: { label: "Dragon Workbench", selectionLabel: "Dragon Workbench" },
101
+ capabilities: { chatTypes: ["direct", "group"] },
387
102
  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
- },
103
+ validate: async () => ({ ok: true }),
104
+ finalize: async () => ({ ok: true })
396
105
  },
397
106
  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
107
  resolveAccount: (cfg, accountId = "default") => {
405
108
  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
109
  return {
421
110
  accountId,
422
- agentId,
423
- orchestratorUrl,
424
- bridgePort,
425
- bridgeToken,
426
- gatewayPort,
427
- gatewayToken,
111
+ agentId: accountConfig.agentId || accountId,
112
+ orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
113
+ bridgePort: accountConfig.bridgePort,
114
+ bridgeToken: accountConfig.bridgeToken,
115
+ gatewayPort: accountConfig.gatewayPort,
116
+ gatewayToken: accountConfig.gatewayToken,
428
117
  };
429
118
  },
430
119
  },
431
120
  });
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
121
  const plugin = createChatChannelPlugin({
447
122
  base: {
448
123
  ...base,
449
- gateway,
124
+ capabilities: { chatTypes: ["direct", "group"] },
125
+ gateway: {
126
+ startAccount: async (ctx) => {
127
+ const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
128
+ await getOrCreateContainer(account, ctx);
129
+ }
130
+ },
450
131
  },
451
132
  outbound: {
452
133
  attachedResults: createRawChannelSendResultAdapter({
453
134
  channel: channelId,
454
135
  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({
136
+ const text = ctx?.text || "";
137
+ const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
138
+ const container = await getOrCreateContainer(account, ctx);
139
+ const bridge = container.get('bridge');
140
+ const telemetry = container.get('telemetry');
141
+ if (bridge.client) {
142
+ bridge.client.sendJson({
466
143
  type: "outbound_text",
467
144
  channel: channelId,
468
- messageId,
469
145
  text,
470
- route: {
471
- agentId,
472
- accountId: ctx?.accountId,
473
- peer: ctx?.peer,
474
- sessionKey,
475
- },
146
+ route: { agentId: account.agentId, accountId: account.accountId, peer: ctx?.peer, sessionKey: ctx?.ctx?.SessionKey },
476
147
  });
477
148
  }
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 };
149
+ await telemetry.reportReply({ content: text, sessionId: ctx?.peer?.id || 'default' });
150
+ return { ok: true, messageId: Date.now().toString() };
495
151
  },
496
152
  }),
497
153
  },
@@ -500,145 +156,35 @@ const entry = defineChannelPluginEntry({
500
156
  id: channelId,
501
157
  name: "Dragon Workbench Channel",
502
158
  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
159
  plugin,
511
160
  registerFull(api) {
512
- console.log("[Dragon] registerFull() hook called by OpenClaw gateway");
513
161
  cachedRuntime = api.runtime;
514
- // Subscribe to real-time events (thinking, tool calls, etc.)
515
- // v2026.4.12+ : runtime.events.onAgentEvent
516
162
  const agentEventHandler = api.runtime?.events?.onAgentEvent || InfraRuntime.onAgentEvent;
517
163
  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
164
+ agentEventHandler(async (evt) => {
521
165
  const sessionKey = evt.sessionKey;
522
166
  if (!sessionKey || !sessionKey.startsWith(`${channelId}:`))
523
167
  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
- }
168
+ const agentId = sessionKey.split(':')[1] || "";
169
+ const accountId = sessionKey.split(':')[2] || "default";
170
+ const account = base.config.resolveAccount(api.runtime.cfg, accountId);
171
+ const container = await getOrCreateContainer(account, { cfg: api.runtime.cfg, abortSignal: new AbortController().signal });
172
+ const bridge = container.get('bridge');
173
+ const telemetry = container.get('telemetry');
174
+ if (bridge.client) {
175
+ bridge.client.sendJson({
176
+ type: "agent_event",
177
+ channel: channelId,
178
+ agentId,
179
+ runId: evt.runId,
180
+ stream: evt.stream,
181
+ data: evt.data,
182
+ sessionKey
183
+ });
619
184
  }
185
+ await telemetry.reportEvent(evt.stream, evt.data, evt.ts);
620
186
  });
621
187
  }
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
- },
188
+ }
643
189
  });
644
190
  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.0",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",