im-hub-pro 0.2.29

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 (384) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +497 -0
  3. package/README.zh-CN.md +496 -0
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +921 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/core/acp-server.d.ts +8 -0
  9. package/dist/core/acp-server.d.ts.map +1 -0
  10. package/dist/core/acp-server.js +266 -0
  11. package/dist/core/acp-server.js.map +1 -0
  12. package/dist/core/agent-base.d.ts +94 -0
  13. package/dist/core/agent-base.d.ts.map +1 -0
  14. package/dist/core/agent-base.js +374 -0
  15. package/dist/core/agent-base.js.map +1 -0
  16. package/dist/core/agent-cwd.d.ts +45 -0
  17. package/dist/core/agent-cwd.d.ts.map +1 -0
  18. package/dist/core/agent-cwd.js +178 -0
  19. package/dist/core/agent-cwd.js.map +1 -0
  20. package/dist/core/agent-cwd.test.d.ts +2 -0
  21. package/dist/core/agent-cwd.test.d.ts.map +1 -0
  22. package/dist/core/agent-cwd.test.js +149 -0
  23. package/dist/core/agent-cwd.test.js.map +1 -0
  24. package/dist/core/approval-bus.d.ts +232 -0
  25. package/dist/core/approval-bus.d.ts.map +1 -0
  26. package/dist/core/approval-bus.js +703 -0
  27. package/dist/core/approval-bus.js.map +1 -0
  28. package/dist/core/approval-bus.synthetic.test.d.ts +2 -0
  29. package/dist/core/approval-bus.synthetic.test.d.ts.map +1 -0
  30. package/dist/core/approval-bus.synthetic.test.js +182 -0
  31. package/dist/core/approval-bus.synthetic.test.js.map +1 -0
  32. package/dist/core/approval-bus.test.d.ts +2 -0
  33. package/dist/core/approval-bus.test.d.ts.map +1 -0
  34. package/dist/core/approval-bus.test.js +537 -0
  35. package/dist/core/approval-bus.test.js.map +1 -0
  36. package/dist/core/approval-router.d.ts +95 -0
  37. package/dist/core/approval-router.d.ts.map +1 -0
  38. package/dist/core/approval-router.js +450 -0
  39. package/dist/core/approval-router.js.map +1 -0
  40. package/dist/core/approval-router.test.d.ts +2 -0
  41. package/dist/core/approval-router.test.d.ts.map +1 -0
  42. package/dist/core/approval-router.test.js +413 -0
  43. package/dist/core/approval-router.test.js.map +1 -0
  44. package/dist/core/audit-log.d.ts +55 -0
  45. package/dist/core/audit-log.d.ts.map +1 -0
  46. package/dist/core/audit-log.js +203 -0
  47. package/dist/core/audit-log.js.map +1 -0
  48. package/dist/core/bgjob-reader.d.ts +65 -0
  49. package/dist/core/bgjob-reader.d.ts.map +1 -0
  50. package/dist/core/bgjob-reader.js +212 -0
  51. package/dist/core/bgjob-reader.js.map +1 -0
  52. package/dist/core/bgjob-reader.test.d.ts +2 -0
  53. package/dist/core/bgjob-reader.test.d.ts.map +1 -0
  54. package/dist/core/bgjob-reader.test.js +178 -0
  55. package/dist/core/bgjob-reader.test.js.map +1 -0
  56. package/dist/core/circuit-breaker.d.ts +37 -0
  57. package/dist/core/circuit-breaker.d.ts.map +1 -0
  58. package/dist/core/circuit-breaker.js +115 -0
  59. package/dist/core/circuit-breaker.js.map +1 -0
  60. package/dist/core/commands/agent.d.ts +4 -0
  61. package/dist/core/commands/agent.d.ts.map +1 -0
  62. package/dist/core/commands/agent.js +21 -0
  63. package/dist/core/commands/agent.js.map +1 -0
  64. package/dist/core/commands/approval.d.ts +3 -0
  65. package/dist/core/commands/approval.d.ts.map +1 -0
  66. package/dist/core/commands/approval.js +44 -0
  67. package/dist/core/commands/approval.js.map +1 -0
  68. package/dist/core/commands/approval.test.d.ts +2 -0
  69. package/dist/core/commands/approval.test.d.ts.map +1 -0
  70. package/dist/core/commands/approval.test.js +85 -0
  71. package/dist/core/commands/approval.test.js.map +1 -0
  72. package/dist/core/commands/audit.d.ts +3 -0
  73. package/dist/core/commands/audit.d.ts.map +1 -0
  74. package/dist/core/commands/audit.js +84 -0
  75. package/dist/core/commands/audit.js.map +1 -0
  76. package/dist/core/commands/builtin.d.ts +3 -0
  77. package/dist/core/commands/builtin.d.ts.map +1 -0
  78. package/dist/core/commands/builtin.js +26 -0
  79. package/dist/core/commands/builtin.js.map +1 -0
  80. package/dist/core/commands/job.d.ts +3 -0
  81. package/dist/core/commands/job.d.ts.map +1 -0
  82. package/dist/core/commands/job.js +195 -0
  83. package/dist/core/commands/job.js.map +1 -0
  84. package/dist/core/commands/model.d.ts +9 -0
  85. package/dist/core/commands/model.d.ts.map +1 -0
  86. package/dist/core/commands/model.js +183 -0
  87. package/dist/core/commands/model.js.map +1 -0
  88. package/dist/core/commands/plan.d.ts +3 -0
  89. package/dist/core/commands/plan.d.ts.map +1 -0
  90. package/dist/core/commands/plan.js +75 -0
  91. package/dist/core/commands/plan.js.map +1 -0
  92. package/dist/core/commands/plan.test.d.ts +2 -0
  93. package/dist/core/commands/plan.test.d.ts.map +1 -0
  94. package/dist/core/commands/plan.test.js +122 -0
  95. package/dist/core/commands/plan.test.js.map +1 -0
  96. package/dist/core/commands/router.d.ts +3 -0
  97. package/dist/core/commands/router.d.ts.map +1 -0
  98. package/dist/core/commands/router.js +71 -0
  99. package/dist/core/commands/router.js.map +1 -0
  100. package/dist/core/commands/schedule.d.ts +3 -0
  101. package/dist/core/commands/schedule.d.ts.map +1 -0
  102. package/dist/core/commands/schedule.js +123 -0
  103. package/dist/core/commands/schedule.js.map +1 -0
  104. package/dist/core/commands/sessions.d.ts +3 -0
  105. package/dist/core/commands/sessions.d.ts.map +1 -0
  106. package/dist/core/commands/sessions.js +88 -0
  107. package/dist/core/commands/sessions.js.map +1 -0
  108. package/dist/core/commands/stats.d.ts +3 -0
  109. package/dist/core/commands/stats.d.ts.map +1 -0
  110. package/dist/core/commands/stats.js +73 -0
  111. package/dist/core/commands/stats.js.map +1 -0
  112. package/dist/core/commands/think.d.ts +3 -0
  113. package/dist/core/commands/think.d.ts.map +1 -0
  114. package/dist/core/commands/think.js +28 -0
  115. package/dist/core/commands/think.js.map +1 -0
  116. package/dist/core/commands/workspaces.d.ts +3 -0
  117. package/dist/core/commands/workspaces.d.ts.map +1 -0
  118. package/dist/core/commands/workspaces.js +47 -0
  119. package/dist/core/commands/workspaces.js.map +1 -0
  120. package/dist/core/config-schema.d.ts +58 -0
  121. package/dist/core/config-schema.d.ts.map +1 -0
  122. package/dist/core/config-schema.js +63 -0
  123. package/dist/core/config-schema.js.map +1 -0
  124. package/dist/core/cron.d.ts +29 -0
  125. package/dist/core/cron.d.ts.map +1 -0
  126. package/dist/core/cron.js +184 -0
  127. package/dist/core/cron.js.map +1 -0
  128. package/dist/core/event-bus.d.ts +80 -0
  129. package/dist/core/event-bus.d.ts.map +1 -0
  130. package/dist/core/event-bus.js +62 -0
  131. package/dist/core/event-bus.js.map +1 -0
  132. package/dist/core/intent-llm.d.ts +27 -0
  133. package/dist/core/intent-llm.d.ts.map +1 -0
  134. package/dist/core/intent-llm.js +170 -0
  135. package/dist/core/intent-llm.js.map +1 -0
  136. package/dist/core/intent.d.ts +12 -0
  137. package/dist/core/intent.d.ts.map +1 -0
  138. package/dist/core/intent.js +187 -0
  139. package/dist/core/intent.js.map +1 -0
  140. package/dist/core/job-board.d.ts +84 -0
  141. package/dist/core/job-board.d.ts.map +1 -0
  142. package/dist/core/job-board.js +379 -0
  143. package/dist/core/job-board.js.map +1 -0
  144. package/dist/core/logger.d.ts +6 -0
  145. package/dist/core/logger.d.ts.map +1 -0
  146. package/dist/core/logger.js +54 -0
  147. package/dist/core/logger.js.map +1 -0
  148. package/dist/core/metrics.d.ts +55 -0
  149. package/dist/core/metrics.d.ts.map +1 -0
  150. package/dist/core/metrics.js +291 -0
  151. package/dist/core/metrics.js.map +1 -0
  152. package/dist/core/onboarding.d.ts +94 -0
  153. package/dist/core/onboarding.d.ts.map +1 -0
  154. package/dist/core/onboarding.js +426 -0
  155. package/dist/core/onboarding.js.map +1 -0
  156. package/dist/core/onboarding.test.d.ts +2 -0
  157. package/dist/core/onboarding.test.d.ts.map +1 -0
  158. package/dist/core/onboarding.test.js +112 -0
  159. package/dist/core/onboarding.test.js.map +1 -0
  160. package/dist/core/rate-limiter.d.ts +44 -0
  161. package/dist/core/rate-limiter.d.ts.map +1 -0
  162. package/dist/core/rate-limiter.js +115 -0
  163. package/dist/core/rate-limiter.js.map +1 -0
  164. package/dist/core/registry.d.ts +32 -0
  165. package/dist/core/registry.d.ts.map +1 -0
  166. package/dist/core/registry.js +122 -0
  167. package/dist/core/registry.js.map +1 -0
  168. package/dist/core/router.d.ts +41 -0
  169. package/dist/core/router.d.ts.map +1 -0
  170. package/dist/core/router.js +431 -0
  171. package/dist/core/router.js.map +1 -0
  172. package/dist/core/schedule.d.ts +65 -0
  173. package/dist/core/schedule.d.ts.map +1 -0
  174. package/dist/core/schedule.js +316 -0
  175. package/dist/core/schedule.js.map +1 -0
  176. package/dist/core/session-subtasks.test.d.ts +2 -0
  177. package/dist/core/session-subtasks.test.d.ts.map +1 -0
  178. package/dist/core/session-subtasks.test.js +88 -0
  179. package/dist/core/session-subtasks.test.js.map +1 -0
  180. package/dist/core/session.d.ts +182 -0
  181. package/dist/core/session.d.ts.map +1 -0
  182. package/dist/core/session.js +774 -0
  183. package/dist/core/session.js.map +1 -0
  184. package/dist/core/sqlite-helper.d.ts +37 -0
  185. package/dist/core/sqlite-helper.d.ts.map +1 -0
  186. package/dist/core/sqlite-helper.js +79 -0
  187. package/dist/core/sqlite-helper.js.map +1 -0
  188. package/dist/core/transcribe.d.ts +25 -0
  189. package/dist/core/transcribe.d.ts.map +1 -0
  190. package/dist/core/transcribe.js +217 -0
  191. package/dist/core/transcribe.js.map +1 -0
  192. package/dist/core/transcribe.test.d.ts +2 -0
  193. package/dist/core/transcribe.test.d.ts.map +1 -0
  194. package/dist/core/transcribe.test.js +163 -0
  195. package/dist/core/transcribe.test.js.map +1 -0
  196. package/dist/core/types.d.ts +352 -0
  197. package/dist/core/types.d.ts.map +1 -0
  198. package/dist/core/types.js +3 -0
  199. package/dist/core/types.js.map +1 -0
  200. package/dist/core/workspace.d.ts +67 -0
  201. package/dist/core/workspace.d.ts.map +1 -0
  202. package/dist/core/workspace.js +113 -0
  203. package/dist/core/workspace.js.map +1 -0
  204. package/dist/index.d.ts +5 -0
  205. package/dist/index.d.ts.map +1 -0
  206. package/dist/index.js +6 -0
  207. package/dist/index.js.map +1 -0
  208. package/dist/plugins/agents/acp/acp-adapter.d.ts +16 -0
  209. package/dist/plugins/agents/acp/acp-adapter.d.ts.map +1 -0
  210. package/dist/plugins/agents/acp/acp-adapter.js +49 -0
  211. package/dist/plugins/agents/acp/acp-adapter.js.map +1 -0
  212. package/dist/plugins/agents/acp/acp-client.d.ts +32 -0
  213. package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -0
  214. package/dist/plugins/agents/acp/acp-client.js +175 -0
  215. package/dist/plugins/agents/acp/acp-client.js.map +1 -0
  216. package/dist/plugins/agents/acp/discovery.d.ts +19 -0
  217. package/dist/plugins/agents/acp/discovery.d.ts.map +1 -0
  218. package/dist/plugins/agents/acp/discovery.js +109 -0
  219. package/dist/plugins/agents/acp/discovery.js.map +1 -0
  220. package/dist/plugins/agents/acp/index.d.ts +4 -0
  221. package/dist/plugins/agents/acp/index.d.ts.map +1 -0
  222. package/dist/plugins/agents/acp/index.js +4 -0
  223. package/dist/plugins/agents/acp/index.js.map +1 -0
  224. package/dist/plugins/agents/acp/types.d.ts +62 -0
  225. package/dist/plugins/agents/acp/types.d.ts.map +1 -0
  226. package/dist/plugins/agents/acp/types.js +5 -0
  227. package/dist/plugins/agents/acp/types.js.map +1 -0
  228. package/dist/plugins/agents/claude-code/adapter.test.d.ts +2 -0
  229. package/dist/plugins/agents/claude-code/adapter.test.d.ts.map +1 -0
  230. package/dist/plugins/agents/claude-code/adapter.test.js +195 -0
  231. package/dist/plugins/agents/claude-code/adapter.test.js.map +1 -0
  232. package/dist/plugins/agents/claude-code/index.d.ts +25 -0
  233. package/dist/plugins/agents/claude-code/index.d.ts.map +1 -0
  234. package/dist/plugins/agents/claude-code/index.js +184 -0
  235. package/dist/plugins/agents/claude-code/index.js.map +1 -0
  236. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +42 -0
  237. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -0
  238. package/dist/plugins/agents/claude-code/mcp-approval-server.js +235 -0
  239. package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -0
  240. package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts +2 -0
  241. package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts.map +1 -0
  242. package/dist/plugins/agents/claude-code/mcp-approval-server.test.js +188 -0
  243. package/dist/plugins/agents/claude-code/mcp-approval-server.test.js.map +1 -0
  244. package/dist/plugins/agents/codex/adapter.test.d.ts +2 -0
  245. package/dist/plugins/agents/codex/adapter.test.d.ts.map +1 -0
  246. package/dist/plugins/agents/codex/adapter.test.js +192 -0
  247. package/dist/plugins/agents/codex/adapter.test.js.map +1 -0
  248. package/dist/plugins/agents/codex/index.d.ts +37 -0
  249. package/dist/plugins/agents/codex/index.d.ts.map +1 -0
  250. package/dist/plugins/agents/codex/index.js +254 -0
  251. package/dist/plugins/agents/codex/index.js.map +1 -0
  252. package/dist/plugins/agents/copilot/index.d.ts +35 -0
  253. package/dist/plugins/agents/copilot/index.d.ts.map +1 -0
  254. package/dist/plugins/agents/copilot/index.js +182 -0
  255. package/dist/plugins/agents/copilot/index.js.map +1 -0
  256. package/dist/plugins/agents/opencode/adapter.test.d.ts +2 -0
  257. package/dist/plugins/agents/opencode/adapter.test.d.ts.map +1 -0
  258. package/dist/plugins/agents/opencode/adapter.test.js +139 -0
  259. package/dist/plugins/agents/opencode/adapter.test.js.map +1 -0
  260. package/dist/plugins/agents/opencode/http-adapter.test.d.ts +2 -0
  261. package/dist/plugins/agents/opencode/http-adapter.test.d.ts.map +1 -0
  262. package/dist/plugins/agents/opencode/http-adapter.test.js +492 -0
  263. package/dist/plugins/agents/opencode/http-adapter.test.js.map +1 -0
  264. package/dist/plugins/agents/opencode/index.d.ts +5 -0
  265. package/dist/plugins/agents/opencode/index.d.ts.map +1 -0
  266. package/dist/plugins/agents/opencode/index.js +30 -0
  267. package/dist/plugins/agents/opencode/index.js.map +1 -0
  268. package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts +138 -0
  269. package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts.map +1 -0
  270. package/dist/plugins/agents/opencode/opencode-http-adapter.js +549 -0
  271. package/dist/plugins/agents/opencode/opencode-http-adapter.js.map +1 -0
  272. package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts +24 -0
  273. package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts.map +1 -0
  274. package/dist/plugins/agents/opencode/opencode-stdio-adapter.js +103 -0
  275. package/dist/plugins/agents/opencode/opencode-stdio-adapter.js.map +1 -0
  276. package/dist/plugins/agents/opencode/serve-manager.d.ts +27 -0
  277. package/dist/plugins/agents/opencode/serve-manager.d.ts.map +1 -0
  278. package/dist/plugins/agents/opencode/serve-manager.js +190 -0
  279. package/dist/plugins/agents/opencode/serve-manager.js.map +1 -0
  280. package/dist/plugins/messengers/discord/discord-adapter.d.ts +22 -0
  281. package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -0
  282. package/dist/plugins/messengers/discord/discord-adapter.js +241 -0
  283. package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -0
  284. package/dist/plugins/messengers/discord/discord-adapter.test.d.ts +2 -0
  285. package/dist/plugins/messengers/discord/discord-adapter.test.d.ts.map +1 -0
  286. package/dist/plugins/messengers/discord/discord-adapter.test.js +332 -0
  287. package/dist/plugins/messengers/discord/discord-adapter.test.js.map +1 -0
  288. package/dist/plugins/messengers/discord/index.d.ts +4 -0
  289. package/dist/plugins/messengers/discord/index.d.ts.map +1 -0
  290. package/dist/plugins/messengers/discord/index.js +4 -0
  291. package/dist/plugins/messengers/discord/index.js.map +1 -0
  292. package/dist/plugins/messengers/discord/markdown-to-discord.d.ts +11 -0
  293. package/dist/plugins/messengers/discord/markdown-to-discord.d.ts.map +1 -0
  294. package/dist/plugins/messengers/discord/markdown-to-discord.js +59 -0
  295. package/dist/plugins/messengers/discord/markdown-to-discord.js.map +1 -0
  296. package/dist/plugins/messengers/discord/types.d.ts +9 -0
  297. package/dist/plugins/messengers/discord/types.d.ts.map +1 -0
  298. package/dist/plugins/messengers/discord/types.js +3 -0
  299. package/dist/plugins/messengers/discord/types.js.map +1 -0
  300. package/dist/plugins/messengers/feishu/card-builder.d.ts +23 -0
  301. package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -0
  302. package/dist/plugins/messengers/feishu/card-builder.js +89 -0
  303. package/dist/plugins/messengers/feishu/card-builder.js.map +1 -0
  304. package/dist/plugins/messengers/feishu/feishu-adapter.d.ts +33 -0
  305. package/dist/plugins/messengers/feishu/feishu-adapter.d.ts.map +1 -0
  306. package/dist/plugins/messengers/feishu/feishu-adapter.js +195 -0
  307. package/dist/plugins/messengers/feishu/feishu-adapter.js.map +1 -0
  308. package/dist/plugins/messengers/feishu/feishu-client.d.ts +44 -0
  309. package/dist/plugins/messengers/feishu/feishu-client.d.ts.map +1 -0
  310. package/dist/plugins/messengers/feishu/feishu-client.js +120 -0
  311. package/dist/plugins/messengers/feishu/feishu-client.js.map +1 -0
  312. package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts +2 -0
  313. package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts.map +1 -0
  314. package/dist/plugins/messengers/feishu/feishu-dedup.test.js +70 -0
  315. package/dist/plugins/messengers/feishu/feishu-dedup.test.js.map +1 -0
  316. package/dist/plugins/messengers/feishu/index.d.ts +4 -0
  317. package/dist/plugins/messengers/feishu/index.d.ts.map +1 -0
  318. package/dist/plugins/messengers/feishu/index.js +4 -0
  319. package/dist/plugins/messengers/feishu/index.js.map +1 -0
  320. package/dist/plugins/messengers/feishu/types.d.ts +113 -0
  321. package/dist/plugins/messengers/feishu/types.d.ts.map +1 -0
  322. package/dist/plugins/messengers/feishu/types.js +4 -0
  323. package/dist/plugins/messengers/feishu/types.js.map +1 -0
  324. package/dist/plugins/messengers/telegram/index.d.ts +4 -0
  325. package/dist/plugins/messengers/telegram/index.d.ts.map +1 -0
  326. package/dist/plugins/messengers/telegram/index.js +4 -0
  327. package/dist/plugins/messengers/telegram/index.js.map +1 -0
  328. package/dist/plugins/messengers/telegram/markdown-to-html.d.ts +5 -0
  329. package/dist/plugins/messengers/telegram/markdown-to-html.d.ts.map +1 -0
  330. package/dist/plugins/messengers/telegram/markdown-to-html.js +186 -0
  331. package/dist/plugins/messengers/telegram/markdown-to-html.js.map +1 -0
  332. package/dist/plugins/messengers/telegram/media-download.d.ts +51 -0
  333. package/dist/plugins/messengers/telegram/media-download.d.ts.map +1 -0
  334. package/dist/plugins/messengers/telegram/media-download.js +224 -0
  335. package/dist/plugins/messengers/telegram/media-download.js.map +1 -0
  336. package/dist/plugins/messengers/telegram/media-download.test.d.ts +2 -0
  337. package/dist/plugins/messengers/telegram/media-download.test.d.ts.map +1 -0
  338. package/dist/plugins/messengers/telegram/media-download.test.js +125 -0
  339. package/dist/plugins/messengers/telegram/media-download.test.js.map +1 -0
  340. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +62 -0
  341. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -0
  342. package/dist/plugins/messengers/telegram/telegram-adapter.js +653 -0
  343. package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -0
  344. package/dist/plugins/messengers/telegram/types.d.ts +47 -0
  345. package/dist/plugins/messengers/telegram/types.d.ts.map +1 -0
  346. package/dist/plugins/messengers/telegram/types.js +3 -0
  347. package/dist/plugins/messengers/telegram/types.js.map +1 -0
  348. package/dist/plugins/messengers/wechat/ilink-adapter.d.ts +68 -0
  349. package/dist/plugins/messengers/wechat/ilink-adapter.d.ts.map +1 -0
  350. package/dist/plugins/messengers/wechat/ilink-adapter.js +483 -0
  351. package/dist/plugins/messengers/wechat/ilink-adapter.js.map +1 -0
  352. package/dist/plugins/messengers/wechat/ilink-client.d.ts +66 -0
  353. package/dist/plugins/messengers/wechat/ilink-client.d.ts.map +1 -0
  354. package/dist/plugins/messengers/wechat/ilink-client.js +288 -0
  355. package/dist/plugins/messengers/wechat/ilink-client.js.map +1 -0
  356. package/dist/plugins/messengers/wechat/ilink-types.d.ts +173 -0
  357. package/dist/plugins/messengers/wechat/ilink-types.d.ts.map +1 -0
  358. package/dist/plugins/messengers/wechat/ilink-types.js +12 -0
  359. package/dist/plugins/messengers/wechat/ilink-types.js.map +1 -0
  360. package/dist/utils/backoff.d.ts +35 -0
  361. package/dist/utils/backoff.d.ts.map +1 -0
  362. package/dist/utils/backoff.js +59 -0
  363. package/dist/utils/backoff.js.map +1 -0
  364. package/dist/utils/cross-platform.d.ts +26 -0
  365. package/dist/utils/cross-platform.d.ts.map +1 -0
  366. package/dist/utils/cross-platform.js +58 -0
  367. package/dist/utils/cross-platform.js.map +1 -0
  368. package/dist/utils/message-split.d.ts +14 -0
  369. package/dist/utils/message-split.d.ts.map +1 -0
  370. package/dist/utils/message-split.js +65 -0
  371. package/dist/utils/message-split.js.map +1 -0
  372. package/dist/utils/safe-equal.d.ts +2 -0
  373. package/dist/utils/safe-equal.d.ts.map +1 -0
  374. package/dist/utils/safe-equal.js +11 -0
  375. package/dist/utils/safe-equal.js.map +1 -0
  376. package/dist/web/public/_app.js +196 -0
  377. package/dist/web/public/index.html +935 -0
  378. package/dist/web/public/settings.html +1181 -0
  379. package/dist/web/public/tasks.html +1827 -0
  380. package/dist/web/server.d.ts +11 -0
  381. package/dist/web/server.d.ts.map +1 -0
  382. package/dist/web/server.js +1820 -0
  383. package/dist/web/server.js.map +1 -0
  384. package/package.json +73 -0
