libp2p-mesh 2026.6.7 → 2026.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -166,10 +166,19 @@ export default definePluginEntry({
166
166
  description: "P2P network for cross-instance agent communication via libp2p.",
167
167
  configSchema: createLibp2pMeshConfigSchema(),
168
168
  register: (api) => {
169
+ if (api.registrationMode === "cli-metadata" ||
170
+ api.registrationMode === "discovery" ||
171
+ api.registrationMode === "full") {
172
+ api.registerCli(registerLibp2pMeshCli, {
173
+ commands: ["libp2p-mesh"],
174
+ });
175
+ if (api.registrationMode === "cli-metadata") {
176
+ return;
177
+ }
178
+ }
179
+ if (api.registrationMode !== "full") {
180
+ return;
181
+ }
169
182
  registerLibp2pMesh(api);
170
- // 5. Register CLI commands (setup wizard + config management)
171
- api.registerCli(registerLibp2pMeshCli, {
172
- commands: ["libp2p-mesh"],
173
- });
174
183
  },
175
184
  });
@@ -16,7 +16,14 @@ export function registerLibp2pMesh(api) {
16
16
  const store = createInstancePeerStore({ logger: api.logger });
17
17
  const delivery = createOpenClawRuntimeInboundDelivery({
18
18
  config: api.config,
19
- loadAdapter: (channel) => api.runtime.channel.outbound.loadAdapter(channel),
19
+ loadAdapter: async (channel) => {
20
+ const loadAdapter = api.runtime?.channel?.outbound?.loadAdapter;
21
+ if (typeof loadAdapter !== "function") {
22
+ api.logger.warn?.(`[libp2p-mesh] runtime channel outbound adapter is unavailable; cannot deliver to ${channel}`);
23
+ return undefined;
24
+ }
25
+ return loadAdapter(channel);
26
+ },
20
27
  logger: api.logger,
21
28
  });
22
29
  const router = createInstanceRouter({
@@ -19,19 +19,22 @@ export function validateMultiaddr(raw) {
19
19
  function isTTY() {
20
20
  return Boolean(process.stdout.isTTY && process.stdin.isTTY);
21
21
  }
22
+ function supportsInteractiveSelect() {
23
+ return isTTY() && process.platform !== "win32";
24
+ }
22
25
  /**
23
- * Move cursor up `n` lines using ANSI escape (no-op on dumb terminals).
26
+ * Clear entire current line: carriage-return to col 0, then erase to end.
24
27
  */
25
- function ansiUp(n) {
26
- if (n <= 0)
27
- return;
28
- process.stdout.write(`\x1b[${n}A`);
28
+ function clearLine() {
29
+ process.stdout.write("\r\x1b[K");
29
30
  }
30
31
  /**
31
- * Clear current line from cursor to end.
32
+ * Move cursor up `n` lines.
32
33
  */
33
- function ansiClearLine() {
34
- process.stdout.write("\x1b[K");
34
+ function cursorUp(n) {
35
+ if (n <= 0)
36
+ return;
37
+ process.stdout.write(`\x1b[${n}A`);
35
38
  }
36
39
  function renderChoices(choices, selectedIdx) {
37
40
  for (let i = 0; i < choices.length; i++) {
@@ -47,11 +50,16 @@ function renderChoices(choices, selectedIdx) {
47
50
  }
48
51
  }
49
52
  }
53
+ /**
54
+ * Re-render the choice list in-place. Assumes cursor is immediately below
55
+ * the last choice line.
56
+ */
50
57
  function reRenderChoices(choices, selectedIdx) {
51
58
  const n = choices.length;
52
- ansiUp(n);
59
+ // Jump back to the first choice line
60
+ cursorUp(n);
53
61
  for (let i = 0; i < n; i++) {
54
- ansiClearLine();
62
+ clearLine(); // \r\x1b[K -- always starts at col 0
55
63
  const c = choices[i];
56
64
  const pointer = i === selectedIdx ? "❯" : " ";
57
65
  const hint = c.hint ? `(${c.hint})` : "";
@@ -63,15 +71,20 @@ function reRenderChoices(choices, selectedIdx) {
63
71
  process.stdout.write(`${line}\n`);
64
72
  }
65
73
  }
74
+ // Cursor is now back below the last choice line — same position as before.
66
75
  }
67
- function clearChoices(count) {
68
- ansiUp(count);
76
+ /**
77
+ * Erase the choice list from the screen (used after selection).
78
+ * Assumes cursor is immediately below the last choice line.
79
+ */
80
+ function eraseChoices(count) {
81
+ cursorUp(count);
69
82
  for (let i = 0; i < count; i++) {
70
- ansiClearLine();
83
+ clearLine();
71
84
  process.stdout.write("\n");
72
85
  }
73
- // Now we're back where the first choice was rendered
74
- ansiUp(count);
86
+ // Back to where the first choice was
87
+ cursorUp(count);
75
88
  }
76
89
  function interactiveSelect(prompt, choices) {
77
90
  let selectedIdx = 0;
@@ -83,11 +96,38 @@ function interactiveSelect(prompt, choices) {
83
96
  const wasRaw = process.stdin.isRaw;
84
97
  const wasPaused = process.stdin.isPaused();
85
98
  let resolved = false;
99
+ const onKeypress = (_str, key) => {
100
+ if (!key || !key.name)
101
+ return;
102
+ if (key.name === "up" || key.name === "k") {
103
+ selectedIdx =
104
+ (selectedIdx - 1 + choices.length) % choices.length;
105
+ reRenderChoices(choices, selectedIdx);
106
+ }
107
+ else if (key.name === "down" || key.name === "j") {
108
+ selectedIdx = (selectedIdx + 1) % choices.length;
109
+ reRenderChoices(choices, selectedIdx);
110
+ }
111
+ else if (key.name === "return" || key.name === "space") {
112
+ resolved = true;
113
+ const chosen = choices[selectedIdx];
114
+ cleanup();
115
+ eraseChoices(choices.length);
116
+ process.stdout.write(` → ${chosen.label}\n`);
117
+ resolve(chosen.value);
118
+ }
119
+ else if (key.ctrl && key.name === "c") {
120
+ cleanup();
121
+ process.stdout.write("\n");
122
+ process.exit(0);
123
+ }
124
+ };
86
125
  const cleanup = () => {
87
126
  if (resolved)
88
127
  return;
89
128
  try {
90
- process.stdin.setRawMode(wasRaw ?? false);
129
+ // Always restore to non-raw so subsequent readline works
130
+ process.stdin.setRawMode(false);
91
131
  }
92
132
  catch {
93
133
  // best-effort restore
@@ -111,38 +151,11 @@ function interactiveSelect(prompt, choices) {
111
151
  process.stdin.setRawMode(true);
112
152
  }
113
153
  catch {
114
- // setRawMode may fail on some terminals; clean up and fall back
115
154
  cleanup();
116
155
  fallbackNumberedSelect(prompt, choices).then(resolve);
117
156
  return;
118
157
  }
119
158
  process.stdin.resume();
120
- const onKeypress = (_str, key) => {
121
- if (!key || !key.name)
122
- return;
123
- if (key.name === "up" || key.name === "k") {
124
- selectedIdx =
125
- (selectedIdx - 1 + choices.length) % choices.length;
126
- reRenderChoices(choices, selectedIdx);
127
- }
128
- else if (key.name === "down" || key.name === "j") {
129
- selectedIdx = (selectedIdx + 1) % choices.length;
130
- reRenderChoices(choices, selectedIdx);
131
- }
132
- else if (key.name === "return" || key.name === "space") {
133
- resolved = true;
134
- const chosen = choices[selectedIdx];
135
- cleanup();
136
- clearChoices(choices.length);
137
- process.stdout.write(` → ${chosen.label}\n`);
138
- resolve(chosen.value);
139
- }
140
- else if (key.ctrl && key.name === "c") {
141
- cleanup();
142
- process.stdout.write("\n");
143
- process.exit(0);
144
- }
145
- };
146
159
  process.stdin.on("keypress", onKeypress);
147
160
  });
148
161
  }
@@ -283,7 +296,7 @@ export function createReadlinePrompter() {
283
296
  : choices;
284
297
  // Release readline so it doesn't fight over stdin in raw mode
285
298
  disposeRL();
286
- const result = isTTY()
299
+ const result = supportsInteractiveSelect()
287
300
  ? await interactiveSelect(prompt, allChoices)
288
301
  : await fallbackNumberedSelect(prompt, allChoices);
289
302
  if (result === SENTINEL_FINISH)
@@ -364,10 +377,7 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
364
377
  "我们将引导你完成 P2P Mesh 网络的基础配置。",
365
378
  "任何时候按 Ctrl+C 可退出,配置不会被保存。",
366
379
  ]);
367
- // =================================================================
368
- // Layer 1: Core Path
369
- // =================================================================
370
- // Step 1: Discovery mode (select throws WizardFinishError on Finish)
380
+ // Step 1: Discovery mode
371
381
  const discovery = await prompter.select("选择节点发现方式:", [
372
382
  {
373
383
  value: "mdns",
@@ -384,15 +394,13 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
384
394
  label: "DHT — Kademlia 分布式发现",
385
395
  hint: "需要至少一个 bootstrap 入口",
386
396
  },
387
- ], { includeSkip: true });
388
- if (!isSkip(discovery)) {
389
- config.discovery = discovery;
390
- // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
391
- if (discovery === "bootstrap" || discovery === "dht") {
392
- const addrs = await prompter.multiline("输入 Bootstrap 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
393
- if (addrs.length > 0) {
394
- config.bootstrapList = addrs;
395
- }
397
+ ]);
398
+ config.discovery = discovery;
399
+ // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
400
+ if (discovery === "bootstrap" || discovery === "dht") {
401
+ const addrs = await prompter.multiline("输入 Bootstrap 节点地址(每行一个,空行结束,直接回车跳过):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
402
+ if (addrs.length > 0) {
403
+ config.bootstrapList = addrs;
396
404
  }
397
405
  }
398
406
  // Step 3: Inbound targets
@@ -400,116 +408,95 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
400
408
  prompter.displayWarning("未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。");
401
409
  }
402
410
  else {
403
- const wantInbound = await prompter.confirm("是否配置 P2P 消息的接收目标?", false, { allowSkip: true });
404
- assertNotFinished(wantInbound);
405
- if (wantInbound === true) {
406
- const targets = [];
407
- let addMore = true;
408
- while (addMore) {
409
- const channelChoices = availableChannels.map((ch) => ({
411
+ const targets = [];
412
+ while (true) {
413
+ const doneLabel = targets.length > 0
414
+ ? "完成接收目标配置"
415
+ : hasInboundConfig(currentConfig)
416
+ ? "保留现有接收目标并继续"
417
+ : "暂不配置接收目标";
418
+ const channelChoices = [
419
+ ...availableChannels.map((ch) => ({
410
420
  value: ch,
411
421
  label: ch,
412
- }));
413
- // select throws WizardFinishError on Finish
414
- const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices, { includeSkip: true });
415
- if (isSkip(channel))
416
- break;
417
- const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`, undefined, { allowSkip: true });
418
- assertNotFinished(target);
419
- if (isSkip(target))
420
- break;
421
- if (target) {
422
- targets.push({ channel, target });
423
- }
424
- const more = await prompter.confirm("是否添加更多接收目标?", false);
425
- assertNotFinished(more);
426
- addMore = more ?? false;
422
+ })),
423
+ {
424
+ value: SENTINEL_SKIP,
425
+ label: doneLabel,
426
+ },
427
+ ];
428
+ const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices);
429
+ if (channel === SENTINEL_SKIP)
430
+ break;
431
+ const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx,直接回车跳过当前频道):`, undefined, { allowSkip: true });
432
+ assertNotFinished(target);
433
+ if (isSkip(target)) {
434
+ continue;
427
435
  }
428
- if (targets.length > 0) {
429
- if (targets.length === 1 &&
430
- !currentConfig.inboundChannel &&
431
- !currentConfig.inboundTarget) {
432
- // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
433
- config.inboundChannel = targets[0].channel;
434
- config.inboundTarget = targets[0].target;
435
- }
436
- config.inboundTargets = targets;
436
+ if (target) {
437
+ targets.push({ channel, target });
437
438
  }
438
439
  }
439
- // wantInbound === null (skip) or false (no) → skip inbound config
440
- }
441
- // Step 4: Preview core config and confirm
442
- const corePreview = formatConfigPreview(config);
443
- prompter.displayBox("即将写入以下配置:", corePreview);
444
- const coreConfirmed = await prompter.confirm("确认写入?", true);
445
- if (!coreConfirmed) {
446
- prompter.displayWarning("已取消,配置未保存。");
447
- throw new WizardCancelledError();
440
+ if (targets.length > 0) {
441
+ applyInboundTargets(config, targets);
442
+ }
448
443
  }
449
- // =================================================================
450
- // Layer 2: Advanced (optional)
451
- // =================================================================
452
- const wantsAdvanced = await prompter.confirm("需要在不同网络之间使用吗(跨 WiFi / 跨城市)?", false, { allowSkip: true });
453
- assertNotFinished(wantsAdvanced);
454
- if (wantsAdvanced === true) {
455
- // Fixed port
456
- const wantFixedPort = await prompter.confirm("是否使用固定监听端口?(推荐跨网络场景)", false, { allowSkip: true });
457
- assertNotFinished(wantFixedPort);
458
- if (wantFixedPort === true) {
459
- const port = await prompter.question("端口号", "4001", {
460
- allowSkip: true,
461
- });
462
- assertNotFinished(port);
463
- if (!isSkip(port)) {
464
- const portNum = parseInt(port, 10);
465
- if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
466
- config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
467
- }
468
- else {
469
- prompter.displayWarning("端口号无效,使用默认动态端口。");
470
- }
444
+ // Step 4: Network profile
445
+ const networkMode = await prompter.select("选择网络使用场景:", [
446
+ {
447
+ value: "lan",
448
+ label: "局域网 / 默认配置",
449
+ hint: "同一 WiFi 或内网使用",
450
+ },
451
+ {
452
+ value: "wan",
453
+ label: "跨网络 / 高级配置",
454
+ hint: " WiFi、跨城市或需要固定入口",
455
+ },
456
+ ]);
457
+ if (networkMode === "wan") {
458
+ const port = await prompter.question("固定监听端口(直接回车跳过,使用动态端口)", "4001", { allowSkip: true });
459
+ assertNotFinished(port);
460
+ if (!isSkip(port)) {
461
+ const portNum = parseInt(port, 10);
462
+ if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
463
+ config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
471
464
  }
472
- }
473
- // NAT traversal
474
- const wantNAT = await prompter.confirm("是否启用 NAT 穿透?(默认开启,推荐保留)", true, { allowSkip: true });
475
- assertNotFinished(wantNAT);
476
- if (wantNAT !== null) {
477
- config.enableNATTraversal = wantNAT;
478
- }
479
- // Circuit Relay
480
- const wantRelay = await prompter.confirm("需要配置 Circuit Relay 中继节点吗?", false, { allowSkip: true });
481
- assertNotFinished(wantRelay);
482
- if (wantRelay === true) {
483
- const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
484
- if (relays.length > 0) {
485
- config.relayList = relays;
465
+ else {
466
+ prompter.displayWarning("端口号无效,使用默认动态端口。");
486
467
  }
487
468
  }
488
- // Custom instance name
489
- const wantName = await prompter.confirm("为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)", false, { allowSkip: true });
490
- assertNotFinished(wantName);
491
- if (wantName === true) {
492
- const name = await prompter.question("节点名称", undefined, {
493
- allowSkip: true,
494
- });
495
- assertNotFinished(name);
496
- if (!isSkip(name) && name) {
497
- config.instanceName = name;
498
- }
469
+ const natTraversal = await prompter.select("NAT 穿透:", [
470
+ {
471
+ value: "true",
472
+ label: "启用",
473
+ hint: "默认,推荐保留",
474
+ },
475
+ {
476
+ value: "false",
477
+ label: "禁用",
478
+ hint: "仅在明确需要时关闭",
479
+ },
480
+ ]);
481
+ config.enableNATTraversal = natTraversal === "true";
482
+ const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束,直接回车跳过):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
483
+ if (relays.length > 0) {
484
+ config.relayList = relays;
499
485
  }
500
- // Final preview and confirm (only if advanced config changed)
501
- if (wantFixedPort === true ||
502
- wantRelay === true ||
503
- wantName === true ||
504
- (wantNAT !== null && !config.enableNATTraversal)) {
505
- const finalPreview = formatConfigPreview(config);
506
- prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
507
- const finalConfirmed = await prompter.confirm("确认写入?", true);
508
- if (!finalConfirmed) {
509
- prompter.displayWarning("已取消高级配置,核心配置已保存。");
510
- }
486
+ const name = await prompter.question("节点名称(直接回车跳过)", undefined, { allowSkip: true });
487
+ assertNotFinished(name);
488
+ if (!isSkip(name) && name) {
489
+ config.instanceName = name;
511
490
  }
512
491
  }
492
+ // Step 5: Final preview and confirm
493
+ const finalPreview = formatConfigPreview(config);
494
+ prompter.displayBox("即将写入以下配置:", finalPreview);
495
+ const confirmed = await prompter.confirm("确认写入?", true);
496
+ if (!confirmed) {
497
+ prompter.displayWarning("已取消,配置未保存。");
498
+ throw new WizardCancelledError();
499
+ }
513
500
  prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
514
501
  return config;
515
502
  }
@@ -542,6 +529,23 @@ export class WizardFinishError extends Error {
542
529
  }
543
530
  }
544
531
  // --- Helpers ---
532
+ function hasInboundConfig(config) {
533
+ return ((Array.isArray(config.inboundTargets) && config.inboundTargets.length > 0) ||
534
+ (Boolean(config.inboundChannel) && Boolean(config.inboundTarget)));
535
+ }
536
+ function applyInboundTargets(config, targets) {
537
+ delete config.inboundTargets;
538
+ delete config.inboundChannel;
539
+ delete config.inboundTarget;
540
+ if (targets.length === 0) {
541
+ return;
542
+ }
543
+ config.inboundTargets = targets;
544
+ if (targets.length === 1) {
545
+ config.inboundChannel = targets[0].channel;
546
+ config.inboundTarget = targets[0].target;
547
+ }
548
+ }
545
549
  function formatConfigPreview(config) {
546
550
  const lines = [];
547
551
  if (config.discovery) {
package/index.ts CHANGED
@@ -169,10 +169,23 @@ export default definePluginEntry({
169
169
  description: "P2P network for cross-instance agent communication via libp2p.",
170
170
  configSchema: createLibp2pMeshConfigSchema(),
171
171
  register: (api) => {
172
+ if (
173
+ api.registrationMode === "cli-metadata" ||
174
+ api.registrationMode === "discovery" ||
175
+ api.registrationMode === "full"
176
+ ) {
177
+ api.registerCli(registerLibp2pMeshCli, {
178
+ commands: ["libp2p-mesh"],
179
+ });
180
+ if (api.registrationMode === "cli-metadata") {
181
+ return;
182
+ }
183
+ }
184
+
185
+ if (api.registrationMode !== "full") {
186
+ return;
187
+ }
188
+
172
189
  registerLibp2pMesh(api);
173
- // 5. Register CLI commands (setup wizard + config management)
174
- api.registerCli(registerLibp2pMeshCli, {
175
- commands: ["libp2p-mesh"],
176
- });
177
190
  },
178
191
  });
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "libp2p-mesh",
3
- "version": "2026.6.7",
3
+ "version": "2026.6.9",
4
4
  "description": "OpenClaw libp2p P2P mesh network plugin for cross-instance agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "scripts": {
9
9
  "build": "tsc -p tsconfig.json",
10
- "test": "node --import tsx --tSest test/*.test.ts",
10
+ "test": "node --import tsx --test test/*.test.ts",
11
11
  "prepack": "npm run build"
12
12
  },
13
13
  "files": [
package/src/plugin.ts CHANGED
@@ -20,7 +20,16 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
20
20
  const store = createInstancePeerStore({ logger: api.logger });
21
21
  const delivery = createOpenClawRuntimeInboundDelivery({
22
22
  config: api.config,
23
- loadAdapter: (channel: string) => api.runtime.channel.outbound.loadAdapter(channel),
23
+ loadAdapter: async (channel: string) => {
24
+ const loadAdapter = api.runtime?.channel?.outbound?.loadAdapter;
25
+ if (typeof loadAdapter !== "function") {
26
+ api.logger.warn?.(
27
+ `[libp2p-mesh] runtime channel outbound adapter is unavailable; cannot deliver to ${channel}`,
28
+ );
29
+ return undefined;
30
+ }
31
+ return loadAdapter(channel);
32
+ },
24
33
  logger: api.logger,
25
34
  });
26
35
  const router = createInstanceRouter({
package/src/wizard.ts CHANGED
@@ -57,19 +57,23 @@ function isTTY(): boolean {
57
57
  return Boolean(process.stdout.isTTY && process.stdin.isTTY);
58
58
  }
59
59
 
60
+ function supportsInteractiveSelect(): boolean {
61
+ return isTTY() && process.platform !== "win32";
62
+ }
63
+
60
64
  /**
61
- * Move cursor up `n` lines using ANSI escape (no-op on dumb terminals).
65
+ * Clear entire current line: carriage-return to col 0, then erase to end.
62
66
  */
63
- function ansiUp(n: number): void {
64
- if (n <= 0) return;
65
- process.stdout.write(`\x1b[${n}A`);
67
+ function clearLine(): void {
68
+ process.stdout.write("\r\x1b[K");
66
69
  }
67
70
 
68
71
  /**
69
- * Clear current line from cursor to end.
72
+ * Move cursor up `n` lines.
70
73
  */
71
- function ansiClearLine(): void {
72
- process.stdout.write("\x1b[K");
74
+ function cursorUp(n: number): void {
75
+ if (n <= 0) return;
76
+ process.stdout.write(`\x1b[${n}A`);
73
77
  }
74
78
 
75
79
  function renderChoices(
@@ -89,14 +93,19 @@ function renderChoices(
89
93
  }
90
94
  }
91
95
 
96
+ /**
97
+ * Re-render the choice list in-place. Assumes cursor is immediately below
98
+ * the last choice line.
99
+ */
92
100
  function reRenderChoices(
93
101
  choices: PromptChoice[],
94
102
  selectedIdx: number,
95
103
  ): void {
96
104
  const n = choices.length;
97
- ansiUp(n);
105
+ // Jump back to the first choice line
106
+ cursorUp(n);
98
107
  for (let i = 0; i < n; i++) {
99
- ansiClearLine();
108
+ clearLine(); // \r\x1b[K -- always starts at col 0
100
109
  const c = choices[i]!;
101
110
  const pointer = i === selectedIdx ? "❯" : " ";
102
111
  const hint = c.hint ? `(${c.hint})` : "";
@@ -107,16 +116,21 @@ function reRenderChoices(
107
116
  process.stdout.write(`${line}\n`);
108
117
  }
109
118
  }
119
+ // Cursor is now back below the last choice line — same position as before.
110
120
  }
111
121
 
112
- function clearChoices(count: number): void {
113
- ansiUp(count);
122
+ /**
123
+ * Erase the choice list from the screen (used after selection).
124
+ * Assumes cursor is immediately below the last choice line.
125
+ */
126
+ function eraseChoices(count: number): void {
127
+ cursorUp(count);
114
128
  for (let i = 0; i < count; i++) {
115
- ansiClearLine();
129
+ clearLine();
116
130
  process.stdout.write("\n");
117
131
  }
118
- // Now we're back where the first choice was rendered
119
- ansiUp(count);
132
+ // Back to where the first choice was
133
+ cursorUp(count);
120
134
  }
121
135
 
122
136
  function interactiveSelect(
@@ -137,10 +151,38 @@ function interactiveSelect(
137
151
 
138
152
  let resolved = false;
139
153
 
154
+ const onKeypress = (
155
+ _str: string | undefined,
156
+ key: { name?: string; ctrl?: boolean },
157
+ ) => {
158
+ if (!key || !key.name) return;
159
+
160
+ if (key.name === "up" || key.name === "k") {
161
+ selectedIdx =
162
+ (selectedIdx - 1 + choices.length) % choices.length;
163
+ reRenderChoices(choices, selectedIdx);
164
+ } else if (key.name === "down" || key.name === "j") {
165
+ selectedIdx = (selectedIdx + 1) % choices.length;
166
+ reRenderChoices(choices, selectedIdx);
167
+ } else if (key.name === "return" || key.name === "space") {
168
+ resolved = true;
169
+ const chosen = choices[selectedIdx]!;
170
+ cleanup();
171
+ eraseChoices(choices.length);
172
+ process.stdout.write(` → ${chosen.label}\n`);
173
+ resolve(chosen.value);
174
+ } else if (key.ctrl && key.name === "c") {
175
+ cleanup();
176
+ process.stdout.write("\n");
177
+ process.exit(0);
178
+ }
179
+ };
180
+
140
181
  const cleanup = () => {
141
182
  if (resolved) return;
142
183
  try {
143
- process.stdin.setRawMode(wasRaw ?? false);
184
+ // Always restore to non-raw so subsequent readline works
185
+ process.stdin.setRawMode(false);
144
186
  } catch {
145
187
  // best-effort restore
146
188
  }
@@ -161,40 +203,12 @@ function interactiveSelect(
161
203
  try {
162
204
  process.stdin.setRawMode(true);
163
205
  } catch {
164
- // setRawMode may fail on some terminals; clean up and fall back
165
206
  cleanup();
166
207
  fallbackNumberedSelect(prompt, choices).then(resolve);
167
208
  return;
168
209
  }
169
210
  process.stdin.resume();
170
211
 
171
- const onKeypress = (
172
- _str: string | undefined,
173
- key: { name?: string; ctrl?: boolean },
174
- ) => {
175
- if (!key || !key.name) return;
176
-
177
- if (key.name === "up" || key.name === "k") {
178
- selectedIdx =
179
- (selectedIdx - 1 + choices.length) % choices.length;
180
- reRenderChoices(choices, selectedIdx);
181
- } else if (key.name === "down" || key.name === "j") {
182
- selectedIdx = (selectedIdx + 1) % choices.length;
183
- reRenderChoices(choices, selectedIdx);
184
- } else if (key.name === "return" || key.name === "space") {
185
- resolved = true;
186
- const chosen = choices[selectedIdx]!;
187
- cleanup();
188
- clearChoices(choices.length);
189
- process.stdout.write(` → ${chosen.label}\n`);
190
- resolve(chosen.value);
191
- } else if (key.ctrl && key.name === "c") {
192
- cleanup();
193
- process.stdout.write("\n");
194
- process.exit(0);
195
- }
196
- };
197
-
198
212
  process.stdin.on("keypress", onKeypress);
199
213
  });
200
214
  }
@@ -355,7 +369,7 @@ export function createReadlinePrompter(): WizardPrompter {
355
369
  // Release readline so it doesn't fight over stdin in raw mode
356
370
  disposeRL();
357
371
 
358
- const result = isTTY()
372
+ const result = supportsInteractiveSelect()
359
373
  ? await interactiveSelect(prompt, allChoices)
360
374
  : await fallbackNumberedSelect(prompt, allChoices);
361
375
 
@@ -455,11 +469,7 @@ export async function runSetupWizard(
455
469
  "任何时候按 Ctrl+C 可退出,配置不会被保存。",
456
470
  ]);
457
471
 
458
- // =================================================================
459
- // Layer 1: Core Path
460
- // =================================================================
461
-
462
- // Step 1: Discovery mode (select throws WizardFinishError on Finish)
472
+ // Step 1: Discovery mode
463
473
  const discovery = await prompter.select(
464
474
  "选择节点发现方式:",
465
475
  [
@@ -479,20 +489,17 @@ export async function runSetupWizard(
479
489
  hint: "需要至少一个 bootstrap 入口",
480
490
  },
481
491
  ],
482
- { includeSkip: true },
483
492
  );
484
- if (!isSkip(discovery)) {
485
- config.discovery = discovery;
486
-
487
- // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
488
- if (discovery === "bootstrap" || discovery === "dht") {
489
- const addrs = await prompter.multiline(
490
- "输入 Bootstrap 节点地址(每行一个,空行结束):",
491
- " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
492
- );
493
- if (addrs.length > 0) {
494
- config.bootstrapList = addrs;
495
- }
493
+ config.discovery = discovery;
494
+
495
+ // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
496
+ if (discovery === "bootstrap" || discovery === "dht") {
497
+ const addrs = await prompter.multiline(
498
+ "输入 Bootstrap 节点地址(每行一个,空行结束,直接回车跳过):",
499
+ " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
500
+ );
501
+ if (addrs.length > 0) {
502
+ config.bootstrapList = addrs;
496
503
  }
497
504
  }
498
505
 
@@ -502,162 +509,124 @@ export async function runSetupWizard(
502
509
  "未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。",
503
510
  );
504
511
  } else {
505
- const wantInbound = await prompter.confirm(
506
- "是否配置 P2P 消息的接收目标?",
507
- false,
508
- { allowSkip: true },
509
- );
510
- assertNotFinished(wantInbound);
511
- if (wantInbound === true) {
512
- const targets: Array<{ id?: string; channel: string; target: string }> =
513
- [];
514
- let addMore = true;
515
- while (addMore) {
516
- const channelChoices: PromptChoice[] = availableChannels.map(
517
- (ch) => ({
518
- value: ch,
519
- label: ch,
520
- }),
521
- );
522
- // select throws WizardFinishError on Finish
523
- const channel = await prompter.select(
524
- "选择接收 P2P 消息的聊天频道:",
525
- channelChoices,
526
- { includeSkip: true },
527
- );
528
- if (isSkip(channel)) break;
529
-
530
- const target = await prompter.question(
531
- `输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`,
532
- undefined,
533
- { allowSkip: true },
534
- );
535
- assertNotFinished(target);
536
- if (isSkip(target)) break;
537
- if (target) {
538
- targets.push({ channel, target });
539
- }
540
- const more = await prompter.confirm("是否添加更多接收目标?", false);
541
- assertNotFinished(more);
542
- addMore = more ?? false;
512
+ const targets: Array<{ id?: string; channel: string; target: string }> = [];
513
+ while (true) {
514
+ const doneLabel =
515
+ targets.length > 0
516
+ ? "完成接收目标配置"
517
+ : hasInboundConfig(currentConfig)
518
+ ? "保留现有接收目标并继续"
519
+ : "暂不配置接收目标";
520
+ const channelChoices: PromptChoice[] = [
521
+ ...availableChannels.map((ch) => ({
522
+ value: ch,
523
+ label: ch,
524
+ })),
525
+ {
526
+ value: SENTINEL_SKIP,
527
+ label: doneLabel,
528
+ },
529
+ ];
530
+ const channel = await prompter.select(
531
+ "选择接收 P2P 消息的聊天频道:",
532
+ channelChoices,
533
+ );
534
+ if (channel === SENTINEL_SKIP) break;
535
+
536
+ const target = await prompter.question(
537
+ `输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx,直接回车跳过当前频道):`,
538
+ undefined,
539
+ { allowSkip: true },
540
+ );
541
+ assertNotFinished(target);
542
+ if (isSkip(target)) {
543
+ continue;
543
544
  }
544
- if (targets.length > 0) {
545
- if (
546
- targets.length === 1 &&
547
- !currentConfig.inboundChannel &&
548
- !currentConfig.inboundTarget
549
- ) {
550
- // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
551
- config.inboundChannel = targets[0]!.channel;
552
- config.inboundTarget = targets[0]!.target;
553
- }
554
- config.inboundTargets = targets;
545
+ if (target) {
546
+ targets.push({ channel, target });
555
547
  }
556
548
  }
557
- // wantInbound === null (skip) or false (no) → skip inbound config
558
- }
559
549
 
560
- // Step 4: Preview core config and confirm
561
- const corePreview = formatConfigPreview(config);
562
- prompter.displayBox("即将写入以下配置:", corePreview);
563
- const coreConfirmed = await prompter.confirm("确认写入?", true);
564
- if (!coreConfirmed) {
565
- prompter.displayWarning("已取消,配置未保存。");
566
- throw new WizardCancelledError();
550
+ if (targets.length > 0) {
551
+ applyInboundTargets(config, targets);
552
+ }
567
553
  }
568
554
 
569
- // =================================================================
570
- // Layer 2: Advanced (optional)
571
- // =================================================================
572
-
573
- const wantsAdvanced = await prompter.confirm(
574
- "需要在不同网络之间使用吗(跨 WiFi / 跨城市)?",
575
- false,
576
- { allowSkip: true },
555
+ // Step 4: Network profile
556
+ const networkMode = await prompter.select(
557
+ "选择网络使用场景:",
558
+ [
559
+ {
560
+ value: "lan",
561
+ label: "局域网 / 默认配置",
562
+ hint: "同一 WiFi 或内网使用",
563
+ },
564
+ {
565
+ value: "wan",
566
+ label: "跨网络 / 高级配置",
567
+ hint: "跨 WiFi、跨城市或需要固定入口",
568
+ },
569
+ ],
577
570
  );
578
- assertNotFinished(wantsAdvanced);
579
- if (wantsAdvanced === true) {
580
- // Fixed port
581
- const wantFixedPort = await prompter.confirm(
582
- "是否使用固定监听端口?(推荐跨网络场景)",
583
- false,
571
+ if (networkMode === "wan") {
572
+ const port = await prompter.question(
573
+ "固定监听端口(直接回车跳过,使用动态端口)",
574
+ "4001",
584
575
  { allowSkip: true },
585
576
  );
586
- assertNotFinished(wantFixedPort);
587
- if (wantFixedPort === true) {
588
- const port = await prompter.question("端口号", "4001", {
589
- allowSkip: true,
590
- });
591
- assertNotFinished(port);
592
- if (!isSkip(port)) {
593
- const portNum = parseInt(port, 10);
594
- if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
595
- config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
596
- } else {
597
- prompter.displayWarning("端口号无效,使用默认动态端口。");
598
- }
577
+ assertNotFinished(port);
578
+ if (!isSkip(port)) {
579
+ const portNum = parseInt(port, 10);
580
+ if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
581
+ config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
582
+ } else {
583
+ prompter.displayWarning("端口号无效,使用默认动态端口。");
599
584
  }
600
585
  }
601
586
 
602
- // NAT traversal
603
- const wantNAT = await prompter.confirm(
604
- "是否启用 NAT 穿透?(默认开启,推荐保留)",
605
- true,
606
- { allowSkip: true },
587
+ const natTraversal = await prompter.select(
588
+ "NAT 穿透:",
589
+ [
590
+ {
591
+ value: "true",
592
+ label: "启用",
593
+ hint: "默认,推荐保留",
594
+ },
595
+ {
596
+ value: "false",
597
+ label: "禁用",
598
+ hint: "仅在明确需要时关闭",
599
+ },
600
+ ],
607
601
  );
608
- assertNotFinished(wantNAT);
609
- if (wantNAT !== null) {
610
- config.enableNATTraversal = wantNAT;
611
- }
602
+ config.enableNATTraversal = natTraversal === "true";
612
603
 
613
- // Circuit Relay
614
- const wantRelay = await prompter.confirm(
615
- "需要配置 Circuit Relay 中继节点吗?",
616
- false,
617
- { allowSkip: true },
604
+ const relays = await prompter.multiline(
605
+ "输入 Relay 节点地址(每行一个,空行结束,直接回车跳过):",
606
+ " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
618
607
  );
619
- assertNotFinished(wantRelay);
620
- if (wantRelay === true) {
621
- const relays = await prompter.multiline(
622
- "输入 Relay 节点地址(每行一个,空行结束):",
623
- " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
624
- );
625
- if (relays.length > 0) {
626
- config.relayList = relays;
627
- }
608
+ if (relays.length > 0) {
609
+ config.relayList = relays;
628
610
  }
629
611
 
630
- // Custom instance name
631
- const wantName = await prompter.confirm(
632
- "为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)",
633
- false,
612
+ const name = await prompter.question(
613
+ "节点名称(直接回车跳过)",
614
+ undefined,
634
615
  { allowSkip: true },
635
616
  );
636
- assertNotFinished(wantName);
637
- if (wantName === true) {
638
- const name = await prompter.question("节点名称", undefined, {
639
- allowSkip: true,
640
- });
641
- assertNotFinished(name);
642
- if (!isSkip(name) && name) {
643
- config.instanceName = name;
644
- }
617
+ assertNotFinished(name);
618
+ if (!isSkip(name) && name) {
619
+ config.instanceName = name;
645
620
  }
621
+ }
646
622
 
647
- // Final preview and confirm (only if advanced config changed)
648
- if (
649
- wantFixedPort === true ||
650
- wantRelay === true ||
651
- wantName === true ||
652
- (wantNAT !== null && !config.enableNATTraversal)
653
- ) {
654
- const finalPreview = formatConfigPreview(config);
655
- prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
656
- const finalConfirmed = await prompter.confirm("确认写入?", true);
657
- if (!finalConfirmed) {
658
- prompter.displayWarning("已取消高级配置,核心配置已保存。");
659
- }
660
- }
623
+ // Step 5: Final preview and confirm
624
+ const finalPreview = formatConfigPreview(config);
625
+ prompter.displayBox("即将写入以下配置:", finalPreview);
626
+ const confirmed = await prompter.confirm("确认写入?", true);
627
+ if (!confirmed) {
628
+ prompter.displayWarning("已取消,配置未保存。");
629
+ throw new WizardCancelledError();
661
630
  }
662
631
 
663
632
  prompter.displaySuccess(
@@ -699,6 +668,32 @@ export class WizardFinishError extends Error {
699
668
 
700
669
  // --- Helpers ---
701
670
 
671
+ function hasInboundConfig(config: Record<string, unknown>): boolean {
672
+ return (
673
+ (Array.isArray(config.inboundTargets) && config.inboundTargets.length > 0) ||
674
+ (Boolean(config.inboundChannel) && Boolean(config.inboundTarget))
675
+ );
676
+ }
677
+
678
+ function applyInboundTargets(
679
+ config: Record<string, unknown>,
680
+ targets: Array<{ id?: string; channel: string; target: string }>,
681
+ ): void {
682
+ delete config.inboundTargets;
683
+ delete config.inboundChannel;
684
+ delete config.inboundTarget;
685
+
686
+ if (targets.length === 0) {
687
+ return;
688
+ }
689
+
690
+ config.inboundTargets = targets;
691
+ if (targets.length === 1) {
692
+ config.inboundChannel = targets[0]!.channel;
693
+ config.inboundTarget = targets[0]!.target;
694
+ }
695
+ }
696
+
702
697
  function formatConfigPreview(
703
698
  config: Record<string, unknown>,
704
699
  ): string[] {