@napuketto/loader 0.0.29 → 0.0.31

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.
@@ -24,9 +24,89 @@ let node_fs = require("node:fs");
24
24
  let node_path = require("node:path");
25
25
  let node_process = require("node:process");
26
26
  node_process = __toESM(node_process, 1);
27
+ let node_child_process = require("node:child_process");
27
28
  let node_os = require("node:os");
28
29
  let zod = require("zod");
29
30
  let node_readline = require("node:readline");
31
+ //#region src/pid-cmdline.ts
32
+ /**
33
+ * pid-cmdline.ts:跨进程命令行查询(instance-lock pid 复用根治,2026-09-10)。
34
+ *
35
+ * 背景(2026-09-08 实测事故):锁内 pid 的持有者已死、pid 被 Windows 复用给
36
+ * 无关进程时,process.kill(pid, 0) 探活成功 → 误判存活 → 新实例永久拒绝启动
37
+ * (只能手动删锁恢复)。根治:探活通过后再查该 pid 当前的真实命令行,与锁内
38
+ * cmdline 摘要比对——查不到(进程已退/查询失败)或不一致(pid 复用)均视为
39
+ * 持有者已死,允许接管。误判方向取舍:误判「死亡」→ 双实例抢 QQ 原生锁,
40
+ * 后启动者挂起退出(可恢复);误判「存活」→ 永久锁死需人工干预——前者代价
41
+ * 远低,故查询失败宁可接管。
42
+ *
43
+ * 平台实现:Windows 走 PowerShell Get-CimInstance Win32_Process 跨进程查询
44
+ * (仅在启动判活时调用一次,冷启动 ~0.5-2s 可接受;wmic 在新 Win11 已移除,
45
+ * 不可用)。Linux 走 /proc/<pid>/cmdline(\0 分隔)。其余平台(darwin 等)
46
+ * 不支持——判活退回纯 pid 探测(旧行为)。
47
+ *
48
+ * 比对不要求逐字符相等:锁内摘要来自 process.execArgv/argv join,查询侧
49
+ * 拿到的是操作系统记录的原始命令行,两者存在引号 / 大小写 / 路径分隔符差异
50
+ * ——统一经 normalizeCmdline 规范化后比对。残余差异源(8.3 短路径、相对
51
+ * 启动路径)极罕见,误判后果同上(可恢复),接受。
52
+ */
53
+ /** 当前平台是否支持跨进程 cmdline 查询(不支持则判活退回纯 pid 探测)。 */
54
+ const CMDLINE_PROBE_SUPPORTED = node_process.default.platform === "win32" || node_process.default.platform === "linux";
55
+ /**
56
+ * 查询指定 pid 的当前命令行。
57
+ *
58
+ * @returns 命令行原文;进程不存在 / 命令行不可得(权限、系统进程 null)/
59
+ * 查询机制失败(超时、PowerShell 缺失)均返回 null(调用方视为持有者已死)
60
+ */
61
+ function readCmdlineForPid(pid) {
62
+ if (node_process.default.platform === "win32") return readWindowsCmdline(pid);
63
+ if (node_process.default.platform === "linux") return readLinuxCmdline(pid);
64
+ return null;
65
+ }
66
+ /** Windows:PowerShell Get-CimInstance 查询(进程不存在时输出空 → null)。 */
67
+ function readWindowsCmdline(pid) {
68
+ try {
69
+ const cmdline = (0, node_child_process.execFileSync)("powershell.exe", [
70
+ "-NoProfile",
71
+ "-NonInteractive",
72
+ "-ExecutionPolicy",
73
+ "Bypass",
74
+ "-Command",
75
+ `[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`
76
+ ], {
77
+ encoding: "utf-8",
78
+ timeout: 1e4,
79
+ windowsHide: true
80
+ }).trim();
81
+ return cmdline === "" ? null : cmdline;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ /** Linux:/proc/<pid>/cmdline(\0 分隔转空格;进程不存在抛错 → null)。 */
87
+ function readLinuxCmdline(pid) {
88
+ try {
89
+ const cmdline = (0, node_fs.readFileSync)(`/proc/${pid}/cmdline`, "utf-8").replaceAll("\0", " ").trim();
90
+ return cmdline === "" ? null : cmdline;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * 命令行规范化(比对用):统一大小写 / 去引号 / 路径分隔符归一 / 折叠空白。
97
+ * Windows 路径大小写不敏感,lowercase 安全。
98
+ */
99
+ function normalizeCmdline(cmdline) {
100
+ return cmdline.toLowerCase().replaceAll("\"", "").replaceAll("\\", "/").replace(/\s+/g, " ").trim();
101
+ }
102
+ /**
103
+ * 本进程命令行摘要(写锁用):execArgv(node 选项)+ argv(脚本与参数),
104
+ * 与操作系统记录的完整命令行结构一致(规范化后可比对)。
105
+ */
106
+ function selfCmdline() {
107
+ return [...node_process.default.execArgv, ...node_process.default.argv].join(" ").trim();
108
+ }
109
+ //#endregion
30
110
  //#region src/instance-lock.ts
31
111
  /**
32
112
  * instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)
@@ -42,6 +122,12 @@ let node_readline = require("node:readline");
42
122
  * - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)
43
123
  * - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)
44
124
  *
125
+ * PID 复用根治(2026-09-10):PID 探活通过后再比对锁内 cmdline 摘要与该
126
+ * PID 当前真实命令行(pid-cmdline.ts)——查不到或不一致 → PID 已被无关
127
+ * 进程复用(持有者已死),视为残留允许接管。旧锁(无 cmdline 字段)无法
128
+ * 校验,保守维持「占用」判定(一次性过渡:新锁写入时自动带摘要)。详见
129
+ * pid-cmdline.ts 头注释的误判代价取舍。
130
+ *
45
131
  * 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留
46
132
  * 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。
47
133
  *
@@ -85,6 +171,15 @@ function checkInstanceLock(dataDir) {
85
171
  pid: null,
86
172
  lockPath
87
173
  };
174
+ const lockCmdline = typeof info.cmdline === "string" && info.cmdline !== "" ? info.cmdline : void 0;
175
+ if (lockCmdline !== void 0 && CMDLINE_PROBE_SUPPORTED) {
176
+ const current = readCmdlineForPid(info.pid);
177
+ if (current === null || normalizeCmdline(current) !== normalizeCmdline(lockCmdline)) return {
178
+ occupied: false,
179
+ pid: null,
180
+ lockPath
181
+ };
182
+ }
88
183
  return {
89
184
  occupied: true,
90
185
  pid: info.pid,
@@ -101,10 +196,11 @@ function acquireInstanceLock(dataDir, cmdline) {
101
196
  if (pid === node_process.default.pid) return true;
102
197
  return false;
103
198
  }
199
+ const effectiveCmdline = cmdline !== void 0 && cmdline !== "" ? cmdline : selfCmdline();
104
200
  const info = {
105
201
  pid: node_process.default.pid,
106
202
  startedAt: Date.now(),
107
- ...cmdline !== void 0 && cmdline !== "" ? { cmdline } : {}
203
+ ...effectiveCmdline !== "" ? { cmdline: effectiveCmdline } : {}
108
204
  };
109
205
  try {
110
206
  (0, node_fs.rmSync)(lockPath, { force: true });
@@ -856,6 +952,7 @@ function attachIpcServices(actions, services) {
856
952
  forwardChannel(services.friendChannel, stops);
857
953
  return () => {
858
954
  for (const stop of stops) stop();
955
+ for (const name of full.keys()) actions.delete(name);
859
956
  };
860
957
  }
861
958
  //#endregion
@@ -950,6 +1047,7 @@ async function attachOb11IpcBridge(actions, services, deps = {}) {
950
1047
  msgChannel: services.channel,
951
1048
  groupChannel: services.groupChannel,
952
1049
  friendChannel: services.friendChannel,
1050
+ buddyCacheEvents: services.buddyCache?.events,
953
1051
  logger: services.logger,
954
1052
  msgApi: services.msgApi,
955
1053
  groupApi: services.groupApi,
@@ -999,6 +1097,7 @@ async function attachOb11IpcBridge(actions, services, deps = {}) {
999
1097
  return () => {
1000
1098
  broadcaster.unregister(bridgeAdapter);
1001
1099
  ob11.unsubscribeOnly();
1100
+ for (const name of ob11.registry.names) actions.delete(name);
1002
1101
  };
1003
1102
  } catch (err) {
1004
1103
  log(`ipc-ob11: OB11 动作桥装配失败(降级纯 kernel 动作面): ${errMsg(err)}`);
@@ -1166,13 +1265,23 @@ function loadProtocolSections(kernel, uin) {
1166
1265
  const BACKSLASH_RE = /\\/g;
1167
1266
  /** adapter 入口 index.mjs 后缀(子路径导出替换用)。 */
1168
1267
  const INDEX_MJS_RE = /index\.mjs$/;
1169
- /** 装配 OB11 + Satori 适配器(登录成功后,非 IPC 模式)。 */
1268
+ /**
1269
+ * 装配 OB11 + Satori 适配器(登录成功后,非 IPC 模式)。
1270
+ *
1271
+ * 返回停止函数(ob11/satori 适配器 stop:传输关闭 + 订阅清理;装配跳过时
1272
+ * no-op)——startProtocols 存入 services.stopAdapters,供未来非 IPC 软重登
1273
+ * 清理(当前软重登仅 IPC 模式有触发通道,见 relogin.ts)。
1274
+ *
1275
+ * ⚠️ 装配失败**抛出**(2026-09-08 修复):cli 模式跑宿主就是为了 OB11/Satori
1276
+ * 服务,此处吞错会让 startProtocols 误判成功、以假 ready 留驻(内部 catch
1277
+ * 曾把 protocols.ts「装配失败返回 null 判引导失败」的意图整个短路)。
1278
+ */
1170
1279
  async function assembleOb11AndSatori(kernel, services, loginResult) {
1171
1280
  const adapterEntry = env.NAPUTO_ADAPTER_ENTRY;
1172
1281
  const networkEntry = env.NAPUTO_NETWORK_ENTRY;
1173
1282
  if (!adapterEntry || !networkEntry) {
1174
1283
  log("bootstrap: NAPUTO_ADAPTER_ENTRY/NETWORK_ENTRY 未设置,跳过协议装配");
1175
- return;
1284
+ return async () => {};
1176
1285
  }
1177
1286
  try {
1178
1287
  const network = await import(`file://${networkEntry.replace(BACKSLASH_RE, "/")}`);
@@ -1194,12 +1303,13 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
1194
1303
  const wrapperExports = services.ctx.wrapper?.exports;
1195
1304
  const qqRoot = kernel.resolveQqUserDataRoot?.(wrapperExports) ?? null;
1196
1305
  const mediaBaseDir = qqRoot !== null ? kernel.resolveQqGlobalPath?.(qqRoot) : void 0;
1197
- await new adapter.NapukettoOneBot11Adapter({
1306
+ const ob11 = new adapter.NapukettoOneBot11Adapter({
1198
1307
  config: ob11Config,
1199
1308
  broadcaster,
1200
1309
  msgChannel: channel,
1201
1310
  groupChannel: services.groupChannel,
1202
1311
  friendChannel: services.friendChannel,
1312
+ buddyCacheEvents: services.buddyCache?.events,
1203
1313
  logger: services.logger,
1204
1314
  msgApi: services.msgApi,
1205
1315
  groupApi: services.groupApi,
@@ -1231,7 +1341,8 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
1231
1341
  }
1232
1342
  },
1233
1343
  groupCache
1234
- }).start();
1344
+ });
1345
+ await ob11.start();
1235
1346
  log("bootstrap: onebot11 adapter started");
1236
1347
  const satoriCacheDir = typeof satoriSection["cacheDir"] === "string" && satoriSection["cacheDir"] !== "" ? satoriSection["cacheDir"] : (0, node_path.join)(env.NAPUTO_CFG_DIR || ".", "cache");
1237
1348
  const satoriConfig = new adapterCore.ProtocolConfig({
@@ -1240,7 +1351,7 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
1240
1351
  defaults: satoriAdapter.satoriConfigSchema.parse({}),
1241
1352
  seed: satoriAdapter.satoriConfigSchema.parse(satoriSection)
1242
1353
  });
1243
- await new satoriAdapter.NapukettoSatoriAdapter({
1354
+ const satori = new satoriAdapter.NapukettoSatoriAdapter({
1244
1355
  config: satoriConfig,
1245
1356
  broadcaster,
1246
1357
  msgChannel: channel,
@@ -1252,10 +1363,16 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
1252
1363
  self: services.self,
1253
1364
  cacheDir: satoriCacheDir,
1254
1365
  groupCache
1255
- }).start();
1366
+ });
1367
+ await satori.start();
1256
1368
  log("bootstrap: satori adapter started");
1369
+ return async () => {
1370
+ await ob11.stop();
1371
+ await satori.stop();
1372
+ };
1257
1373
  } catch (e) {
1258
1374
  log(`bootstrap: 协议装配失败: ${errMsg(e)}`);
1375
+ throw e;
1259
1376
  }
1260
1377
  }
1261
1378
  //#endregion
@@ -1299,9 +1416,11 @@ function formatMessageLog(kernel, msg) {
1299
1416
  }
1300
1417
  /**
1301
1418
  * 消息日志订阅(onRecvMsg 回调参数为消息数组——2026-08-07 运行时实证,遍历逐条打印)。
1419
+ * 返回退订函数(软重登重装配时清理旧订阅,2026-09-08);通道返回面宽松
1420
+ * (EventChannelLike.on 返回 unknown),非函数时退化为 no-op。
1302
1421
  */
1303
1422
  function setupMsgLogging(kernel, channel, logger, colorize) {
1304
- channel.on("Msg/onRecvMsg", (msgs) => {
1423
+ const off = channel.on("Msg/onRecvMsg", (msgs) => {
1305
1424
  forEachRawMessage(msgs, (m) => {
1306
1425
  try {
1307
1426
  const { plain, colored } = formatMessageLog(kernel, m);
@@ -1314,6 +1433,7 @@ function setupMsgLogging(kernel, channel, logger, colorize) {
1314
1433
  }
1315
1434
  });
1316
1435
  });
1436
+ return typeof off === "function" ? off : () => {};
1317
1437
  }
1318
1438
  //#endregion
1319
1439
  //#region src/host/core/kernel-services.ts
@@ -1327,9 +1447,10 @@ function setupMsgLogging(kernel, channel, logger, colorize) {
1327
1447
  /** 登录成功后创建 kernel 业务服务(channel/bridge/cache/apis)。失败返回 null。 */
1328
1448
  async function createKernelServices(kernel, ctx, loginResult) {
1329
1449
  const ipcMode = env.NAPUTO_IPC === "1";
1450
+ const logFile = env.NAPUTO_CFG_DIR !== void 0 ? (0, node_path.join)(env.NAPUTO_CFG_DIR, "logs", "loader.log") : (0, node_path.join)((0, node_os.tmpdir)(), "napuketto-loader.log");
1330
1451
  const logger = kernel.createLogger?.({
1331
1452
  console: !ipcMode,
1332
- ...ipcMode ? { file: env.NAPUTO_CFG_DIR !== void 0 ? (0, node_path.join)(env.NAPUTO_CFG_DIR, "logs", "loader.log") : (0, node_path.join)((0, node_os.tmpdir)(), "napuketto-loader.log") } : {},
1453
+ file: logFile,
1333
1454
  base: { name: "loader" }
1334
1455
  });
1335
1456
  const session = ctx.session;
@@ -1338,21 +1459,29 @@ async function createKernelServices(kernel, ctx, loginResult) {
1338
1459
  return null;
1339
1460
  }
1340
1461
  const channel = new kernel.NTEventChannel("Msg");
1341
- new kernel.MsgBridge(session, channel).register();
1342
- setupMsgLogging(kernel, channel, logger, !ipcMode);
1462
+ const bridge = new kernel.MsgBridge(session, channel);
1463
+ bridge.register();
1464
+ const offMsgLogging = setupMsgLogging(kernel, channel, logger, !ipcMode);
1343
1465
  const groupApi = new kernel.GroupApi(session);
1344
1466
  const util = ctx.exports?.NodeQQNTWrapperUtil;
1345
1467
  const msgApi = new kernel.MsgApi(session, channel, util);
1346
1468
  const friendApi = new kernel.FriendApi(session, { uidToUin: (uids) => groupApi.uidToUin(uids) });
1347
1469
  const groupChannel = new kernel.NTEventChannel("Group");
1348
- new kernel.GroupBridge(session, groupChannel).register();
1470
+ const groupBridge = new kernel.GroupBridge(session, groupChannel);
1471
+ groupBridge.register();
1349
1472
  const friendChannel = new kernel.NTEventChannel("Buddy");
1350
- new kernel.FriendBridge(session, friendChannel).register();
1473
+ const friendBridge = new kernel.FriendBridge(session, friendChannel);
1474
+ friendBridge.register();
1351
1475
  const groupCache = new kernel.GroupCache({
1352
1476
  channel: groupChannel,
1353
1477
  groupApi
1354
1478
  });
1355
1479
  groupCache.register();
1480
+ const buddyCache = new kernel.BuddyCache({
1481
+ channel: friendChannel,
1482
+ logger
1483
+ });
1484
+ buddyCache.register();
1356
1485
  const groupNotifyApi = new kernel.GroupNotifyApi(session);
1357
1486
  const ticketApi = new kernel.TicketApi(session);
1358
1487
  return {
@@ -1366,6 +1495,7 @@ async function createKernelServices(kernel, ctx, loginResult) {
1366
1495
  groupApi,
1367
1496
  friendApi,
1368
1497
  groupCache,
1498
+ buddyCache,
1369
1499
  groupNotifyApi,
1370
1500
  ticketApi,
1371
1501
  richMediaApi: new kernel.RichMediaApi(session),
@@ -1378,7 +1508,15 @@ async function createKernelServices(kernel, ctx, loginResult) {
1378
1508
  },
1379
1509
  session,
1380
1510
  engine: ctx.engine,
1381
- util: ctx.exports?.NodeQQNTWrapperUtil
1511
+ util: ctx.exports?.NodeQQNTWrapperUtil,
1512
+ dispose: () => {
1513
+ bridge.unregister();
1514
+ groupBridge.unregister();
1515
+ friendBridge.unregister();
1516
+ groupCache.unregister();
1517
+ buddyCache.unregister();
1518
+ offMsgLogging();
1519
+ }
1382
1520
  };
1383
1521
  }
1384
1522
  //#endregion
@@ -1408,7 +1546,7 @@ async function startProtocols(kernel, ctx, loginResult) {
1408
1546
  return services;
1409
1547
  }
1410
1548
  try {
1411
- await assembleOb11AndSatori(kernel, services, loginResult);
1549
+ services.stopAdapters = await assembleOb11AndSatori(kernel, services, loginResult);
1412
1550
  } catch (e) {
1413
1551
  log(`bootstrap: 协议装配失败: ${errMsg(e)}`);
1414
1552
  return null;
@@ -1416,6 +1554,156 @@ async function startProtocols(kernel, ctx, loginResult) {
1416
1554
  return services;
1417
1555
  }
1418
1556
  //#endregion
1557
+ //#region src/host/core/relogin.ts
1558
+ /**
1559
+ * relogin.ts:登录控制相位机 + control login 指令处理(2026-09-08 A1/A2)。
1560
+ *
1561
+ * control login(koishi 面板「重新登录/扫码登录」→ IPC control login 指令)
1562
+ * 的成功结果按引导相位分派三条路径:
1563
+ * ① login-race(初始登录竞速中):结果经 preemptRef 接管引导链——快速登录
1564
+ * 风控挂起时强制扫码也能走完装配链(2026-09-08 T2 既有语义)。
1565
+ * ② ready(引导已完成):**软重登**——重装配(清理旧服务 + 用新结果重跑
1566
+ * 装配链),不再整进程重启;完成后重播 ready + logged_in。
1567
+ * ③ assembling/aborted:迟到结果**不上报**(A1 修复)——引导失败进程将退出,
1568
+ * 无条件 sendLogin("logged_in") 会制造 failed→logged_in 的误导序列
1569
+ * (koishi driver 收到 logged_in 但子进程无协议装配,上线后所有请求失败);
1570
+ * 装配进行中的并发结果同样忽略(不干扰初次装配链)。
1571
+ *
1572
+ * 防重入(2026-09-08 拍板):control login 全程互斥(claim/release)——在途
1573
+ * 登录/重装配期间的新指令直接忽略(仅留日志,不发登录消息,面板随首个流程
1574
+ * 的真实状态收敛)。
1575
+ */
1576
+ /** 登录控制相位机(bootstrapWithCore 持有,handler 与引导链共享)。 */
1577
+ var LoginControl = class {
1578
+ phase = "login-race";
1579
+ claimed = false;
1580
+ preempt;
1581
+ constructor(preempt) {
1582
+ this.preempt = preempt;
1583
+ }
1584
+ /** 当前相位。 */
1585
+ get currentPhase() {
1586
+ return this.phase;
1587
+ }
1588
+ /** 取走竞速抢占口(登录期 control login 成功接管引导链用);竞速已结束返回 null。 */
1589
+ takePreempt() {
1590
+ const resolve = this.preempt.resolve;
1591
+ if (resolve === null) return null;
1592
+ this.preempt.resolve = null;
1593
+ return resolve;
1594
+ }
1595
+ /** 竞速结束(初始登录 settle):关抢占口,进入装配期。 */
1596
+ beginAssembly() {
1597
+ this.preempt.resolve = null;
1598
+ this.phase = "assembling";
1599
+ }
1600
+ /** 装配完成:开启软重登窗口。 */
1601
+ markReady() {
1602
+ this.phase = "ready";
1603
+ }
1604
+ /** 引导失败(进程将退出):关抢占口,迟到 control login 结果不再上报。 */
1605
+ abort() {
1606
+ this.preempt.resolve = null;
1607
+ this.phase = "aborted";
1608
+ }
1609
+ /** control login 互斥(在途防重入):同一时刻只允许一个 control login 流程。 */
1610
+ claim() {
1611
+ if (this.claimed) return false;
1612
+ this.claimed = true;
1613
+ return true;
1614
+ }
1615
+ /** 释放互斥(core.login settle 后调用,含软重登重装配完成)。 */
1616
+ release() {
1617
+ this.claimed = false;
1618
+ }
1619
+ };
1620
+ /** 登录状态字面量(IPC login 消息,与 kernel LoginState 对齐)。 */
1621
+ const LOGIN_STATES = [
1622
+ "idle",
1623
+ "waiting_scan",
1624
+ "scanned",
1625
+ "logged_in",
1626
+ "failed"
1627
+ ];
1628
+ /** 宽松收窄(kernel onLoginProgress.state 是 string;bootstrap-core 登录回调共用)。 */
1629
+ function isLoginState(value) {
1630
+ return LOGIN_STATES.includes(value);
1631
+ }
1632
+ /** ready 态软重登:重装配 → 成功重播 ready/logged_in,失败交失败处理。 */
1633
+ async function softReloginAndReport(loginResult, selfInfo, deps) {
1634
+ if (!await deps.reassemble(loginResult)) {
1635
+ (deps.onReassembleFailed ?? defaultReassembleFailed)();
1636
+ return;
1637
+ }
1638
+ sendStatus("ready");
1639
+ sendLogin("logged_in", selfInfo);
1640
+ }
1641
+ /** control login 成功结果按相位分派(抽取自 handler,控制认知复杂度)。 */
1642
+ async function dispatchLoginSuccess(result, control, deps) {
1643
+ const selfInfo = {
1644
+ uin: result.uin,
1645
+ uid: result.uid,
1646
+ nick: result.nick ?? ""
1647
+ };
1648
+ const preemptResolve = control.takePreempt();
1649
+ if (preemptResolve !== null) {
1650
+ sendLogin("logged_in", selfInfo);
1651
+ preemptResolve(result);
1652
+ return;
1653
+ }
1654
+ if (control.currentPhase === "ready") {
1655
+ await softReloginAndReport(result, selfInfo, deps);
1656
+ return;
1657
+ }
1658
+ log(`relogin: control login 成功但引导相位=${control.currentPhase},忽略结果`);
1659
+ }
1660
+ /** 重装配失败默认处理:上报 failed + 退出进程(koishi driver 重启循环回收)。 */
1661
+ function defaultReassembleFailed() {
1662
+ log("relogin: 软重登重装配失败,上报 failed 并退出(driver 重启循环接管)");
1663
+ sendStatus("failed", "软重登重装配失败", {
1664
+ code: "UNKNOWN",
1665
+ message: "软重登重装配失败,详见 napuketto-boot.log"
1666
+ });
1667
+ node_process.default.exit(1);
1668
+ }
1669
+ /**
1670
+ * control login 指令 → 重新登录(qr=true 强制扫码跳过快速登录,uin 指定账号)。
1671
+ * 成功结果按 LoginControl 相位分派(登录期抢占 / ready 软重登 / 忽略迟到)。
1672
+ * 该 handler 仅在 IPC 模式经 startIpcServer 的 onLogin 注册(见 bootstrapWithCore),
1673
+ * 非 IPC 模式无 control 通道不会触达,故无条件走 JSON 行协议、无需再判 ipcMode。
1674
+ */
1675
+ function createLoginControlHandler(core, Appid, control, deps) {
1676
+ return (payload) => {
1677
+ if (!control.claim()) {
1678
+ log("relogin: control login 在途(登录/软重登进行中),忽略本次请求");
1679
+ return;
1680
+ }
1681
+ const opts = {
1682
+ appid: String(Appid),
1683
+ initTimeoutMs: 2e4,
1684
+ qrFallback: true,
1685
+ ...payload.uin !== void 0 ? { quickUin: payload.uin } : {},
1686
+ ...payload.qr === true ? { qrOnly: true } : {},
1687
+ onLoginProgress: (progress) => {
1688
+ if (progress.qr !== void 0) sendQr(progress.qr.pngBase64, progress.qr.qrcodeUrl);
1689
+ if (isLoginState(progress.state)) sendLogin(progress.state, progress.selfInfo, progress.message);
1690
+ }
1691
+ };
1692
+ core.login(opts).then(async (result) => {
1693
+ if (result === null) {
1694
+ sendLogin("failed", void 0, "登录返回空结果");
1695
+ return;
1696
+ }
1697
+ await dispatchLoginSuccess(result, control, deps);
1698
+ }).catch((err) => {
1699
+ log(`bootstrap: control login 失败: ${errMsg(err)}`);
1700
+ sendLogin("failed", void 0, errMsg(err));
1701
+ }).finally(() => {
1702
+ control.release();
1703
+ });
1704
+ };
1705
+ }
1706
+ //#endregion
1419
1707
  //#region src/host/core/session.ts
1420
1708
  /** 对象有效性判断:getMsgService() 调用不抛断言(cpp_impl 已激活)。
1421
1709
  * 与 isSessionUsable 的区别:这里允许返回 null(未 init 但对象有效)。 */
@@ -1688,18 +1976,6 @@ function probeCreatedSession(state, ctx) {
1688
1976
  log(`BOOT: create() 探测失败: ${errMsg(e)}`);
1689
1977
  }
1690
1978
  }
1691
- /** 登录状态字面量(IPC login 消息,与 kernel LoginState 对齐)。 */
1692
- const LOGIN_STATES = [
1693
- "idle",
1694
- "waiting_scan",
1695
- "scanned",
1696
- "logged_in",
1697
- "failed"
1698
- ];
1699
- /** 宽松收窄(kernel onLoginProgress.state 是 string)。 */
1700
- function isLoginState(value) {
1701
- return LOGIN_STATES.includes(value);
1702
- }
1703
1979
  /** 非 IPC 模式 QR 透出标记行前缀(cli forwardFiltered 解析后终端渲染)。 */
1704
1980
  const QR_LINE_PREFIX = "NAPUTO_QR ";
1705
1981
  /** 登录参数(NAPUTO_QUICK_UIN 强制指定 / ref 目标 / qrOnly 强制扫码 / 默认)。 */
@@ -1724,36 +2000,6 @@ function buildLoginOpts(Appid, forcedUin, ref, qrOnly = false) {
1724
2000
  };
1725
2001
  return opts;
1726
2002
  }
1727
- /** control login 指令 → 重新登录(qr=true 强制扫码跳过快速登录,uin 指定账号)。
1728
- * 登录期(初始 doLogin 竞速中)成功结果经 preemptRef 接管引导链。 */
1729
- function createLoginControlHandler(core, Appid, preemptRef) {
1730
- return (payload) => {
1731
- const opts = {
1732
- appid: String(Appid),
1733
- initTimeoutMs: 2e4,
1734
- qrFallback: true,
1735
- ...payload.uin !== void 0 ? { quickUin: payload.uin } : {},
1736
- ...payload.qr === true ? { qrOnly: true } : {},
1737
- onLoginProgress: (progress) => {
1738
- if (progress.qr !== void 0) sendQr(progress.qr.pngBase64, progress.qr.qrcodeUrl);
1739
- if (isLoginState(progress.state)) sendLogin(progress.state, progress.selfInfo, progress.message);
1740
- }
1741
- };
1742
- core.login(opts).then((result) => {
1743
- if (result !== null) {
1744
- sendLogin("logged_in", {
1745
- uin: result.uin,
1746
- uid: result.uid,
1747
- nick: result.nick ?? ""
1748
- });
1749
- preemptRef?.resolve?.(result);
1750
- } else sendLogin("failed", void 0, "登录返回空结果");
1751
- }).catch((err) => {
1752
- log(`bootstrap: control login 失败: ${errMsg(err)}`);
1753
- sendLogin("failed", void 0, errMsg(err));
1754
- });
1755
- };
1756
- }
1757
2003
  /** V2 登录后替换 session:优先 QQ 主 session,其次 vehicle 单例表;自建宿主例外。 */
1758
2004
  function replaceSession(kernel, core, state, ctx) {
1759
2005
  if (env.NAPUTO_SELF_HOST === "1") return null;
@@ -1849,20 +2095,72 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
1849
2095
  const preemptLogin = new Promise((resolve) => {
1850
2096
  preempt.resolve = resolve;
1851
2097
  });
2098
+ const control = new LoginControl(preempt);
2099
+ const reassembleRef = { current: null };
1852
2100
  if (env.NAPUTO_IPC === "1") {
1853
2101
  ipcActions = createIpcActionsForCore(core);
1854
2102
  startIpcServer({
1855
2103
  actions: ipcActions,
1856
- onLogin: createLoginControlHandler(core, Appid, preempt)
2104
+ onLogin: createLoginControlHandler(core, Appid, control, { reassemble: (loginResult) => {
2105
+ const fn = reassembleRef.current;
2106
+ if (fn === null) {
2107
+ log("bootstrap: softRelogin 未就绪(理论不可达:相位机 ready 守卫)");
2108
+ return Promise.resolve(false);
2109
+ }
2110
+ return fn(loginResult);
2111
+ } })
1857
2112
  });
1858
2113
  }
1859
2114
  const ctx = core.attachWrapper(state.wrapperExports, { ...bootEnv });
1860
2115
  log(`bootstrap: attachWrapper OK, engine=${typeof ctx.engine}, session=${ctx.session !== null}`);
2116
+ let services = null;
2117
+ let stopIpcServices = null;
2118
+ let stopOb11Bridge = null;
2119
+ /**
2120
+ * 装配链尾部(登录成功后;初次引导与软重登共用)。
2121
+ * @returns 装配是否成功(协议服务未装配 = false,调用方判引导失败)。
2122
+ */
2123
+ const runAssembly = async (loginResult, initial) => {
2124
+ const chosen = replaceSession(kernel, core, state, ctx);
2125
+ await activateSession(kernel, state, ctx, chosen, loginResult, Appid, bootEnv);
2126
+ await waitSessionReady(kernel, ctx);
2127
+ if (env.NAPUTO_IPC === "1") sendStatus("sessioning");
2128
+ if (initial) await runSmokeIfEnabled(kernel, ctx, loginResult);
2129
+ const newServices = await startProtocols(kernel, ctx, loginResult);
2130
+ if (newServices === null) {
2131
+ log("bootstrap: 协议服务装配失败,引导中止");
2132
+ return false;
2133
+ }
2134
+ if (env.NAPUTO_IPC === "1" && ipcActions !== null) {
2135
+ stopIpcServices = attachIpcServices(ipcActions, newServices);
2136
+ stopOb11Bridge = await attachOb11IpcBridge(ipcActions, newServices);
2137
+ }
2138
+ services = newServices;
2139
+ return true;
2140
+ };
2141
+ /**
2142
+ * 软重登重装配(ready 态 control login 成功,relogin.ts 经 deps 调用):
2143
+ * 清理旧装配面 → 用新登录结果重跑装配链(A2,2026-09-08)。
2144
+ * 清理顺序:协议消费方(OB11 桥退订+动作表移除)→ IPC 事件转发/动作表
2145
+ * → kernel 桥/缓存/消息日志(dispose,幂等)。失败返回 false(上报退出)。
2146
+ */
2147
+ const softRelogin = async (loginResult) => {
2148
+ log(`bootstrap: 软重登 uin=${loginResult.uin}——清理旧装配并重跑装配链`);
2149
+ stopOb11Bridge?.();
2150
+ stopOb11Bridge = null;
2151
+ stopIpcServices?.();
2152
+ stopIpcServices = null;
2153
+ services?.dispose();
2154
+ services = null;
2155
+ return await runAssembly(loginResult, false);
2156
+ };
2157
+ reassembleRef.current = softRelogin;
1861
2158
  probeCreatedSession(state, ctx);
1862
2159
  startSessionProbe(state, ctx);
1863
2160
  probeSessionMethods(ctx);
1864
2161
  if (typeof core.login !== "function") {
1865
2162
  log("bootstrap: kernel core missing login fn");
2163
+ control.abort();
1866
2164
  return false;
1867
2165
  }
1868
2166
  const forcedUin = env.NAPUTO_QUICK_UIN;
@@ -1871,8 +2169,9 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
1871
2169
  await pickLoginAccount(kernel, ctx, ref, forcedUin);
1872
2170
  if (env.NAPUTO_IPC === "1") sendStatus("logging");
1873
2171
  const loginResult = await Promise.race([doLogin(core, buildLoginOpts(Appid, forcedUin, ref, qrOnly)), preemptLogin]);
1874
- preempt.resolve = null;
2172
+ control.beginAssembly();
1875
2173
  if (loginResult === null) {
2174
+ control.abort();
1876
2175
  log("bootstrap: 登录失败,引导中止");
1877
2176
  if (env.NAPUTO_IPC === "1") sendStatus("failed", "登录失败", {
1878
2177
  code: "NOT_LOGIN",
@@ -1886,19 +2185,11 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
1886
2185
  uid: loginResult.uid,
1887
2186
  nick: loginResult.nick ?? ""
1888
2187
  });
1889
- await activateSession(kernel, state, ctx, replaceSession(kernel, core, state, ctx), loginResult, Appid, bootEnv);
1890
- await waitSessionReady(kernel, ctx);
1891
- if (env.NAPUTO_IPC === "1") sendStatus("sessioning");
1892
- await runSmokeIfEnabled(kernel, ctx, loginResult);
1893
- const services = await startProtocols(kernel, ctx, loginResult);
1894
- if (services === null) {
1895
- log("bootstrap: 协议服务装配失败,引导中止");
2188
+ if (!await runAssembly(loginResult, true)) {
2189
+ control.abort();
1896
2190
  return false;
1897
2191
  }
1898
- if (env.NAPUTO_IPC === "1" && ipcActions !== null) {
1899
- attachIpcServices(ipcActions, services);
1900
- await attachOb11IpcBridge(ipcActions, services);
1901
- }
2192
+ control.markReady();
1902
2193
  runProbePhase(kernel, ctx);
1903
2194
  return true;
1904
2195
  }