@xyagent/cli 1.0.0 → 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 (46) hide show
  1. package/README.md +42 -0
  2. package/bin/agentlink +468 -86
  3. package/package.json +2 -2
  4. package/src/tunnel_service.mjs +17 -1
  5. package/src-ext/bin.mjs +12 -11
  6. package/src-ext/commands/agent.mjs +34 -0
  7. package/src-ext/commands/pair.mjs +122 -23
  8. package/src-ext/commands/service.mjs +1 -1
  9. package/src-ext/core/activeRuns.mjs +26 -9
  10. package/src-ext/core/defaultWorkspace.mjs +43 -13
  11. package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
  12. package/src-ext/core/installationIdentity.mjs +94 -0
  13. package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
  14. package/src-ext/core/pairCodeClient.mjs +48 -8
  15. package/src-ext/core/pairInventory.mjs +31 -6
  16. package/src-ext/core/relayWorker.mjs +21 -1
  17. package/src-ext/core/runtimeRegistry.mjs +180 -0
  18. package/src-ext/core/scanWorkspaces.mjs +163 -23
  19. package/src-ext/core/unifiedDispatchHandler.mjs +9 -2
  20. package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
  21. package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
  22. package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
  23. package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
  24. package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
  25. package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
  26. package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
  27. package/src-ext/runtime/claude/handleRequest.mjs +15 -37
  28. package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
  29. package/src-ext/runtime/codebuddy/index.mjs +41 -0
  30. package/src-ext/runtime/codex/handleRequest.mjs +13 -35
  31. package/src-ext/runtime/cursor/index.mjs +46 -0
  32. package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
  33. package/src-ext/runtime/deepagents/preflight.mjs +57 -0
  34. package/src-ext/runtime/hermes/envSetup.mjs +22 -8
  35. package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
  36. package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
  37. package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
  38. package/src-ext/runtime/hermes/index.mjs +1 -1
  39. package/src-ext/runtime/hermes/preflight.mjs +2 -1
  40. package/src-ext/runtime/kimi/index.mjs +100 -0
  41. package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
  42. package/src-ext/runtime/opencode/index.mjs +48 -0
  43. package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
  44. package/src-ext/runtime/opencode/preflight.mjs +72 -0
  45. package/src-ext/runtime/qwen/index.mjs +42 -0
  46. package/src-ext/service/serviceManager.mjs +120 -42
package/bin/agentlink CHANGED
@@ -62,18 +62,30 @@ import { createLogger as _createBubLogger } from "../src-shared/logger.mjs";
62
62
  import { scanWorkspacesByRuntime } from "../src-ext/core/scanWorkspaces.mjs";
63
63
  import { createWorkspace } from "../src-ext/core/createWorkspace.mjs";
64
64
  import { normalizeHostName } from "../src-ext/core/pairCodeClient.mjs";
65
+ import { getOrCreateInstallationId } from "../src-ext/core/installationIdentity.mjs";
65
66
  import { buildPairSessionResults } from "../src-ext/core/pairInventory.mjs";
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";
66
78
  import { SCAN_QR_PNG_OUTPUT_PATH, clearQRImage } from "../src-ext/core/scanPairFlow.mjs";
67
79
  import { renderQrToTerminal } from "../src-ext/core/scanQrRenderer.mjs";
68
80
  import { buildScanDeeplink, renderScanQrPng } from "../src-ext/core/scanDeeplink.mjs";
69
81
 
70
- // Minimum Node.js 22+ required for the built-in WebSocket client and the
71
- // runtime surface we rely on for bridge/service behavior across platforms.
82
+ // Keep this aligned with Desktop's embedded CLI compatibility floor.
83
+ const MIN_SUPPORTED_NODE_MAJOR = 18;
72
84
  {
73
85
  const major = Number.parseInt((process.versions?.node ?? "0").split(".")[0], 10);
74
- if (major < 22) {
86
+ if (major < MIN_SUPPORTED_NODE_MAJOR) {
75
87
  process.stderr.write(
76
- `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`,
77
89
  );
78
90
  process.exit(1);
79
91
  }
@@ -104,26 +116,139 @@ const CLI_COMMAND_NAME = "agentlink";
104
116
  const SERVICE_LABEL = "com.agentlink.bridge";
105
117
  const SERVICE_UNIT_NAME = "agentlink-bridge.service";
106
118
  const SERVICE_TASK_NAME = "Agentlink Bridge";
119
+ // 非默认状态根(例如 Desktop Debug 的 ~/.agentlink-debug)必须有独立服务名,
120
+ // 否则会覆盖正式 ~/.agentlink 的 launchd/systemd/schtasks 服务。
121
+ const SERVICE_SCOPE_HASH_LENGTH = 12;
107
122
  const RUNTIME_KIND_OPENCLAW = "openclaw";
108
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
+ );
109
175
  // scan-capable runtimes (unify-scan-pairing): openclaw/hermes read their gateway
110
176
  // credentials locally (~/.agentlink/config.json via ensureRelayBridgeCredentials)
111
177
  // and can host-claim immediately. claude/codex credentials are issued by the
112
178
  // server at consume-time — no host-claim path exists for them this cycle (see
113
179
  // spec "术语" + Non-goals; S2b covers the explicit unsupported-runtime notice).
180
+ // 🔴 opencode **不得**加进来:它没有本机 host-claim 凭证路径,走 pair 路径。
114
181
  const SCAN_CAPABLE_RUNTIME_KINDS = Object.freeze([RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES]);
115
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
+ }
234
+
116
235
  function serviceNames(runtimeKind) {
117
236
  const kind = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
237
+ const scope = serviceScopeSuffix();
118
238
  if (kind === RUNTIME_KIND_OPENCLAW) {
119
- 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
+ };
120
245
  }
