botmux 2.9.0 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.en.md +140 -76
  2. package/README.md +134 -75
  3. package/dist/adapters/backend/pty-backend.d.ts +6 -0
  4. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/pty-backend.js +10 -0
  6. package/dist/adapters/backend/pty-backend.js.map +1 -1
  7. package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
  8. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
  9. package/dist/adapters/backend/session-backend-selector.js +26 -0
  10. package/dist/adapters/backend/session-backend-selector.js.map +1 -0
  11. package/dist/adapters/backend/tmux-backend.d.ts +80 -3
  12. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.js +301 -49
  14. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  15. package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
  16. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
  17. package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
  19. package/dist/adapters/cli/aiden.d.ts.map +1 -1
  20. package/dist/adapters/cli/aiden.js +5 -0
  21. package/dist/adapters/cli/aiden.js.map +1 -1
  22. package/dist/adapters/cli/claude-code.d.ts +40 -1
  23. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  24. package/dist/adapters/cli/claude-code.js +470 -49
  25. package/dist/adapters/cli/claude-code.js.map +1 -1
  26. package/dist/adapters/cli/coco.d.ts.map +1 -1
  27. package/dist/adapters/cli/coco.js +191 -9
  28. package/dist/adapters/cli/coco.js.map +1 -1
  29. package/dist/adapters/cli/codex.d.ts.map +1 -1
  30. package/dist/adapters/cli/codex.js +94 -17
  31. package/dist/adapters/cli/codex.js.map +1 -1
  32. package/dist/adapters/cli/shared-hints.d.ts +2 -2
  33. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  34. package/dist/adapters/cli/shared-hints.js +7 -5
  35. package/dist/adapters/cli/shared-hints.js.map +1 -1
  36. package/dist/adapters/cli/types.d.ts +38 -1
  37. package/dist/adapters/cli/types.d.ts.map +1 -1
  38. package/dist/autostart.d.ts +14 -0
  39. package/dist/autostart.d.ts.map +1 -0
  40. package/dist/autostart.js +357 -0
  41. package/dist/autostart.js.map +1 -0
  42. package/dist/bot-registry.d.ts +29 -3
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +91 -12
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli/arg-utils.d.ts +11 -0
  47. package/dist/cli/arg-utils.d.ts.map +1 -0
  48. package/dist/cli/arg-utils.js +25 -0
  49. package/dist/cli/arg-utils.js.map +1 -0
  50. package/dist/cli/create-group-resolver.d.ts +32 -0
  51. package/dist/cli/create-group-resolver.d.ts.map +1 -0
  52. package/dist/cli/create-group-resolver.js +70 -0
  53. package/dist/cli/create-group-resolver.js.map +1 -0
  54. package/dist/cli/quoted-render.d.ts +30 -0
  55. package/dist/cli/quoted-render.d.ts.map +1 -0
  56. package/dist/cli/quoted-render.js +29 -0
  57. package/dist/cli/quoted-render.js.map +1 -0
  58. package/dist/cli.js +916 -272
  59. package/dist/cli.js.map +1 -1
  60. package/dist/config.d.ts +6 -0
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +18 -8
  63. package/dist/config.js.map +1 -1
  64. package/dist/core/command-handler.d.ts +43 -0
  65. package/dist/core/command-handler.d.ts.map +1 -1
  66. package/dist/core/command-handler.js +167 -64
  67. package/dist/core/command-handler.js.map +1 -1
  68. package/dist/core/dashboard-events.d.ts +57 -0
  69. package/dist/core/dashboard-events.d.ts.map +1 -0
  70. package/dist/core/dashboard-events.js +23 -0
  71. package/dist/core/dashboard-events.js.map +1 -0
  72. package/dist/core/dashboard-ipc-server.d.ts +43 -0
  73. package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
  74. package/dist/core/dashboard-ipc-server.js +481 -0
  75. package/dist/core/dashboard-ipc-server.js.map +1 -0
  76. package/dist/core/dashboard-locate.d.ts +20 -0
  77. package/dist/core/dashboard-locate.d.ts.map +1 -0
  78. package/dist/core/dashboard-locate.js +26 -0
  79. package/dist/core/dashboard-locate.js.map +1 -0
  80. package/dist/core/dashboard-rows.d.ts +31 -0
  81. package/dist/core/dashboard-rows.d.ts.map +1 -0
  82. package/dist/core/dashboard-rows.js +65 -0
  83. package/dist/core/dashboard-rows.js.map +1 -0
  84. package/dist/core/inherit-peer.d.ts +14 -0
  85. package/dist/core/inherit-peer.d.ts.map +1 -0
  86. package/dist/core/inherit-peer.js +32 -0
  87. package/dist/core/inherit-peer.js.map +1 -0
  88. package/dist/core/scheduler.d.ts +24 -0
  89. package/dist/core/scheduler.d.ts.map +1 -1
  90. package/dist/core/scheduler.js +93 -2
  91. package/dist/core/scheduler.js.map +1 -1
  92. package/dist/core/session-activity.d.ts +3 -0
  93. package/dist/core/session-activity.d.ts.map +1 -0
  94. package/dist/core/session-activity.js +20 -0
  95. package/dist/core/session-activity.js.map +1 -0
  96. package/dist/core/session-discovery.d.ts +39 -0
  97. package/dist/core/session-discovery.d.ts.map +1 -1
  98. package/dist/core/session-discovery.js +114 -21
  99. package/dist/core/session-discovery.js.map +1 -1
  100. package/dist/core/session-manager.d.ts +72 -0
  101. package/dist/core/session-manager.d.ts.map +1 -1
  102. package/dist/core/session-manager.js +396 -106
  103. package/dist/core/session-manager.js.map +1 -1
  104. package/dist/core/types.d.ts +27 -2
  105. package/dist/core/types.d.ts.map +1 -1
  106. package/dist/core/types.js +14 -3
  107. package/dist/core/types.js.map +1 -1
  108. package/dist/core/worker-pool.d.ts +72 -3
  109. package/dist/core/worker-pool.d.ts.map +1 -1
  110. package/dist/core/worker-pool.js +459 -38
  111. package/dist/core/worker-pool.js.map +1 -1
  112. package/dist/daemon.d.ts.map +1 -1
  113. package/dist/daemon.js +601 -309
  114. package/dist/daemon.js.map +1 -1
  115. package/dist/dashboard/aggregator.d.ts +41 -0
  116. package/dist/dashboard/aggregator.d.ts.map +1 -0
  117. package/dist/dashboard/aggregator.js +125 -0
  118. package/dist/dashboard/aggregator.js.map +1 -0
  119. package/dist/dashboard/auth.d.ts +23 -0
  120. package/dist/dashboard/auth.d.ts.map +1 -0
  121. package/dist/dashboard/auth.js +66 -0
  122. package/dist/dashboard/auth.js.map +1 -0
  123. package/dist/dashboard/operator-selector.d.ts +20 -0
  124. package/dist/dashboard/operator-selector.d.ts.map +1 -0
  125. package/dist/dashboard/operator-selector.js +39 -0
  126. package/dist/dashboard/operator-selector.js.map +1 -0
  127. package/dist/dashboard/registry.d.ts +35 -0
  128. package/dist/dashboard/registry.d.ts.map +1 -0
  129. package/dist/dashboard/registry.js +74 -0
  130. package/dist/dashboard/registry.js.map +1 -0
  131. package/dist/dashboard/web/app.d.ts +2 -0
  132. package/dist/dashboard/web/app.d.ts.map +1 -0
  133. package/dist/dashboard/web/app.js +45 -0
  134. package/dist/dashboard/web/app.js.map +1 -0
  135. package/dist/dashboard/web/bot-defaults.d.ts +2 -0
  136. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
  137. package/dist/dashboard/web/bot-defaults.js +201 -0
  138. package/dist/dashboard/web/bot-defaults.js.map +1 -0
  139. package/dist/dashboard/web/groups.d.ts +16 -0
  140. package/dist/dashboard/web/groups.d.ts.map +1 -0
  141. package/dist/dashboard/web/groups.js +584 -0
  142. package/dist/dashboard/web/groups.js.map +1 -0
  143. package/dist/dashboard/web/schedules.d.ts +2 -0
  144. package/dist/dashboard/web/schedules.d.ts.map +1 -0
  145. package/dist/dashboard/web/schedules.js +105 -0
  146. package/dist/dashboard/web/schedules.js.map +1 -0
  147. package/dist/dashboard/web/sessions.d.ts +2 -0
  148. package/dist/dashboard/web/sessions.d.ts.map +1 -0
  149. package/dist/dashboard/web/sessions.js +374 -0
  150. package/dist/dashboard/web/sessions.js.map +1 -0
  151. package/dist/dashboard/web/store.d.ts +23 -0
  152. package/dist/dashboard/web/store.d.ts.map +1 -0
  153. package/dist/dashboard/web/store.js +82 -0
  154. package/dist/dashboard/web/store.js.map +1 -0
  155. package/dist/dashboard-web/app.js +263 -0
  156. package/dist/dashboard-web/index.html +23 -0
  157. package/dist/dashboard-web/style.css +93 -0
  158. package/dist/dashboard.d.ts +2 -0
  159. package/dist/dashboard.d.ts.map +1 -0
  160. package/dist/dashboard.js +639 -0
  161. package/dist/dashboard.js.map +1 -0
  162. package/dist/im/lark/card-builder.d.ts +18 -1
  163. package/dist/im/lark/card-builder.d.ts.map +1 -1
  164. package/dist/im/lark/card-builder.js +70 -9
  165. package/dist/im/lark/card-builder.js.map +1 -1
  166. package/dist/im/lark/card-handler.d.ts.map +1 -1
  167. package/dist/im/lark/card-handler.js +123 -109
  168. package/dist/im/lark/card-handler.js.map +1 -1
  169. package/dist/im/lark/client.d.ts +36 -0
  170. package/dist/im/lark/client.d.ts.map +1 -1
  171. package/dist/im/lark/client.js +119 -13
  172. package/dist/im/lark/client.js.map +1 -1
  173. package/dist/im/lark/event-dispatcher.d.ts +92 -8
  174. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  175. package/dist/im/lark/event-dispatcher.js +410 -89
  176. package/dist/im/lark/event-dispatcher.js.map +1 -1
  177. package/dist/im/lark/forwarded-renderer.d.ts +23 -0
  178. package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
  179. package/dist/im/lark/forwarded-renderer.js +105 -0
  180. package/dist/im/lark/forwarded-renderer.js.map +1 -0
  181. package/dist/im/lark/md-card.d.ts +73 -0
  182. package/dist/im/lark/md-card.d.ts.map +1 -0
  183. package/dist/im/lark/md-card.js +332 -0
  184. package/dist/im/lark/md-card.js.map +1 -0
  185. package/dist/im/lark/merge-forward.d.ts +32 -0
  186. package/dist/im/lark/merge-forward.d.ts.map +1 -0
  187. package/dist/im/lark/merge-forward.js +110 -0
  188. package/dist/im/lark/merge-forward.js.map +1 -0
  189. package/dist/im/lark/message-parser.d.ts +9 -3
  190. package/dist/im/lark/message-parser.d.ts.map +1 -1
  191. package/dist/im/lark/message-parser.js +48 -13
  192. package/dist/im/lark/message-parser.js.map +1 -1
  193. package/dist/im/lark/quote-hint.d.ts +18 -0
  194. package/dist/im/lark/quote-hint.d.ts.map +1 -0
  195. package/dist/im/lark/quote-hint.js +23 -0
  196. package/dist/im/lark/quote-hint.js.map +1 -0
  197. package/dist/services/bridge-fallback-gate.d.ts +42 -0
  198. package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
  199. package/dist/services/bridge-fallback-gate.js +12 -0
  200. package/dist/services/bridge-fallback-gate.js.map +1 -0
  201. package/dist/services/bridge-rotation-policy.d.ts +139 -0
  202. package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
  203. package/dist/services/bridge-rotation-policy.js +125 -0
  204. package/dist/services/bridge-rotation-policy.js.map +1 -0
  205. package/dist/services/bridge-turn-queue.d.ts +154 -0
  206. package/dist/services/bridge-turn-queue.d.ts.map +1 -0
  207. package/dist/services/bridge-turn-queue.js +316 -0
  208. package/dist/services/bridge-turn-queue.js.map +1 -0
  209. package/dist/services/chat-first-seen-store.d.ts +27 -0
  210. package/dist/services/chat-first-seen-store.d.ts.map +1 -0
  211. package/dist/services/chat-first-seen-store.js +114 -0
  212. package/dist/services/chat-first-seen-store.js.map +1 -0
  213. package/dist/services/claude-transcript.d.ts +268 -0
  214. package/dist/services/claude-transcript.d.ts.map +1 -0
  215. package/dist/services/claude-transcript.js +798 -0
  216. package/dist/services/claude-transcript.js.map +1 -0
  217. package/dist/services/coco-transcript.d.ts +35 -0
  218. package/dist/services/coco-transcript.d.ts.map +1 -0
  219. package/dist/services/coco-transcript.js +192 -0
  220. package/dist/services/coco-transcript.js.map +1 -0
  221. package/dist/services/codex-bridge-queue.d.ts +56 -0
  222. package/dist/services/codex-bridge-queue.d.ts.map +1 -0
  223. package/dist/services/codex-bridge-queue.js +150 -0
  224. package/dist/services/codex-bridge-queue.js.map +1 -0
  225. package/dist/services/codex-transcript.d.ts +84 -0
  226. package/dist/services/codex-transcript.d.ts.map +1 -0
  227. package/dist/services/codex-transcript.js +298 -0
  228. package/dist/services/codex-transcript.js.map +1 -0
  229. package/dist/services/group-creator.d.ts +23 -0
  230. package/dist/services/group-creator.d.ts.map +1 -0
  231. package/dist/services/group-creator.js +75 -0
  232. package/dist/services/group-creator.js.map +1 -0
  233. package/dist/services/groups-store.d.ts +98 -0
  234. package/dist/services/groups-store.d.ts.map +1 -0
  235. package/dist/services/groups-store.js +213 -0
  236. package/dist/services/groups-store.js.map +1 -0
  237. package/dist/services/oncall-store.d.ts +80 -8
  238. package/dist/services/oncall-store.d.ts.map +1 -1
  239. package/dist/services/oncall-store.js +265 -55
  240. package/dist/services/oncall-store.js.map +1 -1
  241. package/dist/services/project-scanner.d.ts +1 -2
  242. package/dist/services/project-scanner.d.ts.map +1 -1
  243. package/dist/services/project-scanner.js +118 -68
  244. package/dist/services/project-scanner.js.map +1 -1
  245. package/dist/services/schedule-store.d.ts +5 -0
  246. package/dist/services/schedule-store.d.ts.map +1 -1
  247. package/dist/services/schedule-store.js +77 -1
  248. package/dist/services/schedule-store.js.map +1 -1
  249. package/dist/services/session-store.d.ts +22 -0
  250. package/dist/services/session-store.d.ts.map +1 -1
  251. package/dist/services/session-store.js +62 -4
  252. package/dist/services/session-store.js.map +1 -1
  253. package/dist/setup/bots-store.d.ts +3 -0
  254. package/dist/setup/bots-store.d.ts.map +1 -0
  255. package/dist/setup/bots-store.js +24 -0
  256. package/dist/setup/bots-store.js.map +1 -0
  257. package/dist/setup/detect-platform.d.ts +14 -0
  258. package/dist/setup/detect-platform.d.ts.map +1 -0
  259. package/dist/setup/detect-platform.js +139 -0
  260. package/dist/setup/detect-platform.js.map +1 -0
  261. package/dist/setup/ensure-fonts.d.ts +13 -0
  262. package/dist/setup/ensure-fonts.d.ts.map +1 -0
  263. package/dist/setup/ensure-fonts.js +225 -0
  264. package/dist/setup/ensure-fonts.js.map +1 -0
  265. package/dist/setup/ensure-tmux.d.ts +60 -0
  266. package/dist/setup/ensure-tmux.d.ts.map +1 -0
  267. package/dist/setup/ensure-tmux.js +236 -0
  268. package/dist/setup/ensure-tmux.js.map +1 -0
  269. package/dist/setup/index.d.ts +9 -0
  270. package/dist/setup/index.d.ts.map +1 -0
  271. package/dist/setup/index.js +46 -0
  272. package/dist/setup/index.js.map +1 -0
  273. package/dist/setup/lark-scopes.json +301 -0
  274. package/dist/setup/register-app.d.ts +52 -0
  275. package/dist/setup/register-app.d.ts.map +1 -0
  276. package/dist/setup/register-app.js +91 -0
  277. package/dist/setup/register-app.js.map +1 -0
  278. package/dist/setup/verify-permissions.d.ts +115 -0
  279. package/dist/setup/verify-permissions.d.ts.map +1 -0
  280. package/dist/setup/verify-permissions.js +207 -0
  281. package/dist/setup/verify-permissions.js.map +1 -0
  282. package/dist/skills/definitions.d.ts +4 -0
  283. package/dist/skills/definitions.d.ts.map +1 -1
  284. package/dist/skills/definitions.js +133 -19
  285. package/dist/skills/definitions.js.map +1 -1
  286. package/dist/skills/installer.d.ts +3 -1
  287. package/dist/skills/installer.d.ts.map +1 -1
  288. package/dist/skills/installer.js +18 -3
  289. package/dist/skills/installer.js.map +1 -1
  290. package/dist/types.d.ts +44 -0
  291. package/dist/types.d.ts.map +1 -1
  292. package/dist/utils/bot-routing.d.ts +6 -0
  293. package/dist/utils/bot-routing.d.ts.map +1 -0
  294. package/dist/utils/bot-routing.js +50 -0
  295. package/dist/utils/bot-routing.js.map +1 -0
  296. package/dist/utils/file-lock.d.ts +2 -0
  297. package/dist/utils/file-lock.d.ts.map +1 -0
  298. package/dist/utils/file-lock.js +114 -0
  299. package/dist/utils/file-lock.js.map +1 -0
  300. package/dist/utils/font-installer.js +1 -1
  301. package/dist/utils/font-installer.js.map +1 -1
  302. package/dist/utils/idle-detector.d.ts +6 -0
  303. package/dist/utils/idle-detector.d.ts.map +1 -1
  304. package/dist/utils/idle-detector.js +25 -4
  305. package/dist/utils/idle-detector.js.map +1 -1
  306. package/dist/utils/logger.d.ts +10 -0
  307. package/dist/utils/logger.d.ts.map +1 -1
  308. package/dist/utils/logger.js +60 -8
  309. package/dist/utils/logger.js.map +1 -1
  310. package/dist/utils/render-dimensions.d.ts +48 -0
  311. package/dist/utils/render-dimensions.d.ts.map +1 -0
  312. package/dist/utils/render-dimensions.js +55 -0
  313. package/dist/utils/render-dimensions.js.map +1 -0
  314. package/dist/utils/screen-analyzer.d.ts.map +1 -1
  315. package/dist/utils/screen-analyzer.js +24 -0
  316. package/dist/utils/screen-analyzer.js.map +1 -1
  317. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  318. package/dist/utils/screenshot-renderer.js +67 -23
  319. package/dist/utils/screenshot-renderer.js.map +1 -1
  320. package/dist/utils/terminal-renderer.d.ts +16 -0
  321. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  322. package/dist/utils/terminal-renderer.js +40 -23
  323. package/dist/utils/terminal-renderer.js.map +1 -1
  324. package/dist/utils/transient-snapshot.d.ts +28 -0
  325. package/dist/utils/transient-snapshot.d.ts.map +1 -0
  326. package/dist/utils/transient-snapshot.js +96 -0
  327. package/dist/utils/transient-snapshot.js.map +1 -0
  328. package/dist/worker.js +2220 -83
  329. package/dist/worker.js.map +1 -1
  330. package/package.json +12 -5
