botmux 2.64.0 → 2.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +3 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts +8 -1
  4. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/herdr-backend.js +15 -2
  6. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  7. package/dist/adapters/backend/tmux-backend.d.ts +17 -1
  8. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  9. package/dist/adapters/backend/tmux-backend.js +25 -4
  10. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  11. package/dist/adapters/backend/types.d.ts +14 -0
  12. package/dist/adapters/backend/types.d.ts.map +1 -1
  13. package/dist/adapters/backend/types.js.map +1 -1
  14. package/dist/adapters/backend/zellij-backend.d.ts +12 -1
  15. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  16. package/dist/adapters/backend/zellij-backend.js +25 -8
  17. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  18. package/dist/bot-registry.d.ts +39 -0
  19. package/dist/bot-registry.d.ts.map +1 -1
  20. package/dist/bot-registry.js +30 -0
  21. package/dist/bot-registry.js.map +1 -1
  22. package/dist/cli/send-dispatch.d.ts +23 -0
  23. package/dist/cli/send-dispatch.d.ts.map +1 -0
  24. package/dist/cli/send-dispatch.js +23 -0
  25. package/dist/cli/send-dispatch.js.map +1 -0
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +141 -58
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config.d.ts +8 -6
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +8 -6
  32. package/dist/config.js.map +1 -1
  33. package/dist/core/ask-broker.d.ts +33 -0
  34. package/dist/core/ask-broker.d.ts.map +1 -1
  35. package/dist/core/ask-broker.js +58 -0
  36. package/dist/core/ask-broker.js.map +1 -1
  37. package/dist/core/ask-hook/claude-code.d.ts.map +1 -1
  38. package/dist/core/ask-hook/claude-code.js +15 -9
  39. package/dist/core/ask-hook/claude-code.js.map +1 -1
  40. package/dist/core/ask-hook/codex.d.ts.map +1 -1
  41. package/dist/core/ask-hook/codex.js +2 -1
  42. package/dist/core/ask-hook/codex.js.map +1 -1
  43. package/dist/core/ask-hook/opencode.d.ts.map +1 -1
  44. package/dist/core/ask-hook/opencode.js +9 -6
  45. package/dist/core/ask-hook/opencode.js.map +1 -1
  46. package/dist/core/ask-hook/types.d.ts +3 -1
  47. package/dist/core/ask-hook/types.d.ts.map +1 -1
  48. package/dist/core/ask-types.d.ts +13 -6
  49. package/dist/core/ask-types.d.ts.map +1 -1
  50. package/dist/core/ask-types.js.map +1 -1
  51. package/dist/core/command-handler.d.ts.map +1 -1
  52. package/dist/core/command-handler.js +255 -4
  53. package/dist/core/command-handler.js.map +1 -1
  54. package/dist/core/daemon-heartbeat.d.ts +15 -0
  55. package/dist/core/daemon-heartbeat.d.ts.map +1 -0
  56. package/dist/core/daemon-heartbeat.js +83 -0
  57. package/dist/core/daemon-heartbeat.js.map +1 -0
  58. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  59. package/dist/core/dashboard-ipc-server.js +80 -33
  60. package/dist/core/dashboard-ipc-server.js.map +1 -1
  61. package/dist/core/dispatch.d.ts +1 -23
  62. package/dist/core/dispatch.d.ts.map +1 -1
  63. package/dist/core/dispatch.js +1 -17
  64. package/dist/core/dispatch.js.map +1 -1
  65. package/dist/core/idle-worker-sweeper.d.ts +13 -0
  66. package/dist/core/idle-worker-sweeper.d.ts.map +1 -0
  67. package/dist/core/idle-worker-sweeper.js +42 -0
  68. package/dist/core/idle-worker-sweeper.js.map +1 -0
  69. package/dist/core/maintenance-schedule.d.ts +34 -0
  70. package/dist/core/maintenance-schedule.d.ts.map +1 -0
  71. package/dist/core/maintenance-schedule.js +72 -0
  72. package/dist/core/maintenance-schedule.js.map +1 -0
  73. package/dist/core/maintenance.d.ts +43 -0
  74. package/dist/core/maintenance.d.ts.map +1 -0
  75. package/dist/core/maintenance.js +160 -0
  76. package/dist/core/maintenance.js.map +1 -0
  77. package/dist/core/reply-target.d.ts +23 -0
  78. package/dist/core/reply-target.d.ts.map +1 -0
  79. package/dist/core/reply-target.js +47 -0
  80. package/dist/core/reply-target.js.map +1 -0
  81. package/dist/core/restart-report.d.ts +49 -0
  82. package/dist/core/restart-report.d.ts.map +1 -0
  83. package/dist/core/restart-report.js +98 -0
  84. package/dist/core/restart-report.js.map +1 -0
  85. package/dist/core/scheduler.d.ts.map +1 -1
  86. package/dist/core/scheduler.js +20 -0
  87. package/dist/core/scheduler.js.map +1 -1
  88. package/dist/core/session-manager.d.ts +26 -10
  89. package/dist/core/session-manager.d.ts.map +1 -1
  90. package/dist/core/session-manager.js +104 -26
  91. package/dist/core/session-manager.js.map +1 -1
  92. package/dist/core/session-marker.d.ts +13 -0
  93. package/dist/core/session-marker.d.ts.map +1 -0
  94. package/dist/core/session-marker.js +55 -0
  95. package/dist/core/session-marker.js.map +1 -0
  96. package/dist/core/types.d.ts +20 -1
  97. package/dist/core/types.d.ts.map +1 -1
  98. package/dist/core/types.js.map +1 -1
  99. package/dist/core/worker-budget.d.ts +19 -0
  100. package/dist/core/worker-budget.d.ts.map +1 -0
  101. package/dist/core/worker-budget.js +50 -0
  102. package/dist/core/worker-budget.js.map +1 -0
  103. package/dist/core/worker-pool.d.ts +5 -2
  104. package/dist/core/worker-pool.d.ts.map +1 -1
  105. package/dist/core/worker-pool.js +105 -12
  106. package/dist/core/worker-pool.js.map +1 -1
  107. package/dist/daemon.d.ts.map +1 -1
  108. package/dist/daemon.js +243 -38
  109. package/dist/daemon.js.map +1 -1
  110. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  111. package/dist/dashboard/web/bot-defaults.js +118 -0
  112. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  113. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  114. package/dist/dashboard/web/i18n.js +44 -0
  115. package/dist/dashboard/web/i18n.js.map +1 -1
  116. package/dist/dashboard/web/settings.d.ts.map +1 -1
  117. package/dist/dashboard/web/settings.js +84 -13
  118. package/dist/dashboard/web/settings.js.map +1 -1
  119. package/dist/dashboard-web/app.js +568 -503
  120. package/dist/dashboard.js +87 -7
  121. package/dist/dashboard.js.map +1 -1
  122. package/dist/global-config.d.ts +46 -0
  123. package/dist/global-config.d.ts.map +1 -1
  124. package/dist/global-config.js +115 -0
  125. package/dist/global-config.js.map +1 -1
  126. package/dist/i18n/en.d.ts.map +1 -1
  127. package/dist/i18n/en.js +72 -1
  128. package/dist/i18n/en.js.map +1 -1
  129. package/dist/i18n/zh.d.ts.map +1 -1
  130. package/dist/i18n/zh.js +72 -1
  131. package/dist/i18n/zh.js.map +1 -1
  132. package/dist/im/lark/ask-card.d.ts.map +1 -1
  133. package/dist/im/lark/ask-card.js +15 -1
  134. package/dist/im/lark/ask-card.js.map +1 -1
  135. package/dist/im/lark/card-builder.d.ts +17 -0
  136. package/dist/im/lark/card-builder.d.ts.map +1 -1
  137. package/dist/im/lark/card-builder.js +146 -0
  138. package/dist/im/lark/card-builder.js.map +1 -1
  139. package/dist/im/lark/card-handler.d.ts.map +1 -1
  140. package/dist/im/lark/card-handler.js +119 -4
  141. package/dist/im/lark/card-handler.js.map +1 -1
  142. package/dist/im/lark/client.d.ts +3 -2
  143. package/dist/im/lark/client.d.ts.map +1 -1
  144. package/dist/im/lark/client.js +28 -2
  145. package/dist/im/lark/client.js.map +1 -1
  146. package/dist/im/lark/event-dispatcher.d.ts +7 -17
  147. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  148. package/dist/im/lark/event-dispatcher.js +170 -50
  149. package/dist/im/lark/event-dispatcher.js.map +1 -1
  150. package/dist/im/lark/message-parser.d.ts +1 -0
  151. package/dist/im/lark/message-parser.d.ts.map +1 -1
  152. package/dist/im/lark/message-parser.js +1 -0
  153. package/dist/im/lark/message-parser.js.map +1 -1
  154. package/dist/im/lark/reply-mode-command.d.ts +2 -0
  155. package/dist/im/lark/reply-mode-command.d.ts.map +1 -0
  156. package/dist/im/lark/reply-mode-command.js +102 -0
  157. package/dist/im/lark/reply-mode-command.js.map +1 -0
  158. package/dist/services/bot-config-store.d.ts +144 -0
  159. package/dist/services/bot-config-store.d.ts.map +1 -0
  160. package/dist/services/bot-config-store.js +241 -0
  161. package/dist/services/bot-config-store.js.map +1 -0
  162. package/dist/services/card-prefs-store.d.ts +5 -0
  163. package/dist/services/card-prefs-store.d.ts.map +1 -1
  164. package/dist/services/card-prefs-store.js +47 -0
  165. package/dist/services/card-prefs-store.js.map +1 -1
  166. package/dist/services/chat-reply-mode-store.d.ts +28 -0
  167. package/dist/services/chat-reply-mode-store.d.ts.map +1 -0
  168. package/dist/services/chat-reply-mode-store.js +115 -0
  169. package/dist/services/chat-reply-mode-store.js.map +1 -0
  170. package/dist/services/hook-runner.d.ts +43 -0
  171. package/dist/services/hook-runner.d.ts.map +1 -0
  172. package/dist/services/hook-runner.js +394 -0
  173. package/dist/services/hook-runner.js.map +1 -0
  174. package/dist/services/restart-intent-store.d.ts +26 -0
  175. package/dist/services/restart-intent-store.d.ts.map +1 -0
  176. package/dist/services/restart-intent-store.js +84 -0
  177. package/dist/services/restart-intent-store.js.map +1 -0
  178. package/dist/services/session-lifecycle-hooks.d.ts +10 -0
  179. package/dist/services/session-lifecycle-hooks.d.ts.map +1 -0
  180. package/dist/services/session-lifecycle-hooks.js +66 -0
  181. package/dist/services/session-lifecycle-hooks.js.map +1 -0
  182. package/dist/services/session-store.d.ts +6 -0
  183. package/dist/services/session-store.d.ts.map +1 -1
  184. package/dist/services/session-store.js +25 -0
  185. package/dist/services/session-store.js.map +1 -1
  186. package/dist/skills/definitions.d.ts.map +1 -1
  187. package/dist/skills/definitions.js +28 -3
  188. package/dist/skills/definitions.js.map +1 -1
  189. package/dist/types.d.ts +33 -0
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/utils/install-info.d.ts +13 -0
  192. package/dist/utils/install-info.d.ts.map +1 -0
  193. package/dist/utils/install-info.js +56 -0
  194. package/dist/utils/install-info.js.map +1 -0
  195. package/dist/utils/listen-with-probe.d.ts +26 -0
  196. package/dist/utils/listen-with-probe.d.ts.map +1 -0
  197. package/dist/utils/listen-with-probe.js +64 -0
  198. package/dist/utils/listen-with-probe.js.map +1 -0
  199. package/dist/utils/web-terminal-listen.d.ts +30 -0
  200. package/dist/utils/web-terminal-listen.d.ts.map +1 -0
  201. package/dist/utils/web-terminal-listen.js +81 -0
  202. package/dist/utils/web-terminal-listen.js.map +1 -0
  203. package/dist/worker.js +71 -44
  204. package/dist/worker.js.map +1 -1
  205. package/dist/workflows/definition.d.ts +30 -30
  206. package/dist/workflows/events/payloads.d.ts +4 -4
  207. package/dist/workflows/events/schema.d.ts +156 -156
  208. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -5,7 +5,10 @@ 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