121
246
  const capitalized = kind.charAt(0).toUpperCase() + kind.slice(1);
122
247
  return {
123
- label: `${SERVICE_LABEL}-${kind}`,
124
- unit: `agentlink-bridge-${kind}.service`,
125
- task: `${SERVICE_TASK_NAME} ${capitalized}`,
126
- 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}`,
127
252
  };
128
253
  }
129
254
  // Mirror the WS bridge's operator scopes for local HTTP proxy calls as well.
@@ -367,7 +492,21 @@ const CLI_I18N = {
367
492
  pair_all_detecting_runtimes: "\n检测本机已安装的 AI runtime ...",
368
493
  pair_all_runtime_installed_extra: "({detail})",
369
494
  pair_all_runtime_not_installed: " · {kind} 未安装,跳过{hint}",
370
- 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。",
371
510
  pair_all_host_inventory_report_failed: " ⚠️ host-inventory 上报失败 (HTTP {status}),继续配对 ...",
372
511
  pair_all_host_inventory_report_error: " ⚠️ host-inventory 上报异常 ({error}),继续配对 ...",
373
512
  pair_all_host_inventory_prepare_failed: " ⚠️ host-inventory 准备失败 ({error})",
@@ -395,6 +534,8 @@ const CLI_I18N = {
395
534
  reset_all_detecting_runtimes: "\n检测本机已安装的 AI runtime(逐个解绑)...",
396
535
  reset_all_no_runtimes: "本机未检测到任何已安装 runtime,也无可解绑的本地残留。",
397
536
  reset_all_runtime_state_only: " ⚠ {kind} 未安装,但检测到本地绑定残留,将清理",
537
+ // 已装、但本轮没有配对实现 → 不可能存在可解绑的绑定,不进解绑目标集。
538
+ reset_all_runtime_no_binding: " · {kind} 已安装但本版暂无配对实现,无可解绑绑定,跳过",
398
539
  reset_all_resetting_runtime: "\n→ 正在解绑 {kind} ...",
399
540
  reset_all_runtime_reset_failed: " ⚠️ {kind} 解绑失败 (退出码 {status}),继续下一个 ...",
400
541
  reset_all_summary_header: "\n──────────────── 解绑结果 ────────────────",
@@ -612,7 +753,21 @@ const CLI_I18N = {
612
753
  pair_all_detecting_runtimes: "\nDetecting installed AI runtimes on this host ...",
613
754
  pair_all_runtime_installed_extra: " ({detail})",
614
755
  pair_all_runtime_not_installed: " · {kind} not installed, skipping{hint}",
615
- 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.",
616
771
  pair_all_host_inventory_report_failed: " ⚠️ host-inventory report failed (HTTP {status}), continuing pairing ...",
617
772
  pair_all_host_inventory_report_error: " ⚠️ host-inventory report error ({error}), continuing pairing ...",
618
773
  pair_all_host_inventory_prepare_failed: " ⚠️ host-inventory prepare failed ({error})",
@@ -640,6 +795,7 @@ const CLI_I18N = {
640
795
  reset_all_detecting_runtimes: "\nDetecting installed AI runtimes on this host (unbind each) ...",
641
796
  reset_all_no_runtimes: "No installed runtime and no local binding trace on this host; nothing to unbind.",
642
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",
643
799
  reset_all_resetting_runtime: "\n→ Resetting {kind} ...",
644
800
  reset_all_runtime_reset_failed: " ⚠️ {kind} reset failed (exit {status}), continuing ...",
645
801
  reset_all_summary_header: "\n──────────────── Reset Results ────────────────",
@@ -781,12 +937,15 @@ function printHelp() {
781
937
  "",
782
938
  t("help_usage"),
783
939
  ` ${CLI_COMMAND_NAME} setup [--relay <url>] [--public-base <url>] [--mode <relay>] [--account <id>] [--restart|--no-restart] [--strict-plugin] [--json]`,
784
- ` ${CLI_COMMAND_NAME} pair <4-digit|6-digit|8-alnum> [-r|--runtime <openclaw|hermes|claude|codex|all>] [--mode <relay>] [--strict-plugin] # default: all`,
785
- ` ${CLI_COMMAND_NAME} reset [-r|--runtime <openclaw|hermes|claude|codex|all>] [--relay <url>] [--gateway <url>] [--json] # default: all`,
786
- ` ${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]`,
787
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`,
788
947
  ` ${CLI_COMMAND_NAME} service <status|install|stop|uninstall|restart> [--relay <url>] [--gateway <url>] [--runtime <openclaw|hermes>] [--json]`,
789
- ` ${CLI_COMMAND_NAME} reload [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
948
+ ` ${CLI_COMMAND_NAME} reload [-r|--runtime <${BRIDGE_CAPABLE_RUNTIME_KINDS.join("|")}>] [--json]`,
790
949
  ` ${CLI_COMMAND_NAME} pair-url [<4-digit|6-digit|8-alnum>] [--account <id>] [--code <4-digit|6-digit|8-alnum>] [--json]`,
791
950
  ` ${CLI_COMMAND_NAME} tunnel <on|off> <port> [--relay <url>] [--ws|--tcp] [--json]`,
792
951
  ` ${CLI_COMMAND_NAME} tunnel ls [--json]`,
@@ -851,6 +1010,9 @@ function parseArgs(argv) {
851
1010
  mcpRuntime: undefined,
852
1011
  noServe: false,
853
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,
854
1016
  // Internal plumbing flag — not advertised in --help. Set by `runScan`'s
855
1017
  // per-runtime host-claim child spawn (`--pair-mode scan`), never expected
856
1018
  // from a human-typed `agentlink pair` invocation. See runPair()'s
@@ -937,6 +1099,14 @@ function parseArgs(argv) {
937
1099
  continue;
938
1100
  }
939
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
+ }
1109
+
940
1110
  if (token === "--relay") {
941
1111
  const value = args.shift();
942
1112
  if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--relay" }));
@@ -991,18 +1161,25 @@ function parseArgs(argv) {
991
1161
  const value = args.shift();
992
1162
  if (!value || value.startsWith("--")) fail(`missing value for ${token}`);
993
1163
  const raw = value.trim().toLowerCase();
994
- // 扩展:claude / codex 走新 agentlink-agent(shim 在 main() 做);
995
- // all pair 命令支持,逐个 runtime 串行配对;
996
- // 其余还走老 normalizeRuntimeKind。
997
- 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)) {
998
1178
  options.runtime = raw;
999
1179
  continue;
1000
1180
  }
1001
- const normalizedRuntime = normalizeRuntimeKind(raw, "__invalid__");
1002
- if (normalizedRuntime !== RUNTIME_KIND_OPENCLAW && normalizedRuntime !== RUNTIME_KIND_HERMES) {
1003
- fail(`${token} must be openclaw / hermes / claude / codex / all`);
1004
- }
1005
- options.runtime = normalizedRuntime;
1181
+ // 取值域文案由常量渲染 —— runtime 时无需再改这行(防两处真相漂移)。
1182
+ fail(`${token} must be one of ${KNOWN_RUNTIME_KINDS.join(" / ")} / all`);
1006
1183
  continue;
1007
1184
  }
