@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.
Files changed (72) hide show
  1. package/README.md +42 -0
  2. package/bin/agentlink +1002 -245
  3. package/bin/agentlink-agent +0 -0
  4. package/bin/agentlink-mcp-stdio +0 -0
  5. package/package.json +5 -3
  6. package/src/core.mjs +26 -0
  7. package/src/tunnel_service.mjs +17 -1
  8. package/src-ext/bin.mjs +12 -12
  9. package/src-ext/commands/agent.mjs +33 -10
  10. package/src-ext/commands/pair.mjs +122 -23
  11. package/src-ext/commands/service.mjs +1 -1
  12. package/src-ext/core/activeRuns.mjs +26 -9
  13. package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
  14. package/src-ext/core/autoTunnelDetector.mjs +6 -1
  15. package/src-ext/core/autoTunnelWiring.mjs +1 -1
  16. package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
  17. package/src-ext/core/defaultWorkspace.mjs +43 -13
  18. package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
  19. package/src-ext/core/installationIdentity.mjs +94 -0
  20. package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
  21. package/src-ext/core/pairCodeClient.mjs +48 -8
  22. package/src-ext/core/pairInventory.mjs +31 -6
  23. package/src-ext/core/relayWorker.mjs +22 -2
  24. package/src-ext/core/runtimeRegistry.mjs +180 -0
  25. package/src-ext/core/scanDeeplink.mjs +67 -0
  26. package/src-ext/core/scanPairFlow.mjs +28 -0
  27. package/src-ext/core/scanQrRenderer.mjs +40 -0
  28. package/src-ext/core/scanWorkspaces.mjs +163 -23
  29. package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
  30. package/src-ext/core/usageReporter.mjs +1 -2
  31. package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
  32. package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
  33. package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
  34. package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
  35. package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
  36. package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
  37. package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
  38. package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
  39. package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
  40. package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
  41. package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
  42. package/src-ext/runtime/claude/handleRequest.mjs +15 -37
  43. package/src-ext/runtime/claude/launcher.mjs +0 -6
  44. package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
  45. package/src-ext/runtime/codebuddy/index.mjs +41 -0
  46. package/src-ext/runtime/codex/handleRequest.mjs +13 -35
  47. package/src-ext/runtime/cursor/index.mjs +46 -0
  48. package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
  49. package/src-ext/runtime/deepagents/preflight.mjs +57 -0
  50. package/src-ext/runtime/hermes/envSetup.mjs +22 -8
  51. package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
  52. package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
  53. package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
  54. package/src-ext/runtime/hermes/index.mjs +1 -1
  55. package/src-ext/runtime/hermes/preflight.mjs +2 -1
  56. package/src-ext/runtime/kimi/index.mjs +100 -0
  57. package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
  58. package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
  59. package/src-ext/runtime/opencode/index.mjs +48 -0
  60. package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
  61. package/src-ext/runtime/opencode/preflight.mjs +72 -0
  62. package/src-ext/runtime/qwen/index.mjs +42 -0
  63. package/src-ext/service/serviceManager.mjs +120 -42
  64. package/src-shared/envelope_builder.mjs +2 -2
  65. package/src-ext/runtime/picoclaw/constants.mjs +0 -39
  66. package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
  67. package/src-ext/runtime/picoclaw/index.mjs +0 -314
  68. package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
  69. package/src-ext/runtime/picoclaw/state.mjs +0 -78
  70. package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
  71. package/src-ext/runtime/picoclaw/translator.mjs +0 -272
  72. 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(["claude", "codex"]);
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 —— 现代 unload,先拆掉任何同 label 的旧实例(幂等,未加载也无妨)
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(os.homedir(), ".agentlink", "logs");
263
+ const logDir = path.join(resolveAgentlinkHome(), "logs");
160
264
  fs.mkdirSync(logDir, { recursive: true });
161
265
 
162
- // align-runtime-default-workspace: claude/codex 注入 WorkingDirectory
266
+ // 所有 bridge runtime 都使用自己的默认工作区,避免服务从不确定目录启动。
163
267
  // install 前确保目录存在(否则 launchd 因 WorkingDirectory 不存在拒绝启动)
164
268
  let workingDirectory = null;
165
- if (runtime === "claude" || runtime === "codex") {
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
- let bootstrapOk = false;
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(os.homedir(), ".agentlink", "logs");
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 === "claude" || runtime === "codex") {
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 = ALL_RELOAD_TARGETS[runtime].mac;
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 = ALL_RELOAD_TARGETS[runtime].linux;
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 = ALL_RELOAD_TARGETS[runtime].win;
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
- // picoclaw legacy event name → maps to tool_use.start + arguments.delta.
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 (e.g. picoclaw), prefer it.
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
- }