- import { config } from './config.js';
8
+ import { config, getDashboardExternalHost } from './config.js';
9
+ import { writeHeartbeat } from './core/daemon-heartbeat.js';
10
+ import { startMaintenance, stopMaintenance } from './core/maintenance.js';
11
+ import { sendRestartReportIfPending } from './core/restart-report.js';
9
12
  import { statSync } from 'node:fs';
10
13
  import { getChatMode, listChatMemberOpenIds, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage } from './im/lark/client.js';
11
14
  import { chatHasAllowedUser, resolveGroupJoinPrompt } from './core/auto-start.js';
@@ -15,6 +18,8 @@ import * as chatFirstSeenStore from './services/chat-first-seen-store.js';
15
18
  import { ensureDefaultOncallBound } from './services/oncall-store.js';
16
19
  import * as scheduleStore from './services/schedule-store.js';
17
20
  import * as messageQueue from './services/message-queue.js';
21
+ import { emitHookEvent, HOOK_EVENTS } from './services/hook-runner.js';
22
+ import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js';
18
23
  import { parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
19
24
  import { expandMergeForward } from './im/lark/merge-forward.js';
20
25
  import { buildQuoteHint } from './im/lark/quote-hint.js';
@@ -27,9 +32,8 @@ import { buildTerminalUrl, setTerminalProxyPort } from './core/terminal-url.js';
27
32
  import { startTerminalProxy } from './core/terminal-proxy.js';
28
33
  import * as scheduler from './core/scheduler.js';
29
34
  import { scanMultipleProjects } from './services/project-scanner.js';
30
- import { buildPendingResponseCard, buildQuotaExhaustedCard, buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
31
- import { createPendingResponseQueue, markPendingResponseCardPatched, shouldReplyPendingInThread, shouldTreatPendingCardAsPatchedByMarker, startPendingResponseTurn, syncPendingResponseState } from './core/pending-response.js';
32
- import { readPendingResponsePatchMarker } from './services/pending-response-transaction-store.js';
35
+ import { buildQuotaExhaustedCard, buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
36
+ import { createPendingResponseQueue, markPendingResponseCardPatched, syncPendingResponseState } from './core/pending-response.js';
33
37
  import { t as tr, botLocale, localeForBot } from './i18n/index.js';
34
38
  import { createCliAdapterSync } from './adapters/cli/registry.js';
35
39
  import { initWorkerPool, setActiveSessionsRegistry, forkWorker, killWorker, scheduleCardPatch, setCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, ensureCliEnv, writableTerminalLinkFor, } from './core/worker-pool.js';
@@ -41,6 +45,8 @@ import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
41
45
  import { consumeQuota, removeChatGrant, removeGlobalGrant } from './services/grant-store.js';
42
46
  import { abortCharge, commitCharge, beginCharge } from './services/quota-dedup.js';
43
47
  import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, buildBridgeInputContent, buildReforkPrompt, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, ensureTerminalWorkerPort, } from './core/session-manager.js';
48
+ import { beginReplyTargetTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
49
+ import { sweepIdleWorkers } from './core/idle-worker-sweeper.js';
44
50
  import { handleCardAction } from './im/lark/card-handler.js';
45
51
  import { executeWorkflowCommand, parseWorkflowCommand, resolveBotSnapshot, } from './im/lark/workflow-slash-command.js';
46
52
  import { workflowRunDetailUrl } from './im/lark/workflow-cards.js';
@@ -68,12 +74,32 @@ import { resolveWait } from './workflows/wait.js';
68
74
  import { replay } from './workflows/events/replay.js';
69
75
  import { isValidRunId, readRunSnapshot } from './workflows/ops-projection.js';
70
76
  import { AttemptResumeManager } from './workflows/attempt-resume.js';
71
- import { setCardDispatcher as setAskCardDispatcher, registerAsk as registerAskBroker, } from './core/ask-broker.js';
77
+ import { setCardDispatcher as setAskCardDispatcher, registerAsk as registerAskBroker, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js';
72
78
  import { parseAskBody, resolveAskApprovers } from './core/ask-api.js';
73
79
  import { createLarkAskCardDispatcher } from './im/lark/ask-card.js';
74
80
  // ─── State ───────────────────────────────────────────────────────────────────
75
81
  const activeSessions = new Map();
76
82
  const workflowEventWatchers = new Map();
83
+ function sessionHasReplyThreadAlias(s, rootId) {
84
+ return s.scope === 'chat' && !!s.replyThreadAliases?.[rootId];
85
+ }
86
+ function findChatReplyAlias(rootId, chatId, larkAppId) {
87
+ // A real thread-scope session at this root wins over any historical alias.
88
+ if (activeSessions.get(sessionKey(rootId, larkAppId))?.scope === 'thread')
89
+ return null;
90
+ for (const ds of activeSessions.values()) {
91
+ if (ds.larkAppId !== larkAppId || ds.scope !== 'chat' || ds.chatId !== chatId)
92
+ continue;
93
+ if (sessionHasReplyThreadAlias(ds.session, rootId))
94
+ return { chatId: ds.chatId, sessionId: ds.session.sessionId };
95
+ }
96
+ const diskSessions = sessionStore.listSessions();
97
+ if (diskSessions.some(s => s.status === 'active' && s.larkAppId === larkAppId && s.scope !== 'chat' && s.rootMessageId === rootId)) {
98
+ return null;
99
+ }
100
+ const hit = diskSessions.find(s => s.status === 'active' && s.larkAppId === larkAppId && s.chatId === chatId && sessionHasReplyThreadAlias(s, rootId));
101
+ return hit ? { chatId: hit.chatId, sessionId: hit.sessionId } : null;
102
+ }
77
103
  const workflowRuns = new Map();
78
104
  // v0.1.5 slice 1: run-level progress card index. daemon-internal only
79
105
  // (codex contract boundary 2: daemon restart drops the cardMessageId
@@ -213,29 +239,24 @@ function readSessionFreshFromDisk(sessionId, larkAppId) {
213
239
  }
214
240
  return undefined;
215
241
  }
216
- async function postPendingResponseCard(ds, replyToMessageId, prompt, sender) {
217
- if (!streamingCardDisabledFor(ds))
218
- return;
242
+ async function postPendingResponseCard(ds, replyToMessageId, prompt, sender, turnId) {
243
+ // Card-off means no visible botmux cards at all. If a prior build left an
244
+ // open pending-response placeholder on this session, clear its state so a
245
+ // later `botmux send --mention...` cannot patch it to “final reply sent via
246
+ // new message”. Do not call any Lark send/update API here.
219
247
  await pendingResponseQueue.run(ds.session.sessionId, async () => {
220
- syncPendingResponseState(ds, readSessionFreshFromDisk(ds.session.sessionId, ds.larkAppId));
221
- const marker = readPendingResponsePatchMarker(ds.session.sessionId);
222
- if (shouldTreatPendingCardAsPatchedByMarker(ds.pendingResponseCardId, marker)) {
248
+ const fresh = readSessionFreshFromDisk(ds.session.sessionId, ds.larkAppId);
249
+ syncPendingResponseState(ds, fresh);
250
+ if (fresh)
251
+ syncReplyTargetState(ds, fresh);
252
+ if (ds.pendingResponseCardId || ds.session.pendingResponseCardId) {
223
253
  markPendingResponseCardPatched(ds);
224
- }
225
- syncPendingResponseState(ds.session, ds);
226
- const card = buildPendingResponseCard(localeForBot(ds.larkAppId));
227
- try {
228
- const messageId = await replyMessage(ds.larkAppId, replyToMessageId, card, 'interactive', shouldReplyPendingInThread(ds.scope));
229
- startPendingResponseTurn(ds, messageId);
230
- startPendingResponseTurn(ds.session, messageId);
254
+ markPendingResponseCardPatched(ds.session);
231
255
  sessionStore.updateSession(ds.session);
232
256
  }
233
- catch (err) {
234
- logger.warn(`[${tag(ds)}] failed to post pending response card: ${err instanceof Error ? err.message : String(err)}`);
235
- }
236
257
  });
237
258
  }
238
- async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
259
+ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId) {
239
260
  let ds;
240
261
  if (larkAppId) {
241
262
  ds = activeSessions.get(sessionKey(anchor, larkAppId));
@@ -251,6 +272,11 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
251
272
  const appId = larkAppId ?? ds?.larkAppId ?? getAllBots()[0]?.config.larkAppId;
252
273
  if (!appId)
253
274
  throw new Error('No bot configured');
275
+ const hookContext = ds ? {
276
+ sessionId: ds.session.sessionId,
277
+ scope: ds.scope,
278
+ anchor: sessionAnchorId(ds),
279
+ } : undefined;
254
280
  // Chat-scope: post a plain message to the chat. No reply_in_thread → keeps
255
281
  // the conversation flat in 普通群. The card layer carries chatId in its button
256
282
  // values, so handleCardAction routes back via sessionKey(chatId).
@@ -267,17 +293,25 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
267
293
  // to know we should sendMessage, not reply_in_thread to a non-message-id.
268
294
  if (ds?.scope === 'chat' || anchor.startsWith('oc_')) {
269
295
  const chatId = ds?.chatId ?? anchor;
270
- if (ds?.scope === 'chat' && ds.session.rootMessageId) {
271
- const mode = await getChatMode(appId, chatId, { forceRefresh: true });
272
- if (mode === 'topic') {
273
- 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)}`);
274
- return replyMessage(appId, ds.session.rootMessageId, content, msgType, true);
296
+ if (ds?.scope === 'chat') {
297
+ const fresh = readSessionFreshFromDisk(ds.session.sessionId, ds.larkAppId);
298
+ if (fresh)
299
+ syncReplyTargetState(ds, fresh);
300
+ const target = resolveSessionReplyTarget(ds, turnId);
301
+ if (target.mode === 'thread')
302
+ return replyMessage(appId, target.rootMessageId, content, msgType, true, undefined, hookContext);
303
+ if (ds.session.rootMessageId) {
304
+ const mode = await getChatMode(appId, chatId, { forceRefresh: true });
305
+ if (mode === 'topic') {
306
+ 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)}`);
307
+ return replyMessage(appId, ds.session.rootMessageId, content, msgType, true, undefined, hookContext);
308
+ }
275
309
  }
276
310
  }
277
- return sendMessage(appId, chatId, content, msgType);
311
+ return sendMessage(appId, chatId, content, msgType, undefined, hookContext);
278
312
  }
279
313
  // Thread-scope (or unknown / legacy): reply in thread.
280
- return replyMessage(appId, anchor, content, msgType, true);
314
+ return replyMessage(appId, anchor, content, msgType, true, undefined, hookContext);
281
315
  }
282
316
  async function revokeQuotaGrant(larkAppId, chatId, senderOpenId, ev) {
283
317
  const result = ev.reason === 'chatGrant'
@@ -1374,6 +1408,33 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
1374
1408
  });
