botmux 2.28.0 → 2.29.0

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.
Files changed (80) hide show
  1. package/README.en.md +6 -3
  2. package/README.md +6 -3
  3. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  4. package/dist/adapters/cli/claude-code.js +26 -28
  5. package/dist/adapters/cli/claude-code.js.map +1 -1
  6. package/dist/adapters/cli/shared-hints.d.ts +3 -4
  7. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  8. package/dist/adapters/cli/shared-hints.js +14 -13
  9. package/dist/adapters/cli/shared-hints.js.map +1 -1
  10. package/dist/adapters/cli/types.d.ts +2 -0
  11. package/dist/adapters/cli/types.d.ts.map +1 -1
  12. package/dist/bot-registry.d.ts +5 -1
  13. package/dist/bot-registry.d.ts.map +1 -1
  14. package/dist/bot-registry.js +6 -1
  15. package/dist/bot-registry.js.map +1 -1
  16. package/dist/cli.js +317 -27
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config.d.ts +0 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +0 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/core/command-handler.d.ts +2 -1
  23. package/dist/core/command-handler.d.ts.map +1 -1
  24. package/dist/core/command-handler.js +139 -161
  25. package/dist/core/command-handler.js.map +1 -1
  26. package/dist/core/session-manager.d.ts +6 -2
  27. package/dist/core/session-manager.d.ts.map +1 -1
  28. package/dist/core/session-manager.js +52 -32
  29. package/dist/core/session-manager.js.map +1 -1
  30. package/dist/core/worker-pool.d.ts.map +1 -1
  31. package/dist/core/worker-pool.js +40 -17
  32. package/dist/core/worker-pool.js.map +1 -1
  33. package/dist/daemon.d.ts.map +1 -1
  34. package/dist/daemon.js +17 -16
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/global-config.d.ts +15 -0
  37. package/dist/global-config.d.ts.map +1 -0
  38. package/dist/global-config.js +73 -0
  39. package/dist/global-config.js.map +1 -0
  40. package/dist/i18n/en.d.ts +3 -0
  41. package/dist/i18n/en.d.ts.map +1 -0
  42. package/dist/i18n/en.js +251 -0
  43. package/dist/i18n/en.js.map +1 -0
  44. package/dist/i18n/index.d.ts +33 -0
  45. package/dist/i18n/index.d.ts.map +1 -0
  46. package/dist/i18n/index.js +74 -0
  47. package/dist/i18n/index.js.map +1 -0
  48. package/dist/i18n/types.d.ts +4 -0
  49. package/dist/i18n/types.d.ts.map +1 -0
  50. package/dist/i18n/types.js +5 -0
  51. package/dist/i18n/types.js.map +1 -0
  52. package/dist/i18n/zh.d.ts +6 -0
  53. package/dist/i18n/zh.d.ts.map +1 -0
  54. package/dist/i18n/zh.js +254 -0
  55. package/dist/i18n/zh.js.map +1 -0
  56. package/dist/im/lark/card-builder.d.ts +10 -9
  57. package/dist/im/lark/card-builder.d.ts.map +1 -1
  58. package/dist/im/lark/card-builder.js +58 -53
  59. package/dist/im/lark/card-builder.js.map +1 -1
  60. package/dist/im/lark/card-handler.d.ts.map +1 -1
  61. package/dist/im/lark/card-handler.js +44 -51
  62. package/dist/im/lark/card-handler.js.map +1 -1
  63. package/dist/im/lark/client.d.ts +4 -2
  64. package/dist/im/lark/client.d.ts.map +1 -1
  65. package/dist/im/lark/client.js +1 -7
  66. package/dist/im/lark/client.js.map +1 -1
  67. package/dist/im/lark/message-parser.d.ts.map +1 -1
  68. package/dist/im/lark/message-parser.js +11 -3
  69. package/dist/im/lark/message-parser.js.map +1 -1
  70. package/dist/index-daemon.js +10 -0
  71. package/dist/index-daemon.js.map +1 -1
  72. package/dist/setup/bot-config-editor.d.ts +44 -0
  73. package/dist/setup/bot-config-editor.d.ts.map +1 -0
  74. package/dist/setup/bot-config-editor.js +170 -0
  75. package/dist/setup/bot-config-editor.js.map +1 -0
  76. package/dist/types.d.ts +1 -0
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/worker.js +20 -1
  79. package/dist/worker.js.map +1 -1
  80. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -27,8 +27,20 @@ import { createHmac, randomBytes } from 'node:crypto';
