dlinkclaw-openclaw 2026.10.19 → 2026.10.21
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 +1 -1
- package/dist/src/account.js +26 -28
- package/dist/src/client/dl-client.d.ts +4 -0
- package/dist/src/client/dl-client.js +4 -0
- package/dist/src/monitor.js +7 -0
- package/dist/src/pending-pairing.d.ts +7 -0
- package/dist/src/pending-pairing.js +70 -0
- package/dist/src/policy.d.ts +2 -0
- package/dist/src/policy.js +2 -1
- package/dist/src/services/index.js +28 -3
- package/dist/src/services/services.d.ts +17 -1
- package/dist/src/services/services.js +202 -41
- package/dist/src/services/terminal.d.ts +39 -0
- package/dist/src/services/terminal.js +217 -0
- package/dist/src/services/types.d.ts +25 -3
- package/dist/src/services/ws-client.d.ts +5 -1
- package/dist/src/services/ws-client.js +10 -1
- package/package.json +1 -1
package/dist/package.json
CHANGED
package/dist/src/account.js
CHANGED
|
@@ -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("📱
|
|
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 [
|
|
185
|
+
return [`朵联 WorkRelay: ${configured ? "已配置" : "需要 baseUrl, APIKey 和 Secret"}`];
|
|
184
186
|
},
|
|
185
187
|
},
|
|
186
188
|
introNote: {
|
|
187
|
-
title: "🔧
|
|
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: "
|
|
199
|
+
providerHint: "朵联 API Key",
|
|
216
200
|
credentialLabel: "API Key",
|
|
217
201
|
envPrompt: "使用环境变量中的 DL_APIKEY?",
|
|
218
202
|
keepPrompt: "API Key 已配置,保留当前值?",
|
|
219
|
-
inputPrompt: "
|
|
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: "
|
|
217
|
+
providerHint: "朵联签名密钥 (Secret)",
|
|
234
218
|
credentialLabel: "Secret",
|
|
235
219
|
envPrompt: "使用环境变量中的 DL_SECRET?",
|
|
236
220
|
keepPrompt: "Secret 已配置,保留当前值?",
|
|
237
|
-
inputPrompt: "
|
|
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: "
|
|
324
|
+
message: "朵联 baseUrl, apiKey, secret 未配置",
|
|
327
325
|
fix: `Run: openclaw channels add ${channelId} --base-url <baseUrl> --apiKey <apiKey> --secret <secret>`,
|
|
328
326
|
});
|
|
329
327
|
}
|
|
@@ -28,6 +28,8 @@ export interface MqttConfig {
|
|
|
28
28
|
clientTopics: Array<string>;
|
|
29
29
|
openUserTopics: Array<string>;
|
|
30
30
|
openUserNotifyTopics: Array<string>;
|
|
31
|
+
/** 2026-10-21:团队创建人 empId(owner-only 能力校验) */
|
|
32
|
+
ownerEmpId?: string;
|
|
31
33
|
}
|
|
32
34
|
export declare class DLClient extends EventEmitter {
|
|
33
35
|
private ctx;
|
|
@@ -66,6 +68,8 @@ export declare class DLClient extends EventEmitter {
|
|
|
66
68
|
* 而本方法的目的只是刷新 openUserTopics——它是 publishEventToDLClient(L348)实时读取的,
|
|
67
69
|
* 与 MQTT 订阅目标(clientTopics)、Gateway WS 通道都无关 → 只刷新配置即可,不断连、不重连。
|
|
68
70
|
*/
|
|
71
|
+
/** 2026-10-21:团队创建人(终端等 owner-only 能力使用) */
|
|
72
|
+
getOwnerEmpId(): string | undefined;
|
|
69
73
|
refreshConfigAndReconnect(): Promise<void>;
|
|
70
74
|
exeCmd(cmdMessage: CmdMessage): Promise<CmdResponse | undefined>;
|
|
71
75
|
publishEventToDLClient(payload: any, event: EventType, sqos?: '0' | '1' | '2', to?: string): Promise<void>;
|
|
@@ -276,6 +276,10 @@ export class DLClient extends EventEmitter {
|
|
|
276
276
|
* 而本方法的目的只是刷新 openUserTopics——它是 publishEventToDLClient(L348)实时读取的,
|
|
277
277
|
* 与 MQTT 订阅目标(clientTopics)、Gateway WS 通道都无关 → 只刷新配置即可,不断连、不重连。
|
|
278
278
|
*/
|
|
279
|
+
/** 2026-10-21:团队创建人(终端等 owner-only 能力使用) */
|
|
280
|
+
getOwnerEmpId() {
|
|
281
|
+
return this.mqttConfig?.ownerEmpId;
|
|
282
|
+
}
|
|
279
283
|
async refreshConfigAndReconnect() {
|
|
280
284
|
try {
|
|
281
285
|
await this.fetchConfig();
|
package/dist/src/monitor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/policy.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/policy.js
CHANGED
|
@@ -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
|
-
|
|
177
|
+
// 2026-10-22:pairing 模式下拦截的消息可暂存,审批通过后补发
|
|
178
|
+
return { allowed: false, pendingPairing: dmPolicy === "pairing" };
|
|
178
179
|
}
|
|
179
180
|
/**
|
|
180
181
|
* 设置 dmPolicy
|
|
@@ -18,7 +18,13 @@ export class CmdHandler {
|
|
|
18
18
|
isInitialized = false;
|
|
19
19
|
constructor(config, dlClient, logger) {
|
|
20
20
|
this.dlClient = dlClient;
|
|
21
|
-
|
|
21
|
+
// 2026-10-21:终端输出经 MQTT 事件回推客户端(协议级能力,品牌无关)
|
|
22
|
+
this.services = new ClawServices(config, logger, (sessionId, data) => {
|
|
23
|
+
void this.dlClient.publishEventToDLClient({ sessionId, data }, 'onTerminal');
|
|
24
|
+
}, (payload) => {
|
|
25
|
+
// 2026-10-22:用量上报走服务端事件,由中继落库
|
|
26
|
+
void this.dlClient.publishEventToServer(payload, 'onUsage', '1');
|
|
27
|
+
});
|
|
22
28
|
}
|
|
23
29
|
log(msg, type) {
|
|
24
30
|
this.services.log(msg, type);
|
|
@@ -104,13 +110,21 @@ export class CmdHandler {
|
|
|
104
110
|
try {
|
|
105
111
|
const sessionKey = payload.sessionKey;
|
|
106
112
|
const message = payload.message;
|
|
113
|
+
// 2026-10-23:message 可能缺失
|
|
114
|
+
if (!message) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
107
117
|
const role = message.role;
|
|
108
118
|
if (role !== 'assistant') {
|
|
109
119
|
return;
|
|
110
120
|
}
|
|
111
121
|
//this.log(`onSessionMessage >>> ${JSON.stringify(payload)}`, 'warn');
|
|
112
122
|
const stopReason = message.stopReason;
|
|
113
|
-
|
|
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 }] : []);
|
|
114
128
|
const result_contents = content.map(item => {
|
|
115
129
|
if (item.type === 'thinking') {
|
|
116
130
|
return item.thinking;
|
|
@@ -125,7 +139,8 @@ export class CmdHandler {
|
|
|
125
139
|
if (content.length === 0) {
|
|
126
140
|
return;
|
|
127
141
|
}
|
|
128
|
-
|
|
142
|
+
// 2026-10-23:origin 可能为空,直接取 .label 会 TypeError
|
|
143
|
+
const label = payload.origin?.label || undefined;
|
|
129
144
|
if (!label) {
|
|
130
145
|
this.log(`不能StreamId...${label}`, 'error');
|
|
131
146
|
this.dlClient.publishEventToServer({
|
|
@@ -216,6 +231,16 @@ export class CmdHandler {
|
|
|
216
231
|
else {
|
|
217
232
|
this.log(`🔄CMD 指令:${rawMessage.cmd}, params=${JSON.stringify(rawMessage)}`, 'info');
|
|
218
233
|
}
|
|
234
|
+
// 2026-10-21:终端能力仅限团队创建人(owner)。
|
|
235
|
+
// from 是客户端自己的 chat topic:usr_/<empId>/chat,与 ownerEmpId 比对即可。
|
|
236
|
+
if (typeof rawMessage.cmd === 'string' && rawMessage.cmd.startsWith('terminal.')) {
|
|
237
|
+
const owner = this.dlClient.getOwnerEmpId();
|
|
238
|
+
const expected = owner ? `usr_/${owner}/chat` : '';
|
|
239
|
+
if (!owner || rawMessage.from !== expected) {
|
|
240
|
+
this.log(`terminal denied: from=${rawMessage.from}, owner=${owner}`, 'warn');
|
|
241
|
+
return { success: false, error: 'terminal: forbidden (owner only)', timestamp: Date.now() };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
219
244
|
try {
|
|
220
245
|
// 调用服务层处理
|
|
221
246
|
return await this.services.handleCmdMessage(rawMessage);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import EventEmitter from 'events';
|
|
6
6
|
import { ClawServiceOptions, CmdMessage, CmdResponse } from './types.js';
|
|
7
7
|
import { PluginLogger } from 'openclaw/plugin-sdk/core';
|
|
8
|
+
import { TerminalDataSink } from './terminal.js';
|
|
8
9
|
/**
|
|
9
10
|
* Claw-Services 主类
|
|
10
11
|
* 处理 CMD 消息并调用相应的 OpenClaw API
|
|
@@ -14,7 +15,13 @@ export declare class ClawServices extends EventEmitter {
|
|
|
14
15
|
private readonly options;
|
|
15
16
|
private isInitialized;
|
|
16
17
|
private readonly logger;
|
|
17
|
-
|
|
18
|
+
private readonly terminalManager;
|
|
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;
|
|
18
25
|
log(msg: string, type: 'info' | 'warn' | 'error' | 'debug'): void;
|
|
19
26
|
/**
|
|
20
27
|
* 初始化服务
|
|
@@ -50,6 +57,14 @@ export declare class ClawServices extends EventEmitter {
|
|
|
50
57
|
*/
|
|
51
58
|
private handleCurrentSessionModel;
|
|
52
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;
|
|
53
68
|
private handleAgentCreate;
|
|
54
69
|
private handleAgentUpdate;
|
|
55
70
|
private handleAgentDelete;
|
|
@@ -112,6 +127,7 @@ export declare class ClawServices extends EventEmitter {
|
|
|
112
127
|
* 新增 cmd 通道 agent.stop 指令
|
|
113
128
|
*/
|
|
114
129
|
private handleStopAgent;
|
|
130
|
+
private handleSessionsAbort;
|
|
115
131
|
private handlChatAbout;
|
|
116
132
|
/**
|
|
117
133
|
* 2026-09-06 适配 openclaw 2026.9.1:对码审批改走宿主 RPC channels.pairing.approve(按 requestId)
|
|
@@ -17,6 +17,8 @@ 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';
|
|
21
|
+
import { TerminalManager } from './terminal.js';
|
|
20
22
|
/**
|
|
21
23
|
* Claw-Services 主类
|
|
22
24
|
* 处理 CMD 消息并调用相应的 OpenClaw API
|
|
@@ -26,10 +28,50 @@ export class ClawServices extends EventEmitter {
|
|
|
26
28
|
options;
|
|
27
29
|
isInitialized = false;
|
|
28
30
|
logger;
|
|
29
|
-
|
|
31
|
+
// 2026-10-21:终端会话管理(PTY / 管道)
|
|
32
|
+
terminalManager;
|
|
33
|
+
/** 2026-10-22:token 用量上报(中继落库,按同事聚合) */
|
|
34
|
+
usagePublisher;
|
|
35
|
+
usageTimer = null;
|
|
36
|
+
constructor(options, logger, terminalSink, usagePublisher) {
|
|
30
37
|
super();
|
|
31
38
|
this.options = options;
|
|
32
39
|
this.logger = logger;
|
|
40
|
+
this.usagePublisher = usagePublisher;
|
|
41
|
+
this.terminalManager = new TerminalManager(terminalSink ?? (() => { }), (msg, type) => this.log(msg, type ?? 'info'));
|
|
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();
|
|
33
75
|
}
|
|
34
76
|
log(msg, type) {
|
|
35
77
|
switch (type) {
|
|
@@ -104,6 +146,8 @@ export class ClawServices extends EventEmitter {
|
|
|
104
146
|
});
|
|
105
147
|
await this.client.connect();
|
|
106
148
|
this.isInitialized = true;
|
|
149
|
+
// 2026-10-22:启动后开始周期上报用量
|
|
150
|
+
this.startUsageReporter();
|
|
107
151
|
return true;
|
|
108
152
|
}
|
|
109
153
|
catch (error) {
|
|
@@ -266,12 +310,29 @@ export class ClawServices extends EventEmitter {
|
|
|
266
310
|
// 新增 cmd 通道 chat.abort , 以上两个/stop, /queue interrupt都不能立即终止执行中的会话
|
|
267
311
|
result = await this.handlChatAbout(cmdMessage.params);
|
|
268
312
|
break;
|
|
313
|
+
case 'sessions.abort':
|
|
314
|
+
// 2026-10-24:会话级中止,无 runId 时也能立即停止,并丢弃排队后续
|
|
315
|
+
result = await this.handleSessionsAbort(cmdMessage.params);
|
|
316
|
+
break;
|
|
317
|
+
// 2026-10-21:终端能力(owner-only,见 CmdHandler 校验)
|
|
318
|
+
case 'terminal.open':
|
|
319
|
+
result = await this.terminalManager.open(cmdMessage.params);
|
|
320
|
+
break;
|
|
321
|
+
case 'terminal.input':
|
|
322
|
+
result = this.terminalManager.write(cmdMessage.params?.sessionId, cmdMessage.params?.data ?? '');
|
|
323
|
+
break;
|
|
324
|
+
case 'terminal.resize':
|
|
325
|
+
result = this.terminalManager.resize(cmdMessage.params?.sessionId, cmdMessage.params?.cols, cmdMessage.params?.rows);
|
|
326
|
+
break;
|
|
327
|
+
case 'terminal.close':
|
|
328
|
+
result = this.terminalManager.close(cmdMessage.params?.sessionId);
|
|
329
|
+
break;
|
|
269
330
|
case 'pairing.approve':
|
|
270
331
|
// 2026-09-06 适配 openclaw 2026.9.1:审批改按 requestId(宿主 RPC),客户端由 onPairingRequest 事件获取;
|
|
271
332
|
// code 保留解析用于旧客户端兼容提示(9.1 起宿主 list 不再暴露 code,无法按 code 审批)
|
|
272
|
-
const { requestId, code, empId, accountId } = cmdMessage.params ?? {};
|
|
333
|
+
const { requestId, code, empId, accountId, relayId } = cmdMessage.params ?? {};
|
|
273
334
|
//注意:accountId目前只能为 DEFAULT_ACCOUNT_ID, 本插件不需要使用多账户,服务端提供多帐户服务(openId提供AGENT路由),在安装插件时需提醒使用"default"
|
|
274
|
-
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 });
|
|
275
336
|
break;
|
|
276
337
|
// 工作区 / Artifact
|
|
277
338
|
case 'artifacts.list':
|
|
@@ -330,10 +391,19 @@ export class ClawServices extends EventEmitter {
|
|
|
330
391
|
* @param userId
|
|
331
392
|
*/
|
|
332
393
|
async handleUpdateSessionModel(params) {
|
|
333
|
-
const { sessionKey, provider, model } = params;
|
|
334
|
-
if (!
|
|
394
|
+
const { sessionKey, provider, model, thinkingLevel } = params;
|
|
395
|
+
if (!sessionKey || sessionKey.length === 0) {
|
|
335
396
|
return null;
|
|
336
397
|
}
|
|
398
|
+
// 2026-10-21:支持「只改推理强度」的 patch;provider/model 与 thinkingLevel 至少给一个
|
|
399
|
+
if ((!provider || provider.length === 0 || !model || model.length === 0) && !thinkingLevel) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
// 推理强度合法值校验(off/minimal/low/medium/high),非法值直接忽略
|
|
403
|
+
const THINK_LEVELS = ['off', 'minimal', 'low', 'medium', 'high'];
|
|
404
|
+
const thinkOverride = thinkingLevel && THINK_LEVELS.includes(thinkingLevel)
|
|
405
|
+
? thinkingLevel
|
|
406
|
+
: undefined;
|
|
337
407
|
const agentId = sessionKey.split(':')[1];
|
|
338
408
|
const { cfg, channelId } = this.options;
|
|
339
409
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
|
@@ -346,19 +416,25 @@ export class ClawServices extends EventEmitter {
|
|
|
346
416
|
storePath,
|
|
347
417
|
sessionKey: realKey,
|
|
348
418
|
update: (entry) => {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
419
|
+
if (provider && model) {
|
|
420
|
+
applyModelOverrideToSessionEntry({
|
|
421
|
+
entry,
|
|
422
|
+
selection: {
|
|
423
|
+
provider: provider,
|
|
424
|
+
model: model,
|
|
425
|
+
},
|
|
426
|
+
selectionSource: 'user',
|
|
427
|
+
markLiveSwitchPending: true, // 让当前 run 立即生效
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// 2026-10-21:会话级推理强度写入(与模型覆盖同层持久化)
|
|
431
|
+
if (thinkOverride) {
|
|
432
|
+
entry.thinkingLevel = thinkOverride;
|
|
433
|
+
}
|
|
358
434
|
return { ...entry };
|
|
359
435
|
},
|
|
360
436
|
});
|
|
361
|
-
return { provider, model };
|
|
437
|
+
return { provider, model, thinkingLevel: thinkOverride };
|
|
362
438
|
}
|
|
363
439
|
/**
|
|
364
440
|
* 2026-08-28 解析 direct 会话的真实 sessionKey(兼容 chatId 后缀)
|
|
@@ -369,11 +445,12 @@ export class ClawServices extends EventEmitter {
|
|
|
369
445
|
* 策略:先精确匹配传入 key;查不到 entry 时,用前缀匹配 store 中 updatedAt 最新的 direct 会话。
|
|
370
446
|
*/
|
|
371
447
|
resolveDirectSessionKey(storePath, sessionKey, agentId, channelId) {
|
|
372
|
-
// 1.
|
|
448
|
+
// 1. 精确匹配先记下来,但不直接返回:旧的无后缀 key 可能仍存在,
|
|
449
|
+
// 直接写入会命中过期会话(表现为「切了模型但下一条消息仍用旧模型」)。
|
|
373
450
|
const exact = getSessionEntry({ storePath, sessionKey });
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
451
|
+
const exactUpdated = exact
|
|
452
|
+
? Number(exact.updatedAt ?? exact.lastActivityAt ?? 0)
|
|
453
|
+
: -1;
|
|
377
454
|
// 2. 前缀匹配:agent:<agentId>:<channelId>:direct:<...> 开头的最新会话
|
|
378
455
|
const prefix = `agent:${agentId}:${channelId}:direct:`;
|
|
379
456
|
try {
|
|
@@ -389,8 +466,9 @@ export class ClawServices extends EventEmitter {
|
|
|
389
466
|
bestKey = s.sessionKey;
|
|
390
467
|
}
|
|
391
468
|
}
|
|
392
|
-
|
|
393
|
-
|
|
469
|
+
// 3. 谁更新得更晚就用谁:避免旧 exact 会话把模型覆盖写丢
|
|
470
|
+
if (bestKey && bestUpdated > exactUpdated) {
|
|
471
|
+
this.log(`resolveDirectSessionKey: ${sessionKey} -> ${bestKey} (更晚更新的会话)`, 'info');
|
|
394
472
|
return bestKey;
|
|
395
473
|
}
|
|
396
474
|
}
|
|
@@ -417,13 +495,16 @@ export class ClawServices extends EventEmitter {
|
|
|
417
495
|
if (!entry)
|
|
418
496
|
return null;
|
|
419
497
|
// 只返回用户主动设置的覆盖,不返回 auto fallback
|
|
498
|
+
const result = {};
|
|
420
499
|
if (entry.modelOverrideSource === 'user' && entry.modelOverride) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
model: entry.modelOverride,
|
|
424
|
-
};
|
|
500
|
+
result.provider = entry.providerOverride ?? '';
|
|
501
|
+
result.model = entry.modelOverride;
|
|
425
502
|
}
|
|
426
|
-
|
|
503
|
+
// 2026-10-21:会话级推理强度回显
|
|
504
|
+
if (entry.thinkingLevel) {
|
|
505
|
+
result.thinkingLevel = entry.thinkingLevel;
|
|
506
|
+
}
|
|
507
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
427
508
|
}
|
|
428
509
|
//#region ==================== Agent 管理处理器 ====================
|
|
429
510
|
async handleAgentList() {
|
|
@@ -439,6 +520,43 @@ export class ClawServices extends EventEmitter {
|
|
|
439
520
|
sessionCount: 0 // 需要额外API调用获取
|
|
440
521
|
}));
|
|
441
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
|
+
}
|
|
442
560
|
async handleAgentCreate(params) {
|
|
443
561
|
const workspace_path = getOpenclawPath('workspace', params.id.toLowerCase().replace(/\s+/g, '-'));
|
|
444
562
|
const provider = params.provider ?? '';
|
|
@@ -450,23 +568,36 @@ export class ClawServices extends EventEmitter {
|
|
|
450
568
|
avatar: (params.avatar && params.avatar.length > 0) ? this.options.baseUrl + params.avatar : undefined,
|
|
451
569
|
model: provider.length > 0 ? `${provider}/${params.model}` : params.model,
|
|
452
570
|
};
|
|
453
|
-
|
|
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
|
+
}
|
|
454
584
|
// 2026-09-18 修复(石头哥报:首次点击创建失败,且 name 未写入 openclaw.json):
|
|
455
585
|
// agents.create 后网关是「异步热加载配置」的(日志:create 186ms 成功,紧跟的
|
|
456
586
|
// agents.update 报 agent "xiaoha" not found,1 秒后 config hot reload applied)。
|
|
457
587
|
// 这里改用「update 快速失败重试」:agents.update 对未就绪 agent 是 2ms 快速返回 not found
|
|
458
588
|
// (ws-client.send 在 WS 未连接时也快速抛错),不会挂起;热加载完成后下一次即成功。
|
|
459
589
|
// 避免原 waitAgentReady 用 listAgents 轮询导致客户端 loading 超时的问题。
|
|
460
|
-
|
|
590
|
+
// 2026-10-22:单次 update 已加 4s 超时,重试次数收紧,避免最坏情况超过中继 30s
|
|
591
|
+
const MAX_UPDATE_RETRY = 3;
|
|
461
592
|
const UPDATE_RETRY_MS = 500;
|
|
462
593
|
let nameWritten = false;
|
|
463
594
|
for (let i = 0; i < MAX_UPDATE_RETRY && !nameWritten; i++) {
|
|
464
595
|
try {
|
|
465
|
-
const up = await this.client.updateAgent({
|
|
596
|
+
const up = await this.rpcWithTimeout(this.client.updateAgent({
|
|
466
597
|
agentId: result.agentId,
|
|
467
598
|
name: params.name,
|
|
468
599
|
workspace: workspace_path,
|
|
469
|
-
});
|
|
600
|
+
}), 4000, 'agents.update');
|
|
470
601
|
if (up?.ok) {
|
|
471
602
|
nameWritten = true;
|
|
472
603
|
}
|
|
@@ -493,25 +624,45 @@ export class ClawServices extends EventEmitter {
|
|
|
493
624
|
};
|
|
494
625
|
}
|
|
495
626
|
async handleAgentUpdate(params) {
|
|
496
|
-
|
|
627
|
+
let agents;
|
|
628
|
+
try {
|
|
629
|
+
agents = await this.rpcWithTimeout(this.client.listAgents(), 5000, 'agents.list');
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
agents = { agents: [] };
|
|
633
|
+
}
|
|
497
634
|
const agent = agents.agents.find(a => a.id === params.agentId);
|
|
498
635
|
const provider = params.provider ?? '';
|
|
499
636
|
if (agent) {
|
|
500
637
|
//agent存在直接更新
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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;
|
|
508
659
|
const sessionKey = `agent:${params.agentId}:${this.options.channelId}:direct:${params.userId.toLowerCase()}`;
|
|
509
660
|
await this.handleUpdateSessionModel({
|
|
510
661
|
sessionKey,
|
|
511
662
|
provider: provider,
|
|
512
663
|
model: params.model
|
|
513
664
|
});
|
|
514
|
-
return { id:
|
|
665
|
+
return { id: legacyResult.agentId };
|
|
515
666
|
}
|
|
516
667
|
//不存在直接创建一个
|
|
517
668
|
const p = {
|
|
@@ -821,6 +972,13 @@ export class ClawServices extends EventEmitter {
|
|
|
821
972
|
sessionKey
|
|
822
973
|
});
|
|
823
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
|
+
}
|
|
824
982
|
async handlChatAbout(params) {
|
|
825
983
|
const { sessionKey, runId } = params;
|
|
826
984
|
if (!sessionKey) {
|
|
@@ -843,7 +1001,7 @@ export class ClawServices extends EventEmitter {
|
|
|
843
1001
|
* 服务端暂无该端点会 404 噪音);服务端回调沿用下方 onPairingResult MQTT 事件(7.1 同款链路)
|
|
844
1002
|
*/
|
|
845
1003
|
async handleApprove(params) {
|
|
846
|
-
const { requestId, code, empId, accountId } = params;
|
|
1004
|
+
const { requestId, code, empId, accountId, relayId } = params;
|
|
847
1005
|
if (!requestId) {
|
|
848
1006
|
// 旧客户端只传 code:9.1 无公开按 code 审批 API,提示升级(重新对码后事件会带 requestId)
|
|
849
1007
|
throw new Error(`审批失败:缺少 requestId${code ? `(code=${code} 已不被支持)` : ''},请重新触发对码后重试`);
|
|
@@ -856,10 +1014,13 @@ export class ClawServices extends EventEmitter {
|
|
|
856
1014
|
empId,
|
|
857
1015
|
requestId,
|
|
858
1016
|
});
|
|
1017
|
+
// 2026-10-22:补发该中继在未配对期间被拦截的消息;返回条数供客户端决定是否兜底重发
|
|
1018
|
+
const replayed = relayId ? flushPendingMessages(relayId) : 0;
|
|
859
1019
|
return {
|
|
860
1020
|
success: true,
|
|
861
1021
|
data: {
|
|
862
|
-
...res
|
|
1022
|
+
...res,
|
|
1023
|
+
replayed
|
|
863
1024
|
},
|
|
864
1025
|
timestamp: Date.now()
|
|
865
1026
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type TerminalDataSink = (sessionId: string, data: string) => void;
|
|
2
|
+
export type TerminalLogger = (msg: string, type?: 'info' | 'warn' | 'error' | 'debug') => void;
|
|
3
|
+
export declare class TerminalManager {
|
|
4
|
+
private readonly sink;
|
|
5
|
+
private readonly log;
|
|
6
|
+
private readonly sessions;
|
|
7
|
+
private ptyModule;
|
|
8
|
+
constructor(sink: TerminalDataSink, log: TerminalLogger);
|
|
9
|
+
private defaultCwd;
|
|
10
|
+
private append;
|
|
11
|
+
private touch;
|
|
12
|
+
open(params: {
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
cols?: number;
|
|
16
|
+
rows?: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
sessionId: string;
|
|
19
|
+
mode: string;
|
|
20
|
+
reused: boolean;
|
|
21
|
+
replay: string;
|
|
22
|
+
} | {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
mode: string;
|
|
25
|
+
reused?: undefined;
|
|
26
|
+
replay?: undefined;
|
|
27
|
+
}>;
|
|
28
|
+
write(sessionId: string, data: string): {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
};
|
|
31
|
+
resize(sessionId: string, cols: number, rows: number): {
|
|
32
|
+
sessionId: string;
|
|
33
|
+
};
|
|
34
|
+
close(sessionId: string, alreadyExited?: boolean): {
|
|
35
|
+
sessionId: string;
|
|
36
|
+
closed: boolean;
|
|
37
|
+
};
|
|
38
|
+
disposeAll(): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2026-10-21:终端能力(协议级,非 OpenClaw 专属)。
|
|
3
|
+
* 通过 PTY(可用时)或 child_process 运行登录 shell,输出经 sink 回推客户端。
|
|
4
|
+
* 其他品牌插件(如 dsh)实现同一套 terminal.* 契约即可。
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
const FLUSH_MS = 16;
|
|
10
|
+
const IDLE_MS = 30 * 60 * 1000;
|
|
11
|
+
/** 重新 attach 时回放的最大输出量 */
|
|
12
|
+
const HISTORY_LIMIT = 64 * 1024;
|
|
13
|
+
/** 优先用 PTY(交互式 shell),不可用时退回管道模式 */
|
|
14
|
+
async function loadPty() {
|
|
15
|
+
for (const name of ['node-pty', '@lydell/node-pty']) {
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import(name);
|
|
18
|
+
if (mod) {
|
|
19
|
+
return mod.default ?? mod;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// 继续尝试下一个
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export class TerminalManager {
|
|
29
|
+
sink;
|
|
30
|
+
log;
|
|
31
|
+
sessions = new Map();
|
|
32
|
+
ptyModule;
|
|
33
|
+
constructor(sink, log) {
|
|
34
|
+
this.sink = sink;
|
|
35
|
+
this.log = log;
|
|
36
|
+
}
|
|
37
|
+
defaultCwd() {
|
|
38
|
+
return path.join(os.homedir(), '.openclaw', 'workspace');
|
|
39
|
+
}
|
|
40
|
+
append(session, chunk) {
|
|
41
|
+
if (session.closed || !chunk) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
session.buffer += chunk;
|
|
45
|
+
if (session.history.length < HISTORY_LIMIT) {
|
|
46
|
+
session.history += chunk;
|
|
47
|
+
if (session.history.length > HISTORY_LIMIT) {
|
|
48
|
+
session.history = session.history.slice(-HISTORY_LIMIT);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (session.flushTimer) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// 16ms 合并,避免高频输出打爆 MQTT
|
|
55
|
+
session.flushTimer = setTimeout(() => {
|
|
56
|
+
session.flushTimer = null;
|
|
57
|
+
const data = session.buffer;
|
|
58
|
+
session.buffer = '';
|
|
59
|
+
if (data) {
|
|
60
|
+
try {
|
|
61
|
+
this.sink(session.id, data);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
this.log(`terminal sink failed: ${String(e)}`, 'warn');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, FLUSH_MS);
|
|
68
|
+
}
|
|
69
|
+
touch(session) {
|
|
70
|
+
if (session.idleTimer) {
|
|
71
|
+
clearTimeout(session.idleTimer);
|
|
72
|
+
}
|
|
73
|
+
session.idleTimer = setTimeout(() => {
|
|
74
|
+
this.log(`terminal session idle timeout: ${session.id}`, 'info');
|
|
75
|
+
this.close(session.id);
|
|
76
|
+
}, IDLE_MS);
|
|
77
|
+
}
|
|
78
|
+
async open(params) {
|
|
79
|
+
const sessionId = params.sessionId;
|
|
80
|
+
if (!sessionId) {
|
|
81
|
+
throw new Error('terminal.open: sessionId required');
|
|
82
|
+
}
|
|
83
|
+
const existing = this.sessions.get(sessionId);
|
|
84
|
+
if (existing) {
|
|
85
|
+
// 2026-10-21:客户端切 Tab 后重新 attach,回放最近输出,保持会话连续
|
|
86
|
+
this.touch(existing);
|
|
87
|
+
return {
|
|
88
|
+
sessionId,
|
|
89
|
+
mode: existing.isPty ? 'pty' : 'pipe',
|
|
90
|
+
reused: true,
|
|
91
|
+
replay: existing.history,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (this.ptyModule === undefined) {
|
|
95
|
+
this.ptyModule = await loadPty();
|
|
96
|
+
if (!this.ptyModule) {
|
|
97
|
+
this.log('node-pty unavailable, fallback to pipe mode (no TTY)', 'warn');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const cwd = params.cwd && params.cwd.length > 0 ? params.cwd : this.defaultCwd();
|
|
101
|
+
const cols = params.cols && params.cols > 0 ? params.cols : 120;
|
|
102
|
+
const rows = params.rows && params.rows > 0 ? params.rows : 30;
|
|
103
|
+
const env = { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG ?? 'C.UTF-8' };
|
|
104
|
+
let proc;
|
|
105
|
+
let isPty = false;
|
|
106
|
+
if (this.ptyModule?.spawn) {
|
|
107
|
+
proc = this.ptyModule.spawn('bash', ['-il'], {
|
|
108
|
+
name: 'xterm-256color',
|
|
109
|
+
cols,
|
|
110
|
+
rows,
|
|
111
|
+
cwd,
|
|
112
|
+
env,
|
|
113
|
+
});
|
|
114
|
+
isPty = true;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
proc = spawn('bash', ['-il'], { cwd, env, stdio: 'pipe' });
|
|
118
|
+
isPty = false;
|
|
119
|
+
}
|
|
120
|
+
const session = {
|
|
121
|
+
id: sessionId,
|
|
122
|
+
proc,
|
|
123
|
+
isPty,
|
|
124
|
+
buffer: '',
|
|
125
|
+
history: '',
|
|
126
|
+
flushTimer: null,
|
|
127
|
+
idleTimer: null,
|
|
128
|
+
closed: false,
|
|
129
|
+
};
|
|
130
|
+
this.sessions.set(sessionId, session);
|
|
131
|
+
this.touch(session);
|
|
132
|
+
const onData = (data) => {
|
|
133
|
+
this.touch(session);
|
|
134
|
+
this.append(session, typeof data === 'string' ? data : data.toString('utf8'));
|
|
135
|
+
};
|
|
136
|
+
if (isPty) {
|
|
137
|
+
proc.onData(onData);
|
|
138
|
+
proc.onExit((e) => {
|
|
139
|
+
this.log(`terminal exited: ${sessionId}, code=${e?.exitCode}`, 'info');
|
|
140
|
+
this.close(sessionId, true);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
proc.stdout?.on('data', onData);
|
|
145
|
+
proc.stderr?.on('data', onData);
|
|
146
|
+
proc.on('exit', (code) => {
|
|
147
|
+
this.log(`terminal exited: ${sessionId}, code=${code}`, 'info');
|
|
148
|
+
this.close(sessionId, true);
|
|
149
|
+
});
|
|
150
|
+
proc.on('error', (err) => {
|
|
151
|
+
this.log(`terminal error: ${sessionId}, ${err.message}`, 'error');
|
|
152
|
+
this.append(session, `\r\n[terminal error] ${err.message}\r\n`);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
this.log(`terminal opened: ${sessionId}, mode=${isPty ? 'pty' : 'pipe'}, cwd=${cwd}`, 'info');
|
|
156
|
+
return { sessionId, mode: isPty ? 'pty' : 'pipe' };
|
|
157
|
+
}
|
|
158
|
+
write(sessionId, data) {
|
|
159
|
+
const session = this.sessions.get(sessionId);
|
|
160
|
+
if (!session || session.closed) {
|
|
161
|
+
throw new Error('terminal session not found');
|
|
162
|
+
}
|
|
163
|
+
this.touch(session);
|
|
164
|
+
if (session.isPty) {
|
|
165
|
+
session.proc.write(data);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
session.proc.stdin?.write(data);
|
|
169
|
+
}
|
|
170
|
+
return { sessionId };
|
|
171
|
+
}
|
|
172
|
+
resize(sessionId, cols, rows) {
|
|
173
|
+
const session = this.sessions.get(sessionId);
|
|
174
|
+
if (!session || session.closed) {
|
|
175
|
+
throw new Error('terminal session not found');
|
|
176
|
+
}
|
|
177
|
+
if (session.isPty && session.proc.resize) {
|
|
178
|
+
session.proc.resize(cols, rows);
|
|
179
|
+
}
|
|
180
|
+
return { sessionId };
|
|
181
|
+
}
|
|
182
|
+
close(sessionId, alreadyExited = false) {
|
|
183
|
+
const session = this.sessions.get(sessionId);
|
|
184
|
+
if (!session) {
|
|
185
|
+
return { sessionId, closed: false };
|
|
186
|
+
}
|
|
187
|
+
session.closed = true;
|
|
188
|
+
if (session.flushTimer) {
|
|
189
|
+
clearTimeout(session.flushTimer);
|
|
190
|
+
session.flushTimer = null;
|
|
191
|
+
}
|
|
192
|
+
if (session.idleTimer) {
|
|
193
|
+
clearTimeout(session.idleTimer);
|
|
194
|
+
session.idleTimer = null;
|
|
195
|
+
}
|
|
196
|
+
if (!alreadyExited) {
|
|
197
|
+
try {
|
|
198
|
+
if (session.isPty) {
|
|
199
|
+
session.proc.kill();
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
session.proc.kill('SIGHUP');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// 忽略
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
this.sessions.delete(sessionId);
|
|
210
|
+
return { sessionId, closed: true };
|
|
211
|
+
}
|
|
212
|
+
disposeAll() {
|
|
213
|
+
for (const id of Array.from(this.sessions.keys())) {
|
|
214
|
+
this.close(id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -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' | '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,11 +316,15 @@ 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'
|
|
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;
|
|
323
|
-
model
|
|
325
|
+
model?: string;
|
|
326
|
+
/** 2026-10-21:会话级推理强度(off/minimal/low/medium/high),与模型选择同通道 */
|
|
327
|
+
thinkingLevel?: string;
|
|
324
328
|
};
|
|
325
329
|
export interface SkillInfo {
|
|
326
330
|
name: string;
|
|
@@ -390,3 +394,21 @@ export interface AgentFilesGetParams {
|
|
|
390
394
|
agentId: string;
|
|
391
395
|
name: string;
|
|
392
396
|
}
|
|
397
|
+
export type TerminalOpenParams = {
|
|
398
|
+
sessionId: string;
|
|
399
|
+
cwd?: string;
|
|
400
|
+
cols?: number;
|
|
401
|
+
rows?: number;
|
|
402
|
+
};
|
|
403
|
+
export type TerminalInputParams = {
|
|
404
|
+
sessionId: string;
|
|
405
|
+
data: string;
|
|
406
|
+
};
|
|
407
|
+
export type TerminalResizeParams = {
|
|
408
|
+
sessionId: string;
|
|
409
|
+
cols: number;
|
|
410
|
+
rows: number;
|
|
411
|
+
};
|
|
412
|
+
export type TerminalCloseParams = {
|
|
413
|
+
sessionId: string;
|
|
414
|
+
};
|
|
@@ -141,7 +141,11 @@ export declare class WSClient extends EventEmitter {
|
|
|
141
141
|
}): Promise<any>;
|
|
142
142
|
chatAbort(params: {
|
|
143
143
|
sessionKey: string;
|
|
144
|
-
runId
|
|
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)
|