libp2p-mesh 2026.6.6 → 2026.6.8

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;
@@ -81,10 +94,10 @@ function interactiveSelect(prompt, choices) {
81
94
  renderChoices(choices, selectedIdx);
82
95
  return new Promise((resolve) => {
83
96
  const wasRaw = process.stdin.isRaw;
84
- process.stdin.setRawMode(true);
85
- readline.emitKeypressEvents(process.stdin);
97
+ const wasPaused = process.stdin.isPaused();
98
+ let resolved = false;
86
99
  const onKeypress = (_str, key) => {
87
- if (!key)
100
+ if (!key || !key.name)
88
101
  return;
89
102
  if (key.name === "up" || key.name === "k") {
90
103
  selectedIdx =
@@ -96,9 +109,10 @@ function interactiveSelect(prompt, choices) {
96
109
  reRenderChoices(choices, selectedIdx);
97
110
  }
98
111
  else if (key.name === "return" || key.name === "space") {
112
+ resolved = true;
99
113
  const chosen = choices[selectedIdx];
100
114
  cleanup();
101
- clearChoices(choices.length);
115
+ eraseChoices(choices.length);
102
116
  process.stdout.write(` → ${chosen.label}\n`);
103
117
  resolve(chosen.value);
104
118
  }
@@ -109,9 +123,39 @@ function interactiveSelect(prompt, choices) {
109
123
  }
110
124
  };
111
125
  const cleanup = () => {
112
- process.stdin.setRawMode(wasRaw ?? false);
126
+ if (resolved)
127
+ return;
128
+ try {
129
+ // Always restore to non-raw so subsequent readline works
130
+ process.stdin.setRawMode(false);
131
+ }
132
+ catch {
133
+ // best-effort restore
134
+ }
135
+ if (wasPaused)
136
+ process.stdin.pause();
113
137
  process.stdin.removeListener("keypress", onKeypress);
114
138
  };
139
+ // Pause before reconfiguring stdin to avoid data race
140
+ process.stdin.pause();
141
+ // Must call emitKeypressEvents BEFORE setRawMode per Node.js docs
142
+ try {
143
+ readline.emitKeypressEvents(process.stdin);
144
+ }
145
+ catch {
146
+ cleanup();
147
+ fallbackNumberedSelect(prompt, choices).then(resolve);
148
+ return;
149
+ }
150
+ try {
151
+ process.stdin.setRawMode(true);
152
+ }
153
+ catch {
154
+ cleanup();
155
+ fallbackNumberedSelect(prompt, choices).then(resolve);
156
+ return;
157
+ }
158
+ process.stdin.resume();
115
159
  process.stdin.on("keypress", onKeypress);
116
160
  });
117
161
  }
@@ -252,7 +296,7 @@ export function createReadlinePrompter() {
252
296
  : choices;
253
297
  // Release readline so it doesn't fight over stdin in raw mode
254
298
  disposeRL();
255
- const result = isTTY()
299
+ const result = supportsInteractiveSelect()
256
300
  ? await interactiveSelect(prompt, allChoices)
257
301
  : await fallbackNumberedSelect(prompt, allChoices);
258
302
  if (result === SENTINEL_FINISH)
@@ -333,10 +377,7 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
333
377
  "我们将引导你完成 P2P Mesh 网络的基础配置。",
334
378
  "任何时候按 Ctrl+C 可退出,配置不会被保存。",
335
379
  ]);
336
- // =================================================================
337
- // Layer 1: Core Path
338
- // =================================================================
339
- // Step 1: Discovery mode (select throws WizardFinishError on Finish)
380
+ // Step 1: Discovery mode
340
381
  const discovery = await prompter.select("选择节点发现方式:", [
341
382
  {
342
383
  value: "mdns",
@@ -353,15 +394,13 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
353
394
  label: "DHT — Kademlia 分布式发现",
354
395
  hint: "需要至少一个 bootstrap 入口",
355
396
  },
356
- ], { includeSkip: true });
357
- if (!isSkip(discovery)) {
358
- config.discovery = discovery;
359
- // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
360
- if (discovery === "bootstrap" || discovery === "dht") {
361
- const addrs = await prompter.multiline("输入 Bootstrap 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
362
- if (addrs.length > 0) {
363
- config.bootstrapList = addrs;
364
- }
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;
365
404
  }
366
405
  }
367
406
  // Step 3: Inbound targets
@@ -369,116 +408,95 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
369
408
  prompter.displayWarning("未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。");
370
409
  }
