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
@@ -10,7 +10,14 @@ import { createCliAdapterSync } from '../adapters/cli/registry.js';
10
10
  import { TmuxBackend } from '../adapters/backend/tmux-backend.js';
11
11
  import { getBot, getAllBots } from '../bot-registry.js';
12
12
  import { validateAdoptTarget } from './session-discovery.js';
13
- import { sessionKey } from './types.js';
13
+ import { sessionKey, sessionAnchorId } from './types.js';
14
+ import { markSessionActivity } from './session-activity.js';
15
+ function sessionCreatedAtMs(session) {
16
+ return session.createdAt ? (Date.parse(session.createdAt) || Date.now()) : Date.now();
17
+ }
18
+ function sessionLastMessageAtMs(session) {
19
+ return session.lastMessageAt ? (Date.parse(session.lastMessageAt) || sessionCreatedAtMs(session)) : sessionCreatedAtMs(session);
20
+ }
14
21
  // ─── Path helpers ────────────────────────────────────────────────────────────
15
22
  export function expandHome(p) {
16
23
  return p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
@@ -80,7 +87,10 @@ export async function downloadResources(larkAppId, messageId, resources) {
80
87
  attachments.push({ type: res.type, path: savePath, name: res.name });
81
88
  }
82
89
  catch (err) {
83
- logger.warn(`Failed to download ${res.type} ${res.key}: ${err.message}`);
90
+ // Download failure usually means missing User Token scope or a
91
+ // legitimately revoked attachment — the caller surfaces `needLogin`
92
+ // to the user. Per-failure log stays at info to aid retries.
93
+ logger.info(`Failed to download ${res.type} ${res.key}: ${err.message}`);
84
94
  if (err.message?.includes('User Token'))
85
95
  needLogin = true;
86
96
  }
@@ -109,74 +119,85 @@ export async function getAvailableBots(currentAppId, chatId) {
109
119
  return [];
110
120
  }
111
121
  }
