botmux 2.9.0 → 2.9.2

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 (330) hide show
  1. package/README.en.md +140 -76
  2. package/README.md +134 -75
  3. package/dist/adapters/backend/pty-backend.d.ts +6 -0
  4. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/pty-backend.js +10 -0
  6. package/dist/adapters/backend/pty-backend.js.map +1 -1
  7. package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
  8. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
  9. package/dist/adapters/backend/session-backend-selector.js +26 -0
  10. package/dist/adapters/backend/session-backend-selector.js.map +1 -0
  11. package/dist/adapters/backend/tmux-backend.d.ts +80 -3
  12. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.js +301 -49
  14. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  15. package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
  16. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
  17. package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
  19. package/dist/adapters/cli/aiden.d.ts.map +1 -1
  20. package/dist/adapters/cli/aiden.js +5 -0
  21. package/dist/adapters/cli/aiden.js.map +1 -1
  22. package/dist/adapters/cli/claude-code.d.ts +40 -1
  23. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  24. package/dist/adapters/cli/claude-code.js +470 -49
  25. package/dist/adapters/cli/claude-code.js.map +1 -1
  26. package/dist/adapters/cli/coco.d.ts.map +1 -1
  27. package/dist/adapters/cli/coco.js +191 -9
  28. package/dist/adapters/cli/coco.js.map +1 -1
  29. package/dist/adapters/cli/codex.d.ts.map +1 -1
  30. package/dist/adapters/cli/codex.js +94 -17
  31. package/dist/adapters/cli/codex.js.map +1 -1
  32. package/dist/adapters/cli/shared-hints.d.ts +2 -2
  33. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  34. package/dist/adapters/cli/shared-hints.js +7 -5
  35. package/dist/adapters/cli/shared-hints.js.map +1 -1
  36. package/dist/adapters/cli/types.d.ts +38 -1
  37. package/dist/adapters/cli/types.d.ts.map +1 -1
  38. package/dist/autostart.d.ts +14 -0
  39. package/dist/autostart.d.ts.map +1 -0
  40. package/dist/autostart.js +357 -0
  41. package/dist/autostart.js.map +1 -0
  42. package/dist/bot-registry.d.ts +29 -3
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +91 -12
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli/arg-utils.d.ts +11 -0
  47. package/dist/cli/arg-utils.d.ts.map +1 -0
  48. package/dist/cli/arg-utils.js +25 -0
  49. package/dist/cli/arg-utils.js.map +1 -0
  50. package/dist/cli/create-group-resolver.d.ts +32 -0
  51. package/dist/cli/create-group-resolver.d.ts.map +1 -0
  52. package/dist/cli/create-group-resolver.js +70 -0
  53. package/dist/cli/create-group-resolver.js.map +1 -0
  54. package/dist/cli/quoted-render.d.ts +30 -0
  55. package/dist/cli/quoted-render.d.ts.map +1 -0
  56. package/dist/cli/quoted-render.js +29 -0
  57. package/dist/cli/quoted-render.js.map +1 -0
  58. package/dist/cli.js +916 -272
  59. package/dist/cli.js.map +1 -1
  60. package/dist/config.d.ts +6 -0
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +18 -8
  63. package/dist/config.js.map +1 -1
  64. package/dist/core/command-handler.d.ts +43 -0
  65. package/dist/core/command-handler.d.ts.map +1 -1
  66. package/dist/core/command-handler.js +167 -64
  67. package/dist/core/command-handler.js.map +1 -1
  68. package/dist/core/dashboard-events.d.ts +57 -0
  69. package/dist/core/dashboard-events.d.ts.map +1 -0
  70. package/dist/core/dashboard-events.js +23 -0
  71. package/dist/core/dashboard-events.js.map +1 -0
  72. package/dist/core/dashboard-ipc-server.d.ts +43 -0
  73. package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
  74. package/dist/core/dashboard-ipc-server.js +481 -0
  75. package/dist/core/dashboard-ipc-server.js.map +1 -0
  76. package/dist/core/dashboard-locate.d.ts +20 -0
  77. package/dist/core/dashboard-locate.d.ts.map +1 -0
  78. package/dist/core/dashboard-locate.js +26 -0
  79. package/dist/core/dashboard-locate.js.map +1 -0
  80. package/dist/core/dashboard-rows.d.ts +31 -0
  81. package/dist/core/dashboard-rows.d.ts.map +1 -0
  82. package/dist/core/dashboard-rows.js +65 -0
  83. package/dist/core/dashboard-rows.js.map +1 -0
  84. package/dist/core/inherit-peer.d.ts +14 -0
  85. package/dist/core/inherit-peer.d.ts.map +1 -0
  86. package/dist/core/inherit-peer.js +32 -0
  87. package/dist/core/inherit-peer.js.map +1 -0
  88. package/dist/core/scheduler.d.ts +24 -0
  89. package/dist/core/scheduler.d.ts.map +1 -1
  90. package/dist/core/scheduler.js +93 -2
  91. package/dist/core/scheduler.js.map +1 -1
  92. package/dist/core/session-activity.d.ts +3 -0
  93. package/dist/core/session-activity.d.ts.map +1 -0
  94. package/dist/core/session-activity.js +20 -0
  95. package/dist/core/session-activity.js.map +1 -0
  96. package/dist/core/session-discovery.d.ts +39 -0
  97. package/dist/core/session-discovery.d.ts.map +1 -1
  98. package/dist/core/session-discovery.js +114 -21
  99. package/dist/core/session-discovery.js.map +1 -1
  100. package/dist/core/session-manager.d.ts +72 -0
  101. package/dist/core/session-manager.d.ts.map +1 -1
  102. package/dist/core/session-manager.js +396 -106
  103. package/dist/core/session-manager.js.map +1 -1
  104. package/dist/core/types.d.ts +27 -2
  105. package/dist/core/types.d.ts.map +1 -1
  106. package/dist/core/types.js +14 -3
  107. package/dist/core/types.js.map +1 -1
  108. package/dist/core/worker-pool.d.ts +72 -3
  109. package/dist/core/worker-pool.d.ts.map +1 -1
  110. package/dist/core/worker-pool.js +459 -38
  111. package/dist/core/worker-pool.js.map +1 -1
  112. package/dist/daemon.d.ts.map +1 -1
  113. package/dist/daemon.js +601 -309
  114. package/dist/daemon.js.map +1 -1
  115. package/dist/dashboard/aggregator.d.ts +41 -0
  116. package/dist/dashboard/aggregator.d.ts.map +1 -0
  117. package/dist/dashboard/aggregator.js +125 -0
  118. package/dist/dashboard/aggregator.js.map +1 -0
  119. package/dist/dashboard/auth.d.ts +23 -0
  120. package/dist/dashboard/auth.d.ts.map +1 -0
  121. package/dist/dashboard/auth.js +66 -0
  122. package/dist/dashboard/auth.js.map +1 -0
  123. package/dist/dashboard/operator-selector.d.ts +20 -0
  124. package/dist/dashboard/operator-selector.d.ts.map +1 -0
  125. package/dist/dashboard/operator-selector.js +39 -0
  126. package/dist/dashboard/operator-selector.js.map +1 -0
  127. package/dist/dashboard/registry.d.ts +35 -0
  128. package/dist/dashboard/registry.d.ts.map +1 -0
  129. package/dist/dashboard/registry.js +74 -0
  130. package/dist/dashboard/registry.js.map +1 -0
  131. package/dist/dashboard/web/app.d.ts +2 -0
  132. package/dist/dashboard/web/app.d.ts.map +1 -0
  133. package/dist/dashboard/web/app.js +45 -0
  134. package/dist/dashboard/web/app.js.map +1 -0
  135. package/dist/dashboard/web/bot-defaults.d.ts +2 -0
  136. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
  137. package/dist/dashboard/web/bot-defaults.js +201 -0
  138. package/dist/dashboard/web/bot-defaults.js.map +1 -0
  139. package/dist/dashboard/web/groups.d.ts +16 -0
  140. package/dist/dashboard/web/groups.d.ts.map +1 -0
  141. package/dist/dashboard/web/groups.js +584 -0
  142. package/dist/dashboard/web/groups.js.map +1 -0
  143. package/dist/dashboard/web/schedules.d.ts +2 -0
  144. package/dist/dashboard/web/schedules.d.ts.map +1 -0
  145. package/dist/dashboard/web/schedules.js +105 -0
  146. package/dist/dashboard/web/schedules.js.map +1 -0
  147. package/dist/dashboard/web/sessions.d.ts +2 -0
  148. package/dist/dashboard/web/sessions.d.ts.map +1 -0
  149. package/dist/dashboard/web/sessions.js +374 -0
  150. package/dist/dashboard/web/sessions.js.map +1 -0
  151. package/dist/dashboard/web/store.d.ts +23 -0
  152. package/dist/dashboard/web/store.d.ts.map +1 -0
  153. package/dist/dashboard/web/store.js +82 -0
  154. package/dist/dashboard/web/store.js.map +1 -0
  155. package/dist/dashboard-web/app.js +263 -0
  156. package/dist/dashboard-web/index.html +23 -0
  157. package/dist/dashboard-web/style.css +93 -0
  158. package/dist/dashboard.d.ts +2 -0
  159. package/dist/dashboard.d.ts.map +1 -0
  160. package/dist/dashboard.js +639 -0
  161. package/dist/dashboard.js.map +1 -0
  162. package/dist/im/lark/card-builder.d.ts +18 -1
  163. package/dist/im/lark/card-builder.d.ts.map +1 -1
  164. package/dist/im/lark/card-builder.js +70 -9
  165. package/dist/im/lark/card-builder.js.map +1 -1
  166. package/dist/im/lark/card-handler.d.ts.map +1 -1
  167. package/dist/im/lark/card-handler.js +123 -109
  168. package/dist/im/lark/card-handler.js.map +1 -1
  169. package/dist/im/lark/client.d.ts +36 -0
  170. package/dist/im/lark/client.d.ts.map +1 -1
  171. package/dist/im/lark/client.js +119 -13
  172. package/dist/im/lark/client.js.map +1 -1
  173. package/dist/im/lark/event-dispatcher.d.ts +92 -8
  174. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  175. package/dist/im/lark/event-dispatcher.js +410 -89
  176. package/dist/im/lark/event-dispatcher.js.map +1 -1
  177. package/dist/im/lark/forwarded-renderer.d.ts +23 -0
  178. package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
  179. package/dist/im/lark/forwarded-renderer.js +105 -0
  180. package/dist/im/lark/forwarded-renderer.js.map +1 -0
  181. package/dist/im/lark/md-card.d.ts +73 -0
  182. package/dist/im/lark/md-card.d.ts.map +1 -0
  183. package/dist/im/lark/md-card.js +332 -0
  184. package/dist/im/lark/md-card.js.map +1 -0
  185. package/dist/im/lark/merge-forward.d.ts +32 -0
  186. package/dist/im/lark/merge-forward.d.ts.map +1 -0
  187. package/dist/im/lark/merge-forward.js +110 -0
  188. package/dist/im/lark/merge-forward.js.map +1 -0
  189. package/dist/im/lark/message-parser.d.ts +9 -3
  190. package/dist/im/lark/message-parser.d.ts.map +1 -1
  191. package/dist/im/lark/message-parser.js +48 -13
  192. package/dist/im/lark/message-parser.js.map +1 -1
  193. package/dist/im/lark/quote-hint.d.ts +18 -0
  194. package/dist/im/lark/quote-hint.d.ts.map +1 -0
  195. package/dist/im/lark/quote-hint.js +23 -0
  196. package/dist/im/lark/quote-hint.js.map +1 -0
  197. package/dist/services/bridge-fallback-gate.d.ts +42 -0
  198. package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
  199. package/dist/services/bridge-fallback-gate.js +12 -0
  200. package/dist/services/bridge-fallback-gate.js.map +1 -0
  201. package/dist/services/bridge-rotation-policy.d.ts +139 -0
  202. package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
  203. package/dist/services/bridge-rotation-policy.js +125 -0
  204. package/dist/services/bridge-rotation-policy.js.map +1 -0
  205. package/dist/services/bridge-turn-queue.d.ts +154 -0
  206. package/dist/services/bridge-turn-queue.d.ts.map +1 -0
  207. package/dist/services/bridge-turn-queue.js +316 -0
  208. package/dist/services/bridge-turn-queue.js.map +1 -0
  209. package/dist/services/chat-first-seen-store.d.ts +27 -0
  210. package/dist/services/chat-first-seen-store.d.ts.map +1 -0
  211. package/dist/services/chat-first-seen-store.js +114 -0
  212. package/dist/services/chat-first-seen-store.js.map +1 -0
  213. package/dist/services/claude-transcript.d.ts +268 -0
  214. package/dist/services/claude-transcript.d.ts.map +1 -0
  215. package/dist/services/claude-transcript.js +798 -0
  216. package/dist/services/claude-transcript.js.map +1 -0
  217. package/dist/services/coco-transcript.d.ts +35 -0
  218. package/dist/services/coco-transcript.d.ts.map +1 -0
  219. package/dist/services/coco-transcript.js +192 -0
  220. package/dist/services/coco-transcript.js.map +1 -0
  221. package/dist/services/codex-bridge-queue.d.ts +56 -0
  222. package/dist/services/codex-bridge-queue.d.ts.map +1 -0
  223. package/dist/services/codex-bridge-queue.js +150 -0
  224. package/dist/services/codex-bridge-queue.js.map +1 -0
  225. package/dist/services/codex-transcript.d.ts +84 -0
  226. package/dist/services/codex-transcript.d.ts.map +1 -0
  227. package/dist/services/codex-transcript.js +298 -0
  228. package/dist/services/codex-transcript.js.map +1 -0
  229. package/dist/services/group-creator.d.ts +23 -0
  230. package/dist/services/group-creator.d.ts.map +1 -0
  231. package/dist/services/group-creator.js +75 -0
  232. package/dist/services/group-creator.js.map +1 -0
  233. package/dist/services/groups-store.d.ts +98 -0
  234. package/dist/services/groups-store.d.ts.map +1 -0
  235. package/dist/services/groups-store.js +213 -0
  236. package/dist/services/groups-store.js.map +1 -0
  237. package/dist/services/oncall-store.d.ts +80 -8
  238. package/dist/services/oncall-store.d.ts.map +1 -1
  239. package/dist/services/oncall-store.js +265 -55
  240. package/dist/services/oncall-store.js.map +1 -1
  241. package/dist/services/project-scanner.d.ts +1 -2
  242. package/dist/services/project-scanner.d.ts.map +1 -1
  243. package/dist/services/project-scanner.js +118 -68
  244. package/dist/services/project-scanner.js.map +1 -1
  245. package/dist/services/schedule-store.d.ts +5 -0
  246. package/dist/services/schedule-store.d.ts.map +1 -1
  247. package/dist/services/schedule-store.js +77 -1
  248. package/dist/services/schedule-store.js.map +1 -1
  249. package/dist/services/session-store.d.ts +22 -0
  250. package/dist/services/session-store.d.ts.map +1 -1
  251. package/dist/services/session-store.js +62 -4
  252. package/dist/services/session-store.js.map +1 -1
  253. package/dist/setup/bots-store.d.ts +3 -0
  254. package/dist/setup/bots-store.d.ts.map +1 -0
  255. package/dist/setup/bots-store.js +24 -0
  256. package/dist/setup/bots-store.js.map +1 -0
  257. package/dist/setup/detect-platform.d.ts +14 -0
  258. package/dist/setup/detect-platform.d.ts.map +1 -0
  259. package/dist/setup/detect-platform.js +139 -0
  260. package/dist/setup/detect-platform.js.map +1 -0
  261. package/dist/setup/ensure-fonts.d.ts +13 -0
  262. package/dist/setup/ensure-fonts.d.ts.map +1 -0
  263. package/dist/setup/ensure-fonts.js +225 -0
  264. package/dist/setup/ensure-fonts.js.map +1 -0
  265. package/dist/setup/ensure-tmux.d.ts +60 -0
  266. package/dist/setup/ensure-tmux.d.ts.map +1 -0
  267. package/dist/setup/ensure-tmux.js +236 -0
  268. package/dist/setup/ensure-tmux.js.map +1 -0
  269. package/dist/setup/index.d.ts +9 -0
  270. package/dist/setup/index.d.ts.map +1 -0
  271. package/dist/setup/index.js +46 -0
  272. package/dist/setup/index.js.map +1 -0
  273. package/dist/setup/lark-scopes.json +301 -0
  274. package/dist/setup/register-app.d.ts +52 -0
  275. package/dist/setup/register-app.d.ts.map +1 -0
  276. package/dist/setup/register-app.js +91 -0
  277. package/dist/setup/register-app.js.map +1 -0
  278. package/dist/setup/verify-permissions.d.ts +115 -0
  279. package/dist/setup/verify-permissions.d.ts.map +1 -0
  280. package/dist/setup/verify-permissions.js +207 -0
  281. package/dist/setup/verify-permissions.js.map +1 -0
  282. package/dist/skills/definitions.d.ts +4 -0
  283. package/dist/skills/definitions.d.ts.map +1 -1
  284. package/dist/skills/definitions.js +133 -19
  285. package/dist/skills/definitions.js.map +1 -1
  286. package/dist/skills/installer.d.ts +3 -1
  287. package/dist/skills/installer.d.ts.map +1 -1
  288. package/dist/skills/installer.js +18 -3
  289. package/dist/skills/installer.js.map +1 -1
  290. package/dist/types.d.ts +44 -0
  291. package/dist/types.d.ts.map +1 -1
  292. package/dist/utils/bot-routing.d.ts +6 -0
  293. package/dist/utils/bot-routing.d.ts.map +1 -0
  294. package/dist/utils/bot-routing.js +50 -0
  295. package/dist/utils/bot-routing.js.map +1 -0
  296. package/dist/utils/file-lock.d.ts +2 -0
  297. package/dist/utils/file-lock.d.ts.map +1 -0
  298. package/dist/utils/file-lock.js +114 -0
  299. package/dist/utils/file-lock.js.map +1 -0
  300. package/dist/utils/font-installer.js +1 -1
  301. package/dist/utils/font-installer.js.map +1 -1
  302. package/dist/utils/idle-detector.d.ts +6 -0
  303. package/dist/utils/idle-detector.d.ts.map +1 -1
  304. package/dist/utils/idle-detector.js +25 -4
  305. package/dist/utils/idle-detector.js.map +1 -1
  306. package/dist/utils/logger.d.ts +10 -0
  307. package/dist/utils/logger.d.ts.map +1 -1
  308. package/dist/utils/logger.js +60 -8
  309. package/dist/utils/logger.js.map +1 -1
  310. package/dist/utils/render-dimensions.d.ts +48 -0
  311. package/dist/utils/render-dimensions.d.ts.map +1 -0
  312. package/dist/utils/render-dimensions.js +55 -0
  313. package/dist/utils/render-dimensions.js.map +1 -0
  314. package/dist/utils/screen-analyzer.d.ts.map +1 -1
  315. package/dist/utils/screen-analyzer.js +24 -0
  316. package/dist/utils/screen-analyzer.js.map +1 -1
  317. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  318. package/dist/utils/screenshot-renderer.js +67 -23
  319. package/dist/utils/screenshot-renderer.js.map +1 -1
  320. package/dist/utils/terminal-renderer.d.ts +16 -0
  321. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  322. package/dist/utils/terminal-renderer.js +40 -23
  323. package/dist/utils/terminal-renderer.js.map +1 -1
  324. package/dist/utils/transient-snapshot.d.ts +28 -0
  325. package/dist/utils/transient-snapshot.d.ts.map +1 -0
  326. package/dist/utils/transient-snapshot.js +96 -0
  327. package/dist/utils/transient-snapshot.js.map +1 -0
  328. package/dist/worker.js +2220 -83
  329. package/dist/worker.js.map +1 -1
  330. package/package.json +12 -5