371
410
  else {
372
- const wantInbound = await prompter.confirm("是否配置 P2P 消息的接收目标?", false, { allowSkip: true });
373
- assertNotFinished(wantInbound);
374
- if (wantInbound === true) {
375
- const targets = [];
376
- let addMore = true;
377
- while (addMore) {
378
- 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) => ({
379
420
  value: ch,
380
421
  label: ch,
381
- }));
382
- // select throws WizardFinishError on Finish
383
- const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices, { includeSkip: true });
384
- if (isSkip(channel))
385
- break;
386
- const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`, undefined, { allowSkip: true });
387
- assertNotFinished(target);
388
- if (isSkip(target))
389
- break;
390
- if (target) {
391
- targets.push({ channel, target });
392
- }
393
- const more = await prompter.confirm("是否添加更多接收目标?", false);
394
- assertNotFinished(more);
395
- 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;
396
435
  }
397
- if (targets.length > 0) {
398
- if (targets.length === 1 &&
399
- !currentConfig.inboundChannel &&
400
- !currentConfig.inboundTarget) {
401
- // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
402
- config.inboundChannel = targets[0].channel;
403
- config.inboundTarget = targets[0].target;
404
- }
405
- config.inboundTargets = targets;
436
+ if (target) {
437
+ targets.push({ channel, target });
406
438
  }
407
439
  }
408
- // wantInbound === null (skip) or false (no) → skip inbound config
409
- }
410
- // Step 4: Preview core config and confirm
411
- const corePreview = formatConfigPreview(config);
412
- prompter.displayBox("即将写入以下配置:", corePreview);
413
- const coreConfirmed = await prompter.confirm("确认写入?", true);
414
- if (!coreConfirmed) {
415
- prompter.displayWarning("已取消,配置未保存。");
416
- throw new WizardCancelledError();
440
+ if (targets.length > 0) {
441
+ applyInboundTargets(config, targets);
442
+ }
417
443
  }
418
- // =================================================================
419
- // Layer 2: Advanced (optional)
420
- // =================================================================
421
- const wantsAdvanced = await prompter.confirm("需要在不同网络之间使用吗(跨 WiFi / 跨城市)?", false, { allowSkip: true });
422
- assertNotFinished(wantsAdvanced);
423
- if (wantsAdvanced === true) {
424
- // Fixed port
425
- const wantFixedPort = await prompter.confirm("是否使用固定监听端口?(推荐跨网络场景)", false, { allowSkip: true });
426
- assertNotFinished(wantFixedPort);
427
- if (wantFixedPort === true) {
428
- const port = await prompter.question("端口号", "4001", {
429
- allowSkip: true,
430
- });
431
- assertNotFinished(port);
432
- if (!isSkip(port)) {
433
- const portNum = parseInt(port, 10);
434
- if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
435
- config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
436
- }
437
- else {
438
- prompter.displayWarning("端口号无效,使用默认动态端口。");
439
- }
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}`];
440
464
  }
