@xyagent/cli 0.0.1 → 1.1.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.
- package/README.md +42 -0
- package/bin/agentlink +1002 -245
- package/bin/agentlink-agent +0 -0
- package/bin/agentlink-mcp-stdio +0 -0
- package/package.json +5 -3
- package/src/core.mjs +26 -0
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -12
- package/src-ext/commands/agent.mjs +33 -10
- package/src-ext/commands/pair.mjs +122 -23
- package/src-ext/commands/service.mjs +1 -1
- package/src-ext/core/activeRuns.mjs +26 -9
- package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
- package/src-ext/core/autoTunnelDetector.mjs +6 -1
- package/src-ext/core/autoTunnelWiring.mjs +1 -1
- package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
- package/src-ext/core/defaultWorkspace.mjs +43 -13
- package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
- package/src-ext/core/installationIdentity.mjs +94 -0
- package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
- package/src-ext/core/pairCodeClient.mjs +48 -8
- package/src-ext/core/pairInventory.mjs +31 -6
- package/src-ext/core/relayWorker.mjs +22 -2
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanDeeplink.mjs +67 -0
- package/src-ext/core/scanPairFlow.mjs +28 -0
- package/src-ext/core/scanQrRenderer.mjs +40 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
- package/src-ext/core/usageReporter.mjs +1 -2
- package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
- package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
- package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
- package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
- package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
- package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
- package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
- package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
- package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- package/src-ext/runtime/claude/launcher.mjs +0 -6
- package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
- package/src-ext/runtime/codebuddy/index.mjs +41 -0
- package/src-ext/runtime/codex/handleRequest.mjs +13 -35
- package/src-ext/runtime/cursor/index.mjs +46 -0
- package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/deepagents/preflight.mjs +57 -0
- package/src-ext/runtime/hermes/envSetup.mjs +22 -8
- package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
- package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
- package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
- package/src-ext/runtime/hermes/index.mjs +1 -1
- package/src-ext/runtime/hermes/preflight.mjs +2 -1
- package/src-ext/runtime/kimi/index.mjs +100 -0
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
- package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
- package/src-ext/runtime/opencode/index.mjs +48 -0
- package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/opencode/preflight.mjs +72 -0
- package/src-ext/runtime/qwen/index.mjs +42 -0
- package/src-ext/service/serviceManager.mjs +120 -42
- package/src-shared/envelope_builder.mjs +2 -2
- package/src-ext/runtime/picoclaw/constants.mjs +0 -39
- package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
- package/src-ext/runtime/picoclaw/index.mjs +0 -314
- package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
- package/src-ext/runtime/picoclaw/state.mjs +0 -78
- package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
- package/src-ext/runtime/picoclaw/translator.mjs +0 -272
- package/src-ext/runtime/picoclaw/wsClient.mjs +0 -129
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// 所以调用前必须先跑过 `agentlink pair <code> -r <runtime>` —— ensureState 会校验。
|
|
13
13
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import os from "node:os";
|
|
16
17
|
import path from "node:path";
|
|
17
18
|
import process from "node:process";
|
|
@@ -19,7 +20,30 @@ import { spawnSync } from "node:child_process";
|
|
|
19
20
|
import { readPairState } from "../core/pairCodeClient.mjs";
|
|
20
21
|
import { ensureDefaultWorkspace, defaultWorkspacePath } from "../core/defaultWorkspace.mjs";
|
|
21
22
|
|
|
22
|
-
const SERVICE_RUNTIMES = new Set([
|
|
23
|
+
const SERVICE_RUNTIMES = new Set([
|
|
24
|
+
"claude", "codex", "cursor", "opencode", "qwen", "kimi", "codebuddy",
|
|
25
|
+
]);
|
|
26
|
+
const SERVICE_SCOPE_HASH_LENGTH = 12;
|
|
27
|
+
|
|
28
|
+
function resolveAgentlinkHome() {
|
|
29
|
+
const configured = String(process.env.AGENTLINK_HOME ?? "").trim();
|
|
30
|
+
return configured || path.join(os.homedir(), ".agentlink");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function serviceScopeSuffixFor(agentlinkHome, defaultAgentlinkHome) {
|
|
34
|
+
const currentHome = path.resolve(agentlinkHome);
|
|
35
|
+
const defaultHome = path.resolve(defaultAgentlinkHome);
|
|
36
|
+
if (currentHome === defaultHome) return "";
|
|
37
|
+
const digest = createHash("sha256").update(currentHome).digest("hex");
|
|
38
|
+
return `-${digest.slice(0, SERVICE_SCOPE_HASH_LENGTH)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function serviceScopeSuffix() {
|
|
42
|
+
return serviceScopeSuffixFor(
|
|
43
|
+
resolveAgentlinkHome(),
|
|
44
|
+
path.join(os.homedir(), ".agentlink"),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
/**
|
|
25
49
|
* reload 用的 runtime 清单 —— 比 install 集合更广:也覆盖 openclaw/hermes
|
|
@@ -42,6 +66,31 @@ const ALL_RELOAD_TARGETS = {
|
|
|
42
66
|
linux: "agentlink-agent-codex.service",
|
|
43
67
|
win: "Agentlink\\Agent-codex",
|
|
44
68
|
},
|
|
69
|
+
cursor: {
|
|
70
|
+
mac: "com.agentlink.agent.cursor",
|
|
71
|
+
linux: "agentlink-agent-cursor.service",
|
|
72
|
+
win: "Agentlink\\Agent-cursor",
|
|
73
|
+
},
|
|
74
|
+
opencode: {
|
|
75
|
+
mac: "com.agentlink.agent.opencode",
|
|
76
|
+
linux: "agentlink-agent-opencode.service",
|
|
77
|
+
win: "Agentlink\\Agent-opencode",
|
|
78
|
+
},
|
|
79
|
+
qwen: {
|
|
80
|
+
mac: "com.agentlink.agent.qwen",
|
|
81
|
+
linux: "agentlink-agent-qwen.service",
|
|
82
|
+
win: "Agentlink\\Agent-qwen",
|
|
83
|
+
},
|
|
84
|
+
kimi: {
|
|
85
|
+
mac: "com.agentlink.agent.kimi",
|
|
86
|
+
linux: "agentlink-agent-kimi.service",
|
|
87
|
+
win: "Agentlink\\Agent-kimi",
|
|
88
|
+
},
|
|
89
|
+
codebuddy: {
|
|
90
|
+
mac: "com.agentlink.agent.codebuddy",
|
|
91
|
+
linux: "agentlink-agent-codebuddy.service",
|
|
92
|
+
win: "Agentlink\\Agent-codebuddy",
|
|
93
|
+
},
|
|
45
94
|
openclaw: {
|
|
46
95
|
mac: "com.agentlink.bridge",
|
|
47
96
|
linux: "agentlink-bridge.service",
|
|
@@ -54,6 +103,18 @@ const ALL_RELOAD_TARGETS = {
|
|
|
54
103
|
},
|
|
55
104
|
};
|
|
56
105
|
|
|
106
|
+
function reloadTarget(runtime) {
|
|
107
|
+
const target = ALL_RELOAD_TARGETS[runtime];
|
|
108
|
+
if (!target) return null;
|
|
109
|
+
const scope = serviceScopeSuffix();
|
|
110
|
+
if (!scope) return target;
|
|
111
|
+
return {
|
|
112
|
+
mac: `${target.mac}${scope}`,
|
|
113
|
+
linux: target.linux.replace(/\.service$/, `${scope}.service`),
|
|
114
|
+
win: `${target.win}${scope}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
57
118
|
function ensureRuntime(runtime) {
|
|
58
119
|
if (!SERVICE_RUNTIMES.has(runtime)) {
|
|
59
120
|
throw new Error(`service runtime must be one of: ${[...SERVICE_RUNTIMES].join(", ")}`);
|
|
@@ -95,7 +156,7 @@ function agentBinPath() {
|
|
|
95
156
|
}
|
|
96
157
|
|
|
97
158
|
// ===== macOS launchd =====
|
|
98
|
-
function macLabel(runtime) { return `com.agentlink.agent.${runtime}`; }
|
|
159
|
+
function macLabel(runtime) { return `com.agentlink.agent.${runtime}${serviceScopeSuffix()}`; }
|
|
99
160
|
function macPlistPath(runtime) {
|
|
100
161
|
return path.join(os.homedir(), "Library", "LaunchAgents", `${macLabel(runtime)}.plist`);
|
|
101
162
|
}
|
|
@@ -114,7 +175,7 @@ function macUid() {
|
|
|
114
175
|
* "进程未运行起来",反复 pair / 残留旧 plist 更易触发。
|
|
115
176
|
*
|
|
116
177
|
* 健壮序列(与 bin/agentlink 的 installBridgeServiceMac 对齐):
|
|
117
|
-
* bootout ——
|
|
178
|
+
* bootout —— 先按 service target 拆掉任何同 label 的旧实例(幂等,未加载也无妨)
|
|
118
179
|
* bootstrap —— 把 job 加载进 GUI domain
|
|
119
180
|
* enable —— 清掉上一次 unload -w / disable 留下的 stale "disabled" override
|
|
120
181
|
* kickstart -k —— 强制真正运行(已在跑则重启),不再靠 RunAtLoad 的运气
|
|
@@ -125,6 +186,9 @@ export function buildMacInstallSteps({ uid, label, plistPath }) {
|
|
|
125
186
|
const domain = `gui/${uid}`;
|
|
126
187
|
const service = `gui/${uid}/${label}`;
|
|
127
188
|
return [
|
|
189
|
+
// 不能只按 plistPath bootout:同 label 的旧 job 可能来自已删掉的临时 HOME。
|
|
190
|
+
{ cmd: "launchctl", args: ["bootout", service] },
|
|
191
|
+
// 兼容历史按 plist 加载的实例;上一步未命中时仍尽力清理。
|
|
128
192
|
{ cmd: "launchctl", args: ["bootout", domain, plistPath] },
|
|
129
193
|
{ cmd: "launchctl", args: ["bootstrap", domain, plistPath] },
|
|
130
194
|
{ cmd: "launchctl", args: ["enable", service] },
|
|
@@ -132,12 +196,51 @@ export function buildMacInstallSteps({ uid, label, plistPath }) {
|
|
|
132
196
|
];
|
|
133
197
|
}
|
|
134
198
|
|
|
199
|
+
/**
|
|
200
|
+
* 执行并验证 macOS 安装步骤。注入 spawn 使测试不必触碰真实 gui/<uid> launchd 域。
|
|
201
|
+
*/
|
|
202
|
+
export function runMacInstallSteps({ steps, plistPath, service, spawn = spawnSync, log }) {
|
|
203
|
+
let bootstrapOk = false;
|
|
204
|
+
let kickstart = null;
|
|
205
|
+
for (const step of steps) {
|
|
206
|
+
const verb = step.args[0];
|
|
207
|
+
const result = spawn(step.cmd, step.args, { encoding: "utf8" });
|
|
208
|
+
// bootout 对未加载的 job 必然失败,安装仍应是幂等的。
|
|
209
|
+
if (verb === "bootout") continue;
|
|
210
|
+
if (verb === "bootstrap") {
|
|
211
|
+
bootstrapOk = result.status === 0;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (verb === "enable") continue;
|
|
215
|
+
if (verb === "kickstart") kickstart = result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!kickstart || kickstart.status !== 0) {
|
|
219
|
+
log?.warn("service.install", "modern launchctl sequence failed, falling back to load -w", {
|
|
220
|
+
bootstrap_ok: bootstrapOk,
|
|
221
|
+
});
|
|
222
|
+
const fallback = spawn("launchctl", ["load", "-w", plistPath], { encoding: "utf8" });
|
|
223
|
+
if (fallback.status !== 0) {
|
|
224
|
+
throw new Error("launchctl bootstrap/kickstart failed");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const printed = spawn("launchctl", ["print", service], { encoding: "utf8" });
|
|
229
|
+
if (printed.status !== 0) {
|
|
230
|
+
throw new Error(`launchctl install verification failed for ${service}`);
|
|
231
|
+
}
|
|
232
|
+
const loadedPath = parseLaunchctlPrint(printed.stdout).path;
|
|
233
|
+
if (loadedPath !== plistPath) {
|
|
234
|
+
throw new Error(`launchctl loaded unexpected plist for ${service}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
135
238
|
/**
|
|
136
239
|
* 解析 `launchctl print gui/<uid>/<label>` 的关键字段。纯函数,便于单测。
|
|
137
240
|
* 只有 print 能暴露 `runs`,识破 pid 文件看不出的 "loaded but runs=0" 陷阱。
|
|
138
241
|
*/
|
|
139
242
|
export function parseLaunchctlPrint(out) {
|
|
140
|
-
const h = { state: null, pid: null, runs: null, lastExitCode: null };
|
|
243
|
+
const h = { state: null, pid: null, runs: null, lastExitCode: null, path: null };
|
|
141
244
|
for (const line of String(out || "").split(/\r?\n/)) {
|
|
142
245
|
const t = line.trim();
|
|
143
246
|
let m;
|
|
@@ -145,6 +248,7 @@ export function parseLaunchctlPrint(out) {
|
|
|
145
248
|
else if ((m = t.match(/^pid\s*=\s*(\d+)$/))) h.pid = Number(m[1]);
|
|
146
249
|
else if ((m = t.match(/^runs\s*=\s*(\d+)$/))) h.runs = Number(m[1]);
|
|
147
250
|
else if ((m = t.match(/^last exit code\s*=\s*(\d+)$/))) h.lastExitCode = Number(m[1]);
|
|
251
|
+
else if ((m = t.match(/^path\s*=\s*(.+)$/))) h.path = m[1].trim();
|
|
148
252
|
}
|
|
149
253
|
h.running = (h.state || "").toLowerCase() === "running" || (h.pid != null && h.pid > 0);
|
|
150
254
|
return h;
|
|
@@ -156,13 +260,13 @@ async function macInstall(runtime, log) {
|
|
|
156
260
|
const state = ensureStateOrFail(runtime);
|
|
157
261
|
const label = macLabel(runtime);
|
|
158
262
|
const plistPath = macPlistPath(runtime);
|
|
159
|
-
const logDir = path.join(
|
|
263
|
+
const logDir = path.join(resolveAgentlinkHome(), "logs");
|
|
160
264
|
fs.mkdirSync(logDir, { recursive: true });
|
|
161
265
|
|
|
162
|
-
//
|
|
266
|
+
// 所有 bridge runtime 都使用自己的默认工作区,避免服务从不确定目录启动。
|
|
163
267
|
// install 前确保目录存在(否则 launchd 因 WorkingDirectory 不存在拒绝启动)
|
|
164
268
|
let workingDirectory = null;
|
|
165
|
-
if (runtime
|
|
269
|
+
if (SERVICE_RUNTIMES.has(runtime)) {
|
|
166
270
|
workingDirectory = await ensureDefaultWorkspace(runtime);
|
|
167
271
|
}
|
|
168
272
|
const stdoutLog = path.join(logDir, `service-${runtime}.out.log`);
|
|
@@ -203,6 +307,7 @@ async function macInstall(runtime, log) {
|
|
|
203
307
|
<key>EnvironmentVariables</key>
|
|
204
308
|
<dict>
|
|
205
309
|
<key>AGENTLINK_RELAY_URL</key><string>${escapeXml(state.relay_url)}</string>
|
|
310
|
+
<key>AGENTLINK_HOME</key><string>${escapeXml(resolveAgentlinkHome())}</string>
|
|
206
311
|
<key>AGENTLINK_AGENT_LOG_LEVEL</key><string>info</string>
|
|
207
312
|
<key>PATH</key><string>${escapeXml(mergedPath)}</string>
|
|
208
313
|
<key>HOME</key><string>${escapeXml(os.homedir())}</string>
|
|
@@ -220,35 +325,7 @@ async function macInstall(runtime, log) {
|
|
|
220
325
|
// 现代序列 bootout→bootstrap→enable→kickstart(见 buildMacInstallSteps 注释)。
|
|
221
326
|
const uid = macUid();
|
|
222
327
|
const steps = buildMacInstallSteps({ uid, label, plistPath });
|
|
223
|
-
|
|
224
|
-
let kickstart = null;
|
|
225
|
-
for (const step of steps) {
|
|
226
|
-
const verb = step.args[0];
|
|
227
|
-
const r = spawnSync(step.cmd, step.args, { encoding: "utf8" });
|
|
228
|
-
// bootout 对未加载的 job 必然失败——幂等忽略。
|
|
229
|
-
if (verb === "bootout") continue;
|
|
230
|
-
if (verb === "bootstrap") {
|
|
231
|
-
bootstrapOk = r.status === 0;
|
|
232
|
-
// bootstrap 失败常见于 job 已加载(EALREADY/5);enable+kickstart 仍可救回,
|
|
233
|
-
// 故不在此抛错,留到最后看 kickstart 结果。
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
if (verb === "enable") continue; // 清 disabled override,失败无妨
|
|
237
|
-
if (verb === "kickstart") kickstart = r;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (!kickstart || kickstart.status !== 0) {
|
|
241
|
-
// 现代序列没能拉起——回退 legacy load -w 兜底(与 bin/agentlink 一致)。
|
|
242
|
-
log?.warn("service.install", "modern launchctl sequence failed, falling back to load -w", {
|
|
243
|
-
bootstrap_ok: bootstrapOk,
|
|
244
|
-
kickstart_err: (kickstart?.stderr || kickstart?.stdout || "").trim(),
|
|
245
|
-
});
|
|
246
|
-
const fallback = spawnSync("launchctl", ["load", "-w", plistPath], { encoding: "utf8" });
|
|
247
|
-
if (fallback.status !== 0) {
|
|
248
|
-
const msg = (kickstart?.stderr || kickstart?.stdout || fallback.stderr || fallback.stdout || "").trim();
|
|
249
|
-
throw new Error(`launchctl bootstrap/kickstart failed: ${msg || "unknown"}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
328
|
+
runMacInstallSteps({ steps, plistPath, service: `gui/${uid}/${label}`, log });
|
|
252
329
|
return { backend: "launchd", path: plistPath, label, logs: { out: stdoutLog, err: stderrLog } };
|
|
253
330
|
}
|
|
254
331
|
|
|
@@ -285,7 +362,7 @@ function macStatus(runtime) {
|
|
|
285
362
|
}
|
|
286
363
|
|
|
287
364
|
// ===== Linux systemd (user) =====
|
|
288
|
-
function sdUnitName(runtime) { return `agentlink-agent-${runtime}.service`; }
|
|
365
|
+
function sdUnitName(runtime) { return `agentlink-agent-${runtime}${serviceScopeSuffix()}.service`; }
|
|
289
366
|
function sdUnitPath(runtime) {
|
|
290
367
|
return path.join(os.homedir(), ".config", "systemd", "user", sdUnitName(runtime));
|
|
291
368
|
}
|
|
@@ -305,13 +382,13 @@ async function sdInstall(runtime, log) {
|
|
|
305
382
|
const node = process.execPath;
|
|
306
383
|
const state = ensureStateOrFail(runtime);
|
|
307
384
|
const unitPath = sdUnitPath(runtime);
|
|
308
|
-
const logDir = path.join(
|
|
385
|
+
const logDir = path.join(resolveAgentlinkHome(), "logs");
|
|
309
386
|
fs.mkdirSync(logDir, { recursive: true });
|
|
310
387
|
|
|
311
388
|
// align-runtime-default-workspace: 仅 claude/codex 注入 WorkingDirectory
|
|
312
389
|
// install 前确保目录存在(否则 systemd 因 WorkingDirectory 不存在拒绝启动)
|
|
313
390
|
let workingDirectoryLine = "";
|
|
314
|
-
if (runtime
|
|
391
|
+
if (SERVICE_RUNTIMES.has(runtime)) {
|
|
315
392
|
const wsPath = await ensureDefaultWorkspace(runtime);
|
|
316
393
|
workingDirectoryLine = `WorkingDirectory=${wsPath}`;
|
|
317
394
|
}
|
|
@@ -327,6 +404,7 @@ After=network-online.target
|
|
|
327
404
|
[Service]
|
|
328
405
|
Type=simple
|
|
329
406
|
Environment=AGENTLINK_RELAY_URL=${state.relay_url}
|
|
407
|
+
Environment=AGENTLINK_HOME=${resolveAgentlinkHome()}
|
|
330
408
|
Environment=AGENTLINK_AGENT_LOG_LEVEL=info
|
|
331
409
|
Environment=PATH=${pathEnv}
|
|
332
410
|
Environment=HOME=${os.homedir()}${workingDirectoryLine ? `\n${workingDirectoryLine}` : ""}
|
|
@@ -463,7 +541,7 @@ export function serviceReloadAll() {
|
|
|
463
541
|
|
|
464
542
|
// macOS reload
|
|
465
543
|
function macReload(runtime) {
|
|
466
|
-
const label =
|
|
544
|
+
const label = reloadTarget(runtime).mac;
|
|
467
545
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
468
546
|
const ref = `gui/${uid}/${label}`;
|
|
469
547
|
const present = spawnSync("launchctl", ["print", ref], { encoding: "utf8" });
|
|
@@ -480,7 +558,7 @@ function macReload(runtime) {
|
|
|
480
558
|
|
|
481
559
|
// Linux systemd user reload
|
|
482
560
|
function sdReload(runtime) {
|
|
483
|
-
const unit =
|
|
561
|
+
const unit = reloadTarget(runtime).linux;
|
|
484
562
|
// list-unit-files 可同时检测 masked/enabled/disabled;exit 0 表示 unit 存在。
|
|
485
563
|
// 另一种探测:`systemctl --user cat <unit>` —— 不存在会返回非 0。
|
|
486
564
|
const probe = spawnSync("systemctl", ["--user", "cat", unit], { encoding: "utf8" });
|
|
@@ -497,7 +575,7 @@ function sdReload(runtime) {
|
|
|
497
575
|
|
|
498
576
|
// Windows schtasks reload
|
|
499
577
|
function winReload(runtime) {
|
|
500
|
-
const task =
|
|
578
|
+
const task = reloadTarget(runtime).win;
|
|
501
579
|
const probe = spawnSync("schtasks", ["/Query", "/TN", task, "/FO", "LIST"], { encoding: "utf8" });
|
|
502
580
|
if (probe.status !== 0) {
|
|
503
581
|
return { status: "not-installed", backend: "schtasks", task };
|
|
@@ -445,7 +445,7 @@ export function translateOpenClawChunkToEnvelope(chunk, runId, blockState) {
|
|
|
445
445
|
}
|
|
446
446
|
envelopes.push(env);
|
|
447
447
|
} else if (event === "response.function_call_arguments.delta") {
|
|
448
|
-
//
|
|
448
|
+
// legacy event name → maps to tool_use.start + arguments.delta.
|
|
449
449
|
// chunk shape: { call_id, name, arguments } where arguments is a JSON
|
|
450
450
|
// fragment to append. We auto-start a tool_use block on first occurrence.
|
|
451
451
|
const callId = String(chunk.call_id || "");
|
|
@@ -537,7 +537,7 @@ export function translateOpenClawChunkToEnvelope(chunk, runId, blockState) {
|
|
|
537
537
|
envelopes.push(started, completed);
|
|
538
538
|
} else if (event === "response.output_text.done") {
|
|
539
539
|
// No incremental envelope needed — full text already accumulated via deltas.
|
|
540
|
-
// If runtime carries authoritative full text on .done
|
|
540
|
+
// If the runtime carries authoritative full text on .done, prefer it.
|
|
541
541
|
if (typeof chunk.text === "string" && chunk.text.length > 0) {
|
|
542
542
|
text.content = chunk.text;
|
|
543
543
|
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
// 巴布(PicoClaw)运行时常量。
|
|
2
|
-
//
|
|
3
|
-
// 部署假设:matelink-cli 与 picoclaw daemon 烧录在同一开发板,
|
|
4
|
-
// cli 默认连本地回环 picoclaw WebSocket。token 烧录时固化,cli 端写死同值。
|
|
5
|
-
//
|
|
6
|
-
// 如需改 token,**两处**(开发板 picoclaw config + 此文件)必须同步,否则连接失败。
|
|
7
|
-
|
|
8
|
-
import process from "node:process";
|
|
9
|
-
|
|
10
|
-
// PicoClaw daemon 的 WebSocket 端点(Pico Protocol)。
|
|
11
|
-
// 默认 127.0.0.1:18790/pico/ws,可通过 env 覆盖(开发/调试用)。
|
|
12
|
-
export const PICOCLAW_WS_URL =
|
|
13
|
-
process.env.PICOCLAW_WS_URL?.trim() || "ws://127.0.0.1:18790/pico/ws";
|
|
14
|
-
|
|
15
|
-
// PicoClaw Bearer token —— 与开发板 picoclaw config 里 [pico].token 一致。
|
|
16
|
-
// 生产烧录时双方都写死成下面这个固定值;切换值时必须双侧同步。
|
|
17
|
-
export const PICOCLAW_TOKEN =
|
|
18
|
-
process.env.PICOCLAW_TOKEN?.trim() || "bubu-local-token";
|
|
19
|
-
|
|
20
|
-
// 巴布配对二维码 PNG 输出路径 —— 屏幕显示模块从这个路径加载图片。
|
|
21
|
-
// 默认写到 /tmp/bubu/pair-qr.png,可通过 env 覆盖。
|
|
22
|
-
export const QR_PNG_OUTPUT_PATH =
|
|
23
|
-
process.env.BUBU_QR_PNG_PATH?.trim() || "/tmp/bubu/pair-qr.png";
|
|
24
|
-
|
|
25
|
-
// 配对状态轮询间隔(秒)。服务端建议值在 create 响应里返回,这是兜底。
|
|
26
|
-
export const PAIR_STATUS_POLL_INTERVAL_SEC = 2;
|
|
27
|
-
|
|
28
|
-
// 配对状态最长等待时间(秒)。超过后 cli 重新生成 QR(配对码已过期)。
|
|
29
|
-
export const PAIR_STATUS_POLL_MAX_SEC = 300;
|
|
30
|
-
|
|
31
|
-
// 长轮询拉取请求的等待时长(秒)。
|
|
32
|
-
export const LONG_POLL_WAIT_SEC = 30;
|
|
33
|
-
|
|
34
|
-
// PicoClaw WS 重连退避(毫秒)— 指数退避,上限 30s。
|
|
35
|
-
export const WS_RECONNECT_INITIAL_MS = 1000;
|
|
36
|
-
export const WS_RECONNECT_MAX_MS = 30000;
|
|
37
|
-
|
|
38
|
-
// runtime 名 — 必须与服务端 RuntimeKindPicoclaw 字符串一致。
|
|
39
|
-
export const RUNTIME_NAME = "picoclaw";
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* handleRequest.mjs — D-C2
|
|
3
|
-
*
|
|
4
|
-
* Extracted from picoclaw/index.mjs so it can be unit-tested independently.
|
|
5
|
-
*
|
|
6
|
-
* createPicoHandleRequest({ wsHolder, log, dispatchHandlerOptions })
|
|
7
|
-
* → async (request, ctx) => void
|
|
8
|
-
*
|
|
9
|
-
* Routing:
|
|
10
|
-
* 1. If request.__kind == "agent.run.dispatch" → unifiedDispatchHandler (returns true) → done
|
|
11
|
-
* 2. Otherwise → original picoclaw ws path (extractUserText + wsHolder.send)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { createUnifiedDispatchHandler } from "../../core/unifiedDispatchHandler.mjs";
|
|
15
|
-
import { buildMessageSend } from "./wsClient.mjs";
|
|
16
|
-
import { createTurnState, translatePicoMessage, forceFinalize } from "./translator.mjs";
|
|
17
|
-
import { relayObjectToBlock } from "../_shared/relayObjectToBlock.mjs";
|
|
18
|
-
import { openclawChatAbort } from "../../core/openclawGatewayClient.mjs";
|
|
19
|
-
|
|
20
|
-
// ── multimodal helpers (exported for unit tests) ──────────────────────────────
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* 从 blocks 中筛出 image blocks 和 PDF attachment blocks。
|
|
24
|
-
*
|
|
25
|
-
* Phase 5 (multimodal-attachment-llm-dispatch): この関数は後方互換のために残す。
|
|
26
|
-
* WS dispatch path では relayObjectToBlock を使う(下記 createPicoHandleRequest 内参照)。
|
|
27
|
-
*
|
|
28
|
-
* @param {any[]} blocks
|
|
29
|
-
* @returns {{ imageBlocks: any[], pdfBlocks: any[] }}
|
|
30
|
-
*/
|
|
31
|
-
export function extractPicoBlocks(blocks) {
|
|
32
|
-
if (!Array.isArray(blocks)) return { imageBlocks: [], pdfBlocks: [] };
|
|
33
|
-
const imageBlocks = blocks.filter(
|
|
34
|
-
(b) => b?.type === "image" && b?.source?.kind === "url" && b?.source?.url
|
|
35
|
-
);
|
|
36
|
-
const pdfBlocks = blocks.filter(
|
|
37
|
-
(b) =>
|
|
38
|
-
b?.type === "attachment" &&
|
|
39
|
-
b?.mime === "application/pdf" &&
|
|
40
|
-
b?.source?.kind === "url" &&
|
|
41
|
-
b?.source?.url
|
|
42
|
-
);
|
|
43
|
-
return { imageBlocks, pdfBlocks };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 构造 media.create 帧(用于发送图片到 picoclaw daemon)。
|
|
48
|
-
*
|
|
49
|
-
* @param {string} sessionId
|
|
50
|
-
* @param {{ source: { url: string, media_type?: string } }} imageBlock
|
|
51
|
-
*/
|
|
52
|
-
export function buildMediaCreate(sessionId, imageBlock) {
|
|
53
|
-
return {
|
|
54
|
-
type: "media.create",
|
|
55
|
-
id: randomPicoID(),
|
|
56
|
-
session_id: sessionId,
|
|
57
|
-
timestamp: Date.now(),
|
|
58
|
-
payload: {
|
|
59
|
-
url: imageBlock.source.url,
|
|
60
|
-
media_type: imageBlock.source?.media_type || "image/jpeg",
|
|
61
|
-
kind: "image",
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 构造 message.send 的 content 文本:原始 input + PDF inline 行。
|
|
68
|
-
*
|
|
69
|
-
* @param {string} input 用户输入文本
|
|
70
|
-
* @param {any[]} pdfBlocks PDF attachment blocks
|
|
71
|
-
* @returns {string}
|
|
72
|
-
*/
|
|
73
|
-
export function buildPicoMessageContent(input, pdfBlocks) {
|
|
74
|
-
if (!pdfBlocks || pdfBlocks.length === 0) return input;
|
|
75
|
-
const pdfLines = pdfBlocks
|
|
76
|
-
.map((b) => `[file: ${b.name}] ${b.source.url}`)
|
|
77
|
-
.join("\n");
|
|
78
|
-
if (!input) return pdfLines;
|
|
79
|
-
return `${input}\n${pdfLines}`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function randomPicoID() {
|
|
83
|
-
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 延迟读取以允许测试环境通过 process.env 覆盖
|
|
87
|
-
function getTurnTimeoutMs() {
|
|
88
|
-
return Number(process.env.PICOCLAW_TURN_TIMEOUT_MS || 120000);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* @param {{
|
|
93
|
-
* wsHolder: { isOpen(): boolean, connectOnce(): Promise<void>, send(msg): void, onMessage(fn): () => void },
|
|
94
|
-
* log: { info, warn, error },
|
|
95
|
-
* activeTurnRef: { current: any }, // shared mutable ref so ws messages can resolve the turn
|
|
96
|
-
* publishEventFn: (event: any) => Promise<void>,
|
|
97
|
-
* dispatchHandlerOptions: {
|
|
98
|
-
* relayUrl: string,
|
|
99
|
-
* bridgeToken: string,
|
|
100
|
-
* producerFactory?: Function,
|
|
101
|
-
* openclawInvoke?: Function,
|
|
102
|
-
* },
|
|
103
|
-
* }} opts
|
|
104
|
-
*
|
|
105
|
-
* Note: activeTurnRef and publishEventFn are only required when wsHolder is actually used
|
|
106
|
-
* (i.e. non-dispatch requests). Tests for dispatch path can omit them.
|
|
107
|
-
*/
|
|
108
|
-
export function createPicoHandleRequest({
|
|
109
|
-
wsHolder,
|
|
110
|
-
log,
|
|
111
|
-
activeTurnRef,
|
|
112
|
-
dispatchHandlerOptions,
|
|
113
|
-
}) {
|
|
114
|
-
// 允许测试注入 openclawChatAbort 实现(避免真实 WS 依赖)
|
|
115
|
-
const _chatAbortFn = dispatchHandlerOptions?._openclawChatAbortFn ?? openclawChatAbort;
|
|
116
|
-
|
|
117
|
-
const unifiedHandler = createUnifiedDispatchHandler({
|
|
118
|
-
...dispatchHandlerOptions,
|
|
119
|
-
log,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
return async function handleRequest(request, ctx) {
|
|
123
|
-
// Step 0: RPC 分支 — sessions.abort / chat.abort(openclaw gateway WS 直连)
|
|
124
|
-
if (request?.request?.__relayKind === "rpc") {
|
|
125
|
-
const method = request.request.method;
|
|
126
|
-
const params = request.request.params ?? {};
|
|
127
|
-
let result;
|
|
128
|
-
try {
|
|
129
|
-
if (method === "sessions.abort" || method === "chat.abort") {
|
|
130
|
-
const sessionKey = params.key ?? params.sessionKey ?? "";
|
|
131
|
-
result = await _chatAbortFn({ sessionKey, log });
|
|
132
|
-
} else {
|
|
133
|
-
// 其余 RPC 方法(workspaces.scan 等)picoclaw 不处理,返回 unknown
|
|
134
|
-
result = { ok: false, error: `unknown rpc method: ${method}` };
|
|
135
|
-
}
|
|
136
|
-
} catch (err) {
|
|
137
|
-
result = { ok: false, error: err?.message ?? String(err) };
|
|
138
|
-
}
|
|
139
|
-
const bodyJson = JSON.stringify(result);
|
|
140
|
-
await ctx.publishEvent({
|
|
141
|
-
event: "response",
|
|
142
|
-
statusCode: 200,
|
|
143
|
-
contentType: "application/json; charset=utf-8",
|
|
144
|
-
body: bodyJson,
|
|
145
|
-
});
|
|
146
|
-
await ctx.publishEvent({ event: "end" });
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Step 1: Try unified dispatch handler first.
|
|
151
|
-
const handled = await unifiedHandler(request, ctx);
|
|
152
|
-
if (handled) return;
|
|
153
|
-
|
|
154
|
-
// Step 2: Original picoclaw ws path.
|
|
155
|
-
// Phase 5 (multimodal-attachment-llm-dispatch): relay_object blocks を
|
|
156
|
-
// relayObjectToBlock を通して処理する(runtimeHint="picoclaw", modelCaps=null)。
|
|
157
|
-
// image → url_ref → buildMediaCreate(URL をそのまま picoclaw daemon に送る)
|
|
158
|
-
// document/text/binary → input_text → メッセージに text として追加
|
|
159
|
-
const userText = extractUserText(request);
|
|
160
|
-
const blocks = request?.request?.blocks ?? [];
|
|
161
|
-
|
|
162
|
-
// relay_object blocks の処理(attachment_kind ベース)
|
|
163
|
-
const urlRefBlocks = []; // image → url_ref blocks
|
|
164
|
-
const textInlineLines = []; // non-image → input_text の text
|
|
165
|
-
|
|
166
|
-
for (const block of blocks) {
|
|
167
|
-
if (!block?.source?.kind || block.source.kind !== "url" || !block.source.url) continue;
|
|
168
|
-
// image か attachment のみ relay_object
|
|
169
|
-
if (block.type !== "image" && block.type !== "attachment") continue;
|
|
170
|
-
|
|
171
|
-
const result = await relayObjectToBlock(block, {
|
|
172
|
-
runtimeHint: "picoclaw",
|
|
173
|
-
modelCaps: null, // picoclaw: caps check をスキップ
|
|
174
|
-
fetchAndBase64Encode: async () => { throw new Error("picoclaw does not fetch bytes"); },
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
if (result.error) {
|
|
178
|
-
// エラーは text inline に追加
|
|
179
|
-
textInlineLines.push(`[attachment] (error: ${result.error.message})`);
|
|
180
|
-
} else if (result.type === "url_ref") {
|
|
181
|
-
// image → url_ref → buildMediaCreate 用に保存
|
|
182
|
-
urlRefBlocks.push({ url: result.url, media_type: result.media_type });
|
|
183
|
-
} else if (result.type === "input_text") {
|
|
184
|
-
// document / text / binary → inline text
|
|
185
|
-
textInlineLines.push(result.text);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const hasMedia = urlRefBlocks.length > 0;
|
|
190
|
-
// buildPicoMessageContent 互換:textInlineLines を userText に追加
|
|
191
|
-
let messageContent = userText;
|
|
192
|
-
if (textInlineLines.length > 0) {
|
|
193
|
-
messageContent = userText
|
|
194
|
-
? `${userText}\n${textInlineLines.join("\n")}`
|
|
195
|
-
: textInlineLines.join("\n");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// 纯图片 + 无 PDF + 无文字:只发 media.create,不发 message.send
|
|
199
|
-
const shouldSendMessage = messageContent.trim().length > 0;
|
|
200
|
-
const shouldSendAnyMedia = hasMedia;
|
|
201
|
-
|
|
202
|
-
if (!shouldSendMessage && !shouldSendAnyMedia) {
|
|
203
|
-
log.warn("turn.skip", "no user text or blocks", { requestId: ctx.requestId });
|
|
204
|
-
await ctx.publishEvent({ event: "error", error: "empty user input" });
|
|
205
|
-
await ctx.publishEvent({ event: "end" });
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (!wsHolder.isOpen()) {
|
|
210
|
-
try { await wsHolder.connectOnce(); }
|
|
211
|
-
catch (err) {
|
|
212
|
-
await ctx.publishEvent({ event: "error", error: `picoclaw connect failed: ${err?.message || err}` });
|
|
213
|
-
await ctx.publishEvent({ event: "end" });
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const translatorState = createTurnState();
|
|
219
|
-
let resolveTurn;
|
|
220
|
-
const turnDone = new Promise((r) => { resolveTurn = r; });
|
|
221
|
-
|
|
222
|
-
if (activeTurnRef) {
|
|
223
|
-
activeTurnRef.current = { ctx, translatorState, resolve: resolveTurn };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const sessionId = `bubu-${ctx.requestId}`;
|
|
227
|
-
try {
|
|
228
|
-
// 先发所有 media.create(图片 url_ref),再发 message.send(如有文本或非 image)
|
|
229
|
-
for (const urlRef of urlRefBlocks) {
|
|
230
|
-
// url_ref → buildMediaCreate 互換オブジェクト
|
|
231
|
-
wsHolder.send(buildMediaCreate(sessionId, {
|
|
232
|
-
source: { url: urlRef.url, media_type: urlRef.media_type },
|
|
233
|
-
}));
|
|
234
|
-
}
|
|
235
|
-
if (shouldSendMessage) {
|
|
236
|
-
wsHolder.send(buildMessageSend({
|
|
237
|
-
sessionId,
|
|
238
|
-
content: messageContent,
|
|
239
|
-
requestId: ctx.requestId,
|
|
240
|
-
}));
|
|
241
|
-
}
|
|
242
|
-
} catch (err) {
|
|
243
|
-
if (activeTurnRef) activeTurnRef.current = null;
|
|
244
|
-
await ctx.publishEvent({ event: "error", error: `picoclaw send failed: ${err?.message || err}` });
|
|
245
|
-
await ctx.publishEvent({ event: "end" });
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const turnTimeoutMs = getTurnTimeoutMs();
|
|
250
|
-
const timeoutP = new Promise((r) => setTimeout(() => r("__timeout__"), turnTimeoutMs));
|
|
251
|
-
const winner = await Promise.race([turnDone.then(() => "__done__"), timeoutP]);
|
|
252
|
-
if (winner === "__timeout__") {
|
|
253
|
-
log.warn("picoclaw.turn.timeout", "turn timed out", { requestId: ctx.requestId, ms: turnTimeoutMs });
|
|
254
|
-
const events = forceFinalize(translatorState);
|
|
255
|
-
for (const e of events) {
|
|
256
|
-
if (e) await ctx.publishEvent(e);
|
|
257
|
-
}
|
|
258
|
-
if (activeTurnRef) activeTurnRef.current = null;
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
264
|
-
|
|
265
|
-
function extractUserText(request) {
|
|
266
|
-
const input = request?.request?.input;
|
|
267
|
-
if (typeof input === "string") return input.trim();
|
|
268
|
-
if (Array.isArray(input)) {
|
|
269
|
-
const parts = [];
|
|
270
|
-
for (const item of input) {
|
|
271
|
-
if (typeof item === "string") parts.push(item);
|
|
272
|
-
else if (item && typeof item === "object") {
|
|
273
|
-
if (typeof item.text === "string") parts.push(item.text);
|
|
274
|
-
else if (Array.isArray(item.content)) {
|
|
275
|
-
for (const c of item.content) {
|
|
276
|
-
if (c && typeof c === "object" && typeof c.text === "string") parts.push(c.text);
|
|
277
|
-
}
|
|
278
|
-
} else if (typeof item.content === "string") parts.push(item.content);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return parts.join("\n").trim();
|
|
282
|
-
}
|
|
283
|
-
if (input && typeof input === "object") {
|
|
284
|
-
if (typeof input.text === "string") return input.text.trim();
|
|
285
|
-
if (typeof input.content === "string") return input.content.trim();
|
|
286
|
-
}
|
|
287
|
-
if (typeof request?.clientPreviewText === "string") return request.clientPreviewText.trim();
|
|
288
|
-
return "";
|
|
289
|
-
}
|