package/dist/cli.js CHANGED
@@ -14,14 +14,26 @@
14
14
  * botmux list --plain — plain table output (for piping / scripts)
15
15
  * botmux delete <id> — close a session by ID prefix
16
16
  * botmux delete all — close all active sessions
17
+ * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd)
17
18
  */
18
19
  import { execSync, spawnSync, spawn } from 'node:child_process';
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs';
20
21
  import { join, dirname } from 'node:path';
21
22
  import { homedir } from 'node:os';
22
23
  import { fileURLToPath } from 'node:url';
23
24
  import { createInterface } from 'node:readline';
24
25
  import { createRequire } from 'node:module';
26
+ import { createHmac, randomBytes } from 'node:crypto';
27
+ import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
28
+ import { tmuxEnv } from './setup/ensure-tmux.js';
29
+ import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
30
+ import { logger } from './utils/logger.js';
31
+ import { firstPositional } from './cli/arg-utils.js';
32
+ // CLI subcommands (send/thread/bots/list/etc) print JSON to stdout for
33
+ // callers to parse. Transitive logger.info calls from shared modules would
34
+ // corrupt that stream, so the CLI process runs silent by default. DEBUG=1
35
+ // re-enables logging end-to-end for CLI troubleshooting.
36
+ logger.setSilent(true);
25
37
  const __filename = fileURLToPath(import.meta.url);
26
38
  const __dirname = dirname(__filename);
27
39
  const require = createRequire(import.meta.url);
@@ -106,6 +118,21 @@ function ecosystemConfig() {
106
118
  out_file: join(LOG_DIR, `daemon-${i}-out.log`),
107
119
  env: { SESSION_DATA_DIR: DATA_DIR, BOTMUX_BOT_INDEX: String(i) },
108
120
  }));
121
+ apps.push({
122
+ name: 'botmux-dashboard',
123
+ script: join(PKG_ROOT, 'dist', 'dashboard.js'),
124
+ cwd: PKG_ROOT,
125
+ autorestart: true,
126
+ max_restarts: 10,
127
+ restart_delay: 3000,
128
+ error_file: join(LOG_DIR, 'dashboard-error.log'),
129
+ out_file: join(LOG_DIR, 'dashboard-out.log'),
130
+ merge_logs: true,
131
+ env: {
132
+ BOTMUX_DASHBOARD_HOST: process.env.BOTMUX_DASHBOARD_HOST ?? '0.0.0.0',
133
+ BOTMUX_DASHBOARD_PORT: process.env.BOTMUX_DASHBOARD_PORT ?? '7891',
134
+ },
135
+ });
109
136
  const cfg = { apps };
110
137
  const tmpFile = join(CONFIG_DIR, 'ecosystem.config.json');
111
138
  writeFileSync(tmpFile, JSON.stringify(cfg, null, 2));
@@ -118,32 +145,211 @@ function ask(rl, question) {
118
145
  return new Promise(resolve => rl.question(question, resolve));
119
146
  }
120
147
  // ─── Setup helpers ──────────────────────────────────────────────────────────
121
- function printLarkPermissions() {
122
- console.log('请先在飞书开放平台创建应用: https://open.feishu.cn/app\n');
123
- console.log('需要的权限:');
124
- console.log(' - im:message (发送/接收消息)');
125
- console.log(' - im:message.group_at_msg (群消息)');
126
- console.log(' - im:resource (文件下载)');
127
- console.log(' - im:chat (群信息)');
128
- console.log(' - contact:user.base:readonly (用户信息)\n');
129
- console.log('启用事件订阅 (WebSocket 模式):');
130
- console.log(' - im.message.receive_v1');
131
- console.log(' - card.action.trigger\n');
148
+ // Thin wrapper around setup/bots-store.writeBotsJsonAtomic so call-sites keep
149
+ // the same name without passing BOTS_JSON_FILE explicitly each time.
150
+ function writeBotsJsonAtomic(bots) {
151
+ writeBotsAtomic(BOTS_JSON_FILE, bots);
152
+ }
153
+ /**
154
+ * 从 bot 配置里取 brand. 旧的 bots.json (1.0 之前) 没这个字段, default 到 feishu
155
+ * 保留向后兼容. cmdStart 凭证校验 + printRemainingSteps 深链都靠它选 host.
156
+ */
157
+ function botBrand(b) {
158
+ return b?.brand === 'lark' ? 'lark' : 'feishu';
159
+ }
160
+ /**
161
+ * 把 botmux 推荐的完整 scope JSON (从 src/setup/lark-scopes.json) 写到
162
+ * 用户配置目录, 同时给出跨平台一键复制命令. JSON 长 (293 项, 297 行),
163
+ * terminal 直接打印用户也复制不了, 写文件 + pbcopy/xclip 才是顺手的姿势.
164
+ *
165
+ * Returns: 写出的 JSON 文件绝对路径.
166
+ */
167
+ function writeScopesJsonToConfigDir() {
168
+ // build script 会把 src/setup/lark-scopes.json copy 到 dist/setup/.
169
+ // dist 模式下 __dirname 是 dist/, 找 ./setup/lark-scopes.json; dev (tsx)
170
+ // 模式找 src/setup/lark-scopes.json 在源码同目录也成立.
171
+ const here = dirname(fileURLToPath(import.meta.url));
172
+ const srcCandidates = [
173
+ join(here, 'setup', 'lark-scopes.json'),
174
+ join(here, '..', 'src', 'setup', 'lark-scopes.json'),
175
+ ];
176
+ let scopesPath = srcCandidates[0];
177
+ for (const p of srcCandidates) {
178
+ if (existsSync(p)) {
179
+ scopesPath = p;
180
+ break;
181
+ }
182
+ }
183
+ const destPath = join(CONFIG_DIR, 'lark-scopes.json');
184
+ copyFileSync(scopesPath, destPath);
185
+ return destPath;
186
+ }
187
+ function printCopyHint(filePath) {
188
+ // 环境感知: SSH/headless 没有 X server, xclip 一定报 "Can't open display".
189
+ // 这种场景下"剪贴板"在用户本地 (运行 SSH 客户端的那台机器), 远程机上能做的:
190
+ // - 直接 cat, 让用户在本地 terminal 鼠标选中 (SSH 选中即写本地剪贴板)
191
+ // - OSC 52: terminal app 代写本地剪贴板, iTerm2 / kitty / WezTerm /
192
+ // Alacritty / tmux 1.5+ 都支持, gnome-terminal / Terminal.app 不支持
193
+ // 检测 DISPLAY (X11) 或 WAYLAND_DISPLAY 都没有, 或 SSH_* 环境变量存在
194
+ // → 当作 SSH 场景, 不推荐 xclip / pbcopy.
195
+ const isSsh = !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
196
+ const hasLocalGui = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) && !isSsh;
197
+ const isMacLocal = process.platform === 'darwin' && !isSsh;
198
+ console.log(' 把 JSON 内容拷到本地剪贴板, 然后到飞书"批量导入/导出权限"页粘贴:');
199
+ if (isMacLocal) {
200
+ console.log(` macOS 本地: cat ${filePath} | pbcopy`);
201
+ }
202
+ else if (hasLocalGui) {
203
+ console.log(` Linux 本地 (X 服务器): cat ${filePath} | xclip -selection clipboard`);
204
+ }
205
+ else {
206
+ // SSH / headless: 鼠标选中是最稳的, OSC 52 作为高级选项
207
+ console.log(` SSH 终端鼠标选中复制: cat ${filePath}`);
208
+ console.log(' (终端把选中的字符直接写到你本地剪贴板, 不依赖远端剪贴板工具)');
209
+ console.log(` 或 OSC 52 (兼容 iTerm2 / kitty / WezTerm / Alacritty / tmux 1.5+):`);
210
+ console.log(` base64 -w0 < ${filePath} | awk 'BEGIN{printf "\\033]52;c;"}{printf "%s",$0}END{printf "\\a"}'`);
211
+ }
212
+ console.log('');
132
213
  }