441
- }
442
- // NAT traversal
443
- const wantNAT = await prompter.confirm("是否启用 NAT 穿透?(默认开启,推荐保留)", true, { allowSkip: true });
444
- assertNotFinished(wantNAT);
445
- if (wantNAT !== null) {
446
- config.enableNATTraversal = wantNAT;
447
- }
448
- // Circuit Relay
449
- const wantRelay = await prompter.confirm("需要配置 Circuit Relay 中继节点吗?", false, { allowSkip: true });
450
- assertNotFinished(wantRelay);
451
- if (wantRelay === true) {
452
- const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
453
- if (relays.length > 0) {
454
- config.relayList = relays;
465
+ else {
466
+ prompter.displayWarning("端口号无效,使用默认动态端口。");
455
467
  }
456
468
  }
457
- // Custom instance name
458
- const wantName = await prompter.confirm("为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)", false, { allowSkip: true });
459
- assertNotFinished(wantName);
460
- if (wantName === true) {
461
- const name = await prompter.question("节点名称", undefined, {
462
- allowSkip: true,
463
- });
464
- assertNotFinished(name);
465
- if (!isSkip(name) && name) {
466
- config.instanceName = name;
467
- }
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;
468
485
  }
469
- // Final preview and confirm (only if advanced config changed)
470
- if (wantFixedPort === true ||
471
- wantRelay === true ||
472
- wantName === true ||
473
- (wantNAT !== null && !config.enableNATTraversal)) {
474
- const finalPreview = formatConfigPreview(config);
475
- prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
476
- const finalConfirmed = await prompter.confirm("确认写入?", true);
477
- if (!finalConfirmed) {
478
- prompter.displayWarning("已取消高级配置,核心配置已保存。");
479
- }
486
+ const name = await prompter.question("节点名称(直接回车跳过)", undefined, { allowSkip: true });
487
+ assertNotFinished(name);
488
+ if (!isSkip(name) && name) {
489
+ config.instanceName = name;
480
490
  }
481
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
+ }
482
500
  prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
483
501
  return config;
484
502
  }
@@ -511,6 +529,23 @@ export class WizardFinishError extends Error {
511
529
  }
512
530
  }
