botmux 2.9.1 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +645 -314
  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 +35 -0
  170. package/dist/im/lark/client.d.ts.map +1 -1
  171. package/dist/im/lark/client.js +114 -11
  172. package/dist/im/lark/client.js.map +1 -1
  173. package/dist/im/lark/event-dispatcher.d.ts +88 -6
  174. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  175. package/dist/im/lark/event-dispatcher.js +398 -62
  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 +2248 -83
  329. package/dist/worker.js.map +1 -1
  330. package/package.json +12 -5
package/dist/daemon.js CHANGED
@@ -1,30 +1,39 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, watch, readdirSync } from 'node:fs';
2
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  const __filename = fileURLToPath(import.meta.url);
7
7
  const __dirname = dirname(__filename);
8
8
  import { config } from './config.js';
9
- import { replyMessage, resolveAllowedUsers, getMessageDetail } from './im/lark/client.js';
10
- import { loadBotConfigs, registerBot, getBot, getAllBots, findOncallChat } from './bot-registry.js';
9
+ import { statSync } from 'node:fs';
10
+ import { getChatMode, replyMessage, resolveAllowedUsers, sendMessage } from './im/lark/client.js';
11
+ import { loadBotConfigs, registerBot, getBot, getAllBots, findOncallChatForAnyBot, isChatOncallBoundForAnyBot } from './bot-registry.js';
11
12
  import * as sessionStore from './services/session-store.js';
13
+ import * as chatFirstSeenStore from './services/chat-first-seen-store.js';
14
+ import { autoBindOncallFromDefault } from './services/oncall-store.js';
15
+ import * as scheduleStore from './services/schedule-store.js';
12
16
  import * as messageQueue from './services/message-queue.js';
13
- import { parseEventMessage, parseApiMessage, extractResources, resolveNonsupportMessage, createImgNumberer, unwrapUserDslContent, stripLeadingMentions } from './im/lark/message-parser.js';
17
+ import { parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
18
+ import { expandMergeForward } from './im/lark/merge-forward.js';
19
+ import { buildQuoteHint } from './im/lark/quote-hint.js';
14
20
  import { logger } from './utils/logger.js';
15
21
  import { ensureCjkFontsInstalled } from './utils/font-installer.js';
16
- import { sessionKey } from './core/types.js';
22
+ import { sessionKey, sessionAnchorId } from './core/types.js';
17
23
  import * as scheduler from './core/scheduler.js';
18
24
  import { scanMultipleProjects } from './services/project-scanner.js';
19
25
  import { buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
20
26
  import { createCliAdapterSync } from './adapters/cli/registry.js';
21
- import { initWorkerPool, forkWorker, killWorker, scheduleCardPatch, setCurrentCliVersion, CARD_POSTING_SENTINEL, } from './core/worker-pool.js';
27
+ import { initWorkerPool, setActiveSessionsRegistry, forkWorker, killWorker, scheduleCardPatch, setCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, } from './core/worker-pool.js';
28
+ import { setBotName, setLarkAppId, startIpcServer } from './core/dashboard-ipc-server.js';
22
29
  import { saveFrozenCards } from './services/frozen-card-store.js';
23
- import { DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, handleCommand } from './core/command-handler.js';
30
+ import { DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, handleCommand, parseSlashCommandInvocation, parseForceTopicInvocation } from './core/command-handler.js';
31
+ import { findInheritablePeer } from './core/inherit-peer.js';
24
32
  import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
25
- import { getSessionWorkingDir, getProjectScanDirs, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, } from './core/session-manager.js';
33
+ import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, buildBridgeInputContent, buildReforkPrompt, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, } from './core/session-manager.js';
26
34
  import { handleCardAction } from './im/lark/card-handler.js';
27
- import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate } from './im/lark/event-dispatcher.js';
35
+ import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate, isKnownPeerBot, checkRequiredScopes } from './im/lark/event-dispatcher.js';
36
+ import { markSessionActivity } from './core/session-activity.js';
28
37
  // ─── State ───────────────────────────────────────────────────────────────────
29
38
  const activeSessions = new Map();
30
39
  // Cache last /repo scan results per chat for /repo <number> fallback
@@ -32,18 +41,31 @@ const lastRepoScan = new Map();
32
41
  const cliVersionCache = new Map();
33
42
  const VERSION_CHECK_INTERVAL = 60_000; // cache 1 min
34
43
  /**
35
- * Reply to a message, automatically using reply_in_thread for p2p sessions.
36
- * Always reply in thread to create/continue a topic.
37
- * This ensures topic-style replies in all chat types (p2p, group, topic group).
44
+ * Reply into a session scope-aware.
45
+ *
46
+ * `anchor` is whatever the caller has at hand:
47
+ * - thread-scope sessions → rootMessageId
48
+ * - chat-scope sessions → chatId
49
+ *
50
+ * Behaviour:
51
+ * - thread-scope (or no matching DS, the legacy default) → reply with
52
+ * reply_in_thread=true to the anchor message_id
53
+ * - chat-scope → send a plain
54
+ * message to ds.chatId (no reply, no thread). Cards / button values
55
+ * embed the chatId so handleCardAction can route back into the same
56
+ * session.
57
+ *
58
+ * Lark message ids start with `om_` and chat ids with `oc_`, so the two
59
+ * address spaces never collide; the lookup just tries both.
38
60
  */