214
+ function printRemainingSteps(appId, brand) {
215
+ // PersonalAgent 应用扫码建出来时已默认订阅 im.message.receive_v1 +
216
+ // card.action.trigger, 并开通 bot 能力, 主线只剩两步: 申请权限 + 重定向
217
+ // URL (按需). README "Step 8 收不到消息时" 段提供 fallback 自查链接.
218
+ const host = brand === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
219
+ const home = `https://${host}/app/${appId}`;
220
+ let scopesJsonPath = '';
221
+ try {
222
+ scopesJsonPath = writeScopesJsonToConfigDir();
223
+ }
224
+ catch (err) {
225
+ // 不应阻止 setup 完成, 只 WARN
226
+ console.log(`\n⚠️ 写权限 JSON 失败 (${err.message}), 请手动从仓库源码 src/setup/lark-scopes.json 拷.`);
227
+ }
228
+ console.log('\n剩余两步在开放平台完成:\n');
229
+ console.log(' 1. 申请权限 (一次性导入完整 JSON 提交审批)');
230
+ console.log(` 申请链接: ${home}/auth → 进入「权限管理」→「批量导入/导出权限」→ 粘贴 → 提交`);
231
+ if (scopesJsonPath) {
232
+ console.log(` 权限 JSON: ${scopesJsonPath}`);
233
+ printCopyHint(scopesJsonPath);
234
+ }
235
+ console.log('');
236
+ console.log(' 2. 添加重定向 URL (用于 botmux 内 `/login` 拿用户 UAT 获取卡片消息)');
237
+ console.log(` 申请链接: ${home}/safe → 进入「安全设置」→「重定向 URL」`);
238
+ console.log(' 填入: http://127.0.0.1:9768/callback');
239
+ console.log(' 不需要 `/login` 拿卡片消息的话, 这一步可以跳过.\n');
240
+ console.log(' 完成后 `botmux start` (或 `botmux restart`),启动检查不会卡住,');
241
+ console.log(' 缺权限只 WARN,去开放平台补齐后 daemon 自动恢复。\n');
242
+ }
243
+ /**
244
+ * 让用户选"扫码建应用"还是"手动粘 AppID/Secret".
245
+ *
246
+ * 默认走扫码: 调 SDK `registerApp` → 拿 client_id/client_secret. 失败 (用户拒绝/
247
+ * 超时/网络/取消) 一律降级到手动, 不阻塞流程.
248
+ *
249
+ * Codex review 边界:
250
+ * - secret 不进 argv / 日志 / 错误链 (registerApp 内部 safeMsg 已做; 手动模式下
251
+ * AppSecret 通过 rl.question 异步读取, 不会出现在 process.argv)
252
+ * - 任何失败都返回结构化对象, 不抛 (调用方根据 ok=false 回退)
253
+ */
254
+ async function obtainCredentials(rl) {
255
+ console.log('── 飞书应用建立 ──\n');
256
+ console.log('1) 扫码建应用(推荐,一步拿到 AppID/Secret,需要飞书 App 扫码)');
257
+ console.log('2) 手动粘 AppID/Secret(已经在开放平台创建好应用了)\n');
258
+ const choice = (await ask(rl, '选择 [1]: ')).trim();
259
+ if (choice !== '2') {
260
+ // 动态导入避免冷启动加载 SDK
261
+ const { tryRegisterApp } = await import('./setup/register-app.js');
262
+ const result = await tryRegisterApp();
263
+ if (result.ok) {
264
+ // Lark 国际版需要 daemon 链路全程走 larksuite.com 域 (Client domain /
265
+ // WSClient / event-dispatcher 的 fetch URL / scope 深链 host). 当前
266
+ // botmux runtime 这几处都硬编码 feishu.cn, 所以即使扫码成功了也无法
267
+ // 真正跑起来. 干净做法是 setup 阶段就拒绝, 让用户用 feishu 租户. 单
268
+ // 独 PR 完整接入 lark 后再去掉这个分支.
269
+ if (result.brand === 'lark') {
270
+ console.log(`\n❌ 检测到 Lark 国际版 (larksuite.com) 租户。`);
271
+ console.log(` botmux 当前 daemon 运行链路仅支持飞书 (feishu.cn) 租户,`);
272
+ console.log(` Lark 国际版完整接入会在单独 PR 跟进 (BotConfig / Client domain /`);
273
+ console.log(` WSClient / event-dispatcher 等需要一并支持).`);
274
+ console.log(` 请用飞书 (feishu.cn) 租户重试 setup。\n`);
275
+ return { ok: false, reason: 'lark_unsupported' };
276
+ }
277
+ console.log(`\n✅ 应用创建成功`);
278
+ console.log(` App ID: ${result.appId}`);
279
+ console.log(` 租户类型: ${result.brand}`);
280
+ if (result.userOpenId) {
281
+ console.log(` 扫码人 open_id: ${result.userOpenId}(将默认作为 allowedUsers)`);
282
+ }
283
+ return {
284
+ ok: true,
285
+ appId: result.appId,
286
+ appSecret: result.appSecret,
287
+ brand: result.brand,
288
+ userOpenId: result.userOpenId,
289
+ };
290
+ }
291
+ console.log(`\n⚠️ 扫码失败 (${result.error}): ${result.message}`);
292
+ if (result.error === 'aborted') {
293
+ // 用户主动取消整个 setup, 不再问手动 fallback
294
+ return { ok: false, reason: 'cancelled' };
295
+ }
296
+ console.log(' 降级到手动输入 AppID/Secret。\n');
297
+ }
298
+ else {
299
+ console.log('\n请在浏览器打开 https://open.feishu.cn/app 创建应用,然后回来粘 ID/Secret。\n');
300
+ }
301
+ // 手动 fallback. 不再提问租户类型 — 当前 daemon runtime 只支持 feishu,
302
+ // 让用户选 lark 是误导. 等 lark 完整接入再加回来.
303
+ const appId = (await ask(rl, 'AppID (cli_xxx): ')).trim();
304
+ const appSecret = (await ask(rl, 'AppSecret: ')).trim();
305
+ if (!appId || !appSecret) {
306
+ console.log('\n❌ AppID/AppSecret 不能为空,setup 中止。');
307
+ return { ok: false, reason: 'cancelled' };
308
+ }
309
+ return { ok: true, appId, appSecret, brand: 'feishu' };
310
+ }
311
+ /**
312
+ * 收集一个机器人完整配置 (凭证 + CLI/工作目录/allowedUsers).
313
+ *
314
+ * 顺序: 拿凭证 → tenant_access_token 验证 → 通过才返回 bot 对象. 验证失败
315
+ * 直接返回 null, 调用方负责"不写 bots.json". Codex review 边界 #2.
316
+ */
133
317
  async function promptBotConfig(rl) {
134
- const appId = await ask(rl, 'LARK_APP_ID: ');
135
- const appSecret = await ask(rl, 'LARK_APP_SECRET: ');
136
- console.log('\n支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
318
+ const creds = await obtainCredentials(rl);
319
+ if (!creds.ok)
320
+ return null;
321
+ // 凭证立刻验证. 通不过不写 bots.json.
322
+ console.log('\n校验凭证(取 tenant_access_token)…');
323
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
324
+ const v = await validateCredentials(creds.appId, creds.appSecret, creds.brand);
325
+ if (!v.ok) {
326
+ console.log(`\n❌ 凭证校验失败 (${v.error}): ${v.message}`);
327
+ console.log(' 不写 bots.json。请重新运行 botmux setup。');
328
+ return null;
329
+ }
330
+ console.log('✅ 凭证有效(tenant_access_token 已成功获取)\n');
331
+ console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
137
332
  const cliChoice = await ask(rl, 'CLI 适配器 [1]: ');
138
333
  const cliIdMap = { '1': 'claude-code', '2': 'aiden', '3': 'coco', '4': 'codex', '5': 'gemini', '6': 'opencode' };
139
334
  const cliId = cliIdMap[cliChoice] ?? (cliChoice || 'claude-code');
140
335
  const workingDir = await ask(rl, '默认工作目录 [~]: ');
141
- const allowedUsers = await ask(rl, '允许的用户 (邮箱或 open_id,逗号分隔,留空=不限制): ');
142
- const bot = { larkAppId: appId, larkAppSecret: appSecret, cliId };
143
- if (workingDir)
144
- bot.workingDir = workingDir;
145
- if (allowedUsers)
146
- bot.allowedUsers = allowedUsers.split(',').map((s) => s.trim()).filter(Boolean);
336
+ // 不再持久化 brand 字段: setup 阶段 brand=lark 直接被 obtainCredentials 中止,
337
+ // 落盘的永远是 'feishu', 写进配置是死字段. lark 完整接入再加回来, 那时
338
+ // 同步打开 daemon 链路的 brand 透传. botBrand() helper 读不到字段会 default
339
+ // 'feishu', 兼容旧版同时支持将来扩展.
340
+ const bot = {
341
+ larkAppId: creds.appId,
342
+ larkAppSecret: creds.appSecret,
343
+ cliId,
344
+ // 总是写 workingDir, 留空用 '~'. 用户手动编辑 bots.json 时一眼能看到字段
345
+ // 在哪儿, 不用去 README 查字段名.
346
+ workingDir: workingDir.trim() || '~',
347
+ };
348
+ // 不再问 allowedUsers — 扫码场景默认填扫码人自己 (registerApp 返回里有 open_id);
349
+ // 想改成多人共用 / 不限制, 用户后续手动编辑 ~/.botmux/bots.json 的 allowedUsers
350
+ // 字段即可. 手动 fallback 场景没 open_id, 字段直接不写 (== 不限制).
351
+ if (creds.userOpenId)
352
+ bot.allowedUsers = [creds.userOpenId];
147
353
  return bot;
148
354
  }
149
355
  /** Parse .env file to extract bot config for migration to bots.json */
@@ -177,16 +383,25 @@ function parseDotEnvToBotConfig() {
177
383
  bot.projectScanDir = vars.PROJECT_SCAN_DIR;
178
384
  return bot;
179
385
  }
180
- /** Write single-bot config to bots.json (fresh install or reconfigure) */
386
+ /**
387
+ * 收集一个机器人配置并写盘 (单机器人 fresh install / 重新配置).
388
+ *
389
+ * 失败路径 (扫码取消 / 凭证校验不通过): 不创建任何配置文件, 不动旧 .env.
390
+ * Codex review 边界 #2: 中途失败一律不留半截 JSON.
391
+ */
181
392
  async function writeSingleBotConfig() {
182
- console.log('── 飞书应用配置 ──\n');
183
- printLarkPermissions();
184
393
  const rl = createInterface({ input: process.stdin, output: process.stdout });
185
394
  const bot = await promptBotConfig(rl);
186
395
  rl.close();
187
- writeFileSync(BOTS_JSON_FILE, JSON.stringify([bot], null, 2) + '\n');
396
+ if (!bot)
397
+ return false;
398
+ writeBotsJsonAtomic([bot]);
188
399
  console.log(`\n✅ 配置已写入: ${BOTS_JSON_FILE}`);
189
- console.log(`\n下一步: botmux start`);
400
+ printRemainingSteps(bot.larkAppId, botBrand(bot));
401
+ console.log(`下一步:`);
402
+ console.log(` 1. botmux start 启动 daemon`);
403
+ console.log(` 2. botmux autostart enable 注册开机自启(推荐:${process.platform === 'darwin' ? 'mac launchd' : process.platform === 'linux' ? 'linux user systemd' : '当前平台暂不支持'},无需 sudo)`);
404
+ return true;
190
405
  }
191
406
  // ─── Commands ────────────────────────────────────────────────────────────────
192
407
  async function cmdSetup() {
@@ -207,26 +422,36 @@ async function cmdSetup() {
207
422
  const rl = createInterface({ input: process.stdin, output: process.stdout });
208
423
  const action = await ask(rl, '操作: 1) 添加新机器人 2) 重新配置 (1/2) [1]: ');
209
424
  if (action === '2') {
210
- renameSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
211
- console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak\n`);
212
425
  console.log('\n── 重新配置 ──\n');
213
- printLarkPermissions();
214
426
  const newBot = await promptBotConfig(rl);
215
427
  rl.close();
216
- writeFileSync(BOTS_JSON_FILE, JSON.stringify([newBot], null, 2) + '\n');
217
- console.log(`\n 配置已写入: ${BOTS_JSON_FILE}`);
218
- console.log(`\n下一步: botmux restart`);
428
+ if (!newBot) {
429
+ console.log('\n⚠️ setup 中止,旧配置保留不动。');
430
+ return;
431
+ }
432
+ // Codex review #1: 先 copyFileSync 备份, 再原子写新文件. 之前先 rename
433
+ // 旧文件再 write, 一旦 write 失败 (磁盘/权限/进程被 kill) 用户就丢了
434
+ // bots.json. copy 之后写失败旧文件原地不动, .bak 是无害的同名副本.
435
+ copyFileSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
436
+ console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak`);
437
+ writeBotsJsonAtomic([newBot]);
438
+ console.log(`✅ 配置已写入: ${BOTS_JSON_FILE}`);
439
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
440
+ console.log(`下一步: botmux restart\n`);
219
441
  return;
220
442
  }
221
443
  console.log('\n── 添加新机器人 ──\n');
222
- printLarkPermissions();
223
444
  const newBot = await promptBotConfig(rl);
224
445
  rl.close();
225
- bots.push(newBot);
226
- writeFileSync(BOTS_JSON_FILE, JSON.stringify(bots, null, 2) + '\n');
227
- console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length} 个`);
446
+ if (!newBot) {
447
+ console.log('\n⚠️ setup 中止,bots.json 不动。');
448
+ return;
449
+ }
450
+ writeBotsJsonAtomic([...bots, newBot]);
451
+ console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length + 1} 个`);
228
452
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
229
- console.log(`\n下一步: botmux restart`);
453
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
454
+ console.log(`下一步: botmux restart\n`);
230
455
  }