@@ -0,0 +1,1820 @@
1
+ // Web chat server — HTTP + WebSocket for browser-based agent interaction
2
+ import { createServer } from 'http';
3
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
4
+ import { readdir, stat, readFile, writeFile, rename, unlink } from 'fs/promises';
5
+ import { join, dirname, resolve as resolvePath, sep as pathSep, relative as relativePath } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { homedir } from 'os';
8
+ import { randomBytes } from 'crypto';
9
+ import { WebSocketServer } from 'ws';
10
+ import { parseMessage, routeMessage } from '../core/router.js';
11
+ import { sessionManager } from '../core/session.js';
12
+ import { registry } from '../core/registry.js';
13
+ import { generateTraceId, createLogger, logger as rootLogger } from '../core/logger.js';
14
+ import { validateConfig } from '../core/config-schema.js';
15
+ import { safeEqual } from '../utils/safe-equal.js';
16
+ const webLog = rootLogger.child({ component: 'web' });
17
+ /**
18
+ * Module-level reference to the button-callback handler that approval-router
19
+ * registers on our synthetic web messenger. The WS message switch dispatches
20
+ * `approval-action` events through this so an in-page approval button click
21
+ * flows back into approvalBus.resolvePending(), same path as a Telegram
22
+ * inline-button tap. Set by the web messenger's `onButtonCallback`; remains
23
+ * undefined until approval-router installs (which happens before this file's
24
+ * exported startWebServer is called from cli.ts).
25
+ */
26
+ let webButtonHandler;
27
+ import { isAgentAvailableCached, loadConfig, saveConfig, } from '../core/onboarding.js';
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const PUBLIC_DIR = join(__dirname, 'public');
30
+ const DEFAULT_PORT = 3000;
31
+ const WEB_TOKEN_DIR = join(homedir(), '.im-hub');
32
+ const WEB_TOKEN_FILE = join(WEB_TOKEN_DIR, 'web-token');
33
+ function generateToken() {
34
+ return randomBytes(32).toString('hex');
35
+ }
36
+ function getOrCreateWebToken() {
37
+ try {
38
+ return readFileSync(WEB_TOKEN_FILE, 'utf-8').trim();
39
+ }
40
+ catch {
41
+ const token = generateToken();
42
+ mkdirSync(WEB_TOKEN_DIR, { recursive: true });
43
+ writeFileSync(WEB_TOKEN_FILE, token, { mode: 0o600 });
44
+ return token;
45
+ }
46
+ }
47
+ function isMasked(value) {
48
+ if (!value)
49
+ return false;
50
+ return /^.{0,2}\*{2,}.{0,2}$/.test(value);
51
+ }
52
+ /**
53
+ * Start the web chat server
54
+ */
55
+ export async function startWebServer(options) {
56
+ const port = options.port || DEFAULT_PORT;
57
+ const webToken = getOrCreateWebToken();
58
+ const clients = new Map();
59
+ // HTTP request handler — static files + REST API
60
+ const httpServer = createServer(async (req, res) => {
61
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
62
+ // Static pages
63
+ if (url.pathname === '/' || url.pathname === '/index.html') {
64
+ return serveIndexHtml(res, join(PUBLIC_DIR, 'index.html'), webToken);
65
+ }
66
+ if (url.pathname === '/settings' || url.pathname === '/settings.html') {
67
+ return serveIndexHtml(res, join(PUBLIC_DIR, 'settings.html'), webToken);
68
+ }
69
+ if (url.pathname === '/tasks' || url.pathname === '/tasks.html') {
70
+ return serveIndexHtml(res, join(PUBLIC_DIR, 'tasks.html'), webToken);
71
+ }
72
+ // M4: /api/health is intentionally public (k8s liveness probe friendly)
73
+ // — declare it BEFORE the /api/* token gate so callers don't need to
74
+ // know the web token. Returns only operational status, not config.
75
+ if (url.pathname === '/api/health' && req.method === 'GET') {
76
+ return handleHealth(req, res);
77
+ }
78
+ // Shared web-console utilities (theme manager + i18n + error boundary
79
+ // + auth-aware fetch). Loaded synchronously by every static page in
80
+ // <head> so the theme can apply before first paint. No secrets — safe
81
+ // to serve un-authenticated.
82
+ if (url.pathname === '/_app.js' && req.method === 'GET') {
83
+ return serveStatic(res, join(PUBLIC_DIR, '_app.js'), 'application/javascript; charset=utf-8');
84
+ }
85
+ // REST API — require auth token
86
+ if (url.pathname.startsWith('/api/')) {
87
+ const token = req.headers['x-im-hub-token'] || '';
88
+ if (!safeEqual(token, webToken)) {
89
+ res.writeHead(401);
90
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
91
+ return;
92
+ }
93
+ }
94
+ // REST API
95
+ if (url.pathname === '/api/config' && req.method === 'GET') {
96
+ return handleGetConfig(req, res);
97
+ }
98
+ if (url.pathname === '/api/config' && req.method === 'PUT') {
99
+ return handlePutConfig(req, res);
100
+ }
101
+ if (url.pathname === '/api/agents/status' && req.method === 'GET') {
102
+ return handleAgentsStatus(req, res);
103
+ }
104
+ if (url.pathname === '/api/agents/acp/test' && req.method === 'POST') {
105
+ return handleAcpTest(req, res);
106
+ }
107
+ if (url.pathname === '/api/agents/acp/discover' && req.method === 'POST') {
108
+ return handleAcpDiscover(req, res);
109
+ }
110
+ // Jobs
111
+ if (url.pathname === '/api/jobs' && req.method === 'GET') {
112
+ return handleListJobs(req, res, url);
113
+ }
114
+ const jobIdMatch = url.pathname.match(/^\/api\/jobs\/(\d+)$/);
115
+ if (jobIdMatch && req.method === 'GET') {
116
+ return handleGetJob(req, res, parseInt(jobIdMatch[1], 10));
117
+ }
118
+ const jobCancelMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
119
+ if (jobCancelMatch && req.method === 'POST') {
120
+ return handleCancelJob(req, res, parseInt(jobCancelMatch[1], 10));
121
+ }
122
+ const jobRunMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/run$/);
123
+ if (jobRunMatch && req.method === 'POST') {
124
+ return handleRunJob(req, res, parseInt(jobRunMatch[1], 10));
125
+ }
126
+ if (url.pathname === '/api/jobs' && req.method === 'POST') {
127
+ return handleCreateJob(req, res);
128
+ }
129
+ // bgjobs (read-only view of ~/.claude/bgjobs, ~/.config/opencode/bgjobs, ~/.codex/bgjobs)
130
+ if (url.pathname === '/api/bgjobs' && req.method === 'GET') {
131
+ return handleListBgjobs(req, res, url);
132
+ }
133
+ const bgjobIdMatch = url.pathname.match(/^\/api\/bgjobs\/([\w.-]+)$/);
134
+ if (bgjobIdMatch && req.method === 'GET') {
135
+ return handleGetBgjob(req, res, bgjobIdMatch[1], url);
136
+ }
137
+ // Subtasks (flattened view of session.subtasks across all conversations)
138
+ if (url.pathname === '/api/subtasks' && req.method === 'GET') {
139
+ return handleListSubtasks(req, res, url);
140
+ }
141
+ // Schedules
142
+ if (url.pathname === '/api/schedules' && req.method === 'GET') {
143
+ return handleListSchedules(req, res, url);
144
+ }
145
+ if (url.pathname === '/api/workspaces' && req.method === 'GET') {
146
+ return handleListWorkspaces(req, res, url);
147
+ }
148
+ if (url.pathname === '/api/workspaces' && req.method === 'POST') {
149
+ return handleCreateOrUpdateWorkspace(req, res);
150
+ }
151
+ const workspaceIdMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
152
+ if (workspaceIdMatch && req.method === 'PATCH') {
153
+ return handleCreateOrUpdateWorkspace(req, res, workspaceIdMatch[1]);
154
+ }
155
+ if (workspaceIdMatch && req.method === 'DELETE') {
156
+ return handleDeleteWorkspace(req, res, workspaceIdMatch[1]);
157
+ }
158
+ if (url.pathname === '/api/metrics' && req.method === 'GET') {
159
+ return handleMetrics(req, res, url);
160
+ }
161
+ if (url.pathname === '/api/audit' && req.method === 'GET') {
162
+ return handleAudit(req, res, url);
163
+ }
164
+ // PR-B: agent health snapshot (circuit breaker + rate-limiter remaining
165
+ // + latency p50/95/99) consumed by the Health tab in /tasks.
166
+ if (url.pathname === '/api/agent-health' && req.method === 'GET') {
167
+ return handleAgentHealth(req, res);
168
+ }
169
+ // PR-B: HITL approvals — global pending list + per-reqId resolve.
170
+ if (url.pathname === '/api/approvals' && req.method === 'GET') {
171
+ return handleListApprovals(req, res);
172
+ }
173
+ const approvalResolveMatch = url.pathname.match(/^\/api\/approvals\/([^/]+)\/resolve$/);
174
+ if (approvalResolveMatch && req.method === 'POST') {
175
+ return handleResolveApproval(req, res, approvalResolveMatch[1]);
176
+ }
177
+ // PR-D: Agent workspace file browser. Read-only inspection of
178
+ // ~/.im-hub-workspaces/<agent>/ contents — list dirs, peek small
179
+ // text files. PUT path supports inline editing (annotate CLAUDE.md,
180
+ // AGENTS.md, etc.) — same traversal/size guards as GET.
181
+ if (url.pathname === '/api/workspace-files' && req.method === 'GET') {
182
+ return handleWorkspaceFiles(req, res, url);
183
+ }
184
+ if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
185
+ return handleWorkspaceFileWrite(req, res, url);
186
+ }
187
+ // PR-D: Job batch operations. Same semantics as /api/jobs/:id/cancel
188
+ // and /run but accepts an array of ids in one request — saves N
189
+ // round-trips when the user multi-selects a long list.
190
+ if (url.pathname === '/api/jobs/batch-cancel' && req.method === 'POST') {
191
+ return handleBatchJob(req, res, 'cancel');
192
+ }
193
+ if (url.pathname === '/api/jobs/batch-run' && req.method === 'POST') {
194
+ return handleBatchJob(req, res, 'run', options.defaultAgent);
195
+ }
196
+ // PR-C: SSE event stream — audit / approval / job / metrics events
197
+ // pushed real-time so the dashboard stops polling. EventSource has no
198
+ // header API, so the token rides in `?token=<webToken>` (same shape
199
+ // the WS upgrade uses). Auth is validated inside the handler since
200
+ // /events is outside the /api/* token gate above.
201
+ if (url.pathname === '/events' && req.method === 'GET') {
202
+ const evToken = url.searchParams.get('token') || '';
203
+ if (!safeEqual(evToken, webToken)) {
204
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
205
+ res.end('Unauthorized');
206
+ return;
207
+ }
208
+ return handleEventsSSE(req, res);
209
+ }
210
+ // /api/health handled above the token gate (M4) — keep this comment so
211
+ // future contributors don't re-add the route inside the auth block.
212
+ if (url.pathname === '/api/notify' && req.method === 'POST') {
213
+ return handleNotify(req, res);
214
+ }
215
+ if (url.pathname === '/api/invoke' && req.method === 'POST') {
216
+ return handleInvoke(req, res, options.defaultAgent);
217
+ }
218
+ res.writeHead(404);
219
+ res.end('Not found');
220
+ });
221
+ // WebSocket server
222
+ const wss = new WebSocketServer({ server: httpServer });
223
+ // M3: cap concurrent WS clients so a leaked / shared web token can't OOM
224
+ // the host by opening unbounded connections. Default 100 is generous for
225
+ // a single-user / small-team setup; production multi-tenant should set
226
+ // IMHUB_MAX_WS_CLIENTS to a higher value.
227
+ const maxWsClients = (() => {
228
+ const raw = process.env.IMHUB_MAX_WS_CLIENTS;
229
+ if (raw) {
230
+ const n = parseInt(raw, 10);
231
+ if (Number.isFinite(n) && n > 0)
232
+ return n;
233
+ }
234
+ return 100;
235
+ })();
236
+ wss.on('connection', (ws, req) => {
237
+ if (clients.size >= maxWsClients) {
238
+ // 1013 = "Try Again Later" per RFC 6455. Slightly nicer than a flat
239
+ // close — clients with reconnect logic will back off.
240
+ webLog.warn({
241
+ event: 'ws.cap_reached',
242
+ active: clients.size,
243
+ cap: maxWsClients,
244
+ }, 'WS connection refused (cap)');
245
+ ws.close(1013, 'Server too busy');
246
+ return;
247
+ }
248
+ // Verify token from URL query before accepting connection
249
+ const wsUrl = new URL(req.url || '/', `http://localhost:${port}`);
250
+ const wsToken = wsUrl.searchParams.get('token') || '';
251
+ if (!safeEqual(wsToken, webToken)) {
252
+ ws.close(1008, 'Unauthorized');
253
+ return;
254
+ }
255
+ const clientId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
256
+ const client = { ws, id: clientId, agent: options.defaultAgent };
257
+ clients.set(clientId, client);
258
+ webLog.info({ clientId }, 'Client connected');
259
+ // Send available agents list
260
+ sendToClient(ws, {
261
+ type: 'init',
262
+ agents: registry.listAgents(),
263
+ defaultAgent: options.defaultAgent,
264
+ clientId,
265
+ });
266
+ // Load existing session history if available
267
+ sendSessionHistory(ws, clientId, options.defaultAgent);
268
+ ws.on('message', async (data) => {
269
+ try {
270
+ const msg = JSON.parse(data.toString());
271
+ // Approval-button click intercept. The user tapped an in-page
272
+ // approval card button; route it through the web messenger's
273
+ // button handler (registered by approval-router on install) the
274
+ // same way a Telegram inline-keyboard tap is routed. We don't
275
+ // call handleClientMessage for these — they're not chat input.
276
+ if (msg && msg.type === 'approval-action') {
277
+ const actionData = String(msg.data || '');
278
+ const messageId = String(msg.messageId || '');
279
+ webLog.info({
280
+ event: 'approval.web.click_received',
281
+ clientId, data: actionData, messageId,
282
+ handlerBound: !!webButtonHandler,
283
+ });
284
+ if (!actionData || !messageId) {
285
+ sendToClient(ws, { type: 'error', message: 'approval-action missing data/messageId' });
286
+ return;
287
+ }
288
+ if (!webButtonHandler) {
289
+ // Without the handler, a click would silently no-op forever — the
290
+ // failure mode that PR-A's fix patches. Tell the user and the
291
+ // operator (via log) instead of dropping the click.
292
+ const why = 'approval handler not bound (router not installed?). Restart im-hub to rebind.';
293
+ webLog.warn({ event: 'approval.web.no_handler', clientId, data: actionData, messageId }, why);
294
+ sendToClient(ws, { type: 'error', message: why });
295
+ return;
296
+ }
297
+ try {
298
+ // Most messengers' ButtonCallback#ack updates a platform-native
299
+ // toast / loading spinner. The web client doesn't have one, so
300
+ // ack is a no-op resolving to the in-page status the page itself
301
+ // chose to render after click.
302
+ await webButtonHandler({
303
+ data: actionData, threadId: clientId, userId: `web:${clientId}`,
304
+ userDisplay: 'Web', messageId, ack: async () => { },
305
+ });
306
+ webLog.info({ event: 'approval.web.click_resolved', clientId, data: actionData });
307
+ }
308
+ catch (err) {
309
+ const errMsg = err instanceof Error ? err.message : String(err);
310
+ webLog.error({ event: 'approval.web.click_failed', clientId, data: actionData, err: errMsg });
311
+ sendToClient(ws, { type: 'error', message: `Approval click failed: ${errMsg}` });
312
+ }
313
+ return;
314
+ }
315
+ await handleClientMessage(client, msg, options.defaultAgent);
316
+ }
317
+ catch (err) {
318
+ webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Error parsing client message');
319
+ sendToClient(ws, { type: 'error', message: 'Invalid message format' });
320
+ }
321
+ });
322
+ ws.on('close', () => {
323
+ webLog.info({ clientId }, 'Client disconnected');
324
+ clients.delete(clientId);
325
+ });
326
+ ws.on('error', (err) => {
327
+ webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
328
+ clients.delete(clientId);
329
+ });
330
+ });
331
+ // Start listening on all interfaces
332
+ await new Promise((resolve, reject) => {
333
+ httpServer.on('error', reject);
334
+ httpServer.listen(port, '0.0.0.0', () => resolve());
335
+ });
336
+ webLog.info({ port }, `Chat UI available at http://localhost:${port}`);
337
+ // ============================================================
338
+ // Web messenger registration (HITL approval bridge)
339
+ // ============================================================
340
+ // Register a synthetic messenger named 'web' so approval-router (which
341
+ // resolves the target messenger by platform name) can deliver approval
342
+ // prompts AND outcome edits to the matching browser tab over the
343
+ // existing WebSocket. Chat ingress is unaffected — incoming chat
344
+ // messages still flow through handleClientMessage / routeMessage as
345
+ // before; this messenger only forwards what the bus wants to push.
346
+ //
347
+ // threadId is the WS clientId (RouteContext.threadId === client.id for
348
+ // the web platform). We resolve the matching client at delivery time;
349
+ // if the client has disconnected the send is a no-op and the bus's own
350
+ // auto-deny / sidecar-disconnect path takes over.
351
+ let cardSeq = 0;
352
+ const webMessenger = {
353
+ name: 'web',
354
+ start: async () => { },
355
+ stop: async () => { },
356
+ onMessage: () => { },
357
+ async sendMessage(threadId, text) {
358
+ const c = clients.get(threadId);
359
+ if (!c || c.ws.readyState !== c.ws.OPEN)
360
+ return;
361
+ sendToClient(c.ws, { type: 'approval-text', text });
362
+ },
363
+ async sendApprovalCard(threadId, prompt) {
364
+ const c = clients.get(threadId);
365
+ const messageId = `web-card-${++cardSeq}-${Date.now().toString(36)}`;
366
+ if (c && c.ws.readyState === c.ws.OPEN) {
367
+ sendToClient(c.ws, { type: 'approval-card', messageId, prompt });
368
+ }
369
+ return { messageId };
370
+ },
371
+ async editApprovalCard(threadId, messageId, outcome) {
372
+ const c = clients.get(threadId);
373
+ if (!c || c.ws.readyState !== c.ws.OPEN)
374
+ return;
375
+ sendToClient(c.ws, { type: 'approval-card-edit', messageId, outcome });
376
+ },
377
+ onButtonCallback(handler) {
378
+ webButtonHandler = handler;
379
+ webLog.info({ event: 'approval.web.handler_bound' }, 'web messenger button-callback handler attached');
380
+ },
381
+ };
382
+ registry.registerMessenger(webMessenger);
383
+ // approval-router's install() loop bound buttonCallback only for messengers
384
+ // registered BEFORE install. Our web messenger was just registered (after
385
+ // install), so we have to wire it ourselves — otherwise in-page approval
386
+ // card clicks fire WS 'approval-action' messages with no handler on the
387
+ // server side and silently do nothing. bindButtonHandlerForPlatform is a
388
+ // no-op if approval-router hasn't been install()'d yet (e.g. degraded
389
+ // mode where the bus failed to start).
390
+ try {
391
+ const { bindButtonHandlerForPlatform } = await import('../core/approval-router.js');
392
+ bindButtonHandlerForPlatform('web');
393
+ if (!webButtonHandler) {
394
+ // bindButtonHandlerForPlatform is a silent no-op when `installed` is
395
+ // null on the router (bus failed to start, or cli skipped install).
396
+ // Log so an operator who's confused why approval clicks don't work
397
+ // sees a clear breadcrumb at startup.
398
+ webLog.warn({ event: 'approval.web.bind_skipped' }, 'approval-router not installed — web approval clicks will fail until restart');
399
+ }
400
+ }
401
+ catch (err) {
402
+ webLog.warn({ event: 'approval.web.bind_error', err: err instanceof Error ? err.message : String(err) }, 'approval-router button-handler binding threw');
403
+ }
404
+ // PR-C: periodic metrics tick. Publishes a per-agent snapshot every 5s
405
+ // so the dashboard's Health sparkline can advance even when there are
406
+ // no audit events firing. The bus's recent buffer keeps the latest
407
+ // tick available to fresh SSE connections, so a tab opened mid-cycle
408
+ // sees current data without waiting up to 5 s.
409
+ const metricsTick = setInterval(async () => {
410
+ try {
411
+ const { eventBus } = await import('../core/event-bus.js');
412
+ const { snapshot } = await import('../core/metrics.js');
413
+ const snap = snapshot();
414
+ eventBus.publish({
415
+ type: 'metrics',
416
+ ts: new Date().toISOString(),
417
+ agents: snap.agents.map((a) => ({
418
+ agent: a.agent, total: a.total, success: a.success, failure: a.failure,
419
+ p50Ms: a.p50Ms, p95Ms: a.p95Ms, p99Ms: a.p99Ms,
420
+ })),
421
+ });
422
+ }
423
+ catch { /* swallow — metrics tick must never break the web server */ }
424
+ }, 5_000);
425
+ if (typeof metricsTick === 'object' && metricsTick && 'unref' in metricsTick) {
426
+ metricsTick.unref();
427
+ }
428
+ return {
429
+ port,
430
+ close: () => {
431
+ // Close all WebSocket connections
432
+ for (const [id, client] of clients) {
433
+ client.ws.close();
434
+ clients.delete(id);
435
+ }
436
+ wss.close();
437
+ httpServer.close();
438
+ },
439
+ };
440
+ }
441
+ // ============================================
442
+ // REST API handlers
443
+ // ============================================
444
+ async function handleGetConfig(_req, res) {
445
+ try {
446
+ const config = await loadConfig();
447
+ const agentStatus = await getAgentStatuses();
448
+ sendJson(res, 200, {
449
+ messengers: config.messengers,
450
+ agents: config.agents,
451
+ defaultAgent: config.defaultAgent,
452
+ telegram: config.telegram
453
+ ? { botToken: mask(config.telegram.botToken), channelId: config.telegram.channelId }
454
+ : undefined,
455
+ feishu: config.feishu
456
+ ? { appId: config.feishu.appId, appSecret: mask(config.feishu.appSecret) }
457
+ : undefined,
458
+ acpAgents: config.acpAgents?.map(a => ({
459
+ ...a,
460
+ auth: a.auth
461
+ ? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
462
+ : undefined,
463
+ })),
464
+ webPort: config.webPort,
465
+ agentStatus,
466
+ });
467
+ }
468
+ catch (err) {
469
+ sendJson(res, 500, { error: 'Failed to load config' });
470
+ }
471
+ }
472
+ async function handlePutConfig(req, res) {
473
+ try {
474
+ const body = await readBody(req, res);
475
+ const incoming = JSON.parse(body);
476
+ const existing = await loadConfig();
477
+ const merged = { ...existing };
478
+ for (const key of Object.keys(incoming)) {
479
+ const val = incoming[key];
480
+ // Deep-protect nested known-masked paths so `ab****yz` never overwrites true value
481
+ if (key === 'telegram' && typeof val === 'object' && val !== null) {
482
+ const t = val;
483
+ merged.telegram = {
484
+ ...(existing.telegram || {}),
485
+ ...t,
486
+ botToken: typeof t.botToken === 'string' && isMasked(t.botToken) ? existing.telegram?.botToken : t.botToken,
487
+ };
488
+ continue;
489
+ }
490
+ if (key === 'feishu' && typeof val === 'object' && val !== null) {
491
+ const f = val;
492
+ merged.feishu = {
493
+ ...(existing.feishu || {}),
494
+ ...f,
495
+ appSecret: typeof f.appSecret === 'string' && isMasked(f.appSecret) ? existing.feishu?.appSecret : f.appSecret,
496
+ };
497
+ continue;
498
+ }
499
+ if (key === 'acpAgents' && Array.isArray(val)) {
500
+ merged.acpAgents = val.map((item, i) => {
501
+ const a = item;
502
+ const old = existing.acpAgents?.[i];
503
+ if (a?.auth && typeof a.auth === 'object' && typeof a.auth.token === 'string' && isMasked(a.auth.token)) {
504
+ return { ...a, auth: { ...a.auth, token: old?.auth?.token } };
505
+ }
506
+ return a;
507
+ });
508
+ continue;
509
+ }
510
+ if (typeof val === 'string' && isMasked(val)) {
511
+ continue;
512
+ }
513
+ merged[key] = val;
514
+ }
515
+ const result = validateConfig(merged);
516
+ if (!result.ok) {
517
+ sendJson(res, 400, { error: 'Config validation failed', details: result.errors });
518
+ return;
519
+ }
520
+ await saveConfig(result.config);
521
+ sendJson(res, 200, { ok: true });
522
+ }
523
+ catch (err) {
524
+ const msg = err instanceof Error ? err.message : String(err);
525
+ sendJson(res, 400, { error: msg });
526
+ }
527
+ }
528
+ async function handleAgentsStatus(_req, res) {
529
+ try {
530
+ const agentStatus = await getAgentStatuses();
531
+ sendJson(res, 200, agentStatus);
532
+ }
533
+ catch (err) {
534
+ sendJson(res, 500, { error: 'Failed to check agents' });
535
+ }
536
+ }
537
+ async function handleListWorkspaces(_req, res, url) {
538
+ try {
539
+ const { workspaceRegistry } = await import('../core/workspace.js');
540
+ // ?full=1 returns the full WorkspaceConfig (including member IDs)
541
+ // for the settings editor; default is the summary shape (member count
542
+ // only) used elsewhere.
543
+ const wantFull = url?.searchParams.get('full') === '1';
544
+ sendJson(res, 200, {
545
+ workspaces: wantFull ? workspaceRegistry.listFull() : workspaceRegistry.list(),
546
+ });
547
+ }
548
+ catch (err) {
549
+ const msg = err instanceof Error ? err.message : String(err);
550
+ sendJson(res, 500, { error: msg });
551
+ }
552
+ }
553
+ /**
554
+ * Validate + sanitize an incoming WorkspaceConfig from the settings
555
+ * editor. Returns a clean object on success or a string error message
556
+ * on failure. Reused by POST and PATCH so behavior is identical.
557
+ */
558
+ function validateWorkspacePayload(raw, expectedId) {
559
+ if (!raw || typeof raw !== 'object')
560
+ return { ok: false, error: 'body must be a JSON object' };
561
+ const o = raw;
562
+ const id = String(o.id || '').trim();
563
+ if (!id)
564
+ return { ok: false, error: 'id is required' };
565
+ if (!/^[a-zA-Z0-9_-]+$/.test(id))
566
+ return { ok: false, error: 'id must match [a-zA-Z0-9_-]+' };
567
+ if (id === 'default' && expectedId !== 'default') {
568
+ return { ok: false, error: '"default" workspace is reserved (use PATCH to edit)' };
569
+ }
570
+ if (expectedId && expectedId !== id) {
571
+ return { ok: false, error: `id mismatch: URL is ${expectedId}, body is ${id}` };
572
+ }
573
+ const name = String(o.name || id);
574
+ const agents = Array.isArray(o.agents) ? o.agents.filter((a) => typeof a === 'string') : [];
575
+ const members = Array.isArray(o.members) ? o.members.filter((m) => typeof m === 'string') : undefined;
576
+ let rateLimit;
577
+ if (o.rateLimit && typeof o.rateLimit === 'object') {
578
+ const r = o.rateLimit;
579
+ const rate = Number(r.rate);
580
+ const intervalSec = Number(r.intervalSec);
581
+ const burst = Number(r.burst);
582
+ if (!Number.isFinite(rate) || rate <= 0
583
+ || !Number.isFinite(intervalSec) || intervalSec <= 0
584
+ || !Number.isFinite(burst) || burst <= 0) {
585
+ return { ok: false, error: 'rateLimit.rate / intervalSec / burst must be positive numbers' };
586
+ }
587
+ rateLimit = { rate, intervalSec, burst };
588
+ }
589
+ return { ok: true, cfg: { id, name, agents, members, rateLimit } };
590
+ }
591
+ /**
592
+ * Persist the workspaces array back to ~/.im-hub/config.json so changes
593
+ * survive a restart. We do not touch other config fields — settings.html
594
+ * has its own /api/config PUT for that. Best-effort: a write failure is
595
+ * logged but the in-memory registry has already been updated, so the
596
+ * change is live until the next process boot.
597
+ */
598
+ async function persistWorkspacesToConfig(workspaces) {
599
+ const config = await loadConfig();
600
+ config.workspaces = workspaces;
601
+ await saveConfig(config);
602
+ }
603
+ async function handleCreateOrUpdateWorkspace(req, res, expectedId) {
604
+ try {
605
+ const body = await readBody(req, res);
606
+ let parsed;
607
+ try {
608
+ parsed = JSON.parse(body);
609
+ }
610
+ catch {
611
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
612
+ return;
613
+ }
614
+ const v = validateWorkspacePayload(parsed, expectedId);
615
+ if (!v.ok) {
616
+ sendJson(res, 400, { ok: false, error: v.error });
617
+ return;
618
+ }
619
+ const { workspaceRegistry } = await import('../core/workspace.js');
620
+ workspaceRegistry.add(v.cfg);
621
+ await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
622
+ sendJson(res, 200, { ok: true, workspace: v.cfg });
623
+ }
624
+ catch (err) {
625
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
626
+ }
627
+ }
628
+ async function handleDeleteWorkspace(_req, res, id) {
629
+ try {
630
+ const { workspaceRegistry } = await import('../core/workspace.js');
631
+ if (id === 'default') {
632
+ sendJson(res, 400, { ok: false, error: 'cannot delete the default workspace' });
633
+ return;
634
+ }
635
+ const removed = workspaceRegistry.remove(id);
636
+ if (!removed) {
637
+ sendJson(res, 404, { ok: false, error: `workspace "${id}" not found` });
638
+ return;
639
+ }
640
+ await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
641
+ sendJson(res, 200, { ok: true });
642
+ }
643
+ catch (err) {
644
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
645
+ }
646
+ }
647
+ async function handleMetrics(_req, res, url) {
648
+ try {
649
+ const fmt = url.searchParams.get('format') || 'prom';
650
+ const { snapshot, toPrometheus } = await import('../core/metrics.js');
651
+ if (fmt === 'json') {
652
+ sendJson(res, 200, snapshot());
653
+ return;
654
+ }
655
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
656
+ res.end(toPrometheus());
657
+ }
658
+ catch (err) {
659
+ const msg = err instanceof Error ? err.message : String(err);
660
+ sendJson(res, 500, { error: msg });
661
+ }
662
+ }
663
+ /**
664
+ * POST /api/notify → push a message to an IM thread.
665
+ *
666
+ * Body: { platform, threadId, text, card? }
667
+ * Use case: external systems (CI / monitoring / cron) pushing notices
668
+ * back to a chat thread without going through the Agent layer.
669
+ */
670
+ async function handleNotify(req, res) {
671
+ try {
672
+ const body = await readBody(req, res);
673
+ const { platform, threadId, text, card } = JSON.parse(body);
674
+ if (!platform || !threadId || (!text && !card)) {
675
+ sendJson(res, 400, { error: 'Missing platform / threadId / (text|card)' });
676
+ return;
677
+ }
678
+ // Map platform name to messenger plugin name.
679
+ const messengerName = platform === 'wechat' ? 'wechat-ilink' : platform;
680
+ const messenger = registry.getMessenger(messengerName);
681
+ if (!messenger) {
682
+ sendJson(res, 404, { error: `Messenger "${platform}" not registered` });
683
+ return;
684
+ }
685
+ const traceId = generateTraceId();
686
+ const log = createLogger({ traceId, platform, component: 'notify' });
687
+ log.info({ threadId, hasCard: !!card, textLen: text?.length || 0 }, 'notify in');
688
+ if (card && typeof messenger.sendCard === 'function') {
689
+ await messenger.sendCard(threadId, card);
690
+ }
691
+ else if (text) {
692
+ await messenger.sendMessage(threadId, text);
693
+ }
694
+ else {
695
+ sendJson(res, 400, { error: 'card requires sendCard support, otherwise text is required' });
696
+ return;
697
+ }
698
+ sendJson(res, 200, { ok: true, traceId });
699
+ }
700
+ catch (err) {
701
+ const e = err;
702
+ if (e?.handled)
703
+ return;
704
+ const status = e?.statusCode || 500;
705
+ const msg = e instanceof Error ? e.message : String(err);
706
+ if (!res.headersSent)
707
+ sendJson(res, status, { error: msg });
708
+ }
709
+ }
710
+ /**
711
+ * POST /api/invoke → run an agent prompt as if it came from a user.
712
+ *
713
+ * Body: { prompt, agent?, userId?, platform? }
714
+ * Returns a JSON response with the full text (for streaming use the ACP
715
+ * server's POST /tasks?mode=stream instead).
716
+ */
717
+ async function handleInvoke(req, res, defaultAgent) {
718
+ try {
719
+ const body = await readBody(req, res);
720
+ const parsed = JSON.parse(body);
721
+ if (!parsed.prompt) {
722
+ sendJson(res, 400, { error: 'Missing prompt' });
723
+ return;
724
+ }
725
+ const agentName = parsed.agent || defaultAgent;
726
+ const promptText = parsed.agent ? `/${parsed.agent} ${parsed.prompt}` : parsed.prompt;
727
+ const traceId = generateTraceId();
728
+ const platform = parsed.platform || 'rest';
729
+ const log = createLogger({ traceId, platform, component: 'invoke' });
730
+ log.info({ agent: agentName, promptLen: parsed.prompt.length }, 'invoke in');
731
+ const routeCtx = {
732
+ threadId: `rest:${traceId}`,
733
+ channelId: 'rest',
734
+ platform,
735
+ defaultAgent: agentName,
736
+ traceId,
737
+ logger: log,
738
+ userId: parsed.userId || 'rest-caller',
739
+ };
740
+ const parsedMsg = parseMessage(promptText);
741
+ const result = await routeMessage(parsedMsg, routeCtx);
742
+ let fullText = '';
743
+ if (typeof result === 'string') {
744
+ fullText = result;
745
+ }
746
+ else {
747
+ for await (const chunk of result)
748
+ fullText += chunk;
749
+ }
750
+ sendJson(res, 200, { ok: true, traceId, output: { content: fullText } });
751
+ }
752
+ catch (err) {
753
+ const e = err;
754
+ if (e?.handled)
755
+ return;
756
+ const status = e?.statusCode || 500;
757
+ const msg = e instanceof Error ? e.message : String(err);
758
+ if (!res.headersSent)
759
+ sendJson(res, status, { error: msg });
760
+ }
761
+ }
762
+ async function handleHealth(_req, res) {
763
+ // Quick check: agent availability snapshot. Already used by settings UI;
764
+ // exposing it under /api/health gives ops a stable URL.
765
+ try {
766
+ const status = await getAgentStatuses();
767
+ const anyHealthy = Object.values(status).some(Boolean);
768
+ sendJson(res, anyHealthy ? 200 : 503, {
769
+ ok: anyHealthy,
770
+ agents: status,
771
+ uptimeSec: Math.round(process.uptime()),
772
+ });
773
+ }
774
+ catch (err) {
775
+ const msg = err instanceof Error ? err.message : String(err);
776
+ sendJson(res, 500, { ok: false, error: msg });
777
+ }
778
+ }
779
+ async function handleListJobs(_req, res, url) {
780
+ try {
781
+ const { listJobs, getJobStats } = await import('../core/job-board.js');
782
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 1), 500);
783
+ const status = url.searchParams.get('status');
784
+ const agent = url.searchParams.get('agent') || undefined;
785
+ const jobs = listJobs(limit, status || undefined, agent ? { agent } : {});
786
+ const stats = getJobStats();
787
+ sendJson(res, 200, { jobs, stats });
788
+ }
789
+ catch (err) {
790
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
791
+ }
792
+ }
793
+ async function handleGetJob(_req, res, id) {
794
+ try {
795
+ const { getJob } = await import('../core/job-board.js');
796
+ const job = getJob(id);
797
+ if (!job) {
798
+ sendJson(res, 404, { error: 'Job not found' });
799
+ return;
800
+ }
801
+ sendJson(res, 200, { job });
802
+ }
803
+ catch (err) {
804
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
805
+ }
806
+ }
807
+ async function handleCancelJob(_req, res, id) {
808
+ try {
809
+ const { cancelJob } = await import('../core/job-board.js');
810
+ sendJson(res, 200, { ok: cancelJob(id) });
811
+ }
812
+ catch (err) {
813
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
814
+ }
815
+ }
816
+ async function handleRunJob(req, res, id) {
817
+ try {
818
+ const { getJob, runJob } = await import('../core/job-board.js');
819
+ const { AgentBase } = await import('../core/agent-base.js');
820
+ const job = getJob(id);
821
+ if (!job) {
822
+ sendJson(res, 404, { error: 'Job not found' });
823
+ return;
824
+ }
825
+ const agent = registry.findAgent(job.agent);
826
+ if (!agent) {
827
+ sendJson(res, 404, { error: `Agent "${job.agent}" not registered` });
828
+ return;
829
+ }
830
+ const traceId = generateTraceId();
831
+ const log = createLogger({ traceId, platform: 'web', component: 'job-run' });
832
+ // Fire and forget — UI polls /api/jobs/:id for status.
833
+ void runJob(id, async function* (j, _logger, signal) {
834
+ if (agent instanceof AgentBase) {
835
+ const text = await agent.spawnAndCollect(j.prompt, signal);
836
+ if (text)
837
+ yield text;
838
+ }
839
+ else {
840
+ for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
841
+ if (signal.aborted)
842
+ break;
843
+ yield chunk;
844
+ }
845
+ }
846
+ }, log).catch(() => { });
847
+ sendJson(res, 200, { ok: true, traceId });
848
+ }
849
+ catch (err) {
850
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
851
+ }
852
+ }
853
+ async function handleCreateJob(req, res) {
854
+ try {
855
+ const body = await readBody(req, res);
856
+ const { agent, prompt } = JSON.parse(body);
857
+ if (!agent || !prompt) {
858
+ sendJson(res, 400, { error: 'Missing agent / prompt' });
859
+ return;
860
+ }
861
+ if (!registry.findAgent(agent)) {
862
+ sendJson(res, 404, { error: `Agent "${agent}" not registered` });
863
+ return;
864
+ }
865
+ const { createJob } = await import('../core/job-board.js');
866
+ const id = createJob(agent, prompt);
867
+ sendJson(res, 200, { ok: true, id });
868
+ }
869
+ catch (err) {
870
+ const e = err;
871
+ if (e?.handled)
872
+ return;
873
+ if (!res.headersSent)
874
+ sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
875
+ }
876
+ }
877
+ async function handleListSchedules(_req, res, url) {
878
+ try {
879
+ const { listSchedules } = await import('../core/schedule.js');
880
+ const agent = url.searchParams.get('agent') || undefined;
881
+ sendJson(res, 200, { schedules: listSchedules(50, agent ? { agent } : {}) });
882
+ }
883
+ catch (err) {
884
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
885
+ }
886
+ }
887
+ async function handleListBgjobs(_req, res, url) {
888
+ try {
889
+ const { resolveRoots, listJobsForRoot, listAllJobs } = await import('../core/bgjob-reader.js');
890
+ const rootId = url.searchParams.get('root');
891
+ if (rootId) {
892
+ // Single-root view — used by the dashboard's root selector.
893
+ const root = resolveRoots().find((r) => r.id === rootId);
894
+ if (!root) {
895
+ sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
896
+ return;
897
+ }
898
+ const jobs = await listJobsForRoot(root);
899
+ sendJson(res, 200, { roots: [{ id: root.id, label: root.label, path: root.path }], jobs });
900
+ return;
901
+ }
902
+ // No root specified: return all roots' metadata + jobs grouped by root.
903
+ const all = await listAllJobs();
904
+ sendJson(res, 200, {
905
+ roots: all.map(({ root }) => ({ id: root.id, label: root.label, path: root.path })),
906
+ groups: all.map(({ root, jobs }) => ({ rootId: root.id, jobs })),
907
+ });
908
+ }
909
+ catch (err) {
910
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
911
+ }
912
+ }
913
+ async function handleGetBgjob(_req, res, id, url) {
914
+ try {
915
+ const { findRoot, getJobDetail, resolveRoots, listJobsForRoot } = await import('../core/bgjob-reader.js');
916
+ const tail = Math.min(Math.max(parseInt(url.searchParams.get('tail') || '200', 10) || 200, 1), 5000);
917
+ const rootId = url.searchParams.get('root');
918
+ if (rootId) {
919
+ const root = findRoot(rootId);
920
+ if (!root) {
921
+ sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
922
+ return;
923
+ }
924
+ const job = await getJobDetail(root, id, tail);
925
+ if (!job) {
926
+ sendJson(res, 404, { error: 'Job not found' });
927
+ return;
928
+ }
929
+ sendJson(res, 200, { job });
930
+ return;
931
+ }
932
+ // No root: try every configured root, return first hit.
933
+ for (const root of resolveRoots()) {
934
+ const summaries = await listJobsForRoot(root);
935
+ if (!summaries.some((s) => s.id === id))
936
+ continue;
937
+ const job = await getJobDetail(root, id, tail);
938
+ if (job) {
939
+ sendJson(res, 200, { job });
940
+ return;
941
+ }
942
+ }
943
+ sendJson(res, 404, { error: 'Job not found' });
944
+ }
945
+ catch (err) {
946
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
947
+ }
948
+ }
949
+ async function handleListSubtasks(_req, res, url) {
950
+ try {
951
+ const { sessionManager } = await import('../core/session.js');
952
+ const agent = url.searchParams.get('agent') || undefined;
953
+ const subtasks = await sessionManager.listAllSubtasks(agent ? { agent } : {});
954
+ sendJson(res, 200, { subtasks });
955
+ }
956
+ catch (err) {
957
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
958
+ }
959
+ }
960
+ async function handleAudit(_req, res, url) {
961
+ try {
962
+ const { queryInvocations, getStats } = await import('../core/audit-log.js');
963
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 1000);
964
+ const days = parseInt(url.searchParams.get('days') || '7', 10) || 7;
965
+ const agent = url.searchParams.get('agent') || undefined;
966
+ const platform = url.searchParams.get('platform') || undefined;
967
+ const userId = url.searchParams.get('user') || undefined;
968
+ const intent = url.searchParams.get('intent') || undefined;
969
+ const rows = queryInvocations({ limit, days, agent, platform, userId, intent });
970
+ const stats = getStats();
971
+ sendJson(res, 200, { invocations: rows, stats });
972
+ }
973
+ catch (err) {
974
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
975
+ }
976
+ }
977
+ /**
978
+ * Per-agent operational health snapshot. Drives the Health tab in /tasks.
979
+ *
980
+ * Combines three independent live data sources:
981
+ * - circuit breaker phase / cooldown remaining (core/circuit-breaker.ts)
982
+ * - rate-limiter remaining tokens & config (core/rate-limiter.ts agentLimiter)
983
+ * - latency p50 / p95 / p99 + invocation totals (core/metrics.ts snapshot)
984
+ *
985
+ * No persistence — pure read of in-memory state. Cheap to call (<1 ms for
986
+ * a typical agent fleet) so the page is happy to poll on a 5 s tick.
987
+ */
988
+ async function handleAgentHealth(_req, res) {
989
+ try {
990
+ const { circuitBreaker } = await import('../core/circuit-breaker.js');
991
+ const { agentLimiter } = await import('../core/rate-limiter.js');
992
+ const { snapshot } = await import('../core/metrics.js');
993
+ const snap = snapshot();
994
+ const now = Date.now();
995
+ const agents = registry.listAgents().map((name) => {
996
+ const breaker = circuitBreaker.getStatus(name);
997
+ const cooldownRemainingMs = breaker.openedAt && breaker.phase !== 'closed'
998
+ ? Math.max(0, breaker.cooldownMs - (now - breaker.openedAt))
999
+ : 0;
1000
+ const rate = agentLimiter.status(name);
1001
+ const m = snap.agents.find((a) => a.agent === name);
1002
+ return {
1003
+ agent: name,
1004
+ breaker: {
1005
+ phase: breaker.phase,
1006
+ failures: breaker.failures,
1007
+ cooldownMs: breaker.cooldownMs,
1008
+ cooldownRemainingMs,
1009
+ },
1010
+ rate: {
1011
+ remaining: rate.remaining,
1012
+ rate: rate.rate,
1013
+ intervalSec: rate.intervalSec,
1014
+ },
1015
+ invocations: m
1016
+ ? {
1017
+ total: m.total,
1018
+ success: m.success,
1019
+ failure: m.failure,
1020
+ successRate: m.successRate,
1021
+ costSum: m.costSum,
1022
+ sampleCount: m.sampleCount,
1023
+ p50Ms: m.p50Ms,
1024
+ p95Ms: m.p95Ms,
1025
+ p99Ms: m.p99Ms,
1026
+ }
1027
+ : null,
1028
+ };
1029
+ });
1030
+ sendJson(res, 200, { agents, uptimeSec: snap.uptimeSec });
1031
+ }
1032
+ catch (err) {
1033
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1034
+ }
1035
+ }
1036
+ /**
1037
+ * List every currently-pending HITL approval across all sessions /
1038
+ * platforms. Used by the global Approvals tab in /tasks so the operator
1039
+ * can see at a glance whether something is waiting on a y/n that nobody
1040
+ * is around to give.
1041
+ */
1042
+ async function handleListApprovals(_req, res) {
1043
+ try {
1044
+ const { approvalBus } = await import('../core/approval-bus.js');
1045
+ const pending = approvalBus.listPending();
1046
+ const metrics = approvalBus.getMetrics();
1047
+ sendJson(res, 200, { pending, metrics });
1048
+ }
1049
+ catch (err) {
1050
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1051
+ }
1052
+ }
1053
+ /**
1054
+ * Resolve an approval by reqId (admin / dashboard path). Body:
1055
+ * { behavior: 'allow' | 'deny', autoAllowFurther?: boolean, message?: string }
1056
+ *
1057
+ * resolvePending operates on threadId, not reqId, so we walk listPending
1058
+ * to find the matching pending and forward to the bus. The web token is
1059
+ * the access gate — anyone with it can resolve any pending. Multi-tenant
1060
+ * scoping is tracked separately with the rest of cross-cutting #3.1.
1061
+ */
1062
+ async function handleResolveApproval(req, res, reqId) {
1063
+ try {
1064
+ const body = await readBody(req, res);
1065
+ let parsed;
1066
+ try {
1067
+ parsed = JSON.parse(body);
1068
+ }
1069
+ catch {
1070
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
1071
+ return;
1072
+ }
1073
+ if (parsed.behavior !== 'allow' && parsed.behavior !== 'deny') {
1074
+ sendJson(res, 400, { ok: false, error: 'behavior must be "allow" or "deny"' });
1075
+ return;
1076
+ }
1077
+ const { approvalBus } = await import('../core/approval-bus.js');
1078
+ const target = approvalBus.listPending().find((p) => p.reqId === reqId);
1079
+ if (!target) {
1080
+ sendJson(res, 404, { ok: false, error: 'Approval not pending (may have already resolved or timed out)' });
1081
+ return;
1082
+ }
1083
+ const decision = parsed.behavior === 'allow'
1084
+ ? { behavior: 'allow', autoAllowFurther: parsed.autoAllowFurther === true }
1085
+ : { behavior: 'deny', message: parsed.message || 'denied via dashboard' };
1086
+ const resolved = approvalBus.resolvePending(target.threadId, decision);
1087
+ if (!resolved) {
1088
+ sendJson(res, 409, { ok: false, error: 'Race: pending vanished between list and resolve' });
1089
+ return;
1090
+ }
1091
+ sendJson(res, 200, { ok: true, resolved });
1092
+ }
1093
+ catch (err) {
1094
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
1095
+ }
1096
+ }
1097
+ /** Hard cap on file content we'll ship over the wire — avoids OOM and keeps
1098
+ * the browser responsive. Matches the soft limit our log-tail endpoints use. */
1099
+ const WORKSPACE_FILE_MAX_BYTES = 1 * 1024 * 1024;
1100
+ /** Bytes scanned for a binary heuristic. Null byte in this window → binary. */
1101
+ const BINARY_PROBE_BYTES = 8 * 1024;
1102
+ /**
1103
+ * Read-only view of `~/.im-hub-workspaces/<agent>/`. The Files tab in /tasks
1104
+ * uses it to inspect what an IM-context agent is reading and writing into its
1105
+ * pinned workspace (CLAUDE.md, AGENTS.md, scratch notes, etc.).
1106
+ *
1107
+ * Query params:
1108
+ * agent — required, MUST match a registered agent name. We reject anything
1109
+ * else to keep `agent` from sneaking traversal segments past the
1110
+ * join (e.g. `?agent=../../etc`).
1111
+ * path — optional relative path under the agent's workspace. Defaults to ''.
1112
+ *
1113
+ * Response shape:
1114
+ * { type:'dir', entries:[{name,isDir,size,mtime}] }
1115
+ * { type:'file', content, size, encoding:'utf-8'|'base64', truncated }
1116
+ *
1117
+ * Path-traversal defense: after `resolvePath(base, userPath)` we verify the
1118
+ * result is exactly `base` or starts with `base + sep`. A `..`-laden path
1119
+ * collapses outside the base and gets rejected before any read.
1120
+ *
1121
+ * Edits / writes intentionally not exposed; ops use plain ssh.
1122
+ */
1123
+ async function handleWorkspaceFiles(_req, res, url) {
1124
+ try {
1125
+ const agent = url.searchParams.get('agent') || '';
1126
+ const userPath = url.searchParams.get('path') || '';
1127
+ if (!agent) {
1128
+ sendJson(res, 400, { error: 'Missing required ?agent=' });
1129
+ return;
1130
+ }
1131
+ // Whitelist agent against the registry. Even an empty registry won't
1132
+ // expose anything because a non-registered name fails this check.
1133
+ const known = new Set(registry.listAgents());
1134
+ if (!known.has(agent)) {
1135
+ sendJson(res, 404, { error: `Unknown agent "${agent}"` });
1136
+ return;
1137
+ }
1138
+ const { defaultAgentCwd } = await import('../core/agent-cwd.js');
1139
+ const base = resolvePath(defaultAgentCwd(agent));
1140
+ const target = resolvePath(base, userPath);
1141
+ // The base itself is allowed; anything else must live strictly below it.
1142
+ if (target !== base && !target.startsWith(base + pathSep)) {
1143
+ sendJson(res, 400, { error: 'Path escapes workspace root' });
1144
+ return;
1145
+ }
1146
+ let st;
1147
+ try {
1148
+ st = await stat(target);
1149
+ }
1150
+ catch (err) {
1151
+ const e = err;
1152
+ if (e.code === 'ENOENT') {
1153
+ sendJson(res, 404, { error: 'Not found', path: relativePath(base, target) });
1154
+ return;
1155
+ }
1156
+ throw err;
1157
+ }
1158
+ if (st.isDirectory()) {
1159
+ const names = await readdir(target);
1160
+ const entries = await Promise.all(names.map(async (name) => {
1161
+ try {
1162
+ const sub = await stat(join(target, name));
1163
+ return {
1164
+ name,
1165
+ isDir: sub.isDirectory(),
1166
+ size: sub.isDirectory() ? null : sub.size,
1167
+ mtime: sub.mtime.toISOString(),
1168
+ };
1169
+ }
1170
+ catch {
1171
+ // Broken symlink or race-deleted entry — surface but mark unknown.
1172
+ return { name, isDir: false, size: null, mtime: null, broken: true };
1173
+ }
1174
+ }));
1175
+ // Dirs first, then case-insensitive name sort — matches the convention
1176
+ // most file-managers use. Stable in practice (Intl.Collator sort).
1177
+ entries.sort((a, b) => {
1178
+ if (a.isDir !== b.isDir)
1179
+ return a.isDir ? -1 : 1;
1180
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
1181
+ });
1182
+ sendJson(res, 200, {
1183
+ type: 'dir',
1184
+ agent,
1185
+ path: relativePath(base, target),
1186
+ base,
1187
+ entries,
1188
+ });
1189
+ return;
1190
+ }
1191
+ if (!st.isFile()) {
1192
+ sendJson(res, 400, { error: 'Not a regular file' });
1193
+ return;
1194
+ }
1195
+ // For files larger than the cap, we still return metadata + a note —
1196
+ // user can ssh in for the rest. Avoids surprising the browser with
1197
+ // a 50 MB log dump.
1198
+ const truncated = st.size > WORKSPACE_FILE_MAX_BYTES;
1199
+ const buf = await readFile(target);
1200
+ const slice = truncated ? buf.subarray(0, WORKSPACE_FILE_MAX_BYTES) : buf;
1201
+ // Binary detection: a NUL byte in the first 8 KB is a strong signal.
1202
+ // Cheaper than full UTF-8 validation and matches grep's heuristic.
1203
+ const probe = slice.subarray(0, Math.min(slice.length, BINARY_PROBE_BYTES));
1204
+ const isBinary = probe.includes(0);
1205
+ sendJson(res, 200, {
1206
+ type: 'file',
1207
+ agent,
1208
+ path: relativePath(base, target),
1209
+ size: st.size,
1210
+ mtime: st.mtime.toISOString(),
1211
+ encoding: isBinary ? 'base64' : 'utf-8',
1212
+ content: isBinary ? slice.toString('base64') : slice.toString('utf-8'),
1213
+ truncated,
1214
+ });
1215
+ }
1216
+ catch (err) {
1217
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1218
+ }
1219
+ }
1220
+ /**
1221
+ * Inline edit of a workspace file. UI use-case: annotate the agent's
1222
+ * CLAUDE.md / AGENTS.md / scratch notes from the dashboard without
1223
+ * shelling into the host.
1224
+ *
1225
+ * Body: { content: string } — UTF-8 text only (binary writes refused).
1226
+ * Response: { ok: true, size, mtime } on success.
1227
+ *
1228
+ * Safety:
1229
+ * - Same agent + path traversal guards as the GET handler.
1230
+ * - 1 MiB hard cap on `content` (matches the read cap so a roundtrip
1231
+ * edit can't grow a file beyond what the read can show).
1232
+ * - Atomic write: stage to `<target>.tmp.<rand>` then `rename` so a
1233
+ * crash mid-write can't leave a half-truncated file. The .tmp file
1234
+ * is unlinked on any error path.
1235
+ * - Refuses to overwrite a directory; refuses to create a parent dir
1236
+ * that doesn't exist (no implicit mkdir-p — keeps surprises out).
1237
+ */
1238
+ async function handleWorkspaceFileWrite(req, res, url) {
1239
+ try {
1240
+ const agent = url.searchParams.get('agent') || '';
1241
+ const userPath = url.searchParams.get('path') || '';
1242
+ if (!agent) {
1243
+ sendJson(res, 400, { error: 'Missing required ?agent=' });
1244
+ return;
1245
+ }
1246
+ if (!userPath) {
1247
+ sendJson(res, 400, { error: 'Missing required ?path=' });
1248
+ return;
1249
+ }
1250
+ const known = new Set(registry.listAgents());
1251
+ if (!known.has(agent)) {
1252
+ sendJson(res, 404, { error: `Unknown agent "${agent}"` });
1253
+ return;
1254
+ }
1255
+ const { defaultAgentCwd } = await import('../core/agent-cwd.js');
1256
+ const base = resolvePath(defaultAgentCwd(agent));
1257
+ const target = resolvePath(base, userPath);
1258
+ if (target !== base && !target.startsWith(base + pathSep)) {
1259
+ sendJson(res, 400, { error: 'Path escapes workspace root' });
1260
+ return;
1261
+ }
1262
+ if (target === base) {
1263
+ sendJson(res, 400, { error: 'Cannot overwrite workspace root' });
1264
+ return;
1265
+ }
1266
+ const body = await readBody(req, res);
1267
+ let parsed;
1268
+ try {
1269
+ parsed = JSON.parse(body);
1270
+ }
1271
+ catch {
1272
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1273
+ return;
1274
+ }
1275
+ if (typeof parsed.content !== 'string') {
1276
+ sendJson(res, 400, { error: 'content must be a string' });
1277
+ return;
1278
+ }
1279
+ const content = parsed.content;
1280
+ // Encode early so the size check is on bytes, not chars (a single
1281
+ // CJK char is 3 bytes UTF-8 — char-count would lie about file size).
1282
+ const buf = Buffer.from(content, 'utf-8');
1283
+ if (buf.length > WORKSPACE_FILE_MAX_BYTES) {
1284
+ sendJson(res, 413, { error: 'Content exceeds 1 MiB cap' });
1285
+ return;
1286
+ }
1287
+ // Reject content that contains a NUL byte. UTF-8 text never has one;
1288
+ // accidental binary upload here would corrupt the editor on next read.
1289
+ if (buf.includes(0)) {
1290
+ sendJson(res, 400, { error: 'NUL byte in content — only UTF-8 text accepted' });
1291
+ return;
1292
+ }
1293
+ // Existing-target guards: must not be a directory; parent dir must
1294
+ // exist (no implicit mkdir-p — too easy to typo a deep path and
1295
+ // create a hidden mess).
1296
+ try {
1297
+ const st = await stat(target);
1298
+ if (st.isDirectory()) {
1299
+ sendJson(res, 400, { error: 'Target is a directory' });
1300
+ return;
1301
+ }
1302
+ }
1303
+ catch (err) {
1304
+ const e = err;
1305
+ if (e.code !== 'ENOENT')
1306
+ throw err;
1307
+ // Doesn't exist yet — that's fine for new-file writes, but the
1308
+ // parent directory must be present.
1309
+ const parent = dirname(target);
1310
+ try {
1311
+ const ps = await stat(parent);
1312
+ if (!ps.isDirectory()) {
1313
+ sendJson(res, 400, { error: 'Parent path is not a directory' });
1314
+ return;
1315
+ }
1316
+ }
1317
+ catch {
1318
+ sendJson(res, 400, { error: 'Parent directory does not exist' });
1319
+ return;
1320
+ }
1321
+ }
1322
+ // Atomic write. crypto.randomBytes is the cheapest unique suffix and
1323
+ // already imported up top.
1324
+ const tmp = `${target}.tmp.${randomBytes(6).toString('hex')}`;
1325
+ try {
1326
+ await writeFile(tmp, buf, { mode: 0o600 });
1327
+ await rename(tmp, target);
1328
+ }
1329
+ catch (err) {
1330
+ try {
1331
+ await unlink(tmp);
1332
+ }
1333
+ catch { /* tmp may not have been created */ }
1334
+ throw err;
1335
+ }
1336
+ const finalSt = await stat(target);
1337
+ sendJson(res, 200, {
1338
+ ok: true,
1339
+ agent,
1340
+ path: relativePath(base, target),
1341
+ size: finalSt.size,
1342
+ mtime: finalSt.mtime.toISOString(),
1343
+ });
1344
+ }
1345
+ catch (err) {
1346
+ const e = err;
1347
+ if (e?.handled)
1348
+ return;
1349
+ if (!res.headersSent)
1350
+ sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
1351
+ }
1352
+ }
1353
+ /**
1354
+ * Run cancel/run across an array of job ids in one request. Saves N round
1355
+ * trips when the user multi-selects a long list in the Jobs tab.
1356
+ *
1357
+ * Body: { ids: number[] }
1358
+ * Response: { results: Array<{ id, ok, error?, traceId? }> }
1359
+ *
1360
+ * Per-id failures don't fail the whole request — each entry carries its own
1361
+ * status so the UI can mark partial success.
1362
+ */
1363
+ async function handleBatchJob(req, res, action, defaultAgent) {
1364
+ try {
1365
+ const body = await readBody(req, res);
1366
+ let parsed;
1367
+ try {
1368
+ parsed = JSON.parse(body);
1369
+ }
1370
+ catch {
1371
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1372
+ return;
1373
+ }
1374
+ if (!Array.isArray(parsed.ids) || parsed.ids.length === 0) {
1375
+ sendJson(res, 400, { error: 'ids must be a non-empty array of numbers' });
1376
+ return;
1377
+ }
1378
+ // Cap so a runaway client can't queue thousands of spawns at once. Same
1379
+ // ceiling we use elsewhere for batched ops.
1380
+ if (parsed.ids.length > 100) {
1381
+ sendJson(res, 400, { error: 'Maximum 100 ids per batch' });
1382
+ return;
1383
+ }
1384
+ const ids = parsed.ids
1385
+ .map((x) => (typeof x === 'number' ? x : parseInt(String(x), 10)))
1386
+ .filter((n) => Number.isFinite(n) && n > 0);
1387
+ if (ids.length === 0) {
1388
+ sendJson(res, 400, { error: 'No valid ids in array' });
1389
+ return;
1390
+ }
1391
+ const { getJob, cancelJob, runJob } = await import('../core/job-board.js');
1392
+ const { AgentBase } = await import('../core/agent-base.js');
1393
+ const results = await Promise.all(ids.map(async (id) => {
1394
+ try {
1395
+ if (action === 'cancel') {
1396
+ return { id, ok: cancelJob(id) };
1397
+ }
1398
+ const job = getJob(id);
1399
+ if (!job)
1400
+ return { id, ok: false, error: 'Job not found' };
1401
+ const agent = registry.findAgent(job.agent);
1402
+ if (!agent)
1403
+ return { id, ok: false, error: `Agent "${job.agent}" not registered` };
1404
+ const traceId = generateTraceId();
1405
+ const log = createLogger({ traceId, platform: 'web', component: 'job-run-batch' });
1406
+ // Same fire-and-forget pattern as handleRunJob — the dashboard
1407
+ // streams status from /events / /api/jobs.
1408
+ void runJob(id, async function* (j, _logger, signal) {
1409
+ if (agent instanceof AgentBase) {
1410
+ const text = await agent.spawnAndCollect(j.prompt, signal);
1411
+ if (text)
1412
+ yield text;
1413
+ }
1414
+ else {
1415
+ for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
1416
+ if (signal.aborted)
1417
+ break;
1418
+ yield chunk;
1419
+ }
1420
+ }
1421
+ }, log).catch(() => { });
1422
+ return { id, ok: true, traceId };
1423
+ }
1424
+ catch (err) {
1425
+ return { id, ok: false, error: err instanceof Error ? err.message : String(err) };
1426
+ }
1427
+ }));
1428
+ // defaultAgent isn't used directly — runJob reads job.agent — but we
1429
+ // accept it to keep the call-site symmetric with handleInvoke.
1430
+ void defaultAgent;
1431
+ sendJson(res, 200, { results });
1432
+ }
1433
+ catch (err) {
1434
+ const e = err;
1435
+ if (e?.handled)
1436
+ return;
1437
+ if (!res.headersSent)
1438
+ sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
1439
+ }
1440
+ }
1441
+ /**
1442
+ * Server-Sent Events stream for real-time dashboard updates. Subscribes
1443
+ * to the in-process event-bus and forwards every event as an SSE frame.
1444
+ *
1445
+ * On connect we replay the last ~200 events from the bus's recent ring
1446
+ * so a freshly-opened tab doesn't have to wait for the next event to
1447
+ * have any context.
1448
+ *
1449
+ * Heartbeats every 25 s — Node's default keepalive isn't enough for some
1450
+ * proxies (nginx default is 60s idle close, browsers reconnect EventSource
1451
+ * automatically but we'd rather avoid the churn).
1452
+ *
1453
+ * Token-gated like every other /api endpoint via the upstream guard.
1454
+ */
1455
+ async function handleEventsSSE(req, res) {
1456
+ const { eventBus } = await import('../core/event-bus.js');
1457
+ res.writeHead(200, {
1458
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1459
+ 'Cache-Control': 'no-cache, no-transform',
1460
+ 'Connection': 'keep-alive',
1461
+ 'X-Accel-Buffering': 'no',
1462
+ });
1463
+ // Tell the client what we count as "now" so it can disambiguate replay
1464
+ // from live events.
1465
+ res.write(`event: hello\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`);
1466
+ // Replay recent buffer.
1467
+ for (const e of eventBus.getRecent()) {
1468
+ res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
1469
+ }
1470
+ const onEvent = (e) => {
1471
+ try {
1472
+ res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
1473
+ }
1474
+ catch {
1475
+ // Likely socket closed. The 'close' handler below will clean up;
1476
+ // swallow here so a downstream listener error doesn't propagate.
1477
+ }
1478
+ };
1479
+ eventBus.on('event', onEvent);
1480
+ // Periodic keepalive comment so proxies don't close idle connections.
1481
+ // SSE comments start with ':' and are ignored by EventSource clients.
1482
+ const heartbeat = setInterval(() => {
1483
+ try {
1484
+ res.write(': keepalive\n\n');
1485
+ }
1486
+ catch { /* socket closed */ }
1487
+ }, 25_000);
1488
+ if (typeof heartbeat === 'object' && heartbeat && 'unref' in heartbeat) {
1489
+ heartbeat.unref();
1490
+ }
1491
+ const cleanup = () => {
1492
+ clearInterval(heartbeat);
1493
+ eventBus.off('event', onEvent);
1494
+ };
1495
+ req.on('close', cleanup);
1496
+ req.on('error', cleanup);
1497
+ }
1498
+ async function handleAcpDiscover(req, res) {
1499
+ try {
1500
+ const body = await readBody(req, res);
1501
+ const { baseUrl, register } = JSON.parse(body);
1502
+ if (!baseUrl) {
1503
+ sendJson(res, 400, { error: 'Missing baseUrl' });
1504
+ return;
1505
+ }
1506
+ const { discoverAgents } = await import('../plugins/agents/acp/discovery.js');
1507
+ const result = await discoverAgents(baseUrl);
1508
+ if (register) {
1509
+ await registry.loadACPAgents(result.agents);
1510
+ }
1511
+ sendJson(res, 200, { ok: true, baseUrl: result.baseUrl, agents: result.agents });
1512
+ }
1513
+ catch (err) {
1514
+ const e = err;
1515
+ if (e?.handled)
1516
+ return;
1517
+ const status = e?.statusCode || 500;
1518
+ const msg = e instanceof Error ? e.message : String(err);
1519
+ if (!res.headersSent)
1520
+ sendJson(res, status, { ok: false, error: msg });
1521
+ }
1522
+ }
1523
+ async function handleAcpTest(req, res) {
1524
+ try {
1525
+ const body = await readBody(req, res);
1526
+ // M11: bare JSON.parse threw a SyntaxError that bubbled out into the
1527
+ // outer catch and surfaced as a "500-ish 400" with the parser's raw
1528
+ // message. Validate explicitly so malformed bodies get a clean 400.
1529
+ let parsed;
1530
+ try {
1531
+ parsed = JSON.parse(body);
1532
+ }
1533
+ catch {
1534
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
1535
+ return;
1536
+ }
1537
+ const { endpoint, auth } = parsed;
1538
+ if (!endpoint || typeof endpoint !== 'string') {
1539
+ sendJson(res, 400, { ok: false, error: 'Missing or invalid "endpoint"' });
1540
+ return;
1541
+ }
1542
+ // Dynamic import to avoid circular deps
1543
+ const { ACPClient } = await import('../plugins/agents/acp/acp-client.js');
1544
+ const client = new ACPClient({ name: 'test', endpoint, auth: auth });
1545
+ const manifest = await client.fetchManifest();
1546
+ sendJson(res, 200, {
1547
+ ok: true,
1548
+ name: manifest.name,
1549
+ description: manifest.description,
1550
+ });
1551
+ }
1552
+ catch (err) {
1553
+ const msg = err instanceof Error ? err.message : String(err);
1554
+ sendJson(res, 400, { ok: false, error: msg });
1555
+ }
1556
+ }
1557
+ // ============================================
1558
+ // Helpers
1559
+ // ============================================
1560
+ async function getAgentStatuses() {
1561
+ const agents = registry.listAgents();
1562
+ const status = {};
1563
+ await Promise.all(agents.map(async (name) => {
1564
+ const agent = registry.findAgent(name);
1565
+ if (agent) {
1566
+ try {
1567
+ status[name] = await agent.isAvailable();
1568
+ }
1569
+ catch {
1570
+ status[name] = false;
1571
+ }
1572
+ }
1573
+ }));
1574
+ return status;
1575
+ }
1576
+ function mask(value) {
1577
+ if (!value)
1578
+ return '';
1579
+ if (value.length <= 4)
1580
+ return '****';
1581
+ return value.slice(0, 2) + '****' + value.slice(-2);
1582
+ }
1583
+ /** Hard cap on inbound JSON bodies for the Web REST API. */
1584
+ const MAX_API_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
1585
+ function readBody(req, res) {
1586
+ return new Promise((resolve, reject) => {
1587
+ const chunks = [];
1588
+ let total = 0;
1589
+ let aborted = false;
1590
+ req.on('data', (chunk) => {
1591
+ if (aborted)
1592
+ return;
1593
+ total += chunk.length;
1594
+ if (total > MAX_API_BODY_BYTES) {
1595
+ aborted = true;
1596
+ if (res && !res.headersSent) {
1597
+ sendJson(res, 413, { error: 'Request body too large' });
1598
+ }
1599
+ const err = new Error('Request body too large');
1600
+ err.statusCode = 413;
1601
+ err.handled = !!res;
1602
+ reject(err);
1603
+ return;
1604
+ }
1605
+ chunks.push(chunk);
1606
+ });
1607
+ req.on('end', () => {
1608
+ if (aborted)
1609
+ return;
1610
+ resolve(Buffer.concat(chunks).toString('utf-8'));
1611
+ });
1612
+ req.on('error', (err) => {
1613
+ if (!aborted)
1614
+ reject(err);
1615
+ });
1616
+ });
1617
+ }
1618
+ function sendJson(res, status, data) {
1619
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1620
+ res.end(JSON.stringify(data));
1621
+ }
1622
+ // ============================================
1623
+ // WebSocket chat handlers
1624
+ // ============================================
1625
+ /**
1626
+ * Handle a message from a web client
1627
+ */
1628
+ async function handleClientMessage(client, msg, defaultAgent) {
1629
+ const { ws, id: clientId } = client;
1630
+ switch (msg.type) {
1631
+ case 'message': {
1632
+ if (!msg.text?.trim())
1633
+ return;
1634
+ const text = msg.text.trim();
1635
+ const traceId = generateTraceId();
1636
+ const logger = createLogger({ traceId, platform: 'web', component: 'web' });
1637
+ if (msg.agent && msg.agent !== client.agent) {
1638
+ client.agent = msg.agent;
1639
+ }
1640
+ const parsed = parseMessage(text);
1641
+ try {
1642
+ const routeCtx = {
1643
+ threadId: clientId,
1644
+ channelId: 'web',
1645
+ platform: 'web',
1646
+ defaultAgent: client.agent,
1647
+ traceId,
1648
+ logger,
1649
+ userId: `web:${clientId}`,
1650
+ };
1651
+ logger.info({ event: 'message.received', text: text.substring(0, 120) });
1652
+ const result = await routeMessage(parsed, routeCtx);
1653
+ // String response (built-in commands, errors)
1654
+ if (typeof result === 'string') {
1655
+ sendToClient(ws, { type: 'done', text: result });
1656
+ return;
1657
+ }
1658
+ // Streaming response (agent responses)
1659
+ let fullText = '';
1660
+ for await (const chunk of result) {
1661
+ fullText += chunk;
1662
+ // L1: defer when the per-socket send buffer is full. Without
1663
+ // this, a slow client lets the chunk producer keep allocating
1664
+ // frames into the kernel + ws-internal queue, which can grow
1665
+ // to GBs for a long agent response.
1666
+ await awaitWsDrain(ws);
1667
+ if (ws.readyState !== ws.OPEN)
1668
+ break;
1669
+ sendToClient(ws, { type: 'chunk', text: chunk });
1670
+ }
1671
+ sendToClient(ws, { type: 'done', text: fullText });
1672
+ }
1673
+ catch (err) {
1674
+ const errorMsg = err instanceof Error ? err.message : String(err);
1675
+ const stack = err instanceof Error ? err.stack : undefined;
1676
+ logger.error({ event: 'web.handle.error', err: errorMsg, stack }, 'Error handling client message');
1677
+ sendToClient(ws, { type: 'error', message: `Agent error: ${errorMsg}` });
1678
+ }
1679
+ break;
1680
+ }
1681
+ case 'switch-agent': {
1682
+ if (!msg.agent)
1683
+ return;
1684
+ const agent = registry.findAgent(msg.agent);
1685
+ if (!agent) {
1686
+ sendToClient(ws, { type: 'error', message: `Agent "${msg.agent}" not found` });
1687
+ return;
1688
+ }
1689
+ if (!(await isAgentAvailableCached(agent.name))) {
1690
+ sendToClient(ws, { type: 'error', message: `Agent "${agent.name}" is not available` });
1691
+ return;
1692
+ }
1693
+ client.agent = agent.name;
1694
+ await sessionManager.switchAgent('web', 'web', clientId, agent.name);
1695
+ sendToClient(ws, { type: 'agent-switched', agent: agent.name });
1696
+ break;
1697
+ }
1698
+ case 'get-agents': {
1699
+ const agents = registry.listAgents();
1700
+ sendToClient(ws, { type: 'agents', agents });
1701
+ break;
1702
+ }
1703
+ case 'get-history': {
1704
+ await sendSessionHistory(ws, clientId, defaultAgent);
1705
+ break;
1706
+ }
1707
+ }
1708
+ }
1709
+ /**
1710
+ * Send session history to a client
1711
+ */
1712
+ async function sendSessionHistory(ws, clientId, defaultAgent) {
1713
+ const history = await sessionManager.getSessionWithHistory('web', 'web', clientId);
1714
+ if (history && history.messages.length > 0) {
1715
+ sendToClient(ws, {
1716
+ type: 'history',
1717
+ messages: history.messages,
1718
+ agent: history.session.agent,
1719
+ });
1720
+ }
1721
+ }
1722
+ /** L1: backpressure threshold for the WS send path. When `ws.bufferedAmount`
1723
+ * exceeds this, the streaming chunk loop awaits a tick instead of piling up
1724
+ * more frames. 4 MiB tolerates a few seconds of slow client without
1725
+ * unbounded memory growth — Node's WebSocket impl honors the kernel
1726
+ * send buffer behind this number. */
1727
+ const WS_BACKPRESSURE_HIGHWATER_BYTES = 4 * 1024 * 1024;
1728
+ /**
1729
+ * Send a JSON message to a WebSocket client
1730
+ */
1731
+ function sendToClient(ws, data) {
1732
+ if (ws.readyState === ws.OPEN) {
1733
+ ws.send(JSON.stringify(data));
1734
+ }
1735
+ }
1736
+ /**
1737
+ * Wait until `ws.bufferedAmount` drops below the highwater mark, or the
1738
+ * socket closes, or the timeout fires. Used by the streaming chunk loop to
1739
+ * stop piling up frames at slow clients.
1740
+ *
1741
+ * Polls every 50 ms — node's `ws` doesn't emit a `drain` event we can hook,
1742
+ * but the buffered amount drops monotonically once the kernel ACKs flush.
1743
+ * Bounded by IMHUB_WS_BACKPRESSURE_TIMEOUT_MS (default 5 s) so a frozen
1744
+ * client can't wedge the agent's chunk producer indefinitely.
1745
+ */
1746
+ async function awaitWsDrain(ws) {
1747
+ if (ws.bufferedAmount < WS_BACKPRESSURE_HIGHWATER_BYTES)
1748
+ return;
1749
+ const timeoutMs = (() => {
1750
+ const raw = process.env.IMHUB_WS_BACKPRESSURE_TIMEOUT_MS;
1751
+ if (raw) {
1752
+ const n = parseInt(raw, 10);
1753
+ if (Number.isFinite(n) && n > 0)
1754
+ return n;
1755
+ }
1756
+ return 5_000;
1757
+ })();
1758
+ const startedAt = Date.now();
1759
+ while (ws.readyState === ws.OPEN
1760
+ && ws.bufferedAmount >= WS_BACKPRESSURE_HIGHWATER_BYTES
1761
+ && Date.now() - startedAt < timeoutMs) {
1762
+ await new Promise((r) => setTimeout(r, 50));
1763
+ }
1764
+ }
1765
+ /**
1766
+ * Serve a static file (no token injection needed)
1767
+ */
1768
+ function serveStatic(res, filePath, contentType) {
1769
+ if (!existsSync(filePath)) {
1770
+ res.writeHead(404);
1771
+ res.end('Not found');
1772
+ return;
1773
+ }
1774
+ const content = readFileSync(filePath);
1775
+ res.writeHead(200, { 'Content-Type': contentType });
1776
+ res.end(content);
1777
+ }
1778
+ /**
1779
+ * Serve index/settings HTML with injected web token for API auth
1780
+ */
1781
+ function serveIndexHtml(res, filePath, token) {
1782
+ if (!existsSync(filePath)) {
1783
+ res.writeHead(404);
1784
+ res.end('Not found');
1785
+ return;
1786
+ }
1787
+ let html = readFileSync(filePath, 'utf-8');
1788
+ // JSON.stringify produces a safely quoted JS string literal — prevents
1789
+ // breakout if the token ever contains ' or </script>.
1790
+ html = html.replace('</head>', `<script>window.IMHUB_TOKEN=${JSON.stringify(token)};</script></head>`);
1791
+ // No-cache so dashboard updates land for users without forcing a hard refresh.
1792
+ // The HTML is small (<30KB) and the round-trip is cheap; correctness wins.
1793
+ //
1794
+ // M1: defense-in-depth response headers. CSP keeps 'unsafe-inline' on
1795
+ // script/style because the IMHUB_TOKEN bootstrap and a few inline event
1796
+ // handlers in the static pages still rely on it; tightening to a nonce
1797
+ // is tracked separately. Outside that, lock everything down: no framing,
1798
+ // no MIME sniffing, no Referer leak to third parties, no third-party
1799
+ // resources at all.
1800
+ res.writeHead(200, {
1801
+ 'Content-Type': 'text/html; charset=utf-8',
1802
+ 'Cache-Control': 'no-cache, must-revalidate',
1803
+ 'X-Frame-Options': 'DENY',
1804
+ 'X-Content-Type-Options': 'nosniff',
1805
+ 'Referrer-Policy': 'no-referrer',
1806
+ 'Content-Security-Policy': [
1807
+ "default-src 'self'",
1808
+ "connect-src 'self' ws: wss:",
1809
+ "script-src 'self' 'unsafe-inline'",
1810
+ "style-src 'self' 'unsafe-inline'",
1811
+ "img-src 'self' data:",
1812
+ "font-src 'self' data:",
1813
+ "frame-ancestors 'none'",
1814
+ "base-uri 'self'",
1815
+ "form-action 'self'",
1816
+ ].join('; '),
1817
+ });
1818
+ res.end(html);
1819
+ }
1820
+ //# sourceMappingURL=server.js.map