27
27
  import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
28
28
  import { tmuxEnv } from './setup/ensure-tmux.js';
29
29
  import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
30
+ import { applyBotConfigEdits, assertUniqueBotProcessNames, botProcessName, normalizeBotConfig, parseBotConfigsJson, parseBotSelection, removeBotConfig, resolveCliId, } from './setup/bot-config-editor.js';
30
31
  import { logger } from './utils/logger.js';
31
32
  import { firstPositional } from './cli/arg-utils.js';
33
+ import { isLocale, setDefaultLocale, SUPPORTED_LOCALES } from './i18n/index.js';
34
+ import { readGlobalConfig, setGlobalLocale, globalConfigPath } from './global-config.js';
35
+ // Resolve the CLI's UI locale once from the global config file, so subsequent
36
+ // CLI output (and any t() callers that don't pass an explicit locale) honour
37
+ // the user's chosen language. Daemon entrypoint sets this separately for the
38
+ // daemon process.
39
+ {
40
+ const cfg = readGlobalConfig();
41
+ if (cfg.lang)
42
+ setDefaultLocale(cfg.lang);
43
+ }
32
44
  // CLI subcommands (send/thread/bots/list/etc) print JSON to stdout for
33
45
  // callers to parse. Transitive logger.info calls from shared modules would
34
46
  // corrupt that stream, so the CLI process runs silent by default. DEBUG=1