1375
1409
  return jsonRes(res, 200, result);
1376
1410
  });
1411
+ // ─── hooks emit 转发端点 ────────────────────────────────────────────────────
1412
+ // CLI side(botmux send 等)调用 emitHookEvent 时,把事件转发到 daemon 这条
1413
+ // 接口;daemon 在自己的长寿命事件循环里负责 spawn hook、跑 timeout、超时杀
1414
+ // 整个进程组。短命 CLI 进程的 timer.unref 会让超时承诺失效、跑飞的 hook 留
1415
+ // 孤儿,让 daemon 接管根治这一缺口。daemon 进程自身不带 BOTMUX_SESSION_ID
1416
+ // 环境变量,所以这里调 emitHookEvent 不会再触发转发回退(无递归)。
1417
+ ipcRoute('POST', '/api/hooks/emit', async (req, res) => {
1418
+ let raw;
1419
+ try {
1420
+ raw = await readJsonBody(req);
1421
+ }
1422
+ catch {
1423
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1424
+ }
1425
+ if (!raw || typeof raw !== 'object') {
1426
+ return jsonRes(res, 400, { ok: false, error: 'bad_body' });
1427
+ }
1428
+ const { event, payload } = raw;
1429
+ if (typeof event !== 'string' || !HOOK_EVENTS.includes(event)) {
1430
+ return jsonRes(res, 400, { ok: false, error: 'bad_event' });
1431
+ }
1432
+ if (!payload || typeof payload !== 'object') {
1433
+ return jsonRes(res, 400, { ok: false, error: 'bad_payload' });
1434
+ }
1435
+ emitHookEvent(event, payload);
1436
+ return jsonRes(res, 202, { ok: true });
1437
+ });
1377
1438
  // ─── adopt-session 查询端点 ───────────────────────────────────────────────────
