@botlearn-course/daemon 0.0.1
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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/agent-service-client.d.ts +18 -0
- package/dist/agent-service-client.js +108 -0
- package/dist/auth-store.d.ts +16 -0
- package/dist/auth-store.js +106 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +354 -0
- package/dist/course-client.d.ts +46 -0
- package/dist/course-client.js +143 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +85 -0
- package/dist/file-candidates.d.ts +34 -0
- package/dist/file-candidates.js +173 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.js +154 -0
- package/dist/path-env.d.ts +8 -0
- package/dist/path-env.js +42 -0
- package/dist/redaction.d.ts +24 -0
- package/dist/redaction.js +158 -0
- package/dist/run-dispatcher.d.ts +43 -0
- package/dist/run-dispatcher.js +294 -0
- package/dist/run-queue.d.ts +11 -0
- package/dist/run-queue.js +26 -0
- package/dist/runtime-capabilities.d.ts +3 -0
- package/dist/runtime-capabilities.js +42 -0
- package/dist/runtime-profile.d.ts +8 -0
- package/dist/runtime-profile.js +213 -0
- package/dist/runtimes/acp-stream.d.ts +96 -0
- package/dist/runtimes/acp-stream.js +488 -0
- package/dist/runtimes/claude-code.d.ts +41 -0
- package/dist/runtimes/claude-code.js +353 -0
- package/dist/runtimes/codex.d.ts +44 -0
- package/dist/runtimes/codex.js +332 -0
- package/dist/runtimes/deepseek-tui.d.ts +50 -0
- package/dist/runtimes/deepseek-tui.js +701 -0
- package/dist/runtimes/engine.d.ts +52 -0
- package/dist/runtimes/engine.js +127 -0
- package/dist/runtimes/fake.d.ts +13 -0
- package/dist/runtimes/fake.js +45 -0
- package/dist/runtimes/gemini.d.ts +39 -0
- package/dist/runtimes/gemini.js +251 -0
- package/dist/runtimes/hermes-agent.d.ts +61 -0
- package/dist/runtimes/hermes-agent.js +173 -0
- package/dist/runtimes/index.d.ts +15 -0
- package/dist/runtimes/index.js +74 -0
- package/dist/runtimes/kimi.d.ts +35 -0
- package/dist/runtimes/kimi.js +335 -0
- package/dist/runtimes/ndjson-stream.d.ts +51 -0
- package/dist/runtimes/ndjson-stream.js +207 -0
- package/dist/runtimes/openclaw-acp.d.ts +52 -0
- package/dist/runtimes/openclaw-acp.js +872 -0
- package/dist/runtimes/probe.d.ts +17 -0
- package/dist/runtimes/probe.js +54 -0
- package/dist/runtimes/runtime-errors.d.ts +20 -0
- package/dist/runtimes/runtime-errors.js +95 -0
- package/dist/runtimes/text-cap.d.ts +7 -0
- package/dist/runtimes/text-cap.js +25 -0
- package/dist/transcript.d.ts +13 -0
- package/dist/transcript.js +46 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.js +17 -0
- package/dist/workspace.d.ts +23 -0
- package/dist/workspace.js +54 -0
- package/package.json +40 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
|
|
3
|
+
import { sliceUtf8Bytes } from "./text-cap.js";
|
|
4
|
+
import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
|
|
5
|
+
const log = consoleLogger;
|
|
6
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
7
|
+
/** 空闲(无 in-flight prompt)ACP 子进程的存活时长。 */
|
|
8
|
+
const ACP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
/** 单轮流式 assistant 文本字节上限。 */
|
|
10
|
+
const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
11
|
+
/** sessionKey 里代表本 daemon 的固定身份段(单用户 daemon,无多账号语义)。 */
|
|
12
|
+
const ACP_ACCOUNT_ID = "course-daemon";
|
|
13
|
+
export function resolveOpenclawGatewayFromEnv(env = process.env) {
|
|
14
|
+
const url = env.BOTLEARN_OPENCLAW_URL?.trim();
|
|
15
|
+
if (!url)
|
|
16
|
+
return null;
|
|
17
|
+
return {
|
|
18
|
+
url,
|
|
19
|
+
token: env.BOTLEARN_OPENCLAW_TOKEN?.trim() || undefined,
|
|
20
|
+
openclawAgent: env.BOTLEARN_OPENCLAW_AGENT?.trim() || undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const ACP_POOL = new Map();
|
|
24
|
+
const POOL_KEY = "env";
|
|
25
|
+
let exitCleanupHookInstalled = false;
|
|
26
|
+
/** daemon 退出时杀掉池中子进程,避免孤儿。首次 spawn 时惰性安装。 */
|
|
27
|
+
function installExitCleanupHook() {
|
|
28
|
+
if (exitCleanupHookInstalled)
|
|
29
|
+
return;
|
|
30
|
+
exitCleanupHookInstalled = true;
|
|
31
|
+
process.once("exit", () => {
|
|
32
|
+
for (const [key, h] of ACP_POOL.entries()) {
|
|
33
|
+
shutdownHandle(h, "daemon-exit");
|
|
34
|
+
ACP_POOL.delete(key);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function resetIdle(h, key) {
|
|
39
|
+
if (h.idleTimer)
|
|
40
|
+
clearTimeout(h.idleTimer);
|
|
41
|
+
if (h.inFlight > 0)
|
|
42
|
+
return;
|
|
43
|
+
h.idleTimer = setTimeout(() => {
|
|
44
|
+
if (h.inFlight === 0 && !h.closed) {
|
|
45
|
+
log.info("openclaw-acp.idle-timeout", { key });
|
|
46
|
+
shutdownHandle(h, "idle-timeout");
|
|
47
|
+
ACP_POOL.delete(key);
|
|
48
|
+
}
|
|
49
|
+
}, ACP_IDLE_TIMEOUT_MS);
|
|
50
|
+
h.idleTimer.unref?.();
|
|
51
|
+
}
|
|
52
|
+
function shutdownHandle(h, reason) {
|
|
53
|
+
if (h.closed)
|
|
54
|
+
return;
|
|
55
|
+
h.closed = true;
|
|
56
|
+
h.exitReason = reason;
|
|
57
|
+
if (h.idleTimer)
|
|
58
|
+
clearTimeout(h.idleTimer);
|
|
59
|
+
for (const p of h.pending.values()) {
|
|
60
|
+
p.reject(new Error(`openclaw acp child closed: ${reason}`));
|
|
61
|
+
}
|
|
62
|
+
h.pending.clear();
|
|
63
|
+
h.subscribers.clear();
|
|
64
|
+
try {
|
|
65
|
+
h.child.kill("SIGTERM");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// already dead
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** 仅测试用:清空进程池。 */
|
|
72
|
+
export function __resetOpenclawAcpPoolForTests() {
|
|
73
|
+
for (const [key, h] of ACP_POOL.entries()) {
|
|
74
|
+
shutdownHandle(h, "test-reset");
|
|
75
|
+
ACP_POOL.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Probe
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
function resolveOpenclawCommand(deps = {}) {
|
|
82
|
+
const explicit = (deps.env ?? process.env).BOTLEARN_OPENCLAW_BIN;
|
|
83
|
+
if (explicit && explicit.length > 0)
|
|
84
|
+
return explicit;
|
|
85
|
+
return resolveCommandOnPath("openclaw", deps);
|
|
86
|
+
}
|
|
87
|
+
/** 可用 = 二进制在 PATH 上且 BOTLEARN_OPENCLAW_URL 已配置。 */
|
|
88
|
+
export function probeOpenclaw(deps = {}) {
|
|
89
|
+
const command = resolveOpenclawCommand(deps);
|
|
90
|
+
if (!command)
|
|
91
|
+
return { available: false };
|
|
92
|
+
const version = readCommandVersion(command, [], deps) ?? undefined;
|
|
93
|
+
const gateway = resolveOpenclawGatewayFromEnv(deps.env ?? process.env);
|
|
94
|
+
if (!gateway) {
|
|
95
|
+
return { available: false, path: command, version };
|
|
96
|
+
}
|
|
97
|
+
return { available: true, path: command, version };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* OpenClaw ACP 引擎。spawn `openclaw acp --url <gateway> [--token <token>]`
|
|
101
|
+
* 并跨 run 复用子进程(stdio JSON-RPC);网关端点仅来自
|
|
102
|
+
* BOTLEARN_OPENCLAW_URL / BOTLEARN_OPENCLAW_TOKEN / BOTLEARN_OPENCLAW_AGENT。
|
|
103
|
+
* 每 run 用 `_meta.sessionKey`(含 cwd,即 run 工作区)标识稳定会话;
|
|
104
|
+
* 缓存的 ACP sessionId 只是传输层句柄,load 失败即丢弃重建。
|
|
105
|
+
*
|
|
106
|
+
* 权限姿态:course run 在 owner 本机工作区执行,网关侧的权限策略由
|
|
107
|
+
* OpenClaw 自身配置决定,daemon 不代答权限请求(ACP acp 模式无 reverse-call)。
|
|
108
|
+
*/
|
|
109
|
+
export class OpenclawAcpAdapter {
|
|
110
|
+
id = "openclaw-acp";
|
|
111
|
+
spawnFn;
|
|
112
|
+
env;
|
|
113
|
+
constructor(deps = {}) {
|
|
114
|
+
this.spawnFn = deps.spawnFn ?? spawn;
|
|
115
|
+
this.env = deps.env;
|
|
116
|
+
}
|
|
117
|
+
async run(opts) {
|
|
118
|
+
const gateway = resolveOpenclawGatewayFromEnv(this.env ?? process.env);
|
|
119
|
+
if (!gateway) {
|
|
120
|
+
return failResult(opts.sessionId ?? "", "openclaw-acp: missing gateway endpoint (set BOTLEARN_OPENCLAW_URL, optional BOTLEARN_OPENCLAW_TOKEN / BOTLEARN_OPENCLAW_AGENT)");
|
|
121
|
+
}
|
|
122
|
+
const openclawAgent = gateway.openclawAgent ?? "default";
|
|
123
|
+
// conversationKey 用 run 工作区路径:每个 course run 的工作区唯一,
|
|
124
|
+
// 网关侧因此每 run 一个会话,不会与其他 run 串话。
|
|
125
|
+
const sessionKey = buildAcpSessionKey({
|
|
126
|
+
openclawAgent,
|
|
127
|
+
accountId: ACP_ACCOUNT_ID,
|
|
128
|
+
conversationKey: opts.cwd || "default",
|
|
129
|
+
});
|
|
130
|
+
let handle;
|
|
131
|
+
try {
|
|
132
|
+
handle = await this.acquireHandle(POOL_KEY, gateway);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return failResult(opts.sessionId ?? "", `openclaw-acp: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
handle.inFlight += 1;
|
|
138
|
+
if (handle.idleTimer)
|
|
139
|
+
clearTimeout(handle.idleTimer);
|
|
140
|
+
let acpSessionId = opts.sessionId ?? "";
|
|
141
|
+
let seq = 0;
|
|
142
|
+
let assistantText = "";
|
|
143
|
+
let assistantBytes = 0;
|
|
144
|
+
let capped = false;
|
|
145
|
+
let finalText = "";
|
|
146
|
+
const assistantTextFilter = createAssistantTextFilter();
|
|
147
|
+
const emitBlock = (block) => {
|
|
148
|
+
try {
|
|
149
|
+
opts.onBlock?.(block);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
log.warn("openclaw-acp.onBlock-threw", {
|
|
153
|
+
error: err instanceof Error ? err.message : String(err),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const onNotification = (note) => {
|
|
158
|
+
const update = note.params?.update;
|
|
159
|
+
if (update?.sessionUpdate === "agent_message_chunk") {
|
|
160
|
+
const text = assistantTextFilter.push(extractText(update.content));
|
|
161
|
+
if (text && !capped) {
|
|
162
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
163
|
+
if (assistantBytes + bytes > ASSISTANT_TEXT_CAP) {
|
|
164
|
+
const chunk = sliceUtf8Bytes(text, ASSISTANT_TEXT_CAP - assistantBytes);
|
|
165
|
+
if (chunk) {
|
|
166
|
+
assistantText += chunk;
|
|
167
|
+
assistantBytes += Buffer.byteLength(chunk, "utf8");
|
|
168
|
+
}
|
|
169
|
+
capped = true;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
assistantText += text;
|
|
173
|
+
assistantBytes += bytes;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!text)
|
|
177
|
+
return;
|
|
178
|
+
seq += 1;
|
|
179
|
+
emitBlock({ raw: sanitizeAssistantChunk(note, text), kind: "assistant_text", seq });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
seq += 1;
|
|
183
|
+
const kind = classifyAcpUpdate(note);
|
|
184
|
+
emitBlock({ raw: note, kind, seq });
|
|
185
|
+
};
|
|
186
|
+
let abortListener;
|
|
187
|
+
try {
|
|
188
|
+
// 缓存的 ACP sessionId 先 load 重绑;句柄已失效则丢弃重建。
|
|
189
|
+
if (acpSessionId) {
|
|
190
|
+
try {
|
|
191
|
+
acpSessionId = await this.loadSession(handle, {
|
|
192
|
+
sessionId: acpSessionId,
|
|
193
|
+
cwd: opts.cwd,
|
|
194
|
+
sessionKey,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (!isSessionNotFoundError(err))
|
|
199
|
+
throw err;
|
|
200
|
+
log.warn("openclaw-acp.session-load-not-found", {
|
|
201
|
+
oldSessionId: acpSessionId,
|
|
202
|
+
sessionKey,
|
|
203
|
+
});
|
|
204
|
+
acpSessionId = "";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!acpSessionId) {
|
|
208
|
+
try {
|
|
209
|
+
acpSessionId = await this.newSession(handle, {
|
|
210
|
+
cwd: opts.cwd,
|
|
211
|
+
sessionKey,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw new Error(`newSession failed: ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
handle.subscribers.set(acpSessionId, onNotification);
|
|
219
|
+
if (opts.signal?.aborted) {
|
|
220
|
+
return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
|
|
221
|
+
}
|
|
222
|
+
abortListener = () => {
|
|
223
|
+
// best-effort 取消;ACP cancel 是通知(fire-and-forget)。
|
|
224
|
+
sendNotification(handle, "session/cancel", { sessionId: acpSessionId });
|
|
225
|
+
};
|
|
226
|
+
opts.signal?.addEventListener("abort", abortListener);
|
|
227
|
+
let promptResult;
|
|
228
|
+
try {
|
|
229
|
+
promptResult = await this.prompt(handle, {
|
|
230
|
+
sessionId: acpSessionId,
|
|
231
|
+
text: opts.text,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
// 子进程侧会话丢失(重启、GC)→ 重建后重试一次。
|
|
236
|
+
if (isSessionNotFoundError(err)) {
|
|
237
|
+
try {
|
|
238
|
+
const oldSessionId = acpSessionId;
|
|
239
|
+
log.warn("openclaw-acp.prompt-session-not-found-retry", {
|
|
240
|
+
oldSessionId,
|
|
241
|
+
sessionKey,
|
|
242
|
+
});
|
|
243
|
+
const fresh = await this.newSession(handle, {
|
|
244
|
+
cwd: opts.cwd,
|
|
245
|
+
sessionKey,
|
|
246
|
+
});
|
|
247
|
+
handle.subscribers.delete(acpSessionId);
|
|
248
|
+
acpSessionId = fresh;
|
|
249
|
+
handle.subscribers.set(acpSessionId, onNotification);
|
|
250
|
+
log.info("openclaw-acp.session-recreated", {
|
|
251
|
+
oldSessionId,
|
|
252
|
+
newSessionId: acpSessionId,
|
|
253
|
+
sessionKey,
|
|
254
|
+
});
|
|
255
|
+
promptResult = await this.prompt(handle, {
|
|
256
|
+
sessionId: acpSessionId,
|
|
257
|
+
text: opts.text,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch (err2) {
|
|
261
|
+
throw new Error(`prompt failed after session reset: ${err2.message}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// prompt 响应 shape 不完全固定:先从常见位置取最终文本,
|
|
269
|
+
// 取不到再回退到流式累积的 chunk。
|
|
270
|
+
const tailText = assistantTextFilter.flush();
|
|
271
|
+
if (tailText && !capped) {
|
|
272
|
+
const bytes = Buffer.byteLength(tailText, "utf8");
|
|
273
|
+
const textForBlock = assistantBytes + bytes <= ASSISTANT_TEXT_CAP
|
|
274
|
+
? tailText
|
|
275
|
+
: sliceUtf8Bytes(tailText, ASSISTANT_TEXT_CAP - assistantBytes);
|
|
276
|
+
if (textForBlock) {
|
|
277
|
+
assistantText += textForBlock;
|
|
278
|
+
assistantBytes += Buffer.byteLength(textForBlock, "utf8");
|
|
279
|
+
if (textForBlock !== tailText)
|
|
280
|
+
capped = true;
|
|
281
|
+
seq += 1;
|
|
282
|
+
emitBlock({
|
|
283
|
+
raw: {
|
|
284
|
+
method: "session/update",
|
|
285
|
+
params: {
|
|
286
|
+
sessionId: acpSessionId,
|
|
287
|
+
update: {
|
|
288
|
+
sessionUpdate: "agent_message_chunk",
|
|
289
|
+
content: [{ type: "text", text: textForBlock }],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
kind: "assistant_text",
|
|
294
|
+
seq,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const pickedText = normalizeAssistantText(pickFinalText(promptResult));
|
|
299
|
+
const streamedText = normalizeAssistantText(assistantText);
|
|
300
|
+
finalText = pickSafeAssistantText(pickedText) || pickSafeAssistantText(streamedText);
|
|
301
|
+
if (capped) {
|
|
302
|
+
log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
|
|
303
|
+
}
|
|
304
|
+
if (!finalText) {
|
|
305
|
+
const stopReason = pickStopReason(promptResult);
|
|
306
|
+
if (!stopReason || stopReason === "end_turn") {
|
|
307
|
+
return {
|
|
308
|
+
text: "",
|
|
309
|
+
newSessionId: acpSessionId,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// stdout 非 JSON 尾部往往是 OpenClaw 配置告警(缺 API key 等),
|
|
313
|
+
// 附进错误里帮助定位。
|
|
314
|
+
const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
|
|
315
|
+
const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
|
|
316
|
+
const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
|
|
317
|
+
return failResult(acpSessionId, `openclaw-acp: ${reason}${detail}`);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
text: finalText,
|
|
321
|
+
newSessionId: acpSessionId,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
326
|
+
return failResult(isSessionNotFoundError(err) ? "" : acpSessionId, `openclaw-acp: ${message}`);
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
if (abortListener && opts.signal) {
|
|
330
|
+
try {
|
|
331
|
+
opts.signal.removeEventListener("abort", abortListener);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// ignore
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
handle.subscribers.delete(acpSessionId);
|
|
338
|
+
handle.inFlight = Math.max(0, handle.inFlight - 1);
|
|
339
|
+
resetIdle(handle, POOL_KEY);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------
|
|
343
|
+
// 进程管理
|
|
344
|
+
// ---------------------------------------------------------------------
|
|
345
|
+
async acquireHandle(key, gateway) {
|
|
346
|
+
let handle = ACP_POOL.get(key);
|
|
347
|
+
if (handle && handle.closed) {
|
|
348
|
+
ACP_POOL.delete(key);
|
|
349
|
+
handle = undefined;
|
|
350
|
+
}
|
|
351
|
+
// env 改变(url/token 轮换)后不得继续用旧 --url/--token 的子进程。
|
|
352
|
+
if (handle && (handle.spawnedUrl !== gateway.url || handle.spawnedToken !== gateway.token)) {
|
|
353
|
+
log.info("openclaw-acp.gateway-args-changed", {
|
|
354
|
+
key,
|
|
355
|
+
oldUrl: handle.spawnedUrl,
|
|
356
|
+
newUrl: gateway.url,
|
|
357
|
+
tokenChanged: handle.spawnedToken !== gateway.token,
|
|
358
|
+
});
|
|
359
|
+
shutdownHandle(handle, "gateway-args-changed");
|
|
360
|
+
ACP_POOL.delete(key);
|
|
361
|
+
handle = undefined;
|
|
362
|
+
}
|
|
363
|
+
if (!handle) {
|
|
364
|
+
handle = this.spawnAcpProcess(key, gateway);
|
|
365
|
+
ACP_POOL.set(key, handle);
|
|
366
|
+
}
|
|
367
|
+
if (!handle.initialized) {
|
|
368
|
+
if (!handle.initializePromise) {
|
|
369
|
+
handle.initializePromise = sendRequest(handle, "initialize", {
|
|
370
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
371
|
+
clientCapabilities: {},
|
|
372
|
+
}).then(() => {
|
|
373
|
+
handle.initialized = true;
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
await handle.initializePromise;
|
|
377
|
+
}
|
|
378
|
+
return handle;
|
|
379
|
+
}
|
|
380
|
+
spawnAcpProcess(key, gateway) {
|
|
381
|
+
const command = resolveOpenclawCommand({ env: this.env }) ?? "openclaw";
|
|
382
|
+
const args = ["acp", "--url", gateway.url];
|
|
383
|
+
if (gateway.token)
|
|
384
|
+
args.push("--token", gateway.token);
|
|
385
|
+
const child = this.spawnFn(command, args, {
|
|
386
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
387
|
+
env: { ...process.env },
|
|
388
|
+
});
|
|
389
|
+
installExitCleanupHook();
|
|
390
|
+
const handle = {
|
|
391
|
+
child,
|
|
392
|
+
pending: new Map(),
|
|
393
|
+
subscribers: new Map(),
|
|
394
|
+
nextId: 1,
|
|
395
|
+
buffer: "",
|
|
396
|
+
nonJsonStdoutTail: [],
|
|
397
|
+
initialized: false,
|
|
398
|
+
inFlight: 0,
|
|
399
|
+
closed: false,
|
|
400
|
+
spawnedUrl: gateway.url,
|
|
401
|
+
spawnedToken: gateway.token,
|
|
402
|
+
};
|
|
403
|
+
child.stdout.setEncoding("utf8");
|
|
404
|
+
child.stdout.on("data", (chunk) => onStdoutChunk(handle, chunk));
|
|
405
|
+
child.stderr.setEncoding("utf8");
|
|
406
|
+
child.stderr.on("data", (chunk) => {
|
|
407
|
+
log.debug("openclaw-acp.stderr", { key, chunk: chunk.slice(0, 500) });
|
|
408
|
+
});
|
|
409
|
+
child.on("exit", (code, signal) => {
|
|
410
|
+
shutdownHandle(handle, `exit code=${code ?? "null"} signal=${signal ?? "null"}`);
|
|
411
|
+
ACP_POOL.delete(key);
|
|
412
|
+
});
|
|
413
|
+
child.on("error", (err) => {
|
|
414
|
+
log.warn("openclaw-acp.child-error", {
|
|
415
|
+
key,
|
|
416
|
+
error: err instanceof Error ? err.message : String(err),
|
|
417
|
+
});
|
|
418
|
+
shutdownHandle(handle, `error: ${err.message}`);
|
|
419
|
+
ACP_POOL.delete(key);
|
|
420
|
+
});
|
|
421
|
+
return handle;
|
|
422
|
+
}
|
|
423
|
+
async newSession(handle, args) {
|
|
424
|
+
const result = (await sendRequest(handle, "session/new", {
|
|
425
|
+
cwd: args.cwd,
|
|
426
|
+
mcpServers: [],
|
|
427
|
+
_meta: { sessionKey: args.sessionKey },
|
|
428
|
+
}));
|
|
429
|
+
if (!result?.sessionId || typeof result.sessionId !== "string") {
|
|
430
|
+
throw new Error("newSession returned no sessionId");
|
|
431
|
+
}
|
|
432
|
+
return result.sessionId;
|
|
433
|
+
}
|
|
434
|
+
async loadSession(handle, args) {
|
|
435
|
+
const result = (await sendRequest(handle, "session/load", {
|
|
436
|
+
sessionId: args.sessionId,
|
|
437
|
+
cwd: args.cwd,
|
|
438
|
+
mcpServers: [],
|
|
439
|
+
_meta: { sessionKey: args.sessionKey },
|
|
440
|
+
}));
|
|
441
|
+
if (result?.sessionId && typeof result.sessionId === "string") {
|
|
442
|
+
return result.sessionId;
|
|
443
|
+
}
|
|
444
|
+
return args.sessionId;
|
|
445
|
+
}
|
|
446
|
+
async prompt(handle, args) {
|
|
447
|
+
return sendRequest(handle, "session/prompt", {
|
|
448
|
+
sessionId: args.sessionId,
|
|
449
|
+
prompt: [{ type: "text", text: args.text }],
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// JSON-RPC stdio plumbing
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
function onStdoutChunk(handle, chunk) {
|
|
457
|
+
handle.buffer += chunk;
|
|
458
|
+
let idx;
|
|
459
|
+
while ((idx = handle.buffer.indexOf("\n")) !== -1) {
|
|
460
|
+
const line = handle.buffer.slice(0, idx).trim();
|
|
461
|
+
handle.buffer = handle.buffer.slice(idx + 1);
|
|
462
|
+
if (!line)
|
|
463
|
+
continue;
|
|
464
|
+
let msg;
|
|
465
|
+
try {
|
|
466
|
+
msg = JSON.parse(line);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
handle.nonJsonStdoutTail.push(line.slice(0, 500));
|
|
470
|
+
if (handle.nonJsonStdoutTail.length > 20) {
|
|
471
|
+
handle.nonJsonStdoutTail.splice(0, handle.nonJsonStdoutTail.length - 20);
|
|
472
|
+
}
|
|
473
|
+
log.warn("openclaw-acp.parse-error", {
|
|
474
|
+
error: err instanceof Error ? err.message : String(err),
|
|
475
|
+
line: line.slice(0, 200),
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
routeMessage(handle, msg);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function routeMessage(handle, msg) {
|
|
483
|
+
if (msg && typeof msg === "object" && "id" in msg && ("result" in msg || "error" in msg)) {
|
|
484
|
+
const id = typeof msg.id === "number" ? msg.id : Number(msg.id);
|
|
485
|
+
const pending = handle.pending.get(id);
|
|
486
|
+
if (!pending)
|
|
487
|
+
return;
|
|
488
|
+
handle.pending.delete(id);
|
|
489
|
+
if (msg.error) {
|
|
490
|
+
pending.reject(new Error(formatRpcError(msg.error)));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
pending.resolve(msg.result);
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// 通知。
|
|
498
|
+
if (msg?.method && msg?.params) {
|
|
499
|
+
const sid = msg.params?.sessionId;
|
|
500
|
+
if (typeof sid === "string") {
|
|
501
|
+
const sub = handle.subscribers.get(sid);
|
|
502
|
+
if (sub) {
|
|
503
|
+
try {
|
|
504
|
+
sub({ method: msg.method, params: msg.params });
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
log.warn("openclaw-acp.subscriber-threw", {
|
|
508
|
+
error: err instanceof Error ? err.message : String(err),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function sendRequest(handle, method, params) {
|
|
516
|
+
if (handle.closed)
|
|
517
|
+
return Promise.reject(new Error("acp child closed"));
|
|
518
|
+
return new Promise((resolve, reject) => {
|
|
519
|
+
const id = handle.nextId++;
|
|
520
|
+
handle.pending.set(id, { resolve, reject, method });
|
|
521
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
522
|
+
try {
|
|
523
|
+
handle.child.stdin.write(frame);
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
handle.pending.delete(id);
|
|
527
|
+
reject(err);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
function sendNotification(handle, method, params) {
|
|
532
|
+
if (handle.closed)
|
|
533
|
+
return;
|
|
534
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
|
|
535
|
+
try {
|
|
536
|
+
handle.child.stdin.write(frame);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// best-effort fire-and-forget
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Helpers
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
function failResult(sessionId, error) {
|
|
546
|
+
return {
|
|
547
|
+
text: "",
|
|
548
|
+
newSessionId: sessionId,
|
|
549
|
+
error,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function formatRpcError(error) {
|
|
553
|
+
if (!error || typeof error !== "object")
|
|
554
|
+
return "rpc error";
|
|
555
|
+
const e = error;
|
|
556
|
+
const message = typeof e.message === "string" ? e.message : "rpc error";
|
|
557
|
+
const data = e.data;
|
|
558
|
+
if (data && typeof data === "object") {
|
|
559
|
+
const details = data.details;
|
|
560
|
+
if (typeof details === "string" && details.length > 0) {
|
|
561
|
+
return `${message}: ${details}`;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return message;
|
|
565
|
+
}
|
|
566
|
+
function isSessionNotFoundError(err) {
|
|
567
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
568
|
+
return /session(?:\s+[\w-]+)?\s+not\s+found|unknown\s+session/i.test(msg);
|
|
569
|
+
}
|
|
570
|
+
function classifyAcpUpdate(note) {
|
|
571
|
+
const update = note.params?.update;
|
|
572
|
+
const kind = update?.sessionUpdate;
|
|
573
|
+
switch (kind) {
|
|
574
|
+
case "agent_message_chunk":
|
|
575
|
+
return "assistant_text";
|
|
576
|
+
case "tool_call":
|
|
577
|
+
return "tool_use";
|
|
578
|
+
case "tool_call_update":
|
|
579
|
+
return "tool_result";
|
|
580
|
+
case "session_info_update":
|
|
581
|
+
case "available_commands_update":
|
|
582
|
+
case "usage_update":
|
|
583
|
+
return "system";
|
|
584
|
+
default:
|
|
585
|
+
return "other";
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function extractText(content) {
|
|
589
|
+
if (!content)
|
|
590
|
+
return "";
|
|
591
|
+
if (typeof content === "string")
|
|
592
|
+
return content;
|
|
593
|
+
if (Array.isArray(content)) {
|
|
594
|
+
return content.map(extractText).join("");
|
|
595
|
+
}
|
|
596
|
+
if (typeof content === "object") {
|
|
597
|
+
const c = content;
|
|
598
|
+
const type = typeof c.type === "string" ? c.type.toLowerCase() : "";
|
|
599
|
+
if (type === "thinking" || type === "reasoning" || type === "thought")
|
|
600
|
+
return "";
|
|
601
|
+
if (typeof c.text === "string")
|
|
602
|
+
return c.text;
|
|
603
|
+
if (typeof c.content === "string")
|
|
604
|
+
return c.content;
|
|
605
|
+
if (Array.isArray(c.content))
|
|
606
|
+
return extractText(c.content);
|
|
607
|
+
}
|
|
608
|
+
return "";
|
|
609
|
+
}
|
|
610
|
+
function sanitizeAssistantChunk(note, text) {
|
|
611
|
+
return {
|
|
612
|
+
...note,
|
|
613
|
+
params: {
|
|
614
|
+
...note.params,
|
|
615
|
+
update: {
|
|
616
|
+
...note.params?.update,
|
|
617
|
+
content: [{ type: "text", text }],
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
function normalizeAssistantText(text) {
|
|
623
|
+
if (!text)
|
|
624
|
+
return "";
|
|
625
|
+
const finalMatch = text.match(/<final>([\s\S]*?)<\/final>/i);
|
|
626
|
+
const selected = finalMatch ? finalMatch[1] : text;
|
|
627
|
+
if (!finalMatch && selected.trimStart().toLowerCase().startsWith("<think")) {
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
return stripLeadingBoundaryResidue(selected
|
|
631
|
+
.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, "")
|
|
632
|
+
.replace(/<\/?final>/gi, "")
|
|
633
|
+
.trim());
|
|
634
|
+
}
|
|
635
|
+
function stripLeadingBoundaryResidue(text) {
|
|
636
|
+
if (!text.startsWith("<"))
|
|
637
|
+
return text;
|
|
638
|
+
// 保留真实 HTML/XML 风格标签与比较运算符;孤立的前导 "<" 是 ACP 把
|
|
639
|
+
// 结构边界标记与正文分开流式发送时的残留。
|
|
640
|
+
if (startsWithRealAngleSyntax(text))
|
|
641
|
+
return text;
|
|
642
|
+
return text.slice(1).trimStart();
|
|
643
|
+
}
|
|
644
|
+
function startsWithRealAngleSyntax(text) {
|
|
645
|
+
return /^<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s|>|\/>)/.test(text) || /^<(?:\s|=|<)/.test(text);
|
|
646
|
+
}
|
|
647
|
+
function createAssistantTextFilter() {
|
|
648
|
+
let pending = "";
|
|
649
|
+
let inThink = false;
|
|
650
|
+
let inFinal = false;
|
|
651
|
+
let seenFinal = false;
|
|
652
|
+
let fallback = "";
|
|
653
|
+
const consume = (flush) => {
|
|
654
|
+
let out = "";
|
|
655
|
+
while (pending.length > 0) {
|
|
656
|
+
if (inThink) {
|
|
657
|
+
const close = pending.search(/<\/think>/i);
|
|
658
|
+
if (close === -1) {
|
|
659
|
+
if (flush)
|
|
660
|
+
pending = "";
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
pending = pending.slice(close).replace(/^<\/think>/i, "");
|
|
664
|
+
inThink = false;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (inFinal) {
|
|
668
|
+
const close = pending.search(/<\/final>/i);
|
|
669
|
+
if (close === -1) {
|
|
670
|
+
out += pending;
|
|
671
|
+
pending = "";
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
out += pending.slice(0, close);
|
|
675
|
+
pending = pending.slice(close).replace(/^<\/final>/i, "");
|
|
676
|
+
inFinal = false;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const lt = pending.indexOf("<");
|
|
680
|
+
if (lt === -1) {
|
|
681
|
+
if (seenFinal) {
|
|
682
|
+
out += pending;
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
fallback += pending;
|
|
686
|
+
}
|
|
687
|
+
pending = "";
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
if (lt > 0) {
|
|
691
|
+
if (seenFinal) {
|
|
692
|
+
out += pending.slice(0, lt);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
fallback += pending.slice(0, lt);
|
|
696
|
+
}
|
|
697
|
+
pending = pending.slice(lt);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const lower = pending.toLowerCase();
|
|
701
|
+
if (lower.startsWith("<think")) {
|
|
702
|
+
const end = pending.indexOf(">");
|
|
703
|
+
if (end === -1) {
|
|
704
|
+
if (flush)
|
|
705
|
+
pending = "";
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
pending = pending.slice(end + 1);
|
|
709
|
+
inThink = true;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (lower.startsWith("</think")) {
|
|
713
|
+
const end = pending.indexOf(">");
|
|
714
|
+
if (end === -1) {
|
|
715
|
+
if (flush)
|
|
716
|
+
pending = "";
|
|
717
|
+
return out;
|
|
718
|
+
}
|
|
719
|
+
pending = pending.slice(end + 1);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (lower.startsWith("<final")) {
|
|
723
|
+
const end = pending.indexOf(">");
|
|
724
|
+
if (end === -1) {
|
|
725
|
+
if (flush)
|
|
726
|
+
pending = "";
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
pending = pending.slice(end + 1);
|
|
730
|
+
seenFinal = true;
|
|
731
|
+
fallback = "";
|
|
732
|
+
inFinal = true;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (lower.startsWith("</final")) {
|
|
736
|
+
const end = pending.indexOf(">");
|
|
737
|
+
if (end === -1) {
|
|
738
|
+
if (flush)
|
|
739
|
+
pending = "";
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
pending = pending.slice(end + 1);
|
|
743
|
+
inFinal = false;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const knownPrefixes = ["<think", "</think", "<final", "</final"];
|
|
747
|
+
if (!flush && knownPrefixes.some((prefix) => prefix.startsWith(lower))) {
|
|
748
|
+
return out;
|
|
749
|
+
}
|
|
750
|
+
if (!flush && pending === "<") {
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
if (!startsWithRealAngleSyntax(pending)) {
|
|
754
|
+
pending = pending.slice(1).trimStart();
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (seenFinal) {
|
|
758
|
+
out += "<";
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
fallback += "<";
|
|
762
|
+
}
|
|
763
|
+
pending = pending.slice(1);
|
|
764
|
+
}
|
|
765
|
+
if (flush && !seenFinal && fallback) {
|
|
766
|
+
const text = normalizeAssistantText(fallback);
|
|
767
|
+
fallback = "";
|
|
768
|
+
return pickSafeAssistantText(text);
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
};
|
|
772
|
+
return {
|
|
773
|
+
push(text) {
|
|
774
|
+
if (!text)
|
|
775
|
+
return "";
|
|
776
|
+
pending += text;
|
|
777
|
+
return consume(false);
|
|
778
|
+
},
|
|
779
|
+
flush() {
|
|
780
|
+
return consume(true);
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function pickFinalText(result) {
|
|
785
|
+
if (!result || typeof result !== "object")
|
|
786
|
+
return undefined;
|
|
787
|
+
const r = result;
|
|
788
|
+
if (Array.isArray(r.assistantTexts)) {
|
|
789
|
+
const text = r.assistantTexts.filter((x) => typeof x === "string").join("\n");
|
|
790
|
+
if (text.length > 0)
|
|
791
|
+
return text;
|
|
792
|
+
}
|
|
793
|
+
const contentText = extractText(r.content);
|
|
794
|
+
if (contentText.length > 0)
|
|
795
|
+
return contentText;
|
|
796
|
+
const outputText = extractText(r.output);
|
|
797
|
+
if (outputText.length > 0)
|
|
798
|
+
return outputText;
|
|
799
|
+
const responseText = extractText(r.response);
|
|
800
|
+
if (responseText.length > 0)
|
|
801
|
+
return responseText;
|
|
802
|
+
if (typeof r.text === "string" && r.text.length > 0)
|
|
803
|
+
return r.text;
|
|
804
|
+
if (typeof r.message === "string" && r.message.length > 0)
|
|
805
|
+
return r.message;
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
function pickStopReason(result) {
|
|
809
|
+
if (!result || typeof result !== "object")
|
|
810
|
+
return undefined;
|
|
811
|
+
const v = result.stopReason;
|
|
812
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
813
|
+
}
|
|
814
|
+
function truncateDetail(text, max) {
|
|
815
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
816
|
+
}
|
|
817
|
+
function looksLikeReasoningLeak(text) {
|
|
818
|
+
const t = text.trim();
|
|
819
|
+
if (!t)
|
|
820
|
+
return false;
|
|
821
|
+
return (/^the user (said|asked|wants|is asking)\b/i.test(t) ||
|
|
822
|
+
/^i('|’)m .*\b(i('|’)ll|i will|need to|should|going to)\b/i.test(t) ||
|
|
823
|
+
/\bi('|’)ll respond\b/i.test(t) ||
|
|
824
|
+
/\bi need to\b/i.test(t));
|
|
825
|
+
}
|
|
826
|
+
function pickSafeAssistantText(text) {
|
|
827
|
+
if (!text)
|
|
828
|
+
return "";
|
|
829
|
+
const trimmed = text.trim();
|
|
830
|
+
if (!looksLikeReasoningLeak(trimmed))
|
|
831
|
+
return trimmed;
|
|
832
|
+
return stripLeadingReasoningLeak(trimmed);
|
|
833
|
+
}
|
|
834
|
+
function stripLeadingReasoningLeak(text) {
|
|
835
|
+
const paragraphs = text
|
|
836
|
+
.replace(/\r\n/g, "\n")
|
|
837
|
+
.split(/\n{2,}/)
|
|
838
|
+
.map((p) => p.trim())
|
|
839
|
+
.filter(Boolean);
|
|
840
|
+
while (paragraphs.length > 0 && isReasoningNarration(paragraphs[0])) {
|
|
841
|
+
paragraphs.shift();
|
|
842
|
+
}
|
|
843
|
+
const candidate = paragraphs.join("\n\n").trim();
|
|
844
|
+
return candidate && !looksLikeReasoningLeak(candidate) ? candidate : "";
|
|
845
|
+
}
|
|
846
|
+
function isReasoningNarration(text) {
|
|
847
|
+
const t = text.trim();
|
|
848
|
+
if (!t)
|
|
849
|
+
return false;
|
|
850
|
+
return (looksLikeReasoningLeak(t) ||
|
|
851
|
+
/^let me\b/i.test(t) ||
|
|
852
|
+
/^i('|’)ll\b/i.test(t) ||
|
|
853
|
+
/^i will\b/i.test(t) ||
|
|
854
|
+
/^i should\b/i.test(t) ||
|
|
855
|
+
/^i need to\b/i.test(t));
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* 构造 OpenClaw ACP `sessionKey`。始终包含 accountId 段,避免两个 daemon
|
|
859
|
+
* 身份在同一网关上的 key 冲突。
|
|
860
|
+
*/
|
|
861
|
+
export function buildAcpSessionKey(args) {
|
|
862
|
+
return `agent:${args.openclawAgent}:${args.accountId}:${args.conversationKey}`;
|
|
863
|
+
}
|
|
864
|
+
export const openclawAcpModule = {
|
|
865
|
+
id: "openclaw-acp",
|
|
866
|
+
displayName: "OpenClaw (ACP)",
|
|
867
|
+
binary: "openclaw",
|
|
868
|
+
envVar: "BOTLEARN_OPENCLAW_BIN",
|
|
869
|
+
installHint: "Install the OpenClaw CLI (`openclaw` on PATH, or set BOTLEARN_OPENCLAW_BIN) and point BOTLEARN_OPENCLAW_URL at your gateway (optional: BOTLEARN_OPENCLAW_TOKEN, BOTLEARN_OPENCLAW_AGENT).",
|
|
870
|
+
probe: async () => probeOpenclaw(),
|
|
871
|
+
create: () => wrapEngineAdapter("openclaw-acp", new OpenclawAcpAdapter()),
|
|
872
|
+
};
|