dlinkclaw-openclaw 2026.10.20 → 2026.10.22

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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dlinkclaw-openclaw",
3
- "version": "2026.10.20",
3
+ "version": "2026.10.22",
4
4
  "description": "DLinkClaw OpenClaw channel plugin — 朵联 OpenClaw 接入通道",
5
5
  "keywords": [
6
6
  "DLinkClaw",
@@ -92,5 +92,8 @@
92
92
  "homepage": "https://github.com/dlinkclaw/dlinkclaw-openclaw-channel#readme",
93
93
  "bugs": {
94
94
  "url": "https://github.com/dlinkclaw/dlinkclaw-openclaw-channel/issues"
95
+ },
96
+ "optionalDependencies": {
97
+ "@lydell/node-pty": "^1.2.0-beta.15"
95
98
  }
96
99
  }
@@ -147,7 +147,7 @@ export function checkConfigured(config) {
147
147
  * 1. npm install qrcode-terminal
148
148
  * 2. 取消顶部 import qrcode 的注释
149
149
  * 3. 取消底部 qrcode.generate 的注释
150
- * 4. 替换 registerUrl 为沃联实际注册地址
150
+ * 4. 替换 registerUrl 为朵联实际注册地址
151
151
  */
152
152
  function getDLIntroLines(registerUrl) {
153
153
  const lines = [
@@ -163,12 +163,14 @@ function getDLIntroLines(registerUrl) {
163
163
  // } catch (_) {
164
164
  // // 二维码生成失败,降级显示纯文本链接
165
165
  // }
166
- lines.push("📱 扫码或在浏览器打开以下链接注册沃联账号:", registerUrl, "", "━━━ 方式二:已有 APIKEY ━━━", "直接按提示输入即可。");
166
+ lines.push("📱 扫码或在浏览器打开以下链接注册朵联账号:", registerUrl, "", "━━━ 方式二:已有 APIKEY ━━━", "直接按提示输入即可。");
167
167
  return lines;
168
168
  }
169
169
  export function dlSetupWizard(channelId) {
170
170
  return {
171
171
  channel: channelId,
172
+ // 2026-10-22:先输入 BaseUrl(textInputs),再输入 API Key / Secret(credentials)
173
+ stepOrder: "text-first",
172
174
  status: {
173
175
  configuredLabel: "已配置 ✓",
174
176
  unconfiguredLabel: "需要 baseUrl, APIKey 和 Secret",
@@ -180,11 +182,11 @@ export function dlSetupWizard(channelId) {
180
182
  return configured;
181
183
  },
182
184
  resolveStatusLines: ({ configured }) => {
183
- return [`沃联 WorkRelay: ${configured ? "已配置" : "需要 baseUrl, APIKey 和 Secret"}`];
185
+ return [`朵联 WorkRelay: ${configured ? "已配置" : "需要 baseUrl, APIKey 和 Secret"}`];
184
186
  },
185
187
  },
186
188
  introNote: {
187
- title: "🔧 沃联 WorkRelay 设置向导",
189
+ title: "🔧 朵联 WorkRelay 设置向导",
188
190
  lines: getDLIntroLines(process.env.DL_REGISTER_URL || "https://WorkRelay.cn/register"),
189
191
  shouldShow: ({ cfg, accountId }) => {
190
192
  const account = resolveDLAccount({ cfg, accountId }, channelId);
@@ -192,31 +194,13 @@ export function dlSetupWizard(channelId) {
192
194
  },
193
195
  },
194
196
  credentials: [
195
- {
196
- inputKey: "url",
197
- providerHint: "沃联回调地址 (BaseUrl)",
198
- credentialLabel: "BaseUrl",
199
- envPrompt: "使用环境变量中的 DL_BASEURL?",
200
- keepPrompt: "BaseUrl 已配置,保留当前值?",
201
- inputPrompt: "请输入沃联回调地址 (BaseUrl):",
202
- inspect: ({ cfg, accountId }) => {
203
- const account = resolveDLAccount({ cfg, accountId }, channelId);
204
- const hasValue = Boolean(account.config.baseUrl?.trim());
205
- return {
206
- accountConfigured: hasValue,
207
- hasConfiguredValue: hasValue,
208
- resolvedValue: account.config.baseUrl || undefined,
209
- };
210
- },
211
- applySet: ({ cfg, accountId, resolvedValue }) => setDLChannelAccountParamsValue({ cfg, accountId, 'param': 'baseUrl', value: resolvedValue }, channelId)
212
- },
213
197
  {
214
198
  inputKey: "token",
215
- providerHint: "沃联 API Key",
199
+ providerHint: "朵联 API Key",
216
200
  credentialLabel: "API Key",
217
201
  envPrompt: "使用环境变量中的 DL_APIKEY?",
218
202
  keepPrompt: "API Key 已配置,保留当前值?",
219
- inputPrompt: "请输入沃联 API Key:",
203
+ inputPrompt: "请输入朵联 API Key:",
220
204
  inspect: ({ cfg, accountId }) => {
221
205
  const account = resolveDLAccount({ cfg, accountId }, channelId);
222
206
  const hasValue = Boolean(account.config.apiKey?.trim());
@@ -230,11 +214,11 @@ export function dlSetupWizard(channelId) {
230
214
  },
231
215
  {
232
216
  inputKey: "privateKey",
233
- providerHint: "沃联签名密钥 (Secret)",
217
+ providerHint: "朵联签名密钥 (Secret)",
234
218
  credentialLabel: "Secret",
235
219
  envPrompt: "使用环境变量中的 DL_SECRET?",
236
220
  keepPrompt: "Secret 已配置,保留当前值?",
237
- inputPrompt: "请输入沃联签名密钥 (Secret):",
221
+ inputPrompt: "请输入朵联签名密钥 (Secret):",
238
222
  inspect: ({ cfg, accountId }) => {
239
223
  const account = resolveDLAccount({ cfg, accountId }, channelId);
240
224
  const hasValue = Boolean(account.config.secret?.trim());
@@ -246,7 +230,21 @@ export function dlSetupWizard(channelId) {
246
230
  },
247
231
  applySet: ({ cfg, accountId, resolvedValue }) => setDLChannelAccountParamsValue({ cfg, accountId, 'param': 'secret', value: resolvedValue }, channelId)
248
232
  },
249
- ]
233
+ ],
234
+ // 2026-10-22:BaseUrl 不是密钥,必须放 textInputs;放 credentials 会被向导掩码成 ***
235
+ textInputs: [
236
+ {
237
+ inputKey: "url",
238
+ message: "请输入朵联回调地址 (BaseUrl):",
239
+ placeholder: "https://your-dlink-server.example.com",
240
+ required: true,
241
+ currentValue: ({ cfg, accountId }) => {
242
+ const account = resolveDLAccount({ cfg, accountId }, channelId);
243
+ return account.config.baseUrl?.trim() || undefined;
244
+ },
245
+ applySet: ({ cfg, accountId, value }) => setDLChannelAccountParamsValue({ cfg, accountId, 'param': 'baseUrl', value }, channelId),
246
+ },
247
+ ],
250
248
  };
251
249
  }
252
250
  export function dlSetupAdapter(channelId) {
@@ -323,7 +321,7 @@ export function dlAccountStatus(channelId) {
323
321
  channel: channelId,
324
322
  accountId,
325
323
  kind: "config",
326
- message: "沃联 baseUrl, apiKey, secret 未配置",
324
+ message: "朵联 baseUrl, apiKey, secret 未配置",
327
325
  fix: `Run: openclaw channels add ${channelId} --base-url <baseUrl> --apiKey <apiKey> --secret <secret>`,
328
326
  });
329
327
  }
@@ -2,6 +2,7 @@ import { listDuolinkConfiguredAccounts, resolveDLAccount } from "./account.js";
2
2
  import { getDLLogger, getDLRuntime } from "./runtime.js";
3
3
  import { DLClient } from "./client/dl-client.js";
4
4
  import { checkDmPolicy, checkGroupPolicy } from "./policy.js";
5
+ import { bufferPendingMessage } from "./pending-pairing.js";
5
6
  import { buildMessageContext, parseMessageContent, routeAndDispatchMessage } from "./client/message.js";
6
7
  import { withTimeout } from "./client/timeout.js";
7
8
  import { getDLClient, removeDLClient, setDLClient } from "./client/client-manager.js";
@@ -165,6 +166,12 @@ async function duolinkMessageHandler(dlCtx, client) {
165
166
  logger
166
167
  });
167
168
  if (!dmPolicy.allowed) {
169
+ // 2026-10-22:首次未配对时原消息会被配对码回复取代而丢失,这里暂存,
170
+ // 由 pairing.approve 审批通过后补发(见 services.ts handleApprove)。
171
+ if (dmPolicy.pendingPairing) {
172
+ bufferPendingMessage(relayId, () => duolinkMessageHandler(dlCtx, client));
173
+ logger.info(`${accountLogInfo} message buffered until pairing approved, relayId=${relayId}`);
174
+ }
168
175
  client.publishToServer({
169
176
  streamId: msg.streamId,
170
177
  content: '[tag_error_policy_refuse]',
@@ -0,0 +1,7 @@
1
+ export declare function bufferPendingMessage(key: string, replay: () => Promise<void>): void;
2
+ /**
3
+ * 补发暂存消息,返回补发条数。
4
+ * key 为空时补发所有(兜底场景)。
5
+ */
6
+ export declare function flushPendingMessages(key?: string): number;
7
+ export declare function pendingMessageCount(key?: string): number;
@@ -0,0 +1,70 @@
1
+ const MAX_PER_KEY = 20;
2
+ const MAX_KEYS = 100;
3
+ const TTL_MS = 10 * 60 * 1000;
4
+ const pending = new Map();
5
+ function purgeExpired(now = Date.now()) {
6
+ for (const [key, list] of pending) {
7
+ const alive = list.filter((entry) => now - entry.at <= TTL_MS);
8
+ if (alive.length === 0) {
9
+ pending.delete(key);
10
+ }
11
+ else {
12
+ pending.set(key, alive);
13
+ }
14
+ }
15
+ }
16
+ export function bufferPendingMessage(key, replay) {
17
+ if (!key) {
18
+ return;
19
+ }
20
+ const now = Date.now();
21
+ purgeExpired(now);
22
+ const list = pending.get(key) ?? [];
23
+ list.push({ replay, at: now });
24
+ while (list.length > MAX_PER_KEY) {
25
+ list.shift();
26
+ }
27
+ pending.set(key, list);
28
+ while (pending.size > MAX_KEYS) {
29
+ const oldest = pending.keys().next().value;
30
+ if (oldest === undefined) {
31
+ break;
32
+ }
33
+ pending.delete(oldest);
34
+ }
35
+ }
36
+ /**
37
+ * 补发暂存消息,返回补发条数。
38
+ * key 为空时补发所有(兜底场景)。
39
+ */
40
+ export function flushPendingMessages(key) {
41
+ purgeExpired();
42
+ const keys = key ? [key] : Array.from(pending.keys());
43
+ let count = 0;
44
+ for (const k of keys) {
45
+ const list = pending.get(k);
46
+ if (!list || list.length === 0) {
47
+ continue;
48
+ }
49
+ pending.delete(k);
50
+ for (const entry of list) {
51
+ count += 1;
52
+ // 串行补发,避免同时打爆消息链路
53
+ Promise.resolve()
54
+ .then(() => entry.replay())
55
+ .catch(() => { });
56
+ }
57
+ }
58
+ return count;
59
+ }
60
+ export function pendingMessageCount(key) {
61
+ purgeExpired();
62
+ if (key) {
63
+ return pending.get(key)?.length ?? 0;
64
+ }
65
+ let total = 0;
66
+ for (const list of pending.values()) {
67
+ total += list.length;
68
+ }
69
+ return total;
70
+ }
@@ -7,6 +7,8 @@ export type DLGroupPolicyCheckResult = {
7
7
  export type DmPolicyCheckResult = {
8
8
  allowed: boolean;
9
9
  allowFrom?: Array<string | number>;
10
+ /** 2026-10-22:因未配对被拦截(消息可暂存,配对后补发) */
11
+ pendingPairing?: boolean;
10
12
  };
11
13
  export declare function checkGroupPolicy(params: {
12
14
  chatId: string;
@@ -174,7 +174,8 @@ export async function checkDmPolicy(params) {
174
174
  client.publishEventToDLClient(pairingEvent, 'onPairingRequest', '0').catch(err => logger.error(`${logPreTag}发送对码事件失败:${String(err)}`));
175
175
  }
176
176
  logger.warn(`account[${account.accountId}] sender=${senderId} access denied`);
177
- return { allowed: false };
177
+ // 2026-10-22:pairing 模式下拦截的消息可暂存,审批通过后补发
178
+ return { allowed: false, pendingPairing: dmPolicy === "pairing" };
178
179
  }
179
180
  /**
180
181
  * 设置 dmPolicy
@@ -21,6 +21,9 @@ export class CmdHandler {
21
21
  // 2026-10-21:终端输出经 MQTT 事件回推客户端(协议级能力,品牌无关)
22
22
  this.services = new ClawServices(config, logger, (sessionId, data) => {
23
23
  void this.dlClient.publishEventToDLClient({ sessionId, data }, 'onTerminal');
24
+ }, (payload) => {
25
+ // 2026-10-22:用量上报走服务端事件,由中继落库
26
+ void this.dlClient.publishEventToServer(payload, 'onUsage', '1');
24
27
  });
25
28
  }
26
29
  log(msg, type) {
@@ -107,13 +110,21 @@ export class CmdHandler {
107
110
  try {
108
111
  const sessionKey = payload.sessionKey;
109
112
  const message = payload.message;
113
+ // 2026-10-23:message 可能缺失
114
+ if (!message) {
115
+ return;
116
+ }
110
117
  const role = message.role;
111
118
  if (role !== 'assistant') {
112
119
  return;
113
120
  }
114
121
  //this.log(`onSessionMessage >>> ${JSON.stringify(payload)}`, 'warn');
115
122
  const stopReason = message.stopReason;
116
- const content = message.content ?? [];
123
+ // 2026-10-23:content 可能是字符串(非数组),直接 .map 会 TypeError
124
+ const rawContent = message.content;
125
+ const content = Array.isArray(rawContent)
126
+ ? rawContent
127
+ : (typeof rawContent === 'string' ? [{ type: 'text', text: rawContent }] : []);
117
128
  const result_contents = content.map(item => {
118
129
  if (item.type === 'thinking') {
119
130
  return item.thinking;
@@ -128,7 +139,8 @@ export class CmdHandler {
128
139
  if (content.length === 0) {
129
140
  return;
130
141
  }
131
- const label = payload.origin.label || undefined;
142
+ // 2026-10-23:origin 可能为空,直接取 .label 会 TypeError
143
+ const label = payload.origin?.label || undefined;
132
144
  if (!label) {
133
145
  this.log(`不能StreamId...${label}`, 'error');
134
146
  this.dlClient.publishEventToServer({
@@ -16,7 +16,12 @@ export declare class ClawServices extends EventEmitter {
16
16
  private isInitialized;
17
17
  private readonly logger;
18
18
  private readonly terminalManager;
19
- constructor(options: ClawServiceOptions, logger: PluginLogger, terminalSink?: TerminalDataSink);
19
+ /** 2026-10-22:token 用量上报(中继落库,按同事聚合) */
20
+ private readonly usagePublisher?;
21
+ private usageTimer;
22
+ constructor(options: ClawServiceOptions, logger: PluginLogger, terminalSink?: TerminalDataSink, usagePublisher?: (payload: Record<string, unknown>) => void);
23
+ /** 2026-10-22:每 60 秒把最近一天的 token 用量上报中继(幂等 upsert) */
24
+ private startUsageReporter;
20
25
  log(msg: string, type: 'info' | 'warn' | 'error' | 'debug'): void;
21
26
  /**
22
27
  * 初始化服务
@@ -52,6 +57,14 @@ export declare class ClawServices extends EventEmitter {
52
57
  */
53
58
  private handleCurrentSessionModel;
54
59
  private handleAgentList;
60
+ /**
61
+ * 2026-10-22:网关在 agents.create/update 后会做配置热加载,RPC 响应帧可能丢失,
62
+ * 插件侧 ws-client 默认要干等 30s,导致中继先超时。
63
+ * 这里对 RPC 加短超时,超时后由调用方回查 listAgents 确认是否已生效。
64
+ */
65
+ private rpcWithTimeout;
66
+ /** 回查 agent 是否已写入(热加载完成后可见) */
67
+ private findAgentConfirmed;
55
68
  private handleAgentCreate;
56
69
  private handleAgentUpdate;
57
70
  private handleAgentDelete;
@@ -114,6 +127,7 @@ export declare class ClawServices extends EventEmitter {
114
127
  * 新增 cmd 通道 agent.stop 指令
115
128
  */
116
129
  private handleStopAgent;
130
+ private handleSessionsAbort;
117
131
  private handlChatAbout;
118
132
  /**
119
133
  * 2026-09-06 适配 openclaw 2026.9.1:对码审批改走宿主 RPC channels.pairing.approve(按 requestId)
@@ -17,6 +17,7 @@ import { getSessionEntry, patchSessionEntry, resolveStorePath, listSessionEntrie
17
17
  import { setGatewayClient, approvePairingRequest } from './gateway-rpc.js';
18
18
  import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
19
19
  import { markRunStopped } from '../runtime.js';
20
+ import { flushPendingMessages } from '../pending-pairing.js';
20
21
  import { TerminalManager } from './terminal.js';
21
22
  /**
22
23
  * Claw-Services 主类
@@ -29,12 +30,49 @@ export class ClawServices extends EventEmitter {
29
30
  logger;
30
31
  // 2026-10-21:终端会话管理(PTY / 管道)
31
32
  terminalManager;
32
- constructor(options, logger, terminalSink) {
33
+ /** 2026-10-22:token 用量上报(中继落库,按同事聚合) */
34
+ usagePublisher;
35
+ usageTimer = null;
36
+ constructor(options, logger, terminalSink, usagePublisher) {
33
37
  super();
34
38
  this.options = options;
35
39
  this.logger = logger;
40
+ this.usagePublisher = usagePublisher;
36
41
  this.terminalManager = new TerminalManager(terminalSink ?? (() => { }), (msg, type) => this.log(msg, type ?? 'info'));
37
42
  }
43
+ /** 2026-10-22:每 60 秒把最近一天的 token 用量上报中继(幂等 upsert) */
44
+ startUsageReporter() {
45
+ if (this.usageTimer || !this.usagePublisher) {
46
+ return;
47
+ }
48
+ const tick = async () => {
49
+ try {
50
+ // 2026-10-23:上送绝对时间 + 本机时区,业务日由中继按部署时区归并;
51
+ // 查询窗口取近 48 小时,避免跨日/跨时区边界漏会话(窗口仅用于取数)
52
+ const capturedAt = Date.now();
53
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
54
+ const endDate = new Date().toISOString().slice(0, 10);
55
+ const startDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
56
+ const res = await this.rpcWithTimeout(this.client.getSessionsUsage({ agentScope: 'all', startDate, endDate, limit: 1000 }), 15000, 'sessions.usage');
57
+ const root = res?.data ?? res;
58
+ const sessions = Array.isArray(root?.sessions)
59
+ ? root.sessions
60
+ : Array.isArray(root?.aggregates)
61
+ ? root.aggregates
62
+ : [];
63
+ if (sessions.length === 0) {
64
+ return;
65
+ }
66
+ // relayId 由中继按 apiKey 补齐,这里不传
67
+ this.usagePublisher({ usageDate: endDate, capturedAt, timeZone, sessions });
68
+ }
69
+ catch (e) {
70
+ this.log(`usage reporter: ${String(e)}`, 'debug');
71
+ }
72
+ };
73
+ this.usageTimer = setInterval(() => { void tick(); }, 60_000);
74
+ void tick();
75
+ }
38
76
  log(msg, type) {
39
77
  switch (type) {
40
78
  case 'info':
@@ -108,6 +146,8 @@ export class ClawServices extends EventEmitter {
108
146
  });
109
147
  await this.client.connect();
110
148
  this.isInitialized = true;
149
+ // 2026-10-22:启动后开始周期上报用量
150
+ this.startUsageReporter();
111
151
  return true;
112
152
  }
113
153
  catch (error) {
@@ -270,6 +310,10 @@ export class ClawServices extends EventEmitter {
270
310
  // 新增 cmd 通道 chat.abort , 以上两个/stop, /queue interrupt都不能立即终止执行中的会话
271
311
  result = await this.handlChatAbout(cmdMessage.params);
272
312
  break;
313
+ case 'sessions.abort':
314
+ // 2026-10-24:会话级中止,无 runId 时也能立即停止,并丢弃排队后续
315
+ result = await this.handleSessionsAbort(cmdMessage.params);
316
+ break;
273
317
  // 2026-10-21:终端能力(owner-only,见 CmdHandler 校验)
274
318
  case 'terminal.open':
275
319
  result = await this.terminalManager.open(cmdMessage.params);
@@ -286,9 +330,9 @@ export class ClawServices extends EventEmitter {
286
330
  case 'pairing.approve':
287
331
  // 2026-09-06 适配 openclaw 2026.9.1:审批改按 requestId(宿主 RPC),客户端由 onPairingRequest 事件获取;
288
332
  // code 保留解析用于旧客户端兼容提示(9.1 起宿主 list 不再暴露 code,无法按 code 审批)
289
- const { requestId, code, empId, accountId } = cmdMessage.params ?? {};
333
+ const { requestId, code, empId, accountId, relayId } = cmdMessage.params ?? {};
290
334
  //注意:accountId目前只能为 DEFAULT_ACCOUNT_ID, 本插件不需要使用多账户,服务端提供多帐户服务(openId提供AGENT路由),在安装插件时需提醒使用"default"
291
- result = await this.handleApprove({ requestId, code, empId, accountId: accountId || DEFAULT_ACCOUNT_ID });
335
+ result = await this.handleApprove({ requestId, code, empId, relayId, accountId: accountId || DEFAULT_ACCOUNT_ID });
292
336
  break;
293
337
  // 工作区 / Artifact
294
338
  case 'artifacts.list':
@@ -401,11 +445,12 @@ export class ClawServices extends EventEmitter {
401
445
  * 策略:先精确匹配传入 key;查不到 entry 时,用前缀匹配 store 中 updatedAt 最新的 direct 会话。
402
446
  */
403
447
  resolveDirectSessionKey(storePath, sessionKey, agentId, channelId) {
404
- // 1. 传入 key 直接命中 entry → 直接用
448
+ // 1. 精确匹配先记下来,但不直接返回:旧的无后缀 key 可能仍存在,
449
+ // 直接写入会命中过期会话(表现为「切了模型但下一条消息仍用旧模型」)。
405
450
  const exact = getSessionEntry({ storePath, sessionKey });
406
- if (exact) {
407
- return sessionKey;
408
- }
451
+ const exactUpdated = exact
452
+ ? Number(exact.updatedAt ?? exact.lastActivityAt ?? 0)
453
+ : -1;
409
454
  // 2. 前缀匹配:agent:<agentId>:<channelId>:direct:<...> 开头的最新会话
410
455
  const prefix = `agent:${agentId}:${channelId}:direct:`;
411
456
  try {
@@ -421,8 +466,9 @@ export class ClawServices extends EventEmitter {
421
466
  bestKey = s.sessionKey;
422
467
  }
423
468
  }
424
- if (bestKey) {
425
- this.log(`resolveDirectSessionKey: ${sessionKey} -> ${bestKey} (前缀匹配最新会话)`, 'info');
469
+ // 3. 谁更新得更晚就用谁:避免旧 exact 会话把模型覆盖写丢
470
+ if (bestKey && bestUpdated > exactUpdated) {
471
+ this.log(`resolveDirectSessionKey: ${sessionKey} -> ${bestKey} (更晚更新的会话)`, 'info');
426
472
  return bestKey;
427
473
  }
428
474
  }
@@ -474,6 +520,43 @@ export class ClawServices extends EventEmitter {
474
520
  sessionCount: 0 // 需要额外API调用获取
475
521
  }));
476
522
  }
523
+ /**
524
+ * 2026-10-22:网关在 agents.create/update 后会做配置热加载,RPC 响应帧可能丢失,
525
+ * 插件侧 ws-client 默认要干等 30s,导致中继先超时。
526
+ * 这里对 RPC 加短超时,超时后由调用方回查 listAgents 确认是否已生效。
527
+ */
528
+ rpcWithTimeout(p, ms, label) {
529
+ // 避免落败 promise 产生 unhandledRejection
530
+ void p.catch(() => { });
531
+ return new Promise((resolve, reject) => {
532
+ const timer = setTimeout(() => reject(new Error(`${label} rpc timeout after ${ms}ms`)), ms);
533
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
534
+ });
535
+ }
536
+ /** 回查 agent 是否已写入(热加载完成后可见) */
537
+ async findAgentConfirmed(params) {
538
+ const wantedId = (params.id ?? '').toLowerCase();
539
+ const wantedName = (params.name ?? '').toLowerCase();
540
+ for (let i = 0; i < 6; i++) {
541
+ try {
542
+ const agents = await this.rpcWithTimeout(this.client.listAgents(), 5000, 'agents.list');
543
+ const found = agents.agents.find((a) => {
544
+ const id = (a.id ?? '').toLowerCase();
545
+ const nm = (a.name ?? '').toLowerCase();
546
+ return (wantedId.length > 0 && (id === wantedId || nm === wantedId))
547
+ || (wantedName.length > 0 && (nm === wantedName || id === wantedName));
548
+ });
549
+ if (found) {
550
+ return found;
551
+ }
552
+ }
553
+ catch {
554
+ // 热加载期间 list 可能失败,稍后重试
555
+ }
556
+ await new Promise((r) => setTimeout(r, 800));
557
+ }
558
+ return undefined;
559
+ }
477
560
  async handleAgentCreate(params) {
478
561
  const workspace_path = getOpenclawPath('workspace', params.id.toLowerCase().replace(/\s+/g, '-'));
479
562
  const provider = params.provider ?? '';
@@ -485,23 +568,36 @@ export class ClawServices extends EventEmitter {
485
568
  avatar: (params.avatar && params.avatar.length > 0) ? this.options.baseUrl + params.avatar : undefined,
486
569
  model: provider.length > 0 ? `${provider}/${params.model}` : params.model,
487
570
  };
488
- const result = await this.client.createAgent(createParams);
571
+ let result;
572
+ try {
573
+ result = await this.rpcWithTimeout(this.client.createAgent(createParams), 8000, 'agents.create');
574
+ }
575
+ catch (e) {
576
+ // 响应可能因热加载丢失,但配置已写入 → 回查确认
577
+ const confirmed = await this.findAgentConfirmed(params);
578
+ if (!confirmed) {
579
+ throw e;
580
+ }
581
+ this.log(`agents.create 响应超时但已生效:${confirmed.id}`, 'warn');
582
+ result = { agentId: confirmed.id };
583
+ }
489
584
  // 2026-09-18 修复(石头哥报:首次点击创建失败,且 name 未写入 openclaw.json):
490
585
  // agents.create 后网关是「异步热加载配置」的(日志:create 186ms 成功,紧跟的
491
586
  // agents.update 报 agent "xiaoha" not found,1 秒后 config hot reload applied)。
492
587
  // 这里改用「update 快速失败重试」:agents.update 对未就绪 agent 是 2ms 快速返回 not found
493
588
  // (ws-client.send 在 WS 未连接时也快速抛错),不会挂起;热加载完成后下一次即成功。
494
589
  // 避免原 waitAgentReady 用 listAgents 轮询导致客户端 loading 超时的问题。
495
- const MAX_UPDATE_RETRY = 10;
590
+ // 2026-10-22:单次 update 已加 4s 超时,重试次数收紧,避免最坏情况超过中继 30s
591
+ const MAX_UPDATE_RETRY = 3;
496
592
  const UPDATE_RETRY_MS = 500;
497
593
  let nameWritten = false;
498
594
  for (let i = 0; i < MAX_UPDATE_RETRY && !nameWritten; i++) {
499
595
  try {
500
- const up = await this.client.updateAgent({
596
+ const up = await this.rpcWithTimeout(this.client.updateAgent({
501
597
  agentId: result.agentId,
502
598
  name: params.name,
503
599
  workspace: workspace_path,
504
- });
600
+ }), 4000, 'agents.update');
505
601
  if (up?.ok) {
506
602
  nameWritten = true;
507
603
  }
@@ -528,25 +624,45 @@ export class ClawServices extends EventEmitter {
528
624
  };
529
625
  }
530
626
  async handleAgentUpdate(params) {
531
- const agents = await this.client.listAgents();
627
+ let agents;
628
+ try {
629
+ agents = await this.rpcWithTimeout(this.client.listAgents(), 5000, 'agents.list');
630
+ }
631
+ catch {
632
+ agents = { agents: [] };
633
+ }
532
634
  const agent = agents.agents.find(a => a.id === params.agentId);
533
635
  const provider = params.provider ?? '';
534
636
  if (agent) {
535
637
  //agent存在直接更新
536
- const result = await this.client.updateAgent({
537
- agentId: params.agentId,
538
- name: params.name,
539
- workspace: params.workspace,
540
- model: provider.length > 0 ? `${provider}/${params.model}` : params.model,
541
- avatar: params.avatar
542
- });
638
+ let result;
639
+ try {
640
+ result = await this.rpcWithTimeout(this.client.updateAgent({
641
+ agentId: params.agentId,
642
+ name: params.name,
643
+ workspace: params.workspace,
644
+ model: provider.length > 0 ? `${provider}/${params.model}` : params.model,
645
+ avatar: params.avatar
646
+ }), 8000, 'agents.update');
647
+ }
648
+ catch (e) {
649
+ // 响应可能因热加载丢失,但配置已写入 → 回查确认
650
+ const confirmed = await this.findAgentConfirmed({ id: params.agentId, name: params.name });
651
+ if (!confirmed) {
652
+ throw e;
653
+ }
654
+ this.log(`agents.update 响应超时但已生效:${confirmed.id}`, 'warn');
655
+ result = { agentId: confirmed.id };
656
+ }
657
+ // 兼容旧结构:原来的 updateAgent 调用以 result 收尾,这里保持后续逻辑
658
+ const legacyResult = result;
543
659
  const sessionKey = `agent:${params.agentId}:${this.options.channelId}:direct:${params.userId.toLowerCase()}`;
544
660
  await this.handleUpdateSessionModel({
545
661
  sessionKey,
546
662
  provider: provider,
547
663
  model: params.model
548
664
  });
549
- return { id: result.agentId };
665
+ return { id: legacyResult.agentId };
550
666
  }
551
667
  //不存在直接创建一个
552
668
  const p = {
@@ -856,6 +972,13 @@ export class ClawServices extends EventEmitter {
856
972
  sessionKey
857
973
  });
858
974
  }
975
+ async handleSessionsAbort(params) {
976
+ const { sessionKey } = params;
977
+ if (!sessionKey) {
978
+ throw new Error('sessions.abort 缺少 sessionKey 参数');
979
+ }
980
+ return await this.client?.sessionsAbort({ sessionKey });
981
+ }
859
982
  async handlChatAbout(params) {
860
983
  const { sessionKey, runId } = params;
861
984
  if (!sessionKey) {
@@ -878,7 +1001,7 @@ export class ClawServices extends EventEmitter {
878
1001
  * 服务端暂无该端点会 404 噪音);服务端回调沿用下方 onPairingResult MQTT 事件(7.1 同款链路)
879
1002
  */
880
1003
  async handleApprove(params) {
881
- const { requestId, code, empId, accountId } = params;
1004
+ const { requestId, code, empId, accountId, relayId } = params;
882
1005
  if (!requestId) {
883
1006
  // 旧客户端只传 code:9.1 无公开按 code 审批 API,提示升级(重新对码后事件会带 requestId)
884
1007
  throw new Error(`审批失败:缺少 requestId${code ? `(code=${code} 已不被支持)` : ''},请重新触发对码后重试`);
@@ -891,10 +1014,13 @@ export class ClawServices extends EventEmitter {
891
1014
  empId,
892
1015
  requestId,
893
1016
  });
1017
+ // 2026-10-22:补发该中继在未配对期间被拦截的消息;返回条数供客户端决定是否兜底重发
1018
+ const replayed = relayId ? flushPendingMessages(relayId) : 0;
894
1019
  return {
895
1020
  success: true,
896
1021
  data: {
897
- ...res
1022
+ ...res,
1023
+ replayed
898
1024
  },
899
1025
  timestamp: Date.now()
900
1026
  };
@@ -306,7 +306,7 @@ export interface CmdResult {
306
306
  timestamp: string | number;
307
307
  timezone: string;
308
308
  }
309
- export type CmdType = 'agent.ask' | 'agent.list' | 'agent.create' | 'agent.update' | 'agent.delete' | 'agent.get' | 'agent.stop' | 'chat.abort' | 'task.list' | 'task.create' | 'task.update' | 'task.delete' | 'task.run' | 'status.health' | 'status.system' | 'status.usage' | 'models.list' | 'sessions.list' | 'sessions.get' | 'sessions.describe' | 'sessions.resolve' | 'sessions.create' | 'sessions.archive' | 'sessions.restore' | 'sessions.delete' | 'sessions.reset' | 'sessions.patch' | 'sessions.currentModel' | 'terminal.open' | 'terminal.input' | 'terminal.resize' | 'terminal.close' | 'chat.history' | 'chat.trajectory' | 'skills.status' | 'skills.search' | 'skills.detail' | 'skills.install' | 'skills.update' | 'skills.securityVerdicts' | 'cancelMessage' | 'usage.cost' | 'sessions.usage' | 'sessions.usage.logs' | 'pairing.approve' | 'artifacts.list' | 'artifacts.get' | 'artifacts.download' | 'agents.files.list' | 'agents.files.get' | 'extCapabilities.status' | 'extCapabilities.check' | 'extCapabilities.install';
309
+ export type CmdType = 'agent.ask' | 'agent.list' | 'agent.create' | 'agent.update' | 'agent.delete' | 'agent.get' | 'agent.stop' | 'chat.abort' | 'task.list' | 'task.create' | 'task.update' | 'task.delete' | 'task.run' | 'status.health' | 'status.system' | 'status.usage' | 'models.list' | 'sessions.list' | 'sessions.get' | 'sessions.describe' | 'sessions.resolve' | 'sessions.create' | 'sessions.archive' | 'sessions.restore' | 'sessions.delete' | 'sessions.reset' | 'sessions.patch' | 'sessions.currentModel' | 'sessions.abort' | 'terminal.open' | 'terminal.input' | 'terminal.resize' | 'terminal.close' | 'chat.history' | 'chat.trajectory' | 'skills.status' | 'skills.search' | 'skills.detail' | 'skills.install' | 'skills.update' | 'skills.securityVerdicts' | 'cancelMessage' | 'usage.cost' | 'sessions.usage' | 'sessions.usage.logs' | 'pairing.approve' | 'artifacts.list' | 'artifacts.get' | 'artifacts.download' | 'agents.files.list' | 'agents.files.get' | 'extCapabilities.status' | 'extCapabilities.check' | 'extCapabilities.install';
310
310
  export type ReadlineType = Extract<CmdType, 'chat.trajectory'>;
311
311
  export interface ClawMessage {
312
312
  type: 'res' | 'event';
@@ -316,7 +316,9 @@ export interface ClawMessage {
316
316
  payload: any;
317
317
  error?: any;
318
318
  }
319
- export type EventType = 'onTick' | 'onHealth' | 'onHandshaked' | 'onConnectState' | 'onError' | 'onAgentReply' | 'onAgentChat' | 'onChatTrajectory' | 'onCron' | 'onShutdown' | 'onSessionsChanged' | 'onSkillInstalled' | 'onSkillInstalling' | 'onSkillError' | 'onPairingRequest' | 'onPairingResult' | 'onTerminal';
319
+ export type EventType = 'onTick' | 'onHealth' | 'onHandshaked' | 'onConnectState' | 'onError' | 'onAgentReply' | 'onAgentChat' | 'onChatTrajectory' | 'onCron' | 'onShutdown' | 'onSessionsChanged' | 'onSkillInstalled' | 'onSkillInstalling' | 'onSkillError' | 'onPairingRequest' | 'onPairingResult'
320
+ /** 2026-10-22:周期上报 token 用量 */
321
+ | 'onUsage' | 'onTerminal';
320
322
  export type CmdParamsPatchSessionEntry = {
321
323
  sessionKey: string;
322
324
  provider?: string;
@@ -141,7 +141,11 @@ export declare class WSClient extends EventEmitter {
141
141
  }): Promise<any>;
142
142
  chatAbort(params: {
143
143
  sessionKey: string;
144
- runId: string;
144
+ runId?: string;
145
+ }): Promise<any>;
146
+ /** 2026-10-24:会话级中止(无 runId 时使用),同时丢弃排队后续 */
147
+ sessionsAbort(params: {
148
+ sessionKey: string;
145
149
  }): Promise<any>;
146
150
  getSkillSecurityVerdicts(agentId?: string): Promise<any>;
147
151
  getUsageCost(params: Record<string, unknown>): Promise<any>;
@@ -460,10 +460,19 @@ export class WSClient extends EventEmitter {
460
460
  method: 'chat.abort',
461
461
  params: {
462
462
  sessionKey,
463
- runId
463
+ ...(runId ? { runId } : {})
464
464
  }
465
465
  });
466
466
  }
467
+ /** 2026-10-24:会话级中止(无 runId 时使用),同时丢弃排队后续 */
468
+ async sessionsAbort(params) {
469
+ const { sessionKey } = params;
470
+ return this.sendRequest({
471
+ type: 'req',
472
+ method: 'sessions.abort',
473
+ params: { sessionKey }
474
+ });
475
+ }
467
476
  async getSkillSecurityVerdicts(agentId) {
468
477
  const params = {};
469
478
  if (agentId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dlinkclaw-openclaw",
3
- "version": "2026.10.20",
3
+ "version": "2026.10.22",
4
4
  "description": "DLinkClaw OpenClaw channel plugin — 朵联 OpenClaw 接入通道",
5
5
  "keywords": [
6
6
  "DLinkClaw",
@@ -92,5 +92,8 @@
92
92
  "homepage": "https://github.com/dlinkclaw/dlinkclaw-openclaw-channel#readme",
93
93
  "bugs": {
94
94
  "url": "https://github.com/dlinkclaw/dlinkclaw-openclaw-channel/issues"
95
+ },
96
+ "optionalDependencies": {
97
+ "@lydell/node-pty": "^1.2.0-beta.15"
95
98
  }
96
99
  }