1378
1439
  // CLI side(botmux hook)通过祖先 PID 匹配 adopt 会话,路由 askUserQuestion。
1379
1440
  // GET /api/adopt-session/:pid — 返回该 pid 对应的 adopt 会话路由信息。
@@ -1493,7 +1554,7 @@ async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
1493
1554
  return true;
1494
1555
  }
1495
1556
  async function handleNewTopic(data, ctx) {
1496
- const { chatId, messageId, chatType, larkAppId } = ctx;
1557
+ const { chatId, messageId, chatType, larkAppId, replyRootId } = ctx;
1497
1558
  // scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
1498
1559
  // routing into thread-scope so the bot's first reply seeds a Lark thread.
1499
1560
  let scope = ctx.scope;
@@ -1538,6 +1599,18 @@ async function handleNewTopic(data, ctx) {
1538
1599
  const senderUnionId = data.sender?.sender_id?.union_id;
1539
1600
  const botCfg = getBot(larkAppId).config;
1540
1601
  logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`);
1602
+ emitHookEvent('topic.new', {
1603
+ larkAppId,
1604
+ chatId,
1605
+ chatType,
1606
+ scope,
1607
+ anchor,
1608
+ messageId,
1609
+ senderOpenId,
1610
+ senderType: parsed.senderType,
1611
+ msgType: parsed.msgType,
1612
+ content,
1613
+ });
1541
1614
  if (parseWorkflowCommand(cmdContent)) {
1542
1615
  if (await replyGrantRestrictionIfNeeded(larkAppId, chatId, senderOpenId, anchor, '/workflow')) {
1543
1616
  return;
@@ -1720,6 +1793,8 @@ async function handleNewTopic(data, ctx) {
1720
1793
  ds.session.workingDir = pinnedWorkingDir;
1721
1794
  sessionStore.updateSession(ds.session);
1722
1795
  }
1796
+ beginReplyTargetTurn(ds, replyRootId, messageId);
1797
+ sessionStore.updateSession(ds.session);
1723
1798
  activeSessions.set(sessionKey(anchor, larkAppId), ds);
1724
1799
  // Pinned (oncall binding or inherited from sibling bot): spawn CLI immediately.
1725
1800
  if (pinnedWorkingDir) {
@@ -1728,7 +1803,7 @@ async function handleNewTopic(data, ctx) {
1728
1803
  const selfBot = getBot(larkAppId);
1729
1804
  const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), newTopicSender, { larkAppId, chatId });
1730
1805
  rememberLastCliInput(ds, promptContent, prompt);
1731
- await postPendingResponseCard(ds, messageId, content, newTopicSender);
1806
+ await postPendingResponseCard(ds, messageId, content, newTopicSender, messageId);
1732
1807
  forkWorker(ds, prompt);
1733
1808
  const reason = oncallEntry
1734
1809
  ? `oncall-bound chat ${chatId}`
@@ -1760,7 +1835,7 @@ async function handleNewTopic(data, ctx) {
1760
1835
  const selfBot = getBot(larkAppId);
1761
1836
  const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), newTopicSender, { larkAppId, chatId });
1762
1837
  rememberLastCliInput(ds, promptContent, prompt);
1763
- await postPendingResponseCard(ds, messageId, content, newTopicSender);
1838
+ await postPendingResponseCard(ds, messageId, content, newTopicSender, messageId);
1764
1839
  forkWorker(ds, prompt);
1765
1840
  logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
1766
1841
  }
@@ -1987,7 +2062,7 @@ function lookupForeignBotName(senderOpenId, larkAppId) {
1987
2062
  return 'Bot';
1988
2063
  }
1989
2064
  async function handleThreadReply(data, ctx) {
1990
- const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId } = ctx;
2065
+ const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId } = ctx;
1991
2066
  await resolveNonsupportMessage(data, larkAppId);
1992
2067
  const { parsed, resources } = parseEventMessage(data);
1993
2068
  // Expand merge_forward: fetch sub-messages and collect their resources
@@ -2021,6 +2096,22 @@ async function handleThreadReply(data, ctx) {
2021
2096
  ? `${tr('daemon.foreign_bot_mention_prefix', { botName: foreignBotName }, localeForBot(larkAppId))}\n`
2022
2097
  : '';
2023
2098
  const promptContent = buildQuoteHint(parsed, scope, anchor) + botSenderPrefix + parsed.content;
2099
+ const existingHookSession = activeSessions.get(sessionKey(anchor, larkAppId));
2100
+ emitHookEvent('thread.reply', {
2101
+ larkAppId,
2102
+ chatId: ctxChatId,
2103
+ chatType: ctxChatType,
2104
+ scope,
2105
+ anchor,
2106
+ messageId: parsed.messageId,
2107
+ rootId: parsed.rootId,
2108
+ parentId: parsed.parentId,
2109
+ senderOpenId: senderOpenIdForPrefix,
2110
+ senderType: parsed.senderType,
2111
+ msgType: parsed.msgType,
2112
+ sessionId: existingHookSession?.session.sessionId,
2113
+ content: parsed.content,
2114
+ });
2024
2115
  if (isForeignBot) {
2025
2116
  logger.info(`[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` +
2026
2117
  `senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`);
@@ -2163,6 +2254,30 @@ async function handleThreadReply(data, ctx) {
2163
2254
  return;
2164
2255
  }
2165
2256
  }
2257
+ // 自定义回复拦截:该话题有未结的 ask 且发送者有答复权限 → 把这条文字当答案,
2258
+ // 走 submitCustomReply settle 掉 ask(替代选项语义),不再当作新一轮指令喂给 CLI。
2259
+ // 此时发起 ask 的 CLI 正阻塞等结果,回什么都得先等 ask 结束,故无副作用。
2260
+ // 仅拦截纯文字(slash 命令 / 回调 URL / workflow 已在上方各自 return,可用来中止);
2261
+ // 外部 bot 的 open_id 不在 approvers 里,天然不会命中。非授权人 / 空文字则落到正常
2262
+ // 路由。卡片由 broker.onSettle 自动 PATCH 反映答案,无需额外回消息。
2263
+ if (threadSenderOpenId && threadChatId) {
2264
+ const askReplyText = cmdContent.trim();
2265
+ if (askReplyText) {
2266
+ const pendingAsk = findPendingAskByAnchor({ larkAppId, chatId: threadChatId, anchor });
2267
+ if (pendingAsk && pendingAsk.approvers.has(threadSenderOpenId)) {
2268
+ const outcome = submitCustomReply({
2269
+ askId: pendingAsk.askId,
2270
+ by: threadSenderOpenId,
2271
+ text: askReplyText,
2272
+ });
2273
+ if (outcome === 'accepted') {
2274
+ logger.info(`[${anchor.substring(0, 12)}] ask custom reply accepted from ${threadSenderOpenId.substring(0, 12)}`);
2275
+ return;
2276
+ }
2277
+ logger.info(`[${anchor.substring(0, 12)}] ask custom reply not accepted (${outcome}); falling through to normal routing`);
2278
+ }
2279
+ }
2280
+ }
2166
2281
  logger.info(`Reply in ${scope}-scope session ${anchor.substring(0, 12)}: ${content.substring(0, 100)} (resources: ${resources.length})`);
2167
2282
  let ds = activeSessions.get(sessionKey(anchor, larkAppId));
2168
2283
  // If another bot already owns this anchor, ignore unmentioned replies here as a
@@ -2210,6 +2325,7 @@ async function handleThreadReply(data, ctx) {
2210
2325
  ds.session.quoteTargetId = parsed.messageId;
2211
2326
  ds.session.quoteTargetSenderOpenId = callerOpenId;
2212
2327
  ds.session.quoteTargetSenderIsBot = isForeignBot;
2328
+ beginReplyTargetTurn(ds, replyRootId, parsed.messageId);
2213
2329
  if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
2214
2330
  ds.session.lastCallerOpenId = callerOpenId;
2215
2331
  }
@@ -2350,6 +2466,8 @@ async function handleThreadReply(data, ctx) {
2350
2466
  newDs.session.workingDir = pinnedWorkingDir;
2351
2467
  sessionStore.updateSession(newDs.session);
2352
2468
  }
2469
+ beginReplyTargetTurn(newDs, replyRootId, parsed.messageId);
2470
+ sessionStore.updateSession(newDs.session);
2353
2471
  activeSessions.set(sessionKey(anchor, larkAppId), newDs);
2354
2472
  // Pinned (oncall binding or inherited from peer bot in same thread):
2355
2473
  // spawn CLI immediately, skip repo selection.
@@ -2359,7 +2477,7 @@ async function handleThreadReply(data, ctx) {
2359
2477
  const selfBot = getBot(larkAppId);
2360
2478
  const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), autoCreateSender, { larkAppId, chatId: autoCreateChatId });
2361
2479
  rememberLastCliInput(newDs, promptContent, prompt);
2362
- await postPendingResponseCard(newDs, parsed.messageId, parsed.content, autoCreateSender);
2480
+ await postPendingResponseCard(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId);
2363
2481
  forkWorker(newDs, prompt);
2364
2482
  const reason = oncallEntry
2365
2483
  ? `oncall-bound chat ${autoCreateChatId}`
@@ -2391,7 +2509,7 @@ async function handleThreadReply(data, ctx) {
2391
2509
  const selfBot = getBot(larkAppId);
2392
2510
  const prompt = buildNewTopicPrompt(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), autoCreateSender, { larkAppId, chatId: autoCreateChatId });
2393
2511
  rememberLastCliInput(newDs, promptContent, prompt);
2394
- await postPendingResponseCard(newDs, parsed.messageId, parsed.content, autoCreateSender);
2512
+ await postPendingResponseCard(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId);
2395
2513
  forkWorker(newDs, prompt);
2396
2514
  }
2397
2515
  return;
@@ -2427,8 +2545,8 @@ async function handleThreadReply(data, ctx) {
2427
2545
  });
2428
2546
  beginNewTurn(ds, parsed.content);
2429
2547
  rememberLastCliInput(ds, promptContent, msgContent);
2430
- await postPendingResponseCard(ds, parsed.messageId, parsed.content, await getThreadSender());
2431
- ds.worker.send({ type: 'message', content: msgContent });
2548
+ await postPendingResponseCard(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId);
2549
+ ds.worker.send({ type: 'message', content: msgContent, turnId: parsed.messageId });
2432
2550
  }
2433
2551
  else {
2434
2552
  // Worker not running — re-fork with resume. This is a NEW turn, so drop
@@ -2476,11 +2594,47 @@ async function handleThreadReply(data, ctx) {
2476
2594
  sender: await getThreadSender(),
2477
2595
  });
2478
2596
  rememberLastCliInput(ds, promptContent, wrappedPrompt);
2479
- await postPendingResponseCard(ds, parsed.messageId, parsed.content, await getThreadSender());
2597
+ await postPendingResponseCard(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId);
2598
+ sessionStore.updateSession(ds.session);
2480
2599
  forkWorker(ds, wrappedPrompt, ds.hasHistory);
2481
2600
  }
2482
2601
  }
2483
2602
  // ─── Main ────────────────────────────────────────────────────────────────────
2603
+ /** Owner to DM for the restart report: the bot's first resolved allowedUser
2604
+ * (open_id). Falls back to a raw `ou_…` entry in the config. */
2605
+ function resolvePrimaryOwnerOpenId(larkAppId) {
2606
+ try {
2607
+ const bot = getBot(larkAppId);
2608
+ const resolved = (bot.resolvedAllowedUsers ?? []).find(u => typeof u === 'string' && u.startsWith('ou_'));
2609
+ if (resolved)
2610
+ return resolved;
2611
+ return (bot.config.allowedUsers ?? []).find(u => typeof u === 'string' && u.startsWith('ou_'));
2612
+ }
2613
+ catch {
2614
+ return undefined;
2615
+ }
2616
+ }
2617
+ /** Build the current dashboard URL (active token, not a rotation) from the
2618
+ * dashboard process's persisted `.dashboard-port` / `.dashboard-token`. Falls
2619
+ * back to a token-less base URL if the dashboard hasn't published a token yet. */
2620
+ function dashboardUrlForReport() {
2621
+ try {
2622
+ const dir = join(homedir(), '.botmux');
2623
+ const portFile = join(dir, '.dashboard-port');
2624
+ const tokenFile = join(dir, '.dashboard-token');
2625
+ const port = existsSync(portFile) ? readFileSync(portFile, 'utf8').trim() : String(config.dashboard.port);
2626
+ const base = `http://${getDashboardExternalHost()}:${port}/`;
2627
+ if (existsSync(tokenFile)) {
2628
+ const tok = readFileSync(tokenFile, 'utf8').trim();
2629
+ if (tok)
2630
+ return `${base}?t=${tok}`;
2631
+ }
2632
+ return base;
2633
+ }
2634
+ catch {
2635
+ return undefined;
2636
+ }
2637
+ }
2484
2638
  export async function startDaemon(botIndex) {
2485
2639
  // 首次启动时后台尝试安装 CJK 字体(Debian/Ubuntu),避免截图中文显示豆腐块。
2486
2640
  // 不阻塞:首张截图可能仍是豆腐块,装完重启 daemon 即可正常。
@@ -2560,6 +2714,11 @@ export async function startDaemon(botIndex) {
2560
2714
  // newly-started daemon's hydrate failing on dashboard startup. Binds to
2561
2715
  // 127.0.0.1 only since the dashboard sibling runs on the same host.
2562
2716
  const ipcHandle = await startIpcServer({ port: ipcPort, host: '127.0.0.1' });
2717
+ // startIpcServer probes upward on EADDRINUSE (e.g. a second botmux instance on
2718
+ // this host already holds ipcBasePort+idx), so the bound port may differ from
2719
+ // the requested one. Republish the ACTUAL port into the descriptor before it
2720
+ // is written below — the dashboard reaches us via desc.ipcPort verbatim.
2721
+ desc.ipcPort = ipcHandle.port;
2563
2722
  logger.info(`[dashboard-ipc] listening on 127.0.0.1:${ipcHandle.port} (bot ${idx})`);
2564
2723
  // Single reverse-proxy port that fronts every session's web terminal under
2565
2724
  // /s/{sessionId}, so dev-machine users forward one port (proxyBasePort+idx)
@@ -2695,6 +2854,7 @@ export async function startDaemon(botIndex) {
2695
2854
  handleThreadReply: (data, ctx) => handleThreadReply(data, ctx),
2696
2855
  handleBotAdded: (chatId, operatorOpenId, appId) => handleBotAdded(chatId, operatorOpenId, appId),
2697
2856
  isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
2857
+ resolveReplyThreadAlias: (rootId, chatId, appId) => findChatReplyAlias(rootId, chatId, appId),
2698
2858
  // Chat was converted 普通群 → 话题群 while we held a chat-scope session.
2699
2859
  // Evict it from the routing map so subsequent inbound messages can land
2700
2860
  // on a fresh thread-scope session (dispatcher already rerouted this turn
@@ -2712,6 +2872,13 @@ export async function startDaemon(botIndex) {
2712
2872
  }
2713
2873
  // Restore active sessions from previous run
2714
2874
  await restoreActiveSessions(activeSessions);
2875
+ const idleWorkerSweepTimer = setInterval(() => {
2876
+ const suspended = sweepIdleWorkers(activeSessions);
2877
+ if (suspended.length > 0) {
2878
+ logger.info(`[idle-worker-sweeper] suspended ${suspended.length} idle worker(s)`);
2879
+ }
2880
+ }, 60_000);
2881
+ idleWorkerSweepTimer.unref?.();
2715
2882
  await attachColdWorkflowRuns(cfg.larkAppId);
2716
2883
  // Start scheduler in every daemon. Each daemon owns exactly one bot, so
2717
2884
  // each filters to only execute tasks whose `larkAppId` matches its bot
@@ -2720,6 +2887,39 @@ export async function startDaemon(botIndex) {
2720
2887
  scheduler.setExecuteCallback((task) => executeScheduledTask(task, activeSessions, refreshCliVersion));
2721
2888
  scheduler.setOwnerFilter(cfg.larkAppId, idx === 0);
2722
2889
  scheduler.startScheduler();
2890
+ // Cross-daemon busy heartbeat: each daemon reports how many of its sessions
2891
+ // are mid-CLI-turn so the primary daemon's maintenance gate sees activity
2892
+ // across all bots (one daemon per bot). See core/daemon-heartbeat.ts.
2893
+ const writeBusyHeartbeat = () => {
2894
+ try {
2895
+ let busy = 0;
2896
+ for (const [, ds] of activeSessions) {
2897
+ if (ds.worker && !ds.worker.killed && ds.lastScreenStatus === 'working')
2898
+ busy++;
2899
+ }
2900
+ writeHeartbeat(cfg.larkAppId, busy);
2901
+ }
2902
+ catch { /* best-effort */ }
2903
+ };
2904
+ writeBusyHeartbeat();
2905
+ const maintenanceHeartbeat = setInterval(writeBusyHeartbeat, 15_000);
2906
+ maintenanceHeartbeat.unref?.();
2907
+ // Auto-update / auto-restart and the restart-report DM run only on the
2908
+ // primary daemon (bot-0) — a restart is host-wide.
2909
+ if (idx === 0) {
2910
+ startMaintenance();
2911
+ // After an intentional restart, DM the owner a summary. Delayed a few
2912
+ // seconds so the dashboard process can publish its token first.
2913
+ setTimeout(() => {
2914
+ void sendRestartReportIfPending({
2915
+ primaryLarkAppId: cfg.larkAppId,
2916
+ ownerOpenId: resolvePrimaryOwnerOpenId(cfg.larkAppId),
2917
+ dashboardUrl: dashboardUrlForReport(),
2918
+ sendCard: (openId, card) => sendUserMessage(cfg.larkAppId, openId, card, 'interactive').then(() => undefined),
2919
+ log: (m) => logger.info(`[restart-report] ${m}`),
2920
+ });
2921
+ }, 5_000).unref?.();
2922
+ }
2723
2923
  // Graceful shutdown. Sends SIGTERM (or `{type:'close'}` IPC via killWorker)
2724
2924
  // to every worker, then waits up to SHUTDOWN_GRACE_MS for them to exit
2725
2925
  // before sending SIGKILL to stragglers. Without the wait, daemon
@@ -2734,13 +2934,17 @@ export async function startDaemon(botIndex) {
2734
2934
  if (shuttingDown)
2735
2935
  return;
2736
2936
  shuttingDown = true;
2937
+ setSessionLifecycleShutdown(true);
2737
2938
  logger.info(`Daemon shutting down... (active: ${getActiveCount()})`);
2738
2939
  scheduler.stopScheduler();
2940
+ stopMaintenance();
2941
+ clearInterval(maintenanceHeartbeat);
2739
2942
  for (const watcher of workflowEventWatchers.values())
2740
2943
  watcher.close();
2741
2944
  workflowEventWatchers.clear();
2742
2945
  workflowRuns.clear();
2743
2946
  clearInterval(descriptorHeartbeat);
2947
+ clearInterval(idleWorkerSweepTimer);
2744
2948
  if (memoryDiagnostics)
2745
2949
  clearInterval(memoryDiagnostics);
2746
2950
  removeDaemonDescriptor(cfg.larkAppId);
@@ -2814,6 +3018,7 @@ export async function startDaemon(botIndex) {
2814
3018
  // the descriptor so the dashboard doesn't see a phantom daemon.
2815
3019
  process.on('exit', () => {
2816
3020
  clearInterval(descriptorHeartbeat);
3021
+ clearInterval(idleWorkerSweepTimer);
2817
3022
  if (memoryDiagnostics)
2818
3023
  clearInterval(memoryDiagnostics);
2819
3024
  removeDaemonDescriptor(cfg.larkAppId);