231
456
  else if (hasEnv) {
232
457
  // --- Single-bot mode (.env exists) ---
@@ -235,9 +460,11 @@ async function cmdSetup() {
235
460
  const action = await ask(rl, '操作: 1) 添加新机器人 2) 覆盖当前配置 (1/2): ');
236
461
  if (action === '2') {
237
462
  rl.close();
238
- await writeSingleBotConfig();
239
- renameSync(ENV_FILE, ENV_FILE + '.bak');
240
- console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
463
+ const ok = await writeSingleBotConfig();
464
+ if (ok) {
465
+ renameSync(ENV_FILE, ENV_FILE + '.bak');
466
+ console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
467
+ }
241
468
  return;
242
469
  }
243
470
  // Migrate .env → bots.json
@@ -250,16 +477,20 @@ async function cmdSetup() {
250
477
  }
251
478
  console.log(`\n当前机器人: ${existingBot.larkAppId} (${existingBot.cliId ?? 'claude-code'})`);
252
479
  console.log('\n── 添加新机器人 ──\n');
253
- printLarkPermissions();
254
480
  const newBot = await promptBotConfig(rl);
255
481
  rl.close();
256
- const bots = [existingBot, newBot];
257
- writeFileSync(BOTS_JSON_FILE, JSON.stringify(bots, null, 2) + '\n');
482
+ if (!newBot) {
483
+ console.log('\n⚠️ setup 中止,.env bots.json 都不动。');
484
+ return;
485
+ }
486
+ // 写新文件成功后才备份 .env. 失败不动两边.
487
+ writeBotsJsonAtomic([existingBot, newBot]);
258
488
  renameSync(ENV_FILE, ENV_FILE + '.bak');
259
489
  console.log(`\n✅ 已迁移到多机器人配置`);
260
490
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
261
491
  console.log(` 旧配置已备份: ${ENV_FILE}.bak`);
262
- console.log(`\n下一步: botmux restart`);
492
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
493
+ console.log(`下一步: botmux restart\n`);
263
494
  }
264
495
  else {
265
496
  // --- Fresh install ---
@@ -342,7 +573,7 @@ function preflightNodeSanity() {
342
573
  }
343
574
  }
344
575
  }