1008
1185
  if (token === "--hermes-python") {
@@ -1216,8 +1393,8 @@ function resolveAgentlinkStatePath() {
1216
1393
  // reset 用:某 runtime 是否有「可解绑的本地痕迹」(已配对的凭证 / state 文件)。
1217
1394
  // 与「二进制是否安装」解耦 —— 卸了 CLI 但 binding 还在的 runtime 仍须能被 reset
1218
1395
  // 清掉(否则 server 端绑定 + 本地 state 永久泄漏)。读 reset 实际操作的同一批文件:
1219
- // openclaw / hermes → ~/.agentlink/config.json(relayGatewayId / hermesRelayGatewayId)
1220
- // 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)
1221
1398
  function runtimeHasLocalState(kind) {
1222
1399
  // 1) 残留的 bridge service 也算「有痕迹」—— 凭证 id 已被上一次部分 reset 清掉、
1223
1400
  // 但 service 还在的孤儿,必须仍纳入 reset 清理,否则后台服务空转、卸不掉。
@@ -1225,9 +1402,19 @@ function runtimeHasLocalState(kind) {
1225
1402
  // linux ~/.config/systemd/user),**不**走 `launchctl print`/`systemctl --user`
1226
1403
  // 那种用户级全局探测 —— 否则 reset 的扇出决策会依赖跨 HOME 的全局服务状态,
1227
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 凭证文件,够用且不会串台。
1228
1413
  try {
1229
- if (process.platform === "darwin" && fs.existsSync(resolveMacLaunchAgentPath(kind))) return true;
1230
- 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
+ }
1231
1418
  } catch { /* 文件探测失败不影响下面的凭证判定 */ }
1232
1419
  try {
1233
1420
  if (kind === RUNTIME_KIND_OPENCLAW || kind === RUNTIME_KIND_HERMES) {
@@ -1236,7 +1423,7 @@ function runtimeHasLocalState(kind) {
1236
1423
  const idKey = kind === RUNTIME_KIND_OPENCLAW ? "relayGatewayId" : "hermesRelayGatewayId";
1237
1424
  return Boolean(String(cfg[idKey] ?? "").trim());
1238
1425
  }
1239
- if (kind === "claude" || kind === "codex") {
1426
+ if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
1240
1427
  // ext state(ext-<rt>-last.json)由 pairCodeClient.stateDir() 定位,现已统一
1241
1428
  // 优先 AGENTLINK_HOME(见该文件注释),与此处 resolveAgentlinkHome() 一致 ——
1242
1429
  // 探测与实际清理读写同一份文件,无 AGENTLINK_HOME 分叉。
@@ -1249,7 +1436,15 @@ function runtimeHasLocalState(kind) {
1249
1436
  }
1250
1437
 
1251
1438
  function resolveMatecliRuntimeDir() {
1252
- 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)}`;
1253
1448
  }
1254
1449
 
1255
1450
  function resolveBridgeLogsDir() {
@@ -2690,6 +2885,34 @@ function ensureRelayBridgeCredentials({
2690
2885
  }
2691
2886
  }
2692
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
+
2693
2916
  if (changes.length > 0) {
2694
2917
  writeJsonFile(resolveAgentlinkStatePath(), section);
2695
2918
  }
@@ -3438,6 +3661,21 @@ function writeBridgeLaunchScript(installConfig) {
3438
3661
  const appData =
3439
3662
  (process.env.APPDATA ?? "").trim() ||
3440
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
+ );
3441
3679
  const script = [
3442
3680
  "@echo off",
3443
3681
  `set "HOME=${capturedHome}"`,
@@ -3445,6 +3683,7 @@ function writeBridgeLaunchScript(installConfig) {
3445
3683
  `set "APPDATA=${appData}"`,
3446
3684
  `set "OPENCLAW_HOME=${capturedOpenClawHome}"`,
3447
3685
  `set "AGENTLINK_HOME=${capturedAgentlinkHome}"`,
3686
+ `set "PATH=${defaultPath.replaceAll("%", "%%")};%PATH%"`,
3448
3687
  `"${installConfig.nodePath}" ${nodeArg}`,
3449
3688
  "",
3450
3689
  ].join("\r\n");
@@ -4252,6 +4491,7 @@ async function publishPairToRelay({
4252
4491
  ttlSeconds = 600,
4253
4492
  }) {
4254
4493
  const url = `${relayUrl}/v1/pair-sessions`;
4494
+ const normalizedRuntime = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
4255
4495
  const headers = {
4256
4496
  "content-type": "application/json",
4257
4497
  ...relayAuthHeaders(),
@@ -4261,7 +4501,7 @@ async function publishPairToRelay({
4261
4501
  headers,
4262
4502
  body: JSON.stringify({
4263
4503
  code,
4264
- runtimeKind: normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW),
4504
+ runtimeKind: normalizedRuntime,
4265
4505
  transportMode: normalizeChatTransportMode(transportMode, "relay"),
4266
4506
  ...(clientUserId ? { clientUserId } : {}),
4267
4507
  bindUrl,
@@ -4274,6 +4514,10 @@ async function publishPairToRelay({
4274
4514
  ttlSeconds,
4275
4515
  source: "agentlink-cli",
4276
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),
4277
4521
  ...(pairMode ? { pairMode } : {}),
4278
4522
  }),
4279
4523
  });
@@ -5496,11 +5740,12 @@ async function handleUnifiedRunDispatch({
5496
5740
  gatewayAuthToken,
5497
5741
  bridgeToken,
5498
5742
  defaultModel,
5743
+ openclawConfig,
5499
5744
  request,
5500
5745
  json: jsonMode,
5501
5746
  }) {
5502
5747
  const inner = request?.request ?? {};
5503
- const { thread_id, run_id, input, blocks: rawBlocks = [] } = inner;
5748
+ const { thread_id, run_id, input, blocks: rawBlocks = [], workspace_path } = inner;
5504
5749
 
5505
5750
  if (!thread_id || !run_id) {
5506
5751
  if (!jsonMode) {
@@ -5607,6 +5852,28 @@ async function handleUnifiedRunDispatch({
5607
5852
  }
5608
5853
  }
5609
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
+
5610
5877
  // bridge-auto-tunnel: chat-side `/tunnel ls` / `/tunnel off <port>` 拦截。
5611
5878
  // 命中 → 直接 reply bridge-origin text block + run.completed,跳过本地
5612
5879
  // OpenClaw 网关派发。失败 / 未命中 → 兜底 pass-through 继续走 runtime。
@@ -5700,7 +5967,6 @@ async function handleUnifiedRunDispatch({
5700
5967
  // unify-scan-pairing 全量移除了上一代扫码硬件的命名空间归一 hack:扫码路径
5701
5968
  // 现在与 pair code 共用同一套真实 runtime binding(openclaw/hermes),不再有
5702
5969
  // App 以合成命名空间 agent 绑定的场景,agentId 直接原样使用。
5703
- const agentId = String(inner.agent_id || request.agentId || "main").trim() || "main";
5704
5970
  const headers = {
5705
5971
  "content-type": "application/json",
5706
5972
  "accept": "text/event-stream",
@@ -6396,6 +6662,7 @@ async function runRelayBridge({
6396
6662
  gatewayAuthToken,
6397
6663
  bridgeToken: gatewayToken, // server lookupBridgeToken accepts relay_gateway_token
6398
6664
  defaultModel: bridgeMeta?.openclawConfig?.agents?.defaults?.model?.primary || null,
6665
+ openclawConfig: bridgeMeta?.openclawConfig || null,
6399
6666
  request,
6400
6667
  json,
6401
6668
  });
@@ -6481,7 +6748,10 @@ function formatServiceStatusOutput(result) {
6481
6748
  .join("\n");
6482
6749
  }
6483
6750
 
6484
- // 探测本机已安装的 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。
6485
6755
  // 设计:每项探测要快(≤3s),失败时给出可读 hint。
6486
6756
  // opts.probeVersion=false:只判 installed(which / 文件存在),跳过 `--version`
6487
6757
  // 子进程探测 —— reset 不需要版本号,省去 hermes 最长 60s 的 ls-remote 宽限延迟。
@@ -6518,7 +6788,11 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6518
6788
  });
6519
6789
  }
6520
6790
 
6521
- // 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)。
6522
6796
  //
6523
6797
  // hermes = Nous Research `hermes-agent`(https://github.com/nousresearch/hermes-agent),
6524
6798
  // 装好后会在 PATH 暴露 `hermes` 命令,`hermes --version` 首行形如
@@ -6536,27 +6810,37 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6536
6810
  // (banner.py:140 timeout=10s),网络慢时整条 spawnSync 会 ETIMEDOUT 被
6537
6811
  // 上层误判"未安装";现在不依赖它。
6538
6812
  // ② 拿到 binary 后再跑 `--version` 取版本字符串,超时不影响 installed 判断;
6539
- // hermes 给 60s 宽限(GitHub ls-remote + git fetch 可能慢),claude/codex
6540
- // 是 native 二进制保留 10s。
6541
- 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) {
6542
6816
  const whichCmd = process.platform === "win32" ? "where" : "which";
6543
- const which = spawnSync(whichCmd, [bin], {
6544
- encoding: "utf8", timeout: 2000, windowsHide: true,
6545
- });
6546
- const binPath = (!which.error && which.status === 0)
6547
- ? String(which.stdout ?? "").trim().split(/\r?\n/)[0]
6548
- : "";
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
+ }
6549
6833
  if (!binPath) {
6550
6834
  const code = which.error?.code || "";
6551
6835
  const hint = code && code !== "ENOENT"
6552
6836
  ? t("pair_all_hint_which_failed", { cmd: whichCmd, bin, code })
6553
6837
  : t("pair_all_hint_bin_not_in_path", { bin });
6554
- results.push({ kind: bin, installed: false, hint });
6838
+ results.push({ kind, installed: false, hint });
6555
6839
  continue;
6556
6840
  }
6557
6841
  if (!probeVersion) {
6558
6842
  // reset 路径:只需 installed 布尔,跳过 `--version`(省 hermes 最长 60s 宽限)。
6559
- results.push({ kind: bin, installed: true });
6843
+ results.push({ kind, installed: true });
6560
6844
  continue;
6561
6845
  }
6562
6846
  // ② 取版本(best-effort)。hermes 第一次跑会做 git ls-remote 检查更新,
@@ -6569,7 +6853,7 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6569
6853
  if (!probe.error && probe.status === 0) {
6570
6854
  version = String(probe.stdout ?? "").trim().split(/\r?\n/)[0] || "";
6571
6855
  }
6572
- 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") };
6573
6857
  if (!version) {
6574
6858
  // 记一下为啥没拿到版本,方便用户/我们之后定位
6575
6859
  const code = probe.error?.code;
@@ -6595,15 +6879,26 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
6595
6879
  // openclaw + hermes 的 pair state 都存在 ~/.agentlink/config.json:
6596
6880
  // openclaw → relayGatewayId/relayGatewayToken
6597
6881
  // hermes → hermesRelayGatewayId/hermesRelayGatewayToken
6598
- // claude/codex 存在 ~/.agentlink/ext-<runtime>-last.json。
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 行),所以这不是理论风险。
6599
6891
  //
6600
6892
  // 返回 { gw_id, token, relay_url } 或 null。
6601
6893
  function readLocalPairState(kind) {
6602
6894
  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`;
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`);
6607
6902
  if (!fs.existsSync(p)) return null;
6608
6903
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
6609
6904
  const gw = j?.gw_id || "";
@@ -6612,7 +6907,7 @@ function readLocalPairState(kind) {
6612
6907
  return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
6613
6908
  }
6614
6909
  if (kind === "openclaw" || kind === "hermes") {
6615
- const p = `${home}/.agentlink/config.json`;
6910
+ const p = path.join(agentlinkHome, "config.json");
6616
6911
  if (!fs.existsSync(p)) return null;
6617
6912
  let j;
6618
6913
  try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
@@ -6633,17 +6928,20 @@ function readLocalPairState(kind) {
6633
6928
  // 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
6634
6929
  function clearLocalPairState(kind) {
6635
6930
  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`;
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`);
6640
6938
  if (fs.existsSync(p)) {
6641
6939
  try { fs.unlinkSync(p); } catch {}
6642
6940
  }
6643
6941
  return;
6644
6942
  }
6645
6943
  if (kind === "openclaw" || kind === "hermes") {
6646
- const p = `${home}/.agentlink/config.json`;
6944
+ const p = path.join(agentlinkHome, "config.json");
6647
6945
  if (!fs.existsSync(p)) return;
6648
6946
  let j;
6649
6947
  try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
@@ -6739,7 +7037,8 @@ async function probeRelayBinding(state) {
6739
7037
 
6740
7038
  // 对应 runtime 的"重置后重新配对"命令提示。
6741
7039
  function resetHintFor(kind, code) {
6742
- if (kind === "claude" || kind === "codex") {
7040
+ // ext-agent 家族没有 reset 子命令,其"解绑" uninstall(见 forwardToAgent)。
7041
+ if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
6743
7042
  return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
6744
7043
  }
6745
7044
  // openclaw / hermes 走 reset 子命令
@@ -6759,9 +7058,12 @@ function spawnPairChildForRuntime({ argvTail, runtimeKind, env }) {
6759
7058
 
6760
7059
  // `agentlink pair <code>` 默认批量配对所有已安装 runtime。
6761
7060
  // 也兼容 `agentlink pair <code> --runtime all` 显式写法。
6762
- // 检测本机安装的 4 种 runtime(openclaw / hermes / claude / codex),
6763
- // 对每个已安装的 runtime 串行 spawn 一次自身(替换 --runtime <kind>),
7061
+ // 检测本机安装的 runtime(HOST_DETECTED_RUNTIME_KINDS),对每个「已安装 **且**
7062
+ // 本轮有配对实现」的 runtime 串行 spawn 一次自身(替换 --runtime <kind>),
6764
7063
  // 互不影响 fail(),最后汇总结果。
7064
+ // opencode 自 devagent-m-20260807-cli-runtime-opencode-pairable 起**已进扇出**
7065
+ // (它有了真配对实现)。已装但仍无配对实现的 runtime 才只进 host-inventory 上报、
7066
+ // 不进扇出 —— 见 runPairAll 内 pairTargetKinds 处注释。
6765
7067
  async function runPairAll(options) {
6766
7068
  if (!options.code || !String(options.code).trim()) {
6767
7069
  fail(t("pair_usage_missing"));
@@ -6783,7 +7085,21 @@ async function runPairAll(options) {
6783
7085
 
6784
7086
  const installedKinds = detected.filter((d) => d.installed).map((d) => d.kind);
6785
7087
  if (installedKinds.length === 0) {
6786
- 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 }));
6787
7103
  }
6788
7104
 
6789
7105
  // host-inventory side channel —— 在 spawn 子配对命令之前,先把本机装好的
@@ -6852,7 +7168,10 @@ async function runPairAll(options) {
6852
7168
  continue;
6853
7169
  }
6854
7170
  if (a === "--allow-rebind") {
6855
- continue; // 后面会显式加一次,去重
7171
+ // 刻意**不**转发给子进程:pair-all 的语义是「把没配对的都配上」,已配对的
7172
+ // 由下面的 probeRelayBinding 判定后跳过,不做覆盖兜底(见下一段注释)。
7173
+ // ⚠️ 注释曾写「后面会显式加一次」——那是早期实现,现已不再回注;别照着补回去。
7174
+ continue;
6856
7175
  }
6857
7176
  forwardedArgv.push(a);
6858
7177
  }
@@ -6872,7 +7191,7 @@ async function runPairAll(options) {
6872
7191
  // 已提到顶层(供 runScan 复用,见其上方注释),行为不变。
6873
7192
 
6874
7193
  const summary = [];
6875
- for (const kind of installedKinds) {
7194
+ for (const kind of pairTargetKinds) {
6876
7195
  const state = readLocalPairState(kind);
6877
7196
  if (state) {
6878
7197
  const probe = await probeRelayBinding(state);
@@ -6932,7 +7251,7 @@ async function runPairAll(options) {
6932
7251
  }
6933
7252
  }
6934
7253
 
6935
- // 汇总:4 行 max(4 种 runtime),一眼扫完。
7254
+ // 汇总:一行一类,一眼扫完。
6936
7255
  const newlyPaired = summary.filter((s) => s.ok && !s.skipped).map((s) => s.kind);
6937
7256
  const alreadyPaired = summary.filter((s) => s.skipped).map((s) => s.kind);
6938
7257
  const failList = summary.filter((s) => !s.ok);
@@ -6951,13 +7270,23 @@ async function runPairAll(options) {
6951
7270
  detail: failList.map((s) => `${s.kind}[exit ${s.status}]`).join("、"),
6952
7271
  }));
6953
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
+ }
6954
7279
  if (notInstalledList.length > 0) {
6955
7280
  console.log(t("pair_all_summary_not_installed", { count: notInstalledList.length, kinds: notInstalledList.join("、") }));
6956
7281
  }
6957
- 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) {
6958
7285
  console.log(t("pair_all_summary_ok_hint"));
6959
7286
  } else {
6960
- console.log(t("pair_all_summary_failure_hint"));
7287
+ // 本机只装了「已知但无配对实现」的 runtime(如只装了 opencode):
7288
+ // 一个都没配上,别报「回 App 看已配对的智能体」那种假成功。
7289
+ console.log(t("pair_all_summary_nothing_pairable"));
6961
7290
  }
6962
7291
  console.log("");
6963
7292
 
@@ -6986,13 +7315,19 @@ async function runResetAll(options) {
6986
7315
  // reset 不需要版本号 → 跳过 `--version` 探测(省 hermes 最长 60s 宽限)。
6987
7316
  const detected = detectInstalledRuntimes({ probeVersion: false });
6988
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));
6989
7324
  // 未安装但有本地痕迹(卸了 CLI / 删了 openclaw.json 但 binding 还在)的 runtime
6990
7325
  // 也要纳入清理。读 reset 实际操作的同一批文件(config.json / ext-<rt>-last.json)。
6991
- const ALL_KINDS = [RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES, "claude", "codex"];
7326
+ const ALL_KINDS = PAIRABLE_RUNTIME_KINDS;
6992
7327
  const stateOnlyKinds = ALL_KINDS.filter(
6993
- (k) => !installedKinds.includes(k) && runtimeHasLocalState(k),
7328
+ (k) => !installedPairableKinds.includes(k) && runtimeHasLocalState(k),
6994
7329
  );
6995
- const targetKinds = [...installedKinds, ...stateOnlyKinds];
7330
+ const targetKinds = [...installedPairableKinds, ...stateOnlyKinds];
6996
7331
  // 真正「无事可做」的 runtime(既没装、也没痕迹)才算 not-installed。
6997
7332
  const notInstalledList = detected
6998
7333
  .filter((d) => !d.installed && !stateOnlyKinds.includes(d.kind))
@@ -7000,7 +7335,9 @@ async function runResetAll(options) {
7000
7335
 
7001
7336
  if (!json) {
7002
7337
  for (const entry of detected) {
7003
- 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) {
7004
7341
  console.log(` ✓ ${entry.kind}`);
7005
7342
  } else if (stateOnlyKinds.includes(entry.kind)) {
7006
7343
  console.log(t("reset_all_runtime_state_only", { kind: entry.kind }));
@@ -7012,6 +7349,8 @@ async function runResetAll(options) {
7012
7349
  }
7013
7350
  log.info("reset_all.targets", "resolved reset targets", {
7014
7351
  installed: installedKinds, stateOnly: stateOnlyKinds, notInstalled: notInstalledList,
7352
+ // 已装但无配对实现 → 无绑定可解,不进目标集(诊断时能一眼看出为何被跳过)
7353
+ noBinding: noBindingKinds,
7015
7354
  });
7016
7355
 
7017
7356
  if (targetKinds.length === 0) {
@@ -8144,8 +8483,10 @@ async function startScanWorkers({ kinds, relayUrl, gateway, json }) {
8144
8483
  }
8145
8484
 
8146
8485
  async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictPlugin, runtime }) {
8147
- // S2b(显式 -r claude/codex):不出码、不联系 relay,直接提示 + 非零退出。
8148
- if (runtime === "claude" || runtime === "codex") {
8486
+ // S2b(显式 -r 到非 scan-capable 的 ext-agent runtime):不出码、不联系 relay
8487
+ // 直接提示 + 非零退出。(opencode 更早已被 assertRuntimeBridgeSupportedOrFail
8488
+ // 拦停;这里保留登记表判据作双保险。)
8489
+ if (EXT_AGENT_RUNTIME_KINDS.includes(runtime)) {
8149
8490
  const message = scanUnsupportedRuntimeMessage([runtime]);
8150
8491
  if (json) {
8151
8492
  printScanEvent({ phase: "unsupported_runtime", unsupportedRuntimes: [runtime], hint: message });
@@ -8171,14 +8512,19 @@ async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictP
8171
8512
  // ── 探测本机已装 runtime,划出「scan-capable 候选集」(S1/S2)───────────
8172
8513
  const detected = detectInstalledRuntimes();
8173
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 时不会漏提示。
8174
8518
  const unsupportedDetected = detected
8175
- .filter((d) => d.installed && (d.kind === "claude" || d.kind === "codex"))
8519
+ .filter((d) => d.installed && EXT_AGENT_RUNTIME_KINDS.includes(d.kind))
8176
8520
  .map((d) => d.kind);
8177
8521
 
8178
8522
  let candidateKinds;
8179
8523
  if (runtime) {
8180
8524
  // -r openclaw|hermes:只配这一个(S2)。校验值域已在 CLI 参数解析阶段完成
8181
- // (openclaw/hermes/claude/codex/all),claude/codex 已在上面提前返回。
8525
+ // (openclaw/hermes/claude/codex/opencode/deepagents/all);ext-agent 家族
8526
+ // (claude/codex/opencode)已在上面提前返回,enum-only 的 deepagents 与
8527
+ // pair-only 的 opencode 更早就被命令分派前的两道显式拦停挡下,走不到这里。
8182
8528
  if (!installedKinds.has(runtime)) {
8183
8529
  fail(`${CLI_COMMAND_NAME} scan -r ${runtime}: runtime not installed on this host`);
8184
8530
  }
@@ -8187,7 +8533,11 @@ async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictP
8187
8533
  candidateKinds = SCAN_CAPABLE_RUNTIME_KINDS.filter((k) => installedKinds.has(k));
8188
8534
  }
8189
8535
  if (candidateKinds.length === 0) {
8190
- fail(t("pair_all_no_runtimes"));
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(" / ") }));
8191
8541
  }
8192
8542
 
8193
8543
  if (unsupportedDetected.length > 0) {
@@ -8585,10 +8935,11 @@ async function runBridge({ json, relay, noRelay, gateway, runtime }) {
8585
8935
  }
8586
8936
 
8587
8937
  function runStatus({ json, runtime }) {
8588
- // claude / codex 不走 bin 的 openclaw/hermes 状态视图(normalizeRuntimeKind 会把
8938
+ // ext-agent runtime 不走 bin 的 openclaw/hermes 状态视图(normalizeRuntimeKind 会把
8589
8939
  // 它们静默回退成 openclaw,导致展示错 runtime 的状态)。显式拦下并指向正确命令,
8590
- // 而非静默误导。
8591
- if (runtime === "claude" || runtime === "codex") {
8940
+ // 而非静默误导。(opencode 更早已被 assertRuntimeBridgeSupportedOrFail 拦停,
8941
+ // 这里保留登记表判据是双保险 —— 两道闸的判据不同、都不能漏。)
8942
+ if (EXT_AGENT_RUNTIME_KINDS.includes(runtime)) {
8592
8943
  fail(t("status_ext_runtime_unsupported", { runtime, cli: CLI_COMMAND_NAME }));
8593
8944
  }
8594
8945
  const configPath = resolveOpenClawConfigPath();
@@ -8639,14 +8990,23 @@ function runStatus({ json, runtime }) {
8639
8990
  : null,
8640
8991
  };
8641
8992
 
8642
- // 四端汇总(不指定 -r 时也能一眼看全 openclaw/hermes/claude/codex 的配对 + 服务态),
8993
+ // 多端汇总(不指定 -r 时也能一眼看全**可配对** runtime 的配对 + 服务态),
8643
8994
  // 对称 pair/reset 的「无 -r = 全部」。paired 走 HOME 隔离的凭证判定;service 态走
8644
8995
  // 实际服务管理器(展示用途,反映真实情况)。best-effort,不让汇总失败影响主输出。
8645
- 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) => {
8646
9000
  let paired = false;
8647
9001
  try { paired = runtimeHasLocalState(k); } catch { /* ignore */ }
8648
9002
  let svc = { installed: false, running: false };
8649
- 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
+ }
8650
9010
  return { kind: k, paired, serviceInstalled: !!svc.installed, serviceRunning: !!svc.running };
8651
9011
  });
8652
9012
 
@@ -8867,6 +9227,15 @@ async function forwardToAgent(command, options) {
8867
9227
 
8868
9228
  async function main() {
8869
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
+ }
8870
9239
  // `agentlink pair <code>` / `agentlink reset` 默认分别为本机已安装的 4 种
8871
9240
  // runtime 各 spawn 一次(不写死默认 openclaw —— 四端互不依赖:没装 openclaw
8872
9241
  // 的机器也能整机 reset)。`--runtime all` 保持为兼容别名。
@@ -8882,13 +9251,26 @@ async function main() {
8882
9251
  if (command !== "pair" && command !== "reset" && options.runtime === "all") {
8883
9252
  fail(`--runtime all 仅 \`${CLI_COMMAND_NAME} pair\` / \`${CLI_COMMAND_NAME} reset\` 命令支持`);
8884
9253
  }
8885
- // External Agents (Claude / Codex) shim ——
8886
- // 统一把 `agentlink pair <code> -r claude/codex`、`bridge -r claude/codex`、
8887
- // `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
8888
9268
  // 平行世界代码污染老 bin,也防止 reset 被 normalizeRuntimeKind 回退成 openclaw。
9269
+ // 目标集用 EXT_AGENT_RUNTIME_KINDS(登记表),不再写死 claude/codex 字面量 ——
9270
+ // opencode 的凭证同样存 ext-<rt>-last.json,必须走同一条转发路径。
8889
9271
  if ((command === "pair" || command === "bridge" || command === "service" ||
8890
9272
  command === "uninstall" || command === "reset") &&
8891
- (options.runtime === "claude" || options.runtime === "codex")) {
9273
+ EXT_AGENT_RUNTIME_KINDS.includes(options.runtime)) {
8892
9274
  await forwardToAgent(command, options);
8893
9275
  return;
8894
9276
  }