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 +13 -4
- package/dist/src/plugin.js +8 -1
- package/dist/src/wizard.js +167 -132
- package/index.ts +17 -4
- package/package.json +2 -2
- package/src/plugin.ts +10 -1
- package/src/wizard.ts +195 -173
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
|
});
|
package/dist/src/plugin.js
CHANGED
|
@@ -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) =>
|
|
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({
|
package/dist/src/wizard.js
CHANGED
|
@@ -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
|
-
*
|
|
26
|
+
* Clear entire current line: carriage-return to col 0, then erase to end.
|
|
24
27
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
return;
|
|
28
|
-
process.stdout.write(`\x1b[${n}A`);
|
|
28
|
+
function clearLine() {
|
|
29
|
+
process.stdout.write("\r\x1b[K");
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
|
-
*
|
|
32
|
+
* Move cursor up `n` lines.
|
|
32
33
|
*/
|
|
33
|
-
function
|
|
34
|
-
|
|
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
|
-
|
|
59
|
+
// Jump back to the first choice line
|
|
60
|
+
cursorUp(n);
|
|
53
61
|
for (let i = 0; i < n; i++) {
|
|
54
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
83
|
+
clearLine();
|
|
71
84
|
process.stdout.write("\n");
|
|
72
85
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
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.
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
]
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 (
|
|
398
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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.
|
|
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 --
|
|
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) =>
|
|
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
|
-
*
|
|
65
|
+
* Clear entire current line: carriage-return to col 0, then erase to end.
|
|
62
66
|
*/
|
|
63
|
-
function
|
|
64
|
-
|
|
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
|
-
*
|
|
72
|
+
* Move cursor up `n` lines.
|
|
70
73
|
*/
|
|
71
|
-
function
|
|
72
|
-
|
|
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
|
-
|
|
105
|
+
// Jump back to the first choice line
|
|
106
|
+
cursorUp(n);
|
|
98
107
|
for (let i = 0; i < n; i++) {
|
|
99
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
+
clearLine();
|
|
116
130
|
process.stdout.write("\n");
|
|
117
131
|
}
|
|
118
|
-
//
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 (
|
|
518
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
"是否使用固定监听端口?(推荐跨网络场景)",
|
|
556
|
-
false,
|
|
571
|
+
if (networkMode === "wan") {
|
|
572
|
+
const port = await prompter.question(
|
|
573
|
+
"固定监听端口(直接回车跳过,使用动态端口)",
|
|
574
|
+
"4001",
|
|
557
575
|
{ allowSkip: true },
|
|
558
576
|
);
|
|
559
|
-
assertNotFinished(
|
|
560
|
-
if (
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
582
|
-
if (wantNAT !== null) {
|
|
583
|
-
config.enableNATTraversal = wantNAT;
|
|
584
|
-
}
|
|
602
|
+
config.enableNATTraversal = natTraversal === "true";
|
|
585
603
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
"
|
|
589
|
-
false,
|
|
590
|
-
{ allowSkip: true },
|
|
604
|
+
const relays = await prompter.multiline(
|
|
605
|
+
"输入 Relay 节点地址(每行一个,空行结束,直接回车跳过):",
|
|
606
|
+
" 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
|
|
591
607
|
);
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
false,
|
|
612
|
+
const name = await prompter.question(
|
|
613
|
+
"节点名称(直接回车跳过)",
|
|
614
|
+
undefined,
|
|
607
615
|
{ allowSkip: true },
|
|
608
616
|
);
|
|
609
|
-
assertNotFinished(
|
|
610
|
-
if (
|
|
611
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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[] {
|