345
- function cmdStart() {
576
+ async function cmdStart() {
346
577
  if (!hasConfig()) {
347
578
  console.error('❌ 未找到配置文件');
348
579
  console.error(' 请先运行: botmux setup');
@@ -350,6 +581,41 @@ function cmdStart() {
350
581
  }
351
582
  ensureConfigDir();
352
583
  preflightNodeSanity();
584
+ await ensureSystemDependencies();
585
+ // 启动前快速校验每个 bot 的凭证. Codex review 边界 #5: 凭证无效是
586
+ // 唯一应该阻塞 start 的情况; scope/event 缺失在 daemon 起来后用 WARN
587
+ // + 私信处理 (event-dispatcher.checkRequiredScopes).
588
+ //
589
+ // 失败时打印明确的 appId 前缀和错误码, 不打印 secret, 不 spawn pm2 进程.
590
+ const botsForCheck = loadBotsJson();
591
+ if (botsForCheck.length > 0) {
592
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
593
+ const invalid = [];
594
+ for (const b of botsForCheck) {
595
+ if (!b.larkAppId || !b.larkAppSecret) {
596
+ invalid.push({ appId: b.larkAppId || '(空 appId)', reason: 'larkAppId/larkAppSecret 缺失' });
597
+ continue;
598
+ }
599
+ const v = await validateCredentials(b.larkAppId, b.larkAppSecret, botBrand(b));
600
+ if (!v.ok) {
601
+ if (v.error === 'invalid_credentials') {
602
+ invalid.push({ appId: b.larkAppId, reason: v.message });
603
+ }
604
+ else {
605
+ // network / unknown — 不应该拦下启动, 走 WARN
606
+ console.warn(`⚠️ [${b.larkAppId}] 启动前凭证验证未成功(${v.error}): ${v.message}`);
607
+ console.warn(` daemon 仍会启动;启动后 dispatcher 会自行重试。`);
608
+ }
609
+ }
610
+ }
611
+ if (invalid.length > 0) {
612
+ console.error('\n❌ 以下机器人凭证无效,botmux start 中止:\n');
613
+ for (const e of invalid)
614
+ console.error(` - ${e.appId}: ${e.reason}`);
615
+ console.error('\n 修复方式: 运行 `botmux setup` 选 "重新配置" 重新走扫码/手动流程。');
616
+ process.exit(1);
617
+ }
618
+ }
353
619
  cleanupLegacyPm2();
354
620
  const cfg = ecosystemConfig();
355
621
  runPm2(['start', cfg]);
@@ -358,6 +624,33 @@ function cmdStart() {
358
624
  console.log(`\n✅ daemon 已启动${count > 1 ? ` (${count} 个机器人, 每个独立进程)` : ''}`);
359
625
  console.log(` 日志: botmux logs`);
360
626
  console.log(` 状态: botmux status`);
627
+ // If the user previously enabled autostart, sync the unit file in case
628
+ // node/cli.js paths changed since (nvm switch, npm upgrade, etc.).
629
+ if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
630
+ console.log(` autostart unit 已同步到当前 Node/cli.js 路径`);
631
+ }
632
+ }
633
+ /**
634
+ * Wipe stale dashboard-daemon descriptors (mtime older than 5 minutes).
635
+ * Live daemons refresh their descriptor every 30s via heartbeat; anything
636
+ * older is from a daemon that exited without cleaning up. Called as part of
637
+ * the pm2 zombie-cleanup flow so the dashboard registry stays consistent.
638
+ */
639
+ function cleanupStaleDaemonDescriptors() {
640
+ const regDir = join(DATA_DIR, 'dashboard-daemons');
641
+ if (!existsSync(regDir))
642
+ return;
643
+ for (const f of readdirSync(regDir)) {
644
+ if (!f.endsWith('.json'))
645
+ continue;
646
+ const fp = join(regDir, f);
647
+ try {
648
+ const stat = statSync(fp);
649
+ if (Date.now() - stat.mtimeMs > 5 * 60_000)
650
+ unlinkSync(fp);
651
+ }
652
+ catch { /* ignore */ }
653
+ }
361
654
  }
362
655
  /** Delete all pm2 processes matching botmux / botmux-* under the given PM2_HOME. */
363
656
  function deleteAllBotmuxProcesses(home = PM2_HOME) {
@@ -439,10 +732,12 @@ function cmdStop() {
439
732
  }
440
733
  }
441
734
  catch { /* */ }
735
+ // Wipe abandoned dashboard-daemon descriptors left behind by stopped daemons.
736
+ cleanupStaleDaemonDescriptors();
442
737
  if (!stopped)
443
738
  console.log('daemon 未在运行。');
444
739
  }
445
- function cmdRestart() {
740
+ async function cmdRestart() {
446
741
  if (!hasConfig()) {
447
742
  console.error('❌ 未找到配置文件');
448
743
  console.error(' 请先运行: botmux setup');
@@ -450,11 +745,31 @@ function cmdRestart() {
450
745
  }
451
746
  ensureConfigDir();
452
747
  preflightNodeSanity();
748
+ await ensureSystemDependencies();
453
749
  cleanupLegacyPm2();
454
750
  // Delete all botmux processes (handles both old single-process and new multi-process)
455
751
  deleteAllBotmuxProcesses();
752
+ // Wipe abandoned dashboard-daemon descriptors left behind by killed daemons.
753
+ cleanupStaleDaemonDescriptors();
456
754
  const cfg = ecosystemConfig();
457
755
  runPm2(['start', cfg]);
756
+ if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
757
+ console.log(`autostart unit 已同步到当前 Node/cli.js 路径`);
758
+ }
759
+ }
760
+ /** Wraps `ensureDependencies()`. Neither tmux nor fonts are load-bearing —
761
+ * ensureDependencies surfaces failures as warnings and the daemon continues
762
+ * on PTY backend. Only an unexpected exception (programmer error) propagates. */
763
+ async function ensureSystemDependencies() {
764
+ const { ensureDependencies } = await import('./setup/index.js');
765
+ try {
766
+ await ensureDependencies();
767
+ }
768
+ catch (err) {
769
+ console.error('');
770
+ console.error(`依赖检测内部错误: ${err?.message ?? String(err)}`);
771
+ // Don't exit — let daemon start try anyway; worst case PTY backend works.
772
+ }
458
773
  }
459
774
  /**
460
775
  * If a legacy ~/.pm2 daemon with botmux processes still exists alongside our
@@ -538,6 +853,44 @@ function cmdUpgrade() {
538
853
  process.exit(1);
539
854
  }
540
855
  }
856
+ /**
857
+ * Print a fresh dashboard URL by HMAC-authing to the dashboard process's
858
+ * loopback rotation endpoint. Each call invalidates the previously-issued
859
+ * token, so sharing a URL is the same as sharing a one-shot session.
860
+ */
861
+ async function cmdDashboard() {
862
+ const SECRET_PATH = join(CONFIG_DIR, '.dashboard-secret');
863
+ if (!existsSync(SECRET_PATH)) {
864
+ console.error('Dashboard not initialised. Run `botmux restart` first.');
865
+ process.exit(1);
866
+ }
867
+ const secret = readFileSync(SECRET_PATH, 'utf8').trim();
868
+ const ts = Math.floor(Date.now() / 1000).toString();
869
+ const nonce = randomBytes(8).toString('hex');
870
+ const sig = createHmac('sha256', secret).update(`${ts}:${nonce}`).digest('base64url');
871
+ const port = process.env.BOTMUX_DASHBOARD_PORT ?? '7891';
872
+ let res;
873
+ try {
874
+ res = await fetch(`http://127.0.0.1:${port}/__cli/rotate`, {
875
+ method: 'POST',
876
+ headers: {
877
+ 'X-Botmux-Cli-Ts': ts,
878
+ 'X-Botmux-Cli-Nonce': nonce,
879
+ 'X-Botmux-Cli-Auth': sig,
880
+ },
881
+ });
882
+ }
883
+ catch {
884
+ console.error(`dashboard process not reachable on 127.0.0.1:${port} — \`botmux restart\` will start it`);
885
+ process.exit(1);
886
+ }
887
+ if (!res.ok) {
888
+ console.error('Rotation failed:', res.status, await res.text());
889
+ process.exit(1);
890
+ }
891
+ const body = await res.json();
892
+ console.log(body.url);
893
+ }
541
894
  /**
542
895
  * Resolve the session data directory.
543
896
  * Priority: SESSION_DATA_DIR env > daemon breadcrumb (~/.botmux/.data-dir) > default (~/.botmux/data)
@@ -812,7 +1165,7 @@ function printSessionTable(active) {
812
1165
  /** Check if a tmux session exists. */
813
1166
  function tmuxSessionExists(name) {
814
1167
  try {
815
- execSync(`tmux has-session -t ${name} 2>/dev/null`, { stdio: 'ignore' });
1168
+ execSync(`tmux has-session -t ${name} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
816
1169
  return true;
817
1170
  }
818
1171
  catch {
@@ -956,7 +1309,7 @@ function interactiveSessionPicker(active) {
956
1309
  // Kill tmux session
957
1310
  if (r.hasTmux) {
958
1311
  try {
959
- execSync(`tmux kill-session -t '${r.tmuxName}' 2>/dev/null`, { stdio: 'ignore' });
1312
+ execSync(`tmux kill-session -t '${r.tmuxName}' 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
960
1313
  }
961
1314
  catch { /* */ }
962
1315
  }
@@ -1027,6 +1380,7 @@ function interactiveSessionPicker(active) {
1027
1380
  cleanup();
1028
1381
  spawnSync('tmux', ['attach-session', '-t', selected.tmuxName], {
1029
1382
  stdio: 'inherit',
1383
+ env: tmuxEnv(),
1030
1384
  });
1031
1385
  resolve();
1032
1386
  return;
@@ -1124,7 +1478,7 @@ function cmdDelete() {
1124
1478
  // Kill associated tmux session if it exists
1125
1479
  const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`;
1126
1480
  try {
1127
- execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { stdio: 'ignore' });
1481
+ execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
1128
1482
  console.log(` killed tmux ${tmuxName}`);
1129
1483
  }
1130
1484
  catch { /* no tmux session */ }
@@ -1136,6 +1490,140 @@ function cmdDelete() {
1136
1490
  }
1137
1491
  console.log(`\n已关闭 ${toDelete.length} 个会话`);
1138
1492
  }
1493
+ /**
1494
+ * Discover online daemons. Mirrors the staleness rule used by
1495
+ * dashboard/registry.ts (90s heartbeat) so we don't try to talk to a daemon
1496
+ * that's been dead but left a stale descriptor behind. Uses resolveDataDir()
1497
+ * so SESSION_DATA_DIR / breadcrumb-overridden deployments find the right
1498
+ * descriptor directory.
1499
+ */
1500
+ function listOnlineDaemons() {
1501
+ const regDir = join(resolveDataDir(), 'dashboard-daemons');
1502
+ if (!existsSync(regDir))
1503
+ return [];
1504
+ const STALE_MS = 90_000;
1505
+ const now = Date.now();
1506
+ const all = [];
1507
+ let names = [];
1508
+ try {
1509
+ names = readdirSync(regDir);
1510
+ }
1511
+ catch {
1512
+ return [];
1513
+ }
1514
+ for (const f of names) {
1515
+ if (!f.endsWith('.json'))
1516
+ continue;
1517
+ try {
1518
+ const d = JSON.parse(readFileSync(join(regDir, f), 'utf-8'));
1519
+ if (typeof d?.ipcPort !== 'number' || typeof d?.larkAppId !== 'string')
1520
+ continue;
1521
+ if (now - (d.lastHeartbeat ?? 0) > STALE_MS)
1522
+ continue;
1523
+ all.push({ ipcPort: d.ipcPort, larkAppId: d.larkAppId, lastHeartbeat: d.lastHeartbeat });
1524
+ }
1525
+ catch { /* skip malformed */ }
1526
+ }
1527
+ return all;
1528
+ }
1529
+ function findDaemon(larkAppId) {
1530
+ const all = listOnlineDaemons();
1531
+ if (larkAppId)
1532
+ return all.find(d => d.larkAppId === larkAppId) ?? null;
1533
+ return all[0] ?? null;
1534
+ }
1535
+ async function cmdResume() {
1536
+ const target = process.argv[3];
1537
+ if (!target) {
1538
+ console.error('用法: botmux resume <session-id|prefix>');
1539
+ console.error(' 通过 botmux list 查看活跃会话;resume 仅适用于 status=closed 的会话');
1540
+ process.exit(1);
1541
+ }
1542
+ const sessions = loadSessions();
1543
+ const closed = [...sessions.values()].filter(s => s.status === 'closed');
1544
+ if (closed.length === 0) {
1545
+ console.error('没有已关闭的会话可恢复。');
1546
+ process.exit(1);
1547
+ }
1548
+ const matches = closed.filter(s => s.sessionId.startsWith(target));
1549
+ if (matches.length === 0) {
1550
+ console.error(`❌ 未找到匹配 "${target}" 的已关闭会话`);
1551
+ process.exit(1);
1552
+ }
1553
+ if (matches.length > 1) {
1554
+ console.error(`❌ "${target}" 匹配了 ${matches.length} 个会话,请提供更长的 ID 前缀:`);
1555
+ for (const s of matches) {
1556
+ console.error(` ${s.sessionId.substring(0, 12)} ${s.title}`);
1557
+ }
1558
+ process.exit(1);
1559
+ }
1560
+ const session = matches[0];
1561
+ // Legacy sessions persisted before per-bot files lack larkAppId. Rather
1562
+ // than silently routing to "the first online daemon" — which can land on
1563
+ // the wrong bot in multi-bot setups and corrupt state — refuse and tell
1564
+ // the user what's missing. Single-bot setups still work (we resolve to
1565
+ // that lone daemon below).
1566
+ if (!session.larkAppId) {
1567
+ const online = listOnlineDaemons();
1568
+ if (online.length > 1) {
1569
+ console.error(`❌ 会话 ${session.sessionId.substring(0, 12)} 缺少 larkAppId,多 bot 部署下无法判定归属。`);
1570
+ console.error(' 解决办法:手动给该 session 补 larkAppId 后重试,或使用对应 bot 的话题里 ▶️ 恢复会话 按钮。');
1571
+ console.error(` 在线 daemon (${online.length}): ${online.map(d => d.larkAppId).join(', ')}`);
1572
+ process.exit(1);
1573
+ }
1574
+ if (online.length === 0) {
1575
+ console.error('❌ 没有在线 daemon。请先:botmux start');
1576
+ process.exit(1);
1577
+ }
1578
+ // Single online daemon — safe to use
1579
+ }
1580
+ const daemon = findDaemon(session.larkAppId);
1581
+ if (!daemon) {
1582
+ const hint = session.larkAppId
1583
+ ? `未找到 daemon (larkAppId=${session.larkAppId})`
1584
+ : '未找到任何在线 daemon';
1585
+ console.error(`❌ ${hint}。请确认 daemon 正在运行:botmux status`);
1586
+ process.exit(1);
1587
+ }
1588
+ let res;
1589
+ try {
1590
+ res = await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/sessions/${encodeURIComponent(session.sessionId)}/resume`, { method: 'POST' });
1591
+ }
1592
+ catch (err) {
1593
+ console.error(`❌ 无法连接到 daemon (port=${daemon.ipcPort}): ${err?.message ?? err}`);
1594
+ process.exit(1);
1595
+ }
1596
+ let body = {};
1597
+ try {
1598
+ body = await res.json();
1599
+ }
1600
+ catch { /* */ }
1601
+ if (res.ok && body?.ok) {
1602
+ console.log(`✅ 会话已恢复: ${session.sessionId.substring(0, 12)} ${session.title}`);
1603
+ if (body.workingDir)
1604
+ console.log(` 工作目录: ${body.workingDir}`);
1605
+ console.log(' 下一条消息会以 --resume 拉起 CLI;已在原话题留通知。');
1606
+ return;
1607
+ }
1608
+ const errCode = body?.error ?? `HTTP ${res.status}`;
1609
+ if (errCode === 'anchor_occupied') {
1610
+ const occ = body?.activeSessionId ? ` (占用者: ${body.activeSessionId.substring(0, 12)})` : '';
1611
+ console.error(`❌ 当前话题已有新的活跃会话${occ},无法 resume 旧会话。`);
1612
+ }
1613
+ else if (errCode === 'not_closed') {
1614
+ console.error('❌ 会话当前不是 closed 状态,无需 resume。');
1615
+ }
1616
+ else if (errCode === 'not_found') {
1617
+ console.error('❌ daemon 中找不到该会话(可能已被清理)。');
1618
+ }
1619
+ else if (errCode === 'adopt_unsupported') {
1620
+ console.error('❌ adopt 接管会话不支持 resume。');
1621
+ }
1622
+ else {
1623
+ console.error(`❌ 恢复失败: ${errCode}`);
1624
+ }
1625
+ process.exit(1);
1626
+ }
1139
1627
  function showHelp() {
1140
1628
  console.log(`
1141
1629
  botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
@@ -1148,11 +1636,17 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
1148
1636
  logs 查看 daemon 日志(--lines N, --bot <index>)
1149
1637
  status 查看 daemon 状态
1150
1638
  upgrade 升级到最新版本
1639
+ dashboard 打印新的 Web Dashboard 一次性登录 URL(旧 token 同时失效)
1151
1640
  list 列出活跃会话(交互式选择并连接 tmux)
1152
1641
  --plain 纯文本表格输出(管道/脚本场景)
1153
1642
  delete <id> 关闭指定会话(支持 ID 前缀匹配)
1154
1643
  delete all 关闭所有活跃会话
1155
1644
  delete stopped 清理所有进程已退出的僵尸会话
1645
+ resume <id> 恢复一个已关闭的会话(支持 ID 前缀匹配)— 会话标记回 active,
1646
+ 下条消息会以 --resume 重新拉起 CLI 进程
1647
+ autostart enable 注册开机自启(macOS launchd / Linux user systemd,无需 sudo)
1648
+ autostart disable 注销开机自启
1649
+ autostart status 查看自启状态
1156
1650
 
1157
1651
  定时任务(可在 CLI 会话内自动推断 chat):
1158
1652
  schedule list 列出所有任务
@@ -1167,8 +1661,15 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
1167
1661
  --files <path> 附件(可重复)
1168
1662
  --mention <open_id:name> @提及(可重复)
1169
1663
  --card | --text 强制卡片 / 纯文本(默认按 md 语法自动判断)
1664
+ --top-level 发顶层消息(不回复进当前话题)
1665
+ --chat-id <oc_xxx> 指定目标群(默认当前话题所在群)
1170
1666
  bots list 列出当前群聊中的机器人(含 open_id)
1171
- thread messages [--limit N] 拉取当前话题的消息历史 (JSON)
1667
+ history [--limit N] 拉取当前会话的消息历史 (JSON),话题群 → 话题内,普通群 → 整群
1668
+ quoted <message_id> 拉取被引用的单条消息 (JSON),message_id 取自 daemon 注入的引用提示行
1669
+
1670
+ 新建飞书群:
1671
+ create-group --bot <name> [--bot ...] [--name "群名"]
1672
+ 用指定 bot 起新群;详见 \`botmux create-group --help\`
1172
1673
 
1173
1674
  配置目录: ~/.botmux/
1174
1675
  文档: https://github.com/deepcoldy/botmux
@@ -1246,13 +1747,18 @@ function argFlag(args, flag) {
1246
1747
  return args.includes(flag);
1247
1748
  }
1248
1749
  /** Extract positional args, skipping --flag and the value that follows it
1249
- * (for --flag <value> style). --flag=value style is self-contained. */
1250
- function positionals(args) {
1750
+ * (for --flag <value> style). --flag=value style is self-contained.
1751
+ * `booleanFlags` lists flags that take no value — without this hint the
1752
+ * parser swallows the *next* arg as the flag's value, which silently eats
1753
+ * positional content (or, worse, a following --flag's value). */
1754
+ function positionals(args, booleanFlags = []) {
1251
1755
  const out = [];
1252
1756
  for (let i = 0; i < args.length; i++) {
1253
1757
  const a = args[i];
1254
1758
  if (a.startsWith('--')) {
1255
- if (!a.includes('=') && i + 1 < args.length)
1759
+ const flagName = a.includes('=') ? a.slice(0, a.indexOf('=')) : a;
1760
+ const isBoolean = booleanFlags.includes(flagName);
1761
+ if (!a.includes('=') && !isBoolean && i + 1 < args.length)
1256
1762
  i++; // skip value
1257
1763
  continue;
1258
1764
  }
@@ -1331,6 +1837,9 @@ async function cmdSchedule(sub, rest) {
1331
1837
  chatId,
1332
1838
  rootMessageId,
1333
1839
  larkAppId,
1840
+ creatorChatId: cur?.chatId,
1841
+ creatorRootMessageId: cur?.rootMessageId,
1842
+ creatorLarkAppId: cur?.larkAppId,
1334
1843
  chatType: cur?.chatType === 'p2p' ? 'p2p' : 'topic_group',
1335
1844
  deliver,
1336
1845
  });
@@ -1395,13 +1904,15 @@ async function cmdSchedule(sub, rest) {
1395
1904
  process.exit(1);
1396
1905
  }
1397
1906
  }
1398
- async function cmdThreadMessages(rest) {
1907
+ /** Resolve a CLI subcommand's larkAppId by walking the session marker. Common
1908
+ * prelude for `history` / `quoted` / similar commands that need to talk to
1909
+ * Lark on behalf of the session that spawned them. Exits with stderr on
1910
+ * failure so callers can stay focused on the happy path. */
1911
+ async function resolveSessionAppId(sessionIdArg) {
1399
1912
  process.env.SESSION_DATA_DIR ??= resolveDataDir();
1400
- const limit = parseInt(argValue(rest, '--limit') ?? '50', 10);
1401
- const sessionIdArg = argValue(rest, '--session-id');
1402
1913
  const sid = sessionIdArg ?? findAncestorSessionId();
1403
1914
  if (!sid) {
1404
- console.error('无法推断 session-id。请在 Lark 话题内的 CLI 会话中运行,或传 --session-id <id>。');
1915
+ console.error('无法推断 session-id。请在 Lark 话题/群里的 CLI 会话中运行,或传 --session-id <id>。');
1405
1916
  process.exit(1);
1406
1917
  }
1407
1918
  const sessions = loadSessions();
@@ -1421,15 +1932,75 @@ async function cmdThreadMessages(rest) {
1421
1932
  registerBot(cfg);
1422
1933
  }
1423
1934
  catch { /* ignore */ }
1424
- const { listThreadMessages } = await import('./im/lark/client.js');
1935
+ return { sid, larkAppId: s.larkAppId, session: s };
1936
+ }
1937
+ async function cmdHistory(rest) {
1938
+ const limit = parseInt(argValue(rest, '--limit') ?? '50', 10);
1939
+ const sessionIdArg = argValue(rest, '--session-id');
1940
+ const { sid, larkAppId: appId, session: s } = await resolveSessionAppId(sessionIdArg);
1941
+ const { listThreadMessages, listChatMessages } = await import('./im/lark/client.js');
1425
1942
  const { parseApiMessage } = await import('./im/lark/message-parser.js');
1943
+ const { expandMergeForward } = await import('./im/lark/merge-forward.js');
1426
1944
  try {
1427
- const raw = await listThreadMessages(s.larkAppId, s.chatId, s.rootMessageId, limit);
1428
- const messages = raw.map(m => parseApiMessage(m));
1429
- console.log(JSON.stringify({ sessionId: sid, threadId: s.rootMessageId, messages, total: messages.length }, null, 2));
1945
+ // Chat-scope sessions (普通群整群一会话) have no thread to walk — list the
1946
+ // chat container directly and let the caller cap with --limit. Thread-scope
1947
+ // sessions walk the thread container by root_id.
1948
+ const isChatScope = s.scope === 'chat';
1949
+ const raw = isChatScope
1950
+ ? await listChatMessages(appId, s.chatId, limit)
1951
+ : await listThreadMessages(appId, s.chatId, s.rootMessageId, limit);
1952
+ // Expand merge_forward to <forwarded_messages> XML, mirroring the live event
1953
+ // path in daemon.ts. Each merge_forward gets its own numberer (we don't
1954
+ // download resources here — only [图片 N] placeholders matter).
1955
+ const messages = await Promise.all(raw.map(async (m) => {
1956
+ const parsed = parseApiMessage(m);
1957
+ if (parsed.msgType === 'merge_forward') {
1958
+ await expandMergeForward(appId, parsed.messageId, parsed);
1959
+ }
1960
+ return parsed;
1961
+ }));
1962
+ console.log(JSON.stringify({
1963
+ sessionId: sid,
1964
+ chatId: s.chatId,
1965
+ scope: isChatScope ? 'chat' : 'thread',
1966
+ ...(isChatScope ? {} : { rootMessageId: s.rootMessageId }),
1967
+ messages,
1968
+ total: messages.length,
1969
+ }, null, 2));
1430
1970
  }
1431
1971
  catch (err) {
1432
- console.error(`获取话题消息失败: ${err.message}`);
1972
+ console.error(`获取消息失败: ${err.message}`);
1973
+ process.exit(1);
1974
+ }
1975
+ }
1976
+ async function cmdQuoted(rest) {
1977
+ const sessionIdArg = argValue(rest, '--session-id');
1978
+ // Positional message_id is required. The id comes verbatim from the
1979
+ // `[用户引用了消息 用 botmux quoted om_xxx 查看]` prompt prefix the daemon
1980
+ // injects when the user used the Lark quote-reply UI. Skip --session-id and
1981
+ // its value so `botmux quoted --session-id <uuid> om_xxx` doesn't pick up
1982
+ // the uuid as the message id.
1983
+ const messageId = firstPositional(rest, ['--session-id']);
1984
+ if (!messageId) {
1985
+ console.error('用法: botmux quoted <message_id> [--session-id <id>]');
1986
+ process.exit(1);
1987
+ }
1988
+ const { larkAppId: appId } = await resolveSessionAppId(sessionIdArg);
1989
+ const { getMessageDetail } = await import('./im/lark/client.js');
1990
+ const { expandMergeForward } = await import('./im/lark/merge-forward.js');
1991
+ const { renderQuotedMessage } = await import('./cli/quoted-render.js');
1992
+ try {
1993
+ const detail = await getMessageDetail(appId, messageId);
1994
+ const msg = detail?.items?.[0];
1995
+ if (!msg) {
1996
+ console.error(`未找到消息 ${messageId}`);
1997
+ process.exit(1);
1998
+ }
1999
+ const rendered = await renderQuotedMessage(appId, msg, expandMergeForward);
2000
+ console.log(JSON.stringify(rendered, null, 2));
2001
+ }
2002
+ catch (err) {
2003
+ console.error(`获取被引用消息失败: ${err.message}`);
1433
2004
  process.exit(1);
1434
2005
  }
1435
2006
  }
@@ -1464,139 +2035,23 @@ function argValues(args, ...flags) {
1464
2035
  }
1465
2036
  return out;
1466
2037
  }
1467
- /** Feishu card markdown element doesn't render ATX headings → promote to bold. */
1468
- function transformHeadings(md) {
1469
- return md.replace(/^#{1,6}\s+(.+)$/gm, (_m, c) => `**${c.trim()}**`);
1470
- }
1471
- /** Parse a contiguous pipe-table block into a Feishu card v2 `table` element. */
1472
- function parseTableBlock(block) {
1473
- const lines = block.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
1474
- if (lines.length < 2)
1475
- return null;
1476
- const rows = lines.map(l => l.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()));
1477
- const sepIdx = rows.findIndex(r => r.length > 0 && r.every(c => /^:?-{2,}:?$/.test(c)));
1478
- const header = rows[0];
1479
- const body = sepIdx === 1 ? rows.slice(2) : rows.slice(1);
1480
- if (header.length === 0)
1481
- return null;
1482
- const columns = header.map((h, i) => ({
1483
- name: `c${i}`,
1484
- display_name: h || ' ',
1485
- data_type: 'lark_md',
1486
- width: 'auto',
1487
- }));
1488
- const tableRows = body.map(r => {
1489
- const o = {};
1490
- for (let i = 0; i < header.length; i++)
1491
- o[`c${i}`] = r[i] ?? '';
1492
- return o;
1493
- });
1494
- return {
1495
- tag: 'table',
1496
- page_size: Math.min(10, Math.max(1, tableRows.length || 1)),
1497
- row_height: 'low',
1498
- header_style: {
1499
- text_align: 'left',
1500
- text_size: 'normal',
1501
- background_style: 'grey',
1502
- text_color: 'default',
1503
- bold: true,
1504
- lines: 1,
1505
- },
1506
- columns,
1507
- rows: tableRows,
1508
- };
1509
- }
1510
- /**
1511
- * Split markdown into card v2 body elements:
1512
- * 1. Fenced code blocks are preserved verbatim (shielded from heading/table
1513
- * transforms so `#` and `|` inside code don't get mis-parsed).
1514
- * 2. Pipe-table blocks in prose become native `table` elements.
1515
- * 3. Everything else becomes a `markdown` element with ATX headings promoted
1516
- * to bold (Feishu's markdown element doesn't render `#`).
1517
- * Consecutive markdown fragments are merged so the card keeps reasonable
1518
- * element counts.
1519
- */
1520
- function buildCardBodyElements(md) {
1521
- const elements = [];
1522
- let buffer = '';
1523
- const flushBuffer = () => {
1524
- const t = buffer.replace(/^\s+|\s+$/g, '');
1525
- if (t)
1526
- elements.push({ tag: 'markdown', content: transformHeadings(t) });
1527
- buffer = '';
1528
- };
1529
- // Segment by fenced code blocks (``` ... ```)
1530
- const fenceRe = /^```[^\n]*\n[\s\S]*?^```[ \t]*$/gm;
1531
- const segments = [];
1532
- let fCursor = 0;
1533
- let fm;
1534
- while ((fm = fenceRe.exec(md)) !== null) {
1535
- if (fm.index > fCursor)
1536
- segments.push({ type: 'prose', text: md.slice(fCursor, fm.index) });
1537
- segments.push({ type: 'code', text: fm[0] });
1538
- fCursor = fm.index + fm[0].length;
1539
- }
1540
- if (fCursor < md.length)
1541
- segments.push({ type: 'prose', text: md.slice(fCursor) });
1542
- for (const seg of segments) {
1543
- if (seg.type === 'code') {
1544
- buffer += (buffer && !buffer.endsWith('\n') ? '\n' : '') + seg.text + '\n';
1545
- continue;
1546
- }
1547
- const tableRe = /(?:^[ \t]*\|.+\|[ \t]*\r?\n?){2,}/gm;
1548
- let tCursor = 0;
1549
- let tm;
1550
- while ((tm = tableRe.exec(seg.text)) !== null) {
1551
- buffer += seg.text.slice(tCursor, tm.index);
1552
- flushBuffer();
1553
- const table = parseTableBlock(tm[0]);
1554
- if (table)
1555
- elements.push(table);
1556
- else
1557
- buffer += tm[0];
1558
- tCursor = tm.index + tm[0].length;
1559
- }
1560
- buffer += seg.text.slice(tCursor);
1561
- }
1562
- flushBuffer();
1563
- return elements;
1564
- }
1565
- /**
1566
- * Heuristic: does `text` contain markdown syntax that renders badly as plain
1567
- * text in Feishu (code fences, headings, lists, bold, inline code, links,
1568
- * tables, blockquotes, hr)? If so, `cmdSend` switches to an interactive card
1569
- * so Feishu can render it properly.
1570
- */
1571
- function hasMarkdown(text) {
1572
- if (!text)
1573
- return false;
1574
- return (/```/.test(text) ||
1575
- /^#{1,6}\s/m.test(text) ||
1576
- /^\s{0,3}[-*+]\s+\S/m.test(text) ||
1577
- /^\s{0,3}\d+\.\s+\S/m.test(text) ||
1578
- /\*\*[^*\n]+\*\*/.test(text) ||
1579
- /(^|[^`])`[^`\n]+`([^`]|$)/.test(text) ||
1580
- /\[[^\]\n]+\]\([^)\n]+\)/.test(text) ||
1581
- /^\s*\|.+\|\s*$/m.test(text) ||
1582
- /^>\s/m.test(text) ||
1583
- /^(?:---|\*\*\*|___)\s*$/m.test(text));
1584
- }
2038
+ // Card v2 body builder helpers extracted to im/lark/md-card.ts so the
2039
+ // daemon's bridge fallback path can produce identical cards. cmdSend
2040
+ // keeps using `buildCardBodyElements` and `hasMarkdown` from there.
2041
+ import { buildCardBodyElements, hasMarkdown } from './im/lark/md-card.js';
1585
2042
  /**
1586
2043
  * Decide who the reply card should @ in its footer.
1587
2044
  *
1588
- * Non-oncall chats: `发送给: @<owner>` as before, no cc.
1589
- * Oncall chats: `发送给: @<last caller>` (falls back to owner if unknown);
1590
- * if caller differs from the owners list, `cc` the owners so they stay
1591
- * notified. Caller is deduped out of cc to avoid double-@.
2045
+ * Non-oncall chats: `发送给: @<owner>`.
2046
+ * Oncall chats: `发送给: @<last caller>` (falls back to owner if unknown)
2047
+ * permission is governed by allowedUsers, so there's no per-chat list to cc.
1592
2048
  */
