dlinkclaw-openclaw 1.0.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.
Files changed (85) hide show
  1. package/LICENSE +201 -0
  2. package/README.en.md +46 -0
  3. package/README.md +15 -0
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.js +1 -0
  6. package/dist/package.json +95 -0
  7. package/dist/src/account.d.ts +54 -0
  8. package/dist/src/account.js +394 -0
  9. package/dist/src/channel.d.ts +17 -0
  10. package/dist/src/channel.js +153 -0
  11. package/dist/src/client/client-manager.d.ts +4 -0
  12. package/dist/src/client/client-manager.js +10 -0
  13. package/dist/src/client/const.d.ts +37 -0
  14. package/dist/src/client/const.js +155 -0
  15. package/dist/src/client/crypto.d.ts +47 -0
  16. package/dist/src/client/crypto.js +260 -0
  17. package/dist/src/client/dl-client.d.ts +117 -0
  18. package/dist/src/client/dl-client.js +588 -0
  19. package/dist/src/client/dl-http.d.ts +27 -0
  20. package/dist/src/client/dl-http.js +223 -0
  21. package/dist/src/client/media-handler.d.ts +21 -0
  22. package/dist/src/client/media-handler.js +196 -0
  23. package/dist/src/client/message.d.ts +96 -0
  24. package/dist/src/client/message.js +1211 -0
  25. package/dist/src/client/timeout.d.ts +20 -0
  26. package/dist/src/client/timeout.js +36 -0
  27. package/dist/src/client/types.d.ts +226 -0
  28. package/dist/src/client/types.js +1 -0
  29. package/dist/src/monitor.d.ts +18 -0
  30. package/dist/src/monitor.js +322 -0
  31. package/dist/src/outbound.d.ts +6 -0
  32. package/dist/src/outbound.js +78 -0
  33. package/dist/src/policy.d.ts +42 -0
  34. package/dist/src/policy.js +216 -0
  35. package/dist/src/runtime.d.ts +9 -0
  36. package/dist/src/runtime.js +65 -0
  37. package/dist/src/services/gateway-rpc.d.ts +37 -0
  38. package/dist/src/services/gateway-rpc.js +37 -0
  39. package/dist/src/services/index.d.ts +41 -0
  40. package/dist/src/services/index.js +245 -0
  41. package/dist/src/services/services.d.ts +169 -0
  42. package/dist/src/services/services.js +1160 -0
  43. package/dist/src/services/types.d.ts +391 -0
  44. package/dist/src/services/types.js +5 -0
  45. package/dist/src/services/ws-client.d.ts +186 -0
  46. package/dist/src/services/ws-client.js +896 -0
  47. package/dist/src/utils.d.ts +20 -0
  48. package/dist/src/utils.js +128 -0
  49. package/ext_capabilities/workrelay-speak/CLONE_GUIDE.md +129 -0
  50. package/ext_capabilities/workrelay-speak/HARDWARE.md +84 -0
  51. package/ext_capabilities/workrelay-speak/QWEN_GUIDE.md +151 -0
  52. package/ext_capabilities/workrelay-speak/README.md +79 -0
  53. package/ext_capabilities/workrelay-speak/__pycache__/patch-kokoro-en.cpython-312.pyc +0 -0
  54. package/ext_capabilities/workrelay-speak/install.sh +329 -0
  55. package/ext_capabilities/workrelay-speak/install.sh.bak-20260814 +376 -0
  56. package/ext_capabilities/workrelay-speak/manifest.json +63 -0
  57. package/ext_capabilities/workrelay-speak/patch-kokoro-en.py +85 -0
  58. package/ext_capabilities/workrelay-speak/status.sh +111 -0
  59. package/ext_capabilities/workrelay-speak/workrelay-tts.sh +118 -0
  60. package/ext_capabilities/workrelay-speak-luxtts/README.md +44 -0
  61. package/ext_capabilities/workrelay-speak-luxtts/install.sh +330 -0
  62. package/ext_capabilities/workrelay-speak-luxtts/manifest.json +63 -0
  63. package/ext_capabilities/workrelay-speak-luxtts/samples/45ea2e2c-0863-4966-a739-8bff8817f4c0.wav +0 -0
  64. package/ext_capabilities/workrelay-speak-luxtts/samples/800e5af4-cba5-441e-8003-6d36f35409e6.wav +0 -0
  65. package/ext_capabilities/workrelay-speak-luxtts/samples/85fafe01-d4d6-4a48-890e-9ae113f21905.wav +0 -0
  66. package/ext_capabilities/workrelay-speak-luxtts/samples/reference_text.txt +4 -0
  67. package/ext_capabilities/workrelay-speak-luxtts/samples/stone-sample-6.4s.wav +0 -0
  68. package/ext_capabilities/workrelay-speak-luxtts/status.sh +115 -0
  69. package/ext_capabilities/workrelay-speak-luxtts/workrelay-tts.sh +98 -0
  70. package/ext_capabilities/workrelay-speak-qwen/README.md +62 -0
  71. package/ext_capabilities/workrelay-speak-qwen/install.sh +349 -0
  72. package/ext_capabilities/workrelay-speak-qwen/manifest.json +63 -0
  73. package/ext_capabilities/workrelay-speak-qwen/status.sh +106 -0
  74. package/ext_capabilities/workrelay-speak-qwen/workrelay-tts.sh +98 -0
  75. package/ext_capabilities/workrelay-transcribe/README.md +76 -0
  76. package/ext_capabilities/workrelay-transcribe/__pycache__/transcribe.cpython-312.pyc +0 -0
  77. package/ext_capabilities/workrelay-transcribe/__pycache__/transcribe_server.cpython-312.pyc +0 -0
  78. package/ext_capabilities/workrelay-transcribe/install.sh +227 -0
  79. package/ext_capabilities/workrelay-transcribe/manifest.json +42 -0
  80. package/ext_capabilities/workrelay-transcribe/status.sh +63 -0
  81. package/ext_capabilities/workrelay-transcribe/transcribe.py +56 -0
  82. package/ext_capabilities/workrelay-transcribe/transcribe_server.py +116 -0
  83. package/ext_capabilities/workrelay-transcribe/voicebox-transcribe.sh +88 -0
  84. package/openclaw.plugin.json +142 -0
  85. package/package.json +95 -0
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Claw-Services 主服务接口
3
+ * 为 duolian 插件提供 OpenClaw API 封装
4
+ */
5
+ import EventEmitter from 'events';
6
+ import { exec, spawn } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import WSClient from './ws-client.js';
11
+ import { getOpenclawPath, getExtCapabilitiesDir } from '../utils.js';
12
+ import { applyModelOverrideToSessionEntry } from 'openclaw/plugin-sdk/model-session-runtime';
13
+ import { getSessionEntry, patchSessionEntry, resolveStorePath, listSessionEntries } from 'openclaw/plugin-sdk/config-runtime';
14
+ // 2026-09-06 适配 openclaw 2026.9.1:approveChannelPairingCode 不再导出,改走宿主 RPC(见 gateway-rpc.ts)
15
+ import { setGatewayClient, approvePairingRequest } from './gateway-rpc.js';
16
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
17
+ import { markRunStopped } from '../runtime.js';
18
+ /**
19
+ * Claw-Services 主类
20
+ * 处理 CMD 消息并调用相应的 OpenClaw API
21
+ */
22
+ export class ClawServices extends EventEmitter {
23
+ client = null;
24
+ options;
25
+ isInitialized = false;
26
+ logger;
27
+ constructor(options, logger) {
28
+ super();
29
+ this.options = options;
30
+ this.logger = logger;
31
+ }
32
+ log(msg, type) {
33
+ switch (type) {
34
+ case 'info':
35
+ this.logger.info(`✅ CmdService ${msg}`);
36
+ break;
37
+ case 'warn':
38
+ this.logger.warn(`❗️CmdService ${msg}`);
39
+ break;
40
+ case 'error':
41
+ this.logger.error(`❌️ CmdService ${msg}`);
42
+ break;
43
+ case 'debug':
44
+ if (this.logger.debug) {
45
+ this.logger.debug(`〰️ CmdService ${msg}`);
46
+ }
47
+ else {
48
+ console.debug(`〰️ CmdService ${msg}`);
49
+ }
50
+ }
51
+ }
52
+ /**
53
+ * 初始化服务
54
+ */
55
+ async initialize() {
56
+ if (this.isInitialized) {
57
+ return true;
58
+ }
59
+ const channelId = this.options.channelId;
60
+ try {
61
+ this.client = new WSClient({
62
+ gatewayUrl: this.options.gatewayUrl,
63
+ token: this.options.token
64
+ });
65
+ // 2026-09-06 适配 openclaw 2026.9.1:注入 Gateway RPC 客户端(配对审批等宿主 RPC 用)
66
+ setGatewayClient(this.client);
67
+ this.client.on('onConnected', () => {
68
+ this.log(`ws连接成功`, 'info');
69
+ this.emit('onConnectState', true);
70
+ });
71
+ this.client.on('onDisconnect', () => {
72
+ this.log(`ws连接已断开,5秒后重连...`, 'warn');
73
+ this.emit('onConnectState', false);
74
+ setTimeout(() => this.client?.connect(), 5000);
75
+ });
76
+ this.client.on('onTick', (message) => {
77
+ this.log(`onTick >>> ${message.ts}`, 'debug');
78
+ message.channel = channelId;
79
+ this.emit('onTick', message);
80
+ });
81
+ this.client.on('onHealth', (message) => {
82
+ message.channel = channelId;
83
+ this.emit('onHealth', message);
84
+ });
85
+ this.client.on('onHandshaked', (message) => {
86
+ // WS 握手成功后订阅会话变更事件,实时感知归档/恢复/删除/新建
87
+ this.client?.subscribeSessions()
88
+ .then(() => this.log('已订阅 sessions.changed 事件', 'info'))
89
+ .catch((e) => this.log(`订阅 sessions.changed 失败: ${e}`, 'warn'));
90
+ this.emit('onHandshaked', message);
91
+ });
92
+ this.client.on('onAgentReply', (message) => this.emit('onAgentReply', message));
93
+ this.client.on('onAgentChat', (message) => this.emit('onAgentChat', message));
94
+ this.client.on('onCron', (message) => this.emit('onCron', message));
95
+ this.client.on('onShutdown', (message) => this.emit('onShutdown', message));
96
+ this.client.on('onSessionsChanged', (message) => this.emit('onSessionsChanged', message));
97
+ this.client.on('onSessionMessage', (message) => this.emit('onSessionMessage', message));
98
+ this.client.on('onHeartbeat', (message) => this.emit('onHeartbeat', message));
99
+ this.client.on('onError', (error) => {
100
+ this.log('发生错误:' + String(error), 'error');
101
+ this.emit('error', error);
102
+ });
103
+ await this.client.connect();
104
+ this.isInitialized = true;
105
+ return true;
106
+ }
107
+ catch (error) {
108
+ this.log('❌ Claw-Services 初始化失败:' + String(error), 'error');
109
+ this.isInitialized = false;
110
+ throw error;
111
+ }
112
+ }
113
+ /**
114
+ * 检查服务是否已初始化
115
+ */
116
+ isReady() {
117
+ return this.isInitialized && (this.client?.isReady() || false);
118
+ }
119
+ /**
120
+ * 处理 CMD 消息
121
+ */
122
+ async handleCmdMessage(cmdMessage) {
123
+ if (!this.isReady()) {
124
+ throw new Error('Claw-Services 未初始化');
125
+ }
126
+ const startTime = Date.now();
127
+ try {
128
+ let result;
129
+ // 根据命令类型调用相应的 API
130
+ switch (cmdMessage.cmd) {
131
+ // Agent管理
132
+ case 'agent.ask':
133
+ result = await this.handleAskAgent(cmdMessage.params);
134
+ break;
135
+ case 'agent.list':
136
+ result = await this.handleAgentList();
137
+ break;
138
+ case 'agent.create':
139
+ const agentId = cmdMessage.params['id'];
140
+ const agentList = await this.handleAgentList();
141
+ if (agentList && agentList.length > 0) {
142
+ const hasAgent = agentList.find(rec => rec.id === agentId);
143
+ if (hasAgent) {
144
+ result = hasAgent;
145
+ }
146
+ }
147
+ if (!result) {
148
+ result = await this.handleAgentCreate(cmdMessage.params);
149
+ }
150
+ break;
151
+ case 'agent.update':
152
+ result = await this.handleAgentUpdate(cmdMessage.params);
153
+ break;
154
+ case 'agent.delete':
155
+ result = await this.handleAgentDelete(cmdMessage.params);
156
+ break;
157
+ case 'agent.get':
158
+ result = await this.handleAgentGet(cmdMessage.params);
159
+ break;
160
+ // 任务管理
161
+ case 'task.list':
162
+ result = await this.handleTaskList(cmdMessage.params);
163
+ break;
164
+ case 'task.create':
165
+ result = await this.handleTaskCreate(cmdMessage.params);
166
+ break;
167
+ case 'task.update':
168
+ result = await this.handleTaskUpdate(cmdMessage.params);
169
+ break;
170
+ case 'task.delete':
171
+ result = await this.handleTaskDelete(cmdMessage.params);
172
+ break;
173
+ case 'task.run':
174
+ result = await this.handleTaskRun(cmdMessage.params);
175
+ break;
176
+ // 状态监控命令
177
+ case 'status.health':
178
+ result = await this.handleStatusHealth();
179
+ break;
180
+ case 'status.system':
181
+ result = await this.handleStatusSystem();
182
+ break;
183
+ case 'status.usage':
184
+ result = await this.handleStatusUsage();
185
+ break;
186
+ // 模型管理
187
+ case 'models.list':
188
+ // 2026-09-06 适配 openclaw 9.2:models.list 需显式 agentId(多 agent 配置下无 owner 会报 INVALID_REQUEST)
189
+ result = await this.handleListModels(cmdMessage.params);
190
+ break;
191
+ // 会话
192
+ case 'sessions.list':
193
+ result = await this.handleSessionList(cmdMessage.params);
194
+ break;
195
+ case 'sessions.get':
196
+ result = await this.handleSessionGet(cmdMessage.params);
197
+ break;
198
+ case 'sessions.describe':
199
+ result = await this.handleSessionsDescribe(cmdMessage.params);
200
+ break;
201
+ case 'sessions.resolve':
202
+ result = await this.handleSessionsResolve(cmdMessage.params);
203
+ break;
204
+ case 'sessions.create':
205
+ result = await this.handleSessionCreate(cmdMessage.params);
206
+ break;
207
+ case 'sessions.archive':
208
+ result = await this.handleSessionArchive(cmdMessage.params);
209
+ break;
210
+ case 'sessions.restore':
211
+ result = await this.handleSessionRestore(cmdMessage.params);
212
+ break;
213
+ case 'sessions.delete':
214
+ result = await this.handleSessionDelete(cmdMessage.params);
215
+ break;
216
+ case 'sessions.reset':
217
+ result = await this.handleSessionReset(cmdMessage.params);
218
+ break;
219
+ case 'sessions.patch':
220
+ result = await this.handleUpdateSessionModel(cmdMessage.params);
221
+ break;
222
+ case 'sessions.currentModel':
223
+ result = await this.handleCurrentSessionModel(cmdMessage.params);
224
+ break;
225
+ // 当前会话聊天历史记录
226
+ case 'chat.history':
227
+ result = await this.handleChatHistories(cmdMessage.params);
228
+ break;
229
+ // Skills 管理
230
+ case 'skills.status':
231
+ result = await this.handleSkillsStatus(cmdMessage.params);
232
+ break;
233
+ case 'skills.search':
234
+ result = await this.handleSkillsSearch(cmdMessage.params);
235
+ break;
236
+ case 'skills.detail':
237
+ result = await this.handleSkillDetail(cmdMessage.params);
238
+ break;
239
+ case 'skills.install':
240
+ result = await this.handleSkillInstall(cmdMessage.params);
241
+ break;
242
+ case 'skills.update':
243
+ result = await this.handleSkillUpdate(cmdMessage.params);
244
+ break;
245
+ case 'skills.securityVerdicts':
246
+ result = await this.handleSkillsSecurityVerdicts(cmdMessage.params);
247
+ break;
248
+ case 'usage.cost':
249
+ result = await this.handleUsageCost(cmdMessage.params);
250
+ break;
251
+ case 'sessions.usage':
252
+ result = await this.handleSessionsUsage(cmdMessage.params);
253
+ break;
254
+ case 'sessions.usage.logs':
255
+ result = await this.handleSessionsUsageLogs(cmdMessage.params);
256
+ break;
257
+ case 'cancelMessage':
258
+ result = await this.handleCancelMessage(cmdMessage.params);
259
+ break;
260
+ case 'agent.stop':
261
+ result = await this.handleStopAgent(cmdMessage.params);
262
+ break;
263
+ case 'chat.abort':
264
+ // 新增 cmd 通道 chat.abort , 以上两个/stop, /queue interrupt都不能立即终止执行中的会话
265
+ result = await this.handlChatAbout(cmdMessage.params);
266
+ break;
267
+ case 'pairing.approve':
268
+ // 2026-09-06 适配 openclaw 2026.9.1:审批改按 requestId(宿主 RPC),客户端由 onPairingRequest 事件获取;
269
+ // code 保留解析用于旧客户端兼容提示(9.1 起宿主 list 不再暴露 code,无法按 code 审批)
270
+ const { requestId, code, empId, accountId } = cmdMessage.params ?? {};
271
+ //注意:accountId目前只能为 DEFAULT_ACCOUNT_ID, 本插件不需要使用多账户,服务端提供多帐户服务(openId提供AGENT路由),在安装插件时需提醒使用"default"
272
+ result = await this.handleApprove({ requestId, code, empId, accountId: accountId || DEFAULT_ACCOUNT_ID });
273
+ break;
274
+ // 工作区 / Artifact
275
+ case 'artifacts.list':
276
+ result = await this.handleArtifactsList(cmdMessage);
277
+ break;
278
+ case 'artifacts.get':
279
+ result = await this.handleArtifactsGet(cmdMessage);
280
+ break;
281
+ case 'artifacts.download':
282
+ result = await this.handleArtifactsDownload(cmdMessage);
283
+ break;
284
+ // Agent 工作区文件
285
+ case 'agents.files.list':
286
+ result = await this.handleAgentFilesList(cmdMessage);
287
+ break;
288
+ case 'agents.files.get':
289
+ result = await this.handleAgentFilesGet(cmdMessage);
290
+ break;
291
+ // 扩展能力(ext_capabilities):语音合成 TTS / 语音转录 STT
292
+ case 'extCapabilities.status':
293
+ result = await this.handleExtCapabilityStatus(cmdMessage.params);
294
+ break;
295
+ case 'extCapabilities.check':
296
+ result = await this.handleExtCapabilityCheck(cmdMessage.params);
297
+ break;
298
+ case 'extCapabilities.install':
299
+ result = await this.handleExtCapabilityInstall(cmdMessage.params);
300
+ break;
301
+ default:
302
+ throw new Error(`未知的命令: ${cmdMessage.cmd}`);
303
+ }
304
+ const duration = Date.now() - startTime;
305
+ this.log(`CMD 处理完成: ${cmdMessage.cmd} (${duration}ms)`, 'info');
306
+ return {
307
+ success: true,
308
+ data: result,
309
+ timestamp: Date.now()
310
+ };
311
+ }
312
+ catch (error) {
313
+ const duration = Date.now() - startTime;
314
+ this.log(`CMD 处理失败: ${cmdMessage.cmd} (${duration}ms), error=${String(error)}`, 'error');
315
+ return {
316
+ success: false,
317
+ error: error instanceof Error ? error.message : String(error),
318
+ timestamp: Date.now()
319
+ };
320
+ }
321
+ }
322
+ async handleAskAgent(params) {
323
+ return await this.client?.askAgent(params);
324
+ }
325
+ /**
326
+ * 更新
327
+ * @param agentId
328
+ * @param userId
329
+ */
330
+ async handleUpdateSessionModel(params) {
331
+ const { sessionKey, provider, model } = params;
332
+ if (!provider || provider.length === 0 || !sessionKey || sessionKey.length === 0) {
333
+ return null;
334
+ }
335
+ const agentId = sessionKey.split(':')[1];
336
+ const { cfg, channelId } = this.options;
337
+ const storePath = resolveStorePath(cfg.session?.store, { agentId });
338
+ // 2026-08-28 修复:客户端传入的 sessionKey 可能是无后缀 key(agent:<agentId>:<channelId>:direct:<empId>),
339
+ // 而网关实际会话 key 带 chatId 后缀(agent:<agentId>:<channelId>:direct:<empId>-<botId>-<botType>)。
340
+ // store 里同时存在两套 key 时,直接写入无后缀 key 会命中旧遗留会话,导致切模型不生效。
341
+ // 这里先解析出真实会话 key 再 patch,保证模型覆盖写入当前真实会话。
342
+ const realKey = this.resolveDirectSessionKey(storePath, sessionKey, agentId, channelId);
343
+ await patchSessionEntry({
344
+ storePath,
345
+ sessionKey: realKey,
346
+ update: (entry) => {
347
+ applyModelOverrideToSessionEntry({
348
+ entry,
349
+ selection: {
350
+ provider: provider,
351
+ model: model,
352
+ },
353
+ selectionSource: 'user',
354
+ markLiveSwitchPending: true, // 让当前 run 立即生效
355
+ });
356
+ return { ...entry };
357
+ },
358
+ });
359
+ return { provider, model };
360
+ }
361
+ /**
362
+ * 2026-08-28 解析 direct 会话的真实 sessionKey(兼容 chatId 后缀)
363
+ * 背景:客户端拼的 key 是 agent:<agentId>:<channelId>:direct:<empId>(无后缀),
364
+ * 但网关按 chatId 生成的会话 key 带后缀(agent:<agentId>:<channelId>:direct:<empId>-<botId>-<botType>),
365
+ * store 中两套 key 同时存在时,无后缀 key 可能命中旧遗留会话(模型覆盖过期),
366
+ * 导致 sessions.currentModel 返回旧模型、sessions.patch 写入旧会话。
367
+ * 策略:先精确匹配传入 key;查不到 entry 时,用前缀匹配 store 中 updatedAt 最新的 direct 会话。
368
+ */
369
+ resolveDirectSessionKey(storePath, sessionKey, agentId, channelId) {
370
+ // 1. 传入 key 直接命中 entry → 直接用
371
+ const exact = getSessionEntry({ storePath, sessionKey });
372
+ if (exact) {
373
+ return sessionKey;
374
+ }
375
+ // 2. 前缀匹配:agent:<agentId>:<channelId>:direct:<...> 开头的最新会话
376
+ const prefix = `agent:${agentId}:${channelId}:direct:`;
377
+ try {
378
+ const summaries = listSessionEntries({ storePath });
379
+ let bestKey = '';
380
+ let bestUpdated = -1;
381
+ for (const s of summaries) {
382
+ if (!s.sessionKey.startsWith(prefix))
383
+ continue;
384
+ const updated = s.entry?.updatedAt ?? s.entry?.lastActivityAt ?? 0;
385
+ if (updated > bestUpdated) {
386
+ bestUpdated = updated;
387
+ bestKey = s.sessionKey;
388
+ }
389
+ }
390
+ if (bestKey) {
391
+ this.log(`resolveDirectSessionKey: ${sessionKey} -> ${bestKey} (前缀匹配最新会话)`, 'info');
392
+ return bestKey;
393
+ }
394
+ }
395
+ catch (e) {
396
+ this.log(`resolveDirectSessionKey error: ${String(e)}`, 'error');
397
+ }
398
+ return sessionKey;
399
+ }
400
+ /**
401
+ * 查询当前会话的模型覆盖(用于 ChatPage 恢复选中状态)
402
+ * 2026-08-28 修复:客户端直接传 sessionKey(不再传 agentId+userId 让插件拼 key)
403
+ */
404
+ async handleCurrentSessionModel(params) {
405
+ const { sessionKey } = params;
406
+ if (!sessionKey || sessionKey.length === 0) {
407
+ return null;
408
+ }
409
+ const agentId = sessionKey.split(':')[1];
410
+ const { cfg, channelId } = this.options;
411
+ const storePath = resolveStorePath(cfg.session?.store, { agentId });
412
+ // 兼容历史遗留的无后缀 key:精确命中失败时前缀匹配最新 direct 会话
413
+ const realKey = this.resolveDirectSessionKey(storePath, sessionKey, agentId, channelId);
414
+ const entry = getSessionEntry({ storePath, sessionKey: realKey });
415
+ if (!entry)
416
+ return null;
417
+ // 只返回用户主动设置的覆盖,不返回 auto fallback
418
+ if (entry.modelOverrideSource === 'user' && entry.modelOverride) {
419
+ return {
420
+ provider: entry.providerOverride ?? '',
421
+ model: entry.modelOverride,
422
+ };
423
+ }
424
+ return null;
425
+ }
426
+ //#region ==================== Agent 管理处理器 ====================
427
+ async handleAgentList() {
428
+ const agents = await this.client.listAgents();
429
+ return agents.agents.map(agent => ({
430
+ id: agent.id,
431
+ name: agent.name || agent.id,
432
+ emoji: agent.identity?.emoji,
433
+ model: agent.model?.primary,
434
+ workspace: agent.workspace,
435
+ fileCount: 0, // 需要额外API调用获取
436
+ lastActivity: undefined,
437
+ sessionCount: 0 // 需要额外API调用获取
438
+ }));
439
+ }
440
+ async handleAgentCreate(params) {
441
+ const workspace_path = getOpenclawPath('workspace', params.id.toLowerCase().replace(/\s+/g, '-'));
442
+ const createParams = {
443
+ name: params.id,
444
+ workspace: workspace_path,
445
+ emoji: params.emoji,
446
+ avatar: (params.avatar && params.avatar.length > 0) ? this.options.baseUrl + params.avatar : undefined
447
+ };
448
+ const result = await this.client.createAgent(createParams);
449
+ const provider = params.provider ?? '';
450
+ const udpateParams = {
451
+ agentId: result.agentId,
452
+ name: params.name,
453
+ workspace: workspace_path,
454
+ model: provider.length > 0 ? `${provider}/${params.model}` : params.model,
455
+ };
456
+ await this.client?.updateAgent(udpateParams);
457
+ const sessionKey = `agent:${params.agentId}:${this.options.channelId}:direct:${params.userId.toLowerCase()}`;
458
+ await this.handleUpdateSessionModel({
459
+ sessionKey,
460
+ provider: provider,
461
+ model: params.model
462
+ });
463
+ return {
464
+ id: result.agentId,
465
+ name: params.name
466
+ };
467
+ }
468
+ async handleAgentUpdate(params) {
469
+ const agents = await this.client.listAgents();
470
+ const agent = agents.agents.find(a => a.id === params.agentId);
471
+ const provider = params.provider ?? '';
472
+ if (agent) {
473
+ //agent存在直接更新
474
+ const result = await this.client.updateAgent({
475
+ agentId: params.agentId,
476
+ name: params.name,
477
+ workspace: params.workspace,
478
+ model: provider.length > 0 ? `${provider}/${params.model}` : params.model,
479
+ avatar: params.avatar
480
+ });
481
+ const sessionKey = `agent:${params.agentId}:${this.options.channelId}:direct:${params.userId.toLowerCase()}`;
482
+ await this.handleUpdateSessionModel({
483
+ sessionKey,
484
+ provider: provider,
485
+ model: params.model
486
+ });
487
+ return { id: result.agentId };
488
+ }
489
+ //不存在直接创建一个
490
+ const p = {
491
+ id: params.agentId,
492
+ name: params.name,
493
+ provider,
494
+ model: params.model,
495
+ userId: params.userId
496
+ };
497
+ const result = await this.handleAgentCreate(p);
498
+ return {
499
+ id: result.id
500
+ };
501
+ }
502
+ async handleAgentDelete(params) {
503
+ const result = await this.client.deleteAgent({
504
+ agentId: params.agentId,
505
+ deleteFiles: params.deleteFiles || false
506
+ });
507
+ return { success: result.ok };
508
+ }
509
+ async handleAgentGet(params) {
510
+ const agents = await this.client.listAgents();
511
+ const agent = agents.agents.find(a => a.id === params.agentId);
512
+ if (!agent) {
513
+ throw new Error(`Agent ${params.agentId} 未找到`);
514
+ }
515
+ return agent;
516
+ }
517
+ //#endregion
518
+ //#region ==================== 任务管理处理器 ====================
519
+ async handleTaskList(params) {
520
+ const channelId = this.options.channelId;
521
+ const res = await this.client.listCronTasks({
522
+ limit: params?.limit ?? 200,
523
+ offset: params?.offset ?? 0,
524
+ });
525
+ const agentId_list = params?.agentIds ?? [];
526
+ if (agentId_list.length > 0) {
527
+ const jobs = res.jobs.filter(item => agentId_list.includes(item.agentId)).map(item => {
528
+ if (item.delivery && item.delivery.to && item.delivery.to.startsWith(`${channelId}:`)) {
529
+ return {
530
+ ...item,
531
+ delivery: {
532
+ ...item.delivery,
533
+ to: item.delivery.to.replaceAll(`${channelId}:`, '')
534
+ }
535
+ };
536
+ }
537
+ return item;
538
+ });
539
+ return {
540
+ ...res,
541
+ total: jobs.length,
542
+ jobs
543
+ };
544
+ }
545
+ const jobs = (res.jobs ?? []).map(item => {
546
+ if (item.delivery && item.delivery.to && item.delivery.to.startsWith(`${channelId}:`)) {
547
+ return {
548
+ ...item,
549
+ delivery: {
550
+ ...item.delivery,
551
+ to: item.delivery.to.replaceAll(`${channelId}:`, '')
552
+ }
553
+ };
554
+ }
555
+ return item;
556
+ });
557
+ return {
558
+ ...res,
559
+ total: res.jobs.length,
560
+ jobs
561
+ };
562
+ }
563
+ async handleTaskCreate(params) {
564
+ const res = await this.client.addCronTask(params);
565
+ console.log('创建任务 >>> ', JSON.stringify(res));
566
+ return res;
567
+ }
568
+ // task.update — 更新定时任务(通过 OpenClaw cron.update)
569
+ async handleTaskUpdate(params) {
570
+ const result = await this.client.updateCronTask({
571
+ jobId: params.jobId,
572
+ patch: params.patch,
573
+ });
574
+ console.log('更新任务 >>> ', JSON.stringify(result));
575
+ return result;
576
+ }
577
+ async handleTaskDelete(params) {
578
+ const result = await this.client.removeCronTask({
579
+ jobId: params.jobId
580
+ });
581
+ return { success: result.ok };
582
+ }
583
+ async handleTaskRun(params) {
584
+ const result = await this.client.runCronTask({
585
+ jobId: params.jobId
586
+ });
587
+ return {
588
+ success: result.ok,
589
+ runId: result.runId
590
+ };
591
+ }
592
+ //#endregion
593
+ //#region ==================== 状态监控处理器 ====================
594
+ async handleStatusHealth() {
595
+ const health = await this.client.getHealthStatus();
596
+ return {
597
+ status: health.status,
598
+ version: health.version,
599
+ uptime: this.formatUptime(health.uptime),
600
+ agentCount: Object.keys(health.agents).length,
601
+ channelCount: Object.keys(health.channels).length,
602
+ memoryUsage: health.memory ? `${Math.round(health.memory.percent)}%` : '未知',
603
+ totalTokens: 0, // 需要 usage API
604
+ totalCost: 0 // 需要 usage API
605
+ };
606
+ }
607
+ async handleStatusSystem() {
608
+ const system = await this.client.getSystemStatus();
609
+ return system;
610
+ }
611
+ async handleStatusUsage() {
612
+ const usage = await this.client.getUsageStatus();
613
+ return usage;
614
+ }
615
+ async handleListModels(params) {
616
+ // 2026-09-06 适配 openclaw 9.2:models.list RPC 要求显式 agentId(多 agent 配置无 owner 报 INVALID_REQUEST)。
617
+ // 客户端能带则带;未带时兜底解析默认 agent:systemAgent.agentId → entries 中 main → entries 第一个 → 'main'
618
+ let agentId = params?.agentId?.trim();
619
+ if (!agentId) {
620
+ const cfgAgents = this.options.cfg?.agents ?? {};
621
+ agentId = cfgAgents?.defaults?.systemAgent?.agentId
622
+ ?? (cfgAgents?.entries && typeof cfgAgents.entries === 'object'
623
+ ? (cfgAgents.entries.main ? 'main' : Object.keys(cfgAgents.entries)[0])
624
+ : undefined)
625
+ ?? 'main';
626
+ }
627
+ const res = await this.client?.listModels(agentId);
628
+ const models = Array.isArray(res?.models) ? res.models : (Array.isArray(res) ? res : []);
629
+ // 2026-09-06 适配 openclaw 9.2:模型目录 JSON 结构变化——
630
+ // 旧版每项 { id, name, provider, ... };9.2 起 provider/model 合并为 key(如 "deepseek/deepseek-v4-flash"),
631
+ // 不再下发独立 provider/id 字段(客户端按 provider 分组、按 id 切换模型会失效)。
632
+ // 这里统一归一化为 7.1 语义:{ id: 纯模型名, key, provider, model: 纯模型名, name, ... },
633
+ // 客户端(输入框切换/群成员模型切换/RelayDetail)无需改动即可恢复。
634
+ return models.map((m) => {
635
+ const id = String(m.id ?? m.key ?? '');
636
+ const key = String(m.key ?? id);
637
+ const rawProvider = m.provider ? String(m.provider) : '';
638
+ let provider = rawProvider;
639
+ let model = id;
640
+ if (!provider && key.includes('/')) {
641
+ const idx = key.indexOf('/');
642
+ provider = key.slice(0, idx);
643
+ model = key.slice(idx + 1);
644
+ }
645
+ return { ...m, id: model || key, key, provider, model: model || key };
646
+ });
647
+ }
648
+ //#endregion
649
+ //#region ===================== 会话处理器 ======================
650
+ async handleSessionList(params) {
651
+ return await this.client?.sessionsList(params);
652
+ }
653
+ async handleSessionGet(params) {
654
+ return await this.client?.sessionsGet(params);
655
+ }
656
+ async handleSessionsDescribe(params) {
657
+ return await this.client?.sessionsDescribe(params);
658
+ }
659
+ async handleSessionsResolve(params) {
660
+ return await this.client?.sessionsResolve(params);
661
+ }
662
+ /**
663
+ * 创建新会话
664
+ * sessionKey: agent:<agentId>:<channelId>-<suffix>:<chatType>:<userId>
665
+ * 例: agent:main:workrelay-00001:direct:I35916630580137984
666
+ */
667
+ async handleSessionCreate(params) {
668
+ const { sessionKey } = params;
669
+ if (!sessionKey) {
670
+ throw new Error('sessions.create 缺少 sessionKey 参数');
671
+ }
672
+ return await this.client?.sessionsCreate({ key: sessionKey });
673
+ }
674
+ /**
675
+ * 归档会话
676
+ * 入参: { sessionKey }
677
+ * - sessionKey: 完整的 sessionKey,如 "agent:main:workrelay-00001:direct:i35916630580137984"
678
+ */
679
+ async handleSessionArchive(params) {
680
+ const { sessionKey } = params;
681
+ if (!sessionKey) {
682
+ throw new Error('sessions.archive 缺少 sessionKey 参数');
683
+ }
684
+ this.log(`sessions.archive sessionKey=${sessionKey}`, 'info');
685
+ return await this.client?.sessionsPatch({ key: sessionKey, patch: { archived: true } });
686
+ }
687
+ /**
688
+ * 恢复(取消归档)会话
689
+ * 入参: { sessionKey }
690
+ * - sessionKey: 完整的 sessionKey
691
+ */
692
+ async handleSessionRestore(params) {
693
+ const { sessionKey } = params;
694
+ if (!sessionKey) {
695
+ throw new Error('sessions.restore 缺少 sessionKey 参数');
696
+ }
697
+ this.log(`sessions.restore sessionKey=${sessionKey}`, 'info');
698
+ return await this.client?.sessionsPatch({ key: sessionKey, patch: { archived: false } });
699
+ }
700
+ /**
701
+ * 删除会话
702
+ * 入参: { sessionKey, deleteFiles? }
703
+ * - sessionKey: 完整的 sessionKey
704
+ * - deleteFiles: 是否同时删除会话相关文件(可选,默认 false)
705
+ */
706
+ async handleSessionDelete(params) {
707
+ const sessionKey = params.sessionKey;
708
+ if (!sessionKey) {
709
+ throw new Error('sessions.delete 缺少 sessionKey 参数');
710
+ }
711
+ this.log(`sessions.delete sessionKey=${sessionKey}`, 'info');
712
+ const arr = sessionKey.split(':');
713
+ const userId = arr[arr.length - 1];
714
+ const effective_userId = userId.length <= 20 && userId.startsWith('i');
715
+ if (sessionKey.startsWith(`agent:main:${this.options.channelId}:`) && effective_userId) {
716
+ throw new Error('Deleting the default session is prohibited.');
717
+ }
718
+ return await this.client?.sessionsDelete({ key: sessionKey });
719
+ }
720
+ async handleSessionReset(params) {
721
+ return await this.client?.sessionReset(params);
722
+ }
723
+ async handleChatHistories(params) {
724
+ return await this.client?.chatHistories(params);
725
+ }
726
+ //#endregion
727
+ //#region ==================== Skills 管理处理器 ====================
728
+ async handleSkillsStatus(params) {
729
+ return await this.client?.listSkills(params?.agentId);
730
+ }
731
+ async handleSkillsSearch(params) {
732
+ return await this.client?.searchSkills({
733
+ query: params.query,
734
+ limit: params.limit,
735
+ });
736
+ }
737
+ async handleSkillDetail(params) {
738
+ if (!params?.slug) {
739
+ throw new Error('skills.detail 缺少 slug 参数');
740
+ }
741
+ return await this.client?.getSkillDetail(params.slug);
742
+ }
743
+ async handleSkillInstall(params) {
744
+ if (!params.slug) {
745
+ throw new Error('skills.install 缺少 slug 参数');
746
+ }
747
+ return await this.client?.installSkill(params.slug, params.version);
748
+ }
749
+ async handleSkillUpdate(params) {
750
+ if (!params.skillKey) {
751
+ throw new Error('skills.update 缺少 skillKey 参数');
752
+ }
753
+ return await this.client?.updateSkillConfig(params.skillKey, params.enabled);
754
+ }
755
+ async handleSkillsSecurityVerdicts(params) {
756
+ return await this.client?.getSkillSecurityVerdicts(params?.agentId);
757
+ }
758
+ // 仪表盘:按日期范围聚合成本
759
+ async handleUsageCost(params) {
760
+ const result = await this.client.getUsageCost(params);
761
+ return { data: result };
762
+ }
763
+ // 仪表盘:每 session 详细使用统计
764
+ async handleSessionsUsage(params) {
765
+ const result = await this.client.getSessionsUsage(params);
766
+ return { data: result };
767
+ }
768
+ // 仪表盘:每消息级别使用日志
769
+ async handleSessionsUsageLogs(params) {
770
+ const result = await this.client.getSessionsUsageLogs(params);
771
+ return { data: result };
772
+ }
773
+ /**
774
+ * 取消当前Agent执行,通过Gateway /queue interrupt 中断活跃run
775
+ */
776
+ async handleCancelMessage(params) {
777
+ const { sessionKey } = params;
778
+ if (!sessionKey) {
779
+ throw new Error('cancelMessage 缺少 sessionKey 参数');
780
+ }
781
+ // channelId 从配置获取,不硬编码
782
+ return await this.client?.cancelSession({ sessionKey });
783
+ }
784
+ /**
785
+ * 强制停止当前Agent run(/stop,比 /queue interrupt 更快)
786
+ * 新增 cmd 通道 agent.stop 指令
787
+ */
788
+ async handleStopAgent(params) {
789
+ const { sessionKey } = params;
790
+ if (!sessionKey) {
791
+ throw new Error('agent.stop 缺少 sessionKey 参数');
792
+ }
793
+ return await this.client?.stopSession({
794
+ sessionKey
795
+ });
796
+ }
797
+ async handlChatAbout(params) {
798
+ const { sessionKey, runId } = params;
799
+ if (!sessionKey) {
800
+ throw new Error('chat.bort 缺少 sessionKey, runId 参数');
801
+ }
802
+ // 2026-08-24 立即终止优化:先标记该 runId 已停止,
803
+ // before_tool_call 钩子会 block 此 run 后续的工具调用(chat.abort 是协作式取消,无法打断正在执行的工具)
804
+ if (runId) {
805
+ markRunStopped(runId);
806
+ }
807
+ return await this.client?.chatAbort({ sessionKey, runId });
808
+ }
809
+ //#endregion
810
+ //#region -----------对码审批--------------
811
+ /**
812
+ * 2026-09-06 适配 openclaw 2026.9.1:对码审批改走宿主 RPC channels.pairing.approve(按 requestId)
813
+ * - 9.1 起 approveChannelPairingCode 不再从 plugin-sdk 导出(安全收敛:审批权与对码解耦、插件不再直写宿主 store)
814
+ * - requestId 由插件创建对码时通过 channels.pairing.list 获取,随 onPairingRequest 事件下发给客户端
815
+ * - notify=false:不触发宿主 notifyApproval(其内部 dlClient.approved 走 HTTP /guest/relayBot/approved,
816
+ * 服务端暂无该端点会 404 噪音);服务端回调沿用下方 onPairingResult MQTT 事件(7.1 同款链路)
817
+ */
818
+ async handleApprove(params) {
819
+ const { requestId, code, empId, accountId } = params;
820
+ if (!requestId) {
821
+ // 旧客户端只传 code:9.1 无公开按 code 审批 API,提示升级(重新对码后事件会带 requestId)
822
+ throw new Error(`审批失败:缺少 requestId${code ? `(code=${code} 已不被支持)` : ''},请重新触发对码后重试`);
823
+ }
824
+ const res = await approvePairingRequest(this.options.channelId, accountId, requestId, false);
825
+ if (res) {
826
+ this.emit('onPairingResult', {
827
+ approved: true,
828
+ senderId: res.senderId,
829
+ empId,
830
+ requestId,
831
+ });
832
+ return {
833
+ success: true,
834
+ data: {
835
+ ...res
836
+ },
837
+ timestamp: Date.now()
838
+ };
839
+ }
840
+ throw new Error('审批失败:配对请求无效或已过期!');
841
+ }
842
+ //#endregion
843
+ //#region ======================== 工作区 / Artifact 处理器 ========================
844
+ /** artifacts.list - 列出会话工件 */
845
+ async handleArtifactsList(cmdMessage) {
846
+ const { sessionKey } = cmdMessage.params || {};
847
+ if (!sessionKey || sessionKey.length === 0) {
848
+ throw new Error('artifacts.list 缺少 sessionKey 参数');
849
+ }
850
+ this.log(`artifacts.list sessionKey=${sessionKey}`, 'info');
851
+ return await this.client?.artifactsList({ sessionKey });
852
+ }
853
+ /** artifacts.get - 获取单个工件详情 */
854
+ async handleArtifactsGet(cmdMessage) {
855
+ const params = cmdMessage.params;
856
+ this.log(`artifacts.get params=${JSON.stringify(params)}`, 'info');
857
+ return await this.client?.artifactsGet(params);
858
+ }
859
+ /** artifacts.download - 下载工件 */
860
+ async handleArtifactsDownload(cmdMessage) {
861
+ const params = cmdMessage.params;
862
+ this.log(`artifacts.download params=${JSON.stringify(params)}`, 'info');
863
+ const result = await this.client?.artifactsDownload(params);
864
+ this.log(`artifacts.download result size=${result?.size ?? 'unknown'}`, 'info');
865
+ return result;
866
+ }
867
+ // ======================== Agent 工作区文件处理器 ========================
868
+ /** agents.files.list - 列出 Agent 工作区文件 */
869
+ async handleAgentFilesList(cmdMessage) {
870
+ const params = cmdMessage.params;
871
+ this.log(`agents.files.list params=${JSON.stringify(params)}`, 'info');
872
+ // 2026-07-31 新增:优先尝试本地文件系统直读(递归扫描所有文件),
873
+ // 目录受限/无权限时自动回退到 Gateway RPC(保留原有兜底逻辑)
874
+ try {
875
+ const result = await this.client?.agentFilesListLocal(params.agentId);
876
+ this.log(`agents.files.list (local scan) result count=${result?.files?.length ?? 0}`, 'info');
877
+ return result;
878
+ }
879
+ catch (err) {
880
+ this.log(`agents.files.list (local scan) failed: ${err}, falling back to Gateway RPC`, 'warn');
881
+ const result = await this.client?.agentFilesList(params.agentId);
882
+ this.log(`agents.files.list (Gateway RPC fallback) result count=${result?.files?.length ?? 0}`, 'info');
883
+ return result;
884
+ }
885
+ }
886
+ /** agents.files.get - 获取工作区文件内容 */
887
+ async handleAgentFilesGet(cmdMessage) {
888
+ const params = cmdMessage.params;
889
+ this.log(`agents.files.get params=${JSON.stringify(params)}`, 'info');
890
+ // 2026-07-31 新增:优先本地文件系统直读(绕过 Gateway 文件白名单限制),
891
+ // 权限不足/文件不存在时自动回退到 Gateway RPC
892
+ try {
893
+ const result = await this.client?.agentFilesGetLocal(params.agentId, params.name);
894
+ this.log(`agents.files.get (local) result name=${params.name}`, 'info');
895
+ return result;
896
+ }
897
+ catch (err) {
898
+ this.log(`agents.files.get (local) failed: ${err}, falling back to Gateway RPC`, 'warn');
899
+ const result = await this.client?.agentFilesGet(params.agentId, params.name);
900
+ this.log(`agents.files.get (Gateway RPC fallback) result name=${params.name}`, 'info');
901
+ return result;
902
+ }
903
+ }
904
+ //#endregion
905
+ //#region ==================== 扩展能力处理器(ext_capabilities)====================
906
+ /**
907
+ * extCapabilities.status - 查询扩展能力安装状态
908
+ * params: { name: 'workrelay-speak' | 'workrelay-transcribe' }
909
+ * 执行 ext_capabilities/<name>/status.sh,返回 JSON 供客户端展示
910
+ */
911
+ async handleExtCapabilityStatus(params) {
912
+ const name = params?.name;
913
+ if (!name) {
914
+ throw new Error('extCapabilities.status 缺少 name 参数');
915
+ }
916
+ const extDir = getExtCapabilitiesDir();
917
+ const scriptPath = path.join(extDir, name, 'status.sh');
918
+ this.log(`extCapabilities.status script=${scriptPath}`, 'info');
919
+ if (!fs.existsSync(scriptPath)) {
920
+ return {
921
+ status: 'disabled',
922
+ description: `能力 ${name} 未安装(脚本不存在)`,
923
+ checks: {}
924
+ };
925
+ }
926
+ try {
927
+ const execAsync = promisify(exec);
928
+ const { stdout } = await execAsync(`bash "${scriptPath}"`, { timeout: 15000, encoding: 'utf-8' });
929
+ // status.sh 输出 JSON(尾部可能带换行/日志),取最后一个 { } 块解析
930
+ const match = stdout.match(/\{[\s\S]*\}/);
931
+ let result;
932
+ if (match) {
933
+ result = JSON.parse(match[0]);
934
+ }
935
+ else {
936
+ result = { status: 'unknown', description: stdout.trim() || '状态解析失败', checks: {} };
937
+ }
938
+ // 2026-08-10 安装存活检测:判断 install.sh 进程是否真实在跑。
939
+ // 客户端 store 持久化的 installing 状态在重启后可能残留(假死),
940
+ // 客户端据此自动解除“安装中”,恢复可重新安装。
941
+ result.installing = await this.isExtCapabilityInstalling(name);
942
+ // 2026-08-18 日志位置随状态返回:客户端界面直接显示安装日志路径,方便排查。
943
+ result.logFile = getOpenclawPath('logs', `ext-capabilities-${name}-install.log`);
944
+ return result;
945
+ }
946
+ catch (err) {
947
+ // status.sh 非 0 退出(如未安装返回 1),但 stdout 仍有 JSON
948
+ const stdout = err?.stdout;
949
+ const match = stdout?.match(/\{[\s\S]*\}/);
950
+ if (match) {
951
+ try {
952
+ const result = JSON.parse(match[0]);
953
+ result.installing = await this.isExtCapabilityInstalling(name);
954
+ // 2026-08-18 日志位置随状态返回(与上方成功分支一致)
955
+ result.logFile = getOpenclawPath('logs', `ext-capabilities-${name}-install.log`);
956
+ return result;
957
+ }
958
+ catch { /* ignore */ }
959
+ }
960
+ this.log(`extCapabilities.status 执行失败: ${String(err)}`, 'warn');
961
+ return { status: 'unknown', description: '状态查询失败', checks: {}, installing: false };
962
+ }
963
+ }
964
+ /**
965
+ * 2026-08-10 检测指定扩展能力的安装进程是否真实在跑(存活检测)
966
+ * 注意:不能用 pgrep -f "...install.sh" —— 该命令经 bash -c 执行时,bash 进程
967
+ * 自身命令行包含 install.sh 字符串,pgrep 会永远自匹配,导致 installing 恒为 true。
968
+ * 改用 ps + grep 方括号技巧(grep 自身命令行不含字面 install.sh,不会自匹配)。
969
+ */
970
+ async isExtCapabilityInstalling(name) {
971
+ try {
972
+ const extDir = getExtCapabilitiesDir();
973
+ const installScript = path.join(extDir, name, 'install.sh');
974
+ const execAsync = promisify(exec);
975
+ // [i]nstall.sh 方括号技巧:grep 进程自身命令行是 "grep [i]nstall.sh",不含字面 install.sh
976
+ const { stdout } = await execAsync(`ps -eo args | grep "[i]nstall\\.sh" | grep -v grep`, { timeout: 3000, encoding: 'utf-8' });
977
+ // 只认跑在脚本路径上的进程,避免匹配到其它无关 install.sh
978
+ const lines = stdout.split('\n').filter((line) => line.trim().length > 0);
979
+ return lines.some((line) => line.includes(installScript) || line.includes('bash ' + installScript));
980
+ }
981
+ catch {
982
+ return false; // 出错 → 视为未在安装
983
+ }
984
+ }
985
+ /**
986
+ * extCapabilities.check - 检查扩展能力安装前置条件
987
+ * params: { name: 'workrelay-speak' | 'workrelay-transcribe' }
988
+ * 读取 ext_capabilities/<name>/manifest.json,逐条执行 requirements[].check(shell 表达式),
989
+ * exit 0 = 条件满足。返回每条条件是否满足,客户端据此展示"安装条件"清单。
990
+ * 2026-08-11 新增:通用适配——每个能力自带前置条件清单,用户安装前即可看到环境是否满足。
991
+ */
992
+ async handleExtCapabilityCheck(params) {
993
+ const name = params?.name;
994
+ if (!name) {
995
+ throw new Error('extCapabilities.check 缺少 name 参数');
996
+ }
997
+ const extDir = getExtCapabilitiesDir();
998
+ const manifestPath = path.join(extDir, name, 'manifest.json');
999
+ this.log(`extCapabilities.check manifest=${manifestPath}`, 'info');
1000
+ if (!fs.existsSync(manifestPath)) {
1001
+ return { name, displayName: name, allMet: true, requirements: [], note: '该能力未提供前置条件清单' };
1002
+ }
1003
+ let manifest;
1004
+ try {
1005
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
1006
+ }
1007
+ catch (err) {
1008
+ this.log(`extCapabilities.check manifest 解析失败: ${String(err)}`, 'warn');
1009
+ return { name, displayName: name, allMet: true, requirements: [], note: '前置条件清单解析失败' };
1010
+ }
1011
+ const requirements = manifest.requirements ?? [];
1012
+ const execAsync = promisify(exec);
1013
+ const results = [];
1014
+ for (const req of requirements) {
1015
+ const item = {
1016
+ id: req.id,
1017
+ label: req.label,
1018
+ expect: req.expect,
1019
+ hint: req.hint,
1020
+ met: false,
1021
+ };
1022
+ try {
1023
+ // check 是 shell 表达式,exit 0 = 满足;10s 超时防止命令卡死
1024
+ await execAsync(req.check, { timeout: 10000, encoding: 'utf-8' });
1025
+ item.met = true;
1026
+ }
1027
+ catch (err) {
1028
+ item.met = false;
1029
+ item.detail = String(err?.message ?? err).split('\n')[0] ?? undefined;
1030
+ }
1031
+ results.push(item);
1032
+ }
1033
+ return {
1034
+ name: manifest.name ?? name,
1035
+ displayName: manifest.displayName ?? name,
1036
+ allMet: results.every((r) => r.met === true),
1037
+ requirements: results,
1038
+ };
1039
+ }
1040
+ /**
1041
+ * extCapabilities.install - 安装扩展能力(长任务,后台执行)
1042
+ * params: { name: 'workrelay-speak' | 'workrelay-transcribe' }
1043
+ * install.sh 可能需要 5-15 分钟(下载模型),后台 spawn 立即返回"已开始",
1044
+ * 客户端通过轮询 extCapabilities.status 感知进度
1045
+ */
1046
+ async handleExtCapabilityInstall(params) {
1047
+ const name = params?.name;
1048
+ if (!name) {
1049
+ throw new Error('extCapabilities.install 缺少 name 参数');
1050
+ }
1051
+ const extDir = getExtCapabilitiesDir();
1052
+ const scriptPath = path.join(extDir, name, 'install.sh');
1053
+ this.log(`extCapabilities.install script=${scriptPath}`, 'info');
1054
+ if (!fs.existsSync(scriptPath)) {
1055
+ throw new Error(`能力 ${name} 的安装脚本不存在: ${scriptPath}`);
1056
+ }
1057
+ // 后台 spawn,detached 脱离父进程生命周期
1058
+ // 2026-08-10 修改:stdio 不再 ignore,stdout/stderr 重定向到日志文件,方便排查安装失败原因
1059
+ const logFile = getOpenclawPath('logs', `ext-capabilities-${name}-install.log`);
1060
+ try {
1061
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
1062
+ }
1063
+ catch { /* ignore */ }
1064
+ const outFd = fs.openSync(logFile, 'a');
1065
+ // 2026-09-14 镜像源区域:客户端传入(cn=国内阿里云公共仓库优先 / intl=国际 Docker Hub 优先 / auto=自动)
1066
+ // install.sh 会按该值优先拉取并自动回退到另一源。
1067
+ const region = params?.region === 'cn' || params?.region === 'intl' ? String(params.region) : 'auto';
1068
+ const child = spawn('bash', [scriptPath], {
1069
+ detached: true,
1070
+ stdio: ['ignore', outFd, outFd],
1071
+ env: { ...process.env, WR_IMAGE_REGION: region },
1072
+ });
1073
+ // 2026-08-10 修复:spawn 成功 ≠ 脚本执行成功。脚本内部立即失败(权限/依赖检查)
1074
+ // 时 error 事件不会触发,失败输出全进日志文件,客户端只看到 started:true → 错误被吞。
1075
+ // 方案:监听 exit 事件,3 秒窗口内脚本退出且退出码非 0 → 视为安装失败,读日志尾部报回。
1076
+ const quickFail = await new Promise((resolve) => {
1077
+ let settled = false;
1078
+ const finish = (failed, code) => {
1079
+ if (settled)
1080
+ return;
1081
+ settled = true;
1082
+ resolve({ failed, code });
1083
+ };
1084
+ const timer = setTimeout(() => finish(false, null), 3000); // 3 秒没退出 → 长任务在跑
1085
+ child.once('error', (err) => {
1086
+ clearTimeout(timer);
1087
+ this.log(`extCapabilities.install spawn 错误: ${err.message}`, 'error');
1088
+ finish(true, -1);
1089
+ });
1090
+ child.once('exit', (code, _signal) => {
1091
+ clearTimeout(timer);
1092
+ finish(code !== 0, code ?? -1);
1093
+ });
1094
+ });
1095
+ if (quickFail.failed) {
1096
+ // 读取日志尾部作为具体错误信息报回客户端(不吞错)
1097
+ const tail = this.readLogTail(logFile);
1098
+ this.log(`extCapabilities.install 执行失败(exit=${quickFail.code}): ${tail}`, 'error');
1099
+ return {
1100
+ started: false,
1101
+ name,
1102
+ error: `安装脚本执行失败(exit=${quickFail.code ?? 'unknown'}): ${tail}`,
1103
+ logFile,
1104
+ };
1105
+ }
1106
+ // 长任务在跑:监听后续 error/exit 记日志,unref 脱离父进程生命周期
1107
+ child.on('error', (err) => {
1108
+ this.log(`extCapabilities.install 运行错误: ${err.message}`, 'error');
1109
+ });
1110
+ child.unref();
1111
+ // 记录日志文件位置,便于客户端/日志排查
1112
+ this.log(`extCapabilities.install 日志: ${logFile}`, 'info');
1113
+ return {
1114
+ started: true,
1115
+ name,
1116
+ message: `安装已启动,请稍后刷新状态(${name} 首次安装需下载模型,约 5-15 分钟)`,
1117
+ logFile,
1118
+ };
1119
+ }
1120
+ //#endregion
1121
+ //#region ==================== 工具函数 ==========================
1122
+ /**
1123
+ * 2026-08-10 读取日志文件尾部内容(供安装失败报错)
1124
+ */
1125
+ readLogTail(logFile, maxLines = 20) {
1126
+ try {
1127
+ const content = fs.readFileSync(logFile, 'utf-8');
1128
+ const lines = content.split('\n').filter((l) => l.trim().length > 0);
1129
+ return lines.slice(-maxLines).join('\n').trim() || '(日志为空)';
1130
+ }
1131
+ catch (err) {
1132
+ return `(无法读取日志: ${String(err)})`;
1133
+ }
1134
+ }
1135
+ formatUptime(seconds) {
1136
+ const days = Math.floor(seconds / 86400);
1137
+ const hours = Math.floor((seconds % 86400) / 3600);
1138
+ const minutes = Math.floor((seconds % 3600) / 60);
1139
+ if (days > 0) {
1140
+ return `${days}天${hours}小时`;
1141
+ }
1142
+ else if (hours > 0) {
1143
+ return `${hours}小时${minutes}分钟`;
1144
+ }
1145
+ else {
1146
+ return `${minutes}分钟`;
1147
+ }
1148
+ }
1149
+ cleanup() {
1150
+ // 2026-09-06 适配 openclaw 2026.9.1:清理时同步重置 Gateway RPC 客户端引用
1151
+ setGatewayClient(null);
1152
+ if (this.client) {
1153
+ this.client.disconnect();
1154
+ this.client.removeAllListeners();
1155
+ this.client = null;
1156
+ }
1157
+ this.isInitialized = false;
1158
+ }
1159
+ }
1160
+ export default ClawServices;