@xmoxmo/bncr 0.2.1 → 0.2.3

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/index.ts CHANGED
@@ -15,6 +15,7 @@ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
15
15
  type ChannelModule = typeof import('./src/channel.ts');
16
16
  type OpenClawPluginApi = Parameters<ChannelModule['createBncrBridge']>[0];
17
17
  type BridgeSingleton = ReturnType<ChannelModule['createBncrBridge']>;
18
+ type ChannelPlugin = ReturnType<ChannelModule['createBncrChannelPlugin']>;
18
19
 
19
20
  type LoadedRuntime = {
20
21
  createBncrBridge: ChannelModule['createBncrBridge'];
@@ -22,6 +23,10 @@ type LoadedRuntime = {
22
23
  };
23
24
 
24
25
  const BNCR_REGISTER_META = Symbol.for('bncr.register.meta');
26
+ const BNCR_GLOBAL_REGISTER_TRACE = Symbol.for('bncr.global.register.trace');
27
+ const BNCR_BRIDGE_OWNER = Symbol.for('bncr.bridge.owner');
28
+ const BNCR_GATEWAY_RUNTIME = Symbol.for('bncr.gateway.runtime');
29
+ const MODULE_EPOCH = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
25
30
 
26
31
  type RegisterMeta = {
27
32
  service?: boolean;
@@ -29,13 +34,80 @@ type RegisterMeta = {
29
34
  methods?: Set<string>;
30
35
  apiInstanceId?: string;
31
36
  registryFingerprint?: string;
37
+ registrationMode?: string;
38
+ };
39
+
40
+ type GlobalRegisterTrace = {
41
+ lastApiInstanceId?: string;
42
+ lastRegistryFingerprint?: string;
43
+ seenRegistryFingerprints: Set<string>;
44
+ seenApiInstanceIds: Set<string>;
45
+ };
46
+
47
+ type BridgeOwner = {
48
+ moduleEpoch: string;
49
+ bridgeFactoryId: string;
50
+ apiInstanceId: string;
51
+ registryFingerprint: string;
52
+ registrationMode?: string;
53
+ };
54
+
55
+ type BridgeRegisterStateSnapshot = {
56
+ registerCount: number;
57
+ apiGeneration: number;
58
+ firstRegisterAt: number | null;
59
+ lastRegisterAt: number | null;
60
+ lastApiRebindAt: number | null;
61
+ pluginSource: string | null;
62
+ pluginVersion: string | null;
63
+ lastApiInstanceId: string | null;
64
+ lastRegistryFingerprint: string | null;
65
+ lastDriftSnapshot: unknown;
66
+ registerTraceRecent: Array<Record<string, unknown>>;
67
+ };
68
+
69
+ type GatewayMethodName =
70
+ | 'bncr.connect'
71
+ | 'bncr.inbound'
72
+ | 'bncr.activity'
73
+ | 'bncr.ack'
74
+ | 'bncr.diagnostics'
75
+ | 'bncr.file.init'
76
+ | 'bncr.file.chunk'
77
+ | 'bncr.file.complete'
78
+ | 'bncr.file.abort'
79
+ | 'bncr.file.ack';
80
+
81
+ type BridgeSingletonWithOwner = BridgeSingleton & {
82
+ [BNCR_BRIDGE_OWNER]?: BridgeOwner;
83
+ registerCount?: number;
84
+ apiGeneration?: number;
85
+ firstRegisterAt?: number | null;
86
+ lastRegisterAt?: number | null;
87
+ lastApiRebindAt?: number | null;
88
+ pluginSource?: string | null;
89
+ pluginVersion?: string | null;
90
+ lastApiInstanceId?: string | null;
91
+ lastRegistryFingerprint?: string | null;
92
+ lastDriftSnapshot?: unknown;
93
+ registerTraceRecent?: Array<Record<string, unknown>>;
32
94
  };
33
95
 
34
96
  type OpenClawPluginApiWithMeta = OpenClawPluginApi & {
35
97
  [BNCR_REGISTER_META]?: RegisterMeta;
36
98
  };
37
99
 
100
+ type BncrGatewayRuntime = {
101
+ currentBridge?: BridgeSingletonWithOwner;
102
+ registeredMethodsByRegistry: Map<string, Set<GatewayMethodName>>;
103
+ serviceRegistered?: boolean;
104
+ channelRegistered?: boolean;
105
+ serviceOwnerApiInstanceId?: string;
106
+ channelOwnerApiInstanceId?: string;
107
+ };
108
+
38
109
  let runtime: LoadedRuntime | null = null;
110
+ let activeServiceStop: (() => Promise<void>) | null = null;
39
111
  const identityIds = new WeakMap<object, string>();
40
112
  let identitySeq = 0;
41
113
 
@@ -218,7 +290,7 @@ const loadRuntimeSync = (): LoadedRuntime => {
218
290
  const getIdentityId = (obj: object, prefix: string) => {
219
291
  const existing = identityIds.get(obj);
220
292
  if (existing) return existing;
221
- const next = `${prefix}_${++identitySeq}`;
293
+ const next = `${prefix}_${MODULE_EPOCH}_${++identitySeq}`;
222
294
  identityIds.set(obj, next);
223
295
  return next;
224
296
  };
@@ -247,38 +319,313 @@ const getRegisterMeta = (api: OpenClawPluginApi): RegisterMeta => {
247
319
  return host[BNCR_REGISTER_META]!;
248
320
  };
249
321
 
322
+ const getProcessStore = () => {
323
+ const p = process as NodeJS.Process & {
324
+ [BNCR_GLOBAL_REGISTER_TRACE]?: GlobalRegisterTrace;
325
+ [BNCR_GATEWAY_RUNTIME]?: BncrGatewayRuntime;
326
+ };
327
+ return p;
328
+ };
329
+
330
+ const getGlobalRegisterTrace = () => {
331
+ const p = getProcessStore();
332
+ if (!p[BNCR_GLOBAL_REGISTER_TRACE]) {
333
+ p[BNCR_GLOBAL_REGISTER_TRACE] = {
334
+ seenRegistryFingerprints: new Set<string>(),
335
+ seenApiInstanceIds: new Set<string>(),
336
+ };
337
+ }
338
+ return p[BNCR_GLOBAL_REGISTER_TRACE]!;
339
+ };
340
+
341
+ const getGatewayRuntime = (): BncrGatewayRuntime => {
342
+ const p = getProcessStore();
343
+ if (!p[BNCR_GATEWAY_RUNTIME]) {
344
+ p[BNCR_GATEWAY_RUNTIME] = {
345
+ registeredMethodsByRegistry: new Map<string, Set<GatewayMethodName>>(),
346
+ serviceRegistered: false,
347
+ channelRegistered: false,
348
+ };
349
+ }
350
+ return p[BNCR_GATEWAY_RUNTIME]!;
351
+ };
352
+
353
+ const getProcessOwnerApiInstanceId = (gatewayRuntime: BncrGatewayRuntime) =>
354
+ gatewayRuntime.serviceOwnerApiInstanceId ||
355
+ gatewayRuntime.channelOwnerApiInstanceId ||
356
+ undefined;
357
+
358
+ const shouldAdoptProcessOwner = (
359
+ apiInstanceId: string,
360
+ gatewayRuntime: BncrGatewayRuntime,
361
+ ) => {
362
+ const existingOwnerApiInstanceId = getProcessOwnerApiInstanceId(gatewayRuntime);
363
+ const hasSingletonOwner =
364
+ Boolean(gatewayRuntime.serviceRegistered) || Boolean(gatewayRuntime.channelRegistered);
365
+
366
+ if (!hasSingletonOwner) {
367
+ return {
368
+ adoptOwner: true,
369
+ existingOwnerApiInstanceId,
370
+ reason: 'no-singleton-owner',
371
+ };
372
+ }
373
+
374
+ if (existingOwnerApiInstanceId && existingOwnerApiInstanceId === apiInstanceId) {
375
+ return {
376
+ adoptOwner: true,
377
+ existingOwnerApiInstanceId,
378
+ reason: 'same-owner-api',
379
+ };
380
+ }
381
+
382
+ return {
383
+ adoptOwner: false,
384
+ existingOwnerApiInstanceId,
385
+ reason: 'singleton-owned-by-other-api',
386
+ };
387
+ };
388
+
389
+ const gatewayMethodDispatchers: Record<
390
+ GatewayMethodName,
391
+ (bridge: BridgeSingletonWithOwner, opts: any) => any
392
+ > = {
393
+ 'bncr.connect': (bridge, opts) => bridge.handleConnect(opts),
394
+ 'bncr.inbound': (bridge, opts) => bridge.handleInbound(opts),
395
+ 'bncr.activity': (bridge, opts) => bridge.handleActivity(opts),
396
+ 'bncr.ack': (bridge, opts) => bridge.handleAck(opts),
397
+ 'bncr.diagnostics': (bridge, opts) => bridge.handleDiagnostics(opts),
398
+ 'bncr.file.init': (bridge, opts) => bridge.handleFileInit(opts),
399
+ 'bncr.file.chunk': (bridge, opts) => bridge.handleFileChunk(opts),
400
+ 'bncr.file.complete': (bridge, opts) => bridge.handleFileComplete(opts),
401
+ 'bncr.file.abort': (bridge, opts) => bridge.handleFileAbort(opts),
402
+ 'bncr.file.ack': (bridge, opts) => bridge.handleFileAck(opts),
403
+ };
404
+
405
+ const dispatchGatewayMethod = (name: GatewayMethodName, opts: any) => {
406
+ const gatewayRuntime = getGatewayRuntime();
407
+ const bridge = gatewayRuntime.currentBridge;
408
+ if (!bridge) {
409
+ throw new Error(`bncr gateway runtime unavailable for ${name}`);
410
+ }
411
+ return gatewayMethodDispatchers[name](bridge, opts);
412
+ };
413
+
414
+ const mirrorGatewayMethodForMockApi = (api: OpenClawPluginApi, name: GatewayMethodName) => {
415
+ const host = api as OpenClawPluginApi & {
416
+ methods?: Array<{ name: string; handler: (opts: any) => any }>;
417
+ };
418
+ if (!Array.isArray(host.methods)) return;
419
+ if (host.methods.some((item) => item?.name === name)) return;
420
+ host.methods.push({ name, handler: (opts) => dispatchGatewayMethod(name, opts) });
421
+ };
422
+
250
423
  const ensureGatewayMethodRegistered = (
251
424
  api: OpenClawPluginApi,
252
- name: string,
253
- handler: (opts: any) => any,
425
+ name: GatewayMethodName,
254
426
  debugLog: (...args: any[]) => void,
255
427
  ) => {
256
428
  const meta = getRegisterMeta(api);
429
+ const gatewayRuntime = getGatewayRuntime();
430
+ const registryFingerprint = meta.registryFingerprint || getRegistryFingerprint(api);
431
+ let registryMethods = gatewayRuntime.registeredMethodsByRegistry.get(registryFingerprint);
432
+ if (!registryMethods) {
433
+ registryMethods = new Set<GatewayMethodName>();
434
+ gatewayRuntime.registeredMethodsByRegistry.set(registryFingerprint, registryMethods);
435
+ }
257
436
  if (meta.methods?.has(name)) {
258
437
  debugLog(`register method skip ${name} (already registered on this api)`);
259
438
  return;
260
439
  }
261
- api.registerGatewayMethod(name, handler);
440
+ if (registryMethods.has(name)) {
441
+ mirrorGatewayMethodForMockApi(api, name);
442
+ meta.methods?.add(name);
443
+ debugLog(`register method reuse ${name} (already registered in registry)`);
444
+ return;
445
+ }
446
+ api.registerGatewayMethod(name, (opts) => dispatchGatewayMethod(name, opts));
447
+ mirrorGatewayMethodForMockApi(api, name);
448
+ registryMethods.add(name);
262
449
  meta.methods?.add(name);
263
450
  debugLog(`register method ok ${name}`);
264
451
  };
265
452
 
453
+ const getBridgeOwner = (api: OpenClawPluginApi, loaded: LoadedRuntime): BridgeOwner => {
454
+ const meta = getRegisterMeta(api);
455
+ return {
456
+ moduleEpoch: MODULE_EPOCH,
457
+ bridgeFactoryId: getIdentityId(loaded.createBncrBridge as object, 'bridgeFactory'),
458
+ apiInstanceId: meta.apiInstanceId || 'unknown',
459
+ registryFingerprint: meta.registryFingerprint || 'unknown',
460
+ registrationMode: meta.registrationMode,
461
+ };
462
+ };
463
+
464
+ const sameBridgeOwner = (left?: BridgeOwner, right?: BridgeOwner) => {
465
+ if (!left || !right) return false;
466
+ return (
467
+ left.moduleEpoch === right.moduleEpoch &&
468
+ left.bridgeFactoryId === right.bridgeFactoryId &&
469
+ left.apiInstanceId === right.apiInstanceId &&
470
+ left.registryFingerprint === right.registryFingerprint
471
+ );
472
+ };
473
+
474
+ const snapshotBridgeRegisterState = (
475
+ bridge?: BridgeSingletonWithOwner,
476
+ ): BridgeRegisterStateSnapshot | null => {
477
+ if (!bridge) return null;
478
+ return {
479
+ registerCount: Number(bridge.registerCount || 0),
480
+ apiGeneration: Number(bridge.apiGeneration || 0),
481
+ firstRegisterAt:
482
+ typeof bridge.firstRegisterAt === 'number'
483
+ ? bridge.firstRegisterAt
484
+ : (bridge.firstRegisterAt ?? null),
485
+ lastRegisterAt:
486
+ typeof bridge.lastRegisterAt === 'number'
487
+ ? bridge.lastRegisterAt
488
+ : (bridge.lastRegisterAt ?? null),
489
+ lastApiRebindAt:
490
+ typeof bridge.lastApiRebindAt === 'number'
491
+ ? bridge.lastApiRebindAt
492
+ : (bridge.lastApiRebindAt ?? null),
493
+ pluginSource: typeof bridge.pluginSource === 'string' ? bridge.pluginSource : null,
494
+ pluginVersion: typeof bridge.pluginVersion === 'string' ? bridge.pluginVersion : null,
495
+ lastApiInstanceId:
496
+ typeof bridge.lastApiInstanceId === 'string' ? bridge.lastApiInstanceId : null,
497
+ lastRegistryFingerprint:
498
+ typeof bridge.lastRegistryFingerprint === 'string' ? bridge.lastRegistryFingerprint : null,
499
+ lastDriftSnapshot: bridge.lastDriftSnapshot ?? null,
500
+ registerTraceRecent: Array.isArray(bridge.registerTraceRecent)
501
+ ? bridge.registerTraceRecent.map((trace) => ({ ...trace }))
502
+ : [],
503
+ };
504
+ };
505
+
506
+ const hydrateBridgeRegisterState = (
507
+ bridge: BridgeSingletonWithOwner,
508
+ snapshot: BridgeRegisterStateSnapshot | null,
509
+ ) => {
510
+ if (!snapshot) return bridge;
511
+ bridge.registerCount = snapshot.registerCount;
512
+ bridge.apiGeneration = snapshot.apiGeneration;
513
+ bridge.firstRegisterAt = snapshot.firstRegisterAt;
514
+ bridge.lastRegisterAt = snapshot.lastRegisterAt;
515
+ bridge.lastApiRebindAt = snapshot.lastApiRebindAt;
516
+ bridge.pluginSource = snapshot.pluginSource;
517
+ bridge.pluginVersion = snapshot.pluginVersion;
518
+ bridge.lastApiInstanceId = snapshot.lastApiInstanceId;
519
+ bridge.lastRegistryFingerprint = snapshot.lastRegistryFingerprint;
520
+ bridge.lastDriftSnapshot = snapshot.lastDriftSnapshot;
521
+ bridge.registerTraceRecent = snapshot.registerTraceRecent.map((trace) => ({ ...trace }));
522
+ return bridge;
523
+ };
524
+
525
+ const assignBridgeOwner = (bridge: BridgeSingleton, owner: BridgeOwner) => {
526
+ (bridge as BridgeSingletonWithOwner)[BNCR_BRIDGE_OWNER] = owner;
527
+ return bridge as BridgeSingletonWithOwner;
528
+ };
529
+
266
530
  const getBridgeSingleton = (api: OpenClawPluginApi) => {
267
531
  const loaded = loadRuntimeSync();
268
- const g = globalThis as typeof globalThis & { __bncrBridge?: BridgeSingleton };
532
+ const g = globalThis as typeof globalThis & { __bncrBridge?: BridgeSingletonWithOwner };
533
+ const owner = getBridgeOwner(api, loaded);
534
+ const previousOwner = g.__bncrBridge?.[BNCR_BRIDGE_OWNER];
535
+
269
536
  let created = false;
270
- if (!g.__bncrBridge) {
271
- g.__bncrBridge = loaded.createBncrBridge(api);
272
- created = true;
537
+ let rebuilt = false;
538
+
539
+ if (g.__bncrBridge) {
540
+ const mustRebuild =
541
+ !sameBridgeOwner(previousOwner, owner) &&
542
+ (previousOwner?.moduleEpoch !== owner.moduleEpoch ||
543
+ previousOwner?.bridgeFactoryId !== owner.bridgeFactoryId ||
544
+ previousOwner?.registrationMode !== owner.registrationMode ||
545
+ previousOwner?.apiInstanceId !== owner.apiInstanceId ||
546
+ previousOwner?.registryFingerprint !== owner.registryFingerprint);
547
+
548
+ if (mustRebuild) {
549
+ const registerState = snapshotBridgeRegisterState(g.__bncrBridge);
550
+ try {
551
+ g.__bncrBridge.stopService?.();
552
+ } catch {
553
+ // ignore stop errors during hot-restart recovery
554
+ }
555
+ g.__bncrBridge = hydrateBridgeRegisterState(
556
+ assignBridgeOwner(loaded.createBncrBridge(api), owner),
557
+ registerState,
558
+ );
559
+ created = true;
560
+ rebuilt = true;
561
+ } else {
562
+ g.__bncrBridge.bindApi?.(api);
563
+ assignBridgeOwner(g.__bncrBridge, owner);
564
+ created = false;
565
+ rebuilt = false;
566
+ }
273
567
  } else {
274
- g.__bncrBridge.bindApi?.(api);
568
+ g.__bncrBridge = assignBridgeOwner(loaded.createBncrBridge(api), owner);
569
+ created = true;
275
570
  }
276
- return { bridge: g.__bncrBridge, runtime: loaded, created };
571
+
572
+ return { bridge: g.__bncrBridge, runtime: loaded, created, rebuilt, owner, previousOwner };
573
+ };
574
+
575
+ const getExistingBridgeSingleton = (): BridgeSingletonWithOwner | undefined => {
576
+ const g = globalThis as typeof globalThis & { __bncrBridge?: BridgeSingletonWithOwner };
577
+ return g.__bncrBridge;
277
578
  };
278
579
 
279
580
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
280
581
  typeof value === 'object' && value !== null && !Array.isArray(value);
281
582
 
583
+ const getCurrentBridge = (): BridgeSingletonWithOwner => {
584
+ const bridge = getGatewayRuntime().currentBridge;
585
+ if (!bridge) throw new Error('bncr current bridge unavailable');
586
+ return bridge;
587
+ };
588
+
589
+ const createDynamicChannelPlugin = (loaded: LoadedRuntime): ChannelPlugin => {
590
+ const base = loaded.createBncrChannelPlugin(() => getCurrentBridge());
591
+
592
+ return {
593
+ ...base,
594
+ outbound: {
595
+ ...base.outbound,
596
+ sendText: (ctx: any) => getCurrentBridge().channelSendText(ctx),
597
+ sendMedia: (ctx: any) => getCurrentBridge().channelSendMedia(ctx),
598
+ },
599
+ status: {
600
+ ...base.status,
601
+ buildChannelSummary: async ({ defaultAccountId }: any) =>
602
+ getCurrentBridge().getChannelSummary(defaultAccountId || 'Primary'),
603
+ buildAccountSnapshot: async ({ account, runtime }: any) => {
604
+ const bridgeNow = getCurrentBridge();
605
+ return base.status.buildAccountSnapshot({
606
+ account,
607
+ runtime: runtime || bridgeNow.getAccountRuntimeSnapshot(account?.accountId),
608
+ });
609
+ },
610
+ resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
611
+ const bridgeNow = getCurrentBridge();
612
+ return base.status.resolveAccountState({
613
+ enabled,
614
+ configured,
615
+ account,
616
+ cfg,
617
+ runtime: runtime || bridgeNow.getAccountRuntimeSnapshot(account?.accountId),
618
+ });
619
+ },
620
+ },
621
+ gateway: {
622
+ ...base.gateway,
623
+ startAccount: (ctx: any) => getCurrentBridge().channelStartAccount(ctx),
624
+ stopAccount: (ctx: any) => getCurrentBridge().channelStopAccount(ctx),
625
+ },
626
+ };
627
+ };
628
+
282
629
  const registerBncrCli = (api: OpenClawPluginApi & { registerCli?: (...args: any[]) => void }) => {
283
630
  if (typeof api.registerCli !== 'function') return;
284
631
  api.registerCli(
@@ -290,10 +637,11 @@ const registerBncrCli = (api: OpenClawPluginApi & { registerCli?: (...args: any[
290
637
  'Seed minimal channels.bncr config (adds enabled=true and allowTool=false only when missing)',
291
638
  )
292
639
  .action(async () => {
293
- const cfg = (await api.runtime.config.loadConfig()) as Record<string, unknown>;
294
- if (!isPlainObject(cfg.channels)) cfg.channels = {};
640
+ const cfg = api.runtime.config.current() as Record<string, unknown>;
641
+ const next = structuredClone(cfg);
642
+ if (!isPlainObject(next.channels)) next.channels = {};
295
643
 
296
- const existing = isPlainObject(cfg.channels.bncr) ? cfg.channels.bncr : {};
644
+ const existing = isPlainObject(next.channels.bncr) ? next.channels.bncr : {};
297
645
  const bncrCfg: Record<string, unknown> = { ...existing };
298
646
  const added: string[] = [];
299
647
 
@@ -307,14 +655,14 @@ const registerBncrCli = (api: OpenClawPluginApi & { registerCli?: (...args: any[
307
655
  added.push('allowTool=false');
308
656
  }
309
657
 
310
- cfg.channels.bncr = bncrCfg;
658
+ next.channels.bncr = bncrCfg;
311
659
 
312
660
  if (added.length === 0) {
313
661
  console.log('Minimal bncr config already present. No changes made.');
314
662
  return;
315
663
  }
316
664
 
317
- await api.runtime.config.writeConfigFile(cfg);
665
+ await api.runtime.config.writeConfigFile(next);
318
666
  console.log('Seeded minimal bncr config at channels.bncr.');
319
667
  console.log(`Added missing fields: ${added.join(', ')}`);
320
668
  console.log('Restart the gateway to apply changes.');
@@ -324,6 +672,9 @@ const registerBncrCli = (api: OpenClawPluginApi & { registerCli?: (...args: any[
324
672
  );
325
673
  };
326
674
 
675
+ const shouldSkipNonRuntimeRegister = (mode?: string) =>
676
+ mode === 'cli-metadata' || mode === 'discovery';
677
+
327
678
  const plugin = {
328
679
  id: 'bncr',
329
680
  name: 'Bncr',
@@ -333,14 +684,61 @@ const plugin = {
333
684
  api: OpenClawPluginApi & { registerCli?: (...args: any[]) => void; registrationMode?: string },
334
685
  ) {
335
686
  registerBncrCli(api);
336
- if (api.registrationMode === 'cli-metadata') return;
687
+ if (shouldSkipNonRuntimeRegister(api.registrationMode)) return;
688
+
689
+ // 注意:OpenClaw 要求 plugin register 必须是同步函数;
690
+ // 不要在这里 await 停旧 service / 清理旧 runtime,否则 loader 会直接拒绝加载。
691
+ // 旧实例清理由 service stop / runtime 自愈逻辑兜底,这里只做同步声明式注册。
337
692
 
338
693
  const meta = getRegisterMeta(api);
339
- const { bridge, runtime, created } = getBridgeSingleton(api);
340
- bridge.noteRegister?.({
694
+ meta.registrationMode = api.registrationMode;
695
+ const globalTrace = getGlobalRegisterTrace();
696
+ const previousApiInstanceId = globalTrace.lastApiInstanceId;
697
+ const previousRegistryFingerprint = globalTrace.lastRegistryFingerprint;
698
+ const apiInstanceId = meta.apiInstanceId || 'unknown';
699
+ const registryFingerprint = meta.registryFingerprint || 'unknown';
700
+ const sameApiAsPrevious = previousApiInstanceId === apiInstanceId;
701
+ const sameRegistryAsPrevious = previousRegistryFingerprint === registryFingerprint;
702
+ const firstSeenApi = !globalTrace.seenApiInstanceIds.has(apiInstanceId);
703
+ const firstSeenRegistry = !globalTrace.seenRegistryFingerprints.has(registryFingerprint);
704
+
705
+ const gatewayRuntime = getGatewayRuntime();
706
+ const ownerDecision = shouldAdoptProcessOwner(apiInstanceId, gatewayRuntime);
707
+
708
+ let bridge: BridgeSingletonWithOwner | undefined;
709
+ let runtime: LoadedRuntime;
710
+ let created = false;
711
+ let rebuilt = false;
712
+ let owner: BridgeOwner | undefined;
713
+ let previousOwner: BridgeOwner | undefined;
714
+
715
+ if (ownerDecision.adoptOwner) {
716
+ const adopted = getBridgeSingleton(api);
717
+ bridge = adopted.bridge;
718
+ runtime = adopted.runtime;
719
+ created = adopted.created;
720
+ rebuilt = adopted.rebuilt;
721
+ owner = adopted.owner;
722
+ previousOwner = adopted.previousOwner;
723
+ gatewayRuntime.currentBridge = bridge;
724
+ } else {
725
+ runtime = loadRuntimeSync();
726
+ bridge = gatewayRuntime.currentBridge || getExistingBridgeSingleton();
727
+ previousOwner = getExistingBridgeSingleton()?.[BNCR_BRIDGE_OWNER];
728
+ owner = previousOwner;
729
+ if (bridge && !gatewayRuntime.currentBridge) {
730
+ gatewayRuntime.currentBridge = bridge;
731
+ }
732
+ }
733
+
734
+ globalTrace.seenApiInstanceIds.add(apiInstanceId);
735
+ globalTrace.seenRegistryFingerprints.add(registryFingerprint);
736
+ globalTrace.lastApiInstanceId = apiInstanceId;
737
+ globalTrace.lastRegistryFingerprint = registryFingerprint;
738
+ bridge?.noteRegister?.({
341
739
  source: '~/.openclaw/workspace/plugins/bncr/index.ts',
342
740
  pluginVersion,
343
- apiRebound: !created,
741
+ apiRebound: ownerDecision.adoptOwner ? !created && !rebuilt : false,
344
742
  apiInstanceId: meta.apiInstanceId,
345
743
  registryFingerprint: meta.registryFingerprint,
346
744
  });
@@ -351,100 +749,89 @@ const plugin = {
351
749
  .trim();
352
750
  if (!rendered) return;
353
751
  emitBncrLogLine('info', `[bncr] debug ${rendered}`, { debugOnly: true }, () =>
354
- Boolean(bridge.isDebugEnabled?.()),
752
+ Boolean(bridge?.isDebugEnabled?.()),
355
753
  );
356
754
  };
357
755
 
358
- debugLog(`register begin bridge=${bridge.getBridgeId?.() || 'unknown'} created=${created}`);
359
- if (!created) debugLog('bridge api rebound');
756
+ debugLog(
757
+ `register begin bridge=${bridge?.getBridgeId?.() || 'unknown'} created=${created} rebuilt=${rebuilt} ` +
758
+ `ownerApi=${owner?.apiInstanceId || 'none'} ownerRegistry=${owner?.registryFingerprint || 'none'} ` +
759
+ `previousOwnerApi=${previousOwner?.apiInstanceId || 'none'} previousOwnerRegistry=${previousOwner?.registryFingerprint || 'none'}`,
760
+ );
761
+ debugLog(
762
+ `register classify mode=${meta.registrationMode || 'unknown'} api=${apiInstanceId} registry=${registryFingerprint} ` +
763
+ `sameApiAsPrevious=${sameApiAsPrevious} sameRegistryAsPrevious=${sameRegistryAsPrevious} ` +
764
+ `firstSeenApi=${firstSeenApi} firstSeenRegistry=${firstSeenRegistry}`,
765
+ );
766
+ debugLog(
767
+ `register owner adopt=${ownerDecision.adoptOwner} reason=${ownerDecision.reason} ` +
768
+ `existingOwnerApi=${ownerDecision.existingOwnerApiInstanceId || 'none'}`,
769
+ );
770
+ if (!ownerDecision.adoptOwner) {
771
+ debugLog(
772
+ `bridge rebuild suppressed due to existing singleton owner api ${ownerDecision.existingOwnerApiInstanceId || 'unknown'}`,
773
+ );
774
+ } else {
775
+ if (!created && !rebuilt) debugLog('bridge api rebound');
776
+ if (rebuilt) debugLog('bridge rebuilt due to owner/runtime change');
777
+ }
360
778
 
361
779
  const resolveDebug = async () => {
362
780
  try {
363
- const cfg = await api.runtime.config.loadConfig();
781
+ const cfg = api.runtime.config.current();
364
782
  return Boolean((cfg as any)?.channels?.bncr?.debug?.verbose);
365
783
  } catch {
366
784
  return false;
367
785
  }
368
786
  };
369
787
 
370
- if (!meta.service) {
788
+ if (!gatewayRuntime.serviceRegistered) {
789
+ const serviceStopHandler = async () => {
790
+ await getCurrentBridge().stopService?.();
791
+ };
371
792
  api.registerService({
372
793
  id: 'bncr-bridge-service',
373
794
  start: async (ctx) => {
374
795
  const debug = await resolveDebug();
375
- await bridge.startService(ctx, debug);
796
+ await getCurrentBridge().startService(ctx, debug);
376
797
  },
377
- stop: bridge.stopService,
798
+ stop: serviceStopHandler,
378
799
  });
800
+ activeServiceStop = serviceStopHandler;
801
+ gatewayRuntime.serviceRegistered = true;
802
+ gatewayRuntime.serviceOwnerApiInstanceId = apiInstanceId;
379
803
  meta.service = true;
380
- debugLog('register service ok');
804
+ debugLog(`register service ok ownerApi=${apiInstanceId}`);
381
805
  } else {
382
- debugLog('register service skip (already registered on this api)');
806
+ meta.service = true;
807
+ debugLog(
808
+ `register service skip (process singleton already registered by api ${gatewayRuntime.serviceOwnerApiInstanceId || 'unknown'})`,
809
+ );
383
810
  }
384
811
 
385
- if (!meta.channel) {
386
- api.registerChannel({ plugin: runtime.createBncrChannelPlugin(bridge) });
812
+ if (!gatewayRuntime.channelRegistered) {
813
+ api.registerChannel({ plugin: createDynamicChannelPlugin(runtime) });
814
+ gatewayRuntime.channelRegistered = true;
815
+ gatewayRuntime.channelOwnerApiInstanceId = apiInstanceId;
387
816
  meta.channel = true;
388
- debugLog('register channel ok');
817
+ debugLog(`register channel ok ownerApi=${apiInstanceId}`);
389
818
  } else {
390
- debugLog('register channel skip (already registered on this api)');
819
+ meta.channel = true;
820
+ debugLog(
821
+ `register channel skip (process singleton already registered by api ${gatewayRuntime.channelOwnerApiInstanceId || 'unknown'})`,
822
+ );
391
823
  }
392
824
 
393
- ensureGatewayMethodRegistered(
394
- api,
395
- 'bncr.connect',
396
- (opts) => bridge.handleConnect(opts),
397
- debugLog,
398
- );
399
- ensureGatewayMethodRegistered(
400
- api,
401
- 'bncr.inbound',
402
- (opts) => bridge.handleInbound(opts),
403
- debugLog,
404
- );
405
- ensureGatewayMethodRegistered(
406
- api,
407
- 'bncr.activity',
408
- (opts) => bridge.handleActivity(opts),
409
- debugLog,
410
- );
411
- ensureGatewayMethodRegistered(api, 'bncr.ack', (opts) => bridge.handleAck(opts), debugLog);
412
- ensureGatewayMethodRegistered(
413
- api,
414
- 'bncr.diagnostics',
415
- (opts) => bridge.handleDiagnostics(opts),
416
- debugLog,
417
- );
418
- ensureGatewayMethodRegistered(
419
- api,
420
- 'bncr.file.init',
421
- (opts) => bridge.handleFileInit(opts),
422
- debugLog,
423
- );
424
- ensureGatewayMethodRegistered(
425
- api,
426
- 'bncr.file.chunk',
427
- (opts) => bridge.handleFileChunk(opts),
428
- debugLog,
429
- );
430
- ensureGatewayMethodRegistered(
431
- api,
432
- 'bncr.file.complete',
433
- (opts) => bridge.handleFileComplete(opts),
434
- debugLog,
435
- );
436
- ensureGatewayMethodRegistered(
437
- api,
438
- 'bncr.file.abort',
439
- (opts) => bridge.handleFileAbort(opts),
440
- debugLog,
441
- );
442
- ensureGatewayMethodRegistered(
443
- api,
444
- 'bncr.file.ack',
445
- (opts) => bridge.handleFileAck(opts),
446
- debugLog,
447
- );
825
+ ensureGatewayMethodRegistered(api, 'bncr.connect', debugLog);
826
+ ensureGatewayMethodRegistered(api, 'bncr.inbound', debugLog);
827
+ ensureGatewayMethodRegistered(api, 'bncr.activity', debugLog);
828
+ ensureGatewayMethodRegistered(api, 'bncr.ack', debugLog);
829
+ ensureGatewayMethodRegistered(api, 'bncr.diagnostics', debugLog);
830
+ ensureGatewayMethodRegistered(api, 'bncr.file.init', debugLog);
831
+ ensureGatewayMethodRegistered(api, 'bncr.file.chunk', debugLog);
832
+ ensureGatewayMethodRegistered(api, 'bncr.file.complete', debugLog);
833
+ ensureGatewayMethodRegistered(api, 'bncr.file.abort', debugLog);
834
+ ensureGatewayMethodRegistered(api, 'bncr.file.ack', debugLog);
448
835
  debugLog('register done');
449
836
  },
450
837
  };