122
+ /** XML-escape a string for use as element text content or attribute value.
123
+ * Covers the five XML-mandated entities; sufficient for our use case
124
+ * (paths, names, open_ids, bot identifiers) since we never embed raw user
125
+ * input in attribute values. */
126
+ function xmlEscape(s) {
127
+ return s
128
+ .replace(/&/g, '&')
129
+ .replace(/</g, '&lt;')
130
+ .replace(/>/g, '&gt;')
131
+ .replace(/"/g, '&quot;')
132
+ .replace(/'/g, '&apos;');
133
+ }
112
134
  export function formatAttachmentsHint(attachments) {
113
135
  if (!attachments || attachments.length === 0)
114
136
  return '';
115
137
  let imgN = 0, fileN = 0;
116
- const lines = attachments.map(a => {
117
- const label = a.type === 'image' ? `[图片 ${++imgN}]` : `[文件 ${++fileN}]`;
118
- return `- ${label} (${a.path})`;
138
+ const items = attachments.map(a => {
139
+ const tag = a.type === 'image' ? 'image' : 'file';
140
+ const n = a.type === 'image' ? ++imgN : ++fileN;
141
+ return ` <${tag} n="${n}" path="${xmlEscape(a.path)}" />`;
119
142
  });
120
- return `\n\n附件(使用 Read 工具查看,序号与正文中的 [图片 N] / [文件 N] 占位符对应):\n${lines.join('\n')}`;
143
+ return `<attachments hint="使用 Read 工具查看,序号与正文中的 [图片 N] / [文件 N] 占位符对应">\n${items.join('\n')}\n</attachments>`;
121
144
  }
122
145
  export function buildNewTopicPrompt(userMessage, sessionId, cliId, cliPathOverride, attachments, mentions, availableBots, followUps, botIdentity) {
123
146
  const adapter = createCliAdapterSync(cliId, cliPathOverride);
124
147
  const hints = adapter.systemHints;
125
- const noteLines = hints.map(h => `- ${h}`);
126
- // Bot identity section — tells the bot who it is so it can distinguish
127
- // itself when multiple bots are @mentioned in the same message.
128
- let identitySection = '';
148
+ const routingBlock = hints.length > 0
149
+ ? `<botmux_routing>\n${hints.join('\n')}\n</botmux_routing>`
150
+ : '';
151
+ let identityBlock = '';
129
152
  if (botIdentity && (botIdentity.name || botIdentity.openId)) {
130
- const lines = [
131
- `- 名字:${botIdentity.name ?? '(未知)'}`,
132
- `- open_id:${botIdentity.openId ?? '(未知)'}`,
133
- '- 同一群里可能有多个机器人同时被 @,消息里会以 `@名字` 和 `open_id` 区分',
134
- '- 只执行明确分给自己的那部分,整条消息都指派给别的机器人时保持沉默',
135
- ];
136
- identitySection = `\n\n你的身份:\n${lines.join('\n')}`;
153
+ identityBlock = [
154
+ '<identity>',
155
+ ` <name>${xmlEscape(botIdentity.name ?? '(未知)')}</name>`,
156
+ ` <open_id>${xmlEscape(botIdentity.openId ?? '(未知)')}</open_id>`,
157
+ ' <routing_rules>提醒:让别的 bot 接力干活必须 `botmux send --mention <对方 open_id>`,否则对方 bot 不会被触发。</routing_rules>',
158
+ '</identity>',
159
+ ].join('\n');
137
160
  }
138
- // Mention metadata section
139
- let mentionSection = '';
161
+ let mentionBlock = '';
140
162
  if (mentions && mentions.length > 0) {
141
- const mentionLines = mentions.map(m => {
142
- const idPart = m.openId ? ` open_id: ${m.openId}` : '';
143
- return `- @${m.name}${idPart}`;
163
+ const items = mentions.map(m => {
164
+ const oid = m.openId ? ` open_id="${xmlEscape(m.openId)}"` : '';
165
+ return ` <mention name="${xmlEscape(m.name)}"${oid} />`;
144
166
  });
145
- mentionSection = `\n\n消息中的 @mention:\n${mentionLines.join('\n')}`;
167
+ mentionBlock = `<mentions>\n${items.join('\n')}\n</mentions>`;
146
168
  }
147
- // Available bots section — only show bots NOT already in @mentions
148
- let botSection = '';
169
+ let botBlock = '';
149
170
  if (availableBots && availableBots.length > 0) {
150
171
  const mentionedOpenIds = new Set(mentions?.map(m => m.openId).filter(Boolean));
151
172
  const unmentionedBots = availableBots.filter(b => !mentionedOpenIds.has(b.openId));
152
173
  if (unmentionedBots.length > 0) {
153
- const botLines = unmentionedBots.map(b => `- ${b.displayName} (open_id: ${b.openId})`);
154
- botSection = `\n\n当前群聊中的其他机器人:\n${botLines.join('\n')}\n可通过 botmux send --mention 参数 @mention 它们协作,也可用 botmux bots list 查询。`;
174
+ const items = unmentionedBots.map(b => ` <bot name="${xmlEscape(b.displayName)}" open_id="${xmlEscape(b.openId)}" />`);
175
+ botBlock = `<available_bots hint="让这里的某个 bot 接力干活必须 --mention 它的 open_id(botmux send --mention ou_xxx ...),不 --mention 对方 bot 完全收不到消息">\n${items.join('\n')}\n</available_bots>`;
155
176
  }
156
177
  }
157
- // CLIs with injectsSessionContext get Lark context via system prompt,
158
- // so pass user messages cleanly without wrapper — same format as follow-ups.
159
- const attachHint = formatAttachmentsHint(attachments);
160
- const parts = adapter.injectsSessionContext
161
- ? [`${userMessage}${attachHint}`]
162
- : [`用户发送了:\n---\n${userMessage}${attachHint}\n---`];
163
- // Append follow-up messages buffered during repo selection
178
+ const userBlock = `<user_message>\n${userMessage}\n</user_message>`;
179
+ const parts = [userBlock];
164
180
  if (followUps && followUps.length > 0) {
165
181
  for (const fu of followUps) {
166
- parts.push(adapter.injectsSessionContext ? fu : `用户追加了:\n---\n${fu}\n---`);
182
+ parts.push(`<follow_up_message>\n${fu}\n</follow_up_message>`);
167
183
  }
168
184
  }
185
+ const attachHint = formatAttachmentsHint(attachments);
186
+ if (attachHint)
187
+ parts.push(attachHint);
188
+ // CLIs with injectsSessionContext (Claude Code) get Lark routing/identity
189
+ // and session ID via system prompt, so skip those blocks here.
169
190
  if (!adapter.injectsSessionContext) {
170
- parts.push(`Session ID: ${sessionId}`);
191
+ parts.push(`<session_id>${xmlEscape(sessionId)}</session_id>`);
192
+ if (routingBlock)
193
+ parts.push(routingBlock);
194
+ if (identityBlock)
195
+ parts.push(identityBlock);
171
196
  }
172
- if (noteLines.length > 0)
173
- parts.push(noteLines.join('\n'));
174
- if (identitySection)
175
- parts.push(identitySection.trim());
176
- if (mentionSection)
177
- parts.push(mentionSection.trim());
178
- if (botSection)
179
- parts.push(botSection.trim());
197
+ if (mentionBlock)
198
+ parts.push(mentionBlock);
199
+ if (botBlock)
200
+ parts.push(botBlock);
180
201
  return parts.join('\n\n');
181
202
  }
182
203
  /**
@@ -185,41 +206,141 @@ export function buildNewTopicPrompt(userMessage, sessionId, cliId, cliPathOverri
185
206
  * Session ID is omitted for adopt mode and CLIs with injectsSessionContext.
186
207
  */
187
208
  export function buildFollowUpContent(content, sessionId, opts) {
188
- const parts = [
189
- opts?.attachments && opts.attachments.length > 0
190
- ? `${content}${formatAttachmentsHint(opts.attachments)}`
191
- : content,
192
- ];
209
+ const parts = [`<user_message>\n${content}\n</user_message>`];
210
+ const attachHint = opts?.attachments && opts.attachments.length > 0
211
+ ? formatAttachmentsHint(opts.attachments)
212
+ : '';
213
+ if (attachHint)
214
+ parts.push(attachHint);
193
215
  if (!opts?.isAdoptMode) {
194
- // CLIs with injectsSessionContext get session ID via system prompt + MCP auto-detection
216
+ // CLIs with injectsSessionContext get session ID via system prompt + ancestor-pid auto-detection
195
217
  const skipSessionId = opts?.cliId
196
218
  ? createCliAdapterSync(opts.cliId, opts.cliPathOverride).injectsSessionContext
197
219
  : false;
198
220
  if (!skipSessionId) {
199
- parts.push(`Session ID: ${sessionId}`);
221
+ parts.push(`<session_id>${xmlEscape(sessionId)}</session_id>`);
200
222
  }
201
223
  }
202
224
  if (opts?.mentions && opts.mentions.length > 0) {
203
- const mentionLines = opts.mentions.map(m => {
204
- const idPart = m.openId ? ` open_id: ${m.openId}` : '';
205
- return `- @${m.name}${idPart}`;
225
+ const items = opts.mentions.map(m => {
226
+ const oid = m.openId ? ` open_id="${xmlEscape(m.openId)}"` : '';
227
+ return ` <mention name="${xmlEscape(m.name)}"${oid} />`;
206
228
  });
207
- parts.push(`消息中的 @mention:\n${mentionLines.join('\n')}`);
208
- }
209
- // Per-message routing hint — only for CLIs without system prompt context.
210
- // CLIs with injectsSessionContext (e.g. Claude Code) already have the
211
- // "use botmux send" instruction in --append-system-prompt, no need to repeat.
212
- const skipHint = opts?.cliId
213
- ? createCliAdapterSync(opts.cliId, opts.cliPathOverride).injectsSessionContext
214
- : false;
215
- if (!skipHint) {
216
- parts.push('[请用 botmux send "消息" - 这个shell工具回复飞书用户,终端输出用户看不到]');
229
+ parts.push(`<mentions>\n${items.join('\n')}\n</mentions>`);
217
230
  }
231
+ // Per-message routing hint — system prompt routing block can fade in long
232
+ // sessions, so re-state the core "use botmux send" rule at the tail of every
233
+ // follow-up regardless of CLI.
234
+ parts.push('<botmux_reminder>回复必须 botmux send,终端输出用户看不到</botmux_reminder>');
218
235
  return parts.join('\n\n');
219
236
  }
237
+ /**
238
+ * Build raw input content for adopt-bridge mode.
239
+ *
240
+ * Bridge mode injects the user's text into the existing CLI exactly as the
241
+ * local user would type it: NO `<session_id>`, NO `<botmux_reminder>`, NO
242
+ * Skills hint. The model is intentionally unaware of botmux — the daemon
243
+ * harvests final output via the transcript watcher and forwards it to Lark
244
+ * out-of-band.
245
+ *
246
+ * Attachments and @mentions are surfaced as plain prose so the user's intent
247
+ * carries over, but the format avoids any wording that would prompt the
248
+ * model to call `botmux send` / route through botmux tooling.
249
+ */
250
+ export function buildBridgeInputContent(content, opts) {
251
+ const selfMention = opts?.selfMention;
252
+ const selfNames = new Set();
253
+ if (selfMention?.name)
254
+ selfNames.add(selfMention.name);
255
+ for (const m of opts?.mentions ?? []) {
256
+ if (selfMention?.openId && m.openId === selfMention.openId)
257
+ selfNames.add(m.name);
258
+ if (selfMention?.name && m.name === selfMention.name)
259
+ selfNames.add(m.name);
260
+ }
261
+ const isSelfMention = (m) => {
262
+ // openId is authoritative when both sides have it — avoids classifying
263
+ // a different bot as self in the (theoretical) case where two bots in
264
+ // the same chat share a display name.
265
+ if (selfMention?.openId && m.openId) {
266
+ return m.openId === selfMention.openId;
267
+ }
268
+ // At least one side is missing openId (cold-start window before
269
+ // probeBotOpenId returns, or a mention without openId): fall back to
270
+ // name match.
271
+ return !!selfMention?.name && selfNames.has(m.name);
272
+ };
273
+ const stripLeadingSelfMentions = (s) => {
274
+ if (selfNames.size === 0)
275
+ return s;
276
+ let out = s.trimStart();
277
+ const tags = [...selfNames]
278
+ .sort((a, b) => b.length - a.length)
279
+ .map(name => `@${name}`);
280
+ let changed = true;
281
+ while (changed) {
282
+ changed = false;
283
+ for (const tag of tags) {
284
+ if (!out.startsWith(tag))
285
+ continue;
286
+ const next = out.charAt(tag.length);
287
+ // Avoid stripping prefixes like "@CodexFoo" when the bot name is
288
+ // "Codex"; Lark-rendered mentions are followed by whitespace or EOL.
289
+ if (next && !/\s/.test(next))
290
+ continue;
291
+ out = out.slice(tag.length).trimStart();
292
+ changed = true;
293
+ break;
294
+ }
295
+ }
296
+ return out;
297
+ };
298
+ const parts = [stripLeadingSelfMentions(content)];
299
+ if (opts?.attachments && opts.attachments.length > 0) {
300
+ const lines = opts.attachments.map(a => `- ${a.name} (${a.path})`);
301
+ parts.push(`\n[附件]\n${lines.join('\n')}`);
302
+ }
303
+ const mentions = opts?.mentions?.filter(m => !isSelfMention(m)) ?? [];
304
+ if (mentions.length > 0) {
305
+ const lines = mentions.map(m => `- @${m.name}`);
306
+ parts.push(`\n[@提及]\n${lines.join('\n')}`);
307
+ }
308
+ return parts.join('\n');
309
+ }
220
310
  // ─── Stream-card state persistence ───────────────────────────────────────────
221
311
  /** Sentinel value (CARD_POSTING_SENTINEL from worker-pool) we must skip — it marks an in-flight POST, not a real message_id. */
222
312
  const STREAM_CARD_SENTINEL = '__posting__';
313
+ /**
314
+ * Build the prompt that gets piped into a freshly-spawned CLI when an existing
315
+ * (non-bridge) session re-forks its worker. Hits the `worker=null` re-fork
316
+ * branch in handleThreadReply: resume after /close, daemon-restart + new
317
+ * message, and any other path that lands a new turn without a live worker.
318
+ *
319
+ * Without wrapping, the worker would queue the user's raw text as the initial
320
+ * prompt — the CLI sees no `<user_message>` / `<botmux_reminder>` envelope
321
+ * and answers in its own terminal instead of calling `botmux send`. This
322
+ * helper centralises the wrap so both daemon.ts and tests agree on the shape.
323
+ *
324
+ * Adopt-bridge sessions go through `buildBridgeInputContent` instead — see
325
+ * the buildBridgeInputContent docstring for why bridge prompts intentionally
326
+ * skip botmux routing tags.
327
+ */
328
+ export function buildReforkPrompt(ds, content, opts) {
329
+ if (ds.adoptedFrom) {
330
+ return buildBridgeInputContent(content, {
331
+ attachments: opts?.attachments,
332
+ mentions: opts?.mentions,
333
+ selfMention: opts?.selfMention,
334
+ });
335
+ }
336
+ return buildFollowUpContent(content, ds.session.sessionId, {
337
+ attachments: opts?.attachments,
338
+ mentions: opts?.mentions,
339
+ isAdoptMode: false,
340
+ cliId: opts?.cliId,
341
+ cliPathOverride: opts?.cliPathOverride,
342
+ });
343
+ }
223
344
  /**
224
345
  * Copy current streaming-card fields from `ds` into the persisted Session and save.
225
346
  * Lets the existing card be PATCHed on next screen_update after a daemon restart,
@@ -256,6 +377,9 @@ export function restoreActiveSessions(activeSessions) {
256
377
  killStalePids(active);
257
378
  logger.info(`Registering ${active.length} active session(s) (no CLI spawn until new messages arrive)...`);
258
379
  for (const session of active) {
380
+ // Restored sessions persisted before the scope field was added default to
381
+ // 'thread' — that matches the legacy thread-only behaviour.
382
+ const scope = session.scope === 'chat' ? 'chat' : 'thread';
259
383
  // Adopt sessions: restore if original CLI is still alive, otherwise close
260
384
  if (session.title?.startsWith('Adopt:') && session.adoptedFrom) {
261
385
  const adopted = session.adoptedFrom;
@@ -265,7 +389,6 @@ export function restoreActiveSessions(activeSessions) {
265
389
  continue;
266
390
  }
267
391
  // Original CLI still alive — re-register and fork adopt worker
268
- messageQueue.ensureQueue(session.rootMessageId);
269
392
  const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
270
393
  const ds = {
271
394
  session,
@@ -275,9 +398,10 @@ export function restoreActiveSessions(activeSessions) {
275
398
  larkAppId,
276
399
  chatId: session.chatId,
277
400
  chatType: session.chatType ?? 'group',
278
- spawnedAt: Date.now(),
401
+ scope,
402
+ spawnedAt: sessionCreatedAtMs(session),
279
403
  cliVersion: getCurrentCliVersion(),
280
- lastMessageAt: Date.now(),
404
+ lastMessageAt: sessionLastMessageAtMs(session),
281
405
  hasHistory: false,
282
406
  workingDir: adopted.cwd,
283
407
  adoptedFrom: adopted,
@@ -289,9 +413,11 @@ export function restoreActiveSessions(activeSessions) {
289
413
  currentImageKey: session.currentImageKey,
290
414
  currentTurnTitle: session.currentTurnTitle,
291
415
  };
292
- activeSessions.set(sessionKey(session.rootMessageId, larkAppId), ds);
293
- forkAdoptWorker(ds);
294
- logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adopted.tmuxTarget})`);
416
+ const anchor = sessionAnchorId(ds);
417
+ messageQueue.ensureQueue(anchor);
418
+ activeSessions.set(sessionKey(anchor, larkAppId), ds);
419
+ forkAdoptWorker(ds, { restoredFromMetadata: true });
420
+ logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adopted.tmuxTarget}, scope: ${scope})`);
295
421
  continue;
296
422
  }
297
423
  // Adopt sessions without persisted metadata — close (legacy)
@@ -300,9 +426,8 @@ export function restoreActiveSessions(activeSessions) {
300
426
  sessionStore.closeSession(session.sessionId);
301
427
  continue;
302
428
  }
303
- messageQueue.ensureQueue(session.rootMessageId);
304
429
  const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
305
- activeSessions.set(sessionKey(session.rootMessageId, larkAppId), {
430
+ const ds = {
306
431
  session,
307
432
  worker: null,
308
433
  workerPort: null,
@@ -310,9 +435,10 @@ export function restoreActiveSessions(activeSessions) {
310
435
  larkAppId,
311
436
  chatId: session.chatId,
312
437
  chatType: session.chatType ?? 'group',
313
- spawnedAt: Date.now(),
438
+ scope,
439
+ spawnedAt: sessionCreatedAtMs(session),
314
440
  cliVersion: getCurrentCliVersion(),
315
- lastMessageAt: Date.now(),
441
+ lastMessageAt: sessionLastMessageAtMs(session),
316
442
  hasHistory: true, // restored sessions have prior CLI history
317
443
  workingDir: session.workingDir,
318
444
  // Restore persisted streaming-card state — next screen_update will PATCH
@@ -325,8 +451,11 @@ export function restoreActiveSessions(activeSessions) {
325
451
  displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
326
452
  currentImageKey: session.currentImageKey,
327
453
  currentTurnTitle: session.currentTurnTitle,
328
- });
329
- logger.debug(`Registered session ${session.sessionId} (thread: ${session.rootMessageId})`);
454
+ };
455
+ const anchor = sessionAnchorId(ds);
456
+ messageQueue.ensureQueue(anchor);
457
+ activeSessions.set(sessionKey(anchor, larkAppId), ds);
458
+ logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
330
459
  }
331
460
  // Tmux mode: auto-fork workers for sessions with surviving tmux sessions
332
461
  if (config.daemon.backendType === 'tmux') {
@@ -340,46 +469,199 @@ export function restoreActiveSessions(activeSessions) {
340
469
  }
341
470
  logger.info(`Restored ${active.length} session(s)${config.daemon.backendType === 'tmux' ? '' : ', waiting for messages to resume'}`);
342
471
  }
472
+ /**
473
+ * Reactivate a single closed session — used by the "▶️ 恢复会话" card button
474
+ * and the `botmux resume <id>` CLI command. Mirrors the per-session branch
475
+ * of `restoreActiveSessions` but operates on one record by id and without
476
+ * killing stale pids (the `/close` flow that produced this closed record
477
+ * already killed them).
478
+ *
479
+ * Returns `{ ok: true, ds }` on success; structured error otherwise so callers
480
+ * (HTTP IPC, card handler) can surface a precise message.
481
+ *
482
+ * - 'not_found' — sessionId doesn't exist in any session file
483
+ * - 'not_closed' — session is still active or in some other state
484
+ * - 'anchor_occupied' — another active session already owns this anchor
485
+ * (e.g. user kept typing after /close, auto-creating
486
+ * a fresh thread session); refuse rather than clobber
487
+ * - 'adopt_unsupported' — adopt sessions are torn down by /close and have
488
+ * no resume semantics
489
+ */
490
+ export function resumeSession(sessionId, activeSessions) {
491
+ const session = sessionStore.getSession(sessionId);
492
+ if (!session)
493
+ return { ok: false, error: 'not_found' };
494
+ if (session.status !== 'closed')
495
+ return { ok: false, error: 'not_closed' };
496
+ // Adopt sessions don't survive /close — the user's tmux pane and original
497
+ // CLI pid have already moved on, and bringing the bridge back without a live
498
+ // pane is meaningless.
499
+ if (session.title?.startsWith('Adopt:') || session.adoptedFrom) {
500
+ return { ok: false, error: 'adopt_unsupported' };
501
+ }
502
+ const scope = session.scope === 'chat' ? 'chat' : 'thread';
503
+ const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
504
+ const anchor = scope === 'thread' ? session.rootMessageId : session.chatId;
505
+ const key = sessionKey(anchor, larkAppId);
506
+ const existing = activeSessions.get(key);
507
+ if (existing) {
508
+ return { ok: false, error: 'anchor_occupied', activeSessionId: existing.session.sessionId };
509
+ }
510
+ // Belt-and-suspenders: also scan persisted sessions for any *other* active
511
+ // session pinned to the same (larkAppId, scope, anchor). The in-memory Map
512
+ // is the authoritative routing source for a running daemon, but it's only
513
+ // hydrated for sessions that survived restoreActiveSessions. Cross-process
514
+ // and partial-load situations (e.g. another bot's daemon writes a session
515
+ // file but our Map hasn't caught up, or a closed session was orphaned by a
516
+ // crash that left a sibling session active in the same anchor) can leave a
517
+ // store-level conflict invisible to the Map check above. Refuse instead of
518
+ // overwriting the routing key.
519
+ const conflict = sessionStore.listSessions().find(s => s.sessionId !== sessionId
520
+ && s.status === 'active'
521
+ && (s.larkAppId ?? '') === larkAppId
522
+ && (s.scope === 'chat' ? 'chat' : 'thread') === scope
523
+ && (scope === 'thread' ? s.rootMessageId === anchor : s.chatId === anchor));
524
+ if (conflict) {
525
+ return { ok: false, error: 'anchor_occupied', activeSessionId: conflict.sessionId };
526
+ }
527
+ // Reactivate in store — clear closedAt so dashboard rows don't keep showing
528
+ // the stale close timestamp on the now-active session.
529
+ session.status = 'active';
530
+ session.closedAt = undefined;
531
+ session.lastMessageAt = new Date().toISOString();
532
+ sessionStore.updateSession(session);
533
+ const now = Date.now();
534
+ const ds = {
535
+ session,
536
+ worker: null,
537
+ workerPort: null,
538
+ workerToken: null,
539
+ larkAppId,
540
+ chatId: session.chatId,
541
+ chatType: session.chatType ?? 'group',
542
+ scope,
543
+ spawnedAt: sessionCreatedAtMs(session),
544
+ cliVersion: getCurrentCliVersion(),
545
+ lastMessageAt: now,
546
+ hasHistory: true, // resumed sessions carry CLI history (--resume on next fork)
547
+ workingDir: session.workingDir,
548
+ ownerOpenId: session.ownerOpenId,
549
+ streamCardId: session.streamCardId,
550
+ streamCardNonce: session.streamCardNonce,
551
+ displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
552
+ currentImageKey: session.currentImageKey,
553
+ currentTurnTitle: session.currentTurnTitle,
554
+ };
555
+ messageQueue.ensureQueue(anchor);
556
+ activeSessions.set(key, ds);
557
+ logger.info(`Resumed session ${sessionId.substring(0, 8)} (scope: ${scope}, anchor: ${anchor.substring(0, 12)})`);
558
+ return { ok: true, ds };
559
+ }
343
560
  // ─── Scheduled task execution ────────────────────────────────────────────────
344
561
  export async function executeScheduledTask(task, activeSessions, refreshCliVersion) {
345
562
  // Resolve which bot to use — prefer the task's original bot so replies come from
346
563
  // the same account the user set up the schedule with.
347
564
  const allBots = getAllBots();
348
565
  if (allBots.length === 0) {
349
- logger.warn('No bots configured, skipping scheduled task');
566
+ // Expected at startup before bot configs finish loading; scheduler will
567
+ // re-fire on the next cron tick. Not actionable.
568
+ logger.debug('No bots configured, skipping scheduled task');
350
569
  return;
351
570
  }
352
571
  const bot = (task.larkAppId && allBots.find(b => b.config.larkAppId === task.larkAppId)) ||
353
572
  allBots[0];
354
573
  const larkAppId = bot.config.larkAppId;
355
- const { sendMessage, replyMessage } = await import('../im/lark/client.js');
356
- // Decide where to route: preferred path is to reply inside the original thread.
357
- // Fallback (legacy tasks without rootMessageId): post a new top-level message.
358
- let threadRootId;
574
+ const { getChatMode, sendMessage, replyMessage } = await import('../im/lark/client.js');
575
+ // Scope resolution explicit task.scope wins; otherwise fall back to legacy
576
+ // semantics (rootMessageId present thread, absent chat). Restoring an
577
+ // older schedule without scope keeps current behaviour.
578
+ const scope = task.scope === 'chat'
579
+ ? 'chat'
580
+ : task.scope === 'thread'
581
+ ? 'thread'
582
+ : (task.rootMessageId ? 'thread' : 'chat');
583
+ // Decide where to route the "🕐 task started" notification and where the
584
+ // session conversation lands.
585
+ //
586
+ // Thread-scope (legacy and current default):
587
+ // - cross-thread (creator != target): notify creator's thread; deliver
588
+ // execution into target rootMessageId
589
+ // - same-thread: notify into the bound thread,
590
+ // which doubles as the session anchor
591
+ // - missing rootMessageId: fall back to a fresh top-level
592
+ // post in the chat (one-shot session)
593
+ //
594
+ // Chat-scope (auto-adopt / 普通群): post the start notification straight to
595
+ // the chat without reply_in_thread; the chat IS the session anchor.
596
+ let anchor;
359
597
  let isContinuation = false;
360
- if (task.rootMessageId) {
361
- try {
362
- // Reply in the original thread the returned reply message id is just an
363
- // anchor for this run; the thread's root remains task.rootMessageId, which
364
- // is what the session/card system keys off.
365
- await replyMessage(larkAppId, task.rootMessageId, `🕐 定时任务「${task.name}」开始执行`, 'text', true);
366
- threadRootId = task.rootMessageId;
367
- isContinuation = true;
598
+ if (scope === 'chat') {
599
+ // A group may have been converted from 普通群 to 话题群 after the schedule
600
+ // was created. In topic mode, a top-level sendMessage creates a new topic;
601
+ // keep scheduled continuations in the original thread when we have one.
602
+ const chatMode = await getChatMode(larkAppId, task.chatId, { forceRefresh: true });
603
+ if (chatMode === 'topic' && task.rootMessageId) {
604
+ try {
605
+ await replyMessage(larkAppId, task.rootMessageId, `🕐 定时任务「${task.name}」开始执行`, 'text', true);
606
+ anchor = task.rootMessageId;
607
+ isContinuation = true;
608
+ }
609
+ catch (err) {
610
+ logger.warn(`[scheduler] Failed to reply in converted topic chat ${task.rootMessageId} (${err.message}); falling back to new thread`);
611
+ anchor = await sendMessage(larkAppId, task.chatId, `🕐 定时任务「${task.name}」开始执行`);
612
+ }
368
613
  }
369
- catch (err) {
370
- logger.warn(`[scheduler] Failed to reply in original thread ${task.rootMessageId} (${err.message}); falling back to new thread`);
371
- threadRootId = await sendMessage(larkAppId, task.chatId, `🕐 定时任务「${task.name}」开始执行`);
614
+ else if (task.creatorRootMessageId && task.creatorChatId !== task.chatId) {
615
+ const creatorAppId = task.creatorLarkAppId ?? larkAppId;
616
+ replyMessage(creatorAppId, task.creatorRootMessageId, `🕐 定时任务「${task.name}」已在目标群聊触发`, 'text', true).catch((err) => {
617
+ logger.warn(`[scheduler] Failed to notify creator thread ${task.creatorRootMessageId} (${err.message})`);
618
+ });
619
+ }
620
+ else {
621
+ // Same-chat: post the start banner to the chat as a plain message.
622
+ try {
623
+ await sendMessage(larkAppId, task.chatId, `🕐 定时任务「${task.name}」开始执行`);
624
+ }
625
+ catch (err) {
626
+ logger.warn(`[scheduler] Failed to post start banner in chat ${task.chatId} (${err.message})`);
627
+ }
372
628
  }
629
+ anchor = task.chatId;
630
+ isContinuation = !!activeSessions.get(sessionKey(anchor, larkAppId));
373
631
  }
374
632
  else {
375
- threadRootId = await sendMessage(larkAppId, task.chatId, `🕐 定时任务「${task.name}」开始执行`);
633
+ // thread-scope path (existing logic)
634
+ const isCrossThread = !!task.creatorRootMessageId &&
635
+ !!task.rootMessageId &&
636
+ task.creatorRootMessageId !== task.rootMessageId;
637
+ if (isCrossThread) {
638
+ const creatorAppId = task.creatorLarkAppId ?? larkAppId;
639
+ replyMessage(creatorAppId, task.creatorRootMessageId, `🕐 定时任务「${task.name}」已在目标话题触发`, 'text', true).catch((err) => {
640
+ logger.warn(`[scheduler] Failed to notify creator thread ${task.creatorRootMessageId} (${err.message})`);
641
+ });
642
+ anchor = task.rootMessageId;
643
+ isContinuation = true;
644
+ }
645
+ else if (task.rootMessageId) {
646
+ try {
647
+ await replyMessage(larkAppId, task.rootMessageId, `🕐 定时任务「${task.name}」开始执行`, 'text', true);
648
+ anchor = task.rootMessageId;
649
+ isContinuation = true;
650
+ }
651
+ catch (err) {
652
+ logger.warn(`[scheduler] Failed to reply in original thread ${task.rootMessageId} (${err.message}); falling back to new thread`);
653
+ anchor = await sendMessage(larkAppId, task.chatId, `🕐 定时任务「${task.name}」开始执行`);
654
+ }
655
+ }
656
+ else {
657
+ anchor = await sendMessage(larkAppId, task.chatId, `🕐 定时任务「${task.name}」开始执行`);
658
+ }
376
659
  }
377
660
  refreshCliVersion(bot.config.cliId, bot.config.cliPathOverride);
378
- // If a live session already exists for this thread (user was just chatting in it),
379
- // inject the prompt as a follow-up message rather than spawning a fresh worker.
380
- const existing = activeSessions.get(sessionKey(threadRootId, larkAppId));
661
+ // Inject into a live session if one already exists at this anchor.
662
+ const existing = activeSessions.get(sessionKey(anchor, larkAppId));
381
663
  if (isContinuation && existing?.worker && !existing.worker.killed) {
382
- existing.lastMessageAt = Date.now();
664
+ markSessionActivity(existing);
383
665
  try {
384
666
  existing.worker.send({ type: 'message', content: task.prompt });
385
667
  logger.info(`[scheduler] Task "${task.name}" injected into live session ${existing.session.sessionId}`);
@@ -389,12 +671,19 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
389
671
  logger.warn(`[scheduler] Failed to inject into live session (${err.message}); spawning fresh worker`);
390
672
  }
391
673
  }
392
- // Otherwise create a new session bound to the original thread root so all the
393
- // worker's replies continue to land under that topic in Lark.
394
- const session = sessionStore.createSession(task.chatId, threadRootId, `[定时] ${task.name}`);
674
+ // Spawn a fresh session bound to the chosen anchor.
675
+ // Thread-scope: rootMessageId = anchor. Chat-scope: rootMessageId stores the
676
+ // chatId-as-seed for audit (sessionAnchorId() returns chatId via scope). If a
677
+ // formerly chat-scope task was redirected into a converted topic chat, promote
678
+ // the runtime session to thread-scope so follow-up replies stay in-thread.
679
+ const runtimeScope = scope === 'chat' && anchor !== task.chatId ? 'thread' : scope;
680
+ const session = sessionStore.createSession(task.chatId, anchor, `[定时] ${task.name}`);
681
+ const now = Date.now();
395
682
  session.larkAppId = larkAppId;
683
+ session.scope = runtimeScope;
684
+ session.lastMessageAt = new Date(now).toISOString();
396
685
  sessionStore.updateSession(session);
397
- messageQueue.ensureQueue(threadRootId);
686
+ messageQueue.ensureQueue(anchor);
398
687
  const prompt = buildNewTopicPrompt(task.prompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId });
399
688
  const ds = {
400
689
  session,
@@ -404,14 +693,15 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
404
693
  larkAppId,
405
694
  chatId: task.chatId,
406
695
  chatType: task.chatType === 'p2p' ? 'p2p' : 'group',
407
- spawnedAt: Date.now(),
696
+ scope: runtimeScope,
697
+ spawnedAt: sessionCreatedAtMs(session),
408
698
  cliVersion: getCurrentCliVersion(),
409
- lastMessageAt: Date.now(),
410
- hasHistory: isContinuation, // continuation sessions inherit the old thread's context
699
+ lastMessageAt: now,
700
+ hasHistory: isContinuation,
411
701
  workingDir: task.workingDir,
412
702
  };
413
- activeSessions.set(sessionKey(threadRootId, larkAppId), ds);
703
+ activeSessions.set(sessionKey(anchor, larkAppId), ds);
414
704
  forkWorker(ds, prompt);
415
- logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, thread: ${threadRootId}, continuation: ${isContinuation})`);
705
+ logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${scope}, anchor: ${anchor}, continuation: ${isContinuation})`);
416
706
  }
417
707
  //# sourceMappingURL=session-manager.js.map