@xyagent/cli 0.0.1 → 1.0.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 (32) hide show
  1. package/bin/agentlink +551 -176
  2. package/bin/agentlink-agent +0 -0
  3. package/bin/agentlink-mcp-stdio +0 -0
  4. package/package.json +4 -2
  5. package/src/core.mjs +26 -0
  6. package/src-ext/bin.mjs +0 -1
  7. package/src-ext/commands/agent.mjs +0 -11
  8. package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
  9. package/src-ext/core/autoTunnelDetector.mjs +6 -1
  10. package/src-ext/core/autoTunnelWiring.mjs +1 -1
  11. package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
  12. package/src-ext/core/relayWorker.mjs +1 -1
  13. package/src-ext/core/scanDeeplink.mjs +67 -0
  14. package/src-ext/core/scanPairFlow.mjs +28 -0
  15. package/src-ext/core/scanQrRenderer.mjs +40 -0
  16. package/src-ext/core/unifiedDispatchHandler.mjs +3 -3
  17. package/src-ext/core/usageReporter.mjs +1 -2
  18. package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
  19. package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
  20. package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
  21. package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
  22. package/src-ext/runtime/claude/launcher.mjs +0 -6
  23. package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
  24. package/src-shared/envelope_builder.mjs +2 -2
  25. package/src-ext/runtime/picoclaw/constants.mjs +0 -39
  26. package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
  27. package/src-ext/runtime/picoclaw/index.mjs +0 -314
  28. package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
  29. package/src-ext/runtime/picoclaw/state.mjs +0 -78
  30. package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
  31. package/src-ext/runtime/picoclaw/translator.mjs +0 -272
  32. 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,
@@ -62,6 +63,9 @@ 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";
64
65
  import { buildPairSessionResults } from "../src-ext/core/pairInventory.mjs";
66
+ import { SCAN_QR_PNG_OUTPUT_PATH, clearQRImage } from "../src-ext/core/scanPairFlow.mjs";
67
+ import { renderQrToTerminal } from "../src-ext/core/scanQrRenderer.mjs";
68
+ import { buildScanDeeplink, renderScanQrPng } from "../src-ext/core/scanDeeplink.mjs";
65
69
 
66
70
  // Minimum Node.js 22+ — required for the built-in WebSocket client and the
67
71
  // runtime surface we rely on for bridge/service behavior across platforms.
@@ -102,6 +106,12 @@ const SERVICE_UNIT_NAME = "agentlink-bridge.service";
102
106
  const SERVICE_TASK_NAME = "Agentlink Bridge";
103
107
  const RUNTIME_KIND_OPENCLAW = "openclaw";
104
108
  const RUNTIME_KIND_HERMES = "hermes";
109
+ // scan-capable runtimes (unify-scan-pairing): openclaw/hermes read their gateway
110
+ // credentials locally (~/.agentlink/config.json via ensureRelayBridgeCredentials)
111
+ // and can host-claim immediately. claude/codex credentials are issued by the
112
+ // server at consume-time — no host-claim path exists for them this cycle (see
113
+ // spec "术语" + Non-goals; S2b covers the explicit unsupported-runtime notice).
114
+ const SCAN_CAPABLE_RUNTIME_KINDS = Object.freeze([RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES]);
105
115
 
