@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
package/bin/agentlink CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  formatHostForUrl,
31
31
  generatePairCode,
32
32
  generateRelayGatewayId,
33
+ generateScanPairCode,
33
34
  isTransientNetworkError,
34
35
  normalizeBaseUrl,
35
36
  normalizeChatTransportMode,
@@ -61,15 +62,30 @@ import { createLogger as _createBubLogger } from "../src-shared/logger.mjs";
61
62
  import { scanWorkspacesByRuntime } from "../src-ext/core/scanWorkspaces.mjs";
62
63
  import { createWorkspace } from "../src-ext/core/createWorkspace.mjs";
63
64
  import { normalizeHostName } from "../src-ext/core/pairCodeClient.mjs";
65
+ import { getOrCreateInstallationId } from "../src-ext/core/installationIdentity.mjs";
64
66
  import { buildPairSessionResults } from "../src-ext/core/pairInventory.mjs";
65
-
66
- // Minimum Node.js 22+ — required for the built-in WebSocket client and the
67
- // runtime surface we rely on for bridge/service behavior across platforms.
67
+ // runtime 能力分层的**单一真相**(bin 与 src-ext 共用,防两边各持一份名单漂移)。
68
+ // 语义分层的完整说明见该模块头注释 —— 改分层前先读它。
69
+ import {
70
+ PAIRABLE_RUNTIME_KINDS,
71
+ BRIDGE_CAPABLE_RUNTIME_KINDS,
72
+ PAIR_ONLY_RUNTIME_KINDS,
73
+ EXT_AGENT_RUNTIME_KINDS,
74
+ RUNTIME_CAPABILITIES,
75
+ RUNTIME_BINARY_CANDIDATES,
76
+ pairingCapabilitiesForRuntime,
77
+ } from "../src-ext/core/runtimeRegistry.mjs";
78
+ import { SCAN_QR_PNG_OUTPUT_PATH, clearQRImage } from "../src-ext/core/scanPairFlow.mjs";
79
+ import { renderQrToTerminal } from "../src-ext/core/scanQrRenderer.mjs";
80
+ import { buildScanDeeplink, renderScanQrPng } from "../src-ext/core/scanDeeplink.mjs";
81
+
82
+ // Keep this aligned with Desktop's embedded CLI compatibility floor.
83
+ const MIN_SUPPORTED_NODE_MAJOR = 18;
68
84
  {
69
85
  const major = Number.parseInt((process.versions?.node ?? "0").split(".")[0], 10);
70
- if (major < 22) {
86
+ if (major < MIN_SUPPORTED_NODE_MAJOR) {
71
87
  process.stderr.write(
72
- `Error: agentlink requires Node.js >= 22, current: ${process.version}\nPlease upgrade: https://nodejs.org/\n`,
88
+ `Error: agentlink requires Node.js >= ${MIN_SUPPORTED_NODE_MAJOR}, current: ${process.version}\nPlease upgrade: https://nodejs.org/\n`,
73
89
  );
74
90
  process.exit(1);
75
91
  }
@@ -100,20 +116,139 @@ const CLI_COMMAND_NAME = "agentlink";
100
116
  const SERVICE_LABEL = "com.agentlink.bridge";
101
117
  const SERVICE_UNIT_NAME = "agentlink-bridge.service";
102
118
  const SERVICE_TASK_NAME = "Agentlink Bridge";
119
+ // 非默认状态根(例如 Desktop Debug 的 ~/.agentlink-debug)必须有独立服务名,
120
+ // 否则会覆盖正式 ~/.agentlink 的 launchd/systemd/schtasks 服务。
121
+ const SERVICE_SCOPE_HASH_LENGTH = 12;
103
122
  const RUNTIME_KIND_OPENCLAW = "openclaw";
104
123
  const RUNTIME_KIND_HERMES = "hermes";
124
+ const RUNTIME_KIND_CLAUDE = "claude";
125
+ const RUNTIME_KIND_CODEX = "codex";
126
+ const RUNTIME_KIND_CURSOR = "cursor";
127
+ // add-deepagents-opencode-runtime-enum: 已知 runtime 枚举扩到 6 个。
128
+ // opencode 自 devagent-m-20260807-cli-runtime-opencode-pairable 起**有了配对实现**
129
+ // (PATH 探测 + 握手 + 凭证落地),但仍**没有** bridge / mcpConfigAdapter;
130
+ // deepagents 至今只入枚举。两者的差别由下面的分层集合承载,不靠人记。
131
+ const RUNTIME_KIND_OPENCODE = "opencode";
132
+ const RUNTIME_KIND_DEEPAGENTS = "deepagents";
133
+ const RUNTIME_KIND_QWEN = "qwen";
134
+ const RUNTIME_KIND_KIMI = "kimi";
135
+ const RUNTIME_KIND_CODEBUDDY = "codebuddy";
136
+
137
+ // ─── runtime 种类登记表(cli-runtime-kind-registry) ─────────────────────────
138
+ // 本仓有 **多种语义不同** 的 runtime 集合。早期它们恰好都等于同一批 4 个字符串,
139
+ // 于是被当成"4 种 runtime"散落成字面量;新增 opencode/deepagents 打破了这个巧合,
140
+ // 所以这里把每一层显式列出来 —— 以后加 runtime 只需在对应集合里加一行,
141
+ // 「这一层要不要带上它」被逼成一次显式选择,不再靠人记得满仓 grep。
142
+ //
143
+ // ① 能力分层(PAIRABLE / BRIDGE_CAPABLE / PAIR_ONLY / EXT_AGENT)已上移到
144
+ // src-ext/core/runtimeRegistry.mjs(见文件顶部 import)—— 因为 agentlink-agent
145
+ // 那侧(src-ext/**)也要用同一套判定,各持一份必然漂移。
146
+ // · PAIRABLE = 有 pair/reset 实现(openclaw/hermes/claude/codex/opencode)
147
+ // · BRIDGE_CAPABLE= 有 bridge/service/MCP 实现(前四个,**不含 opencode**)
148
+ // · PAIR_ONLY = 配得上但跑不起来(= opencode),命令层必须显式拦停
149
+ // · EXT_AGENT = 凭证存 ext-<rt>-last.json(claude/codex/opencode)
150
+ // ② 走 `which <bin>`(win `where`)两阶段 PATH 探测的 runtime。
151
+ // openclaw 不在此列 —— 它以 openclaw.json 是否存在作为"已安装"信号。
152
+ const PATH_PROBED_RUNTIME_KINDS = Object.freeze([
153
+ RUNTIME_KIND_HERMES, RUNTIME_KIND_CLAUDE, RUNTIME_KIND_CODEX,
154
+ RUNTIME_KIND_CURSOR, RUNTIME_KIND_OPENCODE, RUNTIME_KIND_DEEPAGENTS,
155
+ RUNTIME_KIND_QWEN, RUNTIME_KIND_KIMI, RUNTIME_KIND_CODEBUDDY,
156
+ ]);
157
+ // ③ 本机探测集 = detectInstalledRuntimes() 的返回顺序,也是 host-inventory
158
+ // 上报(Phase 1 runtimes / Phase 2 results)的口径。
159
+ // DeepAgents 以 `agentlink-deepagents` 独立 sidecar 命令作为 PATH 探测信号。
160
+ const HOST_DETECTED_RUNTIME_KINDS = Object.freeze([
161
+ RUNTIME_KIND_OPENCLAW, ...PATH_PROBED_RUNTIME_KINDS,
162
+ ]);
163
+ // ④ 已知 runtime 枚举 = `--runtime` 参数解析层承认的全集(不含 `all` 别名)。
164
+ const KNOWN_RUNTIME_KINDS = Object.freeze([
165
+ ...HOST_DETECTED_RUNTIME_KINDS,
166
+ ]);
167
+ // ⑤ 已知但连配对都没有的 runtime。必须显式拦停:
168
+ // runPair/runReset/runBridge 都以 normalizeRuntimeKind(runtime, "openclaw")
169
+ // 开头,而它只认 openclaw/hermes、其余一律回落 fallback —— 不拦停就会把
170
+ // 非 openclaw 的配对静默写进 openclaw 的凭证槽位(relayGatewayId/Token),
171
+ // 用户还以为配的是自己选的那个 runtime。
172
+ const ENUM_ONLY_RUNTIME_KINDS = Object.freeze(
173
+ KNOWN_RUNTIME_KINDS.filter((k) => !PAIRABLE_RUNTIME_KINDS.includes(k)),
174
+ );
175
+ // scan-capable runtimes (unify-scan-pairing): openclaw/hermes read their gateway
176
+ // credentials locally (~/.agentlink/config.json via ensureRelayBridgeCredentials)
177
+ // and can host-claim immediately. claude/codex credentials are issued by the
178
+ // server at consume-time — no host-claim path exists for them this cycle (see
179
+ // spec "术语" + Non-goals; S2b covers the explicit unsupported-runtime notice).
180
+ // 🔴 opencode **不得**加进来:它没有本机 host-claim 凭证路径,走 pair 路径。
181
+ const SCAN_CAPABLE_RUNTIME_KINDS = Object.freeze([RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES]);
182
+
183
+ /**
184
+ * enum-only runtime(已知枚举 ∖ 可配对集,当前 = deepagents)撞上任何实现路径时
185
+ * 显式失败退出,绝不静默回落成 openclaw。
186
+ *
187
+ * 必须在**所有命令分派之前**调用:runPair / runReset / runBridge / runService /
188
+ * runScan 都以 normalizeRuntimeKind(runtime, RUNTIME_KIND_OPENCLAW) 起手,而
189
+ * src/core.mjs 的 normalizeRuntimeKind **只认 openclaw/hermes**、其余一律回落
190
+ * fallback。不拦停就会把 `-r <未实现 runtime>` 静默当成 openclaw 执行(配对结果
191
+ * 写进 openclaw 的 relayGatewayId/Token 槽位),用户看到的却是「配对成功」
192
+ * = 静默错配。
193
+ *
194
+ * ⚠️ 这与「取值非法」是两类失败:取值域错误由 parseArgs 给 `must be one of ...`,
195
+ * 这里给的是「取值合法但本轮没有实现」。两条文案不得混用(测试有断言)。
196
+ *
197
+ * @param {string|undefined} runtime options.runtime 原值(未 normalize)
198
+ */
199
+ function assertRuntimeOperableOrFail(runtime) {
200
+ if (runtime && ENUM_ONLY_RUNTIME_KINDS.includes(runtime)) {
201
+ fail(t("runtime_known_but_unsupported", {
202
+ runtime,
203
+ cli: CLI_COMMAND_NAME,
204
+ pairable: PAIRABLE_RUNTIME_KINDS.join(" / "),
205
+ }));
206
+ }
207
+ }
208
+
209
+ /**
210
+ * PAIR_ONLY runtime(可配对、但本轮没有 bridge/service 实现,当前 = opencode)
211
+ * 撞上「需要真的把进程跑起来 / 展示运行态」的命令时显式失败退出。
212
+ *
213
+ * 🔴 这是 assertRuntimeOperableOrFail 让出的那半边防护,不是可选的收尾:
214
+ * opencode 进了可配对集之后就不再被上面那道拦停命中,而 runBridge / runService /
215
+ * runReload / runScan / runStatus 依然全都以 normalizeRuntimeKind(x, "openclaw")
216
+ * 起手 —— 少了这道闸,`agentlink status -r opencode` 会把 **openclaw 的**
217
+ * 凭证与服务态当成 opencode 的展示给用户(同一个静默错配家族的另一种形态)。
218
+ *
219
+ * pair / reset 刻意**不**调这个:它们对 opencode 是真实现(转发 agentlink-agent)。
220
+ *
221
+ * @param {string|undefined} runtime options.runtime 原值(未 normalize)
222
+ * @param {string} command 触发的命令名,仅用于文案
223
+ */
224
+ function assertRuntimeBridgeSupportedOrFail(runtime, command) {
225
+ if (runtime && PAIR_ONLY_RUNTIME_KINDS.includes(runtime)) {
226
+ fail(t("runtime_paired_but_no_bridge", {
227
+ runtime,
228
+ command,
229
+ cli: CLI_COMMAND_NAME,
230
+ bridgeable: BRIDGE_CAPABLE_RUNTIME_KINDS.join(" / "),
231
+ }));
232
+ }
233
+ }
105
234
 
106
235
  function serviceNames(runtimeKind) {
107
236
  const kind = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
237
+ const scope = serviceScopeSuffix();
108
238
  if (kind === RUNTIME_KIND_OPENCLAW) {
109
- return { label: SERVICE_LABEL, unit: SERVICE_UNIT_NAME, task: SERVICE_TASK_NAME, logPrefix: "bridge" };
239
+ return {
240
+ label: `${SERVICE_LABEL}${scope}`,
241
+ unit: scope ? `agentlink-bridge${scope}.service` : SERVICE_UNIT_NAME,
242
+ task: `${SERVICE_TASK_NAME}${scope}`,
243
+ logPrefix: `bridge${scope}`,
244
+ };
110
245
  }
111
246
  const capitalized = kind.charAt(0).toUpperCase() + kind.slice(1);
112
247
  return {
113
- label: `${SERVICE_LABEL}-${kind}`,
114
- unit: `agentlink-bridge-${kind}.service`,
115
- task: `${SERVICE_TASK_NAME} ${capitalized}`,
116
- logPrefix: `bridge-${kind}`,
248
+ label: `${SERVICE_LABEL}-${kind}${scope}`,
249
+ unit: `agentlink-bridge-${kind}${scope}.service`,
250
+ task: `${SERVICE_TASK_NAME} ${capitalized}${scope}`,
251
+ logPrefix: `bridge-${kind}${scope}`,
117
252
  };
118
253
  }
119
254
  // Mirror the WS bridge's operator scopes for local HTTP proxy calls as well.
@@ -357,7 +492,21 @@ const CLI_I18N = {
357
492
  pair_all_detecting_runtimes: "\n检测本机已安装的 AI runtime ...",
358
493
  pair_all_runtime_installed_extra: "({detail})",
359
494
  pair_all_runtime_not_installed: " · {kind} 未安装,跳过{hint}",
360
- pair_all_no_runtimes: "本机未检测到任何可用 runtime(openclaw / hermes / claude / codex)。",
495
+ // {kinds} HOST_DETECTED_RUNTIME_KINDS 渲染 —— 文案本身不再持有 runtime 名,
496
+ // 加 runtime 时中英两侧自动跟随,从结构上消灭「两份枚举串漂移」。
497
+ pair_all_no_runtimes: "本机未检测到任何可用 runtime({kinds})。",
498
+ // 已知 runtime 枚举里、但本轮还没有 pair/bridge 实现的 runtime。
499
+ runtime_known_but_unsupported:
500
+ "runtime `{runtime}` 已被识别,但本版 {cli} 还没有它的配对/桥接实现,暂不支持该命令。\n"
501
+ + "当前可操作的 runtime:{pairable}。",
502
+ // 可配对、但本轮没有 bridge/service 实现的 runtime(PAIR_ONLY)。
503
+ // 与上一条是**两类**失败:这条的配对是通的,只是跑不起来。
504
+ runtime_paired_but_no_bridge:
505
+ "runtime `{runtime}` 本版 {cli} 只支持配对(pair / reset),还没有 bridge 实现,`{command}` 暂不可用。\n"
506
+ + "支持 bridge/service 的 runtime:{bridgeable}。",
507
+ pair_all_runtime_pair_unsupported: " · {kind} 已安装但本版暂无配对实现,跳过配对(已如实上报本机清单)",
508
+ pair_all_summary_pair_unsupported: "ℹ️ 已装但暂无配对实现 ({count}): {kinds}",
509
+ pair_all_summary_nothing_pairable: "\n本机没有可配对的 runtime;已把探测结果如实上报给 App。",
361
510
  pair_all_host_inventory_report_failed: " ⚠️ host-inventory 上报失败 (HTTP {status}),继续配对 ...",
362
511
  pair_all_host_inventory_report_error: " ⚠️ host-inventory 上报异常 ({error}),继续配对 ...",
363
512
  pair_all_host_inventory_prepare_failed: " ⚠️ host-inventory 准备失败 ({error})",
@@ -385,6 +534,8 @@ const CLI_I18N = {
385
534
  reset_all_detecting_runtimes: "\n检测本机已安装的 AI runtime(逐个解绑)...",
386
535
  reset_all_no_runtimes: "本机未检测到任何已安装 runtime,也无可解绑的本地残留。",
387
536
  reset_all_runtime_state_only: " ⚠ {kind} 未安装,但检测到本地绑定残留,将清理",
537
+ // 已装、但本轮没有配对实现 → 不可能存在可解绑的绑定,不进解绑目标集。
538
+ reset_all_runtime_no_binding: " · {kind} 已安装但本版暂无配对实现,无可解绑绑定,跳过",
388
539
  reset_all_resetting_runtime: "\n→ 正在解绑 {kind} ...",
389
540
  reset_all_runtime_reset_failed: " ⚠️ {kind} 解绑失败 (退出码 {status}),继续下一个 ...",
390
541
  reset_all_summary_header: "\n──────────────── 解绑结果 ────────────────",
@@ -602,7 +753,21 @@ const CLI_I18N = {
602
753
  pair_all_detecting_runtimes: "\nDetecting installed AI runtimes on this host ...",
603
754
  pair_all_runtime_installed_extra: " ({detail})",
604
755
  pair_all_runtime_not_installed: " · {kind} not installed, skipping{hint}",
605
- pair_all_no_runtimes: "No usable runtime detected on this host (openclaw / hermes / claude / codex).",
756
+ // {kinds} rendered from HOST_DETECTED_RUNTIME_KINDS see the zh table note:
757
+ // the text no longer holds runtime names, so both locales can never drift.
758
+ pair_all_no_runtimes: "No usable runtime detected on this host ({kinds}).",
759
+ // Pairable but no bridge/service implementation yet (PAIR_ONLY tier).
760
+ // A *different* failure class from runtime_known_but_unsupported below:
761
+ // pairing does work here, the runtime just cannot be run yet.
762
+ runtime_paired_but_no_bridge:
763
+ "runtime `{runtime}` only supports pairing (pair / reset) in this {cli} build; there is no bridge implementation yet, so `{command}` is unavailable.\n"
764
+ + "Runtimes with bridge/service support: {bridgeable}.",
765
+ runtime_known_but_unsupported:
766
+ "runtime `{runtime}` is recognized, but this {cli} build has no pairing/bridge implementation for it yet.\n"
767
+ + "Currently operable runtimes: {pairable}.",
768
+ pair_all_runtime_pair_unsupported: " · {kind} installed but no pairing implementation in this build, skipping pairing (still reported in the host inventory)",
769
+ pair_all_summary_pair_unsupported: "ℹ️ Installed, pairing not implemented yet ({count}): {kinds}",
770
+ pair_all_summary_nothing_pairable: "\nNo pairable runtime on this host; the detection result was reported to the App as-is.",
606
771
  pair_all_host_inventory_report_failed: " ⚠️ host-inventory report failed (HTTP {status}), continuing pairing ...",
607
772
  pair_all_host_inventory_report_error: " ⚠️ host-inventory report error ({error}), continuing pairing ...",
608
773
  pair_all_host_inventory_prepare_failed: " ⚠️ host-inventory prepare failed ({error})",
@@ -630,6 +795,7 @@ const CLI_I18N = {
630
795
  reset_all_detecting_runtimes: "\nDetecting installed AI runtimes on this host (unbind each) ...",
631
796
  reset_all_no_runtimes: "No installed runtime and no local binding trace on this host; nothing to unbind.",
632
797
  reset_all_runtime_state_only: " ⚠ {kind} not installed, but a local binding trace was found — will clean it up",
798
+ reset_all_runtime_no_binding: " · {kind} installed but pairing is not implemented in this build; no binding to unbind, skipping",
633
799
  reset_all_resetting_runtime: "\n→ Resetting {kind} ...",
634
800
  reset_all_runtime_reset_failed: " ⚠️ {kind} reset failed (exit {status}), continuing ...",
635
801
  reset_all_summary_header: "\n──────────────── Reset Results ────────────────",
@@ -771,11 +937,15 @@ function printHelp() {
771
937
  "",
772
938
  t("help_usage"),
773
939
  ` ${CLI_COMMAND_NAME} setup [--relay <url>] [--public-base <url>] [--mode <relay>] [--account <id>] [--restart|--no-restart] [--strict-plugin] [--json]`,
774
- ` ${CLI_COMMAND_NAME} pair <4-digit|6-digit|8-alnum> [-r|--runtime <openclaw|hermes|claude|codex|all>] [--mode <relay>] [--strict-plugin] # default: all`,
775
- ` ${CLI_COMMAND_NAME} reset [-r|--runtime <openclaw|hermes|claude|codex|all>] [--relay <url>] [--gateway <url>] [--json] # default: all`,
776
- ` ${CLI_COMMAND_NAME} bridge [--relay <url>] [--gateway <url>] [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
940
+ // pair / reset 的取值域 = 可配对集(含 opencode),由登记表渲染,
941
+ // 不再手抄第二份枚举串(加 runtime 时用法行自动跟随)。
942
+ ` ${CLI_COMMAND_NAME} pair <4-digit|6-digit|8-alnum> [-r|--runtime <${PAIRABLE_RUNTIME_KINDS.join("|")}|all>] [--mode <relay>] [--strict-plugin] # default: all`,
943
+ ` ${CLI_COMMAND_NAME} reset [-r|--runtime <${PAIRABLE_RUNTIME_KINDS.join("|")}|all>] [--relay <url>] [--gateway <url>] [--json] # default: all`,
944
+ // bridge / reload 的取值域 = 可桥接集(**不含** opencode:本轮它只能配对)。
945
+ ` ${CLI_COMMAND_NAME} bridge [--relay <url>] [--gateway <url>] [-r|--runtime <${BRIDGE_CAPABLE_RUNTIME_KINDS.join("|")}>] [--json]`,
946
+ ` ${CLI_COMMAND_NAME} scan [-r|--runtime <openclaw|hermes>] [--relay <url>] [--json] [--no-serve] [--qr-path <path>] [--strict-plugin] # 扫码配对:出二维码 → App 扫码 → 本机 scan-capable runtime(openclaw/hermes)收发;claude/codex 不支持,请改用 pair`,
777
947
  ` ${CLI_COMMAND_NAME} service <status|install|stop|uninstall|restart> [--relay <url>] [--gateway <url>] [--runtime <openclaw|hermes>] [--json]`,
778
- ` ${CLI_COMMAND_NAME} reload [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
948
+ ` ${CLI_COMMAND_NAME} reload [-r|--runtime <${BRIDGE_CAPABLE_RUNTIME_KINDS.join("|")}>] [--json]`,
779
949
  ` ${CLI_COMMAND_NAME} pair-url [<4-digit|6-digit|8-alnum>] [--account <id>] [--code <4-digit|6-digit|8-alnum>] [--json]`,
780
950
  ` ${CLI_COMMAND_NAME} tunnel <on|off> <port> [--relay <url>] [--ws|--tcp] [--json]`,
781
951
  ` ${CLI_COMMAND_NAME} tunnel ls [--json]`,
@@ -838,6 +1008,16 @@ function parseArgs(argv) {
838
1008
  doctorAction: undefined,
839
1009
  mcpSubcommand: undefined,
840
1010
  mcpRuntime: undefined,
1011
+ noServe: false,
1012
+ qrPath: undefined,
1013
+ // Debug Desktop passes an isolated state root here. Keep it internal so normal
1014
+ // users do not accidentally split their existing runtime pairing state.
1015
+ agentlinkHome: undefined,
1016
+ // Internal plumbing flag — not advertised in --help. Set by `runScan`'s
1017
+ // per-runtime host-claim child spawn (`--pair-mode scan`), never expected
1018
+ // from a human-typed `agentlink pair` invocation. See runPair()'s
1019
+ // pairMode branch (unify-scan-pairing).
1020
+ pairMode: "",
841
1021
  };
842
1022
 
843
1023
  while (args.length > 0) {
@@ -893,6 +1073,39 @@ function parseArgs(argv) {
893
1073
  options.fix = true;
894
1074
  continue;
895
1075
  }
1076
+ if (token === "--no-serve") {
1077
+ // `agentlink scan` 专属:只配对不起 worker(spec S5)。与已有的
1078
+ // --no-serve-relay(options.serveRelay,pair 命令的"配完对不装后台服务"
1079
+ // 语义)刻意分开——scan 这里是"不启动前台 bridge worker",不是装/不装
1080
+ // service,两者不通用。
1081
+ options.noServe = true;
1082
+ continue;
1083
+ }
1084
+ if (token === "--qr-path") {
1085
+ const value = args.shift();
1086
+ if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--qr-path" }));
1087
+ options.qrPath = value.trim();
1088
+ continue;
1089
+ }
1090
+ if (token === "--pair-mode") {
1091
+ // Internal — see options.pairMode default comment above.
1092
+ const value = args.shift();
1093
+ if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--pair-mode" }));
1094
+ const normalized = value.trim().toLowerCase();
1095
+ if (normalized !== "" && normalized !== "scan") {
1096
+ fail(`--pair-mode must be "scan" (got ${value})`);
1097
+ }
1098
+ options.pairMode = normalized;
1099
+ continue;
1100
+ }
1101
+
1102
+ if (token === "--agentlink-home") {
1103
+ const value = args.shift();
1104
+ if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--agentlink-home" }));
1105
+ if (!path.isAbsolute(value.trim())) fail("--agentlink-home must be an absolute path");
1106
+ options.agentlinkHome = value.trim();
1107
+ continue;
1108
+ }
896
1109
 
897
1110
  if (token === "--relay") {
898
1111
  const value = args.shift();
@@ -948,18 +1161,25 @@ function parseArgs(argv) {
948
1161
  const value = args.shift();
949
1162
  if (!value || value.startsWith("--")) fail(`missing value for ${token}`);
950
1163
  const raw = value.trim().toLowerCase();
951
- // 扩展:claude / codex 走新 agentlink-agent(shim 在 main() 做);
952
- // all pair 命令支持,逐个 runtime 串行配对;
953
- // 其余还走老 normalizeRuntimeKind。
954
- if (raw === "all" || raw === "claude" || raw === "codex") {
1164
+ // 解析层只做「取值在不在已知枚举里」这一件事(KNOWN_RUNTIME_KINDS,见常量区):
1165
+ // · openclaw / hermes → bin native 路径
1166
+ // · claude / codex / opencode → main() 转发 agentlink-agent(shim)。
1167
+ // opencode opencode-pairable 起进了可配对集:pair / reset / uninstall
1168
+ // 是真实现,assertRuntimeOperableOrFail() 对它**不再触发**;
1169
+ // bridge / service / reload / scan / status 另由
1170
+ // assertRuntimeBridgeSupportedOrFail() 显式拦停(两道闸分工见其文档注释)
1171
+ // · deepagents → 本轮只有枚举,真执行由 main() 的
1172
+ // assertRuntimeOperableOrFail() 给显式「暂不支持」错误
1173
+ // · all → 仅 pair / reset 支持,逐 runtime 串行
1174
+ // 这里刻意 **不** 再走 normalizeRuntimeKind —— 它只认 openclaw/hermes、其余
1175
+ // 静默回落 fallback,会把新 runtime 悄悄变成 openclaw(静默错配)。
1176
+ // raw 已 trim + toLowerCase,与 normalizeRuntimeKind 的归一化口径一致。
1177
+ if (raw === "all" || KNOWN_RUNTIME_KINDS.includes(raw)) {
955
1178
  options.runtime = raw;
956
1179
  continue;
957
1180
  }
958
- const normalizedRuntime = normalizeRuntimeKind(raw, "__invalid__");
959
- if (normalizedRuntime !== RUNTIME_KIND_OPENCLAW && normalizedRuntime !== RUNTIME_KIND_HERMES) {
960
- fail(`${token} must be openclaw / hermes / claude / codex / all`);
961
- }
962
- options.runtime = normalizedRuntime;
1181
+ // 取值域文案由常量渲染 —— runtime 时无需再改这行(防两处真相漂移)。
1182
+ fail(`${token} must be one of ${KNOWN_RUNTIME_KINDS.join(" / ")} / all`);
963
1183
  continue;
964
1184
  }
965
1185
  if (token === "--hermes-python") {
@@ -1173,8 +1393,8 @@ function resolveAgentlinkStatePath() {
1173
1393
  // reset 用:某 runtime 是否有「可解绑的本地痕迹」(已配对的凭证 / state 文件)。
1174
1394
  // 与「二进制是否安装」解耦 —— 卸了 CLI 但 binding 还在的 runtime 仍须能被 reset
1175
1395
  // 清掉(否则 server 端绑定 + 本地 state 永久泄漏)。读 reset 实际操作的同一批文件:
1176
- // openclaw / hermes → ~/.agentlink/config.json(relayGatewayId / hermesRelayGatewayId)
1177
- // claude / codex → ~/.agentlink/ext-<rt>-last.json(gw_id)
1396
+ // openclaw / hermes → ~/.agentlink/config.json(relayGatewayId / hermesRelayGatewayId)
1397
+ // claude / codex / opencode → ~/.agentlink/ext-<rt>-last.json(gw_id)
1178
1398
  function runtimeHasLocalState(kind) {
1179
1399
  // 1) 残留的 bridge service 也算「有痕迹」—— 凭证 id 已被上一次部分 reset 清掉、
1180
1400
  // 但 service 还在的孤儿,必须仍纳入 reset 清理,否则后台服务空转、卸不掉。
@@ -1182,9 +1402,19 @@ function runtimeHasLocalState(kind) {
1182
1402
  // linux ~/.config/systemd/user),**不**走 `launchctl print`/`systemctl --user`
1183
1403
  // 那种用户级全局探测 —— 否则 reset 的扇出决策会依赖跨 HOME 的全局服务状态,
1184
1404
  // 破坏隔离、并可能误删别的上下文的服务。(win 用 schtasks 无文件,跳过该信号。)
1405
+ //
1406
+ // 🔴 只对 **native 服务** (openclaw/hermes)做这个探测:resolveMacLaunchAgentPath /
1407
+ // resolveLinuxUserUnitPath 都经 serviceNames() → normalizeRuntimeKind(x,"openclaw"),
1408
+ // 而后者只认 openclaw/hermes —— 对 ext-agent runtime(claude/codex/opencode)会
1409
+ // **别名到 openclaw 自己的 plist/unit**,于是"装了 openclaw 的 bridge 服务"会被
1410
+ // 误判成"claude/opencode 有本地痕迹"。ext-agent 的服务单元命名另在
1411
+ // src-ext/service/serviceManager.mjs(com.agentlink.agent.<rt>),不由这里定位;
1412
+ // 它们的痕迹判定完全走下面的 ext-<rt>-last.json 凭证文件,够用且不会串台。
1185
1413
  try {
1186
- if (process.platform === "darwin" && fs.existsSync(resolveMacLaunchAgentPath(kind))) return true;
1187
- if (process.platform === "linux" && fs.existsSync(resolveLinuxUserUnitPath(kind))) return true;
1414
+ if (!EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
1415
+ if (process.platform === "darwin" && fs.existsSync(resolveMacLaunchAgentPath(kind))) return true;
1416
+ if (process.platform === "linux" && fs.existsSync(resolveLinuxUserUnitPath(kind))) return true;
1417
+ }
1188
1418
  } catch { /* 文件探测失败不影响下面的凭证判定 */ }
1189
1419
  try {
1190
1420
  if (kind === RUNTIME_KIND_OPENCLAW || kind === RUNTIME_KIND_HERMES) {
@@ -1193,7 +1423,7 @@ function runtimeHasLocalState(kind) {
1193
1423
  const idKey = kind === RUNTIME_KIND_OPENCLAW ? "relayGatewayId" : "hermesRelayGatewayId";
1194
1424
  return Boolean(String(cfg[idKey] ?? "").trim());
1195
1425
  }
1196
- if (kind === "claude" || kind === "codex") {
1426
+ if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
1197
1427
  // ext state(ext-<rt>-last.json)由 pairCodeClient.stateDir() 定位,现已统一
1198
1428
  // 优先 AGENTLINK_HOME(见该文件注释),与此处 resolveAgentlinkHome() 一致 ——
1199
1429
  // 探测与实际清理读写同一份文件,无 AGENTLINK_HOME 分叉。
@@ -1206,7 +1436,15 @@ function runtimeHasLocalState(kind) {
1206
1436
  }
1207
1437
 
1208
1438
  function resolveMatecliRuntimeDir() {
1209
- return path.join(os.homedir(), ".agentlink");
1439
+ return resolveAgentlinkHome();
1440
+ }
1441
+
1442
+ function serviceScopeSuffix() {
1443
+ const currentHome = path.resolve(resolveAgentlinkHome());
1444
+ const defaultHome = path.resolve(path.join(os.homedir(), ".agentlink"));
1445
+ if (currentHome === defaultHome) return "";
1446
+ const digest = createHash("sha256").update(currentHome).digest("hex");
1447
+ return `-${digest.slice(0, SERVICE_SCOPE_HASH_LENGTH)}`;
1210
1448
  }
1211
1449
 
1212
1450
  function resolveBridgeLogsDir() {
@@ -2647,6 +2885,34 @@ function ensureRelayBridgeCredentials({
2647
2885
  }
2648
2886
  }
2649
2887
 
2888
+ // 🔴 把 relay 地址一起落盘。缺了它,openclaw / hermes 的常驻服务重启后**静默连到
2889
+ // 生产** —— 这不是理论问题,2026-08-13 在真机上坐实过:
2890
+ //
2891
+ // · 桌面端配对时确实传了 `--relay <测试环境>`(pairing.rs pair_args)
2892
+ // · 但 launchd plist 的 ProgramArguments 只有 [node, agentlink-agent, <rt>],
2893
+ // 没有 --relay(plutil 解析确认)
2894
+ // · config.json 里只有 gateway 凭据、**没有任何 URL**
2895
+ // → pairCodeClient.readPairStateFromConfig 读 parsed.relayUrl 读到 undefined
2896
+ // → resolveRelayUrl 回落到 DEFAULT_RELAY_URL = https://go-relay.xyagent.com(生产)
2897
+ // → `agentlink doctor` 实测:relay.reachable = https://go-relay.xyagent.com
2898
+ //
2899
+ // 后果对测试用户最恶劣:配对当时一切正常,**重启电脑后全部悄悄连到生产**,表现是
2900
+ // 「配对好了但会话和消息都不见了」——极难排查。
2901
+ //
2902
+ // 读侧(readPairStateFromConfig)早就在读 `parsed.relayUrl` 了,只有写侧一直没写;
2903
+ // 本函数连 relayUrl 参数都收了、没有它还直接早退,却从不落盘。属纯遗漏。
2904
+ //
2905
+ // 用单个 `relayUrl` 而非 runtime 分槽(hermesRelayUrl/relayUrl):读侧对 hermes 与
2906
+ // openclaw 读的是**同一个 key**,写成两个反而对不上。四端本来同属一台机器、同一个
2907
+ // 桌面端,relay 地址天然相同;真出现要分开的需求时再连读侧一起改。
2908
+ const normalizedRelayUrl = String(relayUrl).trim();
2909
+ if (normalizedRelayUrl && section.relayUrl !== normalizedRelayUrl) {
2910
+ section.relayUrl = normalizedRelayUrl;
2911
+ // 记进 changes 才会触发下面的落盘 —— 只有 URL 变化(凭据都已存在)时,
2912
+ // changes 为空就会静默不写,这一步等于没做。
2913
+ changes.push(`state.relayUrl=${normalizedRelayUrl}`);
2914
+ }
2915
+
2650
2916
  if (changes.length > 0) {
2651
2917
  writeJsonFile(resolveAgentlinkStatePath(), section);
2652
2918
  }
@@ -3395,6 +3661,21 @@ function writeBridgeLaunchScript(installConfig) {
3395
3661
  const appData =
3396
3662
  (process.env.APPDATA ?? "").trim() ||
3397
3663
  path.join(capturedHome, "AppData", "Roaming");
3664
+ // ScheduledTask 的 PATH 是注册表值,看不到安装时进程里的增量(尤其是桌面端
3665
+ // 私有 Node 的目录)—— 与 POSIX 版 `export PATH=<defaultPath>:$PATH` 同口径,
3666
+ // 把 node/openclaw 兄弟目录 + 常见安装位快照进脚本,`%` 转义防意外展开。
3667
+ // 兄弟目录解析显式用 win32 语义、分隔符显式 `;`(生产 win 上与默认等价;
3668
+ // 保证非 win 环境下生成的脚本形态也正确,便于测试与排查)。
3669
+ const defaultPath = buildPathString(
3670
+ [
3671
+ ...resolveSiblingBinDirs(installConfig.nodePath, path.win32),
3672
+ ...resolveSiblingBinDirs(installConfig.openclawPath, path.win32),
3673
+ path.join(capturedHome, ".volta", "bin"),
3674
+ path.join(appData, "npm"),
3675
+ "C:\\Program Files\\nodejs",
3676
+ ],
3677
+ ";",
3678
+ );
3398
3679
  const script = [
3399
3680
  "@echo off",
3400
3681
  `set "HOME=${capturedHome}"`,
@@ -3402,6 +3683,7 @@ function writeBridgeLaunchScript(installConfig) {
3402
3683
  `set "APPDATA=${appData}"`,
3403
3684
  `set "OPENCLAW_HOME=${capturedOpenClawHome}"`,
3404
3685
  `set "AGENTLINK_HOME=${capturedAgentlinkHome}"`,
3686
+ `set "PATH=${defaultPath.replaceAll("%", "%%")};%PATH%"`,
3405
3687
  `"${installConfig.nodePath}" ${nodeArg}`,
3406
3688
  "",
3407
3689
  ].join("\r\n");
@@ -4199,8 +4481,17 @@ async function publishPairToRelay({
4199
4481
  relayClientToken,
4200
4482
  relayGatewayToken,
4201
4483
  accountId,
4484
+ // unify-scan-pairing: "" (default, pair code path) | "scan". Threaded into
4485
+ // the request body so the server can allow an empty clientUserId for the
4486
+ // scan host-claim branch (spec "服务端支持 scan 模式的 pending 归属").
4487
+ // Never persisted server-side — request-only semantics (spec L81).
4488
+ pairMode = "",
4489
+ // scan pending rows SHALL expire within 5 minutes (spec 安全信封 §2); the
4490
+ // pair-code path keeps its existing 600s default.
4491
+ ttlSeconds = 600,
4202
4492
  }) {
4203
4493
  const url = `${relayUrl}/v1/pair-sessions`;
4494
+ const normalizedRuntime = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
4204
4495
  const headers = {
4205
4496
  "content-type": "application/json",
4206
4497
  ...relayAuthHeaders(),
@@ -4210,7 +4501,7 @@ async function publishPairToRelay({
4210
4501
  headers,
4211
4502
  body: JSON.stringify({
4212
4503
  code,
4213
- runtimeKind: normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW),
4504
+ runtimeKind: normalizedRuntime,
4214
4505
  transportMode: normalizeChatTransportMode(transportMode, "relay"),
4215
4506
  ...(clientUserId ? { clientUserId } : {}),
4216
4507
  bindUrl,
@@ -4220,9 +4511,14 @@ async function publishPairToRelay({
4220
4511
  ...(relayClientToken ? { relayClientToken } : {}),
4221
4512
  ...(relayGatewayToken ? { relayGatewayToken } : {}),
4222
4513
  accountId,
4223
- ttlSeconds: 600,
4514
+ ttlSeconds,
4224
4515
  source: "agentlink-cli",
4225
4516
  hostName: normalizeHostName(os.hostname()),
4517
+ // /v1/pair-sessions 是 OpenClaw/Hermes 的旧配对入口;必须同步 v2
4518
+ // 工作区身份,避免 relay 把已配对 gateway 当作 legacy bridge。
4519
+ installation_id: getOrCreateInstallationId(),
4520
+ capabilities: pairingCapabilitiesForRuntime(normalizedRuntime),
4521
+ ...(pairMode ? { pairMode } : {}),
4226
4522
  }),
4227
4523
  });
4228
4524
  const text = await response.text();
@@ -5444,11 +5740,12 @@ async function handleUnifiedRunDispatch({
5444
5740
  gatewayAuthToken,
5445
5741
  bridgeToken,
5446
5742
  defaultModel,
5743
+ openclawConfig,
5447
5744
  request,
5448
5745
  json: jsonMode,
5449
5746
  }) {
5450
5747
  const inner = request?.request ?? {};
5451
- const { thread_id, run_id, input, blocks: rawBlocks = [] } = inner;
5748
+ const { thread_id, run_id, input, blocks: rawBlocks = [], workspace_path } = inner;
5452
5749
 
5453
5750
  if (!thread_id || !run_id) {
5454
5751
  if (!jsonMode) {
@@ -5555,6 +5852,28 @@ async function handleUnifiedRunDispatch({
5555
5852
  }
5556
5853
  }
5557
5854
 
5855
+ // OpenClaw's strict /v1/responses schema has no request-level cwd. Select
5856
+ // the workspace through agent_id and verify that agent's configured
5857
+ // workspace before dispatch; a mismatch must never fall back to main.
5858
+ const agentId = String(inner.agent_id || request.agentId || "main").trim() || "main";
5859
+ if (workspace_path) {
5860
+ try {
5861
+ const contextUrl = new URL("../src-ext/runtime/openclaw/workspaceContext.mjs", import.meta.url).href;
5862
+ const { assertOpenClawWorkspaceContext } = await import(contextUrl);
5863
+ assertOpenClawWorkspaceContext({ config: openclawConfig, agentId, workspacePath: workspace_path });
5864
+ } catch (workspaceError) {
5865
+ try {
5866
+ await postEnvelopes([_buildRunFailed(
5867
+ run_id,
5868
+ "workspace_context_unavailable",
5869
+ String(workspaceError?.message || "openclaw workspace context is unavailable"),
5870
+ )]);
5871
+ } catch { /* best-effort */ }
5872
+ cleanupActiveRunContext(thread_id, run_id);
5873
+ return;
5874
+ }
5875
+ }
5876
+
5558
5877
  // bridge-auto-tunnel: chat-side `/tunnel ls` / `/tunnel off <port>` 拦截。
5559
5878
  // 命中 → 直接 reply bridge-origin text block + run.completed,跳过本地
5560
5879
  // OpenClaw 网关派发。失败 / 未命中 → 兜底 pass-through 继续走 runtime。
@@ -5645,10 +5964,13 @@ async function handleUnifiedRunDispatch({
5645
5964
  // 每轮都是全新 session → 上下文断裂(实测 "go on" 不知道前文是什么)。
5646
5965
  // 用 thread_id 后,同一 agentlink thread 内的所有 turn 落到 openclaw 的
5647
5966
  // 同一个 session bucket,上下文自然续接。
5967
+ // unify-scan-pairing 全量移除了上一代扫码硬件的命名空间归一 hack:扫码路径
5968
+ // 现在与 pair code 共用同一套真实 runtime binding(openclaw/hermes),不再有
5969
+ // App 以合成命名空间 agent 绑定的场景,agentId 直接原样使用。
5648
5970
  const headers = {
5649
5971
  "content-type": "application/json",
5650
5972
  "accept": "text/event-stream",
5651
- "x-openclaw-agent-id": inner.agent_id || request.agentId || "main",
5973
+ "x-openclaw-agent-id": agentId,
5652
5974
  "x-openclaw-session-key": thread_id,
5653
5975
  "x-openclaw-scopes": DEFAULT_GATEWAY_SCOPES,
5654
5976
  };
@@ -5664,7 +5986,7 @@ async function handleUnifiedRunDispatch({
5664
5986
  // (which lives in agents.defaults.model.primary). The accepted forms are
5665
5987
  // `openclaw` (default agent) or `openclaw/<agentId>`. The agentId comes from
5666
5988
  // the dispatch payload's agent_id (or "main" if absent).
5667
- const agentId = inner.agent_id || request.agentId || "main";
5989
+ // (agentId 已在上方解析,缺省回退 "main"。)
5668
5990
  if (!jsonMode) {
5669
5991
  const blockSummary = blocks.map((b) => {
5670
5992
  const kind = b?.source?.kind ? `/${b.source.kind}` : "";
@@ -6340,6 +6662,7 @@ async function runRelayBridge({
6340
6662
  gatewayAuthToken,
6341
6663
  bridgeToken: gatewayToken, // server lookupBridgeToken accepts relay_gateway_token
6342
6664
  defaultModel: bridgeMeta?.openclawConfig?.agents?.defaults?.model?.primary || null,
6665
+ openclawConfig: bridgeMeta?.openclawConfig || null,
6343
6666
  request,
6344
6667
  json,
6345
6668
  });
@@ -6425,7 +6748,10 @@ function formatServiceStatusOutput(result) {
6425
6748
  .join("\n");
6426
6749
  }
6427
6750
 
6428
- // 探测本机已安装的 4 种 runtime。返回顺序:openclaw / hermes / claude / codex。
6751
+ // 探测本机已安装的 runtime。集合与返回顺序 = HOST_DETECTED_RUNTIME_KINDS
6752
+ // (openclaw / hermes / claude / codex / opencode)。
6753
+ // 🔴 deepagents 不在这里:它只入 runtime 枚举,本机没有任何探测手段 —— 没探测过
6754
+ // 就不能出现在探测表/host-inventory 里谎报 not_installed。
6429
6755
  // 设计:每项探测要快(≤3s),失败时给出可读 hint。
6430
6756
  // opts.probeVersion=false:只判 installed(which / 文件存在),跳过 `--version`
6431
6757
  // 子进程探测 —— reset 不需要版本号,省去 hermes 最长 60s 的 ls-remote 宽限延迟。
@@ -6462,7 +6788,11 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6462
6788
  });
6463
6789
  }
6464
6790
 
6465
- // hermes / claude / codex:检测 PATH 中的 CLI 二进制。
6791
+ // hermes / claude / codex / opencode:检测 PATH 中的 CLI 二进制。
6792
+ // opencode(add-deepagents-opencode-runtime-enum)复用**完全同一套** installed
6793
+ // 判定,不另造探测逻辑;自 opencode-pairable 起它同时进入配对扇出目标
6794
+ // (单 runtime 的 `pair -r opencode` 另有 src-ext/runtime/opencode/preflight.mjs
6795
+ // 在消费配对码前再确认一次二进制可用,同构自 codex 的 preflight)。
6466
6796
  //
6467
6797
  // hermes = Nous Research `hermes-agent`(https://github.com/nousresearch/hermes-agent),
6468
6798
  // 装好后会在 PATH 暴露 `hermes` 命令,`hermes --version` 首行形如
@@ -6480,27 +6810,37 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6480
6810
  // (banner.py:140 timeout=10s),网络慢时整条 spawnSync 会 ETIMEDOUT 被
6481
6811
  // 上层误判"未安装";现在不依赖它。
6482
6812
  // ② 拿到 binary 后再跑 `--version` 取版本字符串,超时不影响 installed 判断;
6483
- // hermes 给 60s 宽限(GitHub ls-remote + git fetch 可能慢),claude/codex
6484
- // 是 native 二进制保留 10s。
6485
- for (const bin of ["hermes", "claude", "codex"]) {
6813
+ // hermes 给 60s 宽限(GitHub ls-remote + git fetch 可能慢),
6814
+ // claude/codex/opencode 是 native 二进制保留 10s。
6815
+ for (const kind of PATH_PROBED_RUNTIME_KINDS) {
6486
6816
  const whichCmd = process.platform === "win32" ? "where" : "which";
6487
- const which = spawnSync(whichCmd, [bin], {
6488
- encoding: "utf8", timeout: 2000, windowsHide: true,
6489
- });
6490
- const binPath = (!which.error && which.status === 0)
6491
- ? String(which.stdout ?? "").trim().split(/\r?\n/)[0]
6492
- : "";
6817
+ const candidates = RUNTIME_BINARY_CANDIDATES[kind] ?? [kind];
6818
+ let bin = candidates[0];
6819
+ let which = null;
6820
+ let binPath = "";
6821
+ for (const candidate of candidates) {
6822
+ const probeWhich = spawnSync(whichCmd, [candidate], {
6823
+ encoding: "utf8", timeout: 2000, windowsHide: true,
6824
+ });
6825
+ if (!probeWhich.error && probeWhich.status === 0) {
6826
+ bin = candidate;
6827
+ which = probeWhich;
6828
+ binPath = String(probeWhich.stdout ?? "").trim().split(/\r?\n/)[0];
6829
+ break;
6830
+ }
6831
+ which = probeWhich;
6832
+ }
6493
6833
  if (!binPath) {
6494
6834
  const code = which.error?.code || "";
6495
6835
  const hint = code && code !== "ENOENT"
6496
6836
  ? t("pair_all_hint_which_failed", { cmd: whichCmd, bin, code })
6497
6837
  : t("pair_all_hint_bin_not_in_path", { bin });
6498
- results.push({ kind: bin, installed: false, hint });
6838
+ results.push({ kind, installed: false, hint });
6499
6839
  continue;
6500
6840
  }
6501
6841
  if (!probeVersion) {
6502
6842
  // reset 路径:只需 installed 布尔,跳过 `--version`(省 hermes 最长 60s 宽限)。
6503
- results.push({ kind: bin, installed: true });
6843
+ results.push({ kind, installed: true });
6504
6844
  continue;
6505
6845
  }
6506
6846
  // ② 取版本(best-effort)。hermes 第一次跑会做 git ls-remote 检查更新,
@@ -6513,7 +6853,7 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6513
6853
  if (!probe.error && probe.status === 0) {
6514
6854
  version = String(probe.stdout ?? "").trim().split(/\r?\n/)[0] || "";
6515
6855
  }
6516
- const entry = { kind: bin, installed: true, version: version || t("pair_all_version_unknown") };
6856
+ const entry = { kind, installed: true, version: version || t("pair_all_version_unknown") };
6517
6857
  if (!version) {
6518
6858
  // 记一下为啥没拿到版本,方便用户/我们之后定位
6519
6859
  const code = probe.error?.code;
@@ -6531,11 +6871,199 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6531
6871
  return results;
6532
6872
  }
6533
6873
 
6874
+ // ─── per-runtime local pair-state helpers ───────────────────────────────────
6875
+ // 抽出自 runPairAll(原为其内部闭包):unify-scan-pairing 的 runScan 复用同一套
6876
+ // "本机已有凭证 + 服务端 binding 是否还活着" 判定逻辑(spec S3),避免两套配对
6877
+ // 代码漂移。纯函数/无 runPairAll 局部状态捕获,提到顶层不改变行为。
6878
+
6879
+ // openclaw + hermes 的 pair state 都存在 ~/.agentlink/config.json:
6880
+ // openclaw → relayGatewayId/relayGatewayToken
6881
+ // hermes → hermesRelayGatewayId/hermesRelayGatewayToken
6882
+ // claude/codex/opencode 存在 ~/.agentlink/ext-<runtime>-last.json。
6883
+ //
6884
+ // 🔴 目录一律走 resolveAgentlinkHome()(优先 AGENTLINK_HOME),**不得**用
6885
+ // `process.env.HOME + "/.agentlink"` 自己拼:写方(ext 侧 pairCodeClient.stateDir())
6886
+ // 与探方(runtimeHasLocalState)都优先认 AGENTLINK_HOME,这里拼裸 HOME 会造成
6887
+ // 「写在 A、探在 A、读/清在 B」的三方分叉 —— 已配对的 ext-agent runtime 被判成未
6888
+ // 配对而重复 consume(服务端多一条 orphan binding),stale 清理又会去删另一个
6889
+ // HOME 下的同名凭证(误删别人的)。launchd/systemd 单元本身就注入 AGENTLINK_HOME
6890
+ // (见 3600/3636/3841 行),所以这不是理论风险。
6891
+ //
6892
+ // 返回 { gw_id, token, relay_url } 或 null。
6893
+ function readLocalPairState(kind) {
6894
+ try {
6895
+ const agentlinkHome = resolveAgentlinkHome();
6896
+ if (!agentlinkHome) return null;
6897
+ // ext-agent 家族(claude / codex / opencode):凭证在 ext-<kind>-last.json。
6898
+ // 🔴 判据走登记表而不是字面量 —— 漏一个就会掉进下面的 config.json 分支,
6899
+ // 把 openclaw 的 relayGatewayId 当成它的凭证读出来(静默错配的读方向)。
6900
+ if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
6901
+ const p = path.join(agentlinkHome, `ext-${kind}-last.json`);
6902
+ if (!fs.existsSync(p)) return null;
6903
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
6904
+ const gw = j?.gw_id || "";
6905
+ const tok = j?.bridge_token || "";
6906
+ if (!gw || !tok) return null;
6907
+ return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
6908
+ }
6909
+ if (kind === "openclaw" || kind === "hermes") {
6910
+ const p = path.join(agentlinkHome, "config.json");
6911
+ if (!fs.existsSync(p)) return null;
6912
+ let j;
6913
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
6914
+ catch { return null; }
6915
+ const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
6916
+ const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
6917
+ const gw = j?.[idKey] || "";
6918
+ const tok = j?.[tokKey] || "";
6919
+ if (!gw) return null;
6920
+ return { gw_id: gw, token: tok, relay_url: j?.relayUrl || "" };
6921
+ }
6922
+ return null;
6923
+ } catch {
6924
+ return null;
6925
+ }
6926
+ }
6927
+
6928
+ // 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
6929
+ function clearLocalPairState(kind) {
6930
+ try {
6931
+ // 与 readLocalPairState 同一判据(登记表驱动)**且同一目录解析**
6932
+ // (resolveAgentlinkHome / 优先 AGENTLINK_HOME)—— 读写必须落在同一个文件上,
6933
+ // 否则"清理"是空操作、真正被删的却是另一个 HOME 下的同名凭证。
6934
+ const agentlinkHome = resolveAgentlinkHome();
6935
+ if (!agentlinkHome) return;
6936
+ if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
6937
+ const p = path.join(agentlinkHome, `ext-${kind}-last.json`);
6938
+ if (fs.existsSync(p)) {
6939
+ try { fs.unlinkSync(p); } catch {}
6940
+ }
6941
+ return;
6942
+ }
6943
+ if (kind === "openclaw" || kind === "hermes") {
6944
+ const p = path.join(agentlinkHome, "config.json");
6945
+ if (!fs.existsSync(p)) return;
6946
+ let j;
6947
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
6948
+ const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
6949
+ const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
6950
+ const clientTokKey = kind === "openclaw" ? "relayClientToken" : "hermesRelayClientToken";
6951
+ delete j[idKey];
6952
+ delete j[tokKey];
6953
+ delete j[clientTokKey];
6954
+ try { fs.writeFileSync(p, JSON.stringify(j, null, 2)); } catch {}
6955
+ }
6956
+ } catch {}
6957
+ }
6958
+
6959
+ // 服务端探"绑定状态":GET /v1/gateways/<gw>/binding 返回当前 active
6960
+ // (consumed=1) 的 app-user 绑定。
6961
+ // - 404/401/410 → 服务端 pair_session 已不存在/无权 → 视为已删
6962
+ // - 200 且 binding=null → 网关 session 还在但没有 active 绑定(典型:App
6963
+ // 删除了智能体把 pair_session 软删;或网关重连
6964
+ // host-claim 复活出一条 consumed=0 的行)→ 视为已删
6965
+ // - 200 且 binding!=null → 仍有 active 绑定 → 还在
6966
+ // - 5xx / 超时 → 网络/服务端问题,保守按"还在"处理,不误删本机 state
6967
+ //
6968
+ // 不要用 /meta 探活:那只校验网关 bridge_token / 进程是否存活,网关还在跑就返
6969
+ // 200,**不反映 app-user 绑定是否已被删**。openclaw/hermes 网关常驻,App 删除
6970
+ // 智能体后 /meta 仍 200 → 误判"已配对"、跳过重配对(这正是删 openclaw 后再添加
6971
+ // 显示"已配对"的根因)。
6972
+ //
6973
+ // 也不要比对 binding.code 跟用户传的 code —— pair code 每次扫码新生成,用户拿
6974
+ // 新 code 重跑 pair 时 binding 里仍是旧 code,对不上是常态;只判断 binding 是否
6975
+ // 存在即可,避免把"换码重配对"误判成需要 re-pair。
6976
+ async function probeRelayBinding(state) {
6977
+ if (!state || !state.gw_id) {
6978
+ return { alive: true, reason: "no_probe_url" };
6979
+ }
6980
+ // 老 state 缺 relay_url(典型 openclaw 老格式)→ 回退 env / 内置默认 relay
6981
+ // 再探,而不是直接按"已配对"跳过:那条逃逸正是 P0#18 ReOpen 的残留路径——
6982
+ // App 删除智能体后用户重新 pair,老 state 机器永远卡在「已配对,跳过」。
6983
+ let relayUrl = String(state.relay_url || "").trim();
6984
+ if (!relayUrl) {
6985
+ relayUrl =
6986
+ String(process.env.OPENIM_RELAY_URL || "").trim() ||
6987
+ String(process.env.AGENTLINK_RELAY_URL || "").trim() ||
6988
+ DEFAULT_RELAY_URL;
6989
+ }
6990
+ if (!state.token) {
6991
+ // 没有网关凭证 → 探不了也证明不了 binding 存活。用户此刻正在主动
6992
+ // `pair <code>`,重配对幂等且是用户意图;按 stale 走清理+重配对,
6993
+ // 比误判「已配对」把用户卡死安全。
6994
+ return { alive: false, reason: "no_gateway_token" };
6995
+ }
6996
+ const base = relayUrl.replace(/\/$/, "");
6997
+ const url = `${base}/v1/gateways/${encodeURIComponent(state.gw_id)}/binding`;
6998
+ const controller = new AbortController();
6999
+ const timer = setTimeout(() => controller.abort(), 3000);
7000
+ try {
7001
+ const resp = await fetch(url, {
7002
+ method: "GET",
7003
+ signal: controller.signal,
7004
+ headers: {
7005
+ "Authorization": `Bearer ${state.token}`,
7006
+ },
7007
+ });
7008
+ clearTimeout(timer);
7009
+ // 401 (token 无效) / 404 (gateway session 不存在) / 410 → server 端已删
7010
+ if (resp.status === 401 || resp.status === 404 || resp.status === 410) {
7011
+ return { alive: false, reason: `http_${resp.status}` };
7012
+ }
7013
+ if (resp.ok) {
7014
+ let body = null;
7015
+ try {
7016
+ body = await resp.json();
7017
+ } catch {
7018
+ // 解析失败时保守按"还在"处理,不误删
7019
+ return { alive: true, reason: "binding_parse_failed_assume_alive" };
7020
+ }
7021
+ const binding =
7022
+ body && typeof body === "object" ? body.binding : undefined;
7023
+ // binding 为 null/缺失 → 没有 active 绑定 → 视为已删
7024
+ if (binding == null) {
7025
+ return { alive: false, reason: "binding_null" };
7026
+ }
7027
+ return { alive: true, reason: `http_${resp.status}` };
7028
+ }
7029
+ // 500/503 等服务端错误 → 保守不删
7030
+ return { alive: true, reason: `http_${resp.status}_assume_alive` };
7031
+ } catch (err) {
7032
+ clearTimeout(timer);
7033
+ // 网络超时 / DNS 失败 → 保守不删
7034
+ return { alive: true, reason: `network_err_assume_alive` };
7035
+ }
7036
+ }
7037
+
7038
+ // 对应 runtime 的"重置后重新配对"命令提示。
7039
+ function resetHintFor(kind, code) {
7040
+ // ext-agent 家族没有 reset 子命令,其"解绑"即 uninstall(见 forwardToAgent)。
7041
+ if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
7042
+ return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
7043
+ }
7044
+ // openclaw / hermes 走 reset 子命令
7045
+ return `agentlink reset -r ${kind} && agentlink pair ${code} -r ${kind}`;
7046
+ }
7047
+
7048
+ // 每 runtime 一次 host-claim 子进程 spawn(unify-scan-pairing 抽出,供
7049
+ // runPairAll 与 runScan 共用,"出码方式"通过 extraArgv 注入 —— pair code
7050
+ // 路径转发原始 argv 不变;scan 路径注入 `pair <code> --pair-mode scan
7051
+ // --no-serve-relay ...`,见 runScan())。隔离 fail()/process.exit:单个
7052
+ // runtime 失败不影响其余 runtime(每个都是独立子进程)。
7053
+ function spawnPairChildForRuntime({ argvTail, runtimeKind, env }) {
7054
+ const argv = [CLI_ENTRY, ...argvTail, "--runtime", runtimeKind];
7055
+ const child = spawnSync(process.execPath, argv, { stdio: "inherit", env });
7056
+ return { kind: runtimeKind, ok: child.status === 0, status: child.status ?? null };
7057
+ }
7058
+
6534
7059
  // `agentlink pair <code>` 默认批量配对所有已安装 runtime。
6535
7060
  // 也兼容 `agentlink pair <code> --runtime all` 显式写法。
6536
- // 检测本机安装的 4 种 runtime(openclaw / hermes / claude / codex),
6537
- // 对每个已安装的 runtime 串行 spawn 一次自身(替换 --runtime <kind>),
7061
+ // 检测本机安装的 runtime(HOST_DETECTED_RUNTIME_KINDS),对每个「已安装 **且**
7062
+ // 本轮有配对实现」的 runtime 串行 spawn 一次自身(替换 --runtime <kind>),
6538
7063
  // 互不影响 fail(),最后汇总结果。
7064
+ // opencode 自 devagent-m-20260807-cli-runtime-opencode-pairable 起**已进扇出**
7065
+ // (它有了真配对实现)。已装但仍无配对实现的 runtime 才只进 host-inventory 上报、
7066
+ // 不进扇出 —— 见 runPairAll 内 pairTargetKinds 处注释。
6539
7067
  async function runPairAll(options) {
6540
7068
  if (!options.code || !String(options.code).trim()) {
6541
7069
  fail(t("pair_usage_missing"));
@@ -6557,7 +7085,21 @@ async function runPairAll(options) {
6557
7085
 
6558
7086
  const installedKinds = detected.filter((d) => d.installed).map((d) => d.kind);
6559
7087
  if (installedKinds.length === 0) {
6560
- fail(t("pair_all_no_runtimes"));
7088
+ fail(t("pair_all_no_runtimes", { kinds: HOST_DETECTED_RUNTIME_KINDS.join(" / ") }));
7089
+ }
7090
+
7091
+ // 扇出目标 = 已安装 ∩ 可配对集。已装但没有配对实现的 runtime **必须**排除在外:
7092
+ // 给它 spawn 一次 `--runtime <kind>` 只会撞 assertRuntimeOperableOrFail() 白拿
7093
+ // 一个非 0 退出,把整批 pair-all 拖成失败(纯回归)。
7094
+ // 但它仍留在 installedKinds 里上报给 relay —— 「本机装了它」是事实,App 需要知道。
7095
+ //
7096
+ // 现状:本机探测集 ⊆ 可配对集(opencode 自本轮起有了配对实现),所以
7097
+ // pairUnsupportedKinds 实际恒空。**保留**这条分支而不是删掉:它是由登记表
7098
+ // 派生的通用兜底,下一个"只入探测、还没配对实现"的 runtime 会立刻用上。
7099
+ const pairTargetKinds = installedKinds.filter((k) => PAIRABLE_RUNTIME_KINDS.includes(k));
7100
+ const pairUnsupportedKinds = installedKinds.filter((k) => !PAIRABLE_RUNTIME_KINDS.includes(k));
7101
+ for (const kind of pairUnsupportedKinds) {
7102
+ console.log(t("pair_all_runtime_pair_unsupported", { kind }));
6561
7103
  }
6562
7104
 
6563
7105
  // host-inventory side channel —— 在 spawn 子配对命令之前,先把本机装好的
@@ -6626,7 +7168,10 @@ async function runPairAll(options) {
6626
7168
  continue;
6627
7169
  }
6628
7170
  if (a === "--allow-rebind") {
6629
- continue; // 后面会显式加一次,去重
7171
+ // 刻意**不**转发给子进程:pair-all 的语义是「把没配对的都配上」,已配对的
7172
+ // 由下面的 probeRelayBinding 判定后跳过,不做覆盖兜底(见下一段注释)。
7173
+ // ⚠️ 注释曾写「后面会显式加一次」——那是早期实现,现已不再回注;别照着补回去。
7174
+ continue;
6630
7175
  }
6631
7176
  forwardedArgv.push(a);
6632
7177
  }
@@ -6642,162 +7187,11 @@ async function runPairAll(options) {
6642
7187
  AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
6643
7188
  };
6644
7189
 
6645
- // openclaw + hermes pair state 都存在 ~/.agentlink/config.json:
6646
- // openclaw relayGatewayId/relayGatewayToken
6647
- // hermes → hermesRelayGatewayId/hermesRelayGatewayToken
6648
- // claude/codex 存在 ~/.agentlink/ext-<runtime>-last.json。
6649
- //
6650
- // 返回 { gw_id, token, relay_url } 或 null。
6651
- function readLocalPairState(kind) {
6652
- try {
6653
- const home = process.env.HOME || "";
6654
- if (!home) return null;
6655
- if (kind === "claude" || kind === "codex") {
6656
- const p = `${home}/.agentlink/ext-${kind}-last.json`;
6657
- if (!fs.existsSync(p)) return null;
6658
- const j = JSON.parse(fs.readFileSync(p, "utf8"));
6659
- const gw = j?.gw_id || "";
6660
- const tok = j?.bridge_token || "";
6661
- if (!gw || !tok) return null;
6662
- return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
6663
- }
6664
- if (kind === "openclaw" || kind === "hermes") {
6665
- const p = `${home}/.agentlink/config.json`;
6666
- if (!fs.existsSync(p)) return null;
6667
- let j;
6668
- try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
6669
- catch { return null; }
6670
- const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
6671
- const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
6672
- const gw = j?.[idKey] || "";
6673
- const tok = j?.[tokKey] || "";
6674
- if (!gw) return null;
6675
- return { gw_id: gw, token: tok, relay_url: j?.relayUrl || "" };
6676
- }
6677
- return null;
6678
- } catch {
6679
- return null;
6680
- }
6681
- }
6682
-
6683
- // 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
6684
- function clearLocalPairState(kind) {
6685
- try {
6686
- const home = process.env.HOME || "";
6687
- if (!home) return;
6688
- if (kind === "claude" || kind === "codex") {
6689
- const p = `${home}/.agentlink/ext-${kind}-last.json`;
6690
- if (fs.existsSync(p)) {
6691
- try { fs.unlinkSync(p); } catch {}
6692
- }
6693
- return;
6694
- }
6695
- if (kind === "openclaw" || kind === "hermes") {
6696
- const p = `${home}/.agentlink/config.json`;
6697
- if (!fs.existsSync(p)) return;
6698
- let j;
6699
- try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
6700
- const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
6701
- const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
6702
- const clientTokKey = kind === "openclaw" ? "relayClientToken" : "hermesRelayClientToken";
6703
- delete j[idKey];
6704
- delete j[tokKey];
6705
- delete j[clientTokKey];
6706
- try { fs.writeFileSync(p, JSON.stringify(j, null, 2)); } catch {}
6707
- }
6708
- } catch {}
6709
- }
6710
-
6711
- // 服务端探"绑定状态":GET /v1/gateways/<gw>/binding 返回当前 active
6712
- // (consumed=1) 的 app-user 绑定。
6713
- // - 404/401/410 → 服务端 pair_session 已不存在/无权 → 视为已删
6714
- // - 200 且 binding=null → 网关 session 还在但没有 active 绑定(典型:App
6715
- // 删除了智能体把 pair_session 软删;或网关重连
6716
- // host-claim 复活出一条 consumed=0 的行)→ 视为已删
6717
- // - 200 且 binding!=null → 仍有 active 绑定 → 还在
6718
- // - 5xx / 超时 → 网络/服务端问题,保守按"还在"处理,不误删本机 state
6719
- //
6720
- // 不要用 /meta 探活:那只校验网关 bridge_token / 进程是否存活,网关还在跑就返
6721
- // 200,**不反映 app-user 绑定是否已被删**。openclaw/hermes 网关常驻,App 删除
6722
- // 智能体后 /meta 仍 200 → 误判"已配对"、跳过重配对(这正是删 openclaw 后再添加
6723
- // 显示"已配对"的根因)。
6724
- //
6725
- // 也不要比对 binding.code 跟用户传的 code —— pair code 每次扫码新生成,用户拿
6726
- // 新 code 重跑 pair 时 binding 里仍是旧 code,对不上是常态;只判断 binding 是否
6727
- // 存在即可,避免把"换码重配对"误判成需要 re-pair。
6728
- async function probeRelayBinding(state) {
6729
- if (!state || !state.gw_id) {
6730
- return { alive: true, reason: "no_probe_url" };
6731
- }
6732
- // 老 state 缺 relay_url(典型 openclaw 老格式)→ 回退 env / 内置默认 relay
6733
- // 再探,而不是直接按"已配对"跳过:那条逃逸正是 P0#18 ReOpen 的残留路径——
6734
- // App 删除智能体后用户重新 pair,老 state 机器永远卡在「已配对,跳过」。
6735
- let relayUrl = String(state.relay_url || "").trim();
6736
- if (!relayUrl) {
6737
- relayUrl =
6738
- String(process.env.OPENIM_RELAY_URL || "").trim() ||
6739
- String(process.env.AGENTLINK_RELAY_URL || "").trim() ||
6740
- DEFAULT_RELAY_URL;
6741
- }
6742
- if (!state.token) {
6743
- // 没有网关凭证 → 探不了也证明不了 binding 存活。用户此刻正在主动
6744
- // `pair <code>`,重配对幂等且是用户意图;按 stale 走清理+重配对,
6745
- // 比误判「已配对」把用户卡死安全。
6746
- return { alive: false, reason: "no_gateway_token" };
6747
- }
6748
- const base = relayUrl.replace(/\/$/, "");
6749
- const url = `${base}/v1/gateways/${encodeURIComponent(state.gw_id)}/binding`;
6750
- const controller = new AbortController();
6751
- const timer = setTimeout(() => controller.abort(), 3000);
6752
- try {
6753
- const resp = await fetch(url, {
6754
- method: "GET",
6755
- signal: controller.signal,
6756
- headers: {
6757
- "Authorization": `Bearer ${state.token}`,
6758
- },
6759
- });
6760
- clearTimeout(timer);
6761
- // 401 (token 无效) / 404 (gateway session 不存在) / 410 → server 端已删
6762
- if (resp.status === 401 || resp.status === 404 || resp.status === 410) {
6763
- return { alive: false, reason: `http_${resp.status}` };
6764
- }
6765
- if (resp.ok) {
6766
- let body = null;
6767
- try {
6768
- body = await resp.json();
6769
- } catch {
6770
- // 解析失败时保守按"还在"处理,不误删
6771
- return { alive: true, reason: "binding_parse_failed_assume_alive" };
6772
- }
6773
- const binding =
6774
- body && typeof body === "object" ? body.binding : undefined;
6775
- // binding 为 null/缺失 → 没有 active 绑定 → 视为已删
6776
- if (binding == null) {
6777
- return { alive: false, reason: "binding_null" };
6778
- }
6779
- return { alive: true, reason: `http_${resp.status}` };
6780
- }
6781
- // 500/503 等服务端错误 → 保守不删
6782
- return { alive: true, reason: `http_${resp.status}_assume_alive` };
6783
- } catch (err) {
6784
- clearTimeout(timer);
6785
- // 网络超时 / DNS 失败 → 保守不删
6786
- return { alive: true, reason: `network_err_assume_alive` };
6787
- }
6788
- }
6789
-
6790
- // 对应 runtime 的"重置后重新配对"命令提示。
6791
- function resetHintFor(kind, code) {
6792
- if (kind === "claude" || kind === "codex") {
6793
- return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
6794
- }
6795
- // openclaw / hermes 走 reset 子命令
6796
- return `agentlink reset -r ${kind} && agentlink pair ${code} -r ${kind}`;
6797
- }
7190
+ // readLocalPairState / clearLocalPairState / probeRelayBinding / resetHintFor
7191
+ // 已提到顶层(供 runScan 复用,见其上方注释),行为不变。
6798
7192
 
6799
7193
  const summary = [];
6800
- for (const kind of installedKinds) {
7194
+ for (const kind of pairTargetKinds) {
6801
7195
  const state = readLocalPairState(kind);
6802
7196
  if (state) {
6803
7197
  const probe = await probeRelayBinding(state);
@@ -6825,12 +7219,10 @@ async function runPairAll(options) {
6825
7219
  clearLocalPairState(kind);
6826
7220
  }
6827
7221
  console.log(t("pair_all_pairing_runtime", { kind }));
6828
- const argv = [CLI_ENTRY, ...forwardedArgv, "--runtime", kind];
6829
- const child = spawnSync(process.execPath, argv, { stdio: "inherit", env: quietEnv });
6830
- const ok = child.status === 0;
6831
- summary.push({ kind, ok, status: child.status ?? null });
7222
+ const { ok, status } = spawnPairChildForRuntime({ argvTail: forwardedArgv, runtimeKind: kind, env: quietEnv });
7223
+ summary.push({ kind, ok, status });
6832
7224
  if (!ok) {
6833
- console.warn(t("pair_all_runtime_pair_failed", { kind, status: child.status ?? "?" }));
7225
+ console.warn(t("pair_all_runtime_pair_failed", { kind, status: status ?? "?" }));
6834
7226
  }
6835
7227
  }
6836
7228
 
@@ -6859,7 +7251,7 @@ async function runPairAll(options) {
6859
7251
  }
6860
7252
  }
6861
7253
 
6862
- // 汇总:4 行 max(4 种 runtime),一眼扫完。
7254
+ // 汇总:一行一类,一眼扫完。
6863
7255
  const newlyPaired = summary.filter((s) => s.ok && !s.skipped).map((s) => s.kind);
6864
7256
  const alreadyPaired = summary.filter((s) => s.skipped).map((s) => s.kind);
6865
7257
  const failList = summary.filter((s) => !s.ok);
@@ -6878,13 +7270,23 @@ async function runPairAll(options) {
6878
7270
  detail: failList.map((s) => `${s.kind}[exit ${s.status}]`).join("、"),
6879
7271
  }));
6880
7272
  }
7273
+ if (pairUnsupportedKinds.length > 0) {
7274
+ console.log(t("pair_all_summary_pair_unsupported", {
7275
+ count: pairUnsupportedKinds.length,
7276
+ kinds: pairUnsupportedKinds.join("、"),
7277
+ }));
7278
+ }
6881
7279
  if (notInstalledList.length > 0) {
6882
7280
  console.log(t("pair_all_summary_not_installed", { count: notInstalledList.length, kinds: notInstalledList.join("、") }));
6883
7281
  }
6884
- if (failList.length === 0) {
7282
+ if (failList.length > 0) {
7283
+ console.log(t("pair_all_summary_failure_hint"));
7284
+ } else if (newlyPaired.length > 0 || alreadyPaired.length > 0) {
6885
7285
  console.log(t("pair_all_summary_ok_hint"));
6886
7286
  } else {
6887
- console.log(t("pair_all_summary_failure_hint"));
7287
+ // 本机只装了「已知但无配对实现」的 runtime(如只装了 opencode):
7288
+ // 一个都没配上,别报「回 App 看已配对的智能体」那种假成功。
7289
+ console.log(t("pair_all_summary_nothing_pairable"));
6888
7290
  }
6889
7291
  console.log("");
6890
7292
 
@@ -6913,13 +7315,19 @@ async function runResetAll(options) {
6913
7315
  // reset 不需要版本号 → 跳过 `--version` 探测(省 hermes 最长 60s 宽限)。
6914
7316
  const detected = detectInstalledRuntimes({ probeVersion: false });
6915
7317
  const installedKinds = detected.filter((d) => d.installed).map((d) => d.kind);
7318
+ // 只有「可配对集」的 runtime 会留下可解绑痕迹(config.json 的凭证 / ext-<rt>-last.json)。
7319
+ // 无配对实现的 runtime 不可能存在绑定,装了也没什么可解绑的 ——
7320
+ // 放进目标集只会让子进程撞 assertRuntimeOperableOrFail()、把整批 reset 拖成非 0 退出。
7321
+ // opencode 自本轮起在可配对集内:它会正常进入解绑目标(走 agentlink-agent uninstall)。
7322
+ const installedPairableKinds = installedKinds.filter((k) => PAIRABLE_RUNTIME_KINDS.includes(k));
7323
+ const noBindingKinds = installedKinds.filter((k) => !PAIRABLE_RUNTIME_KINDS.includes(k));
6916
7324
  // 未安装但有本地痕迹(卸了 CLI / 删了 openclaw.json 但 binding 还在)的 runtime
6917
7325
  // 也要纳入清理。读 reset 实际操作的同一批文件(config.json / ext-<rt>-last.json)。
6918
- const ALL_KINDS = [RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES, "claude", "codex"];
7326
+ const ALL_KINDS = PAIRABLE_RUNTIME_KINDS;
6919
7327
  const stateOnlyKinds = ALL_KINDS.filter(
6920
- (k) => !installedKinds.includes(k) && runtimeHasLocalState(k),
7328
+ (k) => !installedPairableKinds.includes(k) && runtimeHasLocalState(k),
6921
7329
  );
6922
- const targetKinds = [...installedKinds, ...stateOnlyKinds];
7330
+ const targetKinds = [...installedPairableKinds, ...stateOnlyKinds];
6923
7331
  // 真正「无事可做」的 runtime(既没装、也没痕迹)才算 not-installed。
6924
7332
  const notInstalledList = detected
6925
7333
  .filter((d) => !d.installed && !stateOnlyKinds.includes(d.kind))
@@ -6927,7 +7335,9 @@ async function runResetAll(options) {
6927
7335
 
6928
7336
  if (!json) {
6929
7337
  for (const entry of detected) {
6930
- if (entry.installed) {
7338
+ if (entry.installed && noBindingKinds.includes(entry.kind)) {
7339
+ console.log(t("reset_all_runtime_no_binding", { kind: entry.kind }));
7340
+ } else if (entry.installed) {
6931
7341
  console.log(` ✓ ${entry.kind}`);
6932
7342
  } else if (stateOnlyKinds.includes(entry.kind)) {
6933
7343
  console.log(t("reset_all_runtime_state_only", { kind: entry.kind }));
@@ -6939,6 +7349,8 @@ async function runResetAll(options) {
6939
7349
  }
6940
7350
  log.info("reset_all.targets", "resolved reset targets", {
6941
7351
  installed: installedKinds, stateOnly: stateOnlyKinds, notInstalled: notInstalledList,
7352
+ // 已装但无配对实现 → 无绑定可解,不进目标集(诊断时能一眼看出为何被跳过)
7353
+ noBinding: noBindingKinds,
6942
7354
  });
6943
7355
 
6944
7356
  if (targetKinds.length === 0) {
@@ -7040,7 +7452,12 @@ async function runPair({
7040
7452
  runtime,
7041
7453
  strictPlugin,
7042
7454
  allowRebind,
7455
+ // unify-scan-pairing: "" (default, pair code manual-entry path) | "scan".
7456
+ // Set internally by runScan's per-runtime host-claim child spawn
7457
+ // (`--pair-mode scan`) — never by a human-typed `agentlink pair` call.
7458
+ pairMode = "",
7043
7459
  }) {
7460
+ const isScanMode = pairMode === "scan";
7044
7461
  if (!explicitCode || !String(explicitCode).trim()) {
7045
7462
  fail(t("pair_usage_missing"));
7046
7463
  }
@@ -7067,11 +7484,24 @@ async function runPair({
7067
7484
  );
7068
7485
  }
7069
7486
 
7070
- const currentSession = await readPairSessionFromRelay({
7071
- relayUrl,
7072
- code,
7073
- runtimeKind: requestedRuntime,
7074
- });
7487
+ // scan 模式 SHALL 不做 host-ready 预检(spec P1-4):code 由 CLI 本地生成、
7488
+ // 无 App 预建行,readPairSessionFromRelay 的 GET 预检会因"行不存在"误挡。
7489
+ // 直接以合成的空会话继续,走下面统一的 publishPairToRelay(pairMode=scan)
7490
+ // Upsert 建 pending 行。
7491
+ const currentSession = isScanMode
7492
+ ? {
7493
+ accountId: "",
7494
+ runtimeKind: requestedRuntime,
7495
+ transportMode: "relay",
7496
+ clientUserID: "",
7497
+ bindUrl: "",
7498
+ consumed: false,
7499
+ }
7500
+ : await readPairSessionFromRelay({
7501
+ relayUrl,
7502
+ code,
7503
+ runtimeKind: requestedRuntime,
7504
+ });
7075
7505
  if (!currentSession) {
7076
7506
  fail(
7077
7507
  [
@@ -7092,15 +7522,18 @@ async function runPair({
7092
7522
  const runtimeKind = requestedRuntime;
7093
7523
  // 复合主键升级后,GET 已经按 runtimeKind 精确取了对应的行,server 不会
7094
7524
  // 再返回其他 runtime 的数据,这里 mismatch 不可能发生 —— 保留旧检查仅作冗余防护。
7095
- if (runtime && runtimeKind !== sessionRuntimeKind) {
7525
+ // scan 模式没有真实 GET 结果(currentSession 是本函数合成的),该检查天然满足,跳过即可。
7526
+ if (!isScanMode && runtime && runtimeKind !== sessionRuntimeKind) {
7096
7527
  fail(t("pair_runtime_mismatch", { app: sessionRuntimeKind, cli: runtimeKind }));
7097
7528
  }
7098
7529
  const transportMode = normalizeChatTransportMode(
7099
7530
  mode,
7100
7531
  normalizeChatTransportMode(currentSession.transportMode, "relay"),
7101
7532
  );
7533
+ // scan 模式:clientUserId 由 App scan-claim 时从 Bearer 派生,host-claim 阶段
7534
+ // 天生为空(spec「服务端支持 scan 模式的 pending 归属」)——不是错误,不 fail()。
7102
7535
  const clientUserId = String(currentSession.clientUserID ?? "").trim().toLowerCase();
7103
- if (!clientUserId) {
7536
+ if (!isScanMode && !clientUserId) {
7104
7537
  fail(
7105
7538
  [
7106
7539
  t("pair_missing_client_user_id"),
@@ -7176,8 +7609,13 @@ async function runPair({
7176
7609
  process.env.OPENCLAW_CUSTOM_IM_PUBLIC_BASE_URL?.trim() ||
7177
7610
  "";
7178
7611
 
7612
+ // scan 模式:gateway.port 可能尚未配置(真机上通常已配置,但 host-claim 本身
7613
+ // 不依赖它——scan 是 CLI-bridge 模型,relay-worker 轮询,不像 pair code 那条
7614
+ // legacy 直连 webhook 路径需要一个外部可达的 bindUrl)。optional:true 让
7615
+ // resolveGatewayBaseUrl 缺配置时返回 null 而不是 fail()/exit;pair code 路径
7616
+ // (isScanMode=false)行为完全不变。
7179
7617
  const localGatewayBaseUrl = runtimeNeedsOpenClaw(runtimeKind)
7180
- ? resolveGatewayBaseUrl({ gateway }, config)
7618
+ ? resolveGatewayBaseUrl({ gateway, optional: isScanMode }, config)
7181
7619
  : normalizeBaseUrl(gateway) || "";
7182
7620
  const gatewayAuthToken = runtimeNeedsOpenClaw(runtimeKind)
7183
7621
  ? resolveGatewayAuthToken(config)
@@ -7196,8 +7634,8 @@ async function runPair({
7196
7634
  accountId,
7197
7635
  code,
7198
7636
  });
7199
- const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl;
7200
- if (!relayBindUrl) {
7637
+ const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl || (isScanMode ? "" : null);
7638
+ if (!isScanMode && !relayBindUrl) {
7201
7639
  fail(t("pairurl_bind_url_unknown"));
7202
7640
  }
7203
7641
 
@@ -7291,16 +7729,24 @@ async function runPair({
7291
7729
  c.startsWith("gateway.http.endpoints.responses.enabled") ||
7292
7730
  c.startsWith("gateway.auth."),
7293
7731
  );
7294
- if (runtimeNeedsOpenClaw(runtimeKind) && prepared.changed) {
7732
+ // scan 模式跳过重启宽限等待:出码循环追求"CLI 秒起二维码",这段睡眠/探测只是
7733
+ // "让日志更友好"的诊断性等待(gatewayProbe.ok=false 只 warn,从不 fail() ——
7734
+ // 不影响 publishPairToRelay 是否成功),对 pair code 手输路径行为不变
7735
+ // (isScanMode 为 false 时,下面两处判断与改动前完全一致)。
7736
+ if (runtimeNeedsOpenClaw(runtimeKind) && prepared.changed && !isScanMode) {
7295
7737
  await new Promise((resolve) => setTimeout(resolve, 2000));
7296
7738
  }
7297
7739
 
7298
- if (runtimeNeedsOpenClaw(runtimeKind)) {
7740
+ // scan 模式且 gateway.port 未配置(localGatewayBaseUrl=null,见上方 optional:true)
7741
+ // 时完全跳过探测——没有 URL 可探,buildGatewayResponsesUrlOrFail 遇 null 会
7742
+ // fail()/exit,而这段探测本就只是诊断性的(见上方注释),跳过不影响正确性。
7743
+ if (runtimeNeedsOpenClaw(runtimeKind) && (!isScanMode || localGatewayBaseUrl)) {
7299
7744
  // 90s covers the observed ~35s restart with ample margin; ~7s handles the
7300
- // steady-state case where openclaw is already serving.
7301
- const PROBE_ATTEMPTS = criticalChange ? 90 : prepared.changed ? 15 : 6;
7745
+ // steady-state case where openclaw is already serving. scan 模式只探测
7746
+ // 1 次、不重试(见上方注释)。
7747
+ const PROBE_ATTEMPTS = isScanMode ? 1 : criticalChange ? 90 : prepared.changed ? 15 : 6;
7302
7748
  const PROBE_INTERVAL_MS = 1000;
7303
- const announcedStart = criticalChange;
7749
+ const announcedStart = !isScanMode && criticalChange;
7304
7750
  if (announcedStart) {
7305
7751
  console.log(t("gateway_wait_restart"));
7306
7752
  }
@@ -7353,6 +7799,7 @@ async function runPair({
7353
7799
  relayClientToken: relayCredentials?.clientToken,
7354
7800
  relayGatewayToken: relayCredentials?.gatewayToken,
7355
7801
  accountId,
7802
+ ...(isScanMode ? { pairMode: "scan", ttlSeconds: 300 } : {}),
7356
7803
  });
7357
7804
 
7358
7805
  const output = {
@@ -7960,6 +8407,281 @@ function runPairUrl({ json, account, code: explicitCode }) {
7960
8407
  console.log(resolvedBindUrl);
7961
8408
  }
7962
8409
 
8410
+ // `agentlink scan`:统一扫码配对(unify-scan-pairing,取代 add-openclaw-scan-pairing
8411
+ // 的上一代扫码硬件方案)——CLI 本地出一个高熵 code → 对每个 scan-capable
8412
+ // runtime(openclaw/hermes)host-claim → 渲染中性 deeplink 二维码 → 等 App
8413
+ // scan-claim → 起 worker。配对后效果与 pair code 路径完全一致(同一
8414
+ // `pair_sessions` 底座,同一 App 侧工作区选择流程),只有出码方式不同。
8415
+ //
8416
+ // `scan --json` 跨多阶段持续输出(探测 → 等待扫码 → 配对成功 → 起 worker),
8417
+ // 采用 NDJSON:每阶段一行紧凑 JSON object(见 printScanEvent)。
8418
+ function printScanEvent(fields) {
8419
+ console.log(JSON.stringify({ command: "scan", ...fields }));
8420
+ }
8421
+
8422
+ // claude/codex 在 scan 模式下没有 host-claim 路径(gateway 凭证由服务端在
8423
+ // consume 时才下发,host-claim 阶段无来源——spec 术语 + Non-goals,本期不做
8424
+ // pending 预分配子系统)。检测到时打印明确提示,不静默、不失败(S2b)。
8425
+ function scanUnsupportedRuntimeMessage(kinds) {
8426
+ return `⚠️ ${kinds.join("/")} 暂不支持扫码配对(scan-capable runtime 仅 openclaw/hermes)。请改用 \`${CLI_COMMAND_NAME} pair <code> -r ${kinds[0]}\` 手动配对。`;
8427
+ }
8428
+
8429
+ // env 覆盖仅供测试加速 S4(过期重出码)场景用——与既有
8430
+ // AGENTLINK_SCAN_QR_PNG_PATH 同一约定(见 scanPairFlow.mjs)。生产默认值:
8431
+ // 2s 轮询间隔、300s TTL(与 host-claim 请求的 ttlSeconds 对齐,安全信封 §2)。
8432
+ const SCAN_CLAIM_POLL_INTERVAL_MS = parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_POLL_INTERVAL_MS) || 2000;
8433
+ // 轮询超过这个窗口还没等到 scan-claim,视为码已过期,重新出码(S4),而不是无限等下去。
8434
+ const SCAN_CLAIM_TTL_MS = (parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_TTL_SEC) || 300) * 1000;
8435
+
8436
+ // 轮询等待 App scan-claim 完成。复用既有 readPairSessionFromRelay(GET
8437
+ // /v1/pair-sessions/:code)——scan-claim 会把该 code 下全部 pending 行标记
8438
+ // consumed=1(spec「新增端点」),任取其中一个已 host-claim 的 runtimeKind
8439
+ // 查询即可代表整组的归属状态,不需要新端点。
8440
+ async function waitForScanClaim({ relayUrl, code, representativeRuntimeKind, ttlMs, sleepImpl }) {
8441
+ const sleep = sleepImpl || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
8442
+ const deadline = Date.now() + ttlMs;
8443
+ for (;;) {
8444
+ const session = await readPairSessionFromRelay({
8445
+ relayUrl,
8446
+ code,
8447
+ runtimeKind: representativeRuntimeKind,
8448
+ });
8449
+ if (session && session.consumed) {
8450
+ return true;
8451
+ }
8452
+ if (Date.now() >= deadline) {
8453
+ return false;
8454
+ }
8455
+ await sleep(SCAN_CLAIM_POLL_INTERVAL_MS);
8456
+ }
8457
+ }
8458
+
8459
+ // 起本地已装 scan-capable runtime 的 worker:单 runtime 用既有前台 runBridge
8460
+ // (与 revoke 时既有 selfUninstallOnRevoke 退出路径完全一致,S6);多 runtime
8461
+ // 复用 runPair `--serve-relay` 分支已经在用的后台服务安装机制(安装成功后各自
8462
+ // 独立常驻,不受本进程退出影响)。S1 的多 runtime 场景是 manual-device 验收
8463
+ // 载体(spec 执行载体声明),这里不额外发明新机制,只编排既有函数。
8464
+ async function startScanWorkers({ kinds, relayUrl, gateway, json }) {
8465
+ if (kinds.length === 1) {
8466
+ await runBridge({ json, relay: relayUrl, noRelay: false, gateway, runtime: kinds[0] });
8467
+ return;
8468
+ }
8469
+ for (const kind of kinds) {
8470
+ const gatewayBaseUrl = runtimeNeedsOpenClaw(kind)
8471
+ ? resolveGatewayBaseUrl({ gateway, optional: true }, ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()))) || ""
8472
+ : relayUrl; // hermes 占位:installBridgeService 契约要求非空,hermes 分支不读它。
8473
+ try {
8474
+ const serviceStatus = getBridgeServiceStatus(kind);
8475
+ (serviceStatus.installed ? restartBridgeService : installBridgeService)({
8476
+ relayUrl, gatewayBaseUrl, runtimeKind: kind,
8477
+ });
8478
+ if (!json) console.log(t("service_installed"));
8479
+ } catch (err) {
8480
+ if (!json) console.warn(`⚠️ ${kind} bridge service 安装失败:${err?.message || err}`);
8481
+ }
8482
+ }
8483
+ }
8484
+
8485
+ async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictPlugin, runtime }) {
8486
+ // S2b(显式 -r 到非 scan-capable 的 ext-agent runtime):不出码、不联系 relay,
8487
+ // 直接提示 + 非零退出。(opencode 更早已被 assertRuntimeBridgeSupportedOrFail
8488
+ // 拦停;这里保留登记表判据作双保险。)
8489
+ if (EXT_AGENT_RUNTIME_KINDS.includes(runtime)) {
8490
+ const message = scanUnsupportedRuntimeMessage([runtime]);
8491
+ if (json) {
8492
+ printScanEvent({ phase: "unsupported_runtime", unsupportedRuntimes: [runtime], hint: message });
8493
+ } else {
8494
+ console.error(message);
8495
+ }
8496
+ process.exit(1);
8497
+ }
8498
+
8499
+ const config = ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()));
8500
+ const section = readChannelSection(config);
8501
+ const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
8502
+ if (!relayUrl) {
8503
+ fail(
8504
+ [
8505
+ t("relay_url_required"),
8506
+ t("relay_url_missing_detail"),
8507
+ t("relay_url_hint"),
8508
+ ].join("\n"),
8509
+ );
8510
+ }
8511
+
8512
+ // ── 探测本机已装 runtime,划出「scan-capable 候选集」(S1/S2)───────────
8513
+ const detected = detectInstalledRuntimes();
8514
+ const installedKinds = new Set(detected.filter((d) => d.installed).map((d) => d.kind));
8515
+ // 已装、但不在 scan-capable 白名单里的 runtime:提示改走 pair(ext-agent 家族
8516
+ // 的凭证由服务端在 consume 时下发,本机没有 host-claim 路径)。判据走登记表,
8517
+ // 加 runtime 时不会漏提示。
8518
+ const unsupportedDetected = detected
8519
+ .filter((d) => d.installed && EXT_AGENT_RUNTIME_KINDS.includes(d.kind))
8520
+ .map((d) => d.kind);
8521
+
8522
+ let candidateKinds;
8523
+ if (runtime) {
8524
+ // -r openclaw|hermes:只配这一个(S2)。校验值域已在 CLI 参数解析阶段完成
8525
+ // (openclaw/hermes/claude/codex/opencode/deepagents/all);ext-agent 家族
8526
+ // (claude/codex/opencode)已在上面提前返回,enum-only 的 deepagents 与
8527
+ // pair-only 的 opencode 更早就被命令分派前的两道显式拦停挡下,走不到这里。
8528
+ if (!installedKinds.has(runtime)) {
8529
+ fail(`${CLI_COMMAND_NAME} scan -r ${runtime}: runtime not installed on this host`);
8530
+ }
8531
+ candidateKinds = [runtime];
8532
+ } else {
8533
+ candidateKinds = SCAN_CAPABLE_RUNTIME_KINDS.filter((k) => installedKinds.has(k));
8534
+ }
8535
+ if (candidateKinds.length === 0) {
8536
+ // 🔴 kinds 必须显式传:该文案自 add-deepagents-opencode-runtime-enum 起改成
8537
+ // {kinds} 占位(不再把 runtime 名写死进文案),漏传会把字面 "{kinds}" 打给用户。
8538
+ // 这里的语境是 scan,所以报的是 **scan-capable 集合**(openclaw/hermes),
8539
+ // 不是 HOST_DETECTED 全集 —— 装了 opencode 也不构成"可扫码配对的 runtime"。
8540
+ fail(t("pair_all_no_runtimes", { kinds: SCAN_CAPABLE_RUNTIME_KINDS.join(" / ") }));
8541
+ }
8542
+
8543
+ if (unsupportedDetected.length > 0) {
8544
+ const message = scanUnsupportedRuntimeMessage(unsupportedDetected);
8545
+ if (json) {
8546
+ printScanEvent({ phase: "unsupported_runtime_detected", unsupportedRuntimes: unsupportedDetected, hint: message });
8547
+ } else {
8548
+ console.log(message);
8549
+ }
8550
+ }
8551
+
8552
+ // ── S3:已有有效凭证 + 服务端 binding 仍存活的 runtime 直接跳过出码 ──────
8553
+ const quietEnv = {
8554
+ ...process.env,
8555
+ AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
8556
+ };
8557
+ const alreadyAliveKinds = [];
8558
+ const needsClaimKinds = [];
8559
+ for (const kind of candidateKinds) {
8560
+ const state = readLocalPairState(kind);
8561
+ if (state) {
8562
+ const probe = await probeRelayBinding(state);
8563
+ if (probe.alive) {
8564
+ alreadyAliveKinds.push(kind);
8565
+ if (kind === RUNTIME_KIND_HERMES) {
8566
+ try {
8567
+ const hLog = _createBubLogger({ comp: "scan" }).child({ rt: "hermes" });
8568
+ await ensureHermesEnvAndProbe(hLog);
8569
+ } catch (err) {
8570
+ console.warn(`⚠️ hermes env/gateway 自检失败:${err?.message || err}`);
8571
+ }
8572
+ }
8573
+ continue;
8574
+ }
8575
+ clearLocalPairState(kind);
8576
+ }
8577
+ needsClaimKinds.push(kind);
8578
+ }
8579
+
8580
+ let claimedKinds = [];
8581
+ if (needsClaimKinds.length === 0) {
8582
+ if (!json) {
8583
+ console.log(`ℹ️ ${alreadyAliveKinds.join("、")} 已有有效凭证,跳过扫码,直接启动 bridge…`);
8584
+ } else {
8585
+ printScanEvent({ phase: "skip_pairing", relayUrl, runtimes: alreadyAliveKinds });
8586
+ }
8587
+ } else {
8588
+ // ── 出码循环(S1 前半段 + S4):本地生成高熵 code → 对 needsClaimKinds
8589
+ // 逐个 host-claim(子进程,与 pair-all 同一隔离机制)→ 渲染二维码 → 轮询
8590
+ // scan-claim;超时/过期则清二维码、重新出码,进程不退出。
8591
+ const resolvedQrPath = qrPath || SCAN_QR_PNG_OUTPUT_PATH;
8592
+ for (;;) {
8593
+ const code = generateScanPairCode();
8594
+ const argvTail = [
8595
+ "pair", code,
8596
+ "--relay", relayUrl,
8597
+ "--pair-mode", "scan",
8598
+ "--no-serve-relay",
8599
+ ...(json ? ["--json"] : []),
8600
+ ...(strictPlugin ? ["--strict-plugin"] : []),
8601
+ ];
8602
+ const roundResults = needsClaimKinds.map((kind) =>
8603
+ spawnPairChildForRuntime({ argvTail, runtimeKind: kind, env: quietEnv }),
8604
+ );
8605
+ const roundClaimed = roundResults.filter((r) => r.ok).map((r) => r.kind);
8606
+ const roundFailed = roundResults.filter((r) => !r.ok).map((r) => r.kind);
8607
+ if (roundFailed.length > 0 && !json) {
8608
+ console.warn(`⚠️ 以下 runtime host-claim 失败,不会出现在本轮二维码中:${roundFailed.join("、")}`);
8609
+ }
8610
+ if (roundClaimed.length === 0) {
8611
+ fail(`agentlink scan: host-claim failed for all candidate runtimes (${needsClaimKinds.join(", ")})`);
8612
+ }
8613
+
8614
+ const deeplink = buildScanDeeplink({ code, relay: relayUrl });
8615
+ const pngResult = await renderScanQrPng({ payload: deeplink, outputPath: resolvedQrPath });
8616
+ if (json) {
8617
+ printScanEvent({
8618
+ phase: "waiting_for_scan",
8619
+ code,
8620
+ deeplink,
8621
+ runtimes: roundClaimed,
8622
+ qr_png_path: pngResult.ok ? pngResult.path : null,
8623
+ qr_png_error: pngResult.ok ? null : pngResult.error,
8624
+ });
8625
+ } else {
8626
+ console.log(`\n扫码配对(${roundClaimed.join("、")}):用 App 扫描下方二维码` +
8627
+ (pngResult.ok ? `(或打开 ${pngResult.path} 用图片扫码)` : "") + "\n");
8628
+ renderQrToTerminal(deeplink);
8629
+ console.log(`\n扫不到?把这段贴到浏览器/App 输入框也可以:${deeplink}\n`);
8630
+ }
8631
+
8632
+ const claimedInTime = await waitForScanClaim({
8633
+ relayUrl,
8634
+ code,
8635
+ representativeRuntimeKind: roundClaimed[0],
8636
+ ttlMs: SCAN_CLAIM_TTL_MS,
8637
+ });
8638
+ clearQRImage(resolvedQrPath);
8639
+ if (claimedInTime) {
8640
+ claimedKinds = roundClaimed;
8641
+ if (json) {
8642
+ printScanEvent({ phase: "paired", relayUrl, runtimes: claimedKinds });
8643
+ } else {
8644
+ console.log(`✅ 配对成功(${claimedKinds.join("、")})`);
8645
+ }
8646
+ break;
8647
+ }
8648
+ // 过期/超时(S4):重新生成 code,重新 host-claim,刷新二维码,不退出。
8649
+ if (!json) {
8650
+ console.log("⏳ 配对码已过期,重新生成二维码…");
8651
+ } else {
8652
+ printScanEvent({ phase: "code_expired_retrying", runtimes: roundClaimed });
8653
+ }
8654
+ }
8655
+ }
8656
+
8657
+ const finalKinds = [...alreadyAliveKinds, ...claimedKinds];
8658
+
8659
+ // ── --no-serve:只配对不起 worker(S5 命令签名之一)─────────────────────
8660
+ if (noServe) {
8661
+ if (json) {
8662
+ printScanEvent({ phase: "done", serve: false, runtimes: finalKinds, relayUrl });
8663
+ } else {
8664
+ console.log("ℹ️ --no-serve:已完成配对,不启动 bridge worker。");
8665
+ }
8666
+ return;
8667
+ }
8668
+
8669
+ // ── 起 worker;revoke 由既有共享路径清凭证 + exit(S6,不逆改)──────────
8670
+ if (json) {
8671
+ printScanEvent({
8672
+ phase: "starting_worker",
8673
+ runtimes: finalKinds,
8674
+ relayUrl,
8675
+ note: `on revoke this process/service clears credentials and exits; re-run \`${CLI_COMMAND_NAME} scan\` to re-pair`,
8676
+ });
8677
+ } else {
8678
+ console.log(
8679
+ `若之后在 App 端解绑(或凭证失效),worker 会自动清凭证并退出 —— 到时重跑 \`${CLI_COMMAND_NAME} scan\` 即可重新配对。`,
8680
+ );
8681
+ }
8682
+ await startScanWorkers({ kinds: finalKinds, relayUrl, gateway, json });
8683
+ }
8684
+
7963
8685
  async function runBridge({ json, relay, noRelay, gateway, runtime }) {
7964
8686
  const configPath = resolveOpenClawConfigPath();
7965
8687
  // 先容忍读(缺文件 → null):runtimeKind 可能来自 ~/.agentlink state 的
@@ -8213,10 +8935,11 @@ async function runBridge({ json, relay, noRelay, gateway, runtime }) {
8213
8935
  }
8214
8936
 
8215
8937
  function runStatus({ json, runtime }) {
8216
- // claude / codex 不走 bin 的 openclaw/hermes 状态视图(normalizeRuntimeKind 会把
8938
+ // ext-agent runtime 不走 bin 的 openclaw/hermes 状态视图(normalizeRuntimeKind 会把
8217
8939
  // 它们静默回退成 openclaw,导致展示错 runtime 的状态)。显式拦下并指向正确命令,
8218
- // 而非静默误导。
8219
- if (runtime === "claude" || runtime === "codex") {
8940
+ // 而非静默误导。(opencode 更早已被 assertRuntimeBridgeSupportedOrFail 拦停,
8941
+ // 这里保留登记表判据是双保险 —— 两道闸的判据不同、都不能漏。)
8942
+ if (EXT_AGENT_RUNTIME_KINDS.includes(runtime)) {
8220
8943
  fail(t("status_ext_runtime_unsupported", { runtime, cli: CLI_COMMAND_NAME }));
8221
8944
  }
8222
8945
  const configPath = resolveOpenClawConfigPath();
@@ -8267,14 +8990,23 @@ function runStatus({ json, runtime }) {
8267
8990
  : null,
8268
8991
  };
8269
8992
 
8270
- // 四端汇总(不指定 -r 时也能一眼看全 openclaw/hermes/claude/codex 的配对 + 服务态),
8993
+ // 多端汇总(不指定 -r 时也能一眼看全**可配对** runtime 的配对 + 服务态),
8271
8994
  // 对称 pair/reset 的「无 -r = 全部」。paired 走 HOME 隔离的凭证判定;service 态走
8272
8995
  // 实际服务管理器(展示用途,反映真实情况)。best-effort,不让汇总失败影响主输出。
8273
- status.runtimes = [RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES, "claude", "codex"].map((k) => {
8996
+ // 🔴 这里刻意用 **可配对集** 而不是已知枚举全集:本视图展示的是「配对态 + 服务态」,
8997
+ // 而 enum-only runtime(deepagents)永远没有配对、也没有 service,
8998
+ // 加进来只会长期多出恒为空的行 = 噪音。它们的"装没装"由 pair 的探测清单负责。
8999
+ status.runtimes = PAIRABLE_RUNTIME_KINDS.map((k) => {
8274
9000
  let paired = false;
8275
9001
  try { paired = runtimeHasLocalState(k); } catch { /* ignore */ }
8276
9002
  let svc = { installed: false, running: false };
8277
- try { svc = getBridgeServiceStatus(k); } catch { /* ignore */ }
9003
+ // 🔴 bridge 实现的 runtime(PAIR_ONLY,当前 = opencode)**不探服务态**:
9004
+ // getBridgeServiceStatus → serviceNames() → normalizeRuntimeKind(x,"openclaw")
9005
+ // 会把它别名到 openclaw 的 plist/unit,于是「openclaw 的 bridge 在跑」会被
9006
+ // 展示成「opencode 的服务在跑」= 静默错配。它压根没有 service,恒 false 才是实话。
9007
+ if (BRIDGE_CAPABLE_RUNTIME_KINDS.includes(k)) {
9008
+ try { svc = getBridgeServiceStatus(k); } catch { /* ignore */ }
9009
+ }
8278
9010
  return { kind: k, paired, serviceInstalled: !!svc.installed, serviceRunning: !!svc.running };
8279
9011
  });
8280
9012
 
@@ -8495,6 +9227,15 @@ async function forwardToAgent(command, options) {
8495
9227
 
8496
9228
  async function main() {
8497
9229
  const { command, options } = parseArgs(process.argv.slice(2));
9230
+ // Must happen before any pairing/runtime dispatch: every downstream resolver
9231
+ // reads AGENTLINK_HOME, and external-agent child processes inherit it.
9232
+ if (options.agentlinkHome) {
9233
+ process.env.AGENTLINK_HOME = options.agentlinkHome;
9234
+ }
9235
+ if (command === "capabilities") {
9236
+ process.stdout.write(`${JSON.stringify(RUNTIME_CAPABILITIES)}\n`);
9237
+ return;
9238
+ }
8498
9239
  // `agentlink pair <code>` / `agentlink reset` 默认分别为本机已安装的 4 种
8499
9240
  // runtime 各 spawn 一次(不写死默认 openclaw —— 四端互不依赖:没装 openclaw
8500
9241
  // 的机器也能整机 reset)。`--runtime all` 保持为兼容别名。
@@ -8510,13 +9251,26 @@ async function main() {
8510
9251
  if (command !== "pair" && command !== "reset" && options.runtime === "all") {
8511
9252
  fail(`--runtime all 仅 \`${CLI_COMMAND_NAME} pair\` / \`${CLI_COMMAND_NAME} reset\` 命令支持`);
8512
9253
  }
8513
- // External Agents (Claude / Codex) shim ——
8514
- // 统一把 `agentlink pair <code> -r claude/codex`、`bridge -r claude/codex`、
8515
- // `reset -r claude/codex`(→ uninstall)转发到 agentlink-agent。避免 external
9254
+ // 🔴 已知枚举 可配对集(deepagents):显式拦停,绝不放进实现路径。
9255
+ // 判据与理由都在 assertRuntimeOperableOrFail 的文档注释里(单一事实源)。
9256
+ assertRuntimeOperableOrFail(options.runtime);
9257
+ // 🔴 可配对 ∖ 可桥接(opencode):pair / reset 放行,其余"要把进程跑起来 / 展示
9258
+ // 运行态"的命令显式拦停 —— 见 assertRuntimeBridgeSupportedOrFail 的文档注释。
9259
+ // 放在分派之前统一做,避免每个 run* 各自记得判一次(漏一个就退化成静默回落)。
9260
+ if (command === "bridge" || command === "service" || command === "reload" ||
9261
+ command === "scan" || command === "status" || command === "setup" ||
9262
+ command === "prepare") {
9263
+ assertRuntimeBridgeSupportedOrFail(options.runtime, command);
9264
+ }
9265
+ // External Agents (Claude / Codex / OpenCode) shim ——
9266
+ // 统一把 `agentlink pair <code> -r <ext>`、`bridge -r <ext>`、
9267
+ // `reset -r <ext>`(→ uninstall)转发到 agentlink-agent。避免 external
8516
9268
  // 平行世界代码污染老 bin,也防止 reset 被 normalizeRuntimeKind 回退成 openclaw。
9269
+ // 目标集用 EXT_AGENT_RUNTIME_KINDS(登记表),不再写死 claude/codex 字面量 ——
9270
+ // opencode 的凭证同样存 ext-<rt>-last.json,必须走同一条转发路径。
8517
9271
  if ((command === "pair" || command === "bridge" || command === "service" ||
8518
9272
  command === "uninstall" || command === "reset") &&
8519
- (options.runtime === "claude" || options.runtime === "codex")) {
9273
+ EXT_AGENT_RUNTIME_KINDS.includes(options.runtime)) {
8520
9274
  await forwardToAgent(command, options);
8521
9275
  return;
8522
9276
  }
@@ -8534,6 +9288,9 @@ async function main() {
8534
9288
  case "bridge":
8535
9289
  await runBridge(options);
8536
9290
  return;
9291
+ case "scan":
9292
+ await runScan(options);
9293
+ return;
8537
9294
  case "service":
8538
9295
  await runService(options);
8539
9296
  return;