@@ -12,12 +12,18 @@ import { randomBytes } from 'node:crypto';
12
12
  import { config } from '../config.js';
13
13
  import * as sessionStore from '../services/session-store.js';
14
14
  import { persistStreamCardState } from './session-manager.js';
15
- import { updateMessage, MessageWithdrawnError } from '../im/lark/client.js';
15
+ import { updateMessage, deleteMessage, MessageWithdrawnError } from '../im/lark/client.js';
16
16
  import { buildStreamingCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, getCliDisplayName } from '../im/lark/card-builder.js';
17
+ import { loadFrozenCards, saveFrozenCards } from '../services/frozen-card-store.js';
17
18
  import { logger } from '../utils/logger.js';
18
19
  import { createCliAdapterSync } from '../adapters/cli/registry.js';
20
+ import { claudeJsonlPathForSession } from '../adapters/cli/claude-code.js';
21
+ import { buildMarkdownCard, buildContextualReplyCard } from '../im/lark/md-card.js';
19
22
  import { TmuxBackend } from '../adapters/backend/tmux-backend.js';
20
23
  import { getBot, getAllBots } from '../bot-registry.js';
24
+ import { dashboardEventBus } from './dashboard-events.js';
25
+ import { composeRowFromActive } from './dashboard-rows.js';
26
+ import { sessionKey, sessionAnchorId } from './types.js';
21
27
  const __filename = fileURLToPath(import.meta.url);