106
116
  function serviceNames(runtimeKind) {
107
117
  const kind = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
@@ -774,6 +784,7 @@ function printHelp() {
774
784
  ` ${CLI_COMMAND_NAME} pair <4-digit|6-digit|8-alnum> [-r|--runtime <openclaw|hermes|claude|codex|all>] [--mode <relay>] [--strict-plugin] # default: all`,
775
785
  ` ${CLI_COMMAND_NAME} reset [-r|--runtime <openclaw|hermes|claude|codex|all>] [--relay <url>] [--gateway <url>] [--json] # default: all`,
776
786
  ` ${CLI_COMMAND_NAME} bridge [--relay <url>] [--gateway <url>] [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
787
+ ` ${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
788
  ` ${CLI_COMMAND_NAME} service <status|install|stop|uninstall|restart> [--relay <url>] [--gateway <url>] [--runtime <openclaw|hermes>] [--json]`,
778
789
  ` ${CLI_COMMAND_NAME} reload [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
779
790
  ` ${CLI_COMMAND_NAME} pair-url [<4-digit|6-digit|8-alnum>] [--account <id>] [--code <4-digit|6-digit|8-alnum>] [--json]`,
@@ -838,6 +849,13 @@ function parseArgs(argv) {
838
849
  doctorAction: undefined,
839
850
  mcpSubcommand: undefined,
840
851
  mcpRuntime: undefined,
852
+ noServe: false,
853
+ qrPath: undefined,
854
+ // Internal plumbing flag — not advertised in --help. Set by `runScan`'s
855
+ // per-runtime host-claim child spawn (`--pair-mode scan`), never expected
856
+ // from a human-typed `agentlink pair` invocation. See runPair()'s
857
+ // pairMode branch (unify-scan-pairing).
858
+ pairMode: "",
841
859
  };
842
860
 
843
861
  while (args.length > 0) {
@@ -893,6 +911,31 @@ function parseArgs(argv) {
893
911
  options.fix = true;
894
912
  continue;
895
913
  }
914
+ if (token === "--no-serve") {
915
+ // `agentlink scan` 专属:只配对不起 worker(spec S5)。与已有的
916
+ // --no-serve-relay(options.serveRelay,pair 命令的"配完对不装后台服务"
917
+ // 语义)刻意分开——scan 这里是"不启动前台 bridge worker",不是装/不装
918
+ // service,两者不通用。
919
+ options.noServe = true;
920
+ continue;
921
+ }
922
+ if (token === "--qr-path") {
923
+ const value = args.shift();
924
+ if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--qr-path" }));
925
+ options.qrPath = value.trim();
926
+ continue;
927
+ }
928
+ if (token === "--pair-mode") {
929
+ // Internal — see options.pairMode default comment above.
930
+ const value = args.shift();
931
+ if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--pair-mode" }));
932
+ const normalized = value.trim().toLowerCase();
933
+ if (normalized !== "" && normalized !== "scan") {
934
+ fail(`--pair-mode must be "scan" (got ${value})`);
935
+ }
936
+ options.pairMode = normalized;
937
+ continue;
938
+ }
896
939
 
897
940
  if (token === "--relay") {
898
941
  const value = args.shift();
@@ -4199,6 +4242,14 @@ async function publishPairToRelay({
4199
4242
  relayClientToken,
4200
4243
  relayGatewayToken,
4201
4244
  accountId,
4245
+ // unify-scan-pairing: "" (default, pair code path) | "scan". Threaded into
4246
+ // the request body so the server can allow an empty clientUserId for the
4247
+ // scan host-claim branch (spec "服务端支持 scan 模式的 pending 归属").
4248
+ // Never persisted server-side — request-only semantics (spec L81).
4249
+ pairMode = "",
4250
+ // scan pending rows SHALL expire within 5 minutes (spec 安全信封 §2); the
4251
+ // pair-code path keeps its existing 600s default.
4252
+ ttlSeconds = 600,
4202
4253
  }) {
4203
4254
  const url = `${relayUrl}/v1/pair-sessions`;
4204
4255
  const headers = {
@@ -4220,9 +4271,10 @@ async function publishPairToRelay({
4220
4271
  ...(relayClientToken ? { relayClientToken } : {}),
4221
4272
  ...(relayGatewayToken ? { relayGatewayToken } : {}),
4222
4273
  accountId,
4223
- ttlSeconds: 600,
4274
+ ttlSeconds,
4224
4275
  source: "agentlink-cli",
4225
4276
  hostName: normalizeHostName(os.hostname()),
4277
+ ...(pairMode ? { pairMode } : {}),
4226
4278
  }),
4227
4279
  });
4228
4280
  const text = await response.text();
@@ -5645,10 +5697,14 @@ async function handleUnifiedRunDispatch({
5645
5697
  // 每轮都是全新 session → 上下文断裂(实测 "go on" 不知道前文是什么)。
5646
5698
  // 用 thread_id 后,同一 agentlink thread 内的所有 turn 落到 openclaw 的
5647
5699
  // 同一个 session bucket,上下文自然续接。
5700
+ // unify-scan-pairing 全量移除了上一代扫码硬件的命名空间归一 hack:扫码路径
5701
+ // 现在与 pair code 共用同一套真实 runtime binding(openclaw/hermes),不再有
5702
+ // App 以合成命名空间 agent 绑定的场景,agentId 直接原样使用。
5703
+ const agentId = String(inner.agent_id || request.agentId || "main").trim() || "main";
5648
5704
  const headers = {
5649
5705
  "content-type": "application/json",
5650
5706
  "accept": "text/event-stream",
5651
- "x-openclaw-agent-id": inner.agent_id || request.agentId || "main",
5707
+ "x-openclaw-agent-id": agentId,
5652
5708
  "x-openclaw-session-key": thread_id,
5653
5709
  "x-openclaw-scopes": DEFAULT_GATEWAY_SCOPES,
5654
5710
  };
@@ -5664,7 +5720,7 @@ async function handleUnifiedRunDispatch({
5664
5720
  // (which lives in agents.defaults.model.primary). The accepted forms are
5665
5721
  // `openclaw` (default agent) or `openclaw/<agentId>`. The agentId comes from
5666
5722
  // the dispatch payload's agent_id (or "main" if absent).
5667
- const agentId = inner.agent_id || request.agentId || "main";
5723
+ // (agentId 已在上方解析,缺省回退 "main"。)
5668
5724
  if (!jsonMode) {
5669
5725
  const blockSummary = blocks.map((b) => {
5670
5726
  const kind = b?.source?.kind ? `/${b.source.kind}` : "";
@@ -6531,6 +6587,176 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6531
6587
  return results;
6532
6588
  }
6533
6589
 
6590
+ // ─── per-runtime local pair-state helpers ───────────────────────────────────
6591
+ // 抽出自 runPairAll(原为其内部闭包):unify-scan-pairing 的 runScan 复用同一套
6592
+ // "本机已有凭证 + 服务端 binding 是否还活着" 判定逻辑(spec S3),避免两套配对
6593
+ // 代码漂移。纯函数/无 runPairAll 局部状态捕获,提到顶层不改变行为。
6594
+
6595
+ // openclaw + hermes 的 pair state 都存在 ~/.agentlink/config.json:
6596
+ // openclaw → relayGatewayId/relayGatewayToken
6597
+ // hermes → hermesRelayGatewayId/hermesRelayGatewayToken
6598
+ // claude/codex 存在 ~/.agentlink/ext-<runtime>-last.json。
6599
+ //
6600
+ // 返回 { gw_id, token, relay_url } 或 null。
6601
+ function readLocalPairState(kind) {
6602
+ try {
6603
+ const home = process.env.HOME || "";
6604
+ if (!home) return null;
6605
+ if (kind === "claude" || kind === "codex") {
6606
+ const p = `${home}/.agentlink/ext-${kind}-last.json`;
6607
+ if (!fs.existsSync(p)) return null;
6608
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
6609
+ const gw = j?.gw_id || "";
6610
+ const tok = j?.bridge_token || "";
6611
+ if (!gw || !tok) return null;
6612
+ return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
6613
+ }
6614
+ if (kind === "openclaw" || kind === "hermes") {
6615
+ const p = `${home}/.agentlink/config.json`;
6616
+ if (!fs.existsSync(p)) return null;
6617
+ let j;
6618
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
6619
+ catch { return null; }
6620
+ const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
6621
+ const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
6622
+ const gw = j?.[idKey] || "";
6623
+ const tok = j?.[tokKey] || "";
6624
+ if (!gw) return null;
6625
+ return { gw_id: gw, token: tok, relay_url: j?.relayUrl || "" };
6626
+ }
6627
+ return null;
6628
+ } catch {
6629
+ return null;
6630
+ }
6631
+ }
6632
+
6633
+ // 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
6634
+ function clearLocalPairState(kind) {
6635
+ try {
6636
+ const home = process.env.HOME || "";
6637
+ if (!home) return;
6638
+ if (kind === "claude" || kind === "codex") {
6639
+ const p = `${home}/.agentlink/ext-${kind}-last.json`;
6640
+ if (fs.existsSync(p)) {
6641
+ try { fs.unlinkSync(p); } catch {}
6642
+ }
6643
+ return;
6644
+ }
6645
+ if (kind === "openclaw" || kind === "hermes") {
6646
+ const p = `${home}/.agentlink/config.json`;
6647
+ if (!fs.existsSync(p)) return;
6648
+ let j;
6649
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
6650
+ const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
6651
+ const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
6652
+ const clientTokKey = kind === "openclaw" ? "relayClientToken" : "hermesRelayClientToken";
6653
+ delete j[idKey];
6654
+ delete j[tokKey];
6655
+ delete j[clientTokKey];
6656
+ try { fs.writeFileSync(p, JSON.stringify(j, null, 2)); } catch {}
6657
+ }
6658
+ } catch {}
6659
+ }
6660
+
6661
+ // 服务端探"绑定状态":GET /v1/gateways/<gw>/binding 返回当前 active
6662
+ // (consumed=1) 的 app-user 绑定。
6663
+ // - 404/401/410 → 服务端 pair_session 已不存在/无权 → 视为已删
6664
+ // - 200 且 binding=null → 网关 session 还在但没有 active 绑定(典型:App
6665
+ // 删除了智能体把 pair_session 软删;或网关重连
6666
+ // host-claim 复活出一条 consumed=0 的行)→ 视为已删
6667
+ // - 200 且 binding!=null → 仍有 active 绑定 → 还在
6668
+ // - 5xx / 超时 → 网络/服务端问题,保守按"还在"处理,不误删本机 state
6669
+ //
6670
+ // 不要用 /meta 探活:那只校验网关 bridge_token / 进程是否存活,网关还在跑就返
6671
+ // 200,**不反映 app-user 绑定是否已被删**。openclaw/hermes 网关常驻,App 删除
6672
+ // 智能体后 /meta 仍 200 → 误判"已配对"、跳过重配对(这正是删 openclaw 后再添加
6673
+ // 显示"已配对"的根因)。
6674
+ //
6675
+ // 也不要比对 binding.code 跟用户传的 code —— pair code 每次扫码新生成,用户拿
6676
+ // 新 code 重跑 pair 时 binding 里仍是旧 code,对不上是常态;只判断 binding 是否
6677
+ // 存在即可,避免把"换码重配对"误判成需要 re-pair。
6678
+ async function probeRelayBinding(state) {
6679
+ if (!state || !state.gw_id) {
6680
+ return { alive: true, reason: "no_probe_url" };
6681
+ }
6682
+ // 老 state 缺 relay_url(典型 openclaw 老格式)→ 回退 env / 内置默认 relay
6683
+ // 再探,而不是直接按"已配对"跳过:那条逃逸正是 P0#18 ReOpen 的残留路径——
6684
+ // App 删除智能体后用户重新 pair,老 state 机器永远卡在「已配对,跳过」。
6685
+ let relayUrl = String(state.relay_url || "").trim();
6686
+ if (!relayUrl) {
6687
+ relayUrl =
6688
+ String(process.env.OPENIM_RELAY_URL || "").trim() ||
6689
+ String(process.env.AGENTLINK_RELAY_URL || "").trim() ||
6690
+ DEFAULT_RELAY_URL;
6691
+ }
6692
+ if (!state.token) {
6693
+ // 没有网关凭证 → 探不了也证明不了 binding 存活。用户此刻正在主动
6694
+ // `pair <code>`,重配对幂等且是用户意图;按 stale 走清理+重配对,
6695
+ // 比误判「已配对」把用户卡死安全。
6696
+ return { alive: false, reason: "no_gateway_token" };
6697
+ }
6698
+ const base = relayUrl.replace(/\/$/, "");
6699
+ const url = `${base}/v1/gateways/${encodeURIComponent(state.gw_id)}/binding`;
6700
+ const controller = new AbortController();
6701
+ const timer = setTimeout(() => controller.abort(), 3000);
6702
+ try {
6703
+ const resp = await fetch(url, {
6704
+ method: "GET",
6705
+ signal: controller.signal,
6706
+ headers: {
6707
+ "Authorization": `Bearer ${state.token}`,
6708
+ },
6709
+ });
6710
+ clearTimeout(timer);
6711
+ // 401 (token 无效) / 404 (gateway session 不存在) / 410 → server 端已删
6712
+ if (resp.status === 401 || resp.status === 404 || resp.status === 410) {
6713
+ return { alive: false, reason: `http_${resp.status}` };
6714
+ }
6715
+ if (resp.ok) {
6716
+ let body = null;
6717
+ try {
6718
+ body = await resp.json();
6719
+ } catch {
6720
+ // 解析失败时保守按"还在"处理,不误删
6721
+ return { alive: true, reason: "binding_parse_failed_assume_alive" };
6722
+ }
6723
+ const binding =
6724
+ body && typeof body === "object" ? body.binding : undefined;
6725
+ // binding 为 null/缺失 → 没有 active 绑定 → 视为已删
6726
+ if (binding == null) {
6727
+ return { alive: false, reason: "binding_null" };
6728
+ }
6729
+ return { alive: true, reason: `http_${resp.status}` };
6730
+ }
6731
+ // 500/503 等服务端错误 → 保守不删
6732
+ return { alive: true, reason: `http_${resp.status}_assume_alive` };
6733
+ } catch (err) {
6734
+ clearTimeout(timer);
6735
+ // 网络超时 / DNS 失败 → 保守不删
6736
+ return { alive: true, reason: `network_err_assume_alive` };
6737
+ }
6738
+ }
6739
+
6740
+ // 对应 runtime 的"重置后重新配对"命令提示。
6741
+ function resetHintFor(kind, code) {
6742
+ if (kind === "claude" || kind === "codex") {
6743
+ return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
6744
+ }
6745
+ // openclaw / hermes 走 reset 子命令
6746
+ return `agentlink reset -r ${kind} && agentlink pair ${code} -r ${kind}`;
6747
+ }
6748
+
6749
+ // 每 runtime 一次 host-claim 子进程 spawn(unify-scan-pairing 抽出,供
6750
+ // runPairAll 与 runScan 共用,"出码方式"通过 extraArgv 注入 —— pair code
6751
+ // 路径转发原始 argv 不变;scan 路径注入 `pair <code> --pair-mode scan
6752
+ // --no-serve-relay ...`,见 runScan())。隔离 fail()/process.exit:单个
6753
+ // runtime 失败不影响其余 runtime(每个都是独立子进程)。
6754
+ function spawnPairChildForRuntime({ argvTail, runtimeKind, env }) {
6755
+ const argv = [CLI_ENTRY, ...argvTail, "--runtime", runtimeKind];
6756
+ const child = spawnSync(process.execPath, argv, { stdio: "inherit", env });
6757
+ return { kind: runtimeKind, ok: child.status === 0, status: child.status ?? null };
6758
+ }
6759
+
6534
6760
  // `agentlink pair <code>` 默认批量配对所有已安装 runtime。
6535
6761
  // 也兼容 `agentlink pair <code> --runtime all` 显式写法。
6536
6762
  // 检测本机安装的 4 种 runtime(openclaw / hermes / claude / codex),
@@ -6642,159 +6868,8 @@ async function runPairAll(options) {
6642
6868
  AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
6643
6869
  };
6644
6870
 
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
- }
6871
+ // readLocalPairState / clearLocalPairState / probeRelayBinding / resetHintFor
6872
+ // 已提到顶层(供 runScan 复用,见其上方注释),行为不变。
6798
6873
 
6799
6874
  const summary = [];
6800
6875
  for (const kind of installedKinds) {
@@ -6825,12 +6900,10 @@ async function runPairAll(options) {
6825
6900
  clearLocalPairState(kind);
6826
6901
  }
6827
6902
  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 });
6903
+ const { ok, status } = spawnPairChildForRuntime({ argvTail: forwardedArgv, runtimeKind: kind, env: quietEnv });
6904
+ summary.push({ kind, ok, status });
6832
6905
  if (!ok) {
6833
- console.warn(t("pair_all_runtime_pair_failed", { kind, status: child.status ?? "?" }));
6906
+ console.warn(t("pair_all_runtime_pair_failed", { kind, status: status ?? "?" }));
6834
6907
  }
6835
6908
  }