513
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
+ }
514
549
  function formatConfigPreview(config) {
515
550
  const lines = [];
516
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.6",
3
+ "version": "2026.6.8",
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(
@@ -133,15 +147,15 @@ function interactiveSelect(
133
147
 
134
148
  return new Promise((resolve) => {
135
149
  const wasRaw = process.stdin.isRaw;
150
+ const wasPaused = process.stdin.isPaused();
136
151
 
137
- process.stdin.setRawMode(true);
138
- readline.emitKeypressEvents(process.stdin);
152
+ let resolved = false;
139
153
 
140
154
  const onKeypress = (
141
155
  _str: string | undefined,
142
156
  key: { name?: string; ctrl?: boolean },
143
157
  ) => {
144
- if (!key) return;
158
+ if (!key || !key.name) return;
145
159
 
146
160
  if (key.name === "up" || key.name === "k") {
147
161
  selectedIdx =
@@ -151,9 +165,10 @@ function interactiveSelect(
151
165
  selectedIdx = (selectedIdx + 1) % choices.length;
152
166
  reRenderChoices(choices, selectedIdx);
153
167
  } else if (key.name === "return" || key.name === "space") {
168
+ resolved = true;
154
169
  const chosen = choices[selectedIdx]!;
155
170
  cleanup();
156
- clearChoices(choices.length);
171
+ eraseChoices(choices.length);
157
172
  process.stdout.write(` → ${chosen.label}\n`);
158
173
  resolve(chosen.value);
159
174
  } else if (key.ctrl && key.name === "c") {
@@ -164,10 +179,36 @@ function interactiveSelect(
164
179
  };
165
180
 
166
181
  const cleanup = () => {
167
- process.stdin.setRawMode(wasRaw ?? false);
182
+ if (resolved) return;
183
+ try {
184
+ // Always restore to non-raw so subsequent readline works
185
+ process.stdin.setRawMode(false);
186
+ } catch {
187
+ // best-effort restore
188
+ }
189
+ if (wasPaused) process.stdin.pause();
168
190
  process.stdin.removeListener("keypress", onKeypress);
169
191
  };
170
192
 
193
+ // Pause before reconfiguring stdin to avoid data race
194
+ process.stdin.pause();
195
+ // Must call emitKeypressEvents BEFORE setRawMode per Node.js docs
196
+ try {
197
+ readline.emitKeypressEvents(process.stdin);
198
+ } catch {
199
+ cleanup();
200
+ fallbackNumberedSelect(prompt, choices).then(resolve);
201
+ return;
202
+ }
203
+ try {
204
+ process.stdin.setRawMode(true);
205
+ } catch {
206
+ cleanup();
207
+ fallbackNumberedSelect(prompt, choices).then(resolve);
208
+ return;
209
+ }
210
+ process.stdin.resume();
211
+
171
212
  process.stdin.on("keypress", onKeypress);
172
213
  });
173
214
  }
@@ -328,7 +369,7 @@ export function createReadlinePrompter(): WizardPrompter {
328
369
  // Release readline so it doesn't fight over stdin in raw mode
329
370
  disposeRL();
330
371
 
331
- const result = isTTY()
372
+ const result = supportsInteractiveSelect()
332
373
  ? await interactiveSelect(prompt, allChoices)
333
374
  : await fallbackNumberedSelect(prompt, allChoices);
334
375
 
@@ -428,11 +469,7 @@ export async function runSetupWizard(
428
469
  "任何时候按 Ctrl+C 可退出,配置不会被保存。",
429
470
  ]);
430
471
 
431
- // =================================================================
432
- // Layer 1: Core Path
433
- // =================================================================
434
-
435
- // Step 1: Discovery mode (select throws WizardFinishError on Finish)
472
+ // Step 1: Discovery mode
436
473
  const discovery = await prompter.select(
437
474
  "选择节点发现方式:",
438
475
  [
@@ -452,20 +489,17 @@ export async function runSetupWizard(
452
489
  hint: "需要至少一个 bootstrap 入口",
453
490
  },
454
491
  ],
455
- { includeSkip: true },
456
492
  );
457
- if (!isSkip(discovery)) {
458
- config.discovery = discovery;
459
-
460
- // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
461
- if (discovery === "bootstrap" || discovery === "dht") {
462
- const addrs = await prompter.multiline(
463
- "输入 Bootstrap 节点地址(每行一个,空行结束):",
464
- " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
465
- );
466
- if (addrs.length > 0) {
467
- config.bootstrapList = addrs;
468
- }
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;
469
503
  }
470
504
  }
471
505
 
@@ -475,162 +509,124 @@ export async function runSetupWizard(
475
509
  "未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。",
476
510
  );
477
511
  } else {
478
- const wantInbound = await prompter.confirm(
479
- "是否配置 P2P 消息的接收目标?",
480
- false,
481
- { allowSkip: true },
482
- );
483
- assertNotFinished(wantInbound);
484
- if (wantInbound === true) {
485
- const targets: Array<{ id?: string; channel: string; target: string }> =
486
- [];
487
- let addMore = true;
488
- while (addMore) {
489
- const channelChoices: PromptChoice[] = availableChannels.map(
490
- (ch) => ({
491
- value: ch,
492
- label: ch,
493
- }),
494
- );
495
- // select throws WizardFinishError on Finish
496
- const channel = await prompter.select(
497
- "选择接收 P2P 消息的聊天频道:",
498
- channelChoices,
499
- { includeSkip: true },
500
- );
501
- if (isSkip(channel)) break;
502
-
503
- const target = await prompter.question(
504
- `输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`,
505
- undefined,
506
- { allowSkip: true },
507
- );
508
- assertNotFinished(target);
509
- if (isSkip(target)) break;
510
- if (target) {
511
- targets.push({ channel, target });
512
- }
513
- const more = await prompter.confirm("是否添加更多接收目标?", false);
514
- assertNotFinished(more);
515
- 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;
516
544
  }
517
- if (targets.length > 0) {
518
- if (
519
- targets.length === 1 &&
520
- !currentConfig.inboundChannel &&
521
- !currentConfig.inboundTarget
522
- ) {
523
- // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
524
- config.inboundChannel = targets[0]!.channel;
525
- config.inboundTarget = targets[0]!.target;
526
- }
527
- config.inboundTargets = targets;
545
+ if (target) {
546
+ targets.push({ channel, target });
528
547
  }
529
548
  }
530
- // wantInbound === null (skip) or false (no) → skip inbound config
531
- }
532
549
 
533
- // Step 4: Preview core config and confirm
534
- const corePreview = formatConfigPreview(config);
535
- prompter.displayBox("即将写入以下配置:", corePreview);
536
- const coreConfirmed = await prompter.confirm("确认写入?", true);
537
- if (!coreConfirmed) {
538
- prompter.displayWarning("已取消,配置未保存。");
539
- throw new WizardCancelledError();
550
+ if (targets.length > 0) {
551
+ applyInboundTargets(config, targets);
552
+ }
540
553
  }
541
554
 
542
- // =================================================================
543
- // Layer 2: Advanced (optional)
544
- // =================================================================
545
-
546
- const wantsAdvanced = await prompter.confirm(
547
- "需要在不同网络之间使用吗(跨 WiFi / 跨城市)?",
548
- false,
549
- { 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
+ ],
550
570
  );
551
- assertNotFinished(wantsAdvanced);
552
- if (wantsAdvanced === true) {
553
- // Fixed port
554
- const wantFixedPort = await prompter.confirm(
555
- "是否使用固定监听端口?(推荐跨网络场景)",
556
- false,
571
+ if (networkMode === "wan") {
572
+ const port = await prompter.question(
573
+ "固定监听端口(直接回车跳过,使用动态端口)",
574
+ "4001",
557
575
  { allowSkip: true },
558
576
  );
559
- assertNotFinished(wantFixedPort);
560
- if (wantFixedPort === true) {
561
- const port = await prompter.question("端口号", "4001", {
562
- allowSkip: true,
563
- });
564
- assertNotFinished(port);
565
- if (!isSkip(port)) {
566
- const portNum = parseInt(port, 10);
567
- if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
568
- config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
569
- } else {
570
- prompter.displayWarning("端口号无效,使用默认动态端口。");
571
- }
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("端口号无效,使用默认动态端口。");
572
584
  }
573
585
  }
574
586
 
575
- // NAT traversal
576
- const wantNAT = await prompter.confirm(
577
- "是否启用 NAT 穿透?(默认开启,推荐保留)",
578
- true,
579
- { 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
+ ],
580
601
  );
581
- assertNotFinished(wantNAT);
582
- if (wantNAT !== null) {
583
- config.enableNATTraversal = wantNAT;
584
- }
602
+ config.enableNATTraversal = natTraversal === "true";
585
603
 
586
- // Circuit Relay
587
- const wantRelay = await prompter.confirm(
588
- "需要配置 Circuit Relay 中继节点吗?",
589
- false,
590
- { allowSkip: true },
604
+ const relays = await prompter.multiline(
605
+ "输入 Relay 节点地址(每行一个,空行结束,直接回车跳过):",
606
+ " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
591
607
  );
592
- assertNotFinished(wantRelay);
593
- if (wantRelay === true) {
594
- const relays = await prompter.multiline(
595
- "输入 Relay 节点地址(每行一个,空行结束):",
596
- " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
597
- );
598
- if (relays.length > 0) {
599
- config.relayList = relays;
600
- }
608
+ if (relays.length > 0) {
609
+ config.relayList = relays;
601
610
  }
602
611
 
603
- // Custom instance name
604
- const wantName = await prompter.confirm(
605
- "为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)",
606
- false,
612
+ const name = await prompter.question(
613
+ "节点名称(直接回车跳过)",
614
+ undefined,
607
615
  { allowSkip: true },
608
616
  );
609
- assertNotFinished(wantName);
610
- if (wantName === true) {
611
- const name = await prompter.question("节点名称", undefined, {
612
- allowSkip: true,
613
- });
614
- assertNotFinished(name);
615
- if (!isSkip(name) && name) {
616
- config.instanceName = name;
617
- }
617
+ assertNotFinished(name);
618
+ if (!isSkip(name) && name) {
619
+ config.instanceName = name;
618
620
  }
621
+ }
619
622
 
620
- // Final preview and confirm (only if advanced config changed)
621
- if (
622
- wantFixedPort === true ||
623
- wantRelay === true ||
624
- wantName === true ||
625
- (wantNAT !== null && !config.enableNATTraversal)
626
- ) {
627
- const finalPreview = formatConfigPreview(config);
628
- prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
629
- const finalConfirmed = await prompter.confirm("确认写入?", true);
630
- if (!finalConfirmed) {
631
- prompter.displayWarning("已取消高级配置,核心配置已保存。");
632
- }
633
- }
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();
634
630
  }
635
631
 
636
632
  prompter.displaySuccess(
@@ -672,6 +668,32 @@ export class WizardFinishError extends Error {
672
668
 
673
669
  // --- Helpers ---
674
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
+
675
697
  function formatConfigPreview(
676
698
  config: Record<string, unknown>,
677
699
  ): string[] {