22
28
  const __dirname = dirname(__filename);
23
29
  let callbacks;
@@ -32,14 +38,124 @@ function requireCallbacks() {
32
38
  throw new Error('WorkerPool not initialised — call initWorkerPool() first');
33
39
  return callbacks;
34
40
  }
41
+ // ─── Active session registry (daemon-owned, accessor for IPC) ───────────────
42
+ // The activeSessions Map physically lives in daemon.ts. To let the dashboard
43
+ // IPC server (and other modules) read it without reaching back into daemon, the
44
+ // daemon registers its Map here at boot. Helpers below return a snapshot or
45
+ // linear-scan by sessionId.
46
+ let activeSessionsRegistry;
47
+ export function setActiveSessionsRegistry(m) {
48
+ activeSessionsRegistry = m;
49
+ }
50
+ export function listActiveSessions() {
51
+ return activeSessionsRegistry ? [...activeSessionsRegistry.values()] : [];
52
+ }
53
+ /** Linear-scan lookup of the active-sessions Map by `Session.sessionId`.
54
+ * The Map's actual key is `sessionKey(rootId, larkAppId)` (composite), so we
55
+ * cannot use Map.get here. */
56
+ export function findActiveBySessionId(sessionId) {
57
+ if (!activeSessionsRegistry)
58
+ return undefined;
59
+ for (const s of activeSessionsRegistry.values())
60
+ if (s.session.sessionId === sessionId)
61
+ return s;
62
+ return undefined;
63
+ }
64
+ /** Direct access to the active-sessions Map. Reserved for callers that need
65
+ * to mutate (e.g. resumeSession reactivating a closed record); read-only
66
+ * callers should prefer listActiveSessions / findActiveBySessionId. */
67
+ export function getActiveSessionsRegistry() {
68
+ return activeSessionsRegistry;
69
+ }
35
70
  // ─── Helpers ────────────────────────────────────────────────────────────────