@@ -91,17 +103,29 @@ function runPm2(args, inherit = true, home = PM2_HOME) {
91
103
  function loadBotsJson() {
92
104
  if (existsSync(BOTS_JSON_FILE)) {
93
105
  try {
94
- return JSON.parse(readFileSync(BOTS_JSON_FILE, 'utf-8'));
106
+ return parseBotConfigsJson(readFileSync(BOTS_JSON_FILE, 'utf-8'), BOTS_JSON_FILE);
95
107
  }
96
- catch {
97
- return [];
108
+ catch (err) {
109
+ console.error(`❌ ${err?.message ?? String(err)}`);
110
+ process.exit(1);
98
111
  }
99
112
  }
100
113
  return [];
101
114
  }
115
+ function ensureUniqueBotProcessNames(bots) {
116
+ try {
117
+ assertUniqueBotProcessNames(bots, PM2_NAME);
118
+ }
119
+ catch (err) {
120
+ console.error(`❌ ${err?.message ?? String(err)}`);
121
+ console.error(' 请修改 bots.json 中的 name,确保进程名唯一。');
122
+ process.exit(1);
123
+ }
124
+ }
102
125
  function ecosystemConfig() {
103
126
  const daemonScript = join(PKG_ROOT, 'dist', 'index-daemon.js');
104
127
  const bots = loadBotsJson();
128
+ ensureUniqueBotProcessNames(bots);
105
129
  const baseApp = {
106
130
  script: daemonScript,
107
131
  cwd: CONFIG_DIR,
@@ -113,7 +137,7 @@ function ecosystemConfig() {
113
137
  };
114
138
  const apps = bots.map((_bot, i) => ({
115
139
  ...baseApp,
116
- name: `${PM2_NAME}-${i}`,
140
+ name: botProcessName(_bot, i, PM2_NAME),
117
141
  error_file: join(LOG_DIR, `daemon-${i}-error.log`),
118
142
  out_file: join(LOG_DIR, `daemon-${i}-out.log`),
119
143
  env: { SESSION_DATA_DIR: DATA_DIR, BOTMUX_BOT_INDEX: String(i) },
@@ -145,10 +169,18 @@ function ask(rl, question) {
145
169
  return new Promise(resolve => rl.question(question, resolve));
146
170
  }
147
171
  // ─── Setup helpers ──────────────────────────────────────────────────────────
172
+ function printInputHelp(title, lines) {
173
+ console.log(`\n${title}`);
174
+ for (const line of lines) {
175
+ console.log(` ${line}`);
176
+ }
177
+ }
148
178
  // Thin wrapper around setup/bots-store.writeBotsJsonAtomic so call-sites keep
149
179
  // the same name without passing BOTS_JSON_FILE explicitly each time.
150
180
  function writeBotsJsonAtomic(bots) {
151
- writeBotsAtomic(BOTS_JSON_FILE, bots);
181
+ const normalized = bots.map(bot => normalizeBotConfig(bot));
182
+ ensureUniqueBotProcessNames(normalized);
183
+ writeBotsAtomic(BOTS_JSON_FILE, normalized);
152
184
  }
153
185
  /**
154
186
  * 从 bot 配置里取 brand. 旧的 bots.json (1.0 之前) 没这个字段, default 到 feishu
@@ -330,8 +362,15 @@ async function promptBotConfig(rl) {
330
362
  console.log('✅ 凭证有效(tenant_access_token 已成功获取)\n');
331
363
  console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode');
332
364
  const cliChoice = await ask(rl, 'CLI 适配器 [1]: ');
333
- const cliIdMap = { '1': 'claude-code', '2': 'aiden', '3': 'coco', '4': 'codex', '5': 'cursor', '6': 'gemini', '7': 'opencode' };
334
- const cliId = cliIdMap[cliChoice] ?? (cliChoice || 'claude-code');
365
+ let cliId;
366
+ try {
367
+ cliId = resolveCliId(cliChoice) ?? 'claude-code';
368
+ }
369
+ catch (err) {
370
+ console.log(`\n❌ ${err?.message ?? String(err)}`);
371
+ console.log(' 不写 bots.json。请重新运行 botmux setup。');
372
+ return null;
373
+ }
335
374
  const workingDir = await ask(rl, '默认工作目录 [~]: ');
336
375
  // 不再持久化 brand 字段: setup 阶段 brand=lark 直接被 obtainCredentials 中止,
337
376
  // 落盘的永远是 'feishu', 写进配置是死字段. 等 lark 完整接入再加回来, 那时
@@ -350,7 +389,90 @@ async function promptBotConfig(rl) {
350
389
  // 字段即可. 手动 fallback 场景没 open_id, 字段直接不写 (== 不限制).
351
390
  if (creds.userOpenId)
352
391
  bot.allowedUsers = [creds.userOpenId];
353
- return bot;
392
+ return normalizeBotConfig(bot);
393
+ }
394
+ function formatOptionalValue(v) {
395
+ if (Array.isArray(v))
396
+ return v.join(',');
397
+ if (typeof v === 'string' && v)
398
+ return v;
399
+ return '未设置';
400
+ }
401
+ /**
402
+ * 把 bots.json 渲染成对齐的小表格. 不带行号——进程名 (botmux-N) 已经
403
+ * 是唯一可寻址的标识, 行号 + 进程名后缀 1-based / 0-based 并列容易引
404
+ * 起 off-by-one 误解 (用户曾踩过 "1. botmux-0" 这种排版).
405
+ *
406
+ * 选择机器人时直接输完整进程名 (botmux-N / botmux-custom) 或 AppID,
407
+ * parseBotSelection 不再接受裸数字, 避免又冒出 "序号到底是几" 的歧义.
408
+ */
409
+ function formatBotConfigTable(bots) {
410
+ if (bots.length === 0)
411
+ return '';
412
+ const headers = ['进程名', 'App ID', 'CLI'];
413
+ const rows = bots.map((b, i) => [
414
+ botProcessName(b, i, PM2_NAME),
415
+ String(b?.larkAppId ?? ''),
416
+ String(b?.cliId ?? 'claude-code'),
417
+ ]);
418
+ const widths = headers.map((h, c) => Math.max(displayWidth(h), ...rows.map(r => displayWidth(r[c]))));
419
+ const render = (cells) => ' ' + cells.map((cell, i) => padEndDisplay(cell, widths[i])).join(' ');
420
+ return [render(headers), ...rows.map(render)].join('\n');
421
+ }
422
+ async function promptEditBotConfig(rl, bot) {
423
+ console.log('\n字段留空表示保留当前值;可选字段输入 - 表示清空。\n');
424
+ const input = {};
425
+ printInputHelp('botmux status 显示名称', [
426
+ '可选。用于本机进程名,方便在 botmux status / logs 中识别机器人。',
427
+ '留空保留当前值;输入 - 清空自定义名称并恢复 botmux-<序号>。',
428
+ ]);
429
+ input.name = await ask(rl, `botmux status 显示名称 [${formatOptionalValue(bot.name)}]: `);
430
+ printInputHelp('LARK_APP_ID', [
431
+ '飞书开放平台应用的 App ID。修改后,这个配置项会切到另一个飞书应用。',
432
+ '留空保留当前值;修改会二次确认,因为历史会话和群聊状态不会自动迁移。',
433
+ ]);
434
+ input.larkAppId = await ask(rl, `LARK_APP_ID [${bot.larkAppId}]: `);
435
+ printInputHelp('LARK_APP_SECRET', [
436
+ '当前 App ID 对应的 App Secret。只更新密钥时填写这一项即可。',
437
+ '留空保留当前值。',
438
+ ]);
439
+ input.larkAppSecret = await ask(rl, `LARK_APP_SECRET [保留当前值]: `);
440
+ console.log('\n支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode');
441
+ printInputHelp('CLI 适配器', [
442
+ '选择 botmux 需要套用哪一种 CLI 参数协议和会话恢复方式。',
443
+ '留空保留当前值;可以输入序号,也可以直接输入适配器 ID。',
444
+ ]);
445
+ input.cliChoice = await ask(rl, `CLI 适配器 [${bot.cliId ?? 'claude-code'}]: `);
446
+ printInputHelp('CLI 可执行文件路径覆盖', [
447
+ '可选。CLI 入口的绝对路径,用于在原 CLI 外面套一层 wrapper / router。',
448
+ '典型场景:ccr (Claude Code Router) / claude-w / aiden-x-claude 等自定义入口。',
449
+ '留空保留当前值;输入 - 清空覆盖,回到 PATH 查 cliId 对应的默认二进制。',
450
+ ]);
451
+ input.cliPathOverride = await ask(rl, `CLI 可执行文件路径覆盖 [${formatOptionalValue(bot.cliPathOverride)}]: `);
452
+ printInputHelp('会话后端 backendType', [
453
+ '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着。',
454
+ '留空保留当前值;输入 - 回到自动检测;只接受 pty 或 tmux。',
455
+ ]);
456
+ input.backendType = await ask(rl, `会话后端 backendType [${formatOptionalValue(bot.backendType)}]: `);
457
+ printInputHelp('默认工作目录', [
458
+ '可选。新会话默认进入的目录,支持逗号分隔多个候选目录。',
459
+ '留空保留当前值;输入 - 清空并回到默认 ~。',
460
+ ]);
461
+ input.workingDir = await ask(rl, `默认工作目录 [${formatOptionalValue(bot.workingDir)}]: `);
462
+ printInputHelp('允许的用户', [
463
+ '可选。限制哪些飞书用户可以操作机器人,支持邮箱前缀或 open_id,多个值用逗号分隔。',
464
+ '留空保留当前值;输入 - 清空限制。',
465
+ ]);
466
+ input.allowedUsers = await ask(rl, `允许的用户 [${formatOptionalValue(bot.allowedUsers)}]: `);
467
+ const edited = applyBotConfigEdits(bot, input);
468
+ if (edited.larkAppId !== bot.larkAppId) {
469
+ console.log('\n⚠️ LARK_APP_ID 变更后,旧 appId 下的历史会话/群聊状态数据不会自动迁移。');
470
+ const confirm = (await ask(rl, `确认将 LARK_APP_ID 从 ${bot.larkAppId} 改为 ${edited.larkAppId}? (y/N): `)).trim().toLowerCase();
471
+ if (confirm !== 'y' && confirm !== 'yes') {
472
+ edited.larkAppId = bot.larkAppId;
473
+ }
474
+ }
475
+ return edited;
354
476
  }
355
477
  /** Parse .env file to extract bot config for migration to bots.json */
356
478
  function parseDotEnvToBotConfig() {
@@ -371,16 +493,14 @@ function parseDotEnvToBotConfig() {
371
493
  };
372
494
  if (vars.CLI_ID)
373
495
  bot.cliId = vars.CLI_ID;
374
- if (vars.CLI_PATH)
375
- bot.cliPathOverride = vars.CLI_PATH;
496
+ if (vars.CLI_PATH?.trim())
497
+ bot.cliPathOverride = vars.CLI_PATH.trim();
376
498
  if (vars.BACKEND_TYPE)
377
499
  bot.backendType = vars.BACKEND_TYPE;
378
500
  if (vars.WORKING_DIR)
379
501
  bot.workingDir = vars.WORKING_DIR;
380
502
  if (vars.ALLOWED_USERS)
381
503
  bot.allowedUsers = vars.ALLOWED_USERS.split(',').map((s) => s.trim()).filter(Boolean);
382
- if (vars.PROJECT_SCAN_DIR)
383
- bot.projectScanDir = vars.PROJECT_SCAN_DIR;
384
504
  return bot;
385
505
  }
386
506
  /**
@@ -413,14 +533,12 @@ async function cmdSetup() {
413
533
  console.log(`数据目录: ${DATA_DIR}\n`);
414
534
  if (hasBots) {
415
535
  // --- Multi-bot mode (bots.json exists) ---
416
- const bots = JSON.parse(readFileSync(BOTS_JSON_FILE, 'utf-8'));
417
- console.log(`已配置 ${bots.length} 个机器人:`);
418
- for (let i = 0; i < bots.length; i++) {
419
- console.log(` ${i + 1}. ${bots[i].larkAppId} (${bots[i].cliId ?? 'claude-code'})`);
420
- }
536
+ const bots = loadBotsJson();
537
+ console.log(`已配置 ${bots.length} 个机器人:\n`);
538
+ console.log(formatBotConfigTable(bots));
421
539
  console.log('');
422
540
  const rl = createInterface({ input: process.stdin, output: process.stdout });
423
- const action = await ask(rl, '操作: 1) 添加新机器人 2) 重新配置 (1/2) [1]: ');
541
+ const action = await ask(rl, '操作: 1) 添加新机器人 2) 重新配置 3) 编辑现有机器人 4) 删除机器人 (1/2/3/4) [1]: ');
424
542
  if (action === '2') {
425
543
  console.log('\n── 重新配置 ──\n');
426
544
  const newBot = await promptBotConfig(rl);
@@ -440,6 +558,79 @@ async function cmdSetup() {
440
558
  console.log(`下一步: botmux restart\n`);
441
559
  return;
442
560
  }
561
+ if (action === '3') {
562
+ console.log('\n── 编辑现有机器人 ──\n');
563
+ const selected = await ask(rl, '选择机器人(进程名 或 AppID): ');
564
+ const index = parseBotSelection(selected, bots);
565
+ if (index === undefined) {
566
+ rl.close();
567
+ console.log('\n❌ 找不到指定机器人,配置未修改。');
568
+ return;
569
+ }
570
+ const original = bots[index];
571
+ let edited;
572
+ try {
573
+ edited = await promptEditBotConfig(rl, original);
574
+ }
575
+ catch (err) {
576
+ rl.close();
577
+ console.log(`\n❌ 编辑失败: ${err?.message ?? String(err)}`);
578
+ return;
579
+ }
580
+ // 凭证字段有变化时, 像 promptBotConfig 一样跑一次 tenant_access_token
581
+ // 校验. 失败不写盘——避免编辑后 typo 一个字符, daemon 重启时才发现.
582
+ // (cmdRestart 不校验凭证, 只 cmdStart 校验, 所以编辑路径必须自己兜.)
583
+ const appIdChanged = edited.larkAppId !== original.larkAppId;
584
+ const appSecretChanged = edited.larkAppSecret !== original.larkAppSecret;
585
+ if (appIdChanged || appSecretChanged) {
586
+ console.log('\n校验新凭证(取 tenant_access_token)…');
587
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
588
+ const v = await validateCredentials(edited.larkAppId, edited.larkAppSecret, botBrand(edited));
589
+ if (!v.ok) {
590
+ rl.close();
591
+ console.log(`\n❌ 凭证校验失败 (${v.error}): ${v.message}`);
592
+ console.log(' 配置未修改。请重新运行 botmux setup → 编辑现有机器人。');
593
+ return;
594
+ }
595
+ console.log('✅ 凭证有效\n');
596
+ }
597
+ rl.close();
598
+ const nextBots = bots.slice();
599
+ nextBots[index] = edited;
600
+ copyFileSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
601
+ console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak`);
602
+ writeBotsJsonAtomic(nextBots);
603
+ console.log(`✅ 已更新机器人 ${botProcessName(edited, index, PM2_NAME)} (${edited.larkAppId})`);
604
+ // appId 切换 = 换了一个飞书应用, 新 appId 大概率需要重新申请权限 + 配重定向 URL.
605
+ // 把 printRemainingSteps 的深链端给用户, 比 README 警告里那句"历史数据不迁移"更可操作.
606
+ if (appIdChanged) {
607
+ printRemainingSteps(edited.larkAppId, botBrand(edited));
608
+ }
609
+ console.log(`下一步: botmux restart\n`);
610
+ return;
611
+ }
612
+ if (action === '4') {
613
+ console.log('\n── 删除机器人 ──\n');
614
+ const selected = await ask(rl, '选择机器人(进程名 或 AppID): ');
615
+ const result = removeBotConfig(bots, selected);
616
+ if (!result) {
617
+ rl.close();
618
+ console.log('\n❌ 找不到指定机器人,配置未修改。');
619
+ return;
620
+ }
621
+ const confirm = (await ask(rl, `确认删除 ${botProcessName(result.removed, result.index, PM2_NAME)} (${result.removed.larkAppId})? (y/N): `)).trim().toLowerCase();
622
+ rl.close();
623
+ if (confirm !== 'y' && confirm !== 'yes') {
624
+ console.log('\n已取消,配置未修改。');
625
+ return;
626
+ }
627
+ copyFileSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
628
+ console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak`);
629
+ writeBotsJsonAtomic(result.bots);
630
+ console.log(`✅ 已删除机器人 ${botProcessName(result.removed, result.index, PM2_NAME)} (${result.removed.larkAppId})`);
631
+ console.log(`下一步: botmux restart\n`);
632
+ return;
633
+ }
443
634
  console.log('\n── 添加新机器人 ──\n');
444
635
  const newBot = await promptBotConfig(rl);
445
636
  rl.close();
@@ -746,12 +937,12 @@ async function cmdRestart() {
746
937
  ensureConfigDir();
747
938
  preflightNodeSanity();
748
939
  await ensureSystemDependencies();
940
+ const cfg = ecosystemConfig();
749
941
  cleanupLegacyPm2();
750
942
  // Delete all botmux processes (handles both old single-process and new multi-process)
751
943
  deleteAllBotmuxProcesses();
752
944
  // Wipe abandoned dashboard-daemon descriptors left behind by killed daemons.
753
945
  cleanupStaleDaemonDescriptors();
754
- const cfg = ecosystemConfig();
755
946
  runPm2(['start', cfg]);
756
947
  if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
757
948
  console.log(`autostart unit 已同步到当前 Node/cli.js 路径`);
@@ -819,13 +1010,23 @@ function cmdLogs() {
819
1010
  ? process.argv[process.argv.indexOf('--lines') + 1] || '50'
820
1011
  : '50';
821
1012
  const bots = loadBotsJson();
822
- // Support --bot <index> to filter specific bot logs
1013
+ // Support --bot <0-based-index|pm2-name|appId> to filter specific bot logs.
823
1014
  const botIdx = process.argv.includes('--bot')
824
1015
  ? process.argv[process.argv.indexOf('--bot') + 1]
825
1016
  : undefined;
826
1017
  let target;
827
1018
  if (botIdx !== undefined) {
828
- target = `${PM2_NAME}-${botIdx}`;
1019
+ const numericIdx = /^\d+$/.test(botIdx) ? Number(botIdx) : undefined;
1020
+ const selectedIdx = numericIdx === undefined
1021
+ ? parseBotSelection(botIdx, bots)
1022
+ : numericIdx >= 0 && numericIdx < bots.length
1023
+ ? numericIdx
1024
+ : undefined;
1025
+ target = selectedIdx !== undefined
1026
+ ? botProcessName(bots[selectedIdx], selectedIdx, PM2_NAME)
1027
+ : numericIdx !== undefined
1028
+ ? `${PM2_NAME}-${botIdx}`
1029
+ : botIdx;
829
1030
  }
830
1031
  else {
831
1032
  // Show all botmux logs via pm2 regex match
@@ -1633,7 +1834,7 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
1633
1834
  start 启动 daemon
1634
1835
  stop 停止 daemon
1635
1836
  restart 重启 daemon(自动恢复活跃会话)
1636
- logs 查看 daemon 日志(--lines N, --bot <index>)
1837
+ logs 查看 daemon 日志(--lines N, --bot <0-based-index|pm2-name|appId>)
1637
1838
  status 查看 daemon 状态
1638
1839
  upgrade 升级到最新版本
1639
1840
  dashboard 打印新的 Web Dashboard 一次性登录 URL(旧 token 同时失效)
@@ -1647,6 +1848,9 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
1647
1848
  autostart enable 注册开机自启(macOS launchd / Linux user systemd,无需 sudo)
1648
1849
  autostart disable 注销开机自启
1649
1850
  autostart status 查看自启状态
1851
+ lang [zh|en] 切换 UI 语言(无参 = 查看当前设置)
1852
+ --bot N 仅改 bots.json 中第 N 个 bot 的 lang
1853
+ --unset 清除(global 或 --bot N 配合)
1650
1854
 
1651
1855
  定时任务(可在 CLI 会话内自动推断 chat):
1652
1856
  schedule list 列出所有任务
@@ -2591,15 +2795,11 @@ async function cmdBots(sub, rest) {
2591
2795
  botEntries = JSON.parse(readFileSync(botInfoPath, 'utf-8'));
2592
2796
  }
2593
2797
  catch { /* */ }
2594
- const botByCli = new Map();
2595
- for (const b of botEntries)
2596
- botByCli.set(b.cliId, b);
2597
2798
  try {
2598
2799
  const { listChatBotMembers } = await import('./im/lark/client.js');
2599
2800
  const chatBots = await listChatBotMembers(appId, s.chatId);
2600
2801
  const result = chatBots.map(cb => {
2601
- const info = botByCli.get(cb.name);
2602
- return { name: cb.displayName, openId: cb.openId, isSelf: info?.larkAppId === appId };
2802
+ return { name: cb.displayName, openId: cb.openId, isSelf: cb.larkAppId === appId };
2603
2803
  });
2604
2804
  console.log(JSON.stringify({ sessionId: sid, chatId: s.chatId, bots: result, total: result.length }, null, 2));
2605
2805
  }
@@ -2611,6 +2811,93 @@ async function cmdBots(sub, rest) {
2611
2811
  console.log(JSON.stringify({ sessionId: sid, bots: result, total: result.length, note: `chat query failed: ${err.message}` }, null, 2));
2612
2812
  }
2613
2813
  }
2814
+ // ─── botmux lang ─────────────────────────────────────────────────────────────
2815
+ /**
2816
+ * `botmux lang [zh|en] [--bot N] [--unset]`
2817
+ *
2818
+ * No arg → print effective locale + per-bot overrides.
2819
+ * `zh|en` → write global `~/.botmux/config.json` (or, with `--bot N`, write
2820
+ * the per-bot `lang` field in `bots.json`).
2821
+ * `--unset` → clear the global config's `lang` (or, with `--bot N`, drop
2822
+ * the per-bot override).
2823
+ *
2824
+ * On any write, hint the user to `botmux restart` so live daemons pick it up.
2825
+ */
2826
+ function cmdLang(args) {
2827
+ ensureConfigDir();
2828
+ const cfg = readGlobalConfig();
2829
+ const globalLang = cfg.lang;
2830
+ const botFlagIdx = args.indexOf('--bot');
2831
+ const botFlag = botFlagIdx >= 0 ? parseInt(args[botFlagIdx + 1] ?? '', 10) : NaN;
2832
+ const unset = args.includes('--unset');
2833
+ const positional = args.filter((a, i) => {
2834
+ if (a === '--bot')
2835
+ return false;
2836
+ if (i > 0 && args[i - 1] === '--bot')
2837
+ return false;
2838
+ if (a === '--unset')
2839
+ return false;
2840
+ return true;
2841
+ });
2842
+ const target = positional[0]?.toLowerCase();
2843
+ // No-arg → status
2844
+ if (!target && !unset) {
2845
+ const bots = loadBotsJson();
2846
+ const effective = globalLang ?? 'zh';
2847
+ console.log(`Global lang: ${globalLang ?? '(unset, defaults to zh)'}`);
2848
+ console.log(`Effective for CLI: ${effective}`);
2849
+ console.log(`Config file: ${globalConfigPath()}`);
2850
+ if (bots.length > 0) {
2851
+ console.log('\nPer-bot:');
2852
+ bots.forEach((b, i) => {
2853
+ const explicit = isLocale(b.lang) ? b.lang : undefined;
2854
+ const eff = explicit ?? effective;
2855
+ const tag = explicit ? `${explicit} (explicit override)` : `${eff} (inherits global)`;
2856
+ console.log(` ${i}. ${b.larkAppId} → ${tag}`);
2857
+ });
2858
+ }
2859
+ return;
2860
+ }
2861
+ // Per-bot operations require an existing bots.json index.
2862
+ if (!isNaN(botFlag)) {
2863
+ const bots = loadBotsJson();
2864
+ if (botFlag < 0 || botFlag >= bots.length) {
2865
+ console.error(`--bot index out of range; bots.json has ${bots.length} entry(ies). Use \`botmux lang\` to see indices.`);
2866
+ process.exit(1);
2867
+ }
2868
+ if (unset) {
2869
+ delete bots[botFlag].lang;
2870
+ writeBotsJsonAtomic(bots);
2871
+ console.log(`✅ Cleared per-bot lang for bot ${botFlag} (${bots[botFlag].larkAppId}).`);
2872
+ }
2873
+ else {
2874
+ if (!isLocale(target)) {
2875
+ console.error(`Unknown locale "${target}". Supported: ${SUPPORTED_LOCALES.join(', ')}.`);
2876
+ process.exit(1);
2877
+ }
2878
+ bots[botFlag].lang = target;
2879
+ writeBotsJsonAtomic(bots);
2880
+ console.log(`✅ Set bot ${botFlag} (${bots[botFlag].larkAppId}) lang → ${target}.`);
2881
+ }
2882
+ console.log(`Run \`botmux restart\` for changes to take effect.`);
2883
+ return;
2884
+ }
2885
+ // Global operations
2886
+ if (unset) {
2887
+ setGlobalLocale(null);
2888
+ console.log(`✅ Cleared global lang (will default to zh).`);
2889
+ console.log(`Run \`botmux restart\` for changes to take effect.`);
2890
+ return;
2891
+ }
2892
+ if (!isLocale(target)) {
2893
+ console.error(`Unknown locale "${target}". Supported: ${SUPPORTED_LOCALES.join(', ')}.`);
2894
+ console.error(`Usage: botmux lang [zh|en] [--bot N] [--unset]`);
2895
+ process.exit(1);
2896
+ }
2897
+ setGlobalLocale(target);
2898
+ console.log(`✅ Set global lang → ${target}.`);
2899
+ console.log(`Run \`botmux restart\` for changes to take effect.`);
2900
+ }
2614
2901
  // ─── Main ────────────────────────────────────────────────────────────────────
2615
2902
  function getVersion() {
2616
2903
  const pkgPath = join(PKG_ROOT, 'package.json');
@@ -2682,6 +2969,9 @@ switch (command) {
2682
2969
  case 'quoted':
2683
2970
  await cmdQuoted(process.argv.slice(3));
2684
2971
  break;
2972
+ case 'lang':
2973
+ cmdLang(process.argv.slice(3));
2974
+ break;
2685
2975
  case 'thread': {
2686
2976
  // Removed in favor of `botmux history` (普通群也兼容). Friendly stderr so
2687
2977
  // pre-rename scripts/skills surface the rename instead of "unknown command".