1593
2049
  function buildFooterAddressing(s, oncall) {
1594
2050
  const owner = s.ownerOpenId;
1595
2051
  const caller = s.lastCallerOpenId ?? owner;
1596
2052
  if (!oncall)
1597
2053
  return { sendTo: owner, cc: [] };
1598
- const cc = oncall.owners.filter(id => id && id !== caller);
1599
- return { sendTo: caller, cc };
2054
+ return { sendTo: caller, cc: [] };
1600
2055
  }
1601
2056
  async function cmdSend(rest) {
1602
2057
  process.env.SESSION_DATA_DIR ??= resolveDataDir();
@@ -1607,6 +2062,12 @@ async function cmdSend(rest) {
1607
2062
  const contentFile = argValue(rest, '--content-file');
1608
2063
  const forceCard = rest.includes('--card');
1609
2064
  const forceText = rest.includes('--text');
2065
+ // Publish-mode flags: post a fresh top-level message in a chat instead of
2066
+ // replying into the bound thread. Lets a session "publish" to a different
2067
+ // chat (e.g. a public release-notes group) while keeping its own thread
2068
+ // for streaming-card / progress UI.
2069
+ const sendTopLevel = rest.includes('--top-level');
2070
+ const overrideChatId = argValue(rest, '--chat-id');
1610
2071
  const sid = sessionIdArg ?? findAncestorSessionId();
1611
2072
  if (!sid) {
1612
2073
  console.error('无法推断 session-id。请在 Lark 话题内的 CLI 会话中运行,或传 --session-id <id>。');
@@ -1632,7 +2093,7 @@ async function cmdSend(rest) {
1632
2093
  content = readFileSync(contentFile, 'utf-8');
1633
2094
  }
1634
2095
  else {
1635
- const pos = positionals(rest);
2096
+ const pos = positionals(rest, ['--card', '--text', '--top-level']);
1636
2097
  if (pos.length > 0) {
1637
2098
  content = pos.join(' ');
1638
2099
  }
@@ -1666,15 +2127,31 @@ async function cmdSend(rest) {
1666
2127
  }
1667
2128
  }
1668
2129
  // Register bots so Lark client works
1669
- const { registerBot, loadBotConfigs, findOncallChat } = await import('./bot-registry.js');
2130
+ const { registerBot, loadBotConfigs, findOncallChatForAnyBot } = await import('./bot-registry.js');
1670
2131
  try {
1671
2132
  for (const cfg of loadBotConfigs())
1672
2133
  registerBot(cfg);
1673
2134
  }
1674
2135
  catch { /* */ }
1675
- const { replyMessage, uploadImage, uploadFile } = await import('./im/lark/client.js');
2136
+ const { sendMessage, replyMessage, uploadImage, uploadFile } = await import('./im/lark/client.js');
1676
2137
  const appId = s.larkAppId;
1677
- const oncallEntry = s.chatId ? findOncallChat(appId, s.chatId) : undefined;
2138
+ // Effective target chat for top-level mode (defaults to session's chat)
2139
+ const targetChatId = overrideChatId ?? s.chatId;
2140
+ // Chat-scope sessions (普通群整群一会话) post to chatId without
2141
+ // reply_in_thread, otherwise Lark would force every reply into a fresh
2142
+ // topic — defeating the whole point of chat-scope routing.
2143
+ const isChatScope = s.scope === 'chat';
2144
+ // Oncall addressing only meaningful for replies inside the session's own
2145
+ // chat — skip when publishing top-level or to a different chat. Treat
2146
+ // oncall as chat-level: in multi-daemon setups this session's bot may not
2147
+ // be the one that persisted the binding, but users still expect footer
2148
+ // addressing to go to the last caller in the shared oncall workspace.
2149
+ const oncallEntry = !sendTopLevel && !overrideChatId && s.chatId
2150
+ ? findOncallChatForAnyBot(s.chatId) : undefined;
2151
+ // Dispatch helper: top-level / chat-scope send vs reply-in-thread, single decision point
2152
+ const dispatch = (content, msgType) => (sendTopLevel || isChatScope)
2153
+ ? sendMessage(appId, targetChatId, content, msgType)
2154
+ : replyMessage(appId, s.rootMessageId, content, msgType, true);
1678
2155
  try {
1679
2156
  // Upload images in parallel
1680
2157
  const imageKeys = [];
@@ -1702,6 +2179,13 @@ async function cmdSend(rest) {
1702
2179
  // app's cross-ref file for per-app-scoped open_ids. Without this, a plain
1703
2180
  // "@Claude" in text only triggers IPC routing but Lark UI shows it as
1704
2181
  // plain text — confusing the user who thinks the @ didn't fire.
2182
+ //
2183
+ // bot-to-bot @mention 两条触发入口(显式 --mention / 正文 `@BotName`)都
2184
+ // 落到下方的 mentions 数组,单 source of truth:让 Lark 在消息里渲染
2185
+ // 真正的 @at 元素。对方 bot 的 daemon 通过 WSClient 原生事件接到(依赖
2186
+ // "获取群组中其他机器人和用户@当前机器人的消息"权限),不再走任何本地
2187
+ // 转发——botmux 历史上为绕过 Lark 不投递跨 bot 事件搞过 signal-file,
2188
+ // 那套已经在该权限上线后整体下线。
1705
2189
  try {
1706
2190
  const dataDir = resolveDataDir();
1707
2191
  const botInfoPath = join(dataDir, 'bots-info.json');
@@ -1711,18 +2195,34 @@ async function cmdSend(rest) {
1711
2195
  ? JSON.parse(readFileSync(crossRefPath, 'utf-8'))
1712
2196
  : {};
1713
2197
  const alreadyMentioned = new Set(mentions.map(m => m.open_id));
1714
- for (const entry of botEntries) {
2198
+ // Sort by name length desc so longer names ("Claude分身") win over their
2199
+ // prefix ("Claude") when both could match — break-on-first-hit otherwise
2200
+ // routes "@Claude分身" to Claude.
2201
+ const sortedEntries = [...botEntries].sort((a, b) => (b.botName?.length ?? 0) - (a.botName?.length ?? 0));
2202
+ for (const entry of sortedEntries) {
1715
2203
  if (!entry.botName || entry.larkAppId === appId)
1716
2204
  continue;
1717
2205
  const names = [entry.botName, entry.cliId].filter(Boolean);
1718
2206
  for (const name of names) {
1719
- const re = new RegExp(`@${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
2207
+ const escName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2208
+ // Boundary: lookbehind blocks only ASCII word chars (so `user@Claude`
2209
+ // is rejected but `看看@CoCo` is accepted — CJK prefix is normal in
2210
+ // Chinese text). Lookahead blocks any Unicode letter/digit so
2211
+ // `@Claude2` doesn't match name "Claude" and `@Claude分身好的` doesn't
2212
+ // either-half-match.
2213
+ const re = new RegExp(`(?<![A-Za-z0-9_])@${escName}(?![\\p{L}\\p{N}_])`, 'iu');
1720
2214
  if (!re.test(text))
1721
2215
  continue;
1722
- // Prefer sender-scoped open_id from cross-ref (what Lark's sender app
1723
- // has seen for the target bot); fall back to target's own open_id.
1724
- const senderScopedId = crossRef[entry.botName] ?? entry.botOpenId;
1725
- if (!senderScopedId || alreadyMentioned.has(senderScopedId))
2216
+ // Lark open_id is per-app scoped. Use sender-scoped id from cross-ref
2217
+ // only falling back to entry.botOpenId would feed Lark a wrong-scope
2218
+ // id (target's self-scoped) and the API would reject it. Skip + warn
2219
+ // so the missing cross-ref is observable instead of silently dropped.
2220
+ const senderScopedId = crossRef[entry.botName];
2221
+ if (!senderScopedId) {
2222
+ console.error(`[botmux send] no cross-ref entry for "${entry.botName}" in app ${appId}, skipping auto-mention (cross-ref populates after the sender app first sees the target bot)`);
2223
+ break;
2224
+ }
2225
+ if (alreadyMentioned.has(senderScopedId))
1726
2226
  break;
1727
2227
  mentions.push({ open_id: senderScopedId, name: entry.botName });
1728
2228
  alreadyMentioned.add(senderScopedId);
@@ -1742,6 +2242,13 @@ async function cmdSend(rest) {
1742
2242
  const mentionPattern = namedMentions.length > 0
1743
2243
  ? new RegExp(`@(${namedMentions.map(m => m.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi')
1744
2244
  : null;
2245
+ // Capture sentAtMs BEFORE dispatch — the worker's bridge fallback gates
2246
+ // on `sentAtMs ∈ [turn.markTimeMs, nextTurn.markTimeMs)`. If we recorded
2247
+ // it after dispatch (which can take seconds), a slow Lark RTT could push
2248
+ // this send's timestamp past the next turn's mark and falsely suppress
2249
+ // that turn's fallback emit. Pre-dispatch timestamp captures the moment
2250
+ // we committed to sending — that's the boundary the gate cares about.
2251
+ const sentAtMs = Date.now();
1745
2252
  let messageId;
1746
2253
  if (useCard) {
1747
2254
  // Inline @mention → <at id=open_id></at>; explicit --mention args that
@@ -1788,10 +2295,13 @@ async function cmdSend(rest) {
1788
2295
  // Footer: de-emphasized markdown (v2 dropped the `note` tag). Use small
1789
2296
  // text size + grey font tag so it reads like a footnote below the hr.
1790
2297
  // Oncall groups: `发送给` targets whoever triggered this turn (may not
1791
- // be the session owner), plus a `cc` line listing oncall owners so they
1792
- // stay informed. Non-oncall: keep owner-only behaviour.
2298
+ // be the session owner). Non-oncall: keep owner-only behaviour.
1793
2299
  const footerParts = ['[botmux](https://github.com/deepcoldy/botmux)'];
1794
- const addressing = buildFooterAddressing(s, oncallEntry);
2300
+ // Top-level publish has no specific recipient — drop "发送给/cc" addressing
2301
+ // so the message doesn't @ the session owner who isn't even in the target chat.
2302
+ const addressing = sendTopLevel
2303
+ ? { sendTo: undefined, cc: [] }
2304
+ : buildFooterAddressing(s, oncallEntry);
1795
2305
  if (addressing.sendTo)
1796
2306
  footerParts.push(`发送给:<at id=${addressing.sendTo}></at>`);
1797
2307
  if (addressing.cc.length > 0) {
@@ -1808,7 +2318,7 @@ async function cmdSend(rest) {
1808
2318
  config: { update_multi: true },
1809
2319
  body: { direction: 'vertical', elements },
1810
2320
  });
1811
- messageId = await replyMessage(appId, s.rootMessageId, cardJson, 'interactive', true);
2321
+ messageId = await dispatch(cardJson, 'interactive');
1812
2322
  }
1813
2323
  else {
1814
2324
  // Plain-text path: build post content, paragraph per line.
@@ -1847,9 +2357,11 @@ async function cmdSend(rest) {
1847
2357
  }
1848
2358
  }
1849
2359
  // Footer: mirror the card layout — a blank paragraph separates the body
1850
- // from the addressing line(s). `发送给: @<caller>` always; oncall groups
1851
- // additionally get `cc: @<owners>` on the next line.
1852
- const addressing = buildFooterAddressing(s, oncallEntry);
2360
+ // from the addressing line(s). `发送给: @<caller>` always. Top-level
2361
+ // publish has no specific recipient skip addressing entirely.
2362
+ const addressing = sendTopLevel
2363
+ ? { sendTo: undefined, cc: [] }
2364
+ : buildFooterAddressing(s, oncallEntry);
1853
2365
  if (addressing.sendTo || addressing.cc.length > 0) {
1854
2366
  if (postContent.length > 0)
1855
2367
  postContent.push([{ tag: 'text', text: '' }]);
@@ -1861,77 +2373,37 @@ async function cmdSend(rest) {
1861
2373
  }
1862
2374
  }
1863
2375
  const postJson = JSON.stringify({ zh_cn: { title: '', content: postContent } });
1864
- messageId = await replyMessage(appId, s.rootMessageId, postJson, 'post', true);
2376
+ messageId = await dispatch(postJson, 'post');
2377
+ }
2378
+ // Bridge fallback marker — append-only jsonl per session. The worker
2379
+ // gates its non-adopt transcript-driven fallback on whether any send
2380
+ // happened within the current Lark turn's window. Only when this send
2381
+ // landed in the session's own thread (not --top-level, not --chat-id
2382
+ // override) does it cancel that turn's fallback.
2383
+ if (!sendTopLevel && !overrideChatId) {
2384
+ try {
2385
+ const markerDir = join(resolveDataDir(), 'turn-sends');
2386
+ if (!existsSync(markerDir))
2387
+ mkdirSync(markerDir, { recursive: true });
2388
+ // sentAtMs was captured pre-dispatch (see above). messageId is the
2389
+ // confirmed Lark message id from the now-successful dispatch.
2390
+ const line = JSON.stringify({ sentAtMs, messageId }) + '\n';
2391
+ appendFileSync(join(markerDir, `${sid}.jsonl`), line);
2392
+ }
2393
+ catch { /* best-effort: marker miss only causes a redundant fallback message */ }
1865
2394
  }
1866
2395
  // Send file attachments as separate messages
1867
2396
  const fileIds = [];
1868
2397
  for (const fp of files) {
1869
2398
  const fileKey = await uploadFile(appId, fp);
1870
- const fid = await replyMessage(appId, s.rootMessageId, JSON.stringify({ file_key: fileKey }), 'file', true);
2399
+ const fid = await dispatch(JSON.stringify({ file_key: fileKey }), 'file');
1871
2400
  fileIds.push(fid);
1872
2401
  }
1873
- // Bot-to-bot mention signals
1874
- const dataDir = resolveDataDir();
1875
- const botInfoPath = join(dataDir, 'bots-info.json');
1876
- let botEntries = [];
1877
- try {
1878
- if (existsSync(botInfoPath))
1879
- botEntries = JSON.parse(readFileSync(botInfoPath, 'utf-8'));
1880
- }
1881
- catch { /* */ }
1882
- const openIdToAppId = new Map();
1883
- for (const e of botEntries)
1884
- if (e.botOpenId)
1885
- openIdToAppId.set(e.botOpenId, e.larkAppId);
1886
- try {
1887
- for (const file of readdirSync(dataDir)) {
1888
- if (!file.startsWith('bot-openids-') || !file.endsWith('.json'))
1889
- continue;
1890
- try {
1891
- const crossRef = JSON.parse(readFileSync(join(dataDir, file), 'utf-8'));
1892
- for (const [botName, crossOpenId] of Object.entries(crossRef)) {
1893
- const entry = botEntries.find(e => e.botName?.toLowerCase() === botName.toLowerCase());
1894
- if (entry)
1895
- openIdToAppId.set(crossOpenId, entry.larkAppId);
1896
- }
1897
- }
1898
- catch { /* */ }
1899
- }
1900
- }
1901
- catch { /* */ }
1902
- const targetAppIds = new Set();
1903
- for (const m of mentions) {
1904
- const ta = openIdToAppId.get(m.open_id);
1905
- if (ta && ta !== appId)
1906
- targetAppIds.add(ta);
1907
- }
1908
- if (text && botEntries.length > 0) {
1909
- for (const entry of botEntries) {
1910
- if (!entry.botOpenId || entry.larkAppId === appId)
1911
- continue;
1912
- const names = [entry.botName, entry.cliId].filter(Boolean);
1913
- for (const name of names) {
1914
- if (new RegExp(`@${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)) {
1915
- targetAppIds.add(entry.larkAppId);
1916
- break;
1917
- }
1918
- }
1919
- }
1920
- }
1921
- if (targetAppIds.size > 0) {
1922
- const signalDir = join(dataDir, 'bot-mentions');
1923
- if (!existsSync(signalDir))
1924
- mkdirSync(signalDir, { recursive: true });
1925
- for (const targetApp of targetAppIds) {
1926
- const te = botEntries.find(e => e.larkAppId === targetApp);
1927
- const signal = {
1928
- rootMessageId: s.rootMessageId, chatId: s.chatId, chatType: s.chatType,
1929
- senderAppId: appId, targetBotOpenId: te?.botOpenId ?? targetApp,
1930
- content: text, messageId, timestamp: Date.now(),
1931
- };
1932
- writeFileSync(join(signalDir, `${Date.now()}-${(te?.botOpenId ?? targetApp).slice(-8)}.json`), JSON.stringify(signal));
1933
- }
1934
- }
2402
+ // Bot-to-bot 转发依赖飞书"获取群组中其他机器人和用户@当前机器人的消息"权限:
2403
+ // 目标 bot 的 daemon 现在能从 WSClient 原生收到 sender_type='app' 的事件,
2404
+ // 不需要 botmux 自己再写本地 signal 文件做转发。outgoing 消息里 @BotName /
2405
+ // --mention open_id 解析(在上方 mentions 数组里完成)仍然必要,它让
2406
+ // Lark 在消息里渲染真正的 @at 元素,从而触发对方 bot 的 WS 事件投递。
1935
2407
  console.log(JSON.stringify({ success: true, messageId, sessionId: sid }));
1936
2408
  }
1937
2409
  catch (err) {
@@ -1939,6 +2411,147 @@ async function cmdSend(rest) {
1939
2411
  process.exit(1);
1940
2412
  }
1941
2413
  }
2414
+ // ─── Create-group subcommand ─────────────────────────────────────────────────
2415
+ async function cmdCreateGroup(rest) {
2416
+ if (rest.includes('--help') || rest.includes('-h')) {
2417
+ console.log(`
2418
+ botmux create-group — 用一组机器人新建飞书群
2419
+
2420
+ 用法:
2421
+ botmux create-group --bot <name|larkAppId> [--bot ...] [--name "群名"]
2422
+
2423
+ 参数:
2424
+ --bot <ref> 至少一个,可多次。ref 推荐用 bot 显示名(同 botmux send 的 @<name>)或完整 larkAppId;
2425
+ cliId(如 claude-code)仅作 fallback —— 多个 bot 常共用同一个 cliId,重名命中只能取
2426
+ bots.json 中第一个。重名 → 取 bots.json 中第一个匹配,stderr 打 warning。
2427
+ 重复 ref → 自动去重保留首次顺序。
2428
+ --name <群名> 可选;不传则用飞书默认无名群。
2429
+
2430
+ 行为:
2431
+ - 第一个解析到的 bot 作为 creator(决定建群身份 + 初始群主 + open_id app scope)。
2432
+ - 邀请用户 / 转让群主 / @通知 对象都从 creator 的 resolvedAllowedUsers 取首个 open_id(email 自动转换;
2433
+ 转不出来或为空则跳过对应步骤,stderr warning)。
2434
+ - 不依赖 botmux 会话,任何环境都能跑。
2435
+
2436
+ 输出协议(skill 友好):
2437
+ - 成功(即使 transfer/notify 部分失败):stdout 单行 chatId,exit 0;stderr 打人类提示 + applink。
2438
+ - 失败(缺 --bot / 解析失败 / chat.create 抛错):stdout 空,exit 非零;stderr 打错误。
2439
+ `);
2440
+ return;
2441
+ }
2442
+ process.env.SESSION_DATA_DIR ??= resolveDataDir();
2443
+ const botRefs = argValues(rest, '--bot');
2444
+ const name = argValue(rest, '--name');
2445
+ if (botRefs.length === 0) {
2446
+ console.error('用法: botmux create-group --bot <name|larkAppId> [--bot ...] [--name "群名"]');
2447
+ console.error('至少传一个 --bot。');
2448
+ process.exit(1);
2449
+ }
2450
+ // Load bot configs (bots.json order) and bots-info.json (for botName)
2451
+ const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
2452
+ let botConfigs;
2453
+ try {
2454
+ botConfigs = loadBotConfigs().map(c => ({ larkAppId: c.larkAppId, cliId: c.cliId }));
2455
+ }
2456
+ catch (err) {
2457
+ console.error(`加载 bots.json 失败: ${err?.message ?? err}`);
2458
+ process.exit(1);
2459
+ }
2460
+ const dataDir = resolveDataDir();
2461
+ const botInfoPath = join(dataDir, 'bots-info.json');
2462
+ let botInfoEntries = [];
2463
+ try {
2464
+ if (existsSync(botInfoPath))
2465
+ botInfoEntries = JSON.parse(readFileSync(botInfoPath, 'utf-8'));
2466
+ }
2467
+ catch { /* */ }
2468
+ const { resolveBotRefs } = await import('./cli/create-group-resolver.js');
2469
+ const resolved = resolveBotRefs(botRefs, botConfigs, botInfoEntries.map(b => ({ larkAppId: b.larkAppId, botName: b.botName })));
2470
+ for (const w of resolved.ambiguousWarnings)
2471
+ console.error(`⚠️ ${w}`);
2472
+ if (resolved.invalid.length > 0) {
2473
+ console.error(`无法解析的 --bot 引用: ${resolved.invalid.join(', ')}`);
2474
+ console.error('可用 bot:');
2475
+ for (const cfg of botConfigs) {
2476
+ const info = botInfoEntries.find(b => b.larkAppId === cfg.larkAppId);
2477
+ console.error(` - ${info?.botName ?? '(unnamed)'} cliId=${cfg.cliId} ${cfg.larkAppId}`);
2478
+ }
2479
+ process.exit(1);
2480
+ }
2481
+ if (resolved.larkAppIds.length === 0) {
2482
+ console.error('未解析到任何 bot,请检查 --bot 引用。');
2483
+ process.exit(1);
2484
+ }
2485
+ const creatorLarkAppId = resolved.larkAppIds[0];
2486
+ // Register bots so getBotClient works inside service
2487
+ const fullConfigs = loadBotConfigs();
2488
+ const needed = new Set(resolved.larkAppIds);
2489
+ try {
2490
+ for (const cfg of fullConfigs)
2491
+ if (needed.has(cfg.larkAppId))
2492
+ registerBot(cfg);
2493
+ }
2494
+ catch (err) {
2495
+ console.error(`注册 bot 失败: ${err?.message ?? err}`);
2496
+ process.exit(1);
2497
+ }
2498
+ // Derive user_open_id from creator's allowedUsers (creator app scope only).
2499
+ // resolveAllowedUsers converts emails → open_ids via creator's Lark client.
2500
+ const creatorCfg = fullConfigs.find(c => c.larkAppId === creatorLarkAppId);
2501
+ const allowedRaw = creatorCfg?.allowedUsers ?? [];
2502
+ const { resolveAllowedUsers } = await import('./im/lark/client.js');
2503
+ let creatorAllowedOpenIds = [];
2504
+ try {
2505
+ creatorAllowedOpenIds = await resolveAllowedUsers(creatorLarkAppId, allowedRaw);
2506
+ }
2507
+ catch (err) {
2508
+ console.error(`⚠️ 解析 creator allowedUsers 失败: ${err?.message ?? err}(继续创建空群)`);
2509
+ }
2510
+ const targetOpenId = creatorAllowedOpenIds[0];
2511
+ if (!targetOpenId) {
2512
+ console.error('⚠️ creator bot 的 allowedUsers 没有可用 open_id — 将创建仅含 bot 的群(跳过邀请/转让/@通知)。');
2513
+ }
2514
+ const { createGroupWithBots } = await import('./services/group-creator.js');
2515
+ let result;
2516
+ try {
2517
+ result = await createGroupWithBots({
2518
+ creatorLarkAppId,
2519
+ larkAppIds: resolved.larkAppIds,
2520
+ name: name?.trim() || undefined,
2521
+ userOpenIds: targetOpenId ? [targetOpenId] : [],
2522
+ transferOwnerTo: targetOpenId,
2523
+ notifyOwnerOpenId: targetOpenId,
2524
+ });
2525
+ }
2526
+ catch (err) {
2527
+ console.error(`建群失败: ${err?.message ?? err}`);
2528
+ process.exit(1);
2529
+ }
2530
+ // Always stdout chatId on createChat success — even if transfer/notify
2531
+ // partially failed, the chat exists and retrying would create duplicates.
2532
+ process.stdout.write(`${result.chatId}\n`);
2533
+ // Human-readable summary + warnings → stderr.
2534
+ const link = `https://applink.feishu.cn/client/chat/open?openChatId=${encodeURIComponent(result.chatId)}`;
2535
+ console.error(`✅ 群已创建:${link}`);
2536
+ if (result.invalidBotIds.length > 0) {
2537
+ console.error(`⚠️ 飞书拒绝邀请的 bot: ${result.invalidBotIds.join(', ')}`);
2538
+ }
2539
+ if (result.invalidUserIds.length > 0) {
2540
+ console.error(`⚠️ 飞书拒绝邀请的 user: ${result.invalidUserIds.join(', ')}`);
2541
+ }
2542
+ if (result.transferError) {
2543
+ console.error(`⚠️ 群主转让失败 (${result.transferError}) — 当前群主仍为 creator bot`);
2544
+ }
2545
+ else if (result.ownerTransferredTo) {
2546
+ console.error(`✅ 群主已转让给 ${result.ownerTransferredTo}`);
2547
+ }
2548
+ if (result.notifyError) {
2549
+ console.error(`⚠️ @通知发送失败: ${result.notifyError}`);
2550
+ }
2551
+ else if (result.notifyMessageId) {
2552
+ console.error(`✅ @通知已发送 (msg ${result.notifyMessageId})`);
2553
+ }
2554
+ }
1942
2555
  // ─── Bots subcommand ─────────────────────────────────────────────────────────
1943
2556
  async function cmdBots(sub, rest) {
1944
2557
  process.env.SESSION_DATA_DIR ??= resolveDataDir();
@@ -2019,13 +2632,13 @@ switch (command) {
2019
2632
  await cmdSetup();
2020
2633
  break;
2021
2634
  case 'start':
2022
- cmdStart();
2635
+ await cmdStart();
2023
2636
  break;
2024
2637
  case 'stop':
2025
2638
  cmdStop();
2026
2639
  break;
2027
2640
  case 'restart':
2028
- cmdRestart();
2641
+ await cmdRestart();
2029
2642
  break;
2030
2643
  case 'logs':
2031
2644
  cmdLogs();
@@ -2036,6 +2649,9 @@ switch (command) {
2036
2649
  case 'upgrade':
2037
2650
  cmdUpgrade();
2038
2651
  break;
2652
+ case 'dashboard':
2653
+ await cmdDashboard();
2654
+ break;
2039
2655
  case 'list':
2040
2656
  case 'ls':
2041
2657
  await cmdList();
@@ -2045,21 +2661,49 @@ switch (command) {
2045
2661
  case 'rm':
2046
2662
  cmdDelete();
2047
2663
  break;
2664
+ case 'resume':
2665
+ await cmdResume();
2666
+ break;
2048
2667
  case 'schedule':
2049
2668
  await cmdSchedule(process.argv[3] ?? '', process.argv.slice(4));
2050
2669
  break;
2051
2670
  case 'send':
2052
2671
  await cmdSend(process.argv.slice(3));
2053
2672
  break;
2673
+ case 'create-group':
2674
+ await cmdCreateGroup(process.argv.slice(3));
2675
+ break;
2054
2676
  case 'bots':
2055
2677
  await cmdBots(process.argv[3] ?? 'list', process.argv.slice(4));
2056
2678
  break;
2679
+ case 'history':
2680
+ await cmdHistory(process.argv.slice(3));
2681
+ break;
2682
+ case 'quoted':
2683
+ await cmdQuoted(process.argv.slice(3));
2684
+ break;
2057
2685
  case 'thread': {
2686
+ // Removed in favor of `botmux history` (普通群也兼容). Friendly stderr so
2687
+ // pre-rename scripts/skills surface the rename instead of "unknown command".
2058
2688
  const sub = process.argv[3] ?? '';
2059
- if (sub === 'messages' || sub === 'msgs')
2060
- await cmdThreadMessages(process.argv.slice(4));
2689
+ console.error(sub === 'messages' || sub === 'msgs'
2690
+ ? `\`botmux thread ${sub}\` 已重命名为 \`botmux history\` (跑普通群和话题群都用它)。`
2691
+ : `\`botmux thread\` 已下线,请用 \`botmux history\``);
2692
+ process.exit(1);
2693
+ break;
2694
+ }
2695
+ case 'autostart': {
2696
+ ensureConfigDir();
2697
+ const sub = process.argv[3] ?? 'status';
2698
+ const opts = { pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR };
2699
+ if (sub === 'enable' || sub === 'install')
2700
+ enableAutostart(opts);
2701
+ else if (sub === 'disable' || sub === 'uninstall')
2702
+ disableAutostart(opts);
2703
+ else if (sub === 'status')
2704
+ autostartStatus(opts);
2061
2705
  else {
2062
- console.error(`用法: botmux thread messages [--limit N] [--session-id ID]`);
2706
+ console.error(`用法: botmux autostart <enable|disable|status>`);
2063
2707
  process.exit(1);
2064
2708
  }
2065
2709
  break;