36
71
  function tag(ds) {
37
72
  return ds.session.sessionId.substring(0, 8);
38
73
  }
74
+ const WORKER_ERROR_MARKER = '[botmux-worker-error]';
75
+ function logWorkerStderr(t, line) {
76
+ if (!line)
77
+ return;
78
+ const taggedLine = `[${t}:err] ${line}`;
79
+ if (line.includes(WORKER_ERROR_MARKER)) {
80
+ logger.error(taggedLine);
81
+ return;
82
+ }
83
+ logger.info(taggedLine);
84
+ }
39
85
  // Sentinel value for streamCardId while a POST (new card) is in-flight.
40
86
  // Prevents duplicate card POSTs when multiple screen_updates arrive before
41
87
  // the first POST returns a real message_id.
42
88
  export const CARD_POSTING_SENTINEL = '__posting__';
89
+ /**
90
+ * Move the current streaming card into `frozenCards` without freezing it
91
+ * cosmetically. The next successful card POST will sweep it via
92
+ * `recallFrozenCards`. Used on paths that bypass the normal freeze step
93
+ * (worker dead before a new turn, repo switch tearing down the session) so
94
+ * we never delete the only visible card before its successor exists — if
95
+ * fork / worker_ready / POST fails, the parked card stays in the thread.
96
+ *
97
+ * Lazy-loads `frozenCards` from disk if the in-memory Map is missing
98
+ * (post daemon-restart, before any card-handler action has loaded it).
99
+ * Without this, parking would synthesize an empty Map and the subsequent
100
+ * `saveFrozenCards` would overwrite earlier turns' entries on disk —
101
+ * stranding their cards in the thread with no way to recall them.
102
+ *
103
+ * No-op when there is no live card to park.
104
+ */
105
+ export function parkStreamCard(ds) {
106
+ if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL)
107
+ return;
108
+ if (!ds.streamCardNonce)
109
+ return;
110
+ if (!ds.frozenCards)
111
+ ds.frozenCards = loadFrozenCards(ds.session.sessionId);
112
+ ds.frozenCards.set(ds.streamCardNonce, {
113
+ messageId: ds.streamCardId,
114
+ content: ds.lastScreenContent ?? '',
115
+ title: ds.currentTurnTitle ?? '',
116
+ displayMode: ds.displayMode ?? 'hidden',
117
+ imageKey: ds.currentImageKey,
118
+ });
119
+ saveFrozenCards(ds.session.sessionId, ds.frozenCards);
120
+ }
121
+ /**
122
+ * Delete previously-frozen streaming cards from Lark and clear the cache.
123
+ * Called whenever a new streaming card becomes the active one — old turns'
124
+ * cards just add visual clutter when scrolling thread history.
125
+ *
126
+ * Lazy-loads `frozenCards` from disk if the in-memory Map is missing
127
+ * (post daemon-restart). Best-effort delete; failures (already withdrawn,
128
+ * expired) are non-fatal.
129
+ *
130
+ * Skips any entry whose messageId matches `ds.streamCardId` — guards the
131
+ * daemon-restart window where a turn was frozen (entry persisted to disk)
132
+ * but a new card was never POSTed before the crash. After restart the same
133
+ * messageId is the live `streamCardId` again, and recalling it would delete
134
+ * the only card the user can see.
135
+ */
136
+ export function recallFrozenCards(ds) {
137
+ if (!ds.frozenCards)
138
+ ds.frozenCards = loadFrozenCards(ds.session.sessionId);
139
+ if (ds.frozenCards.size === 0)
140
+ return;
141
+ const activeId = ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL
142
+ ? ds.streamCardId
143
+ : undefined;
144
+ const targets = [];
145
+ for (const [nonce, fc] of [...ds.frozenCards.entries()]) {
146
+ if (activeId && fc.messageId === activeId)
147
+ continue;
148
+ targets.push(fc.messageId);
149
+ ds.frozenCards.delete(nonce);
150
+ }
151
+ if (targets.length === 0)
152
+ return;
153
+ saveFrozenCards(ds.session.sessionId, ds.frozenCards);
154
+ for (const messageId of targets) {
155
+ deleteMessage(ds.larkAppId, messageId).catch(() => { });
156
+ }
157
+ logger.info(`[${tag(ds)}] Recalled ${targets.length} previous streaming card(s)`);
158
+ }
43
159
  // ─── Card PATCH serialization queue ─────────────────────────────────────────
44
160
  // Only one PATCH in-flight at a time per session. New PATCHes queue on
45
161
  // ds.pendingCardJson (latest wins). When the in-flight PATCH completes,
