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
@@ -0,0 +1,639 @@
1
+ // src/dashboard.ts
2
+ import { createServer } from 'node:http';
3
+ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, statSync, } from 'node:fs';
4
+ import { join, dirname, extname } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { logger } from './utils/logger.js';
8
+ import { config } from './config.js';
9
+ import { generateToken, parseCookie, buildSetCookie, verifyHmac, } from './dashboard/auth.js';
10
+ import { DaemonRegistry } from './dashboard/registry.js';
11
+ import { Aggregator, subscribeDaemon } from './dashboard/aggregator.js';
12
+ import { pickCreatorForGroup } from './dashboard/operator-selector.js';
13
+ const SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
14
+ const REGISTRY_DIR = join(homedir(), '.botmux', 'data', 'dashboard-daemons');
15
+ let activeToken = null;
16
+ function loadOrCreateSecret() {
17
+ if (existsSync(SECRET_PATH))
18
+ return readFileSync(SECRET_PATH, 'utf8').trim();
19
+ const s = randomBytes(32).toString('base64url');
20
+ mkdirSync(dirname(SECRET_PATH), { recursive: true });
21
+ writeFileSync(SECRET_PATH, s, { mode: 0o600 });
22
+ chmodSync(SECRET_PATH, 0o600);
23
+ logger.info(`[dashboard] Generated dashboard secret at ${SECRET_PATH}`);
24
+ return s;
25
+ }
26
+ const SECRET = loadOrCreateSecret();
27
+ mkdirSync(REGISTRY_DIR, { recursive: true });
28
+ const registry = new DaemonRegistry(REGISTRY_DIR);
29
+ const aggregator = new Aggregator();
30
+ const subs = new Map();
31
+ const attaching = new Set(); // dedup concurrent attaches per appId
32
+ /**
33
+ * Attach to one daemon: hydrate its sessions/schedules into the aggregator,
34
+ * THEN open the SSE subscription. Order matters — hydrating after subscribe
35
+ * would let snapshot data clobber events that arrived between subscribe and
36
+ * the snapshot fetch.
37
+ *
38
+ * Idempotent: a second call for the same daemon while one is in flight is a
39
+ * no-op; a call after attach finished re-hydrates (useful when a daemon
40
+ * restarts and we want to refresh its slice of the cache).
41
+ */
42
+ async function attachDaemon(d) {
43
+ if (attaching.has(d.larkAppId))
44
+ return;
45
+ attaching.add(d.larkAppId);
46
+ try {
47
+ // 1. Hydrate snapshot (blocking — completes before we wire SSE)
48
+ try {
49
+ const [sRes, schRes] = await Promise.all([
50
+ fetch(`http://127.0.0.1:${d.ipcPort}/api/sessions`),
51
+ fetch(`http://127.0.0.1:${d.ipcPort}/api/schedules`),
52
+ ]);
53
+ const s = await sRes.json();
54
+ const sch = await schRes.json();
55
+ aggregator.hydrateSessions(d.larkAppId, s.sessions ?? []);
56
+ aggregator.hydrateSchedules(sch.schedules ?? []);
57
+ }
58
+ catch (e) {
59
+ logger.warn(`[dashboard] hydrate ${d.larkAppId}: ${e.message ?? e}`);
60
+ }
61
+ // 2. Open SSE subscription if not already (idempotent)
62
+ if (!subs.has(d.larkAppId)) {
63
+ subs.set(d.larkAppId, subscribeDaemon(d, aggregator, e => logger.warn(`[aggregator] ${d.larkAppId}: ${e.message}`)));
64
+ }
65
+ }
66
+ finally {
67
+ attaching.delete(d.larkAppId);
68
+ }
69
+ }
70
+ function syncSubscriptions() {
71
+ const online = new Set(registry.list().map(d => d.larkAppId));
72
+ // Attach (hydrate + subscribe) any newly-online daemon. Fire-and-forget
73
+ // because the registry callback is sync and the attach is per-daemon
74
+ // independent.
75
+ for (const d of registry.list()) {
76
+ if (!subs.has(d.larkAppId)) {
77
+ void attachDaemon(d);
78
+ }
79
+ }
80
+ // Close subscriptions for daemons that went offline. Cache entries are
81
+ // intentionally retained — the user may still want to see the last-known
82
+ // state of those sessions/schedules in the dashboard.
83
+ for (const [id, off] of subs) {
84
+ if (!online.has(id)) {
85
+ off();
86
+ subs.delete(id);
87
+ }
88
+ }
89
+ }
90
+ await registry.start();
91
+ registry.on(syncSubscriptions);
92
+ // Initial attach for every daemon already known. Run in parallel so a slow
93
+ // daemon doesn't block the others.
94
+ await Promise.all(registry.list().map(attachDaemon));
95
+ // ─── Static frontend ─────────────────────────────────────────────────────────
96
+ // Path to the bundled frontend (sibling of dist/dashboard.js)
97
+ const __dirname = dirname(new URL(import.meta.url).pathname);
98
+ const WEB_DIR = join(__dirname, 'dashboard-web');
99
+ const MIME = {
100
+ '.html': 'text/html; charset=utf-8',
101
+ '.js': 'application/javascript',
102
+ '.css': 'text/css',
103
+ '.svg': 'image/svg+xml',
104
+ '.woff2': 'font/woff2',
105
+ };
106
+ function serveStatic(_req, res, pathname) {
107
+ const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
108
+ const fp = join(WEB_DIR, rel);
109
+ // Path-traversal guard: resolved path must stay inside WEB_DIR
110
+ if (!fp.startsWith(WEB_DIR + '/') && fp !== join(WEB_DIR, 'index.html'))
111
+ return false;
112
+ try {
113
+ const st = statSync(fp);
114
+ if (!st.isFile())
115
+ return false;
116
+ res.writeHead(200, { 'content-type': MIME[extname(fp)] ?? 'application/octet-stream' });
117
+ res.end(readFileSync(fp));
118
+ return true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ // ─── HTTP routing ────────────────────────────────────────────────────────────
125
+ function authedToken(req, url) {
126
+ const q = url.searchParams.get('t');
127
+ if (q && q === activeToken)
128
+ return q;
129
+ return parseCookie(req.headers.cookie);
130
+ }
131
+ function jsonRes(res, status, body) {
132
+ res.writeHead(status, { 'content-type': 'application/json' });
133
+ res.end(JSON.stringify(body));
134
+ }
135
+ async function proxyToDaemon(larkAppId, daemonPath, init) {
136
+ const d = registry.getByAppId(larkAppId);
137
+ if (!d) {
138
+ return new Response(JSON.stringify({ ok: false, error: 'daemon_offline' }), {
139
+ status: 503,
140
+ headers: { 'content-type': 'application/json' },
141
+ });
142
+ }
143
+ return fetch(`http://127.0.0.1:${d.ipcPort}${daemonPath}`, init);
144
+ }
145
+ /**
146
+ * Close every active session matching `pred` by routing to its owning daemon.
147
+ * Used after disband (close all sessions in chat) and leave (close only the
148
+ * leaving bot's sessions in chat) so the UI doesn't end up with zombie workers
149
+ * pointing at a chat the bot can no longer post into.
150
+ */
151
+ async function closeSessionsMatching(pred) {
152
+ const matching = aggregator.getSessions().filter(s => s.status !== 'closed' && pred(s));
153
+ return Promise.all(matching.map(async (s) => {
154
+ try {
155
+ const upstream = await proxyToDaemon(s.larkAppId, `/api/sessions/${encodeURIComponent(s.sessionId)}/close`, { method: 'POST' });
156
+ const text = await upstream.text();
157
+ let body = null;
158
+ try {
159
+ body = JSON.parse(text);
160
+ }
161
+ catch { /* tolerate */ }
162
+ return {
163
+ sessionId: s.sessionId,
164
+ ok: !!body?.ok,
165
+ error: body?.ok ? undefined : (body?.error ?? `http_${upstream.status}`),
166
+ };
167
+ }
168
+ catch (e) {
169
+ return { sessionId: s.sessionId, ok: false, error: e?.message ?? String(e) };
170
+ }
171
+ }));
172
+ }
173
+ const server = createServer(async (req, res) => {
174
+ try {
175
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
176
+ // Health probe (no auth) — for pm2
177
+ if (url.pathname === '/__health') {
178
+ return jsonRes(res, 200, { ok: true });
179
+ }
180
+ // CLI rotate (HMAC + loopback only) — for `botmux dashboard`
181
+ if (req.method === 'POST' && url.pathname === '/__cli/rotate') {
182
+ const ts = req.headers['x-botmux-cli-ts'];
183
+ const nonce = req.headers['x-botmux-cli-nonce'];
184
+ const sig = req.headers['x-botmux-cli-auth'];
185
+ if (typeof ts !== 'string' || typeof nonce !== 'string' || typeof sig !== 'string') {
186
+ return jsonRes(res, 400, { error: 'missing_headers' });
187
+ }
188
+ const remote = (req.socket.remoteAddress ?? '').replace(/^::ffff:/, '');
189
+ const r = verifyHmac(SECRET, { ts, nonce, sig }, remote);
190
+ if (!r.ok)
191
+ return jsonRes(res, 401, { error: 'unauthorized', reason: r.reason });
192
+ activeToken = generateToken();
193
+ const fullUrl = `http://${config.dashboard.externalHost}:${config.dashboard.port}/?t=${activeToken}`;
194
+ return jsonRes(res, 200, { url: fullUrl });
195
+ }
196
+ // All other paths require an authenticated session.
197
+ const tok = authedToken(req, url);
198
+ if (!tok || tok !== activeToken) {
199
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8' });
200
+ res.end('<h1>Token expired</h1><p>Run <code>botmux dashboard</code> to get a fresh URL.</p>');
201
+ return;
202
+ }
203
+ // First hit with `?t=<token>` sets the cookie + redirects to clean URL.
204
+ if (url.searchParams.has('t')) {
205
+ res.writeHead(302, {
206
+ 'set-cookie': buildSetCookie(tok),
207
+ 'location': url.pathname || '/',
208
+ });
209
+ res.end();
210
+ return;
211
+ }
212
+ // ─── Static frontend (index.html + /assets/*) ──────────────────────────
213
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname.startsWith('/assets/'))) {
214
+ // Map /assets/foo.js → WEB_DIR/foo.js
215
+ const lookupPath = url.pathname.startsWith('/assets/')
216
+ ? '/' + url.pathname.slice(8)
217
+ : url.pathname;
218
+ if (serveStatic(req, res, lookupPath))
219
+ return;
220
+ }
221
+ // ─── Public API (cookie/token already validated above) ──────────────────
222
+ if (req.method === 'GET' && url.pathname === '/api/sessions') {
223
+ return jsonRes(res, 200, { sessions: aggregator.getSessions() });
224
+ }
225
+ if (req.method === 'GET' && url.pathname === '/api/schedules') {
226
+ return jsonRes(res, 200, { schedules: aggregator.getSchedules() });
227
+ }
228
+ let m;
229
+ if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/(close|locate)$/))) {
230
+ const sid = decodeURIComponent(m[1]);
231
+ const op = m[2];
232
+ const owner = aggregator.ownerOf(sid);
233
+ if (!owner)
234
+ return jsonRes(res, 404, { ok: false, error: 'unknown_session' });
235
+ const upstream = await proxyToDaemon(owner, `/api/sessions/${sid}/${op}`, { method: 'POST' });
236
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
237
+ res.end(await upstream.text());
238
+ return;
239
+ }
240
+ if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)\/(run|pause|resume)$/))) {
241
+ const id = decodeURIComponent(m[1]);
242
+ const op = m[2];
243
+ const owner = aggregator.scheduleOwnerOf(id);
244
+ if (!owner)
245
+ return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
246
+ const upstream = await proxyToDaemon(owner, `/api/schedules/${id}/${op}`, { method: 'POST' });
247
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
248
+ res.end(await upstream.text());
249
+ return;
250
+ }
251
+ // ─── Groups (Phase B) ────────────────────────────────────────────────────
252
+ if (req.method === 'GET' && url.pathname === '/api/groups') {
253
+ // Fan out: each online daemon returns the chats its bot is in.
254
+ // Merge by chatId; populate memberBots with inChat flags for every configured bot.
255
+ const out = new Map();
256
+ // Sort by botIndex so the matrix columns + the create-group bot picker
257
+ // both match the order in bots.json (fs.readdir order is unstable).
258
+ const onlineBots = [...registry.list()].sort((a, b) => a.botIndex - b.botIndex);
259
+ await Promise.all(onlineBots.map(async (d) => {
260
+ try {
261
+ const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups`);
262
+ if (!r.ok)
263
+ return;
264
+ const j = await r.json();
265
+ for (const c of j.chats ?? []) {
266
+ // Strip per-bot fields from chat-level so the merged record stays
267
+ // bot-agnostic. oncallChat lives inside memberBots; firstSeenAt is
268
+ // accumulated as the earliest observation across all bots.
269
+ const { oncallChat, firstSeenAt, ...chatBase } = c;
270
+ const cur = out.get(c.chatId) ?? { ...chatBase, memberBots: [], _firstSeenAt: null };
271
+ cur.memberBots.push({
272
+ larkAppId: d.larkAppId,
273
+ botName: d.botName,
274
+ inChat: true,
275
+ oncallChat: oncallChat ?? null,
276
+ });
277
+ if (typeof firstSeenAt === 'number') {
278
+ cur._firstSeenAt = cur._firstSeenAt === null
279
+ ? firstSeenAt
280
+ : Math.min(cur._firstSeenAt, firstSeenAt);
281
+ }
282
+ out.set(c.chatId, cur);
283
+ }
284
+ }
285
+ catch { /* skip offline daemons silently — best-effort */ }
286
+ }));
287
+ // Fill in inChat:false slots for bots NOT returned for a given chat (matrix view)
288
+ for (const c of out.values()) {
289
+ const present = new Set(c.memberBots.map((mb) => mb.larkAppId));
290
+ for (const b of onlineBots) {
291
+ if (!present.has(b.larkAppId)) {
292
+ c.memberBots.push({ larkAppId: b.larkAppId, botName: b.botName, inChat: false, oncallChat: null });
293
+ }
294
+ }
295
+ }
296
+ // Sort newest-first by client-side firstSeenAt (Lark exposes no chat
297
+ // create_time, so daemon stamps timestamps the first time it lists each
298
+ // chat). Tie-break by name asc so chats backfilled in the same listChats
299
+ // pass — typically every chat on first deploy — get a stable order.
300
+ const sorted = [...out.values()]
301
+ .sort((a, b) => {
302
+ const ta = a._firstSeenAt ?? 0;
303
+ const tb = b._firstSeenAt ?? 0;
304
+ if (tb !== ta)
305
+ return tb - ta;
306
+ return (a.name ?? a.chatId).localeCompare(b.name ?? b.chatId);
307
+ })
308
+ .map(({ _firstSeenAt, ...rest }) => rest);
309
+ return jsonRes(res, 200, {
310
+ chats: sorted,
311
+ bots: onlineBots.map(b => ({ larkAppId: b.larkAppId, botName: b.botName })),
312
+ });
313
+ }
314
+ let m2;
315
+ if (req.method === 'POST' && (m2 = url.pathname.match(/^\/api\/groups\/([^/]+)\/add-bots$/))) {
316
+ const chatId = decodeURIComponent(m2[1]);
317
+ // Read body once; we'll forward it to the proxy daemon
318
+ let raw;
319
+ try {
320
+ const chunks = [];
321
+ for await (const c of req)
322
+ chunks.push(c);
323
+ raw = Buffer.concat(chunks).toString('utf8') || '{}';
324
+ JSON.parse(raw); // validate is JSON
325
+ }
326
+ catch {
327
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
328
+ }
329
+ // Find a daemon whose bot is already in this chat
330
+ let proxy;
331
+ for (const d of registry.list()) {
332
+ try {
333
+ const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups/${encodeURIComponent(chatId)}/membership`);
334
+ if (!r.ok)
335
+ continue;
336
+ const j = await r.json();
337
+ if (j.inChat) {
338
+ proxy = d;
339
+ break;
340
+ }
341
+ }
342
+ catch { /* skip */ }
343
+ }
344
+ if (!proxy)
345
+ return jsonRes(res, 200, { ok: false, error: 'no_proxy_bot' });
346
+ const upstream = await fetch(`http://127.0.0.1:${proxy.ipcPort}/api/groups/${encodeURIComponent(chatId)}/add-bots`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: raw });
347
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
348
+ res.end(await upstream.text());
349
+ return;
350
+ }
351
+ // Disband a chat. Body: `{ larkAppId }` — the bot whose daemon should
352
+ // perform the delete. Disband only succeeds when that bot is currently
353
+ // the chat owner (or creator with operate_as_owner scope, which botmux
354
+ // doesn't request by default), so the frontend is responsible for picking
355
+ // a viable bot. The route just proxies and surfaces Lark's error verbatim.
356
+ let mDisband;
357
+ if (req.method === 'POST' && (mDisband = url.pathname.match(/^\/api\/groups\/([^/]+)\/disband$/))) {
358
+ const chatId = decodeURIComponent(mDisband[1]);
359
+ let parsed;
360
+ try {
361
+ const chunks = [];
362
+ for await (const c of req)
363
+ chunks.push(c);
364
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
365
+ }
366
+ catch {
367
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
368
+ }
369
+ const appId = typeof parsed.larkAppId === 'string' ? parsed.larkAppId : '';
370
+ if (!appId)
371
+ return jsonRes(res, 400, { ok: false, error: 'larkAppId_required' });
372
+ const upstream = await proxyToDaemon(appId, `/api/groups/${encodeURIComponent(chatId)}/disband`, { method: 'POST' });
373
+ const upstreamText = await upstream.text();
374
+ let upstreamJson = null;
375
+ try {
376
+ upstreamJson = JSON.parse(upstreamText);
377
+ }
378
+ catch { /* tolerate */ }
379
+ // On successful disband, the chat is gone for everyone — every bot's
380
+ // session in this chat becomes a zombie (worker still alive, can't post).
381
+ // Close them all so the UI / Sessions list don't keep them as active.
382
+ let closedSessions = [];
383
+ if (upstreamJson?.ok) {
384
+ closedSessions = await closeSessionsMatching(s => s.chatId === chatId);
385
+ }
386
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
387
+ res.end(JSON.stringify({ ...(upstreamJson ?? {}), closedSessions }));
388
+ return;
389
+ }
390
+ // Make selected bots leave a chat. Body: `{ larkAppIds: string[] }`. Each
391
+ // bot is removed via its own daemon (Lark allows self-removal under any
392
+ // role). Per-bot results returned so the UI can show partial successes.
393
+ let mLeave;
394
+ if (req.method === 'POST' && (mLeave = url.pathname.match(/^\/api\/groups\/([^/]+)\/leave$/))) {
395
+ const chatId = decodeURIComponent(mLeave[1]);
396
+ let parsed;
397
+ try {
398
+ const chunks = [];
399
+ for await (const c of req)
400
+ chunks.push(c);
401
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
402
+ }
403
+ catch {
404
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
405
+ }
406
+ const ids = Array.isArray(parsed.larkAppIds)
407
+ ? parsed.larkAppIds.filter((x) => typeof x === 'string')
408
+ : [];
409
+ if (ids.length === 0)
410
+ return jsonRes(res, 400, { ok: false, error: 'larkAppIds_required' });
411
+ // Re-check membership on the daemon side before issuing leave — UI cache
412
+ // can be stale, and Lark's bot-self-remove returns a confusing error if
413
+ // the bot isn't actually in the chat. Skipping such bots up-front keeps
414
+ // the per-bot result useful (`not_in_chat`) instead of a vague API error.
415
+ const result = await Promise.all(ids.map(async (appId) => {
416
+ const d = registry.getByAppId(appId);
417
+ if (!d)
418
+ return { larkAppId: appId, ok: false, error: 'daemon_offline' };
419
+ try {
420
+ const memRes = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups/${encodeURIComponent(chatId)}/membership`);
421
+ const memJson = await memRes.json();
422
+ if (!memJson.inChat)
423
+ return { larkAppId: appId, ok: false, error: 'not_in_chat' };
424
+ }
425
+ catch (e) {
426
+ return { larkAppId: appId, ok: false, error: `membership_check_failed: ${e?.message ?? e}` };
427
+ }
428
+ const upstream = await proxyToDaemon(appId, `/api/groups/${encodeURIComponent(chatId)}/leave`, { method: 'POST' });
429
+ const text = await upstream.text();
430
+ let body = null;
431
+ try {
432
+ body = JSON.parse(text);
433
+ }
434
+ catch { /* tolerate */ }
435
+ // On successful leave, the leaving bot can no longer post into the
436
+ // chat — its sessions there are stranded. Close only THIS bot's
437
+ // sessions for THIS chat (other bots may still be in the chat with
438
+ // their own active sessions).
439
+ const closedSessions = body?.ok
440
+ ? await closeSessionsMatching(s => s.chatId === chatId && s.larkAppId === appId)
441
+ : [];
442
+ return {
443
+ larkAppId: appId,
444
+ ok: !!body?.ok,
445
+ error: body?.ok ? undefined : (body?.error ?? `http_${upstream.status}`),
446
+ closedSessions,
447
+ };
448
+ }));
449
+ return jsonRes(res, 200, { result });
450
+ }
451
+ // ─── Oncall bindings (per chat × bot) ────────────────────────────────────
452
+ // PUT /api/groups/:chatId/oncall/:larkAppId body: {workingDir}
453
+ // DELETE /api/groups/:chatId/oncall/:larkAppId
454
+ let mOncall;
455
+ if ((mOncall = url.pathname.match(/^\/api\/groups\/([^/]+)\/oncall\/([^/]+)$/))) {
456
+ const chatId = decodeURIComponent(mOncall[1]);
457
+ const appId = decodeURIComponent(mOncall[2]);
458
+ if (req.method === 'PUT') {
459
+ const chunks = [];
460
+ for await (const c of req)
461
+ chunks.push(c);
462
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
463
+ const upstream = await proxyToDaemon(appId, `/api/oncall/${encodeURIComponent(chatId)}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: raw });
464
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
465
+ res.end(await upstream.text());
466
+ return;
467
+ }
468
+ if (req.method === 'DELETE') {
469
+ const upstream = await proxyToDaemon(appId, `/api/oncall/${encodeURIComponent(chatId)}`, { method: 'DELETE' });
470
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
471
+ res.end(await upstream.text());
472
+ return;
473
+ }
474
+ }
475
+ // ─── Per-bot defaults (Bot Defaults tab) ─────────────────────────────────
476
+ // GET /api/bots — fan out to each daemon, return
477
+ // [{larkAppId, botName, defaultOncall, ...}]
478
+ // PUT /api/bots/:appId/default-oncall — proxy to that bot's daemon
479
+ if (req.method === 'GET' && url.pathname === '/api/bots') {
480
+ const onlineBots = [...registry.list()].sort((a, b) => a.botIndex - b.botIndex);
481
+ const out = await Promise.all(onlineBots.map(async (d) => {
482
+ try {
483
+ const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/bot-default-oncall`);
484
+ if (!r.ok) {
485
+ return { larkAppId: d.larkAppId, botName: d.botName, online: true, error: `http_${r.status}` };
486
+ }
487
+ const j = await r.json();
488
+ return {
489
+ larkAppId: d.larkAppId,
490
+ botName: d.botName ?? j.botName,
491
+ online: true,
492
+ defaultOncall: j.defaultOncall,
493
+ autoboundChatCount: j.autoboundChatCount ?? 0,
494
+ };
495
+ }
496
+ catch (e) {
497
+ return { larkAppId: d.larkAppId, botName: d.botName, online: true, error: e?.message ?? String(e) };
498
+ }
499
+ }));
500
+ return jsonRes(res, 200, { bots: out });
501
+ }
502
+ let mBotDef;
503
+ if (req.method === 'PUT' && (mBotDef = url.pathname.match(/^\/api\/bots\/([^/]+)\/default-oncall$/))) {
504
+ const appId = decodeURIComponent(mBotDef[1]);
505
+ const chunks = [];
506
+ for await (const c of req)
507
+ chunks.push(c);
508
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
509
+ const upstream = await proxyToDaemon(appId, `/api/bot-default-oncall`, {
510
+ method: 'PUT',
511
+ headers: { 'content-type': 'application/json' },
512
+ body: raw,
513
+ });
514
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
515
+ res.end(await upstream.text());
516
+ return;
517
+ }
518
+ // Create a new chat — pick a creator from the user-selected larkAppIds
519
+ // (Feishu makes the calling bot the implicit first member, so picking
520
+ // anything else would silently add an unwanted bot). Auto-invite the
521
+ // operator using the creator bot's pre-resolved allowedUsers — open_ids
522
+ // are app-scoped, so creator daemon and operator open_id come from the
523
+ // SAME bot by construction. See dashboard/operator-selector.ts.
524
+ if (req.method === 'POST' && url.pathname === '/api/groups/create') {
525
+ let parsed;
526
+ try {
527
+ const chunks = [];
528
+ for await (const c of req)
529
+ chunks.push(c);
530
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
531
+ parsed = JSON.parse(raw);
532
+ }
533
+ catch {
534
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
535
+ }
536
+ const selectedIds = Array.isArray(parsed.larkAppIds)
537
+ ? parsed.larkAppIds.filter((x) => typeof x === 'string')
538
+ : [];
539
+ if (selectedIds.length === 0) {
540
+ return jsonRes(res, 400, { ok: false, error: 'larkAppIds_required' });
541
+ }
542
+ const explicit = Array.isArray(parsed.userOpenIds)
543
+ ? parsed.userOpenIds.filter((x) => typeof x === 'string')
544
+ : [];
545
+ const pick = pickCreatorForGroup(selectedIds, (id) => {
546
+ const d = registry.getByAppId(id);
547
+ return d ? { larkAppId: d.larkAppId, resolvedAllowedUsers: d.resolvedAllowedUsers ?? [] } : undefined;
548
+ });
549
+ if (!pick) {
550
+ return jsonRes(res, 503, { ok: false, error: 'no_online_daemon' });
551
+ }
552
+ const creator = registry.getByAppId(pick.creatorLarkAppId);
553
+ const merged = new Set([...explicit, ...pick.userOpenIds]);
554
+ // Auto-invite/transfer/notify target: prefer the explicit open_id passed
555
+ // by the caller (rare API consumer use), else the creator bot's first
556
+ // resolved allowlist entry.
557
+ const autoInvited = explicit[0] ?? pick.userOpenIds[0] ?? null;
558
+ const forwardBody = {
559
+ name: typeof parsed.name === 'string' ? parsed.name : undefined,
560
+ larkAppIds: selectedIds,
561
+ userOpenIds: [...merged],
562
+ // Auto-transfer ownership to the auto-invited operator. Scope-safe
563
+ // because the open_id was sourced from the creator bot's own allowlist.
564
+ transferOwnerTo: autoInvited ?? undefined,
565
+ // Send an @-mention message into the new chat so the operator gets a
566
+ // Feishu push notification — being a chat member alone doesn't always
567
+ // surface the chat in their sidebar (esp. mobile).
568
+ notifyOwnerOpenId: autoInvited ?? undefined,
569
+ };
570
+ const upstream = await fetch(`http://127.0.0.1:${creator.ipcPort}/api/groups/create`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(forwardBody) });
571
+ const upstreamText = await upstream.text();
572
+ let upstreamJson = null;
573
+ try {
574
+ upstreamJson = JSON.parse(upstreamText);
575
+ }
576
+ catch { /* leave null */ }
577
+ if (upstreamJson && typeof upstreamJson === 'object') {
578
+ // If Lark rejected the invite (open_id wrong scope, banned user, etc.)
579
+ // null out autoInvitedOpenId so the frontend doesn't falsely claim
580
+ // success — the user actually isn't a member of the new chat.
581
+ const invalidUsers = Array.isArray(upstreamJson.invalidUserIds)
582
+ ? upstreamJson.invalidUserIds
583
+ : [];
584
+ if (autoInvited && invalidUsers.includes(autoInvited)) {
585
+ upstreamJson.autoInvitedOpenId = null;
586
+ upstreamJson.autoInviteRejected = true;
587
+ // ownerTransferredTo is already null from daemon (it skips transfer
588
+ // when invitee_rejected), so nothing more to do here.
589
+ }
590
+ else {
591
+ upstreamJson.autoInvitedOpenId = autoInvited;
592
+ }
593
+ }
594
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
595
+ res.end(upstreamJson ? JSON.stringify(upstreamJson) : upstreamText);
596
+ return;
597
+ }
598
+ // Public SSE — relays aggregator's listener events
599
+ if (req.method === 'GET' && url.pathname === '/events') {
600
+ res.writeHead(200, {
601
+ 'content-type': 'text/event-stream',
602
+ 'cache-control': 'no-cache, no-transform',
603
+ 'connection': 'keep-alive',
604
+ });
605
+ res.write('retry: 5000\n\n');
606
+ const off = aggregator.on(ev => {
607
+ res.write(`event: ${ev.type}\ndata: ${JSON.stringify({ larkAppId: ev.larkAppId, body: ev.body })}\n\n`);
608
+ });
609
+ const hb = setInterval(() => {
610
+ res.write(`event: heartbeat\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`);
611
+ }, 15_000);
612
+ res.on('close', () => { off(); clearInterval(hb); });
613
+ return;
614
+ }
615
+ // Public API + static frontend land in Task 17 / 18. For now: 404.
616
+ jsonRes(res, 404, { error: 'not_found_yet', path: url.pathname });
617
+ }
618
+ catch (err) {
619
+ logger.error('[dashboard] handler error', err);
620
+ if (!res.headersSent)
621
+ jsonRes(res, 500, { error: String(err) });
622
+ }
623
+ });
624
+ server.listen(config.dashboard.port, config.dashboard.host, () => {
625
+ logger.info(`[dashboard] listening on ${config.dashboard.host}:${config.dashboard.port}`);
626
+ });
627
+ // Graceful shutdown
628
+ function shutdown() {
629
+ for (const off of subs.values())
630
+ off();
631
+ subs.clear();
632
+ registry.stop();
633
+ server.close(() => process.exit(0));
634
+ // Hard-exit fallback after 5s
635
+ setTimeout(() => process.exit(0), 5_000).unref();
636
+ }
637
+ process.on('SIGTERM', shutdown);
638
+ process.on('SIGINT', shutdown);
639
+ //# sourceMappingURL=dashboard.js.map