@efengx/openclaw-channel-dragon 0.5.30 → 0.5.33

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.
@@ -16,6 +16,8 @@ export declare class ChannelComponent implements IComponent {
16
16
  start(): Promise<void>;
17
17
  stop(): Promise<void>;
18
18
  private normalizeSessionSegment;
19
+ private encodeWorkbenchTarget;
20
+ private decodeWorkbenchTarget;
19
21
  private resolveOpenClawAgentId;
20
22
  private buildOpenClawSessionKey;
21
23
  private resolveWorkbenchSessionIdFromOpenClawSessionKey;
@@ -27,6 +29,12 @@ export declare class ChannelComponent implements IComponent {
27
29
  handleOutboundText: (ctx: any) => Promise<{
28
30
  ok: boolean;
29
31
  messageId: string;
32
+ error: any;
33
+ } | {
34
+ ok: boolean;
35
+ messageId: string;
36
+ error?: undefined;
30
37
  }>;
38
+ reportTargetProtocolError: (message: string, rawTarget: unknown) => Promise<void>;
31
39
  handleAgentEvent: (evt: any) => Promise<void>;
32
40
  }
@@ -1,5 +1,6 @@
1
1
  import { dragonChannelPluginVersion } from "../../version.js";
2
2
  const channelId = "dragon";
3
+ const workbenchTargetPrefix = "dragon-workbench:";
3
4
  export class ChannelComponent {
4
5
  options;
5
6
  telemetry;
@@ -20,6 +21,21 @@ export class ChannelComponent {
20
21
  .slice(0, 64);
21
22
  return normalized || fallback;
22
23
  }
24
+ encodeWorkbenchTarget(sessionId) {
25
+ const value = String(sessionId || 'default').trim() || 'default';
26
+ return `${workbenchTargetPrefix}${value}`;
27
+ }
28
+ decodeWorkbenchTarget(value) {
29
+ const target = String(value || '').trim();
30
+ if (!target.startsWith(workbenchTargetPrefix)) {
31
+ throw new Error(`Invalid dragon workbench target "${target || '<empty>'}"; expected "${workbenchTargetPrefix}<sessionId>"`);
32
+ }
33
+ const sessionId = target.slice(workbenchTargetPrefix.length).trim();
34
+ if (!sessionId) {
35
+ throw new Error(`Invalid dragon workbench target "${target}"; sessionId is empty`);
36
+ }
37
+ return sessionId;
38
+ }
23
39
  resolveOpenClawAgentId() {
24
40
  const agents = this.options.cfg?.agents?.list;
25
41
  if (Array.isArray(agents)) {
@@ -136,7 +152,7 @@ export class ChannelComponent {
136
152
  ctx: {
137
153
  Body: content,
138
154
  From: sessionId === 'default' ? "workbench-user" : `workbench-user-${sessionId}`,
139
- To: sessionId === 'default' ? "dragon-workbench" : `dragon-workbench-${sessionId}`,
155
+ To: this.encodeWorkbenchTarget(sessionId),
140
156
  ChatType: "direct",
141
157
  Provider: channelId,
142
158
  ChannelId: channelId,
@@ -355,10 +371,34 @@ export class ChannelComponent {
355
371
  handleOutboundText = async (ctx) => {
356
372
  const text = ctx?.text || "";
357
373
  const { logger } = this.options;
358
- logger?.info?.(`[dragon channels][Dragon Plugin] Outbound Text (Action): "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
359
- await this.telemetry.reportReply({ content: text, sessionId: ctx?.peer?.id || "default", source: "channel_outbound" });
374
+ let sessionId;
375
+ try {
376
+ sessionId = this.decodeWorkbenchTarget(ctx?.peer?.id);
377
+ }
378
+ catch (error) {
379
+ const message = error?.message || String(error);
380
+ logger?.error?.(`[dragon channels][Dragon Plugin] outbound target protocol error: ${message}`);
381
+ await this.reportTargetProtocolError(message, ctx?.peer?.id);
382
+ return { ok: false, messageId: Date.now().toString(), error: message };
383
+ }
384
+ logger?.info?.(`[dragon channels][Dragon Plugin] Outbound Text (Action): "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" [Session: ${sessionId}] [RawPeer: ${ctx?.peer?.id || 'none'}]`);
385
+ await this.telemetry.reportReply({ content: text, sessionId, source: "channel_outbound" });
360
386
  return { ok: true, messageId: Date.now().toString() };
361
387
  };
388
+ reportTargetProtocolError = async (message, rawTarget) => {
389
+ const content = [
390
+ '⚠️ Dragon channel target 协议错误。',
391
+ `错误信息:${message}`,
392
+ `收到的 target:${String(rawTarget || '<empty>')}`,
393
+ `期望格式:${workbenchTargetPrefix}<sessionId>`,
394
+ ].join('\n');
395
+ await this.telemetry.reportReply({
396
+ content,
397
+ sessionId: 'default',
398
+ source: 'target_protocol_error',
399
+ msgId: `dragon_target_error_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
400
+ });
401
+ };
362
402
  handleAgentEvent = async (evt) => {
363
403
  const sessionId = evt?.sessionId ||
364
404
  this.resolveWorkbenchSessionIdFromOpenClawSessionKey(evt?.sessionKey);
@@ -5,17 +5,30 @@ export declare class SseComponent implements IComponent {
5
5
  private http;
6
6
  private channel;
7
7
  private options;
8
- private eventSource;
9
8
  private shouldReconnect;
10
9
  private reconnectTimer;
10
+ private idleTimer;
11
+ private fetchAbort;
12
+ private reader;
13
+ private reconnectAttempts;
14
+ private connectionSeq;
15
+ private lastTransportActivityAt;
11
16
  constructor(http: HttpComponent, channel: ChannelComponent, options: {
12
17
  agentId: string;
18
+ accountId: string;
13
19
  version: string;
14
20
  logger?: any;
21
+ abortSignal?: AbortSignal;
22
+ setStatus?: (next: Record<string, unknown>) => void;
23
+ reconnectMs?: number;
24
+ idleTimeoutMs?: number;
15
25
  });
16
26
  start(): Promise<void>;
17
27
  stop(): Promise<void>;
18
28
  private connect;
29
+ private touchTransport;
30
+ private armIdleWatchdog;
31
+ private markDisconnected;
19
32
  private startSseReader;
20
33
  private handleEvent;
21
34
  private scheduleReconnect;
@@ -2,44 +2,123 @@ export class SseComponent {
2
2
  http;
3
3
  channel;
4
4
  options;
5
- eventSource;
6
5
  shouldReconnect = true;
7
6
  reconnectTimer;
7
+ idleTimer;
8
+ fetchAbort;
9
+ reader;
10
+ reconnectAttempts = 0;
11
+ connectionSeq = 0;
12
+ lastTransportActivityAt = 0;
8
13
  constructor(http, channel, options) {
9
14
  this.http = http;
10
15
  this.channel = channel;
11
16
  this.options = options;
12
17
  }
13
18
  async start() {
19
+ this.options.logger?.info?.(`[dragon channels][SSE] component start: version=${this.options.version}, agent=${this.options.agentId}, account=${this.options.accountId}, baseUrl=${this.http.options.baseURL}`);
20
+ this.options.setStatus?.({
21
+ accountId: this.options.accountId,
22
+ connected: false,
23
+ lastError: null,
24
+ });
25
+ this.options.abortSignal?.addEventListener('abort', () => {
26
+ this.options.logger?.info?.(`[dragon channels][SSE] upstream abort received: agent=${this.options.agentId}, account=${this.options.accountId}`);
27
+ void this.stop();
28
+ }, { once: true });
14
29
  this.connect();
15
30
  }
16
31
  async stop() {
17
32
  this.shouldReconnect = false;
18
33
  if (this.reconnectTimer)
19
34
  clearTimeout(this.reconnectTimer);
20
- this.eventSource?.close();
35
+ this.reconnectTimer = undefined;
36
+ if (this.idleTimer)
37
+ clearTimeout(this.idleTimer);
38
+ this.idleTimer = undefined;
39
+ this.fetchAbort?.abort();
40
+ this.fetchAbort = undefined;
41
+ try {
42
+ await this.reader?.cancel?.();
43
+ }
44
+ catch {
45
+ // Reader cancellation can race with fetch abort.
46
+ }
47
+ this.reader = undefined;
48
+ this.options.setStatus?.({
49
+ accountId: this.options.accountId,
50
+ connected: false,
51
+ lastStopAt: Date.now(),
52
+ });
53
+ this.options.logger?.info?.(`[dragon channels][SSE] component stopped: agent=${this.options.agentId}, account=${this.options.accountId}`);
21
54
  }
22
55
  connect() {
23
56
  if (!this.shouldReconnect)
24
57
  return;
25
58
  const { agentId, logger } = this.options;
26
- const url = `${this.http.options.baseURL}/api/agents/events`;
27
- logger?.info?.(`[dragon channels][Dragon Plugin] v${this.options.version} connecting to Orchestrator SSE: ${url} (agent=${agentId})`);
59
+ const params = new URLSearchParams({
60
+ client: 'dragon-channel',
61
+ agentId,
62
+ accountId: this.options.accountId,
63
+ version: this.options.version,
64
+ });
65
+ const url = `${this.http.options.baseURL}/api/agents/events?${params.toString()}`;
66
+ const connectionId = `${Date.now()}-${++this.connectionSeq}`;
67
+ logger?.info?.(`[dragon channels][SSE] connect start: connection=${connectionId}, attempt=${this.reconnectAttempts + 1}, version=${this.options.version}, url=${url}, agent=${agentId}, account=${this.options.accountId}`);
28
68
  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);
69
+ void this.startSseReader(url, connectionId);
34
70
  }
35
71
  catch (err) {
36
- logger?.error?.(`[dragon channels][Dragon Plugin] SSE Init Failed: ${err.message}`);
37
- this.scheduleReconnect();
72
+ logger?.error?.(`[dragon channels][SSE] connect init failed: connection=${connectionId}, error=${err.message}`);
73
+ this.markDisconnected(err?.message || String(err));
74
+ this.scheduleReconnect('init_failed');
75
+ }
76
+ }
77
+ touchTransport(connectionId, reason) {
78
+ const now = Date.now();
79
+ this.lastTransportActivityAt = now;
80
+ this.options.setStatus?.({
81
+ accountId: this.options.accountId,
82
+ connected: true,
83
+ lastEventAt: now,
84
+ lastTransportActivityAt: now,
85
+ lastError: null,
86
+ });
87
+ this.options.logger?.debug?.(`[dragon channels][SSE] transport activity: connection=${connectionId}, reason=${reason}, at=${now}`);
88
+ this.armIdleWatchdog(connectionId);
89
+ }
90
+ armIdleWatchdog(connectionId) {
91
+ if (this.idleTimer)
92
+ clearTimeout(this.idleTimer);
93
+ const timeoutMs = this.options.idleTimeoutMs ?? 90_000;
94
+ this.idleTimer = setTimeout(() => {
95
+ if (!this.shouldReconnect)
96
+ return;
97
+ const idleMs = Date.now() - this.lastTransportActivityAt;
98
+ this.options.logger?.warn?.(`[dragon channels][SSE] idle timeout: connection=${connectionId}, idleMs=${idleMs}, timeoutMs=${timeoutMs}; aborting fetch for reconnect`);
99
+ this.markDisconnected(`SSE idle timeout after ${idleMs}ms`);
100
+ this.fetchAbort?.abort();
101
+ }, timeoutMs);
102
+ if (typeof this.idleTimer === 'object' && 'unref' in this.idleTimer) {
103
+ this.idleTimer.unref();
38
104
  }
39
105
  }
40
- async startSseReader(url) {
106
+ markDisconnected(error) {
107
+ this.options.setStatus?.({
108
+ accountId: this.options.accountId,
109
+ connected: false,
110
+ lastError: error,
111
+ lastTransportActivityAt: this.lastTransportActivityAt || null,
112
+ });
113
+ }
114
+ async startSseReader(url, connectionId) {
115
+ const abort = new AbortController();
116
+ this.fetchAbort = abort;
117
+ const upstreamAbort = () => abort.abort();
118
+ this.options.abortSignal?.addEventListener('abort', upstreamAbort, { once: true });
41
119
  try {
42
120
  const response = await fetch(url, {
121
+ signal: abort.signal,
43
122
  headers: {
44
123
  'Accept': 'text/event-stream',
45
124
  'Authorization': `Bearer ${this.http.options.authToken || ''}`
@@ -51,38 +130,87 @@ export class SseComponent {
51
130
  const reader = response.body?.getReader();
52
131
  if (!reader)
53
132
  throw new Error("Response body is null");
54
- this.options.logger?.debug?.(`[dragon channels][Dragon Plugin] SSE Stream Established.`);
133
+ this.reader = reader;
134
+ this.reconnectAttempts = 0;
135
+ const now = Date.now();
136
+ this.lastTransportActivityAt = now;
137
+ this.options.setStatus?.({
138
+ accountId: this.options.accountId,
139
+ connected: true,
140
+ lastConnectedAt: now,
141
+ lastTransportActivityAt: now,
142
+ lastError: null,
143
+ reconnectAttempts: 0,
144
+ });
145
+ this.options.logger?.info?.(`[dragon channels][SSE] stream established: connection=${connectionId}, agent=${this.options.agentId}, account=${this.options.accountId}`);
146
+ this.armIdleWatchdog(connectionId);
55
147
  const decoder = new TextDecoder();
56
148
  let buffer = "";
57
149
  while (this.shouldReconnect) {
58
150
  const { value, done } = await reader.read();
59
- if (done)
151
+ if (done) {
152
+ this.options.logger?.warn?.(`[dragon channels][SSE] stream ended by remote: connection=${connectionId}`);
60
153
  break;
154
+ }
155
+ this.touchTransport(connectionId, 'chunk');
61
156
  buffer += decoder.decode(value, { stream: true });
62
157
  const lines = buffer.split("\n");
63
158
  buffer = lines.pop() || "";
64
159
  for (const line of lines) {
65
160
  const cleanLine = line.trim();
161
+ if (!cleanLine || cleanLine.startsWith(':')) {
162
+ this.touchTransport(connectionId, cleanLine.startsWith(':') ? 'heartbeat' : 'blank_line');
163
+ continue;
164
+ }
66
165
  if (cleanLine.startsWith("data: ")) {
67
166
  try {
68
167
  const data = JSON.parse(cleanLine.slice(6));
168
+ this.options.logger?.debug?.(`[dragon channels][SSE] event received: connection=${connectionId}, type=${data?.type || 'unknown'}, agent=${data?.agentId || 'unknown'}`);
69
169
  void this.handleEvent(data).catch((err) => {
70
- this.options.logger?.error?.(`[dragon channels][Dragon Plugin] SSE event handling failed: ${err?.message || err}`);
170
+ this.options.logger?.error?.(`[dragon channels][SSE] event handling failed: connection=${connectionId}, error=${err?.message || err}`);
71
171
  });
72
172
  }
73
173
  catch (e) {
74
- this.options.logger?.error?.(`[dragon channels][Dragon Plugin] SSE event JSON parse failed for line: "${cleanLine}", error: ${e?.message || e}`);
174
+ this.options.logger?.error?.(`[dragon channels][SSE] event JSON parse failed: connection=${connectionId}, line="${cleanLine}", error=${e?.message || e}`);
75
175
  }
76
176
  }
77
177
  }
78
178
  }
179
+ if (this.shouldReconnect) {
180
+ const message = 'SSE stream ended';
181
+ this.markDisconnected(message);
182
+ this.scheduleReconnect(message);
183
+ }
79
184
  }
80
185
  catch (err) {
81
186
  if (this.shouldReconnect) {
82
- this.options.logger?.warn?.(`[dragon channels][Dragon Plugin] SSE Stream Disconnected: ${err.message}`);
83
- this.scheduleReconnect();
187
+ const message = err?.name === 'AbortError'
188
+ ? 'SSE fetch aborted'
189
+ : (err?.message || String(err));
190
+ this.options.logger?.warn?.(`[dragon channels][SSE] stream disconnected: connection=${connectionId}, error=${message}`);
191
+ this.markDisconnected(message);
192
+ this.scheduleReconnect(message);
84
193
  }
85
194
  }
195
+ finally {
196
+ this.options.abortSignal?.removeEventListener('abort', upstreamAbort);
197
+ if (this.reader) {
198
+ try {
199
+ await this.reader.cancel?.();
200
+ }
201
+ catch {
202
+ // Ignore cancellation races.
203
+ }
204
+ }
205
+ if (this.fetchAbort === abort) {
206
+ this.fetchAbort = undefined;
207
+ }
208
+ if (this.idleTimer) {
209
+ clearTimeout(this.idleTimer);
210
+ this.idleTimer = undefined;
211
+ }
212
+ this.reader = undefined;
213
+ }
86
214
  }
87
215
  async handleEvent(data) {
88
216
  const { agentId, logger } = this.options;
@@ -101,9 +229,33 @@ export class SseComponent {
101
229
  // Handle history sync if needed, but deliverToOpenClaw usually handles normal chat
102
230
  }
103
231
  }
104
- scheduleReconnect() {
232
+ scheduleReconnect(reason) {
233
+ if (!this.shouldReconnect)
234
+ return;
105
235
  if (this.reconnectTimer)
106
236
  clearTimeout(this.reconnectTimer);
107
- this.reconnectTimer = setTimeout(() => this.connect(), 5000);
237
+ this.reconnectAttempts += 1;
238
+ const baseMs = this.options.reconnectMs ?? 5_000;
239
+ const delayMs = Math.min(60_000, baseMs * Math.max(1, this.reconnectAttempts));
240
+ this.options.setStatus?.({
241
+ accountId: this.options.accountId,
242
+ connected: false,
243
+ restartPending: true,
244
+ reconnectAttempts: this.reconnectAttempts,
245
+ lastError: reason,
246
+ });
247
+ this.options.logger?.info?.(`[dragon channels][SSE] reconnect scheduled: attempt=${this.reconnectAttempts}, delayMs=${delayMs}, reason=${reason}, agent=${this.options.agentId}, account=${this.options.accountId}`);
248
+ this.reconnectTimer = setTimeout(() => {
249
+ this.reconnectTimer = undefined;
250
+ this.options.setStatus?.({
251
+ accountId: this.options.accountId,
252
+ restartPending: false,
253
+ reconnectAttempts: this.reconnectAttempts,
254
+ });
255
+ this.connect();
256
+ }, delayMs);
257
+ if (typeof this.reconnectTimer === 'object' && 'unref' in this.reconnectTimer) {
258
+ this.reconnectTimer.unref();
259
+ }
108
260
  }
109
261
  }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { ChannelComponent } from "./components/channel/ChannelComponent.js";
8
8
  import { SseComponent } from "./components/sync/SseComponent.js";
9
9
  import { dragonChannelPluginVersion } from "./version.js";
10
10
  const channelId = "dragon";
11
+ const workbenchTargetPrefix = "dragon-workbench:";
11
12
  let cachedRuntime;
12
13
  const containers = new Map();
13
14
  function isDragonSessionKey(sessionKey) {
@@ -22,10 +23,31 @@ function resolveAccountIdFromSessionKey(sessionKey) {
22
23
  }
23
24
  return "default";
24
25
  }
26
+ function encodeWorkbenchTarget(sessionId) {
27
+ const value = String(sessionId || "default").trim() || "default";
28
+ return `${workbenchTargetPrefix}${value}`;
29
+ }
30
+ function decodeWorkbenchTarget(raw) {
31
+ const target = String(raw || "").trim();
32
+ if (!target.startsWith(workbenchTargetPrefix)) {
33
+ throw new Error(`Invalid dragon workbench target "${target || "<empty>"}"; expected "${workbenchTargetPrefix}<sessionId>"`);
34
+ }
35
+ const sessionId = target.slice(workbenchTargetPrefix.length).trim();
36
+ if (!sessionId) {
37
+ throw new Error(`Invalid dragon workbench target "${target}"; sessionId is empty`);
38
+ }
39
+ return sessionId;
40
+ }
41
+ function containerKey(account) {
42
+ return `${account.accountId}:${account.agentId}:${account.orchestratorUrl}`;
43
+ }
25
44
  async function getOrCreateContainer(account, ctx) {
26
- const key = `${account.agentId}:${account.orchestratorUrl}`;
27
- if (containers.has(key))
45
+ const key = containerKey(account);
46
+ if (containers.has(key)) {
47
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
48
+ logger?.debug?.(`[dragon channels][Dragon Plugin] Reusing channel container: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
28
49
  return containers.get(key);
50
+ }
29
51
  const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
30
52
  const container = new ServiceContainer();
31
53
  logger?.info?.(`[dragon channels][Dragon Plugin] Starting channel plugin v${dragonChannelPluginVersion} for agent=${account.agentId}, account=${account.accountId}, orchestrator=${account.orchestratorUrl}`);
@@ -51,13 +73,43 @@ async function getOrCreateContainer(account, ctx) {
51
73
  // 3. Sync Infrastructure
52
74
  container.register('sse', new SseComponent(http, channel, {
53
75
  agentId: account.agentId,
76
+ accountId: account.accountId,
54
77
  version: dragonChannelPluginVersion,
55
- logger
78
+ logger,
79
+ abortSignal: ctx.abortSignal,
80
+ setStatus: ctx.setStatus,
81
+ reconnectMs: 5_000,
82
+ idleTimeoutMs: 90_000,
56
83
  }));
57
84
  await container.startAll();
58
85
  containers.set(key, container);
86
+ logger?.info?.(`[dragon channels][Dragon Plugin] Channel container started: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
59
87
  return container;
60
88
  }
89
+ async function stopContainer(account, ctx) {
90
+ const key = containerKey(account);
91
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
92
+ const container = containers.get(key);
93
+ if (!container) {
94
+ logger?.debug?.(`[dragon channels][Dragon Plugin] No channel container to stop: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
95
+ return;
96
+ }
97
+ logger?.info?.(`[dragon channels][Dragon Plugin] Stopping channel container: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
98
+ try {
99
+ await container.stopAll();
100
+ }
101
+ finally {
102
+ containers.delete(key);
103
+ ctx?.setStatus?.({
104
+ accountId: account.accountId,
105
+ connected: false,
106
+ running: false,
107
+ restartPending: false,
108
+ lastStopAt: Date.now(),
109
+ });
110
+ logger?.info?.(`[dragon channels][Dragon Plugin] Channel container stopped: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
111
+ }
112
+ }
61
113
  const base = createChannelPluginBase({
62
114
  id: channelId,
63
115
  meta: {
@@ -80,7 +132,7 @@ const base = createChannelPluginBase({
80
132
  return {
81
133
  accountId,
82
134
  agentId: accountConfig.agentId || accountId,
83
- orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
135
+ orchestratorUrl: accountConfig.orchestratorUrl,
84
136
  orchestratorAuthToken: accountConfig.orchestratorAuthToken || accountConfig.authToken,
85
137
  };
86
138
  },
@@ -93,35 +145,45 @@ const plugin = createChatChannelPlugin({
93
145
  gateway: {
94
146
  startAccount: async (ctx) => {
95
147
  const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
96
- await getOrCreateContainer(account, ctx);
97
- // v0.5.6+: Prevent channel exit by waiting for abort signal
98
- if (ctx.abortSignal.aborted)
99
- return;
100
- await new Promise((resolve) => {
101
- ctx.abortSignal.addEventListener('abort', resolve, { once: true });
148
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
149
+ logger?.info?.(`[dragon channels][Dragon Plugin] startAccount begin: agent=${account.agentId}, account=${account.accountId}, orchestrator=${account.orchestratorUrl}`);
150
+ ctx.setStatus?.({
151
+ accountId: account.accountId,
152
+ enabled: true,
153
+ configured: true,
154
+ running: true,
155
+ connected: false,
156
+ lastStartAt: Date.now(),
157
+ lastError: null,
102
158
  });
159
+ try {
160
+ await getOrCreateContainer(account, ctx);
161
+ if (ctx.abortSignal.aborted)
162
+ return;
163
+ await new Promise((resolve) => {
164
+ ctx.abortSignal.addEventListener('abort', resolve, { once: true });
165
+ });
166
+ }
167
+ finally {
168
+ logger?.info?.(`[dragon channels][Dragon Plugin] startAccount exiting: agent=${account.agentId}, account=${account.accountId}, aborted=${ctx.abortSignal.aborted}`);
169
+ await stopContainer(account, ctx);
170
+ }
171
+ },
172
+ stopAccount: async (ctx) => {
173
+ const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
174
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
175
+ logger?.info?.(`[dragon channels][Dragon Plugin] stopAccount requested: agent=${account.agentId}, account=${account.accountId}`);
176
+ await stopContainer(account, ctx);
103
177
  }
104
178
  },
105
179
  messaging: {
106
- targetPrefixes: ["dragon-workbench", "dragon"],
180
+ targetPrefixes: [workbenchTargetPrefix],
107
181
  normalizeTarget: (raw) => {
108
- let target = raw || "";
109
- for (const prefix of ["dragon-workbench-", "dragon-workbench:", "dragon-", "dragon:"]) {
110
- if (target.startsWith(prefix)) {
111
- target = target.substring(prefix.length);
112
- }
113
- }
114
- return target ? `dragon-workbench-${target}` : "";
182
+ return encodeWorkbenchTarget(decodeWorkbenchTarget(raw));
115
183
  },
116
184
  resolveOutboundSessionRoute: (params) => {
117
- let target = params.target || "";
118
- for (const prefix of ["dragon-workbench-", "dragon-workbench:", "dragon-", "dragon:"]) {
119
- if (target.startsWith(prefix)) {
120
- target = target.substring(prefix.length);
121
- }
122
- }
123
- if (!target)
124
- return null;
185
+ const sessionId = decodeWorkbenchTarget(params.target);
186
+ const target = encodeWorkbenchTarget(sessionId);
125
187
  return buildChannelOutboundSessionRoute({
126
188
  cfg: params.cfg,
127
189
  agentId: params.agentId,
@@ -129,27 +191,22 @@ const plugin = createChatChannelPlugin({
129
191
  accountId: params.accountId,
130
192
  peer: {
131
193
  kind: "direct",
132
- id: target
194
+ id: sessionId
133
195
  },
134
196
  chatType: "direct",
135
- from: `dragon-workbench-${target}`,
136
- to: `dragon-workbench-${target}`
197
+ from: target,
198
+ to: target
137
199
  });
138
200
  },
139
201
  targetResolver: {
140
202
  looksLikeId: (raw) => {
141
- return raw.startsWith("dragon-workbench-") || raw.startsWith("session-");
203
+ return raw.startsWith(workbenchTargetPrefix);
142
204
  },
143
- hint: "<sessionId>",
205
+ hint: "dragon-workbench:<sessionId>",
144
206
  resolveTarget: async ({ input }) => {
145
- let sessionId = input;
146
- for (const prefix of ["dragon-workbench-", "dragon-workbench:", "dragon-", "dragon:"]) {
147
- if (sessionId.startsWith(prefix)) {
148
- sessionId = sessionId.substring(prefix.length);
149
- }
150
- }
207
+ const sessionId = decodeWorkbenchTarget(input);
151
208
  return {
152
- to: `dragon-workbench-${sessionId}`,
209
+ to: encodeWorkbenchTarget(sessionId),
153
210
  kind: "direct",
154
211
  display: `Dragon Workbench User (${sessionId})`,
155
212
  source: "normalized"
@@ -169,15 +226,28 @@ const plugin = createChatChannelPlugin({
169
226
  const account = base.config.resolveAccount(cfg, accountId);
170
227
  const container = await getOrCreateContainer(account, ctx);
171
228
  const channel = container.get('channel');
172
- let target = params.to || params.target || ctx.toolContext?.currentChannelId || "default";
173
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction target resolution input: ${target}`);
174
- for (const prefix of ["dragon-workbench-", "dragon-workbench:", "dragon-", "dragon:"]) {
175
- if (target.startsWith(prefix)) {
176
- target = target.substring(prefix.length);
177
- }
229
+ const rawTarget = params.to || params.target || ctx.toolContext?.currentChannelId || "default";
230
+ logger?.info?.(`[dragon channels][Dragon Plugin] handleAction target resolution input: ${rawTarget}`);
231
+ let sessionId;
232
+ let target;
233
+ try {
234
+ sessionId = decodeWorkbenchTarget(rawTarget);
235
+ target = encodeWorkbenchTarget(sessionId);
178
236
  }
179
- const sessionId = target || "default";
180
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction target resolved: sessionId=${sessionId}, rawTarget=${target}`);
237
+ catch (error) {
238
+ const message = error?.message || String(error);
239
+ logger?.error?.(`[dragon channels][Dragon Plugin] handleAction target protocol error: ${message}`);
240
+ await channel.reportTargetProtocolError(message, rawTarget);
241
+ return {
242
+ ok: false,
243
+ error: message,
244
+ didSendViaMessagingTool: false,
245
+ messagingToolSentTexts: [],
246
+ messagingToolSentMediaUrls: [],
247
+ messagingToolSentTargets: [],
248
+ };
249
+ }
250
+ logger?.info?.(`[dragon channels][Dragon Plugin] handleAction target resolved: sessionId=${sessionId}, target=${target}, rawTarget=${rawTarget}`);
181
251
  // Collect ALL media URLs from the params (mirrors what OpenClaw tracks internally
182
252
  // via collectMessagingMediaUrlsFromRecord when the tool call starts, so that
183
253
  // hasGatewayAgentDeliveredExpectedMedia can match them against expectedMediaUrls).
@@ -224,7 +294,7 @@ const plugin = createChatChannelPlugin({
224
294
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction sending text over channel: text_len=${text.length}, targetSession=${sessionId}`);
225
295
  const result = await channel.handleOutboundText({
226
296
  text,
227
- peer: { id: sessionId }
297
+ peer: { id: target }
228
298
  });
229
299
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction channel send result: ok=${result.ok}, messageId=${result.messageId || "none"}`);
230
300
  const evidence = {
@@ -272,12 +342,12 @@ const entry = defineChannelPluginEntry({
272
342
  return;
273
343
  const accountId = resolveAccountIdFromSessionKey(sessionKey);
274
344
  const account = base.config.resolveAccount(api?.runtime?.cfg, accountId);
275
- const container = await getOrCreateContainer(account, {
276
- cfg: api?.runtime?.cfg,
277
- abortSignal: new AbortController().signal,
278
- channelRuntime: api?.runtime?.channelRuntime,
279
- logger: api?.runtime?.logger ?? api?.runtime?.log,
280
- });
345
+ const logger = api?.runtime?.logger ?? api?.runtime?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
346
+ const container = containers.get(containerKey(account));
347
+ if (!container) {
348
+ logger?.warn?.(`[dragon channels][Dragon Plugin] agent event skipped because managed container is not running: sessionKey=${sessionKey}, account=${account.accountId}, agent=${account.agentId}`);
349
+ return;
350
+ }
281
351
  const channel = container.get('channel');
282
352
  await channel.handleAgentEvent(evt);
283
353
  });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const dragonChannelPluginVersion = "0.5.30";
1
+ export declare const dragonChannelPluginVersion = "0.5.33";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const dragonChannelPluginVersion = "0.5.30";
1
+ export const dragonChannelPluginVersion = "0.5.33";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efengx/openclaw-channel-dragon",
3
- "version": "0.5.30",
3
+ "version": "0.5.33",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",