6836
6909
 
@@ -7040,7 +7113,12 @@ async function runPair({
7040
7113
  runtime,
7041
7114
  strictPlugin,
7042
7115
  allowRebind,
7116
+ // unify-scan-pairing: "" (default, pair code manual-entry path) | "scan".
7117
+ // Set internally by runScan's per-runtime host-claim child spawn
7118
+ // (`--pair-mode scan`) — never by a human-typed `agentlink pair` call.
7119
+ pairMode = "",
7043
7120
  }) {
7121
+ const isScanMode = pairMode === "scan";
7044
7122
  if (!explicitCode || !String(explicitCode).trim()) {
7045
7123
  fail(t("pair_usage_missing"));
7046
7124
  }
@@ -7067,11 +7145,24 @@ async function runPair({
7067
7145
  );
7068
7146
  }
7069
7147
 
7070
- const currentSession = await readPairSessionFromRelay({
7071
- relayUrl,
7072
- code,
7073
- runtimeKind: requestedRuntime,
7074
- });
7148
+ // scan 模式 SHALL 不做 host-ready 预检(spec P1-4):code 由 CLI 本地生成、
7149
+ // 无 App 预建行,readPairSessionFromRelay 的 GET 预检会因"行不存在"误挡。
7150
+ // 直接以合成的空会话继续,走下面统一的 publishPairToRelay(pairMode=scan)
7151
+ // Upsert 建 pending 行。
7152
+ const currentSession = isScanMode
7153
+ ? {
7154
+ accountId: "",
7155
+ runtimeKind: requestedRuntime,
7156
+ transportMode: "relay",
7157
+ clientUserID: "",
7158
+ bindUrl: "",
7159
+ consumed: false,
7160
+ }
7161
+ : await readPairSessionFromRelay({
7162
+ relayUrl,
7163
+ code,
7164
+ runtimeKind: requestedRuntime,
7165
+ });
7075
7166
  if (!currentSession) {
7076
7167
  fail(
7077
7168
  [
@@ -7092,15 +7183,18 @@ async function runPair({
7092
7183
  const runtimeKind = requestedRuntime;
7093
7184
  // 复合主键升级后,GET 已经按 runtimeKind 精确取了对应的行,server 不会
7094
7185
  // 再返回其他 runtime 的数据,这里 mismatch 不可能发生 —— 保留旧检查仅作冗余防护。
7095
- if (runtime && runtimeKind !== sessionRuntimeKind) {
7186
+ // scan 模式没有真实 GET 结果(currentSession 是本函数合成的),该检查天然满足,跳过即可。
7187
+ if (!isScanMode && runtime && runtimeKind !== sessionRuntimeKind) {
7096
7188
  fail(t("pair_runtime_mismatch", { app: sessionRuntimeKind, cli: runtimeKind }));
7097
7189
  }
7098
7190
  const transportMode = normalizeChatTransportMode(
7099
7191
  mode,
7100
7192
  normalizeChatTransportMode(currentSession.transportMode, "relay"),
7101
7193
  );
7194
+ // scan 模式:clientUserId 由 App scan-claim 时从 Bearer 派生,host-claim 阶段
7195
+ // 天生为空(spec「服务端支持 scan 模式的 pending 归属」)——不是错误,不 fail()。
7102
7196
  const clientUserId = String(currentSession.clientUserID ?? "").trim().toLowerCase();
7103
- if (!clientUserId) {
7197
+ if (!isScanMode && !clientUserId) {
7104
7198
  fail(
7105
7199
  [
7106
7200
  t("pair_missing_client_user_id"),
@@ -7176,8 +7270,13 @@ async function runPair({
7176
7270
  process.env.OPENCLAW_CUSTOM_IM_PUBLIC_BASE_URL?.trim() ||
7177
7271
  "";
7178
7272
 
7273
+ // scan 模式:gateway.port 可能尚未配置(真机上通常已配置,但 host-claim 本身
7274
+ // 不依赖它——scan 是 CLI-bridge 模型,relay-worker 轮询,不像 pair code 那条
7275
+ // legacy 直连 webhook 路径需要一个外部可达的 bindUrl)。optional:true 让
7276
+ // resolveGatewayBaseUrl 缺配置时返回 null 而不是 fail()/exit;pair code 路径
7277
+ // (isScanMode=false)行为完全不变。
7179
7278
  const localGatewayBaseUrl = runtimeNeedsOpenClaw(runtimeKind)
7180
- ? resolveGatewayBaseUrl({ gateway }, config)
7279
+ ? resolveGatewayBaseUrl({ gateway, optional: isScanMode }, config)
7181
7280
  : normalizeBaseUrl(gateway) || "";
7182
7281
  const gatewayAuthToken = runtimeNeedsOpenClaw(runtimeKind)
7183
7282
  ? resolveGatewayAuthToken(config)
@@ -7196,8 +7295,8 @@ async function runPair({
7196
7295
  accountId,
7197
7296
  code,
7198
7297
  });
7199
- const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl;
7200
- if (!relayBindUrl) {
7298
+ const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl || (isScanMode ? "" : null);
7299
+ if (!isScanMode && !relayBindUrl) {
7201
7300
  fail(t("pairurl_bind_url_unknown"));
7202
7301
  }
7203
7302
 
@@ -7291,16 +7390,24 @@ async function runPair({
7291
7390
  c.startsWith("gateway.http.endpoints.responses.enabled") ||
7292
7391
  c.startsWith("gateway.auth."),
7293
7392
  );
7294
- if (runtimeNeedsOpenClaw(runtimeKind) && prepared.changed) {
7393
+ // scan 模式跳过重启宽限等待:出码循环追求"CLI 秒起二维码",这段睡眠/探测只是
7394
+ // "让日志更友好"的诊断性等待(gatewayProbe.ok=false 只 warn,从不 fail() ——
7395
+ // 不影响 publishPairToRelay 是否成功),对 pair code 手输路径行为不变
7396
+ // (isScanMode 为 false 时,下面两处判断与改动前完全一致)。
7397
+ if (runtimeNeedsOpenClaw(runtimeKind) && prepared.changed && !isScanMode) {
7295
7398
  await new Promise((resolve) => setTimeout(resolve, 2000));
7296
7399
  }
7297
7400
 
7298
- if (runtimeNeedsOpenClaw(runtimeKind)) {
7401
+ // scan 模式且 gateway.port 未配置(localGatewayBaseUrl=null,见上方 optional:true)
7402
+ // 时完全跳过探测——没有 URL 可探,buildGatewayResponsesUrlOrFail 遇 null 会
7403
+ // fail()/exit,而这段探测本就只是诊断性的(见上方注释),跳过不影响正确性。
7404
+ if (runtimeNeedsOpenClaw(runtimeKind) && (!isScanMode || localGatewayBaseUrl)) {
7299
7405
  // 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;
7406
+ // steady-state case where openclaw is already serving. scan 模式只探测
7407
+ // 1 次、不重试(见上方注释)。
7408
+ const PROBE_ATTEMPTS = isScanMode ? 1 : criticalChange ? 90 : prepared.changed ? 15 : 6;
7302
7409
  const PROBE_INTERVAL_MS = 1000;
7303
- const announcedStart = criticalChange;
7410
+ const announcedStart = !isScanMode && criticalChange;
7304
7411
  if (announcedStart) {
7305
7412
  console.log(t("gateway_wait_restart"));
7306
7413
  }
@@ -7353,6 +7460,7 @@ async function runPair({
7353
7460
  relayClientToken: relayCredentials?.clientToken,
7354
7461
  relayGatewayToken: relayCredentials?.gatewayToken,
7355
7462
  accountId,
7463
+ ...(isScanMode ? { pairMode: "scan", ttlSeconds: 300 } : {}),
7356
7464
  });
7357
7465
 
7358
7466
  const output = {
@@ -7960,6 +8068,270 @@ function runPairUrl({ json, account, code: explicitCode }) {
7960
8068
  console.log(resolvedBindUrl);
7961
8069
  }
7962
8070
 
8071
+ // `agentlink scan`:统一扫码配对(unify-scan-pairing,取代 add-openclaw-scan-pairing
8072
+ // 的上一代扫码硬件方案)——CLI 本地出一个高熵 code → 对每个 scan-capable
8073
+ // runtime(openclaw/hermes)host-claim → 渲染中性 deeplink 二维码 → 等 App
8074
+ // scan-claim → 起 worker。配对后效果与 pair code 路径完全一致(同一
8075
+ // `pair_sessions` 底座,同一 App 侧工作区选择流程),只有出码方式不同。
8076
+ //
8077
+ // `scan --json` 跨多阶段持续输出(探测 → 等待扫码 → 配对成功 → 起 worker),
8078
+ // 采用 NDJSON:每阶段一行紧凑 JSON object(见 printScanEvent)。
8079
+ function printScanEvent(fields) {
8080
+ console.log(JSON.stringify({ command: "scan", ...fields }));
8081
+ }
8082
+
8083
+ // claude/codex 在 scan 模式下没有 host-claim 路径(gateway 凭证由服务端在
8084
+ // consume 时才下发,host-claim 阶段无来源——spec 术语 + Non-goals,本期不做
8085
+ // pending 预分配子系统)。检测到时打印明确提示,不静默、不失败(S2b)。
8086
+ function scanUnsupportedRuntimeMessage(kinds) {
8087
+ return `⚠️ ${kinds.join("/")} 暂不支持扫码配对(scan-capable runtime 仅 openclaw/hermes)。请改用 \`${CLI_COMMAND_NAME} pair <code> -r ${kinds[0]}\` 手动配对。`;
8088
+ }
8089
+
8090
+ // env 覆盖仅供测试加速 S4(过期重出码)场景用——与既有
8091
+ // AGENTLINK_SCAN_QR_PNG_PATH 同一约定(见 scanPairFlow.mjs)。生产默认值:
8092
+ // 2s 轮询间隔、300s TTL(与 host-claim 请求的 ttlSeconds 对齐,安全信封 §2)。
8093
+ const SCAN_CLAIM_POLL_INTERVAL_MS = parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_POLL_INTERVAL_MS) || 2000;
8094
+ // 轮询超过这个窗口还没等到 scan-claim,视为码已过期,重新出码(S4),而不是无限等下去。
8095
+ const SCAN_CLAIM_TTL_MS = (parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_TTL_SEC) || 300) * 1000;
8096
+
8097
+ // 轮询等待 App scan-claim 完成。复用既有 readPairSessionFromRelay(GET
8098
+ // /v1/pair-sessions/:code)——scan-claim 会把该 code 下全部 pending 行标记
8099
+ // consumed=1(spec「新增端点」),任取其中一个已 host-claim 的 runtimeKind
8100
+ // 查询即可代表整组的归属状态,不需要新端点。
8101
+ async function waitForScanClaim({ relayUrl, code, representativeRuntimeKind, ttlMs, sleepImpl }) {
8102
+ const sleep = sleepImpl || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
8103
+ const deadline = Date.now() + ttlMs;
8104
+ for (;;) {
8105
+ const session = await readPairSessionFromRelay({
8106
+ relayUrl,
8107
+ code,
8108
+ runtimeKind: representativeRuntimeKind,
8109
+ });
8110
+ if (session && session.consumed) {
8111
+ return true;
8112
+ }
8113
+ if (Date.now() >= deadline) {
8114
+ return false;
8115
+ }
8116
+ await sleep(SCAN_CLAIM_POLL_INTERVAL_MS);
8117
+ }
8118
+ }
8119
+
8120
+ // 起本地已装 scan-capable runtime 的 worker:单 runtime 用既有前台 runBridge
8121
+ // (与 revoke 时既有 selfUninstallOnRevoke 退出路径完全一致,S6);多 runtime
8122
+ // 复用 runPair `--serve-relay` 分支已经在用的后台服务安装机制(安装成功后各自
8123
+ // 独立常驻,不受本进程退出影响)。S1 的多 runtime 场景是 manual-device 验收
8124
+ // 载体(spec 执行载体声明),这里不额外发明新机制,只编排既有函数。
8125
+ async function startScanWorkers({ kinds, relayUrl, gateway, json }) {
8126
+ if (kinds.length === 1) {
8127
+ await runBridge({ json, relay: relayUrl, noRelay: false, gateway, runtime: kinds[0] });
8128
+ return;
8129
+ }
8130
+ for (const kind of kinds) {
8131
+ const gatewayBaseUrl = runtimeNeedsOpenClaw(kind)
8132
+ ? resolveGatewayBaseUrl({ gateway, optional: true }, ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()))) || ""
8133
+ : relayUrl; // hermes 占位:installBridgeService 契约要求非空,hermes 分支不读它。
8134
+ try {
8135
+ const serviceStatus = getBridgeServiceStatus(kind);
8136
+ (serviceStatus.installed ? restartBridgeService : installBridgeService)({
8137
+ relayUrl, gatewayBaseUrl, runtimeKind: kind,
8138
+ });
8139
+ if (!json) console.log(t("service_installed"));
8140
+ } catch (err) {
8141
+ if (!json) console.warn(`⚠️ ${kind} bridge service 安装失败:${err?.message || err}`);
8142
+ }
8143
+ }
8144
+ }
8145
+
8146
+ async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictPlugin, runtime }) {
8147
+ // S2b(显式 -r claude/codex):不出码、不联系 relay,直接提示 + 非零退出。
8148
+ if (runtime === "claude" || runtime === "codex") {
8149
+ const message = scanUnsupportedRuntimeMessage([runtime]);
8150
+ if (json) {
8151
+ printScanEvent({ phase: "unsupported_runtime", unsupportedRuntimes: [runtime], hint: message });
8152
+ } else {
8153
+ console.error(message);
8154
+ }
8155
+ process.exit(1);
8156
+ }
8157
+
8158
+ const config = ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()));
8159
+ const section = readChannelSection(config);
8160
+ const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
8161
+ if (!relayUrl) {
8162
+ fail(
8163
+ [
8164
+ t("relay_url_required"),
8165
+ t("relay_url_missing_detail"),
8166
+ t("relay_url_hint"),
8167
+ ].join("\n"),
8168
+ );
8169
+ }
8170
+
8171
+ // ── 探测本机已装 runtime,划出「scan-capable 候选集」(S1/S2)───────────
8172
+ const detected = detectInstalledRuntimes();
8173
+ const installedKinds = new Set(detected.filter((d) => d.installed).map((d) => d.kind));
8174
+ const unsupportedDetected = detected
8175
+ .filter((d) => d.installed && (d.kind === "claude" || d.kind === "codex"))
8176
+ .map((d) => d.kind);
8177
+
8178
+ let candidateKinds;
8179
+ if (runtime) {
8180
+ // -r openclaw|hermes:只配这一个(S2)。校验值域已在 CLI 参数解析阶段完成
8181
+ // (openclaw/hermes/claude/codex/all),claude/codex 已在上面提前返回。
8182
+ if (!installedKinds.has(runtime)) {
8183
+ fail(`${CLI_COMMAND_NAME} scan -r ${runtime}: runtime not installed on this host`);
8184
+ }
8185
+ candidateKinds = [runtime];
8186
+ } else {
8187
+ candidateKinds = SCAN_CAPABLE_RUNTIME_KINDS.filter((k) => installedKinds.has(k));
8188
+ }
8189
+ if (candidateKinds.length === 0) {
8190
+ fail(t("pair_all_no_runtimes"));
8191
+ }
8192
+
8193
+ if (unsupportedDetected.length > 0) {
8194
+ const message = scanUnsupportedRuntimeMessage(unsupportedDetected);
8195
+ if (json) {
8196
+ printScanEvent({ phase: "unsupported_runtime_detected", unsupportedRuntimes: unsupportedDetected, hint: message });
8197
+ } else {
8198
+ console.log(message);
8199
+ }
8200
+ }
8201
+
8202
+ // ── S3:已有有效凭证 + 服务端 binding 仍存活的 runtime 直接跳过出码 ──────
8203
+ const quietEnv = {
8204
+ ...process.env,
8205
+ AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
8206
+ };
8207
+ const alreadyAliveKinds = [];
8208
+ const needsClaimKinds = [];
8209
+ for (const kind of candidateKinds) {
8210
+ const state = readLocalPairState(kind);
8211
+ if (state) {
8212
+ const probe = await probeRelayBinding(state);
8213
+ if (probe.alive) {
8214
+ alreadyAliveKinds.push(kind);
8215
+ if (kind === RUNTIME_KIND_HERMES) {
8216
+ try {
8217
+ const hLog = _createBubLogger({ comp: "scan" }).child({ rt: "hermes" });
8218
+ await ensureHermesEnvAndProbe(hLog);
8219
+ } catch (err) {
8220
+ console.warn(`⚠️ hermes env/gateway 自检失败:${err?.message || err}`);
8221
+ }
8222
+ }
8223
+ continue;
8224
+ }
8225
+ clearLocalPairState(kind);
8226
+ }
8227
+ needsClaimKinds.push(kind);
8228
+ }
8229
+
8230
+ let claimedKinds = [];
8231
+ if (needsClaimKinds.length === 0) {
8232
+ if (!json) {
8233
+ console.log(`ℹ️ ${alreadyAliveKinds.join("、")} 已有有效凭证,跳过扫码,直接启动 bridge…`);
8234
+ } else {
8235
+ printScanEvent({ phase: "skip_pairing", relayUrl, runtimes: alreadyAliveKinds });
8236
+ }
8237
+ } else {
8238
+ // ── 出码循环(S1 前半段 + S4):本地生成高熵 code → 对 needsClaimKinds
8239
+ // 逐个 host-claim(子进程,与 pair-all 同一隔离机制)→ 渲染二维码 → 轮询
8240
+ // scan-claim;超时/过期则清二维码、重新出码,进程不退出。
8241
+ const resolvedQrPath = qrPath || SCAN_QR_PNG_OUTPUT_PATH;
8242
+ for (;;) {
8243
+ const code = generateScanPairCode();
8244
+ const argvTail = [
8245
+ "pair", code,
8246
+ "--relay", relayUrl,
8247
+ "--pair-mode", "scan",
8248
+ "--no-serve-relay",
8249
+ ...(json ? ["--json"] : []),
8250
+ ...(strictPlugin ? ["--strict-plugin"] : []),
8251
+ ];
8252
+ const roundResults = needsClaimKinds.map((kind) =>
8253
+ spawnPairChildForRuntime({ argvTail, runtimeKind: kind, env: quietEnv }),
8254
+ );
8255
+ const roundClaimed = roundResults.filter((r) => r.ok).map((r) => r.kind);
8256
+ const roundFailed = roundResults.filter((r) => !r.ok).map((r) => r.kind);
8257
+ if (roundFailed.length > 0 && !json) {
8258
+ console.warn(`⚠️ 以下 runtime host-claim 失败,不会出现在本轮二维码中:${roundFailed.join("、")}`);
8259
+ }
8260
+ if (roundClaimed.length === 0) {
8261
+ fail(`agentlink scan: host-claim failed for all candidate runtimes (${needsClaimKinds.join(", ")})`);
8262
+ }
8263
+
8264
+ const deeplink = buildScanDeeplink({ code, relay: relayUrl });
8265
+ const pngResult = await renderScanQrPng({ payload: deeplink, outputPath: resolvedQrPath });
8266
+ if (json) {
8267
+ printScanEvent({
8268
+ phase: "waiting_for_scan",
8269
+ code,
8270
+ deeplink,
8271
+ runtimes: roundClaimed,
8272
+ qr_png_path: pngResult.ok ? pngResult.path : null,
8273
+ qr_png_error: pngResult.ok ? null : pngResult.error,
8274
+ });
8275
+ } else {
8276
+ console.log(`\n扫码配对(${roundClaimed.join("、")}):用 App 扫描下方二维码` +
8277
+ (pngResult.ok ? `(或打开 ${pngResult.path} 用图片扫码)` : "") + "\n");
8278
+ renderQrToTerminal(deeplink);
8279
+ console.log(`\n扫不到?把这段贴到浏览器/App 输入框也可以:${deeplink}\n`);
8280
+ }
8281
+
8282
+ const claimedInTime = await waitForScanClaim({
8283
+ relayUrl,
8284
+ code,
8285
+ representativeRuntimeKind: roundClaimed[0],
8286
+ ttlMs: SCAN_CLAIM_TTL_MS,
8287
+ });
8288
+ clearQRImage(resolvedQrPath);
8289
+ if (claimedInTime) {
8290
+ claimedKinds = roundClaimed;
8291
+ if (json) {
8292
+ printScanEvent({ phase: "paired", relayUrl, runtimes: claimedKinds });
8293
+ } else {
8294
+ console.log(`✅ 配对成功(${claimedKinds.join("、")})`);
8295
+ }
8296
+ break;
8297
+ }
8298
+ // 过期/超时(S4):重新生成 code,重新 host-claim,刷新二维码,不退出。
8299
+ if (!json) {
8300
+ console.log("⏳ 配对码已过期,重新生成二维码…");
8301
+ } else {
8302
+ printScanEvent({ phase: "code_expired_retrying", runtimes: roundClaimed });
8303
+ }
8304
+ }
8305
+ }
8306
+
8307
+ const finalKinds = [...alreadyAliveKinds, ...claimedKinds];
8308
+
8309
+ // ── --no-serve:只配对不起 worker(S5 命令签名之一)─────────────────────
8310
+ if (noServe) {
8311
+ if (json) {
8312
+ printScanEvent({ phase: "done", serve: false, runtimes: finalKinds, relayUrl });
8313
+ } else {
8314
+ console.log("ℹ️ --no-serve:已完成配对,不启动 bridge worker。");
8315
+ }
8316
+ return;
8317
+ }
8318
+
8319
+ // ── 起 worker;revoke 由既有共享路径清凭证 + exit(S6,不逆改)──────────
8320
+ if (json) {
8321
+ printScanEvent({
8322
+ phase: "starting_worker",
8323
+ runtimes: finalKinds,
8324
+ relayUrl,
8325
+ note: `on revoke this process/service clears credentials and exits; re-run \`${CLI_COMMAND_NAME} scan\` to re-pair`,
8326
+ });
8327
+ } else {
8328
+ console.log(
8329
+ `若之后在 App 端解绑(或凭证失效),worker 会自动清凭证并退出 —— 到时重跑 \`${CLI_COMMAND_NAME} scan\` 即可重新配对。`,
8330
+ );
8331
+ }
8332
+ await startScanWorkers({ kinds: finalKinds, relayUrl, gateway, json });
8333
+ }
8334
+
7963
8335
  async function runBridge({ json, relay, noRelay, gateway, runtime }) {
7964
8336
  const configPath = resolveOpenClawConfigPath();
7965
8337
  // 先容忍读(缺文件 → null):runtimeKind 可能来自 ~/.agentlink state 的
@@ -8534,6 +8906,9 @@ async function main() {
8534
8906
  case "bridge":
8535
8907
  await runBridge(options);
8536
8908
  return;
8909
+ case "scan":
8910
+ await runScan(options);
8911
+ return;
8537
8912
  case "service":
8538
8913
  await runService(options);
8539
8914
  return;