39
- async function sessionReply(rootId, content, msgType = 'text', larkAppId) {
61
+ async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
40
62
  let ds;
41
63
  if (larkAppId) {
42
- ds = activeSessions.get(sessionKey(rootId, larkAppId));
64
+ ds = activeSessions.get(sessionKey(anchor, larkAppId));
43
65
  }
44
66
  else {
45
67
  for (const s of activeSessions.values()) {
46
- if (s.session.rootMessageId === rootId) {
68
+ if (sessionAnchorId(s) === anchor) {
47
69
  ds = s;
48
70
  break;
49
71
  }
@@ -52,7 +74,33 @@ async function sessionReply(rootId, content, msgType = 'text', larkAppId) {
52
74
  const appId = larkAppId ?? ds?.larkAppId ?? getAllBots()[0]?.config.larkAppId;
53
75
  if (!appId)
54
76
  throw new Error('No bot configured');
55
- return replyMessage(appId, rootId, content, msgType, true);
77
+ // Chat-scope: post a plain message to the chat. No reply_in_thread → keeps
78
+ // the conversation flat in 普通群. The card layer carries chatId in its button
79
+ // values, so handleCardAction routes back via sessionKey(chatId).
80
+ //
81
+ // If a 普通群 is converted to a 话题群 while this chat-scope session is alive,
82
+ // a top-level sendMessage would create a brand-new topic for every reply.
83
+ // Force-refresh chat_mode at dispatch time and fall back to the session's
84
+ // original triggering message as the thread anchor.
85
+ //
86
+ // Detect chat-scope from either ds.scope or anchor's `oc_` prefix. The
87
+ // prefix fallback covers the close-button race: card-handler deletes ds
88
+ // from activeSessions BEFORE sending the close-confirmation reply, so by
89
+ // the time we run, ds is gone — but the anchor (chatId, oc_xxx) is enough
90
+ // to know we should sendMessage, not reply_in_thread to a non-message-id.
91
+ if (ds?.scope === 'chat' || anchor.startsWith('oc_')) {
92
+ const chatId = ds?.chatId ?? anchor;
93
+ if (ds?.scope === 'chat' && ds.session.rootMessageId) {
94
+ const mode = await getChatMode(appId, chatId, { forceRefresh: true });
95
+ if (mode === 'topic') {
96
+ logger.warn(`[routing] Chat-scope session ${ds.session.sessionId.substring(0, 8)} is now topic-mode; replying in original thread ${ds.session.rootMessageId.substring(0, 12)}`);
97
+ return replyMessage(appId, ds.session.rootMessageId, content, msgType, true);
98
+ }
99
+ }
100
+ return sendMessage(appId, chatId, content, msgType);
101
+ }
102
+ // Thread-scope (or unknown / legacy): reply in thread.
103
+ return replyMessage(appId, anchor, content, msgType, true);
56
104
  }
57
105
  // ─── PID file ────────────────────────────────────────────────────────────────
58
106
  function getPidFile() {
@@ -107,6 +155,26 @@ function removePidFile() {
107
155
  logger.info('PID file removed');
108
156
  }
109
157
  }
158
+ // ─── Daemon descriptor (dashboard registry) ─────────────────────────────────
159
+ // Each per-bot daemon publishes a self-descriptor JSON at
160
+ // ~/.botmux/data/dashboard-daemons/<larkAppId>.json so the dashboard sibling
161
+ // process can discover all running daemons. The file is touched every 30s as
162
+ // a heartbeat (mtime drives offline detection) and removed on graceful exit.
163
+ const DAEMON_REGISTRY_DIR = join(homedir(), '.botmux', 'data', 'dashboard-daemons');
164
+ function writeDaemonDescriptor(d) {
165
+ mkdirSync(DAEMON_REGISTRY_DIR, { recursive: true });
166
+ const fp = join(DAEMON_REGISTRY_DIR, `${d.larkAppId}.json`);
167
+ writeFileSync(fp, JSON.stringify(d), { mode: 0o600 });
168
+ }
169
+ function removeDaemonDescriptor(larkAppId) {
170
+ const fp = join(DAEMON_REGISTRY_DIR, `${larkAppId}.json`);
171
+ if (existsSync(fp)) {
172
+ try {
173
+ unlinkSync(fp);
174
+ }
175
+ catch { /* ignore */ }
176
+ }
177
+ }
110
178
  // ─── Version tracking ────────────────────────────────────────────────────────
111
179
  function refreshCliVersion(cliId, cliPathOverride) {
112
180
  const now = Date.now();
@@ -150,6 +218,39 @@ function getActiveCount() {
150
218
  }
151
219
  return count;
152
220
  }
221
+ /**
222
+ * Freeze the previous turn's streaming card at "idle" and mark a new turn so the
223
+ * next screen_update from the worker POSTs a fresh streaming card instead of
224
+ * PATCH-ing the previous one. Shared by the normal-message path and the
225
+ * passthrough slash-command path (/model, /clear, /compact, etc.) — without
226
+ * this, passthrough commands silently PATCH the previous card and the user
227
+ * sees no visible response.
228
+ */
229
+ function beginNewTurn(ds, title) {
230
+ if (ds.streamCardId && ds.workerPort) {
231
+ const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
232
+ const dsBotCfg = getBot(ds.larkAppId).config;
233
+ const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId);
234
+ const prevMode = ds.displayMode ?? 'hidden';
235
+ const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, prevTitle, ds.lastScreenContent ?? '', 'idle', dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false);
236
+ scheduleCardPatch(ds, frozenCard);
237
+ if (ds.streamCardNonce && ds.streamCardId !== CARD_POSTING_SENTINEL) {
238
+ if (!ds.frozenCards)
239
+ ds.frozenCards = new Map();
240
+ ds.frozenCards.set(ds.streamCardNonce, {
241
+ messageId: ds.streamCardId,
242
+ content: ds.lastScreenContent ?? '',
243
+ title: prevTitle,
244
+ displayMode: prevMode,
245
+ imageKey: ds.currentImageKey,
246
+ });
247
+ saveFrozenCards(ds.session.sessionId, ds.frozenCards);
248
+ }
249
+ }
250
+ ds.streamCardPending = true;
251
+ ds.currentTurnTitle = title.substring(0, 50);
252
+ persistStreamCardState(ds);
253
+ }
153
254
  // Dependencies passed to command-handler
154
255
  const commandDeps = {
155
256
  activeSessions,
@@ -163,71 +264,66 @@ const cardDeps = {
163
264
  sessionReply,
164
265
  lastRepoScan,
165
266
  };
166
- // ─── Merge-forward expansion ────────────────────────────────────────────────
267
+ // ─── Event handling ──────────────────────────────────────────────────────────
167
268
  /**
168
- * Expand a merge_forward message by fetching sub-messages via Lark API.
169
- * Replaces parsed.content with readable text and collects additional resources.
269
+ * Default-oncall is a uniform forward-only policy: whenever the toggle is
270
+ * on, ANY chat the bot is currently in — old or newly added, doesn't matter —
271
+ * gets auto-bound to the configured workingDir on its next observed topic,
272
+ * unless it's already bound (`findOncallChatForAnyBot` upstream) or the user
273
+ * has opted out via tombstone.
274
+ *
275
+ * Returns the binding entry on success, undefined when any precondition
276
+ * fails or the lock-internal authoritative check (in `autoBindOncallFromDefault`)
277
+ * sees a concurrent tombstone / existing binding.
170
278
  */
171
- async function expandMergeForward(larkAppId, messageId, parsed, depth = 0, numberer = createImgNumberer()) {
172
- const MAX_DEPTH = 5;
173
- const extraResources = [];
279
+ async function maybeAutoBindDefaultOncall(larkAppId, chatId, chatType) {
280
+ if (chatType !== 'group')
281
+ return undefined; // oncall is group-only by design
282
+ const bot = getBot(larkAppId);
283
+ const def = bot.config.defaultOncall;
284
+ if (!def?.enabled || !def.workingDir)
285
+ return undefined;
286
+ // Fast-path tombstone check against the in-memory snapshot — avoids taking
287
+ // the lock when we already know we'd skip. The AUTHORITATIVE re-check lives
288
+ // inside autoBindOncallFromDefault under the file lock, so a race with a
289
+ // concurrent unbind (which writes the tombstone) is still safe.
290
+ const autobound = bot.config.defaultOncallAutoboundChats ?? [];
291
+ if (autobound.includes(chatId))
292
+ return undefined;
293
+ // Validate workingDir at fire time too — directory might have been
294
+ // deleted/moved since the dashboard save validated it. Skipping (vs.
295
+ // crashing) lets the user fix the path without losing other bot config.
296
+ const resolved = expandHome(def.workingDir);
297
+ let isDir = false;
174
298
  try {
175
- // Lark returns HTTP 500 if user_card_content is combined with a
176
- // merge_forward message_id, so explicitly disable it here. Interactive
177
- // sub-messages come back in the simplified "Format A" shape which our
178
- // card extractor already handles.
179
- const detail = await getMessageDetail(larkAppId, messageId, { userCardContent: false });
180
- const subMessages = (detail?.items ?? []).filter((m) => m.upper_message_id === messageId);
181
- if (subMessages.length === 0)
182
- return { extraResources };
183
- const parts = ['[转发消息]'];
184
- for (const msg of subMessages) {
185
- const senderLabel = msg.sender?.sender_type === 'app' ? '机器人' : (msg.sender?.id ?? '未知');
186
- parts.push(`--- ${senderLabel} ---`);
187
- // Interactive sub-messages may still carry the simplified "upgrade your
188
- // client" fallback; unwrap user_dsl before any extraction so both the
189
- // resource pass and text pass see the real v2 body.
190
- // Interactive sub-messages arrive via REST as a simplified fallback.
191
- // Lark's im.message.get never returns user_dsl (even for the bot's own
192
- // messages, even via direct id lookup), so we can only unwrap when a
193
- // user_dsl somehow got through. For third-party cards whose simplified
194
- // form is the "请升级至最新版本客户端" fallback, the real body is
195
- // unrecoverable from REST.
196
- if (msg.msg_type === 'interactive') {
197
- const unwrapped = unwrapUserDslContent(msg.body?.content ?? '');
198
- if (unwrapped !== null) {
199
- msg.body = { ...(msg.body ?? {}), content: unwrapped };
200
- }
201
- }
202
- // Resources first so the numberer assigns [图片 N] in attachment order;
203
- // text extraction below reuses those numbers. Do NOT override messageId —
204
- // Lark requires the parent merge_forward's message_id to download
205
- // resources (error 234003 if sub-message ID is used).
206
- const subResources = extractResources(msg.msg_type ?? 'text', msg.body?.content ?? '', numberer);
207
- extraResources.push(...subResources);
208
- // Recursively expand nested merge_forward
209
- if (msg.msg_type === 'merge_forward' && depth < MAX_DEPTH) {
210
- const nested = { content: '[合并转发消息]', msgType: 'merge_forward', messageId: msg.message_id, rootId: '', senderId: msg.sender?.id ?? '', senderType: msg.sender?.sender_type ?? '', createTime: msg.create_time ?? '', mentions: [] };
211
- const { extraResources: nestedResources } = await expandMergeForward(larkAppId, msg.message_id, nested, depth + 1, numberer);
212
- parts.push(nested.content);
213
- extraResources.push(...nestedResources);
214
- }
215
- else {
216
- const sub = parseApiMessage(msg, numberer);
217
- parts.push(sub.content);
218
- }
219
- }
220
- parsed.content = parts.join('\n');
221
- parsed.msgType = 'merge_forward_expanded';
299
+ isDir = statSync(resolved).isDirectory();
222
300
  }
223
- catch (err) {
224
- logger.warn(`Failed to expand merge_forward ${messageId}: ${err}`);
225
- // Keep original placeholder content
301
+ catch { /* not a dir */ }
302
+ if (!isDir) {
303
+ logger.warn(`[${larkAppId}] defaultOncall workingDir invalid (${resolved}); ` +
304
+ `skipping auto-bind for chat=${chatId}`);
305
+ return undefined;
306
+ }
307
+ const r = await autoBindOncallFromDefault(larkAppId, chatId, def.workingDir);
308
+ if (!r.ok) {
309
+ logger.warn(`[${larkAppId}] defaultOncall auto-bind failed: chat=${chatId} reason=${r.reason}`);
310
+ return undefined;
311
+ }
312
+ if (r.skipped) {
313
+ // Lock-internal authoritative check disagreed with our fast-path —
314
+ // tombstone or binding raced in. Fine, just don't surface a binding.
315
+ logger.info(`[${larkAppId}] defaultOncall auto-bind skipped chat=${chatId} reason=${r.skipped}`);
316
+ return undefined;
226
317
  }
227
- return { extraResources };
318
+ logger.info(`[${larkAppId}] defaultOncall auto-bound chat=${chatId} ${def.workingDir}`);
319
+ return r.entry;
228
320
  }
229
- // ─── Event handling ──────────────────────────────────────────────────────────
230
- async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkAppId) {
321
+ async function handleNewTopic(data, ctx) {
322
+ const { chatId, messageId, chatType, larkAppId } = ctx;
323
+ // scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
324
+ // routing into thread-scope so the bot's first reply seeds a Lark thread.
325
+ let scope = ctx.scope;
326
+ let anchor = ctx.anchor;
231
327
  await resolveNonsupportMessage(data, larkAppId);
232
328
  const { parsed, resources } = parseEventMessage(data);
233
329
  // Expand merge_forward: fetch sub-messages and collect their resources
@@ -235,32 +331,62 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
235
331
  const { extraResources } = await expandMergeForward(larkAppId, messageId, parsed);
236
332
  resources.push(...extraResources);
237
333
  }
238
- const content = parsed.content.trim();
334
+ let content = parsed.content.trim();
239
335
  // Strip leading @<bot> mentions so "@bot /oncall bind" is recognized as a command.
240
- const cmdContent = stripLeadingMentions(content, parsed.mentions);
336
+ let cmdContent = stripLeadingMentions(content, parsed.mentions);
337
+ // `/t` / `/topic` — force the bot to reply in a thread, even in 普通群.
338
+ // In 普通群 the inbound message is chat-scope by default; override to
339
+ // thread-scope anchored at the user's message_id so sessionReply() uses
340
+ // reply_in_thread=true and seeds a fresh Lark thread. In 话题群 / p2p
341
+ // (already thread-scope) it's just a prefix strip — no routing change.
342
+ // Empty prompt is allowed: the user can fill it in while the repo card is
343
+ // pending (pendingFollowUps in handleThreadReply picks up subsequent text).
344
+ const forceTopic = parseForceTopicInvocation(cmdContent);
345
+ if (forceTopic) {
346
+ if (scope === 'chat') {
347
+ scope = 'thread';
348
+ anchor = messageId;
349
+ }
350
+ content = forceTopic.prompt;
351
+ parsed.content = forceTopic.prompt;
352
+ cmdContent = forceTopic.prompt;
353
+ logger.info(`[/t] Force-topic invocation: prompt="${forceTopic.prompt.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)})`);
354
+ }
241
355
  const senderOpenId = data.sender?.sender_id?.open_id;
242
356
  const botCfg = getBot(larkAppId).config;
243
- logger.info(`New topic: "${content.substring(0, 60)}" (resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId}`);
357
+ logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`);
244
358
  // Intercept daemon commands in new topics (no session needed for some commands)
245
- if (cmdContent.startsWith('/')) {
246
- const cmd = cmdContent.split(/\s+/)[0].toLowerCase();
359
+ const invocation = parseSlashCommandInvocation(cmdContent);
360
+ if (invocation) {
361
+ const { cmd, content: commandContent } = invocation;
247
362
  if (PASSTHROUGH_COMMANDS.has(cmd)) {
248
- await sessionReply(messageId, `${cmd} 需要在已有会话内使用(先发一条普通消息启动 CLI)。`, 'text', larkAppId);
363
+ await sessionReply(anchor, `${cmd} 需要在已有会话内使用(先发一条普通消息启动 CLI)。`, 'text', larkAppId);
249
364
  return;
250
365
  }
251
366
  if (DAEMON_COMMANDS.has(cmd)) {
252
- // Oncall groups: any member can talk, but daemon commands (except /oncall
253
- // itself which gates bind/unbind inside) are owner-only.
254
- if (cmd !== '/oncall' && findOncallChat(larkAppId, chatId) && !canOperate(larkAppId, chatId, senderOpenId)) {
255
- await sessionReply(messageId, `⚠️ ${cmd} oncall owner 可执行。`, 'text', larkAppId);
367
+ // Oncall groups: anyone can chat with the bot, but daemon commands
368
+ // (including /oncall itself) require allowedUsers. Treat the chat as
369
+ // oncall when ANY bot has it bound sibling bots in multi-bot
370
+ // deployments inherit the same gate so /cd /restart /close don't slip
371
+ // past allowedUsers just because this bot wasn't the one that bound.
372
+ if (isChatOncallBoundForAnyBot(chatId) && !canOperate(larkAppId, chatId, senderOpenId)) {
373
+ await sessionReply(anchor, `⚠️ ${cmd} 仅 allowedUsers 可执行。`, 'text', larkAppId);
256
374
  return;
257
375
  }
258
- const session = sessionStore.createSession(chatId, messageId, cmdContent.substring(0, 50), chatType);
376
+ // Same rootMessageId reasoning as below in the main spawn path:
377
+ // thread-scope MUST anchor on the thread root or sessionAnchorId() will
378
+ // disagree with activeSessions's key and downstream card buttons silently
379
+ // break. Chat-scope keeps the inbound messageId as audit only.
380
+ const cmdRootIdForStore = scope === 'thread' ? anchor : messageId;
381
+ const session = sessionStore.createSession(chatId, cmdRootIdForStore, cmdContent.substring(0, 50), chatType);
382
+ const now = Date.now();
259
383
  session.larkAppId = larkAppId;
260
384
  session.ownerOpenId = senderOpenId;
261
385
  session.lastCallerOpenId = senderOpenId;
386
+ session.lastMessageAt = new Date(now).toISOString();
387
+ session.scope = scope;
262
388
  sessionStore.updateSession(session);
263
- activeSessions.set(sessionKey(messageId, larkAppId), {
389
+ activeSessions.set(sessionKey(anchor, larkAppId), {
264
390
  session,
265
391
  worker: null,
266
392
  workerPort: null,
@@ -268,14 +394,15 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
268
394
  larkAppId,
269
395
  chatId,
270
396
  chatType,
271
- spawnedAt: Date.now(),
397
+ scope,
398
+ spawnedAt: Date.parse(session.createdAt) || now,
272
399
  cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
273
- lastMessageAt: Date.now(),
400
+ lastMessageAt: now,
274
401
  hasHistory: false,
275
402
  ownerOpenId: senderOpenId,
276
403
  });
277
404
  // Pass mention-stripped content so /command argument parsing works.
278
- await handleCommand(cmd, messageId, { ...parsed, content: cmdContent }, commandDeps, larkAppId);
405
+ await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
279
406
  return;
280
407
  }
281
408
  }
@@ -285,18 +412,55 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
285
412
  parsed.attachments = attachments;
286
413
  }
287
414
  if (needLogin) {
288
- sessionReply(messageId, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', larkAppId);
415
+ sessionReply(anchor, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', larkAppId);
289
416
  }
417
+ // First-turn quote-reply: when the user @s the bot via Lark's "quote" UI as
418
+ // the very first interaction (no active session yet), the same hint that
419
+ // handleThreadReply prepends needs to ride along here too. Without it, the
420
+ // bot never learns about the quoted message_id and `botmux quoted` is dead
421
+ // weight on first turns. `content` (post force-topic-strip) is what the
422
+ // worker will see; promptContent wraps it for prompt-building paths but
423
+ // leaves `content` untouched for title / log substring uses.
424
+ const promptContent = buildQuoteHint(parsed, scope, anchor) + content;
290
425
  refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
291
- // Create session in pending-repo state — don't spawn CLI yet
292
- const session = sessionStore.createSession(chatId, messageId, parsed.content.substring(0, 50), chatType);
426
+ // Create session in pending-repo state — don't spawn CLI yet.
427
+ // For thread-scope, rootMessageId == anchor (the thread root). Critical
428
+ // because sessionAnchorId() uses rootMessageId for thread-scope, and the
429
+ // session card's button payload (value.root_id) flows from there back into
430
+ // activeSessions.get(sessionKey(rootId, larkAppId)) — if rootMessageId is
431
+ // the inbound message_id instead of the thread root, every restart/close/
432
+ // disconnect click silently no-ops.
433
+ // For chat-scope, rootMessageId stores the seed message_id (audit only);
434
+ // routing keys off chatId via sessionAnchorId(), so any value works.
435
+ const rootIdForStore = scope === 'thread' ? anchor : messageId;
436
+ const session = sessionStore.createSession(chatId, rootIdForStore, parsed.content.substring(0, 50), chatType);
437
+ const now = Date.now();
293
438
  session.larkAppId = larkAppId;
294
439
  session.ownerOpenId = senderOpenId;
440
+ session.lastMessageAt = new Date(now).toISOString();
441
+ session.scope = scope;
295
442
  sessionStore.updateSession(session);
296
- messageQueue.ensureQueue(messageId);
297
- messageQueue.appendMessage(messageId, parsed);
298
- // Oncall group: pin working dir from binding, skip repo selection entirely.
299
- const oncallEntry = findOncallChat(larkAppId, chatId);
443
+ messageQueue.ensureQueue(anchor);
444
+ messageQueue.appendMessage(anchor, parsed);
445
+ // Oncall group: pin working dir from the chat-level binding, even if a
446
+ // sibling bot (running in another daemon) is the one that persisted it.
447
+ // Layered lookup:
448
+ // 1) any existing binding (this bot or sibling)
449
+ // 2) this bot's defaultOncall — auto-binds the chat if it's brand new
450
+ // and the flag is on. Once auto-bound, the chat appears in oncallChats
451
+ // so the next handleNewTopic sees it via (1).
452
+ let oncallEntry = findOncallChatForAnyBot(chatId);
453
+ if (!oncallEntry) {
454
+ oncallEntry = await maybeAutoBindDefaultOncall(larkAppId, chatId, chatType);
455
+ }
456
+ // Cross-bot / chat-scope inheritance: reuse a sibling session's workingDir
457
+ // and skip the repo card. Same block lives in handleThreadReply's auto-create
458
+ // branch — both handlers land unowned messages after the 4fec43c routing
459
+ // change. Helper is shared.
460
+ const inheritedFrom = !oncallEntry
461
+ ? findInheritablePeer({ scope, anchor, chatId, chatType, selfAppId: larkAppId })
462
+ : null;
463
+ const pinnedWorkingDir = oncallEntry?.workingDir ?? inheritedFrom?.workingDir;
300
464
  const ds = {
301
465
  session,
302
466
  worker: null,
@@ -305,29 +469,33 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
305
469
  larkAppId,
306
470
  chatId,
307
471
  chatType,
308
- spawnedAt: Date.now(),
472
+ scope,
473
+ spawnedAt: Date.parse(session.createdAt) || now,
309
474
  cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
310
- lastMessageAt: Date.now(),
475
+ lastMessageAt: now,
311
476
  hasHistory: false,
312
- pendingRepo: !oncallEntry,
313
- pendingPrompt: content,
477
+ pendingRepo: !pinnedWorkingDir,
478
+ pendingPrompt: promptContent,
314
479
  pendingAttachments: attachments.length > 0 ? attachments : undefined,
315
480
  pendingMentions: parsed.mentions,
316
481
  ownerOpenId: senderOpenId,
317
482
  currentTurnTitle: content.substring(0, 50),
318
- workingDir: oncallEntry?.workingDir,
483
+ workingDir: pinnedWorkingDir,
319
484
  };
320
- if (oncallEntry) {
321
- ds.session.workingDir = oncallEntry.workingDir;
485
+ if (pinnedWorkingDir) {
486
+ ds.session.workingDir = pinnedWorkingDir;
322
487
  sessionStore.updateSession(ds.session);
323
488
  }
324
- activeSessions.set(sessionKey(messageId, larkAppId), ds);
325
- // Oncall-bound chat: spawn CLI immediately with the pinned working dir.
326
- if (oncallEntry) {
489
+ activeSessions.set(sessionKey(anchor, larkAppId), ds);
490
+ // Pinned (oncall binding or inherited from sibling bot): spawn CLI immediately.
491
+ if (pinnedWorkingDir) {
327
492
  const selfBot = getBot(larkAppId);
328
- const prompt = buildNewTopicPrompt(content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
493
+ const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
329
494
  forkWorker(ds, prompt);
330
- logger.info(`[${tag(ds)}] Oncall-bound chat ${chatId} → workingDir=${oncallEntry.workingDir}, skipped repo select`);
495
+ const reason = oncallEntry
496
+ ? `oncall-bound chat ${chatId}`
497
+ : `inherited from sibling session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`;
498
+ logger.info(`[${tag(ds)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
331
499
  return;
332
500
  }
333
501
  // Show repo selection card
@@ -339,20 +507,56 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
339
507
  if (projects.length > 0) {
340
508
  lastRepoScan.set(chatId, projects);
341
509
  const currentCwd = getSessionWorkingDir(ds);
342
- const cardJson = buildRepoSelectCard(projects, currentCwd, messageId);
343
- ds.repoCardMessageId = await sessionReply(messageId, cardJson, 'interactive', larkAppId);
510
+ const cardJson = buildRepoSelectCard(projects, currentCwd, anchor);
511
+ ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
344
512
  logger.info(`[${tag(ds)}] Waiting for repo selection (${projects.length} projects)`);
345
513
  }
346
514
  else {
347
515
  // No projects found — skip repo selection, spawn directly
348
516
  ds.pendingRepo = false;
349
517
  const selfBot = getBot(larkAppId);
350
- const prompt = buildNewTopicPrompt(content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
518
+ const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
351
519
  forkWorker(ds, prompt);
352
520
  logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
353
521
  }
354
522
  }
355
- async function handleThreadReply(data, rootId, larkAppId) {
523
+ /** Reverse-lookup a foreign bot's display name for a sender open_id observed on
524
+ * this app's WS events. Priority:
525
+ * 1) bot-openids-${larkAppId}.json — per-app cross-ref populated by
526
+ * updateBotOpenIdCrossRef when @mentions go through us. Open_id is
527
+ * per-app scoped, so this is the authoritative map for this larkAppId.
528
+ * 2) bots-info.json — fallback for bots not yet in our cross-ref but
529
+ * registered as botmux peers (matches by their self-reported open_id;
530
+ * only works when the peer's app id space coincides with ours).
531
+ * Returns "Bot" if neither lookup hits — keeps the prefix readable rather
532
+ * than blocking the message.
533
+ */
534
+ function lookupForeignBotName(senderOpenId, larkAppId) {
535
+ try {
536
+ const fp = join(config.session.dataDir, `bot-openids-${larkAppId}.json`);
537
+ if (existsSync(fp)) {
538
+ const data = JSON.parse(readFileSync(fp, 'utf-8'));
539
+ for (const [name, openId] of Object.entries(data)) {
540
+ if (openId === senderOpenId)
541
+ return name;
542
+ }
543
+ }
544
+ }
545
+ catch { /* fall through */ }
546
+ try {
547
+ const infoPath = join(config.session.dataDir, 'bots-info.json');
548
+ if (existsSync(infoPath)) {
549
+ const entries = JSON.parse(readFileSync(infoPath, 'utf-8'));
550
+ const hit = entries.find(e => e.botOpenId === senderOpenId);
551
+ if (hit)
552
+ return hit.botName ?? getCliDisplayName(hit.cliId);
553
+ }
554
+ }
555
+ catch { /* */ }
556
+ return 'Bot';
557
+ }
558
+ async function handleThreadReply(data, ctx) {
559
+ const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId } = ctx;
356
560
  await resolveNonsupportMessage(data, larkAppId);
357
561
  const { parsed, resources } = parseEventMessage(data);
358
562
  // Expand merge_forward: fetch sub-messages and collect their resources
@@ -360,6 +564,34 @@ async function handleThreadReply(data, rootId, larkAppId) {
360
564
  const { extraResources } = await expandMergeForward(larkAppId, parsed.messageId, parsed);
361
565
  resources.push(...extraResources);
362
566
  }
567
+ // Foreign bot @mention prefix: when sender is another botmux bot,把内容包成
568
+ // [来自 X 的 @mention]\n<原文> 喂给 worker,让 CLI 知道这是另一个 bot 发的——
569
+ // 不是用户直接发的——后续不需要按"对话用户"的方式处理。signal-file 路径
570
+ // 删掉之前由 processBotMentionSignal 拼,现在统一在这里拼。仅影响发给
571
+ // worker 的 prompt 内容,title / 命令解析 / 日志还是用原 parsed.content。
572
+ //
573
+ // 检测策略走双轨:
574
+ // 1) `sender.sender_type === 'app' | 'bot'` —— 飞书事件标注为机器人发送。
575
+ // 'app' 是文档里的常规值;'bot' 是实测中跨 bot @ 卡片消息到接收方时
576
+ // 飞书实际给的值(与 'app' 等价对待,少依赖一次 cross-ref 学习)。
577
+ // 2) sender 的 open_id 在我们本 app 的 cross-ref(bot-openids-<appId>.json)
578
+ // 里能匹配到一个 botmux 同伴名字 —— 兜底覆盖 sender_type 又变其他取值
579
+ // 或者全无的边角情况,前提是之前已通过 @mention 学习链路记录过对方。
580
+ const senderOpenIdForPrefix = parsed.senderId || data?.sender?.sender_id?.open_id;
581
+ const selfBotOpenId = getBot(larkAppId).botOpenId;
582
+ const isBotSenderType = parsed.senderType === 'app' || parsed.senderType === 'bot';
583
+ const isForeignBot = !!senderOpenIdForPrefix &&
584
+ senderOpenIdForPrefix !== selfBotOpenId &&
585
+ (isBotSenderType ||
586
+ isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenIdForPrefix));
587
+ const botSenderPrefix = isForeignBot
588
+ ? `[来自 ${lookupForeignBotName(senderOpenIdForPrefix, larkAppId)} 的 @mention]\n`
589
+ : '';
590
+ const promptContent = buildQuoteHint(parsed, scope, anchor) + botSenderPrefix + parsed.content;
591
+ if (isForeignBot) {
592
+ logger.info(`[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` +
593
+ `senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`);
594
+ }
363
595
  const content = parsed.content.trim();
364
596
  // Strip leading @<bot> mentions so "@bot /restart" is recognized as a command.
365
597
  const cmdContent = stripLeadingMentions(content, parsed.mentions);
@@ -367,49 +599,64 @@ async function handleThreadReply(data, rootId, larkAppId) {
367
599
  if (isCallbackUrl(content)) {
368
600
  const result = await handleCallbackUrl(content);
369
601
  if (result) {
370
- replyMessage(larkAppId, parsed.messageId, JSON.stringify({ text: result }), 'text', true)
602
+ // Route through sessionReply so chat-scope (普通群) lands as a plain
603
+ // chat message instead of a forced new thread.
604
+ sessionReply(anchor, result, 'text', larkAppId)
371
605
  .catch(err => logger.error(`Failed to reply login result: ${err}`));
372
606
  return;
373
607
  }
374
608
  }
375
609
  // Intercept daemon commands
376
- if (cmdContent.startsWith('/')) {
377
- const cmd = cmdContent.split(/\s+/)[0].toLowerCase();
610
+ const invocation = parseSlashCommandInvocation(cmdContent);
611
+ if (invocation) {
612
+ const { cmd, content: commandContent } = invocation;
378
613
  if (PASSTHROUGH_COMMANDS.has(cmd)) {
379
- const ds = activeSessions.get(sessionKey(rootId, larkAppId));
614
+ const ds = activeSessions.get(sessionKey(anchor, larkAppId));
380
615
  if (ds?.worker && !ds.worker.killed) {
381
- ds.worker.send({ type: 'raw_input', content: cmdContent });
382
- ds.lastMessageAt = Date.now();
383
- logger.info(`[${rootId.substring(0, 12)}] Passthrough ${cmd} → worker`);
616
+ // Mark a new turn so the CLI's response to /model, /clear, /compact, etc.
617
+ // shows up as a fresh streaming card instead of silently PATCH-ing the
618
+ // previous turn's card.
619
+ beginNewTurn(ds, commandContent);
620
+ ds.worker.send({ type: 'raw_input', content: commandContent });
621
+ markSessionActivity(ds);
622
+ logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
384
623
  }
385
624
  else {
386
- sessionReply(rootId, `${cmd} 需要活跃的 CLI 进程,当前话题无运行中的会话。`, 'text', larkAppId);
625
+ sessionReply(anchor, `${cmd} 需要活跃的 CLI 进程,当前话题无运行中的会话。`, 'text', larkAppId);
387
626
  }
388
627
  return;
389
628
  }
390
629
  if (DAEMON_COMMANDS.has(cmd)) {
391
- // Oncall owner gate for thread-reply daemon commands
392
- const existingDs = activeSessions.get(sessionKey(rootId, larkAppId));
393
- const threadChatId = existingDs?.chatId ?? data?.message?.chat_id;
630
+ // Oncall allowedUsers gate for thread-reply daemon commands
631
+ const existingDs = activeSessions.get(sessionKey(anchor, larkAppId));
632
+ const threadChatId = existingDs?.chatId ?? ctxChatId ?? data?.message?.chat_id;
394
633
  const threadSenderOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
395
- if (cmd !== '/oncall' && threadChatId && findOncallChat(larkAppId, threadChatId) && !canOperate(larkAppId, threadChatId, threadSenderOpenId)) {
396
- sessionReply(rootId, `⚠️ ${cmd} 仅 oncall owner 可执行。`, 'text', larkAppId);
634
+ if (threadChatId && isChatOncallBoundForAnyBot(threadChatId) && !canOperate(larkAppId, threadChatId, threadSenderOpenId)) {
635
+ sessionReply(anchor, `⚠️ ${cmd} 仅 allowedUsers 可执行。`, 'text', larkAppId);
397
636
  return;
398
637
  }
399
638
  // Pass mention-stripped content so /command argument parsing works.
400
- handleCommand(cmd, rootId, { ...parsed, content: cmdContent }, commandDeps, larkAppId);
639
+ handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
401
640
  return;
402
641
  }
403
642
  }
404
- logger.info(`Thread reply in ${rootId}: ${content.substring(0, 100)} (resources: ${resources.length})`);
405
- let ds = activeSessions.get(sessionKey(rootId, larkAppId));
406
- // If another bot already owns this thread, ignore unmentioned replies here as a
643
+ logger.info(`Reply in ${scope}-scope session ${anchor.substring(0, 12)}: ${content.substring(0, 100)} (resources: ${resources.length})`);
644
+ let ds = activeSessions.get(sessionKey(anchor, larkAppId));
645
+ // If another bot already owns this anchor, ignore unmentioned replies here as a
407
646
  // second line of defense. Explicit @mentions are still allowed to spin up/take over.
647
+ // For chat-scope: another bot's session in the same chat is keyed by its own chatId.
648
+ // For thread-scope: same rootMessageId may have peer sessions across bots.
408
649
  if (!ds) {
409
650
  const mentionedThisBot = isBotMentioned(larkAppId, data?.message ?? {}, data?.sender?.sender_id?.open_id);
410
- const hasOtherBot = [...activeSessions.values()].some(s => s.session.rootMessageId === rootId && s.larkAppId !== larkAppId);
651
+ const hasOtherBot = [...activeSessions.values()].some(s => {
652
+ if (s.larkAppId === larkAppId)
653
+ return false;
654
+ if (s.scope === 'chat')
655
+ return s.chatId === ctxChatId && scope === 'chat';
656
+ return s.session.rootMessageId === anchor;
657
+ });
411
658
  if (hasOtherBot && !mentionedThisBot) {
412
- logger.info(`[${larkAppId}] Ignoring thread ${rootId}; another bot already owns it`);
659
+ logger.info(`[${larkAppId}] Ignoring ${scope}-scope ${anchor}; another bot already owns it`);
413
660
  return;
414
661
  }
415
662
  }
@@ -420,13 +667,13 @@ async function handleThreadReply(data, rootId, larkAppId) {
420
667
  parsed.attachments = attachments;
421
668
  }
422
669
  if (needLogin) {
423
- sessionReply(rootId, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', effectiveAppId);
670
+ sessionReply(anchor, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', effectiveAppId);
424
671
  }
425
672
  // Update last message time + last caller (used by `botmux send` to address
426
673
  // reply cards to whoever triggered this turn — matters in oncall groups
427
674
  // where the caller is often not the session owner).
428
675
  if (ds) {
429
- ds.lastMessageAt = Date.now();
676
+ markSessionActivity(ds);
430
677
  const callerOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
431
678
  if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
432
679
  ds.session.lastCallerOpenId = callerOpenId;
@@ -437,8 +684,8 @@ async function handleThreadReply(data, rootId, larkAppId) {
437
684
  if (ds?.pendingRepo) {
438
685
  // Enrich content with attachment hints and mention metadata (same as normal send)
439
686
  let enriched = attachments.length > 0
440
- ? `${parsed.content}${formatAttachmentsHint(attachments)}`
441
- : parsed.content;
687
+ ? `${promptContent}${formatAttachmentsHint(attachments)}`
688
+ : promptContent;
442
689
  if (parsed.mentions && parsed.mentions.length > 0) {
443
690
  const mentionLines = parsed.mentions.map(m => {
444
691
  const idPart = m.openId ? ` → open_id: ${m.openId}` : '';
@@ -449,63 +696,95 @@ async function handleThreadReply(data, rootId, larkAppId) {
449
696
  if (!ds.pendingFollowUps)
450
697
  ds.pendingFollowUps = [];
451
698
  ds.pendingFollowUps.push(enriched);
452
- await sessionReply(rootId, '请先在上方卡片中选择仓库,您的消息已暂存,选择后会自动发送。', 'text', larkAppId);
699
+ await sessionReply(anchor, '请先在上方卡片中选择仓库,您的消息已暂存,选择后会自动发送。', 'text', larkAppId);
453
700
  return;
454
701
  }
455
- // Route to file queue
456
- messageQueue.ensureQueue(rootId);
457
- messageQueue.appendMessage(rootId, parsed);
702
+ // Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat)
703
+ messageQueue.ensureQueue(anchor);
704
+ messageQueue.appendMessage(anchor, parsed);
458
705
  if (!ds) {
459
- // No active session for this thread — auto-create with repo selection
460
- if (activeSessions.has(sessionKey(rootId, larkAppId))) {
461
- logger.info(`[${larkAppId}] Session already exists for thread ${rootId}, skipping auto-create`);
706
+ // No active session at this anchor — auto-create. This branch is mostly a
707
+ // safety net; the dispatcher routes here only when isSessionOwner() returns
708
+ // true, but races (between check and execution, or session-closed events)
709
+ // can land us here.
710
+ if (activeSessions.has(sessionKey(anchor, larkAppId))) {
711
+ logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, skipping auto-create`);
462
712
  return;
463
713
  }
464
- const chatId = data?.message?.chat_id ?? '';
465
- const chatType = (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
714
+ const autoCreateChatId = ctxChatId ?? data?.message?.chat_id ?? '';
715
+ const autoCreateChatType = ctxChatType ?? (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
466
716
  const botCfg = getBot(larkAppId).config;
467
- logger.info(`No active session for thread ${rootId}, auto-creating new session...`);
717
+ logger.info(`No active session for ${scope}-scope ${anchor}, auto-creating new session...`);
468
718
  refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
469
719
  const senderOId = data.sender?.sender_id?.open_id;
470
- const session = sessionStore.createSession(chatId, rootId, parsed.content.substring(0, 50), chatType);
720
+ // For thread-scope: rootMessageId = anchor (real thread root).
721
+ // For chat-scope: rootMessageId = the message_id that triggered this auto-create
722
+ // (used as audit trail; routing key is chatId).
723
+ const rootIdForStore = scope === 'thread' ? anchor : parsed.messageId;
724
+ const session = sessionStore.createSession(autoCreateChatId, rootIdForStore, parsed.content.substring(0, 50), autoCreateChatType);
725
+ const now = Date.now();
471
726
  session.larkAppId = larkAppId;
472
727
  session.ownerOpenId = senderOId;
473
728
  session.lastCallerOpenId = senderOId;
729
+ session.lastMessageAt = new Date(now).toISOString();
730
+ session.scope = scope;
474
731
  sessionStore.updateSession(session);
475
- // Oncall group: pin working dir from binding, skip repo selection entirely
476
- // (mirrors handleNewTopic auto-create was missing this check).
477
- const oncallEntry = findOncallChat(larkAppId, chatId);
732
+ // Oncall group: pin working dir from the chat-level binding, even if a
733
+ // sibling bot (running in another daemon) is the one that persisted it.
734
+ // Defaults auto-bind path mirrors handleNewTopic — keep both call sites
735
+ // in sync (this is the auto-create branch that fires when routing lands
736
+ // here without an active session, e.g. chat-scope first-reply paths).
737
+ let oncallEntry = findOncallChatForAnyBot(autoCreateChatId);
738
+ if (!oncallEntry) {
739
+ oncallEntry = await maybeAutoBindDefaultOncall(larkAppId, autoCreateChatId, autoCreateChatType);
740
+ }
741
+ // Cross-bot / chat-scope inheritance — see findInheritablePeer comments.
742
+ const inheritedFrom = !oncallEntry
743
+ ? findInheritablePeer({
744
+ scope,
745
+ anchor,
746
+ chatId: autoCreateChatId,
747
+ chatType: autoCreateChatType,
748
+ selfAppId: larkAppId,
749
+ })
750
+ : null;
751
+ const pinnedWorkingDir = oncallEntry?.workingDir ?? inheritedFrom?.workingDir;
478
752
  const newDs = {
479
753
  session,
480
754
  worker: null,
481
755
  workerPort: null,
482
756
  workerToken: null,
483
757
  larkAppId,
484
- chatId,
485
- chatType,
486
- spawnedAt: Date.now(),
758
+ chatId: autoCreateChatId,
759
+ chatType: autoCreateChatType,
760
+ scope,
761
+ spawnedAt: Date.parse(session.createdAt) || now,
487
762
  cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
488
- lastMessageAt: Date.now(),
763
+ lastMessageAt: now,
489
764
  hasHistory: false,
490
- pendingRepo: !oncallEntry,
491
- pendingPrompt: parsed.content,
765
+ pendingRepo: !pinnedWorkingDir,
766
+ pendingPrompt: promptContent,
492
767
  pendingAttachments: attachments.length > 0 ? attachments : undefined,
493
768
  pendingMentions: parsed.mentions,
494
769
  ownerOpenId: senderOId,
495
770
  currentTurnTitle: parsed.content.substring(0, 50),
496
- workingDir: oncallEntry?.workingDir,
771
+ workingDir: pinnedWorkingDir,
497
772
  };
498
- if (oncallEntry) {
499
- newDs.session.workingDir = oncallEntry.workingDir;
773
+ if (pinnedWorkingDir) {
774
+ newDs.session.workingDir = pinnedWorkingDir;
500
775
  sessionStore.updateSession(newDs.session);
501
776
  }
502
- activeSessions.set(sessionKey(rootId, larkAppId), newDs);
503
- // Oncall-bound chat: spawn CLI immediately with the pinned working dir.
504
- if (oncallEntry) {
777
+ activeSessions.set(sessionKey(anchor, larkAppId), newDs);
778
+ // Pinned (oncall binding or inherited from peer bot in same thread):
779
+ // spawn CLI immediately, skip repo selection.
780
+ if (pinnedWorkingDir) {
505
781
  const selfBot = getBot(larkAppId);
506
- const prompt = buildNewTopicPrompt(parsed.content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
782
+ const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
507
783
  forkWorker(newDs, prompt);
508
- logger.info(`[${tag(newDs)}] Oncall-bound chat ${chatId} → workingDir=${oncallEntry.workingDir}, skipped repo select`);
784
+ const reason = oncallEntry
785
+ ? `oncall-bound chat ${autoCreateChatId}`
786
+ : `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`;
787
+ logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
509
788
  return;
510
789
  }
511
790
  // Show repo selection card (same as handleNewTopic)
@@ -515,17 +794,17 @@ async function handleThreadReply(data, rootId, larkAppId) {
515
794
  projects = scanMultipleProjects(scanDirs2);
516
795
  }
517
796
  if (projects.length > 0) {
518
- lastRepoScan.set(chatId, projects);
797
+ lastRepoScan.set(autoCreateChatId, projects);
519
798
  const currentCwd = getSessionWorkingDir(newDs);
520
- const cardJson = buildRepoSelectCard(projects, currentCwd, rootId);
521
- newDs.repoCardMessageId = await sessionReply(rootId, cardJson, 'interactive', larkAppId);
799
+ const cardJson = buildRepoSelectCard(projects, currentCwd, anchor);
800
+ newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
522
801
  logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
523
802
  }
524
803
  else {
525
804
  // No projects found — skip repo selection, spawn directly
526
805
  newDs.pendingRepo = false;
527
806
  const selfBot = getBot(larkAppId);
528
- const prompt = buildNewTopicPrompt(parsed.content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
807
+ const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
529
808
  forkWorker(newDs, prompt);
530
809
  }
531
810
  return;
@@ -533,41 +812,30 @@ async function handleThreadReply(data, rootId, larkAppId) {
533
812
  // Send message to worker via IPC
534
813
  if (ds.worker && !ds.worker.killed) {
535
814
  const dsBotCfgForMsg = getBot(ds.larkAppId).config;
536
- const msgContent = buildFollowUpContent(parsed.content, ds.session.sessionId, {
537
- attachments,
538
- mentions: parsed.mentions,
539
- isAdoptMode: !!ds.adoptedFrom,
540
- cliId: dsBotCfgForMsg.cliId,
541
- cliPathOverride: dsBotCfgForMsg.cliPathOverride,
542
- });
543
- // Freeze the previous turn's card at "idle" before starting a new turn
544
- if (ds.streamCardId && ds.workerPort) {
545
- const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
546
- const dsBotCfg = getBot(ds.larkAppId).config;
547
- const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId);
548
- const prevMode = ds.displayMode ?? 'hidden';
549
- const frozenCard = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readUrl, prevTitle, ds.lastScreenContent ?? '', 'idle', dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey);
550
- // Freeze through the serialization queue to avoid racing with an in-flight PATCH.
551
- // scheduleCardPatch replaces any stale pending item (latest-wins).
552
- scheduleCardPatch(ds, frozenCard);
553
- // Cache frozen card data so historical cards can still be toggled (expand/collapse)
554
- if (ds.streamCardNonce && ds.streamCardId !== CARD_POSTING_SENTINEL) {
555
- if (!ds.frozenCards)
556
- ds.frozenCards = new Map();
557
- ds.frozenCards.set(ds.streamCardNonce, {
558
- messageId: ds.streamCardId,
559
- content: ds.lastScreenContent ?? '',
560
- title: prevTitle,
561
- displayMode: prevMode,
562
- imageKey: ds.currentImageKey,
563
- });
564
- saveFrozenCards(ds.session.sessionId, ds.frozenCards);
565
- }
566
- }
567
- // Mark new turn — next screen_update will create a fresh streaming card
568
- ds.streamCardPending = true;
569
- ds.currentTurnTitle = parsed.content.substring(0, 50);
570
- persistStreamCardState(ds);
815
+ // Adopt mode: the adopted CLI is the user's external process and was
816
+ // never injected with botmux's skill / system prompt. Sending it the
817
+ // `<user_message>` / `<botmux_reminder>` / `<session_id>` wrappers
818
+ // surfaces those tags verbatim in its UI (the user reported Codex
819
+ // showing raw XML on every Lark message). Use the bridge raw-input
820
+ // builder for ALL adopt sessions regardless of cliId — transcript
821
+ // harvest (Claude bridge or Codex bridge) handles the reply path
822
+ // out-of-band.
823
+ const isBridge = !!ds.adoptedFrom;
824
+ const selfBot = getBot(ds.larkAppId);
825
+ const msgContent = isBridge
826
+ ? buildBridgeInputContent(promptContent, {
827
+ attachments,
828
+ mentions: parsed.mentions,
829
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
830
+ })
831
+ : buildFollowUpContent(promptContent, ds.session.sessionId, {
832
+ attachments,
833
+ mentions: parsed.mentions,
834
+ isAdoptMode: false,
835
+ cliId: dsBotCfgForMsg.cliId,
836
+ cliPathOverride: dsBotCfgForMsg.cliPathOverride,
837
+ });
838
+ beginNewTurn(ds, parsed.content);
571
839
  ds.worker.send({ type: 'message', content: msgContent });
572
840
  }
573
841
  else {
@@ -576,104 +844,33 @@ async function handleThreadReply(data, rootId, larkAppId) {
576
844
  // card instead of PATCHing the previous turn's card in place.
577
845
  logger.info(`[${tag(ds)}] Worker not running, re-forking...`);
578
846
  ds.currentTurnTitle = parsed.content.substring(0, 50);
847
+ // The cosmetic freeze step (above) is gated on a live worker. With no
848
+ // worker we just park the current card in frozenCards — the upcoming
849
+ // new POST will recall it. Parking instead of deleting preserves the
850
+ // "old card stays until a new one is live" invariant: if fork /
851
+ // worker_ready / POST fails, the user still sees the previous card.
852
+ parkStreamCard(ds);
579
853
  ds.streamCardId = undefined;
580
854
  ds.streamCardNonce = undefined;
581
855
  persistStreamCardState(ds);
582
- forkWorker(ds, parsed.content, ds.hasHistory);
583
- }
584
- }
585
- function processBotMentionSignal(signal) {
586
- // Find the target bot by open_id
587
- const targetBot = getAllBots().find(b => b.botOpenId === signal.targetBotOpenId);
588
- if (!targetBot) {
589
- logger.debug(`[bot-mention] No bot found for open_id ${signal.targetBotOpenId}`);
590
- return;
591
- }
592
- const targetAppId = targetBot.config.larkAppId;
593
- const ds = activeSessions.get(sessionKey(signal.rootMessageId, targetAppId));
594
- if (ds && ds.worker && !ds.worker.killed) {
595
- // Target bot has an active session in this thread — send the message.
596
- // Look up sender name from bots-info.json (each daemon only registers its own bot,
597
- // so getAllBots() won't find other bots).
598
- let senderName = 'Bot';
599
- try {
600
- const infoPath = join(config.session.dataDir, 'bots-info.json');
601
- if (existsSync(infoPath)) {
602
- const entries = JSON.parse(readFileSync(infoPath, 'utf-8'));
603
- const sender = entries.find(e => e.larkAppId === signal.senderAppId);
604
- if (sender)
605
- senderName = sender.botName ?? getCliDisplayName(sender.cliId);
606
- }
607
- }
608
- catch { /* ignore */ }
609
- const enrichedParts = [`[来自 ${senderName} 的 @mention]\n${signal.content}`];
610
- if (!ds.adoptedFrom) {
611
- const mentionBotCfg = getBot(ds.larkAppId).config;
612
- const mentionAdapter = createCliAdapterSync(mentionBotCfg.cliId, mentionBotCfg.cliPathOverride);
613
- if (!mentionAdapter.injectsSessionContext) {
614
- enrichedParts.push(`Session ID: ${ds.session.sessionId}`);
615
- }
616
- }
617
- const enrichedContent = enrichedParts.join('\n\n');
618
- ds.lastMessageAt = Date.now();
619
- ds.streamCardPending = true;
620
- ds.currentTurnTitle = signal.content.substring(0, 50);
621
- persistStreamCardState(ds);
622
- ds.worker.send({ type: 'message', content: enrichedContent });
623
- logger.info(`[bot-mention] Routed message from ${signal.senderAppId} to ${targetAppId} in thread ${signal.rootMessageId}`);
624
- }
625
- else {
626
- logger.debug(`[bot-mention] Target bot ${targetAppId} has no active worker for thread ${signal.rootMessageId}`);
627
- }
628
- }
629
- function isSignalForMe(signal) {
630
- return getAllBots().some(b => b.botOpenId === signal.targetBotOpenId);
631
- }
632
- function startBotMentionWatcher() {
633
- const signalDir = join(config.session.dataDir, 'bot-mentions');
634
- if (!existsSync(signalDir))
635
- mkdirSync(signalDir, { recursive: true });
636
- // Process any existing signal files (from before daemon started)
637
- try {
638
- for (const file of readdirSync(signalDir)) {
639
- if (!file.endsWith('.json'))
640
- continue;
641
- const filePath = join(signalDir, file);
642
- try {
643
- const signal = JSON.parse(readFileSync(filePath, 'utf-8'));
644
- if (!isSignalForMe(signal))
645
- continue; // not for this daemon, leave for target
646
- unlinkSync(filePath);
647
- processBotMentionSignal(signal);
648
- }
649
- catch (err) {
650
- logger.debug(`[bot-mention] Failed to process signal ${file}: ${err}`);
651
- }
652
- }
856
+ // Wrap the user message in the same `<user_message>` / `<session_id>` /
857
+ // `<botmux_reminder>` envelope as live-worker turns. Without this, the
858
+ // initial prompt that worker queues for the freshly-spawned CLI is the
859
+ // raw user text — the CLI sees no botmux routing context and stops calling
860
+ // `botmux send`, posting answers to its own terminal instead. Hits resume
861
+ // (after /close) and daemon-restart paths; both go through this branch
862
+ // because worker=null at that point.
863
+ const dsBotCfgForFork = getBot(ds.larkAppId).config;
864
+ const selfBot = getBot(ds.larkAppId);
865
+ const wrappedPrompt = buildReforkPrompt(ds, promptContent, {
866
+ attachments,
867
+ mentions: parsed.mentions,
868
+ cliId: dsBotCfgForFork.cliId,
869
+ cliPathOverride: dsBotCfgForFork.cliPathOverride,
870
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
871
+ });
872
+ forkWorker(ds, wrappedPrompt, ds.hasHistory);
653
873
  }
654
- catch { /* ignore */ }
655
- // Watch for new signal files
656
- watch(signalDir, (event, filename) => {
657
- if (event !== 'rename' || !filename?.endsWith('.json'))
658
- return;
659
- const filePath = join(signalDir, filename);
660
- // Small delay to ensure the file is fully written
661
- setTimeout(() => {
662
- try {
663
- if (!existsSync(filePath))
664
- return; // already processed or deleted
665
- const signal = JSON.parse(readFileSync(filePath, 'utf-8'));
666
- if (!isSignalForMe(signal))
667
- return; // not for this daemon, leave for target
668
- unlinkSync(filePath);
669
- processBotMentionSignal(signal);
670
- }
671
- catch (err) {
672
- logger.debug(`[bot-mention] Failed to process signal ${filename}: ${err}`);
673
- }
674
- }, 50);
675
- });
676
- logger.info(`[bot-mention] Watching for signals in ${signalDir}`);
677
874
  }
678
875
  // ─── Main ────────────────────────────────────────────────────────────────────
679
876
  export async function startDaemon(botIndex) {
@@ -689,19 +886,70 @@ export async function startDaemon(botIndex) {
689
886
  const cfg = botConfigs[idx];
690
887
  registerBot(cfg);
691
888
  sessionStore.init(cfg.larkAppId);
889
+ chatFirstSeenStore.init(cfg.larkAppId);
890
+ // Watch schedules.json for external writes (e.g. `botmux schedule add`
891
+ // running in a separate node process) so dashboard event bus stays in sync.
892
+ scheduleStore.startExternalWriteWatcher();
692
893
  logger.info(`Bot ${idx}/${botConfigs.length}: ${cfg.larkAppId} (cli: ${cfg.cliId})`);
693
894
  writePidFile();
895
+ // Publish self-descriptor for the dashboard registry. The dashboard sibling
896
+ // process discovers running daemons by scanning ~/.botmux/data/dashboard-daemons/
897
+ // and watching for mtime updates (heartbeat) / file removal (shutdown).
898
+ const ipcPort = config.dashboard.ipcBasePort + idx;
899
+ const desc = {
900
+ larkAppId: cfg.larkAppId,
901
+ botName: cfg.larkAppId,
902
+ botIndex: idx,
903
+ ipcPort,
904
+ pid: process.pid,
905
+ startedAt: Date.now(),
906
+ lastHeartbeat: Date.now(),
907
+ // Strip email-form entries — the dashboard only needs resolved open_ids,
908
+ // and the email→open_id resolution below will rewrite this field.
909
+ resolvedAllowedUsers: getBot(cfg.larkAppId).resolvedAllowedUsers.filter(u => !u.includes('@')),
910
+ };
694
911
  // Initialise worker pool with daemon callbacks
695
912
  initWorkerPool({
696
913
  sessionReply,
697
914
  getSessionWorkingDir,
698
915
  getActiveCount,
699
916
  closeSession(ds) {
700
- sessionStore.closeSession(ds.session.sessionId);
701
- activeSessions.delete(sessionKey(ds.session.rootMessageId, ds.larkAppId));
917
+ // Route through the dashboard-aware helper so session.exited / session.update
918
+ // events fire for withdrawn-message / crash / adopt-exit teardown paths too,
919
+ // matching the dashboard-driven close.
920
+ void closeSessionHelper(ds.session.sessionId).catch(() => { });
702
921
  logger.info(`[${ds.session.sessionId.substring(0, 8)}] Session auto-closed (message withdrawn)`);
703
922
  },
704
923
  });
924
+ // Expose the activeSessions Map (owned by daemon) to worker-pool readers,
925
+ // so dashboard IPC and other consumers can list/lookup live sessions.
926
+ setActiveSessionsRegistry(activeSessions);
927
+ // Seed dashboard IPC botName with the bot's config id; the friendly name from
928
+ // /bot/v3/info is wired into the registry descriptor (below) but the IPC server
929
+ // also needs its own copy for SessionRow.botName.
930
+ setBotName(cfg.larkAppId);
931
+ setLarkAppId(cfg.larkAppId);
932
+ // Bind dashboard IPC HTTP server BEFORE publishing the registry descriptor.
933
+ // Otherwise the dashboard process can race-fetch the IPC port from the
934
+ // descriptor and hit ECONNREFUSED before we're listening — that left every
935
+ // newly-started daemon's hydrate failing on dashboard startup. Binds to
936
+ // 127.0.0.1 only since the dashboard sibling runs on the same host.
937
+ const ipcHandle = await startIpcServer({ port: ipcPort, host: '127.0.0.1' });
938
+ logger.info(`[dashboard-ipc] listening on 127.0.0.1:${ipcHandle.port} (bot ${idx})`);
939
+ // Now that the IPC port is actually listening, publish the descriptor so
940
+ // the dashboard can discover us and successfully fetch /api/sessions etc.
941
+ desc.lastHeartbeat = Date.now();
942
+ writeDaemonDescriptor(desc);
943
+ const descriptorHeartbeat = setInterval(() => {
944
+ desc.lastHeartbeat = Date.now();
945
+ try {
946
+ writeDaemonDescriptor(desc);
947
+ }
948
+ catch { /* best effort */ }
949
+ }, 30_000);
950
+ // Don't keep the event loop alive on this interval alone.
951
+ if (typeof descriptorHeartbeat.unref === 'function')
952
+ descriptorHeartbeat.unref();
705
953
  // Per-bot initialization
706
954
  for (const bot of getAllBots()) {
707
955
  const cfg = bot.config;
@@ -719,20 +967,58 @@ export async function startDaemon(botIndex) {
719
967
  logger.warn(`[${cfg.larkAppId}] Failed to resolve allowedUsers: ${err.message}`);
720
968
  }
721
969
  }
970
+ // Republish the descriptor with the post-resolution open_ids so the
971
+ // dashboard's create-group flow can pick this bot as creator using the
972
+ // operator's scope-correct open_id. Best-effort; the periodic heartbeat
973
+ // will eventually catch up too.
974
+ desc.resolvedAllowedUsers = bot.resolvedAllowedUsers.filter(u => !u.includes('@'));
975
+ try {
976
+ writeDaemonDescriptor(desc);
977
+ }
978
+ catch { /* best effort */ }
722
979
  }
723
- // Probe bot open_id and persist to bots-info.json
980
+ // Probe bot open_id and persist to bots-info.json. When the friendly
981
+ // botName comes back from /bot/v3/info, refresh the dashboard descriptor
982
+ // so the registry shows "Claude" / "Codex" instead of the raw app id.
724
983
  probeBotOpenId(cfg.larkAppId).then(() => {
725
984
  writeBotInfoFile(config.session.dataDir);
985
+ const probedName = bot.botName;
986
+ if (probedName && probedName !== desc.botName) {
987
+ desc.botName = probedName;
988
+ try {
989
+ writeDaemonDescriptor(desc);
990
+ }
991
+ catch { /* best effort */ }
992
+ }
726
993
  }).catch(err => {
727
- logger.warn(`[${cfg.larkAppId}] Bot open_id probe failed: ${err.message}`);
994
+ // Probe runs in background and is retried by the periodic heartbeat;
995
+ // a single failure here is not actionable. Surface as debug only.
996
+ logger.debug(`[${cfg.larkAppId}] Bot open_id probe failed (will retry): ${err.message}`);
997
+ });
998
+ // Required-scope check: 启动后 best-effort 校验
999
+ // im:message.group_at_msg.include_bot:readonly。缺失会 logger.error +
1000
+ // 私信 allowedUsers[0]。校验异步,跑失败不影响 daemon。
1001
+ checkRequiredScopes(cfg.larkAppId).catch(err => {
1002
+ logger.debug(`[${cfg.larkAppId}] required-scope check failed: ${err?.message ?? err}`);
728
1003
  });
729
1004
  // Start event dispatcher for this bot
730
1005
  startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, {
731
1006
  handleCardAction: (data, appId) => handleCardAction(data, cardDeps, appId),
732
- handleNewTopic: (data, chatId, messageId, chatType, appId) => handleNewTopic(data, chatId, messageId, chatType, appId),
733
- handleThreadReply: (data, rootId, appId) => handleThreadReply(data, rootId, appId),
734
- isSessionOwner: (rootId, appId) => {
735
- return activeSessions.has(sessionKey(rootId, appId));
1007
+ handleNewTopic: (data, ctx) => handleNewTopic(data, ctx),
1008
+ handleThreadReply: (data, ctx) => handleThreadReply(data, ctx),
1009
+ isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
1010
+ // Chat was converted 普通群 → 话题群 while we held a chat-scope session.
1011
+ // Evict it from the routing map so subsequent inbound messages can land
1012
+ // on a fresh thread-scope session (dispatcher already rerouted this turn
1013
+ // to handleNewTopic). The worker is left running on purpose: the user may
1014
+ // still have its web terminal open, and `/close` is the canonical cleanup
1015
+ // path. Scheduler tasks tied to this session keep their `scope='chat'`
1016
+ // semantics — that's an edge case worth following up on, not blocking
1017
+ // the main fix.
1018
+ onChatModeConverted: (chatId, appId) => {
1019
+ const key = sessionKey(chatId, appId);
1020
+ const evicted = activeSessions.delete(key);
1021
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=${evicted}; worker (if any) keeps running until /close`);
736
1022
  },
737
1023
  });
738
1024
  }
@@ -745,17 +1031,38 @@ export async function startDaemon(botIndex) {
745
1031
  scheduler.setExecuteCallback((task) => executeScheduledTask(task, activeSessions, refreshCliVersion));
746
1032
  scheduler.setOwnerFilter(cfg.larkAppId, idx === 0);
747
1033
  scheduler.startScheduler();
748
- // Watch for bot-to-bot mention signals from MCP send_to_thread tool.
749
- // Lark WSClient does not deliver events for bot-sent messages, so the MCP
750
- // tool writes signal files that the daemon picks up and routes internally.
751
- startBotMentionWatcher();
752
- // Graceful shutdown
753
- const shutdown = () => {
1034
+ // Graceful shutdown. Sends SIGTERM (or `{type:'close'}` IPC via killWorker)
1035
+ // to every worker, then waits up to SHUTDOWN_GRACE_MS for them to exit
1036
+ // before sending SIGKILL to stragglers. Without the wait, daemon
1037
+ // `process.exit(0)` races worker signal delivery — and any worker whose
1038
+ // main thread is in a sync code path (e.g. the bridge fingerprint scan
1039
+ // bug fixed in v2.9.2) loses the signal and survives as a ppid=1 orphan
1040
+ // forever (we'd accumulated 841 such orphans across daemon restarts,
1041
+ // consuming ~65 GB of RAM until manually SIGKILL'd).
1042
+ const SHUTDOWN_GRACE_MS = 3000;
1043
+ let shuttingDown = false;
1044
+ const shutdown = async () => {
1045
+ if (shuttingDown)
1046
+ return;
1047
+ shuttingDown = true;
754
1048
  logger.info(`Daemon shutting down... (active: ${getActiveCount()})`);
755
1049
  scheduler.stopScheduler();
1050
+ clearInterval(descriptorHeartbeat);
1051
+ removeDaemonDescriptor(cfg.larkAppId);
1052
+ ipcHandle.close().catch(() => { });
1053
+ const pendingExits = [];
1054
+ const survivors = [];
756
1055
  for (const [, ds] of activeSessions) {
757
1056
  if (ds.worker && !ds.worker.killed) {
758
1057
  logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
1058
+ const w = ds.worker;
1059
+ // Capture the exit promise BEFORE killWorker nulls ds.worker.
1060
+ if (w.exitCode === null && w.signalCode === null) {
1061
+ pendingExits.push(new Promise(resolve => {
1062
+ w.once('exit', () => resolve());
1063
+ }));
1064
+ survivors.push(w);
1065
+ }
759
1066
  const backendType = ds.larkAppId
760
1067
  ? (getBot(ds.larkAppId).config.backendType ?? config.daemon.backendType)
761
1068
  : config.daemon.backendType;
@@ -763,7 +1070,7 @@ export async function startDaemon(botIndex) {
763
1070
  // Tmux mode: just kill the worker process — tmux session survives for re-attach.
764
1071
  // Worker's SIGTERM handler calls backend.kill() which only detaches.
765
1072
  try {
766
- ds.worker.kill('SIGTERM');
1073
+ w.kill('SIGTERM');
767
1074
  }
768
1075
  catch { /* ignore */ }
769
1076
  ds.worker = null;
@@ -775,11 +1082,35 @@ export async function startDaemon(botIndex) {
775
1082
  }
776
1083
  }
777
1084
  }
1085
+ if (pendingExits.length > 0) {
1086
+ const timeout = new Promise(resolve => setTimeout(resolve, SHUTDOWN_GRACE_MS));
1087
+ await Promise.race([Promise.all(pendingExits), timeout]);
1088
+ let stragglers = 0;
1089
+ for (const w of survivors) {
1090
+ if (w.exitCode === null && w.signalCode === null) {
1091
+ stragglers++;
1092
+ try {
1093
+ w.kill('SIGKILL');
1094
+ }
1095
+ catch { /* already dead */ }
1096
+ }
1097
+ }
1098
+ if (stragglers > 0) {
1099
+ logger.warn(`${stragglers}/${survivors.length} worker(s) didn't exit within ${SHUTDOWN_GRACE_MS}ms — SIGKILL'd to prevent ppid=1 orphans.`);
1100
+ }
1101
+ }
778
1102
  removePidFile();
779
1103
  process.exit(0);
780
1104
  };
781
- process.on('SIGTERM', shutdown);
782
- process.on('SIGINT', shutdown);
1105
+ process.on('SIGTERM', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
1106
+ process.on('SIGINT', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
1107
+ // Best-effort cleanup on plain `exit` (e.g. uncaught fatal). No worker
1108
+ // shutdown here since the process is already on its way out — just remove
1109
+ // the descriptor so the dashboard doesn't see a phantom daemon.
1110
+ process.on('exit', () => {
1111
+ clearInterval(descriptorHeartbeat);
1112
+ removeDaemonDescriptor(cfg.larkAppId);
1113
+ });
783
1114
  logger.info('Daemon is running. Press Ctrl+C to stop.');
784
1115
  }
785
1116
  //# sourceMappingURL=daemon.js.map