@@ -74,9 +190,21 @@ function flushCardPatch(ds) {
74
190
  updateMessage(ds.larkAppId, cardId, json)
75
191
  .catch(err => {
76
192
  if (err instanceof MessageWithdrawnError) {
77
- logger.warn(`[${tag(ds)}] Stream card withdrawn, clearing reference`);
78
- ds.streamCardId = undefined;
79
- persistStreamCardState(ds);
193
+ // Only clear streamCardId when the withdrawn message is still the
194
+ // active one. With auto-recall a new turn may have advanced
195
+ // ds.streamCardId past `cardId` while this PATCH was in flight (the
196
+ // recall on the new POST deletes the previous card, which surfaces
197
+ // here as MessageWithdrawnError). Clearing unconditionally would
198
+ // forget the live new card and trigger a duplicate POST on the next
199
+ // screen_update.
200
+ if (ds.streamCardId === cardId) {
201
+ logger.warn(`[${tag(ds)}] Stream card withdrawn, clearing reference`);
202
+ ds.streamCardId = undefined;
203
+ persistStreamCardState(ds);
204
+ }
205
+ else {
206
+ logger.debug(`[${tag(ds)}] Stale card ${cardId.substring(0, 12)} withdrawn (current: ${ds.streamCardId?.substring(0, 12) ?? 'none'})`);
207
+ }
80
208
  return;
81
209
  }
82
210
  logger.debug(`[${tag(ds)}] Failed to update streaming card: ${err}`);
@@ -228,6 +356,51 @@ export function killWorker(ds) {
228
356
  ds.workerPort = null;
229
357
  ds.workerToken = null;
230
358
  }
359
+ // ─── Idempotent session close (dashboard IPC) ───────────────────────────────
360
+ /**
361
+ * Idempotent close: kill worker if alive, mark Session status='closed' + closedAt,
362
+ * publish session.exited (if a live worker was killed) and session.update
363
+ * (if the persistence row transitioned to closed).
364
+ *
365
+ * Calling this on an unknown sessionId, an already-closed session, or a session
366
+ * whose worker died asynchronously must still resolve with `{ ok: true }`.
367
+ */
368
+ export async function closeSession(sessionId) {
369
+ const ds = findActiveBySessionId(sessionId);
370
+ let killedLive = false;
371
+ if (ds) {
372
+ killWorker(ds);
373
+ activeSessionsRegistry?.delete(sessionKey(sessionAnchorId(ds), ds.larkAppId));
374
+ killedLive = true;
375
+ if (!ds.exitEventEmitted) {
376
+ ds.exitEventEmitted = true;
377
+ dashboardEventBus.publish({
378
+ type: 'session.exited',
379
+ body: { sessionId, reason: 'dashboard_close' },
380
+ });
381
+ }
382
+ }
383
+ // Persistence path — load → mark closed → save (delegated to sessionStore).
384
+ const stored = sessionStore.getSession(sessionId);
385
+ const wasOpen = !!stored && stored.status !== 'closed';
386
+ if (wasOpen) {
387
+ sessionStore.closeSession(sessionId);
388
+ const after = sessionStore.getSession(sessionId);
389
+ dashboardEventBus.publish({
390
+ type: 'session.update',
391
+ body: {
392
+ sessionId,
393
+ patch: {
394
+ status: 'closed',
395
+ closedAt: after?.closedAt ? Date.parse(after.closedAt) : Date.now(),
396
+ },
397
+ },
398
+ });
399
+ }
400
+ // alreadyClosed = nothing happened on either path.
401
+ const alreadyClosed = !killedLive && !wasOpen;
402
+ return { ok: true, alreadyClosed };
403
+ }
231
404
  // ─── Fork worker ────────────────────────────────────────────────────────────
232
405
  export function forkWorker(ds, prompt, resume = false) {
233
406
  const cb = requireCallbacks();
@@ -264,13 +437,18 @@ export function forkWorker(ds, prompt, resume = false) {
264
437
  ...process.env,
265
438
  PATH: pathWithBotmux,
266
439
  CLAUDECODE: undefined,
267
- BOTMUX: '1', // Inherited by CLI MCP server for session detection
440
+ BOTMUX: '1', // Marker so user scripts/skills can detect a botmux-spawned CLI
268
441
  SESSION_DATA_DIR: config.session.dataDir,
269
442
  LARK_APP_ID: botCfg.larkAppId,
270
443
  LARK_APP_SECRET: botCfg.larkAppSecret,
271
444
  },
272
445
  });
273
- // Pipe worker stdout/stderr to daemon logger
446
+ // Pipe worker stdout/stderr to daemon logger.
447
+ // Both go through logger.info → daemon.log (not error.log). Worker stderr
448
+ // is NOT necessarily an error: CLI adapters (claude, codex, etc.) write
449
+ // progress, version banners, deprecation warnings, etc. there. The line
450
+ // is still visible (tagged `:err`) for triage. Real worker faults arrive
451
+ // separately via the IPC `Worker error` branch and stay as logger.error.
274
452
  worker.stdout?.on('data', (data) => {
275
453
  for (const line of data.toString().split('\n')) {
276
454
  const trimmed = line.trim();
@@ -280,9 +458,7 @@ export function forkWorker(ds, prompt, resume = false) {
280
458
  });
281
459
  worker.stderr?.on('data', (data) => {
282
460
  for (const line of data.toString().split('\n')) {
283
- const trimmed = line.trim();
284
- if (trimmed)
285
- logger.error(`[${t}:worker] ${trimmed}`);
461
+ logWorkerStderr(t, line.trim());
286
462
  }
287
463
  });
288
464
  // Send init config — use per-bot settings
@@ -290,13 +466,14 @@ export function forkWorker(ds, prompt, resume = false) {
290
466
  type: 'init',
291
467
  sessionId: ds.session.sessionId,
292
468
  chatId: ds.chatId,
293
- rootMessageId: ds.session.rootMessageId,
469
+ rootMessageId: sessionAnchorId(ds),
294
470
  workingDir: cwd,
295
471
  cliId: botCfg.cliId,
296
472
  cliPathOverride: botCfg.cliPathOverride,
297
473
  backendType: botCfg.backendType ?? config.daemon.backendType,
298
474
  prompt,
299
475
  resume,
476
+ cliSessionId: ds.session.cliSessionId,
300
477
  ownerOpenId: ds.ownerOpenId,
301
478
  webPort: ds.session.webPort,
302
479
  larkAppId: botCfg.larkAppId,
@@ -311,8 +488,23 @@ export function forkWorker(ds, prompt, resume = false) {
311
488
  ds.worker = worker;
312
489
  ds.spawnedAt = Date.now();
313
490
  ds.cliVersion = currentCliVersion;
491
+ // Stamp cliId on the persisted session so the dashboard can show a CLI badge
492
+ // even after the session is closed. Subsequent updateSession spreads carry
493
+ // this field forward for free.
494
+ if (ds.session.cliId !== botCfg.cliId) {
495
+ ds.session.cliId = botCfg.cliId;
496
+ sessionStore.updateSession(ds.session);
497
+ }
314
498
  sessionStore.updateSessionPid(ds.session.sessionId, worker.pid ?? null);
315
499
  logger.info(`[${t}] Worker forked (pid: ${worker.pid}, active: ${cb.getActiveCount()})`);
500
+ // Reset the exit-emit flag for the freshly spawned worker so a subsequent
501
+ // exit publishes again (the previous lifecycle's flag would otherwise mask it).
502
+ ds.exitEventEmitted = false;
503
+ // Notify dashboard SSE subscribers a new session is live.
504
+ dashboardEventBus.publish({
505
+ type: 'session.spawned',
506
+ body: { session: composeRowFromActive(ds) },
507
+ });
316
508
  }
317
509
  // ─── Shared worker IPC handler ──────────────────────────────────────────────
318
510
  function setupWorkerHandlers(ds, worker) {
@@ -320,9 +512,15 @@ function setupWorkerHandlers(ds, worker) {
320
512
  const t = tag(ds);
321
513
  const bot = getBot(ds.larkAppId);
322
514
  const botCfg = bot.config;
323
- // Adopt mode flags — computed once, used in all buildStreamingCard calls
515
+ // Adopt mode flags — computed once, used in all buildStreamingCard calls.
516
+ // Bridge mode (the v3 default for /adopt) hides the legacy takeover
517
+ // button: the model never sees botmux, daemon harvests final output via
518
+ // the transcript watcher, and the old takeover path would SIGKILL the
519
+ // user's original CLI 1.5s after fork — incompatible with bridge intent.
520
+ // Explicit takeover will be re-introduced as `/adopt --takeover` in a
521
+ // follow-up patch with safe semantics (no implicit kill).
324
522
  const isAdopt = !!ds.adoptedFrom;
325
- const showTakeover = isAdopt && !!ds.adoptedFrom?.sessionId;
523
+ const showTakeover = false;
326
524
  worker.on('message', async (msg) => {
327
525
  switch (msg.type) {
328
526
  case 'ready': {
@@ -334,6 +532,14 @@ function setupWorkerHandlers(ds, worker) {
334
532
  const readOnlyUrl = `http://${config.web.externalHost}:${msg.port}`;
335
533
  const writeUrl = `${readOnlyUrl}?token=${msg.token}`;
336
534
  logger.info(`[${t}] Worker ready, terminal at ${readOnlyUrl}`);
535
+ // Dashboard: surface the new xterm port so the live terminal link works.
536
+ dashboardEventBus.publish({
537
+ type: 'session.update',
538
+ body: {
539
+ sessionId: ds.session.sessionId,
540
+ patch: { webPort: msg.port },
541
+ },
542
+ });
337
543
  // If a previous streaming card survived (e.g. daemon restart), try to
338
544
  // PATCH it with the new "starting" state instead of POSTing a fresh card.
339
545
  // ds.streamCardPending forces a new card (e.g. mid-session repo switch
@@ -347,13 +553,17 @@ function setupWorkerHandlers(ds, worker) {
347
553
  // Reuse persisted nonce so existing card buttons (toggle/etc) keep working.
348
554
  if (!ds.streamCardNonce)
349
555
  ds.streamCardNonce = randomBytes(4).toString('hex');
350
- const streamCardJson = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readOnlyUrl, initTitle, ds.lastScreenContent ?? '', 'starting', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
556
+ const streamCardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, initTitle, ds.lastScreenContent ?? '', 'starting', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
351
557
  await updateMessage(ds.larkAppId, restoredCardId, streamCardJson);
352
558
  persistStreamCardState(ds);
353
559
  // Re-sync worker's display mode (it starts fresh in 'hidden')
354
560
  if (ds.worker && ds.displayMode && ds.displayMode !== 'hidden') {
355
561
  ds.worker.send({ type: 'set_display_mode', mode: ds.displayMode });
356
562
  }
563
+ // The restored card is now the active one — withdraw any cards
564
+ // frozen before the daemon went down so they don't pile up in the
565
+ // thread on each restart.
566
+ recallFrozenCards(ds);
357
567
  logger.info(`[${t}] Reused existing streaming card ${restoredCardId.substring(0, 12)} after worker (re)start`);
358
568
  break;
359
569
  }
@@ -371,9 +581,13 @@ function setupWorkerHandlers(ds, worker) {
371
581
  try {
372
582
  ds.streamCardNonce = randomBytes(4).toString('hex');
373
583
  const initTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
374
- const streamCardJson = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readOnlyUrl, initTitle, '', 'starting', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
375
- ds.streamCardId = await cb.sessionReply(ds.session.rootMessageId, streamCardJson, 'interactive', ds.larkAppId);
584
+ const streamCardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, initTitle, '', 'starting', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
585
+ ds.streamCardId = await cb.sessionReply(sessionAnchorId(ds), streamCardJson, 'interactive', ds.larkAppId);
376
586
  persistStreamCardState(ds);
587
+ // New card is live — recall any cards frozen by previous turns.
588
+ // Done after `streamCardId` is committed so we never delete the old
589
+ // card without a successor visible to the user.
590
+ recallFrozenCards(ds);
377
591
  }
378
592
  catch (err) {
379
593
  if (err instanceof MessageWithdrawnError) {
@@ -388,8 +602,8 @@ function setupWorkerHandlers(ds, worker) {
388
602
  persistStreamCardState(ds);
389
603
  // Fallback: send static session card
390
604
  try {
391
- const cardJson = buildSessionCard(ds.session.sessionId, ds.session.rootMessageId, readOnlyUrl, ds.session.title || getCliDisplayName(botCfg.cliId), botCfg.cliId);
392
- await cb.sessionReply(ds.session.rootMessageId, cardJson, 'interactive', ds.larkAppId);
605
+ const cardJson = buildSessionCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, ds.session.title || getCliDisplayName(botCfg.cliId), botCfg.cliId, undefined, !!ds.adoptedFrom);
606
+ await cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId);
393
607
  }
394
608
  catch (fallbackErr) {
395
609
  if (fallbackErr instanceof MessageWithdrawnError) {
@@ -413,6 +627,23 @@ function setupWorkerHandlers(ds, worker) {
413
627
  const prevStatus = ds.lastScreenStatus;
414
628
  ds.lastScreenContent = msg.content;
415
629
  ds.lastScreenStatus = msg.status;
630
+ // Dashboard: publish a patch only when status truly transitioned, so
631
+ // SSE clients reflect real state changes (starting → working → idle)
632
+ // without flooding on every PTY tick. The screen analyzer is the
633
+ // upstream debouncer — by the time we get here, status flips are
634
+ // already coarse-grained.
635
+ if (prevStatus !== msg.status) {
636
+ dashboardEventBus.publish({
637
+ type: 'session.update',
638
+ body: {
639
+ sessionId: ds.session.sessionId,
640
+ patch: {
641
+ status: ds.lastScreenStatus,
642
+ lastMessageAt: ds.lastMessageAt,
643
+ },
644
+ },
645
+ });
646
+ }
416
647
  const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
417
648
  const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
418
649
  const mode = ds.displayMode ?? 'hidden';
@@ -428,13 +659,22 @@ function setupWorkerHandlers(ds, worker) {
428
659
  // New turn → image_key from previous turn no longer valid
429
660
  if (isNewTurn)
430
661
  ds.currentImageKey = undefined;
431
- const cardJson = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readUrl, turnTitle, isNewTurn ? '' : msg.content, msg.status, botCfg.cliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
662
+ const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, isNewTurn ? '' : msg.content, msg.status, botCfg.cliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
432
663
  // Mark POST in-flight so subsequent screen_updates are dropped,
433
664
  // not POSTed as duplicate cards.
434
665
  ds.streamCardPending = false;
435
666
  ds.streamCardId = CARD_POSTING_SENTINEL;
436
- cb.sessionReply(ds.session.rootMessageId, cardJson, 'interactive', ds.larkAppId)
437
- .then(msgId => { ds.streamCardId = msgId; persistStreamCardState(ds); })
667
+ cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId)
668
+ .then(msgId => {
669
+ ds.streamCardId = msgId;
670
+ persistStreamCardState(ds);
671
+ // New card live — recall any cards parked by previous turns
672
+ // (user message, bot @mention, adopt-bridge new turn, etc.).
673
+ // This is the main turn-to-turn POST path; without recall here,
674
+ // every long session would leak old streaming cards into the
675
+ // thread.
676
+ recallFrozenCards(ds);
677
+ })
438
678
  .catch(err => {
439
679
  if (err instanceof MessageWithdrawnError) {
440
680
  logger.warn(`[${t}] Root message withdrawn, closing stale session`);
@@ -453,7 +693,7 @@ function setupWorkerHandlers(ds, worker) {
453
693
  const statusChanged = prevStatus !== msg.status;
454
694
  if (!statusChanged)
455
695
  break;
456
- const cardJson = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readUrl, turnTitle, msg.content, msg.status, botCfg.cliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
696
+ const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, msg.content, msg.status, botCfg.cliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
457
697
  scheduleCardPatch(ds, cardJson);
458
698
  }
459
699
  break;
@@ -472,7 +712,7 @@ function setupWorkerHandlers(ds, worker) {
472
712
  break;
473
713
  const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
474
714
  const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
475
- const cardJson = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readUrl, turnTitle, ds.lastScreenContent ?? '', msg.status, botCfg.cliId, 'screenshot', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
715
+ const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', msg.status, botCfg.cliId, 'screenshot', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
476
716
  scheduleCardPatch(ds, cardJson);
477
717
  break;
478
718
  }
@@ -487,10 +727,20 @@ function setupWorkerHandlers(ds, worker) {
487
727
  ds.tuiPromptOptions = msg.options;
488
728
  ds.tuiPromptMultiSelect = msg.multiSelect;
489
729
  ds.tuiToggledIndices = [];
730
+ const prevTuiTurnTitle = ds.currentTurnTitle;
490
731
  ds.currentTurnTitle = msg.description; // store for card PATCH on toggle
732
+ if (prevTuiTurnTitle !== ds.currentTurnTitle) {
733
+ dashboardEventBus.publish({
734
+ type: 'session.update',
735
+ body: {
736
+ sessionId: ds.session.sessionId,
737
+ patch: { title: ds.currentTurnTitle },
738
+ },
739
+ });
740
+ }
491
741
  try {
492
- const cardJson = buildTuiPromptCard(ds.session.rootMessageId, ds.session.sessionId, msg.description, msg.options, msg.multiSelect);
493
- const cardMsgId = await cb.sessionReply(ds.session.rootMessageId, cardJson, 'interactive', ds.larkAppId);
742
+ const cardJson = buildTuiPromptCard(sessionAnchorId(ds), ds.session.sessionId, msg.description, msg.options, msg.multiSelect);
743
+ const cardMsgId = await cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId);
494
744
  ds.tuiPromptCardId = cardMsgId;
495
745
  }
496
746
  catch (err) {
@@ -519,14 +769,21 @@ function setupWorkerHandlers(ds, worker) {
519
769
  if (ds.streamCardId && ds.workerPort) {
520
770
  const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
521
771
  const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
522
- const frozenCard = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
772
+ const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
523
773
  scheduleCardPatch(ds, frozenCard);
524
774
  }
525
775
  killWorker(ds);
526
- try {
527
- await cb.sessionReply(ds.session.rootMessageId, '\u23cf 已采纳的 CLI 会话已退出', 'text', ds.larkAppId);
776
+ // Skip the exit notice when the session was already closed via the
777
+ // card button card-handler already posted "已断开,原 CLI 会话
778
+ // 不受影响" right before killing us, so another exit message here
779
+ // is just noise. Natural exits (user typed `exit`, CLI crashed)
780
+ // leave status='active' and still get the notice.
781
+ if (ds.session.status !== 'closed') {
782
+ try {
783
+ await cb.sessionReply(sessionAnchorId(ds), '\u23cf 已采纳的 CLI 会话已退出', 'text', ds.larkAppId);
784
+ }
785
+ catch { /* best effort */ }
528
786
  }
529
- catch { /* best effort */ }
530
787
  break;
531
788
  }
532
789
  // Rate-limit auto-restart to prevent crash loops
@@ -544,14 +801,14 @@ function setupWorkerHandlers(ds, worker) {
544
801
  if (ds.streamCardId && ds.workerPort) {
545
802
  const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
546
803
  const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
547
- const frozenCard = buildStreamingCard(ds.session.sessionId, ds.session.rootMessageId, readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey);
804
+ const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey);
548
805
  scheduleCardPatch(ds, frozenCard);
549
806
  }
550
807
  // Kill the worker process to free resources
551
808
  killWorker(ds);
552
809
  const cliName = getCliDisplayName(botCfg.cliId);
553
810
  try {
554
- await cb.sessionReply(ds.session.rootMessageId, `\u26a0\ufe0f ${cliName} 在 1 分钟内崩溃 ${rc.count} 次,已停止自动重启。发消息可触发重新启动。`, 'text', ds.larkAppId);
811
+ await cb.sessionReply(sessionAnchorId(ds), `\u26a0\ufe0f ${cliName} 在 1 分钟内崩溃 ${rc.count} 次,已停止自动重启。发消息可触发重新启动。`, 'text', ds.larkAppId);
555
812
  }
556
813
  catch (replyErr) {
557
814
  if (replyErr instanceof MessageWithdrawnError) {
@@ -575,13 +832,55 @@ function setupWorkerHandlers(ds, worker) {
575
832
  case 'user_notify': {
576
833
  logger.warn(`[${t}] Worker user_notify: ${msg.message}`);
577
834
  try {
578
- await cb.sessionReply(ds.session.rootMessageId, msg.message, 'text', ds.larkAppId);
835
+ await cb.sessionReply(sessionAnchorId(ds), msg.message, 'text', ds.larkAppId);
579
836
  }
580
837
  catch (err) {
581
838
  logger.error(`[${t}] Failed to deliver user_notify to Lark: ${err.message}`);
582
839
  }
583
840
  break;
584
841
  }
842
+ case 'final_output': {
843
+ // Adopt-bridge: worker harvested the assistant turn from Claude Code's
844
+ // transcript JSONL and forwarded it to us. Dedup by lastUuid so a
845
+ // re-drain after a noisy idle doesn't re-send the same answer.
846
+ if (!msg.content || !msg.content.trim())
847
+ break;
848
+ if (msg.lastUuid && ds.lastBridgeEmittedUuid === msg.lastUuid) {
849
+ logger.debug(`[${t}] final_output deduped (uuid ${msg.lastUuid.substring(0, 8)})`);
850
+ break;
851
+ }
852
+ // Worker pops the turn off its queue right after emit, so it will
853
+ // NOT re-send this payload on its own. Daemon owns retry on
854
+ // transient Lark failures.
855
+ deliverFinalOutput(ds, msg, t, 0);
856
+ break;
857
+ }
858
+ case 'adopt_preamble': {
859
+ // Adopt-bridge: surface the last completed user/assistant exchange
860
+ // from the adopted CLI session so the Lark thread has context to
861
+ // continue from. Best-effort — failure here just means the user
862
+ // won't see the preamble; adopt itself isn't blocked. Card chrome
863
+ // matches the regular markdown-card path (schema 2.0 + footer) so
864
+ // the assistant body renders with proper code blocks / tables /
865
+ // lists instead of arriving as a wall of plain text.
866
+ if (!ds.adoptedFrom) {
867
+ logger.warn(`[${t}] Ignored adopt_preamble from non-adopt worker`);
868
+ break;
869
+ }
870
+ if (!msg.userText.trim() && !msg.assistantText.trim())
871
+ break;
872
+ const cardJson = buildContextualReplyCard({
873
+ title: '📜 /adopt 前最后一轮',
874
+ userText: msg.userText,
875
+ assistantText: msg.assistantText,
876
+ assistantLabel: getCliDisplayName(botCfg.cliId),
877
+ recipientOpenId: ds.session.ownerOpenId,
878
+ });
879
+ cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId).catch((err) => {
880
+ logger.warn(`[${t}] Failed to deliver adopt_preamble to Lark: ${err.message}`);
881
+ });
882
+ break;
883
+ }
585
884
  }
586
885
  });
587
886
  worker.on('exit', (code) => {
@@ -592,10 +891,91 @@ function setupWorkerHandlers(ds, worker) {
592
891
  ds.worker = null;
593
892
  ds.workerPort = null;
594
893
  }
894
+ // Notify dashboard, but only once per session lifecycle. The
895
+ // dashboard-driven `closeSession()` path also publishes; whichever
896
+ // fires first wins, the other's emit is suppressed.
897
+ if (!ds.exitEventEmitted) {
898
+ ds.exitEventEmitted = true;
899
+ dashboardEventBus.publish({
900
+ type: 'session.exited',
901
+ body: {
902
+ sessionId: ds.session.sessionId,
903
+ reason: code === 0 ? 'graceful' : `exit_code_${code}`,
904
+ },
905
+ });
906
+ }
595
907
  });
596
908
  }
909
+ // ─── Bridge final-output delivery (with retry) ──────────────────────────────
910
+ const FINAL_OUTPUT_RETRY_BACKOFF_MS = [0, 5000, 15000]; // immediate, +5s, +15s
911
+ /** Deliver a bridge `final_output` to Lark. The worker emits each turn
912
+ * exactly once (it pops the turn off its queue at emit time), so the
913
+ * daemon owns retries on transient failures. After 3 attempts we log
914
+ * and give up — the user's answer is lost; better than leaking memory
915
+ * via an unbounded retry loop. */
916
+ function deliverFinalOutput(ds, msg, t, attempt) {
917
+ const cb = requireCallbacks();
918
+ setTimeout(async () => {
919
+ // Guard: if the user closed the session (or it was torn down for any
920
+ // other reason) between attempts, don't post a stale final answer to
921
+ // a closed thread.
922
+ if (ds.session.status === 'closed') {
923
+ logger.info(`[${t}] Bridge final_output abandoned — session closed (turn ${msg.turnId.substring(0, 8)})`);
924
+ return;
925
+ }
926
+ try {
927
+ // Wrap the model's reply in the same card chrome `botmux send` uses
928
+ // (schema 2.0 + footer with botmux link + 发送给 owner) so a turn
929
+ // delivered via this fallback path looks identical in the Lark thread
930
+ // to one the model sent itself. Markdown rendering, tables, code
931
+ // blocks all flow through the shared `buildCardBodyElements`.
932
+ //
933
+ // Local-turn variants (kind = 'local-turn' / 'local-turn-headless')
934
+ // also surface the user-side prompt synced from the adopted pane;
935
+ // they use the contextual card so the user prompt sits in a
936
+ // blockquote and only the assistant body goes through full markdown
937
+ // rendering.
938
+ const cardJson = msg.kind === 'local-turn' || msg.kind === 'local-turn-headless'
939
+ ? buildContextualReplyCard({
940
+ title: msg.kind === 'local-turn-headless'
941
+ ? '🖥️ 终端本地对话续传(daemon 重启时模型正在输出)'
942
+ : '🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
943
+ userText: msg.kind === 'local-turn' ? msg.userText ?? '' : undefined,
944
+ assistantText: msg.content,
945
+ assistantLabel: getCliDisplayName(getBot(ds.larkAppId).config.cliId),
946
+ recipientOpenId: ds.session.ownerOpenId,
947
+ })
948
+ : buildMarkdownCard(msg.content, ds.session.ownerOpenId);
949
+ await cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId);
950
+ ds.lastBridgeEmittedUuid = msg.lastUuid;
951
+ logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`);
952
+ }
953
+ catch (err) {
954
+ if (err instanceof MessageWithdrawnError) {
955
+ // Root message gone — no point retrying. Mark as emitted so any
956
+ // duplicate IPC is correctly deduped, and tear the session down.
957
+ ds.lastBridgeEmittedUuid = msg.lastUuid;
958
+ logger.warn(`[${t}] Root message withdrawn while forwarding final_output, closing session`);
959
+ cb.closeSession(ds);
960
+ return;
961
+ }
962
+ const next = attempt + 1;
963
+ if (next >= FINAL_OUTPUT_RETRY_BACKOFF_MS.length) {
964
+ logger.error(`[${t}] Bridge final_output gave up after ${next} attempts (turn ${msg.turnId.substring(0, 8)}): ${err.message}`);
965
+ // Don't commit the dedup marker — leave room for any future
966
+ // retransmit (e.g. daemon restart that re-fires the IPC).
967
+ return;
968
+ }
969
+ logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`);
970
+ deliverFinalOutput(ds, msg, t, next);
971
+ }
972
+ }, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0);
973
+ }
974
+ /** Test-only alias so the retry pipeline can be exercised without a real
975
+ * fork. Intentionally underscored to discourage non-test callers. */
976
+ export const __testOnly_deliverFinalOutput = deliverFinalOutput;
597
977
  // ─── Fork adopt worker ──────────────────────────────────────────────────────
598
- export function forkAdoptWorker(ds) {
978
+ export function forkAdoptWorker(ds, opts) {
599
979
  const cb = requireCallbacks();
600
980
  const workerPath = join(__dirname, '..', 'worker.js');
601
981
  const t = tag(ds);
@@ -631,7 +1011,8 @@ export function forkAdoptWorker(ds) {
631
1011
  LARK_APP_SECRET: botCfg.larkAppSecret,
632
1012
  },
633
1013
  });
634
- // Pipe worker stdout/stderr
1014
+ // Pipe worker stdout/stderr — both go through logger.info (→ daemon.log,
1015
+ // not error.log). See forkWorker for the rationale.
635
1016
  worker.stdout?.on('data', (data) => {
636
1017
  for (const line of data.toString().split('\n')) {
637
1018
  const trimmed = line.trim();
@@ -641,18 +1022,33 @@ export function forkAdoptWorker(ds) {
641
1022
  });
642
1023
  worker.stderr?.on('data', (data) => {
643
1024
  for (const line of data.toString().split('\n')) {
644
- const trimmed = line.trim();
645
- if (trimmed)
646
- logger.error(`[${t}:worker] ${trimmed}`);
1025
+ logWorkerStderr(t, line.trim());
647
1026
  }
648
1027
  });
1028
+ // Bridge mode is gated per-CLI:
1029
+ // - claude-code: needs sessionId to compute jsonl path. PID + cwd let
1030
+ // the worker follow Claude's `/clear` / `/resume` rotations.
1031
+ // - codex: worker resolves the rollout path either from cliSessionId
1032
+ // (passed below when known) or by reading the Codex pid's open fds
1033
+ // in /proc — so we always pass the pid for codex adopt.
1034
+ // - coco: events.jsonl path is `~/.cache/coco/sessions/<sid>/events.jsonl`,
1035
+ // deterministic from cliSessionId. PID is the fallback when discovery
1036
+ // missed (events.jsonl isn't held open continuously, so worker may need
1037
+ // to re-probe via session.log / traces.jsonl fds).
1038
+ // Other CLIs fall back to legacy screen-capture only.
1039
+ const adoptedCliId = adopted.cliId ?? 'claude-code';
1040
+ const bridgeJsonlPath = adoptedCliId === 'claude-code' && adopted.sessionId
1041
+ ? claudeJsonlPathForSession(adopted.sessionId, adopted.cwd)
1042
+ : undefined;
1043
+ const isStructuredBridge = adoptedCliId === 'codex' || adoptedCliId === 'coco';
649
1044
  const initMsg = {
650
1045
  type: 'init',
651
1046
  sessionId: ds.session.sessionId,
652
1047
  chatId: ds.chatId,
653
- rootMessageId: ds.session.rootMessageId,
1048
+ rootMessageId: sessionAnchorId(ds),
654
1049
  workingDir: adopted.cwd,
655
- cliId: adopted.cliId ?? 'claude-code',
1050
+ cliId: adoptedCliId,
1051
+ cliSessionId: isStructuredBridge ? adopted.sessionId : undefined,
656
1052
  backendType: 'tmux',
657
1053
  prompt: '',
658
1054
  resume: false,
@@ -666,6 +1062,19 @@ export function forkAdoptWorker(ds) {
666
1062
  adoptTmuxTarget: adopted.tmuxTarget,
667
1063
  adoptPaneCols: adopted.paneCols,
668
1064
  adoptPaneRows: adopted.paneRows,
1065
+ bridgeJsonlPath,
1066
+ // PID + cwd: claude uses for `~/.claude/sessions/<pid>.json` resolver;
1067
+ // codex uses for `/proc/<pid>/fd` rollout discovery (works even if
1068
+ // session-discovery couldn't probe sessionId up-front).
1069
+ adoptCliPid: (adoptedCliId === 'claude-code' || isStructuredBridge) ? adopted.originalCliPid : undefined,
1070
+ adoptCwd: (adoptedCliId === 'claude-code' || isStructuredBridge) ? adopted.cwd : undefined,
1071
+ // Restored-from-metadata: this fork is recreating an /adopt session after
1072
+ // a daemon restart, NOT a fresh /adopt command. The Lark thread already
1073
+ // has every prior turn pushed as cards, so the worker should skip the
1074
+ // "📜 /adopt 前最后一轮" preamble (it would surface a stale turn from
1075
+ // whichever jsonl was current at the original /adopt time, which may be
1076
+ // way out of date if the user has /clear'd since).
1077
+ adoptRestoredFromMetadata: opts?.restoredFromMetadata === true ? true : undefined,
669
1078
  };
670
1079
  worker.send(initMsg);
671
1080
  ds.initConfig = initMsg;
@@ -674,7 +1083,19 @@ export function forkAdoptWorker(ds) {
674
1083
  ds.worker = worker;
675
1084
  ds.spawnedAt = Date.now();
676
1085
  ds.cliVersion = '';
1086
+ // Stamp cliId on the persisted session so the dashboard can show a CLI badge
1087
+ // even after the session is closed. Adopt sessions inherit the adopted CLI's id.
1088
+ const adoptedCliIdTyped = adoptedCliId;
1089
+ if (ds.session.cliId !== adoptedCliIdTyped) {
1090
+ ds.session.cliId = adoptedCliIdTyped;
1091
+ sessionStore.updateSession(ds.session);
1092
+ }
677
1093
  logger.info(`[${t}] Adopt worker forked (pid: ${worker.pid}, target: ${adopted.tmuxTarget})`);
1094
+ ds.exitEventEmitted = false;
1095
+ dashboardEventBus.publish({
1096
+ type: 'session.spawned',
1097
+ body: { session: composeRowFromActive(ds) },
1098
+ });
678
1099
  }
679
1100
  // ─── Kill stale PIDs ────────────────────────────────────────────────────────
680
1101
  export function killStalePids(activeSessions_) {