agim-cli 1.0.1

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 (453) hide show
  1. package/CHANGELOG.md +1234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +422 -0
  4. package/README.zh-CN.md +414 -0
  5. package/dist/cli-ui/cmd-handlers.d.ts +11 -0
  6. package/dist/cli-ui/cmd-handlers.d.ts.map +1 -0
  7. package/dist/cli-ui/cmd-handlers.js +240 -0
  8. package/dist/cli-ui/cmd-handlers.js.map +1 -0
  9. package/dist/cli-ui/config-wizard.d.ts +3 -0
  10. package/dist/cli-ui/config-wizard.d.ts.map +1 -0
  11. package/dist/cli-ui/config-wizard.js +851 -0
  12. package/dist/cli-ui/config-wizard.js.map +1 -0
  13. package/dist/cli-ui/entry-menu.d.ts +28 -0
  14. package/dist/cli-ui/entry-menu.d.ts.map +1 -0
  15. package/dist/cli-ui/entry-menu.js +50 -0
  16. package/dist/cli-ui/entry-menu.js.map +1 -0
  17. package/dist/cli-ui/env-file.d.ts +35 -0
  18. package/dist/cli-ui/env-file.d.ts.map +1 -0
  19. package/dist/cli-ui/env-file.js +163 -0
  20. package/dist/cli-ui/env-file.js.map +1 -0
  21. package/dist/cli-ui/i18n.d.ts +204 -0
  22. package/dist/cli-ui/i18n.d.ts.map +1 -0
  23. package/dist/cli-ui/i18n.js +455 -0
  24. package/dist/cli-ui/i18n.js.map +1 -0
  25. package/dist/cli-ui/lang-picker.d.ts +10 -0
  26. package/dist/cli-ui/lang-picker.d.ts.map +1 -0
  27. package/dist/cli-ui/lang-picker.js +33 -0
  28. package/dist/cli-ui/lang-picker.js.map +1 -0
  29. package/dist/cli-ui/paths.d.ts +4 -0
  30. package/dist/cli-ui/paths.d.ts.map +1 -0
  31. package/dist/cli-ui/paths.js +11 -0
  32. package/dist/cli-ui/paths.js.map +1 -0
  33. package/dist/cli-ui/prompts.d.ts +65 -0
  34. package/dist/cli-ui/prompts.d.ts.map +1 -0
  35. package/dist/cli-ui/prompts.js +125 -0
  36. package/dist/cli-ui/prompts.js.map +1 -0
  37. package/dist/cli-ui/service.d.ts +41 -0
  38. package/dist/cli-ui/service.d.ts.map +1 -0
  39. package/dist/cli-ui/service.js +241 -0
  40. package/dist/cli-ui/service.js.map +1 -0
  41. package/dist/cli.d.ts +3 -0
  42. package/dist/cli.d.ts.map +1 -0
  43. package/dist/cli.js +1143 -0
  44. package/dist/cli.js.map +1 -0
  45. package/dist/core/acp-server.d.ts +8 -0
  46. package/dist/core/acp-server.d.ts.map +1 -0
  47. package/dist/core/acp-server.js +266 -0
  48. package/dist/core/acp-server.js.map +1 -0
  49. package/dist/core/agent-base.d.ts +94 -0
  50. package/dist/core/agent-base.d.ts.map +1 -0
  51. package/dist/core/agent-base.js +373 -0
  52. package/dist/core/agent-base.js.map +1 -0
  53. package/dist/core/agent-cwd.d.ts +48 -0
  54. package/dist/core/agent-cwd.d.ts.map +1 -0
  55. package/dist/core/agent-cwd.js +181 -0
  56. package/dist/core/agent-cwd.js.map +1 -0
  57. package/dist/core/agent-helper.d.ts +65 -0
  58. package/dist/core/agent-helper.d.ts.map +1 -0
  59. package/dist/core/agent-helper.js +150 -0
  60. package/dist/core/agent-helper.js.map +1 -0
  61. package/dist/core/agim-paths.d.ts +10 -0
  62. package/dist/core/agim-paths.d.ts.map +1 -0
  63. package/dist/core/agim-paths.js +64 -0
  64. package/dist/core/agim-paths.js.map +1 -0
  65. package/dist/core/approval-bus.d.ts +300 -0
  66. package/dist/core/approval-bus.d.ts.map +1 -0
  67. package/dist/core/approval-bus.js +990 -0
  68. package/dist/core/approval-bus.js.map +1 -0
  69. package/dist/core/approval-router.d.ts +101 -0
  70. package/dist/core/approval-router.d.ts.map +1 -0
  71. package/dist/core/approval-router.js +540 -0
  72. package/dist/core/approval-router.js.map +1 -0
  73. package/dist/core/audit-log.d.ts +55 -0
  74. package/dist/core/audit-log.d.ts.map +1 -0
  75. package/dist/core/audit-log.js +203 -0
  76. package/dist/core/audit-log.js.map +1 -0
  77. package/dist/core/bgjob-reader.d.ts +65 -0
  78. package/dist/core/bgjob-reader.d.ts.map +1 -0
  79. package/dist/core/bgjob-reader.js +212 -0
  80. package/dist/core/bgjob-reader.js.map +1 -0
  81. package/dist/core/circuit-breaker.d.ts +37 -0
  82. package/dist/core/circuit-breaker.d.ts.map +1 -0
  83. package/dist/core/circuit-breaker.js +115 -0
  84. package/dist/core/circuit-breaker.js.map +1 -0
  85. package/dist/core/commands/agent.d.ts +4 -0
  86. package/dist/core/commands/agent.d.ts.map +1 -0
  87. package/dist/core/commands/agent.js +40 -0
  88. package/dist/core/commands/agent.js.map +1 -0
  89. package/dist/core/commands/approval.d.ts +3 -0
  90. package/dist/core/commands/approval.d.ts.map +1 -0
  91. package/dist/core/commands/approval.js +85 -0
  92. package/dist/core/commands/approval.js.map +1 -0
  93. package/dist/core/commands/audit.d.ts +3 -0
  94. package/dist/core/commands/audit.d.ts.map +1 -0
  95. package/dist/core/commands/audit.js +84 -0
  96. package/dist/core/commands/audit.js.map +1 -0
  97. package/dist/core/commands/builtin.d.ts +3 -0
  98. package/dist/core/commands/builtin.d.ts.map +1 -0
  99. package/dist/core/commands/builtin.js +304 -0
  100. package/dist/core/commands/builtin.js.map +1 -0
  101. package/dist/core/commands/cron.d.ts +3 -0
  102. package/dist/core/commands/cron.d.ts.map +1 -0
  103. package/dist/core/commands/cron.js +128 -0
  104. package/dist/core/commands/cron.js.map +1 -0
  105. package/dist/core/commands/job.d.ts +3 -0
  106. package/dist/core/commands/job.d.ts.map +1 -0
  107. package/dist/core/commands/job.js +195 -0
  108. package/dist/core/commands/job.js.map +1 -0
  109. package/dist/core/commands/memo.d.ts +3 -0
  110. package/dist/core/commands/memo.d.ts.map +1 -0
  111. package/dist/core/commands/memo.js +151 -0
  112. package/dist/core/commands/memo.js.map +1 -0
  113. package/dist/core/commands/model.d.ts +9 -0
  114. package/dist/core/commands/model.d.ts.map +1 -0
  115. package/dist/core/commands/model.js +183 -0
  116. package/dist/core/commands/model.js.map +1 -0
  117. package/dist/core/commands/plan.d.ts +3 -0
  118. package/dist/core/commands/plan.d.ts.map +1 -0
  119. package/dist/core/commands/plan.js +75 -0
  120. package/dist/core/commands/plan.js.map +1 -0
  121. package/dist/core/commands/remind.d.ts +3 -0
  122. package/dist/core/commands/remind.d.ts.map +1 -0
  123. package/dist/core/commands/remind.js +271 -0
  124. package/dist/core/commands/remind.js.map +1 -0
  125. package/dist/core/commands/router.d.ts +3 -0
  126. package/dist/core/commands/router.d.ts.map +1 -0
  127. package/dist/core/commands/router.js +71 -0
  128. package/dist/core/commands/router.js.map +1 -0
  129. package/dist/core/commands/sessions.d.ts +3 -0
  130. package/dist/core/commands/sessions.d.ts.map +1 -0
  131. package/dist/core/commands/sessions.js +88 -0
  132. package/dist/core/commands/sessions.js.map +1 -0
  133. package/dist/core/commands/stats.d.ts +3 -0
  134. package/dist/core/commands/stats.d.ts.map +1 -0
  135. package/dist/core/commands/stats.js +73 -0
  136. package/dist/core/commands/stats.js.map +1 -0
  137. package/dist/core/commands/think.d.ts +3 -0
  138. package/dist/core/commands/think.d.ts.map +1 -0
  139. package/dist/core/commands/think.js +28 -0
  140. package/dist/core/commands/think.js.map +1 -0
  141. package/dist/core/commands/workspaces.d.ts +3 -0
  142. package/dist/core/commands/workspaces.d.ts.map +1 -0
  143. package/dist/core/commands/workspaces.js +47 -0
  144. package/dist/core/commands/workspaces.js.map +1 -0
  145. package/dist/core/config-schema.d.ts +60 -0
  146. package/dist/core/config-schema.d.ts.map +1 -0
  147. package/dist/core/config-schema.js +75 -0
  148. package/dist/core/config-schema.js.map +1 -0
  149. package/dist/core/coord-systems.d.ts +65 -0
  150. package/dist/core/coord-systems.d.ts.map +1 -0
  151. package/dist/core/coord-systems.js +229 -0
  152. package/dist/core/coord-systems.js.map +1 -0
  153. package/dist/core/cron.d.ts +29 -0
  154. package/dist/core/cron.d.ts.map +1 -0
  155. package/dist/core/cron.js +184 -0
  156. package/dist/core/cron.js.map +1 -0
  157. package/dist/core/event-bus.d.ts +80 -0
  158. package/dist/core/event-bus.d.ts.map +1 -0
  159. package/dist/core/event-bus.js +62 -0
  160. package/dist/core/event-bus.js.map +1 -0
  161. package/dist/core/intent-llm.d.ts +27 -0
  162. package/dist/core/intent-llm.d.ts.map +1 -0
  163. package/dist/core/intent-llm.js +170 -0
  164. package/dist/core/intent-llm.js.map +1 -0
  165. package/dist/core/intent.d.ts +12 -0
  166. package/dist/core/intent.d.ts.map +1 -0
  167. package/dist/core/intent.js +187 -0
  168. package/dist/core/intent.js.map +1 -0
  169. package/dist/core/job-board.d.ts +82 -0
  170. package/dist/core/job-board.d.ts.map +1 -0
  171. package/dist/core/job-board.js +379 -0
  172. package/dist/core/job-board.js.map +1 -0
  173. package/dist/core/location-context.d.ts +32 -0
  174. package/dist/core/location-context.d.ts.map +1 -0
  175. package/dist/core/location-context.js +69 -0
  176. package/dist/core/location-context.js.map +1 -0
  177. package/dist/core/location-token.d.ts +57 -0
  178. package/dist/core/location-token.d.ts.map +1 -0
  179. package/dist/core/location-token.js +128 -0
  180. package/dist/core/location-token.js.map +1 -0
  181. package/dist/core/logger.d.ts +6 -0
  182. package/dist/core/logger.d.ts.map +1 -0
  183. package/dist/core/logger.js +54 -0
  184. package/dist/core/logger.js.map +1 -0
  185. package/dist/core/memo-rpc.d.ts +13 -0
  186. package/dist/core/memo-rpc.d.ts.map +1 -0
  187. package/dist/core/memo-rpc.js +288 -0
  188. package/dist/core/memo-rpc.js.map +1 -0
  189. package/dist/core/memos.d.ts +163 -0
  190. package/dist/core/memos.d.ts.map +1 -0
  191. package/dist/core/memos.js +502 -0
  192. package/dist/core/memos.js.map +1 -0
  193. package/dist/core/metrics.d.ts +55 -0
  194. package/dist/core/metrics.d.ts.map +1 -0
  195. package/dist/core/metrics.js +291 -0
  196. package/dist/core/metrics.js.map +1 -0
  197. package/dist/core/onboarding.d.ts +99 -0
  198. package/dist/core/onboarding.d.ts.map +1 -0
  199. package/dist/core/onboarding.js +426 -0
  200. package/dist/core/onboarding.js.map +1 -0
  201. package/dist/core/pending-reminder.d.ts +25 -0
  202. package/dist/core/pending-reminder.d.ts.map +1 -0
  203. package/dist/core/pending-reminder.js +53 -0
  204. package/dist/core/pending-reminder.js.map +1 -0
  205. package/dist/core/rate-limiter.d.ts +44 -0
  206. package/dist/core/rate-limiter.d.ts.map +1 -0
  207. package/dist/core/rate-limiter.js +115 -0
  208. package/dist/core/rate-limiter.js.map +1 -0
  209. package/dist/core/registry.d.ts +32 -0
  210. package/dist/core/registry.d.ts.map +1 -0
  211. package/dist/core/registry.js +126 -0
  212. package/dist/core/registry.js.map +1 -0
  213. package/dist/core/remind-intent.d.ts +25 -0
  214. package/dist/core/remind-intent.d.ts.map +1 -0
  215. package/dist/core/remind-intent.js +196 -0
  216. package/dist/core/remind-intent.js.map +1 -0
  217. package/dist/core/reminder-rpc.d.ts +17 -0
  218. package/dist/core/reminder-rpc.d.ts.map +1 -0
  219. package/dist/core/reminder-rpc.js +169 -0
  220. package/dist/core/reminder-rpc.js.map +1 -0
  221. package/dist/core/reminders.d.ts +159 -0
  222. package/dist/core/reminders.d.ts.map +1 -0
  223. package/dist/core/reminders.js +977 -0
  224. package/dist/core/reminders.js.map +1 -0
  225. package/dist/core/router.d.ts +55 -0
  226. package/dist/core/router.d.ts.map +1 -0
  227. package/dist/core/router.js +497 -0
  228. package/dist/core/router.js.map +1 -0
  229. package/dist/core/schedule.d.ts +65 -0
  230. package/dist/core/schedule.d.ts.map +1 -0
  231. package/dist/core/schedule.js +323 -0
  232. package/dist/core/schedule.js.map +1 -0
  233. package/dist/core/session.d.ts +182 -0
  234. package/dist/core/session.d.ts.map +1 -0
  235. package/dist/core/session.js +807 -0
  236. package/dist/core/session.js.map +1 -0
  237. package/dist/core/sqlite-helper.d.ts +37 -0
  238. package/dist/core/sqlite-helper.d.ts.map +1 -0
  239. package/dist/core/sqlite-helper.js +79 -0
  240. package/dist/core/sqlite-helper.js.map +1 -0
  241. package/dist/core/transcribe.d.ts +25 -0
  242. package/dist/core/transcribe.d.ts.map +1 -0
  243. package/dist/core/transcribe.js +217 -0
  244. package/dist/core/transcribe.js.map +1 -0
  245. package/dist/core/types.d.ts +360 -0
  246. package/dist/core/types.d.ts.map +1 -0
  247. package/dist/core/types.js +3 -0
  248. package/dist/core/types.js.map +1 -0
  249. package/dist/core/workspace.d.ts +67 -0
  250. package/dist/core/workspace.d.ts.map +1 -0
  251. package/dist/core/workspace.js +113 -0
  252. package/dist/core/workspace.js.map +1 -0
  253. package/dist/index.d.ts +5 -0
  254. package/dist/index.d.ts.map +1 -0
  255. package/dist/index.js +6 -0
  256. package/dist/index.js.map +1 -0
  257. package/dist/plugins/agents/acp/acp-adapter.d.ts +16 -0
  258. package/dist/plugins/agents/acp/acp-adapter.d.ts.map +1 -0
  259. package/dist/plugins/agents/acp/acp-adapter.js +49 -0
  260. package/dist/plugins/agents/acp/acp-adapter.js.map +1 -0
  261. package/dist/plugins/agents/acp/acp-client.d.ts +32 -0
  262. package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -0
  263. package/dist/plugins/agents/acp/acp-client.js +177 -0
  264. package/dist/plugins/agents/acp/acp-client.js.map +1 -0
  265. package/dist/plugins/agents/acp/discovery.d.ts +19 -0
  266. package/dist/plugins/agents/acp/discovery.d.ts.map +1 -0
  267. package/dist/plugins/agents/acp/discovery.js +111 -0
  268. package/dist/plugins/agents/acp/discovery.js.map +1 -0
  269. package/dist/plugins/agents/acp/index.d.ts +4 -0
  270. package/dist/plugins/agents/acp/index.d.ts.map +1 -0
  271. package/dist/plugins/agents/acp/index.js +4 -0
  272. package/dist/plugins/agents/acp/index.js.map +1 -0
  273. package/dist/plugins/agents/acp/types.d.ts +62 -0
  274. package/dist/plugins/agents/acp/types.d.ts.map +1 -0
  275. package/dist/plugins/agents/acp/types.js +5 -0
  276. package/dist/plugins/agents/acp/types.js.map +1 -0
  277. package/dist/plugins/agents/claude-code/index.d.ts +25 -0
  278. package/dist/plugins/agents/claude-code/index.d.ts.map +1 -0
  279. package/dist/plugins/agents/claude-code/index.js +184 -0
  280. package/dist/plugins/agents/claude-code/index.js.map +1 -0
  281. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +59 -0
  282. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -0
  283. package/dist/plugins/agents/claude-code/mcp-approval-server.js +645 -0
  284. package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -0
  285. package/dist/plugins/agents/codex/build-mcp-cli-args.d.ts +28 -0
  286. package/dist/plugins/agents/codex/build-mcp-cli-args.d.ts.map +1 -0
  287. package/dist/plugins/agents/codex/build-mcp-cli-args.js +74 -0
  288. package/dist/plugins/agents/codex/build-mcp-cli-args.js.map +1 -0
  289. package/dist/plugins/agents/codex/index.d.ts +53 -0
  290. package/dist/plugins/agents/codex/index.d.ts.map +1 -0
  291. package/dist/plugins/agents/codex/index.js +341 -0
  292. package/dist/plugins/agents/codex/index.js.map +1 -0
  293. package/dist/plugins/agents/copilot/index.d.ts +35 -0
  294. package/dist/plugins/agents/copilot/index.d.ts.map +1 -0
  295. package/dist/plugins/agents/copilot/index.js +182 -0
  296. package/dist/plugins/agents/copilot/index.js.map +1 -0
  297. package/dist/plugins/agents/opencode/ensure-mcp-config.d.ts +11 -0
  298. package/dist/plugins/agents/opencode/ensure-mcp-config.d.ts.map +1 -0
  299. package/dist/plugins/agents/opencode/ensure-mcp-config.js +100 -0
  300. package/dist/plugins/agents/opencode/ensure-mcp-config.js.map +1 -0
  301. package/dist/plugins/agents/opencode/index.d.ts +5 -0
  302. package/dist/plugins/agents/opencode/index.d.ts.map +1 -0
  303. package/dist/plugins/agents/opencode/index.js +30 -0
  304. package/dist/plugins/agents/opencode/index.js.map +1 -0
  305. package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts +166 -0
  306. package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts.map +1 -0
  307. package/dist/plugins/agents/opencode/opencode-http-adapter.js +682 -0
  308. package/dist/plugins/agents/opencode/opencode-http-adapter.js.map +1 -0
  309. package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts +32 -0
  310. package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts.map +1 -0
  311. package/dist/plugins/agents/opencode/opencode-stdio-adapter.js +137 -0
  312. package/dist/plugins/agents/opencode/opencode-stdio-adapter.js.map +1 -0
  313. package/dist/plugins/agents/opencode/serve-manager.d.ts +27 -0
  314. package/dist/plugins/agents/opencode/serve-manager.d.ts.map +1 -0
  315. package/dist/plugins/agents/opencode/serve-manager.js +194 -0
  316. package/dist/plugins/agents/opencode/serve-manager.js.map +1 -0
  317. package/dist/plugins/messengers/dingtalk/dingtalk-adapter.d.ts +57 -0
  318. package/dist/plugins/messengers/dingtalk/dingtalk-adapter.d.ts.map +1 -0
  319. package/dist/plugins/messengers/dingtalk/dingtalk-adapter.js +409 -0
  320. package/dist/plugins/messengers/dingtalk/dingtalk-adapter.js.map +1 -0
  321. package/dist/plugins/messengers/dingtalk/dingtalk-client.d.ts +48 -0
  322. package/dist/plugins/messengers/dingtalk/dingtalk-client.d.ts.map +1 -0
  323. package/dist/plugins/messengers/dingtalk/dingtalk-client.js +236 -0
  324. package/dist/plugins/messengers/dingtalk/dingtalk-client.js.map +1 -0
  325. package/dist/plugins/messengers/dingtalk/index.d.ts +3 -0
  326. package/dist/plugins/messengers/dingtalk/index.d.ts.map +1 -0
  327. package/dist/plugins/messengers/dingtalk/index.js +3 -0
  328. package/dist/plugins/messengers/dingtalk/index.js.map +1 -0
  329. package/dist/plugins/messengers/dingtalk/link-coords.d.ts +23 -0
  330. package/dist/plugins/messengers/dingtalk/link-coords.d.ts.map +1 -0
  331. package/dist/plugins/messengers/dingtalk/link-coords.js +89 -0
  332. package/dist/plugins/messengers/dingtalk/link-coords.js.map +1 -0
  333. package/dist/plugins/messengers/dingtalk/media-store.d.ts +16 -0
  334. package/dist/plugins/messengers/dingtalk/media-store.d.ts.map +1 -0
  335. package/dist/plugins/messengers/dingtalk/media-store.js +77 -0
  336. package/dist/plugins/messengers/dingtalk/media-store.js.map +1 -0
  337. package/dist/plugins/messengers/dingtalk/types.d.ts +82 -0
  338. package/dist/plugins/messengers/dingtalk/types.d.ts.map +1 -0
  339. package/dist/plugins/messengers/dingtalk/types.js +14 -0
  340. package/dist/plugins/messengers/dingtalk/types.js.map +1 -0
  341. package/dist/plugins/messengers/discord/discord-adapter.d.ts +21 -0
  342. package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -0
  343. package/dist/plugins/messengers/discord/discord-adapter.js +238 -0
  344. package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -0
  345. package/dist/plugins/messengers/discord/index.d.ts +4 -0
  346. package/dist/plugins/messengers/discord/index.d.ts.map +1 -0
  347. package/dist/plugins/messengers/discord/index.js +4 -0
  348. package/dist/plugins/messengers/discord/index.js.map +1 -0
  349. package/dist/plugins/messengers/discord/markdown-to-discord.d.ts +11 -0
  350. package/dist/plugins/messengers/discord/markdown-to-discord.d.ts.map +1 -0
  351. package/dist/plugins/messengers/discord/markdown-to-discord.js +59 -0
  352. package/dist/plugins/messengers/discord/markdown-to-discord.js.map +1 -0
  353. package/dist/plugins/messengers/discord/types.d.ts +9 -0
  354. package/dist/plugins/messengers/discord/types.d.ts.map +1 -0
  355. package/dist/plugins/messengers/discord/types.js +3 -0
  356. package/dist/plugins/messengers/discord/types.js.map +1 -0
  357. package/dist/plugins/messengers/email/email-adapter.d.ts +33 -0
  358. package/dist/plugins/messengers/email/email-adapter.d.ts.map +1 -0
  359. package/dist/plugins/messengers/email/email-adapter.js +137 -0
  360. package/dist/plugins/messengers/email/email-adapter.js.map +1 -0
  361. package/dist/plugins/messengers/feishu/card-builder.d.ts +23 -0
  362. package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -0
  363. package/dist/plugins/messengers/feishu/card-builder.js +89 -0
  364. package/dist/plugins/messengers/feishu/card-builder.js.map +1 -0
  365. package/dist/plugins/messengers/feishu/feishu-adapter.d.ts +23 -0
  366. package/dist/plugins/messengers/feishu/feishu-adapter.d.ts.map +1 -0
  367. package/dist/plugins/messengers/feishu/feishu-adapter.js +250 -0
  368. package/dist/plugins/messengers/feishu/feishu-adapter.js.map +1 -0
  369. package/dist/plugins/messengers/feishu/feishu-client.d.ts +43 -0
  370. package/dist/plugins/messengers/feishu/feishu-client.d.ts.map +1 -0
  371. package/dist/plugins/messengers/feishu/feishu-client.js +118 -0
  372. package/dist/plugins/messengers/feishu/feishu-client.js.map +1 -0
  373. package/dist/plugins/messengers/feishu/index.d.ts +4 -0
  374. package/dist/plugins/messengers/feishu/index.d.ts.map +1 -0
  375. package/dist/plugins/messengers/feishu/index.js +4 -0
  376. package/dist/plugins/messengers/feishu/index.js.map +1 -0
  377. package/dist/plugins/messengers/feishu/types.d.ts +113 -0
  378. package/dist/plugins/messengers/feishu/types.d.ts.map +1 -0
  379. package/dist/plugins/messengers/feishu/types.js +4 -0
  380. package/dist/plugins/messengers/feishu/types.js.map +1 -0
  381. package/dist/plugins/messengers/telegram/index.d.ts +4 -0
  382. package/dist/plugins/messengers/telegram/index.d.ts.map +1 -0
  383. package/dist/plugins/messengers/telegram/index.js +4 -0
  384. package/dist/plugins/messengers/telegram/index.js.map +1 -0
  385. package/dist/plugins/messengers/telegram/markdown-to-html.d.ts +5 -0
  386. package/dist/plugins/messengers/telegram/markdown-to-html.d.ts.map +1 -0
  387. package/dist/plugins/messengers/telegram/markdown-to-html.js +186 -0
  388. package/dist/plugins/messengers/telegram/markdown-to-html.js.map +1 -0
  389. package/dist/plugins/messengers/telegram/media-download.d.ts +59 -0
  390. package/dist/plugins/messengers/telegram/media-download.d.ts.map +1 -0
  391. package/dist/plugins/messengers/telegram/media-download.js +228 -0
  392. package/dist/plugins/messengers/telegram/media-download.js.map +1 -0
  393. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +77 -0
  394. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -0
  395. package/dist/plugins/messengers/telegram/telegram-adapter.js +880 -0
  396. package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -0
  397. package/dist/plugins/messengers/telegram/types.d.ts +47 -0
  398. package/dist/plugins/messengers/telegram/types.d.ts.map +1 -0
  399. package/dist/plugins/messengers/telegram/types.js +3 -0
  400. package/dist/plugins/messengers/telegram/types.js.map +1 -0
  401. package/dist/plugins/messengers/wechat/context-store.d.ts +18 -0
  402. package/dist/plugins/messengers/wechat/context-store.d.ts.map +1 -0
  403. package/dist/plugins/messengers/wechat/context-store.js +105 -0
  404. package/dist/plugins/messengers/wechat/context-store.js.map +1 -0
  405. package/dist/plugins/messengers/wechat/ilink-adapter.d.ts +71 -0
  406. package/dist/plugins/messengers/wechat/ilink-adapter.d.ts.map +1 -0
  407. package/dist/plugins/messengers/wechat/ilink-adapter.js +664 -0
  408. package/dist/plugins/messengers/wechat/ilink-adapter.js.map +1 -0
  409. package/dist/plugins/messengers/wechat/ilink-client.d.ts +75 -0
  410. package/dist/plugins/messengers/wechat/ilink-client.d.ts.map +1 -0
  411. package/dist/plugins/messengers/wechat/ilink-client.js +331 -0
  412. package/dist/plugins/messengers/wechat/ilink-client.js.map +1 -0
  413. package/dist/plugins/messengers/wechat/ilink-types.d.ts +181 -0
  414. package/dist/plugins/messengers/wechat/ilink-types.d.ts.map +1 -0
  415. package/dist/plugins/messengers/wechat/ilink-types.js +22 -0
  416. package/dist/plugins/messengers/wechat/ilink-types.js.map +1 -0
  417. package/dist/plugins/messengers/wechat/media-download.d.ts +32 -0
  418. package/dist/plugins/messengers/wechat/media-download.d.ts.map +1 -0
  419. package/dist/plugins/messengers/wechat/media-download.js +78 -0
  420. package/dist/plugins/messengers/wechat/media-download.js.map +1 -0
  421. package/dist/scripts/migrate-gcj02-to-wgs84.d.ts +3 -0
  422. package/dist/scripts/migrate-gcj02-to-wgs84.d.ts.map +1 -0
  423. package/dist/scripts/migrate-gcj02-to-wgs84.js +52 -0
  424. package/dist/scripts/migrate-gcj02-to-wgs84.js.map +1 -0
  425. package/dist/utils/backoff.d.ts +35 -0
  426. package/dist/utils/backoff.d.ts.map +1 -0
  427. package/dist/utils/backoff.js +59 -0
  428. package/dist/utils/backoff.js.map +1 -0
  429. package/dist/utils/cross-platform.d.ts +26 -0
  430. package/dist/utils/cross-platform.d.ts.map +1 -0
  431. package/dist/utils/cross-platform.js +58 -0
  432. package/dist/utils/cross-platform.js.map +1 -0
  433. package/dist/utils/message-split.d.ts +14 -0
  434. package/dist/utils/message-split.d.ts.map +1 -0
  435. package/dist/utils/message-split.js +65 -0
  436. package/dist/utils/message-split.js.map +1 -0
  437. package/dist/utils/safe-equal.d.ts +2 -0
  438. package/dist/utils/safe-equal.d.ts.map +1 -0
  439. package/dist/utils/safe-equal.js +11 -0
  440. package/dist/utils/safe-equal.js.map +1 -0
  441. package/dist/web/public/_app.js +196 -0
  442. package/dist/web/public/index.html +936 -0
  443. package/dist/web/public/loc.html +305 -0
  444. package/dist/web/public/login.html +106 -0
  445. package/dist/web/public/memos.html +271 -0
  446. package/dist/web/public/reminders.html +234 -0
  447. package/dist/web/public/settings.html +1355 -0
  448. package/dist/web/public/tasks.html +1835 -0
  449. package/dist/web/server.d.ts +12 -0
  450. package/dist/web/server.d.ts.map +1 -0
  451. package/dist/web/server.js +2399 -0
  452. package/dist/web/server.js.map +1 -0
  453. package/package.json +92 -0
@@ -0,0 +1,2399 @@
1
+ // Web chat server — HTTP + WebSocket for browser-based agent interaction
2
+ import { createServer } from 'node:http';
3
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { readdir, stat, readFile, writeFile, rename, unlink, realpath, open } from 'node:fs/promises';
5
+ import { join, dirname, resolve as resolvePath, sep as pathSep, relative as relativePath } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { AGIM_HOME } from '../core/agim-paths.js';
8
+ import { randomBytes, createHmac, timingSafeEqual } from 'node: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
+ import { consumeToken, peekToken } from '../core/location-token.js';
17
+ import { createMemo, updateMemo, getMemo, mapUrls } from '../core/memos.js';
18
+ import { normalizeIncomingCoords } from '../core/coord-systems.js';
19
+ const webLog = rootLogger.child({ component: 'web' });
20
+ /**
21
+ * Module-level reference to the button-callback handler that approval-router
22
+ * registers on our synthetic web messenger. The WS message switch dispatches
23
+ * `approval-action` events through this so an in-page approval button click
24
+ * flows back into approvalBus.resolvePending(), same path as a Telegram
25
+ * inline-button tap. Set by the web messenger's `onButtonCallback`; remains
26
+ * undefined until approval-router installs (which happens before this file's
27
+ * exported startWebServer is called from cli.ts).
28
+ */
29
+ let webButtonHandler;
30
+ import { isAgentAvailableCached, loadConfig, saveConfig, } from '../core/onboarding.js';
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const PUBLIC_DIR = join(__dirname, 'public');
33
+ const DEFAULT_PORT = 3000;
34
+ const WEB_TOKEN_DIR = AGIM_HOME;
35
+ const WEB_TOKEN_FILE = join(WEB_TOKEN_DIR, 'web-token');
36
+ function generateToken() {
37
+ return randomBytes(32).toString('hex');
38
+ }
39
+ function getOrCreateWebToken() {
40
+ try {
41
+ return readFileSync(WEB_TOKEN_FILE, 'utf-8').trim();
42
+ }
43
+ catch {
44
+ const token = generateToken();
45
+ mkdirSync(WEB_TOKEN_DIR, { recursive: true });
46
+ writeFileSync(WEB_TOKEN_FILE, token, { mode: 0o600 });
47
+ return token;
48
+ }
49
+ }
50
+ function isMasked(value) {
51
+ if (!value)
52
+ return false;
53
+ return /^.{0,2}\*{2,}.{0,2}$/.test(value);
54
+ }
55
+ export function createSerialQueue() {
56
+ let queue = Promise.resolve();
57
+ return (fn) => {
58
+ const run = queue.then(fn, fn);
59
+ queue = run.catch(() => { });
60
+ void run;
61
+ };
62
+ }
63
+ /**
64
+ * Start the web chat server
65
+ */
66
+ export async function startWebServer(options) {
67
+ const port = options.port || DEFAULT_PORT;
68
+ const bindHost = process.env.IMHUB_WEB_BIND || '127.0.0.1';
69
+ const webToken = getOrCreateWebToken();
70
+ const clients = new Map();
71
+ // Auth-required mode is now scoped to public binds. When listening on
72
+ // 127.0.0.1 / ::1 / localhost the web console is wide open — whoever has
73
+ // shell on this host already owns it, and asking small users to paste a
74
+ // token they don't understand was the biggest friction point in onboarding.
75
+ // For 0.0.0.0 / LAN / WAN binds we keep the token requirement so a
76
+ // copy-pasted IMHUB_WEB_BIND=0.0.0.0 doesn't expose memos/reminders/config.
77
+ //
78
+ // Escape hatches via env:
79
+ // IMHUB_WEB_REQUIRE_AUTH=1 — force token even on localhost (legacy / paranoid)
80
+ // IMHUB_WEB_REQUIRE_AUTH=0 — force NO token even on public bind (you know
81
+ // what you're doing — proxy + HTTPS + external auth)
82
+ const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
83
+ const requireAuth = (() => {
84
+ const forced = process.env.IMHUB_WEB_REQUIRE_AUTH;
85
+ if (forced === '1')
86
+ return true;
87
+ if (forced === '0')
88
+ return false;
89
+ return isPublicBind;
90
+ })();
91
+ // Per-process secret for signing session cookies. Not persisted — browser
92
+ // sessions expire on restart, which is acceptable for a dev tool.
93
+ const cookieSecret = randomBytes(32).toString('hex');
94
+ const COOKIE_NAME = 'imhub_session';
95
+ function makeSessionCookie() {
96
+ return createHmac('sha256', cookieSecret).update(webToken).digest('hex');
97
+ }
98
+ function isValidSessionCookie(cookie) {
99
+ const expected = makeSessionCookie();
100
+ if (cookie.length !== expected.length)
101
+ return false;
102
+ try {
103
+ return timingSafeEqual(Buffer.from(cookie, 'utf8'), Buffer.from(expected, 'utf8'));
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ function parseCookies(req) {
110
+ const hdr = req.headers.cookie || '';
111
+ const out = {};
112
+ for (const pair of hdr.split(';')) {
113
+ const eq = pair.indexOf('=');
114
+ if (eq < 0)
115
+ continue;
116
+ out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
117
+ }
118
+ return out;
119
+ }
120
+ function hasValidSession(req) {
121
+ const cookies = parseCookies(req);
122
+ return isValidSessionCookie(cookies[COOKIE_NAME] || '');
123
+ }
124
+ function setSessionCookie(req, res) {
125
+ const val = makeSessionCookie();
126
+ const parts = [`${COOKIE_NAME}=${val}`, 'HttpOnly', 'SameSite=Strict', 'Path=/'];
127
+ // Mark Secure when we have any signal we're served over TLS:
128
+ // - public bind (assume reverse proxy will/should terminate TLS)
129
+ // - X-Forwarded-Proto: https from a trusted reverse proxy
130
+ // For pure localhost dev (no proxy) we omit Secure so plain HTTP works.
131
+ const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
132
+ const xfp = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
133
+ if (isPublicBind || xfp === 'https')
134
+ parts.push('Secure');
135
+ res.setHeader('Set-Cookie', parts.join('; '));
136
+ }
137
+ if (isPublicBind) {
138
+ webLog.warn({
139
+ event: 'web.public_bind_warning',
140
+ bind: bindHost,
141
+ requireAuth,
142
+ }, requireAuth
143
+ ? 'Web server bound to a non-localhost address — token auth REQUIRED; front it with a reverse proxy that terminates TLS'
144
+ : 'Web server bound publicly with IMHUB_WEB_REQUIRE_AUTH=0 — caller assumes responsibility for upstream auth');
145
+ }
146
+ webLog.info({
147
+ event: 'web.auth_mode',
148
+ bind: bindHost,
149
+ requireAuth,
150
+ }, requireAuth
151
+ ? 'Web console: token auth REQUIRED (login page at /login)'
152
+ : 'Web console: token auth DISABLED (localhost bind — open access)');
153
+ // HTTP request handler — static files + REST API
154
+ const httpServer = createServer(async (req, res) => {
155
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
156
+ // Login page — always accessible
157
+ if (url.pathname === '/login' || url.pathname === '/login.html') {
158
+ return serveStatic(res, join(PUBLIC_DIR, 'login.html'), 'text/html; charset=utf-8');
159
+ }
160
+ // POST /api/auth/login — validate token, set session cookie
161
+ if (url.pathname === '/api/auth/login' && req.method === 'POST') {
162
+ const body = await readBody(req, res);
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(body);
166
+ }
167
+ catch {
168
+ sendJson(res, 400, { error: 'Invalid JSON' });
169
+ return;
170
+ }
171
+ if (typeof parsed.token === 'string' && safeEqual(parsed.token, webToken)) {
172
+ setSessionCookie(req, res);
173
+ sendJson(res, 200, { ok: true });
174
+ }
175
+ else {
176
+ sendJson(res, 401, { error: 'Invalid token' });
177
+ }
178
+ return;
179
+ }
180
+ // Static pages — gated by session cookie ONLY when requireAuth is on
181
+ // (i.e. the server is bound publicly). Localhost binds skip the gate
182
+ // entirely so small users don't need to know what a token is.
183
+ if (url.pathname === '/' || url.pathname === '/index.html' ||
184
+ url.pathname === '/settings' || url.pathname === '/settings.html' ||
185
+ url.pathname === '/tasks' || url.pathname === '/tasks.html' ||
186
+ url.pathname === '/reminders' || url.pathname === '/reminders.html' ||
187
+ url.pathname === '/memos' || url.pathname === '/memos.html') {
188
+ if (requireAuth && !hasValidSession(req)) {
189
+ res.writeHead(302, { Location: `/login?next=${encodeURIComponent(url.pathname)}` });
190
+ res.end();
191
+ return;
192
+ }
193
+ const fileMap = {
194
+ '/': 'index.html', '/index.html': 'index.html',
195
+ '/settings': 'settings.html', '/settings.html': 'settings.html',
196
+ '/tasks': 'tasks.html', '/tasks.html': 'tasks.html',
197
+ '/reminders': 'reminders.html', '/reminders.html': 'reminders.html',
198
+ '/memos': 'memos.html', '/memos.html': 'memos.html',
199
+ };
200
+ return servePageHtml(res, join(PUBLIC_DIR, fileMap[url.pathname]));
201
+ }
202
+ // M4: /api/health is intentionally public (k8s liveness probe friendly)
203
+ // — declare it BEFORE the /api/* token gate so callers don't need to
204
+ // know the web token. Returns only operational status, not config.
205
+ if (url.pathname === '/api/health' && req.method === 'GET') {
206
+ return handleHealth(req, res);
207
+ }
208
+ // Shared web-console utilities (theme manager + i18n + error boundary
209
+ // + auth-aware fetch). Loaded synchronously by every static page in
210
+ // <head> so the theme can apply before first paint. No secrets — safe
211
+ // to serve un-authenticated.
212
+ if (url.pathname === '/_app.js' && req.method === 'GET') {
213
+ return serveStatic(res, join(PUBLIC_DIR, '_app.js'), 'application/javascript; charset=utf-8');
214
+ }
215
+ // ─── Location capture endpoints (public; auth via single-use token) ───
216
+ // Public on purpose — the user reaches these via a token-bearing link
217
+ // sent into their IM thread. Token (32 hex chars, 10-min TTL, single
218
+ // use) is the credential. Designed to sit behind a CDN that proxies
219
+ // agent.iclaw.host → this port. **CDN MUST whitelist only /loc and
220
+ // /api/loc** — other paths on :3000 (/tasks, /api/*, /reminders) are
221
+ // operator-only and would leak if the CDN catch-all forwards.
222
+ if (url.pathname === '/loc' && req.method === 'GET') {
223
+ return serveStatic(res, join(PUBLIC_DIR, 'loc.html'), 'text/html; charset=utf-8');
224
+ }
225
+ // Short alias: /l/<token> serves the same H5 page. The page auto-detects
226
+ // either ?t= query or /l/<token> path. Lets us issue ~38-char URLs
227
+ // (vs 70+ for /loc?t=<32-hex>) which keeps WeChat chat cleaner.
228
+ if (url.pathname.startsWith('/l/') && req.method === 'GET') {
229
+ return serveStatic(res, join(PUBLIC_DIR, 'loc.html'), 'text/html; charset=utf-8');
230
+ }
231
+ // GET /api/loc/info?t=… — read-only peek for the H5 page to show what
232
+ // is being recorded. Does NOT consume the token — that happens on POST.
233
+ // Returns 410 when the token is expired/unknown so the page can render
234
+ // a hard-stop "link expired" state instead of pretending and failing
235
+ // on the POST.
236
+ if (url.pathname === '/api/loc/info' && req.method === 'GET') {
237
+ const tk = url.searchParams.get('t') || '';
238
+ if (!tk) {
239
+ sendJson(res, 400, { error: 'missing t' });
240
+ return;
241
+ }
242
+ const peek = peekToken(tk);
243
+ if (!peek) {
244
+ sendJson(res, 410, { error: 'token expired or unknown' });
245
+ return;
246
+ }
247
+ // For augment-mode (memo_id present), tell the H5 page so it can show
248
+ // "正在补充'<existing what>'的位置" instead of "正在记录: <what>".
249
+ let augmentLabel;
250
+ if (peek.memoId != null) {
251
+ const existing = getMemo(peek.memoId, { userId: peek.ctx.userId });
252
+ if (existing)
253
+ augmentLabel = existing.what;
254
+ }
255
+ sendJson(res, 200, {
256
+ ok: true,
257
+ what: peek.what,
258
+ memoId: peek.memoId ?? undefined,
259
+ augmentLabel,
260
+ expiresAt: new Date(peek.expiresAt).toISOString(),
261
+ });
262
+ return;
263
+ }
264
+ if (url.pathname === '/api/loc' && req.method === 'POST') {
265
+ const body = await readBody(req, res);
266
+ let parsed;
267
+ try {
268
+ parsed = JSON.parse(body);
269
+ }
270
+ catch {
271
+ sendJson(res, 400, { error: 'invalid JSON' });
272
+ return;
273
+ }
274
+ const tk = typeof parsed.t === 'string' ? parsed.t : '';
275
+ const rawLat = typeof parsed.lat === 'number' ? parsed.lat : NaN;
276
+ const rawLng = typeof parsed.lng === 'number' ? parsed.lng : NaN;
277
+ if (!tk || !Number.isFinite(rawLat) || !Number.isFinite(rawLng)) {
278
+ sendJson(res, 400, { error: '请求体缺少 t / lat / lng' });
279
+ return;
280
+ }
281
+ if (rawLat < -90 || rawLat > 90 || rawLng < -180 || rawLng > 180) {
282
+ sendJson(res, 400, { error: '坐标超出有效范围' });
283
+ return;
284
+ }
285
+ // navigator.geolocation returns whatever the host engine hands the
286
+ // page. The coord system varies by browser+OS:
287
+ // - iOS Safari / WebKit in mainland China: GCJ-02 (Apple's PRC
288
+ // compliance offset baked into Core Location).
289
+ // - WeChat X5 webview / Android Chrome without GMS: raw WGS-84.
290
+ // - PC browsers anywhere: WGS-84.
291
+ // Default to WGS-84 pass-through (matches the most common Chinese
292
+ // mobile path — WeChat — which was silently miscalibrated before
293
+ // the 2026-05-12 fix). iOS users in China set IMHUB_H5_COORDS_GCJ02=1
294
+ // to restore the GCJ→WGS path. See core/coord-systems.ts.
295
+ const norm = normalizeIncomingCoords('h5-browser-geolocation', rawLat, rawLng);
296
+ const lat = norm.lat;
297
+ const lng = norm.lng;
298
+ const cr = consumeToken(tk);
299
+ if (!cr.ok) {
300
+ const msg = cr.reason === 'expired' ? '链接已过期,请重新让 bot 发起记录' : '链接无效或已被使用';
301
+ sendJson(res, 410, { error: msg });
302
+ return;
303
+ }
304
+ const ctx = cr.ctx;
305
+ const what = cr.what || '';
306
+ const accuracy = typeof parsed.accuracy === 'number' ? parsed.accuracy : 0;
307
+ // Two paths:
308
+ // - cr.memoId set → UPDATE existing memo's where_*
309
+ // - cr.memoId null → CREATE new memo with what + where_*
310
+ let memoId = null;
311
+ let displayWhat = what;
312
+ try {
313
+ if (cr.memoId != null) {
314
+ const existing = getMemo(cr.memoId, { userId: ctx.userId });
315
+ if (existing) {
316
+ updateMemo(cr.memoId, {
317
+ whereLat: lat,
318
+ whereLng: lng,
319
+ }, { userId: ctx.userId });
320
+ memoId = cr.memoId;
321
+ displayWhat = existing.what; // memo's own what wins for reply
322
+ }
323
+ else {
324
+ // Memo got deleted between issue + consume? Fall back to creating
325
+ // a new one with whatever `what` the token had — better than
326
+ // dropping the user's grant.
327
+ memoId = createMemo({
328
+ platform: ctx.platform, channelId: ctx.channelId,
329
+ threadId: ctx.threadId, userId: ctx.userId,
330
+ what: what || '位置',
331
+ whereLat: lat, whereLng: lng,
332
+ source: 'browser',
333
+ });
334
+ }
335
+ }
336
+ else {
337
+ memoId = createMemo({
338
+ platform: ctx.platform, channelId: ctx.channelId,
339
+ threadId: ctx.threadId, userId: ctx.userId,
340
+ what: what || '位置',
341
+ whereLat: lat, whereLng: lng,
342
+ source: 'browser',
343
+ });
344
+ }
345
+ }
346
+ catch (err) {
347
+ webLog.warn({ event: 'web.loc.memo_failed', err: String(err) }, 'failed to write/update memo');
348
+ }
349
+ const messengerName = ctx.platform === 'wechat' ? 'wechat-ilink' : ctx.platform;
350
+ const messenger = registry.getMessenger(messengerName);
351
+ const idTag = memoId ? `#${memoId}` : '';
352
+ const accTxt = accuracy > 0 ? ` (±${Math.round(accuracy)}m)` : '';
353
+ const headLabel = displayWhat ? `'${displayWhat}'` : '';
354
+ const urls = mapUrls(lat, lng, displayWhat || '当前位置', '');
355
+ const verb = cr.memoId != null ? '已补全' : '已记下';
356
+ const reply = [
357
+ `✅ ${verb}${headLabel} ${idTag}`.trim(),
358
+ ` 坐标: ${lat.toFixed(6)}, ${lng.toFixed(6)}${accTxt}`,
359
+ '',
360
+ ` 🗺 [百度地图](${urls.baidu}) · [高德地图](${urls.amap}) · [Google](${urls.google})`,
361
+ ].filter(Boolean).join('\n');
362
+ if (messenger) {
363
+ messenger.sendMessage(ctx.threadId, reply).catch((err) => {
364
+ webLog.warn({ event: 'web.loc.dispatch_failed', threadId: ctx.threadId, err: String(err) });
365
+ });
366
+ }
367
+ else {
368
+ webLog.warn({ event: 'web.loc.no_messenger', platform: ctx.platform });
369
+ }
370
+ sendJson(res, 200, { ok: true, id: memoId, lat, lng });
371
+ return;
372
+ }
373
+ // REST API — gated by header token OR session cookie ONLY when
374
+ // requireAuth is on. Localhost binds let the call through; same
375
+ // trust model as the static pages above.
376
+ if (requireAuth && url.pathname.startsWith('/api/')) {
377
+ const token = req.headers['x-im-hub-token'] || '';
378
+ if (!safeEqual(token, webToken) && !hasValidSession(req)) {
379
+ res.writeHead(401);
380
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
381
+ return;
382
+ }
383
+ }
384
+ // REST API
385
+ if (url.pathname === '/api/config' && req.method === 'GET') {
386
+ return handleGetConfig(req, res);
387
+ }
388
+ if (url.pathname === '/api/config' && req.method === 'PUT') {
389
+ return handlePutConfig(req, res);
390
+ }
391
+ if (url.pathname === '/api/agents/status' && req.method === 'GET') {
392
+ return handleAgentsStatus(req, res);
393
+ }
394
+ if (url.pathname === '/api/agents/acp/test' && req.method === 'POST') {
395
+ return handleAcpTest(req, res);
396
+ }
397
+ if (url.pathname === '/api/agents/acp/discover' && req.method === 'POST') {
398
+ return handleAcpDiscover(req, res);
399
+ }
400
+ // Jobs
401
+ if (url.pathname === '/api/jobs' && req.method === 'GET') {
402
+ return handleListJobs(req, res, url);
403
+ }
404
+ const jobIdMatch = url.pathname.match(/^\/api\/jobs\/(\d+)$/);
405
+ if (jobIdMatch && req.method === 'GET') {
406
+ return handleGetJob(req, res, parseInt(jobIdMatch[1], 10));
407
+ }
408
+ const jobCancelMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
409
+ if (jobCancelMatch && req.method === 'POST') {
410
+ return handleCancelJob(req, res, parseInt(jobCancelMatch[1], 10));
411
+ }
412
+ const jobRunMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/run$/);
413
+ if (jobRunMatch && req.method === 'POST') {
414
+ return handleRunJob(req, res, parseInt(jobRunMatch[1], 10));
415
+ }
416
+ if (url.pathname === '/api/jobs' && req.method === 'POST') {
417
+ return handleCreateJob(req, res);
418
+ }
419
+ // bgjobs (read-only view of ~/.claude/bgjobs, ~/.config/opencode/bgjobs, ~/.codex/bgjobs)
420
+ if (url.pathname === '/api/bgjobs' && req.method === 'GET') {
421
+ return handleListBgjobs(req, res, url);
422
+ }
423
+ const bgjobIdMatch = url.pathname.match(/^\/api\/bgjobs\/([\w.-]+)$/);
424
+ if (bgjobIdMatch && req.method === 'GET') {
425
+ return handleGetBgjob(req, res, bgjobIdMatch[1], url);
426
+ }
427
+ // Subtasks (flattened view of session.subtasks across all conversations)
428
+ if (url.pathname === '/api/subtasks' && req.method === 'GET') {
429
+ return handleListSubtasks(req, res, url);
430
+ }
431
+ // Schedules
432
+ if (url.pathname === '/api/schedules' && req.method === 'GET') {
433
+ return handleListSchedules(req, res, url);
434
+ }
435
+ // Reminders — list / cancel / snooze. Web-only path (the IM-side path
436
+ // is /remind slash command). Auth via the same web session cookie as
437
+ // every other /api/* endpoint above; no per-user filtering yet, so
438
+ // single-operator deployments only.
439
+ if (url.pathname === '/api/reminders' && req.method === 'GET') {
440
+ return handleListReminders(req, res, url);
441
+ }
442
+ const reminderCancelMatch = url.pathname.match(/^\/api\/reminders\/(\d+)\/cancel$/);
443
+ if (reminderCancelMatch && req.method === 'POST') {
444
+ return handleCancelReminderApi(req, res, Number.parseInt(reminderCancelMatch[1], 10));
445
+ }
446
+ const reminderSnoozeMatch = url.pathname.match(/^\/api\/reminders\/(\d+)\/snooze$/);
447
+ if (reminderSnoozeMatch && req.method === 'POST') {
448
+ return handleSnoozeReminderApi(req, res, Number.parseInt(reminderSnoozeMatch[1], 10));
449
+ }
450
+ // /api/memos — search / list / delete. List uses the same searchMemos
451
+ // function the MCP tool exposes; query/who/what/has_location/limit
452
+ // come through as URL params.
453
+ if (url.pathname === '/api/memos' && req.method === 'GET') {
454
+ return handleListMemos(req, res, url);
455
+ }
456
+ const memoIdMatch = url.pathname.match(/^\/api\/memos\/(\d+)$/);
457
+ if (memoIdMatch && req.method === 'DELETE') {
458
+ return handleDeleteMemo(req, res, Number.parseInt(memoIdMatch[1], 10));
459
+ }
460
+ // /api/env — read/write SMTP + Baidu AK + IMHUB_WEB_BIND. Values
461
+ // sensitive enough that GET returns them masked (only the last 4 chars
462
+ // visible) unless an explicit ?reveal=1 is passed (still auth-gated).
463
+ if (url.pathname === '/api/env' && req.method === 'GET') {
464
+ return handleGetEnv(req, res, url);
465
+ }
466
+ if (url.pathname === '/api/env' && req.method === 'PUT') {
467
+ return handlePutEnv(req, res);
468
+ }
469
+ if (url.pathname === '/api/workspaces' && req.method === 'GET') {
470
+ return handleListWorkspaces(req, res, url);
471
+ }
472
+ if (url.pathname === '/api/workspaces' && req.method === 'POST') {
473
+ return handleCreateOrUpdateWorkspace(req, res);
474
+ }
475
+ const workspaceIdMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
476
+ if (workspaceIdMatch && req.method === 'PATCH') {
477
+ return handleCreateOrUpdateWorkspace(req, res, workspaceIdMatch[1]);
478
+ }
479
+ if (workspaceIdMatch && req.method === 'DELETE') {
480
+ return handleDeleteWorkspace(req, res, workspaceIdMatch[1]);
481
+ }
482
+ if (url.pathname === '/api/metrics' && req.method === 'GET') {
483
+ return handleMetrics(req, res, url);
484
+ }
485
+ if (url.pathname === '/api/audit' && req.method === 'GET') {
486
+ return handleAudit(req, res, url);
487
+ }
488
+ // PR-B: agent health snapshot (circuit breaker + rate-limiter remaining
489
+ // + latency p50/95/99) consumed by the Health tab in /tasks.
490
+ if (url.pathname === '/api/agent-health' && req.method === 'GET') {
491
+ return handleAgentHealth(req, res);
492
+ }
493
+ // PR-B: HITL approvals — global pending list + per-reqId resolve.
494
+ if (url.pathname === '/api/approvals' && req.method === 'GET') {
495
+ return handleListApprovals(req, res);
496
+ }
497
+ const approvalResolveMatch = url.pathname.match(/^\/api\/approvals\/([^/]+)\/resolve$/);
498
+ if (approvalResolveMatch && req.method === 'POST') {
499
+ return handleResolveApproval(req, res, approvalResolveMatch[1]);
500
+ }
501
+ // PR-D: Agent workspace file browser. Read-only inspection of
502
+ // ~/.im-hub-workspaces/<agent>/ contents — list dirs, peek small
503
+ // text files. PUT path supports inline editing (annotate CLAUDE.md,
504
+ // AGENTS.md, etc.) — same traversal/size guards as GET.
505
+ if (url.pathname === '/api/workspace-files' && req.method === 'GET') {
506
+ return handleWorkspaceFiles(req, res, url);
507
+ }
508
+ if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
509
+ return handleWorkspaceFileWrite(req, res, url);
510
+ }
511
+ // PR-D: Job batch operations. Same semantics as /api/jobs/:id/cancel
512
+ // and /run but accepts an array of ids in one request — saves N
513
+ // round-trips when the user multi-selects a long list.
514
+ if (url.pathname === '/api/jobs/batch-cancel' && req.method === 'POST') {
515
+ return handleBatchJob(req, res, 'cancel');
516
+ }
517
+ if (url.pathname === '/api/jobs/batch-run' && req.method === 'POST') {
518
+ return handleBatchJob(req, res, 'run', options.defaultAgent);
519
+ }
520
+ // PR-C: SSE event stream — audit / approval / job / metrics events
521
+ // pushed real-time so the dashboard stops polling. EventSource has no
522
+ // header API, so the token rides in `?token=<webToken>` (same shape
523
+ // the WS upgrade uses). Auth is validated inside the handler since
524
+ // /events is outside the /api/* token gate above.
525
+ if (url.pathname === '/events' && req.method === 'GET') {
526
+ const evToken = url.searchParams.get('token') || '';
527
+ if (requireAuth && !safeEqual(evToken, webToken) && !hasValidSession(req)) {
528
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
529
+ res.end('Unauthorized');
530
+ return;
531
+ }
532
+ return handleEventsSSE(req, res);
533
+ }
534
+ // /api/health handled above the token gate (M4) — keep this comment so
535
+ // future contributors don't re-add the route inside the auth block.
536
+ if (url.pathname === '/api/notify' && req.method === 'POST') {
537
+ return handleNotify(req, res);
538
+ }
539
+ if (url.pathname === '/api/invoke' && req.method === 'POST') {
540
+ return handleInvoke(req, res, options.defaultAgent);
541
+ }
542
+ res.writeHead(404);
543
+ res.end('Not found');
544
+ });
545
+ // WebSocket server
546
+ const wss = new WebSocketServer({ server: httpServer });
547
+ // M3: cap concurrent WS clients so a leaked / shared web token can't OOM
548
+ // the host by opening unbounded connections. Default 100 is generous for
549
+ // a single-user / small-team setup; production multi-tenant should set
550
+ // IMHUB_MAX_WS_CLIENTS to a higher value.
551
+ const maxWsClients = (() => {
552
+ const raw = process.env.IMHUB_MAX_WS_CLIENTS;
553
+ if (raw) {
554
+ const n = parseInt(raw, 10);
555
+ if (Number.isFinite(n) && n > 0)
556
+ return n;
557
+ }
558
+ return 100;
559
+ })();
560
+ wss.on('connection', (ws, req) => {
561
+ if (clients.size >= maxWsClients) {
562
+ // 1013 = "Try Again Later" per RFC 6455. Slightly nicer than a flat
563
+ // close — clients with reconnect logic will back off.
564
+ webLog.warn({
565
+ event: 'ws.cap_reached',
566
+ active: clients.size,
567
+ cap: maxWsClients,
568
+ }, 'WS connection refused (cap)');
569
+ ws.close(1013, 'Server too busy');
570
+ return;
571
+ }
572
+ // Verify token from URL query or session cookie before accepting connection
573
+ // — only when requireAuth (public bind). Localhost connections are trusted.
574
+ const wsUrl = new URL(req.url || '/', `http://localhost:${port}`);
575
+ const wsToken = wsUrl.searchParams.get('token') || '';
576
+ if (requireAuth && !safeEqual(wsToken, webToken) && !hasValidSession(req)) {
577
+ ws.close(1008, 'Unauthorized');
578
+ return;
579
+ }
580
+ const clientId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
581
+ const client = { ws, id: clientId, agent: options.defaultAgent };
582
+ clients.set(clientId, client);
583
+ webLog.info({ clientId }, 'Client connected');
584
+ // Send available agents list
585
+ sendToClient(ws, {
586
+ type: 'init',
587
+ agents: registry.listAgents(),
588
+ defaultAgent: options.defaultAgent,
589
+ clientId,
590
+ });
591
+ // Load existing session history if available
592
+ sendSessionHistory(ws, clientId, options.defaultAgent);
593
+ const enqueueInbound = createSerialQueue();
594
+ ws.on('message', (data) => {
595
+ enqueueInbound(async () => {
596
+ try {
597
+ const msg = JSON.parse(data.toString());
598
+ // Approval-button click intercept. The user tapped an in-page
599
+ // approval card button; route it through the web messenger's
600
+ // button handler (registered by approval-router on install) the
601
+ // same way a Telegram inline-keyboard tap is routed. We don't
602
+ // call handleClientMessage for these — they're not chat input.
603
+ if (msg && msg.type === 'approval-action') {
604
+ const actionData = String(msg.data || '');
605
+ const messageId = String(msg.messageId || '');
606
+ webLog.info({
607
+ event: 'approval.web.click_received',
608
+ clientId, data: actionData, messageId,
609
+ handlerBound: !!webButtonHandler,
610
+ });
611
+ if (!actionData || !messageId) {
612
+ sendToClient(ws, { type: 'error', message: 'approval-action missing data/messageId' });
613
+ return;
614
+ }
615
+ if (!webButtonHandler) {
616
+ // Without the handler, a click would silently no-op forever — the
617
+ // failure mode that PR-A's fix patches. Tell the user and the
618
+ // operator (via log) instead of dropping the click.
619
+ const why = 'approval handler not bound (router not installed?). Restart im-hub to rebind.';
620
+ webLog.warn({ event: 'approval.web.no_handler', clientId, data: actionData, messageId }, why);
621
+ sendToClient(ws, { type: 'error', message: why });
622
+ return;
623
+ }
624
+ try {
625
+ // Most messengers' ButtonCallback#ack updates a platform-native
626
+ // toast / loading spinner. The web client doesn't have one, so
627
+ // ack is a no-op resolving to the in-page status the page itself
628
+ // chose to render after click.
629
+ await webButtonHandler({
630
+ data: actionData, threadId: clientId, userId: `web:${clientId}`,
631
+ userDisplay: 'Web', messageId, ack: async () => { },
632
+ });
633
+ webLog.info({ event: 'approval.web.click_resolved', clientId, data: actionData });
634
+ }
635
+ catch (err) {
636
+ const errMsg = err instanceof Error ? err.message : String(err);
637
+ webLog.error({ event: 'approval.web.click_failed', clientId, data: actionData, err: errMsg });
638
+ sendToClient(ws, { type: 'error', message: `Approval click failed: ${errMsg}` });
639
+ }
640
+ return;
641
+ }
642
+ await handleClientMessage(client, msg, options.defaultAgent);
643
+ }
644
+ catch (err) {
645
+ webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Error parsing client message');
646
+ sendToClient(ws, { type: 'error', message: 'Invalid message format' });
647
+ }
648
+ });
649
+ });
650
+ ws.on('close', () => {
651
+ webLog.info({ clientId }, 'Client disconnected');
652
+ clients.delete(clientId);
653
+ });
654
+ ws.on('error', (err) => {
655
+ webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
656
+ clients.delete(clientId);
657
+ });
658
+ });
659
+ // Default to loopback; operators can opt into LAN/public exposure with
660
+ // IMHUB_WEB_BIND=0.0.0.0 behind their firewall/reverse proxy.
661
+ await new Promise((resolve, reject) => {
662
+ httpServer.on('error', reject);
663
+ httpServer.listen(port, bindHost, () => resolve());
664
+ });
665
+ webLog.info({ port, bindHost }, `Chat UI available at http://${bindHost === '0.0.0.0' ? 'localhost' : bindHost}:${port}`);
666
+ // ============================================================
667
+ // Web messenger registration (HITL approval bridge)
668
+ // ============================================================
669
+ // Register a synthetic messenger named 'web' so approval-router (which
670
+ // resolves the target messenger by platform name) can deliver approval
671
+ // prompts AND outcome edits to the matching browser tab over the
672
+ // existing WebSocket. Chat ingress is unaffected — incoming chat
673
+ // messages still flow through handleClientMessage / routeMessage as
674
+ // before; this messenger only forwards what the bus wants to push.
675
+ //
676
+ // threadId is the WS clientId (RouteContext.threadId === client.id for
677
+ // the web platform). We resolve the matching client at delivery time;
678
+ // if the client has disconnected the send is a no-op and the bus's own
679
+ // auto-deny / sidecar-disconnect path takes over.
680
+ let cardSeq = 0;
681
+ const webMessenger = {
682
+ name: 'web',
683
+ start: async () => { },
684
+ stop: async () => { },
685
+ onMessage: () => { },
686
+ async sendMessage(threadId, text) {
687
+ const c = clients.get(threadId);
688
+ if (!c || c.ws.readyState !== c.ws.OPEN)
689
+ return;
690
+ sendToClient(c.ws, { type: 'approval-text', text });
691
+ },
692
+ async sendApprovalCard(threadId, prompt) {
693
+ const c = clients.get(threadId);
694
+ const messageId = `web-card-${++cardSeq}-${Date.now().toString(36)}`;
695
+ if (c && c.ws.readyState === c.ws.OPEN) {
696
+ sendToClient(c.ws, { type: 'approval-card', messageId, prompt });
697
+ }
698
+ return { messageId };
699
+ },
700
+ async editApprovalCard(threadId, messageId, outcome) {
701
+ const c = clients.get(threadId);
702
+ if (!c || c.ws.readyState !== c.ws.OPEN)
703
+ return;
704
+ sendToClient(c.ws, { type: 'approval-card-edit', messageId, outcome });
705
+ },
706
+ onButtonCallback(handler) {
707
+ webButtonHandler = handler;
708
+ webLog.info({ event: 'approval.web.handler_bound' }, 'web messenger button-callback handler attached');
709
+ },
710
+ };
711
+ registry.registerMessenger(webMessenger);
712
+ // approval-router's install() loop bound buttonCallback only for messengers
713
+ // registered BEFORE install. Our web messenger was just registered (after
714
+ // install), so we have to wire it ourselves — otherwise in-page approval
715
+ // card clicks fire WS 'approval-action' messages with no handler on the
716
+ // server side and silently do nothing. bindButtonHandlerForPlatform is a
717
+ // no-op if approval-router hasn't been install()'d yet (e.g. degraded
718
+ // mode where the bus failed to start).
719
+ try {
720
+ const { bindButtonHandlerForPlatform } = await import('../core/approval-router.js');
721
+ bindButtonHandlerForPlatform('web');
722
+ if (!webButtonHandler) {
723
+ // bindButtonHandlerForPlatform is a silent no-op when `installed` is
724
+ // null on the router (bus failed to start, or cli skipped install).
725
+ // Log so an operator who's confused why approval clicks don't work
726
+ // sees a clear breadcrumb at startup.
727
+ webLog.warn({ event: 'approval.web.bind_skipped' }, 'approval-router not installed — web approval clicks will fail until restart');
728
+ }
729
+ }
730
+ catch (err) {
731
+ webLog.warn({ event: 'approval.web.bind_error', err: err instanceof Error ? err.message : String(err) }, 'approval-router button-handler binding threw');
732
+ }
733
+ // PR-C: periodic metrics tick. Publishes a per-agent snapshot every 5s
734
+ // so the dashboard's Health sparkline can advance even when there are
735
+ // no audit events firing. The bus's recent buffer keeps the latest
736
+ // tick available to fresh SSE connections, so a tab opened mid-cycle
737
+ // sees current data without waiting up to 5 s.
738
+ const metricsTick = setInterval(async () => {
739
+ try {
740
+ const { eventBus } = await import('../core/event-bus.js');
741
+ const { snapshot } = await import('../core/metrics.js');
742
+ const snap = snapshot();
743
+ eventBus.publish({
744
+ type: 'metrics',
745
+ ts: new Date().toISOString(),
746
+ agents: snap.agents.map((a) => ({
747
+ agent: a.agent, total: a.total, success: a.success, failure: a.failure,
748
+ p50Ms: a.p50Ms, p95Ms: a.p95Ms, p99Ms: a.p99Ms,
749
+ })),
750
+ });
751
+ }
752
+ catch { /* swallow — metrics tick must never break the web server */ }
753
+ }, 5_000);
754
+ if (typeof metricsTick === 'object' && metricsTick && 'unref' in metricsTick) {
755
+ metricsTick.unref();
756
+ }
757
+ return {
758
+ port,
759
+ close: () => {
760
+ // Close all WebSocket connections
761
+ for (const [id, client] of clients) {
762
+ client.ws.close();
763
+ clients.delete(id);
764
+ }
765
+ wss.close();
766
+ httpServer.close();
767
+ },
768
+ };
769
+ }
770
+ // ============================================
771
+ // REST API handlers
772
+ // ============================================
773
+ async function handleGetConfig(_req, res) {
774
+ try {
775
+ const config = await loadConfig();
776
+ const agentStatus = await getAgentStatuses();
777
+ sendJson(res, 200, {
778
+ messengers: config.messengers,
779
+ agents: config.agents,
780
+ defaultAgent: config.defaultAgent,
781
+ telegram: config.telegram
782
+ ? { botToken: mask(config.telegram.botToken), channelId: config.telegram.channelId }
783
+ : undefined,
784
+ feishu: config.feishu
785
+ ? { appId: config.feishu.appId, appSecret: mask(config.feishu.appSecret) }
786
+ : undefined,
787
+ acpAgents: config.acpAgents?.map(a => ({
788
+ ...a,
789
+ auth: a.auth
790
+ ? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
791
+ : undefined,
792
+ })),
793
+ webPort: config.webPort,
794
+ agentStatus,
795
+ });
796
+ }
797
+ catch (_err) {
798
+ sendJson(res, 500, { error: 'Failed to load config' });
799
+ }
800
+ }
801
+ async function handlePutConfig(req, res) {
802
+ try {
803
+ const body = await readBody(req, res);
804
+ const incoming = JSON.parse(body);
805
+ const existing = await loadConfig();
806
+ const merged = { ...existing };
807
+ for (const key of Object.keys(incoming)) {
808
+ const val = incoming[key];
809
+ // Deep-protect nested known-masked paths so `ab****yz` never overwrites true value
810
+ if (key === 'telegram' && typeof val === 'object' && val !== null) {
811
+ const t = val;
812
+ merged.telegram = {
813
+ ...(existing.telegram || {}),
814
+ ...t,
815
+ botToken: typeof t.botToken === 'string' && isMasked(t.botToken) ? existing.telegram?.botToken : t.botToken,
816
+ };
817
+ continue;
818
+ }
819
+ if (key === 'feishu' && typeof val === 'object' && val !== null) {
820
+ const f = val;
821
+ merged.feishu = {
822
+ ...(existing.feishu || {}),
823
+ ...f,
824
+ appSecret: typeof f.appSecret === 'string' && isMasked(f.appSecret) ? existing.feishu?.appSecret : f.appSecret,
825
+ };
826
+ continue;
827
+ }
828
+ if (key === 'acpAgents' && Array.isArray(val)) {
829
+ merged.acpAgents = val.map((item, i) => {
830
+ const a = item;
831
+ const old = existing.acpAgents?.[i];
832
+ if (a?.auth && typeof a.auth === 'object' && typeof a.auth.token === 'string' && isMasked(a.auth.token)) {
833
+ return { ...a, auth: { ...a.auth, token: old?.auth?.token } };
834
+ }
835
+ return a;
836
+ });
837
+ continue;
838
+ }
839
+ if (typeof val === 'string' && isMasked(val)) {
840
+ continue;
841
+ }
842
+ merged[key] = val;
843
+ }
844
+ const result = validateConfig(merged);
845
+ if (!result.ok) {
846
+ sendJson(res, 400, { error: 'Config validation failed', details: result.errors });
847
+ return;
848
+ }
849
+ await saveConfig(result.config);
850
+ sendJson(res, 200, { ok: true });
851
+ }
852
+ catch (err) {
853
+ const msg = err instanceof Error ? err.message : String(err);
854
+ sendJson(res, 400, { error: msg });
855
+ }
856
+ }
857
+ async function handleAgentsStatus(_req, res) {
858
+ try {
859
+ const agentStatus = await getAgentStatuses();
860
+ sendJson(res, 200, agentStatus);
861
+ }
862
+ catch (_err) {
863
+ sendJson(res, 500, { error: 'Failed to check agents' });
864
+ }
865
+ }
866
+ async function handleListWorkspaces(_req, res, url) {
867
+ try {
868
+ const { workspaceRegistry } = await import('../core/workspace.js');
869
+ // ?full=1 returns the full WorkspaceConfig (including member IDs)
870
+ // for the settings editor; default is the summary shape (member count
871
+ // only) used elsewhere.
872
+ const wantFull = url?.searchParams.get('full') === '1';
873
+ sendJson(res, 200, {
874
+ workspaces: wantFull ? workspaceRegistry.listFull() : workspaceRegistry.list(),
875
+ });
876
+ }
877
+ catch (err) {
878
+ const msg = err instanceof Error ? err.message : String(err);
879
+ sendJson(res, 500, { error: msg });
880
+ }
881
+ }
882
+ /**
883
+ * Validate + sanitize an incoming WorkspaceConfig from the settings
884
+ * editor. Returns a clean object on success or a string error message
885
+ * on failure. Reused by POST and PATCH so behavior is identical.
886
+ */
887
+ function validateWorkspacePayload(raw, expectedId) {
888
+ if (!raw || typeof raw !== 'object')
889
+ return { ok: false, error: 'body must be a JSON object' };
890
+ const o = raw;
891
+ const id = String(o.id || '').trim();
892
+ if (!id)
893
+ return { ok: false, error: 'id is required' };
894
+ if (!/^[a-zA-Z0-9_-]+$/.test(id))
895
+ return { ok: false, error: 'id must match [a-zA-Z0-9_-]+' };
896
+ if (id === 'default' && expectedId !== 'default') {
897
+ return { ok: false, error: '"default" workspace is reserved (use PATCH to edit)' };
898
+ }
899
+ if (expectedId && expectedId !== id) {
900
+ return { ok: false, error: `id mismatch: URL is ${expectedId}, body is ${id}` };
901
+ }
902
+ const name = String(o.name || id);
903
+ const agents = Array.isArray(o.agents) ? o.agents.filter((a) => typeof a === 'string') : [];
904
+ const members = Array.isArray(o.members) ? o.members.filter((m) => typeof m === 'string') : undefined;
905
+ let rateLimit;
906
+ if (o.rateLimit && typeof o.rateLimit === 'object') {
907
+ const r = o.rateLimit;
908
+ const rate = Number(r.rate);
909
+ const intervalSec = Number(r.intervalSec);
910
+ const burst = Number(r.burst);
911
+ if (!Number.isFinite(rate) || rate <= 0
912
+ || !Number.isFinite(intervalSec) || intervalSec <= 0
913
+ || !Number.isFinite(burst) || burst <= 0) {
914
+ return { ok: false, error: 'rateLimit.rate / intervalSec / burst must be positive numbers' };
915
+ }
916
+ rateLimit = { rate, intervalSec, burst };
917
+ }
918
+ return { ok: true, cfg: { id, name, agents, members, rateLimit } };
919
+ }
920
+ /**
921
+ * Persist the workspaces array back to ~/.im-hub/config.json so changes
922
+ * survive a restart. We do not touch other config fields — settings.html
923
+ * has its own /api/config PUT for that. Best-effort: a write failure is
924
+ * logged but the in-memory registry has already been updated, so the
925
+ * change is live until the next process boot.
926
+ */
927
+ async function persistWorkspacesToConfig(workspaces) {
928
+ const config = await loadConfig();
929
+ config.workspaces = workspaces;
930
+ await saveConfig(config);
931
+ }
932
+ async function handleCreateOrUpdateWorkspace(req, res, expectedId) {
933
+ try {
934
+ const body = await readBody(req, res);
935
+ let parsed;
936
+ try {
937
+ parsed = JSON.parse(body);
938
+ }
939
+ catch {
940
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
941
+ return;
942
+ }
943
+ const v = validateWorkspacePayload(parsed, expectedId);
944
+ if (!v.ok) {
945
+ sendJson(res, 400, { ok: false, error: v.error });
946
+ return;
947
+ }
948
+ const { workspaceRegistry } = await import('../core/workspace.js');
949
+ workspaceRegistry.add(v.cfg);
950
+ await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
951
+ sendJson(res, 200, { ok: true, workspace: v.cfg });
952
+ }
953
+ catch (err) {
954
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
955
+ }
956
+ }
957
+ async function handleDeleteWorkspace(_req, res, id) {
958
+ try {
959
+ const { workspaceRegistry } = await import('../core/workspace.js');
960
+ if (id === 'default') {
961
+ sendJson(res, 400, { ok: false, error: 'cannot delete the default workspace' });
962
+ return;
963
+ }
964
+ const removed = workspaceRegistry.remove(id);
965
+ if (!removed) {
966
+ sendJson(res, 404, { ok: false, error: `workspace "${id}" not found` });
967
+ return;
968
+ }
969
+ await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
970
+ sendJson(res, 200, { ok: true });
971
+ }
972
+ catch (err) {
973
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
974
+ }
975
+ }
976
+ async function handleMetrics(_req, res, url) {
977
+ try {
978
+ const fmt = url.searchParams.get('format') || 'prom';
979
+ const { snapshot, toPrometheus } = await import('../core/metrics.js');
980
+ if (fmt === 'json') {
981
+ sendJson(res, 200, snapshot());
982
+ return;
983
+ }
984
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
985
+ res.end(toPrometheus());
986
+ }
987
+ catch (err) {
988
+ const msg = err instanceof Error ? err.message : String(err);
989
+ sendJson(res, 500, { error: msg });
990
+ }
991
+ }
992
+ /**
993
+ * POST /api/notify → push a message to an IM thread.
994
+ *
995
+ * Body: { platform, threadId, text, card? }
996
+ * Use case: external systems (CI / monitoring / cron) pushing notices
997
+ * back to a chat thread without going through the Agent layer.
998
+ */
999
+ async function handleNotify(req, res) {
1000
+ try {
1001
+ const body = await readBody(req, res);
1002
+ const { platform, threadId, text, card } = JSON.parse(body);
1003
+ if (!platform || !threadId || (!text && !card)) {
1004
+ sendJson(res, 400, { error: 'Missing platform / threadId / (text|card)' });
1005
+ return;
1006
+ }
1007
+ // Map platform name to messenger plugin name.
1008
+ const messengerName = platform === 'wechat' ? 'wechat-ilink' : platform;
1009
+ const messenger = registry.getMessenger(messengerName);
1010
+ if (!messenger) {
1011
+ sendJson(res, 404, { error: `Messenger "${platform}" not registered` });
1012
+ return;
1013
+ }
1014
+ const traceId = generateTraceId();
1015
+ const log = createLogger({ traceId, platform, component: 'notify' });
1016
+ log.info({ threadId, hasCard: !!card, textLen: text?.length || 0 }, 'notify in');
1017
+ if (card && typeof messenger.sendCard === 'function') {
1018
+ await messenger.sendCard(threadId, card);
1019
+ }
1020
+ else if (text) {
1021
+ await messenger.sendMessage(threadId, text);
1022
+ }
1023
+ else {
1024
+ sendJson(res, 400, { error: 'card requires sendCard support, otherwise text is required' });
1025
+ return;
1026
+ }
1027
+ sendJson(res, 200, { ok: true, traceId });
1028
+ }
1029
+ catch (err) {
1030
+ const e = err;
1031
+ if (e?.handled)
1032
+ return;
1033
+ const status = e?.statusCode || 500;
1034
+ const msg = e instanceof Error ? e.message : String(err);
1035
+ if (!res.headersSent)
1036
+ sendJson(res, status, { error: msg });
1037
+ }
1038
+ }
1039
+ /**
1040
+ * POST /api/invoke → run an agent prompt as if it came from a user.
1041
+ *
1042
+ * Body: { prompt, agent?, userId?, platform? }
1043
+ * Returns a JSON response with the full text (for streaming use the ACP
1044
+ * server's POST /tasks?mode=stream instead).
1045
+ */
1046
+ async function handleInvoke(req, res, defaultAgent) {
1047
+ try {
1048
+ const body = await readBody(req, res);
1049
+ const parsed = JSON.parse(body);
1050
+ if (!parsed.prompt) {
1051
+ sendJson(res, 400, { error: 'Missing prompt' });
1052
+ return;
1053
+ }
1054
+ const agentName = parsed.agent || defaultAgent;
1055
+ const promptText = parsed.agent ? `/${parsed.agent} ${parsed.prompt}` : parsed.prompt;
1056
+ const traceId = generateTraceId();
1057
+ const platform = parsed.platform || 'rest';
1058
+ const log = createLogger({ traceId, platform, component: 'invoke' });
1059
+ log.info({ agent: agentName, promptLen: parsed.prompt.length }, 'invoke in');
1060
+ const routeCtx = {
1061
+ threadId: `rest:${traceId}`,
1062
+ channelId: 'rest',
1063
+ platform,
1064
+ defaultAgent: agentName,
1065
+ traceId,
1066
+ logger: log,
1067
+ userId: parsed.userId || 'rest-caller',
1068
+ };
1069
+ const parsedMsg = parseMessage(promptText);
1070
+ const result = await routeMessage(parsedMsg, routeCtx);
1071
+ let fullText = '';
1072
+ if (typeof result === 'string') {
1073
+ fullText = result;
1074
+ }
1075
+ else {
1076
+ for await (const chunk of result)
1077
+ fullText += chunk;
1078
+ }
1079
+ sendJson(res, 200, { ok: true, traceId, output: { content: fullText } });
1080
+ }
1081
+ catch (err) {
1082
+ const e = err;
1083
+ if (e?.handled)
1084
+ return;
1085
+ const status = e?.statusCode || 500;
1086
+ const msg = e instanceof Error ? e.message : String(err);
1087
+ if (!res.headersSent)
1088
+ sendJson(res, status, { error: msg });
1089
+ }
1090
+ }
1091
+ async function handleHealth(_req, res) {
1092
+ // Quick check: agent availability snapshot. Already used by settings UI;
1093
+ // exposing it under /api/health gives ops a stable URL.
1094
+ try {
1095
+ const status = await getAgentStatuses();
1096
+ const anyHealthy = Object.values(status).some(Boolean);
1097
+ sendJson(res, anyHealthy ? 200 : 503, {
1098
+ ok: anyHealthy,
1099
+ agents: status,
1100
+ uptimeSec: Math.round(process.uptime()),
1101
+ });
1102
+ }
1103
+ catch (err) {
1104
+ const msg = err instanceof Error ? err.message : String(err);
1105
+ sendJson(res, 500, { ok: false, error: msg });
1106
+ }
1107
+ }
1108
+ async function handleListJobs(_req, res, url) {
1109
+ try {
1110
+ const { listJobs, getJobStats } = await import('../core/job-board.js');
1111
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 1), 500);
1112
+ const status = url.searchParams.get('status');
1113
+ const agent = url.searchParams.get('agent') || undefined;
1114
+ const jobs = listJobs(limit, status || undefined, agent ? { agent } : {});
1115
+ const stats = getJobStats();
1116
+ sendJson(res, 200, { jobs, stats });
1117
+ }
1118
+ catch (err) {
1119
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1120
+ }
1121
+ }
1122
+ async function handleGetJob(_req, res, id) {
1123
+ try {
1124
+ const { getJob } = await import('../core/job-board.js');
1125
+ const job = getJob(id);
1126
+ if (!job) {
1127
+ sendJson(res, 404, { error: 'Job not found' });
1128
+ return;
1129
+ }
1130
+ sendJson(res, 200, { job });
1131
+ }
1132
+ catch (err) {
1133
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1134
+ }
1135
+ }
1136
+ async function handleCancelJob(_req, res, id) {
1137
+ try {
1138
+ const { cancelJob } = await import('../core/job-board.js');
1139
+ sendJson(res, 200, { ok: cancelJob(id) });
1140
+ }
1141
+ catch (err) {
1142
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1143
+ }
1144
+ }
1145
+ async function handleRunJob(_req, res, id) {
1146
+ try {
1147
+ const { getJob, runJob } = await import('../core/job-board.js');
1148
+ const { AgentBase } = await import('../core/agent-base.js');
1149
+ const job = getJob(id);
1150
+ if (!job) {
1151
+ sendJson(res, 404, { error: 'Job not found' });
1152
+ return;
1153
+ }
1154
+ const agent = registry.findAgent(job.agent);
1155
+ if (!agent) {
1156
+ sendJson(res, 404, { error: `Agent "${job.agent}" not registered` });
1157
+ return;
1158
+ }
1159
+ const traceId = generateTraceId();
1160
+ const log = createLogger({ traceId, platform: 'web', component: 'job-run' });
1161
+ // Fire and forget — UI polls /api/jobs/:id for status.
1162
+ void runJob(id, async function* (j, _logger, signal) {
1163
+ if (agent instanceof AgentBase) {
1164
+ const text = await agent.spawnAndCollect(j.prompt, signal);
1165
+ if (text)
1166
+ yield text;
1167
+ }
1168
+ else {
1169
+ for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
1170
+ if (signal.aborted)
1171
+ break;
1172
+ yield chunk;
1173
+ }
1174
+ }
1175
+ }, log).catch(() => { });
1176
+ sendJson(res, 200, { ok: true, traceId });
1177
+ }
1178
+ catch (err) {
1179
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1180
+ }
1181
+ }
1182
+ async function handleCreateJob(req, res) {
1183
+ try {
1184
+ const body = await readBody(req, res);
1185
+ const { agent, prompt } = JSON.parse(body);
1186
+ if (!agent || !prompt) {
1187
+ sendJson(res, 400, { error: 'Missing agent / prompt' });
1188
+ return;
1189
+ }
1190
+ if (!registry.findAgent(agent)) {
1191
+ sendJson(res, 404, { error: `Agent "${agent}" not registered` });
1192
+ return;
1193
+ }
1194
+ const { createJob } = await import('../core/job-board.js');
1195
+ const id = createJob(agent, prompt);
1196
+ sendJson(res, 200, { ok: true, id });
1197
+ }
1198
+ catch (err) {
1199
+ const e = err;
1200
+ if (e?.handled)
1201
+ return;
1202
+ if (!res.headersSent)
1203
+ sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
1204
+ }
1205
+ }
1206
+ async function handleListSchedules(_req, res, url) {
1207
+ try {
1208
+ const { listSchedules } = await import('../core/schedule.js');
1209
+ const agent = url.searchParams.get('agent') || undefined;
1210
+ sendJson(res, 200, { schedules: listSchedules(50, agent ? { agent } : {}) });
1211
+ }
1212
+ catch (err) {
1213
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1214
+ }
1215
+ }
1216
+ /**
1217
+ * GET /api/reminders?status=pending&limit=100
1218
+ * Returns a flat list of reminders. status filter is optional; without it
1219
+ * we only return pending — that's the 99% UI use-case (the page is a
1220
+ * "what's queued up" view, not an audit log).
1221
+ */
1222
+ async function handleListReminders(_req, res, url) {
1223
+ try {
1224
+ const { listReminders, describeRecurrence } = await import('../core/reminders.js');
1225
+ const status = url.searchParams.get('status');
1226
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 500);
1227
+ const rows = listReminders({ status: status ?? 'pending', limit });
1228
+ const annotated = rows.map((r) => ({
1229
+ ...r,
1230
+ recurrence_label: r.recurrence ? describeRecurrence(r.recurrence) : null,
1231
+ }));
1232
+ sendJson(res, 200, { reminders: annotated });
1233
+ }
1234
+ catch (err) {
1235
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1236
+ }
1237
+ }
1238
+ async function handleCancelReminderApi(_req, res, id) {
1239
+ if (!Number.isFinite(id) || id <= 0) {
1240
+ sendJson(res, 400, { error: 'invalid id' });
1241
+ return;
1242
+ }
1243
+ try {
1244
+ const { cancelReminder } = await import('../core/reminders.js');
1245
+ const ok = cancelReminder(id);
1246
+ if (!ok) {
1247
+ sendJson(res, 404, { error: 'not found or not cancellable' });
1248
+ return;
1249
+ }
1250
+ sendJson(res, 200, { ok: true, id });
1251
+ }
1252
+ catch (err) {
1253
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1254
+ }
1255
+ }
1256
+ async function handleSnoozeReminderApi(req, res, id) {
1257
+ if (!Number.isFinite(id) || id <= 0) {
1258
+ sendJson(res, 400, { error: 'invalid id' });
1259
+ return;
1260
+ }
1261
+ try {
1262
+ const body = await readBody(req, res);
1263
+ const parsed = JSON.parse(body || '{}');
1264
+ const duration = parsed.duration?.trim() || '';
1265
+ if (!duration) {
1266
+ sendJson(res, 400, { error: 'duration required (e.g. "5m")' });
1267
+ return;
1268
+ }
1269
+ const { snoozeReminder, parseDuration, ReminderError } = await import('../core/reminders.js');
1270
+ const ms = parseDuration(duration);
1271
+ if (ms === null) {
1272
+ sendJson(res, 400, { error: `bad duration "${duration}"` });
1273
+ return;
1274
+ }
1275
+ try {
1276
+ const newId = snoozeReminder(id, ms);
1277
+ sendJson(res, 200, { ok: true, originalId: id, newId, duration });
1278
+ }
1279
+ catch (err) {
1280
+ if (err instanceof ReminderError) {
1281
+ sendJson(res, 400, { error: err.message });
1282
+ return;
1283
+ }
1284
+ throw err;
1285
+ }
1286
+ }
1287
+ catch (err) {
1288
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1289
+ }
1290
+ }
1291
+ // ─── memos ─────────────────────────────────────────────────────────────
1292
+ async function handleListMemos(_req, res, url) {
1293
+ try {
1294
+ const { searchMemos } = await import('../core/memos.js');
1295
+ const query = url.searchParams.get('query') || undefined;
1296
+ const who = url.searchParams.get('who') || undefined;
1297
+ const what = url.searchParams.get('what') || undefined;
1298
+ const hasLocation = url.searchParams.get('has_location') === 'true' ? true : undefined;
1299
+ const includeExpired = url.searchParams.get('include_expired') === 'true' ? true : undefined;
1300
+ const limitRaw = parseInt(url.searchParams.get('limit') || '50', 10);
1301
+ const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50;
1302
+ const rows = searchMemos({ query, who, what, hasLocation, includeExpired, limit });
1303
+ // Strip ownership noise but keep everything searchable / displayable.
1304
+ // mapUrls is added per-row so the dashboard can render map links.
1305
+ const { mapUrls: makeMapUrls } = await import('../core/memos.js');
1306
+ const items = rows.map((m) => {
1307
+ const out = {
1308
+ id: m.id,
1309
+ platform: m.platform,
1310
+ userId: m.user_id,
1311
+ what: m.what,
1312
+ who: m.who,
1313
+ whenAt: m.when_at,
1314
+ whenText: m.when_text,
1315
+ where_lat: m.where_lat,
1316
+ where_lng: m.where_lng,
1317
+ where_label: m.where_label,
1318
+ how: m.how,
1319
+ why: m.why,
1320
+ memo: m.memo,
1321
+ source: m.source,
1322
+ expiresAt: m.expires_at,
1323
+ createdAt: m.created_at,
1324
+ updatedAt: m.updated_at,
1325
+ };
1326
+ if (m.where_lat != null && m.where_lng != null) {
1327
+ out.mapUrls = makeMapUrls(m.where_lat, m.where_lng, m.where_label || m.what, '');
1328
+ }
1329
+ return out;
1330
+ });
1331
+ sendJson(res, 200, { memos: items });
1332
+ }
1333
+ catch (err) {
1334
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1335
+ }
1336
+ }
1337
+ async function handleDeleteMemo(_req, res, id) {
1338
+ if (!Number.isFinite(id) || id <= 0) {
1339
+ sendJson(res, 400, { error: 'invalid id' });
1340
+ return;
1341
+ }
1342
+ try {
1343
+ const { deleteMemo } = await import('../core/memos.js');
1344
+ const ok = deleteMemo(id);
1345
+ if (!ok) {
1346
+ sendJson(res, 404, { error: `memo #${id} not found` });
1347
+ return;
1348
+ }
1349
+ sendJson(res, 200, { ok: true, id });
1350
+ }
1351
+ catch (err) {
1352
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1353
+ }
1354
+ }
1355
+ // ─── env file (SMTP + Baidu AK + …) ────────────────────────────────────
1356
+ const ENV_EDITABLE_KEYS = [
1357
+ 'IMHUB_SMTP_HOST', 'IMHUB_SMTP_PORT', 'IMHUB_SMTP_USER', 'IMHUB_SMTP_PASS',
1358
+ 'IMHUB_SMTP_FROM', 'IMHUB_SMTP_SECURE',
1359
+ 'IMHUB_BAIDU_MAP_AK',
1360
+ 'IMHUB_LOC_BASE_URL', 'IMHUB_TZ_OFFSET_HOURS',
1361
+ ];
1362
+ const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK']);
1363
+ function maskSecret(v) {
1364
+ if (!v)
1365
+ return '';
1366
+ if (v.length <= 8)
1367
+ return '*'.repeat(v.length);
1368
+ return v.slice(0, 4) + '*'.repeat(Math.max(0, v.length - 8)) + v.slice(-4);
1369
+ }
1370
+ async function handleGetEnv(_req, res, url) {
1371
+ try {
1372
+ const { readEnvFile } = await import('../cli-ui/env-file.js');
1373
+ const env = readEnvFile();
1374
+ const reveal = url.searchParams.get('reveal') === '1';
1375
+ const out = {};
1376
+ for (const key of ENV_EDITABLE_KEYS) {
1377
+ const v = env[key];
1378
+ if (v === undefined)
1379
+ continue;
1380
+ out[key] = reveal ? v : (SECRET_KEYS.has(key) ? maskSecret(v) : v);
1381
+ }
1382
+ sendJson(res, 200, { env: out, secretKeys: Array.from(SECRET_KEYS) });
1383
+ }
1384
+ catch (err) {
1385
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1386
+ }
1387
+ }
1388
+ async function handlePutEnv(req, res) {
1389
+ try {
1390
+ const body = await readBody(req, res);
1391
+ const parsed = JSON.parse(body || '{}');
1392
+ const updates = parsed.updates;
1393
+ if (!updates || typeof updates !== 'object') {
1394
+ sendJson(res, 400, { error: 'updates object required' });
1395
+ return;
1396
+ }
1397
+ // Filter to whitelist — never let arbitrary keys through.
1398
+ const safe = {};
1399
+ for (const [k, v] of Object.entries(updates)) {
1400
+ if (!ENV_EDITABLE_KEYS.includes(k))
1401
+ continue;
1402
+ if (v === null || typeof v === 'string')
1403
+ safe[k] = v;
1404
+ }
1405
+ if (Object.keys(safe).length === 0) {
1406
+ sendJson(res, 400, { error: 'no editable keys in updates' });
1407
+ return;
1408
+ }
1409
+ const { updateEnvFile } = await import('../cli-ui/env-file.js');
1410
+ updateEnvFile(safe);
1411
+ sendJson(res, 200, { ok: true, updated: Object.keys(safe) });
1412
+ }
1413
+ catch (err) {
1414
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1415
+ }
1416
+ }
1417
+ async function handleListBgjobs(_req, res, url) {
1418
+ try {
1419
+ const { resolveRoots, listJobsForRoot, listAllJobs } = await import('../core/bgjob-reader.js');
1420
+ const rootId = url.searchParams.get('root');
1421
+ if (rootId) {
1422
+ // Single-root view — used by the dashboard's root selector.
1423
+ const root = resolveRoots().find((r) => r.id === rootId);
1424
+ if (!root) {
1425
+ sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
1426
+ return;
1427
+ }
1428
+ const jobs = await listJobsForRoot(root);
1429
+ sendJson(res, 200, { roots: [{ id: root.id, label: root.label, path: root.path }], jobs });
1430
+ return;
1431
+ }
1432
+ // No root specified: return all roots' metadata + jobs grouped by root.
1433
+ const all = await listAllJobs();
1434
+ sendJson(res, 200, {
1435
+ roots: all.map(({ root }) => ({ id: root.id, label: root.label, path: root.path })),
1436
+ groups: all.map(({ root, jobs }) => ({ rootId: root.id, jobs })),
1437
+ });
1438
+ }
1439
+ catch (err) {
1440
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1441
+ }
1442
+ }
1443
+ async function handleGetBgjob(_req, res, id, url) {
1444
+ try {
1445
+ const { findRoot, getJobDetail, resolveRoots, listJobsForRoot } = await import('../core/bgjob-reader.js');
1446
+ const tail = Math.min(Math.max(parseInt(url.searchParams.get('tail') || '200', 10) || 200, 1), 5000);
1447
+ const rootId = url.searchParams.get('root');
1448
+ if (rootId) {
1449
+ const root = findRoot(rootId);
1450
+ if (!root) {
1451
+ sendJson(res, 404, { error: `bgjob root "${rootId}" not configured` });
1452
+ return;
1453
+ }
1454
+ const job = await getJobDetail(root, id, tail);
1455
+ if (!job) {
1456
+ sendJson(res, 404, { error: 'Job not found' });
1457
+ return;
1458
+ }
1459
+ sendJson(res, 200, { job });
1460
+ return;
1461
+ }
1462
+ // No root: try every configured root, return first hit.
1463
+ for (const root of resolveRoots()) {
1464
+ const summaries = await listJobsForRoot(root);
1465
+ if (!summaries.some((s) => s.id === id))
1466
+ continue;
1467
+ const job = await getJobDetail(root, id, tail);
1468
+ if (job) {
1469
+ sendJson(res, 200, { job });
1470
+ return;
1471
+ }
1472
+ }
1473
+ sendJson(res, 404, { error: 'Job not found' });
1474
+ }
1475
+ catch (err) {
1476
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1477
+ }
1478
+ }
1479
+ async function handleListSubtasks(_req, res, url) {
1480
+ try {
1481
+ const { sessionManager } = await import('../core/session.js');
1482
+ const agent = url.searchParams.get('agent') || undefined;
1483
+ const subtasks = await sessionManager.listAllSubtasks(agent ? { agent } : {});
1484
+ sendJson(res, 200, { subtasks });
1485
+ }
1486
+ catch (err) {
1487
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1488
+ }
1489
+ }
1490
+ async function handleAudit(_req, res, url) {
1491
+ try {
1492
+ const { queryInvocations, getStats } = await import('../core/audit-log.js');
1493
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 1000);
1494
+ const days = parseInt(url.searchParams.get('days') || '7', 10) || 7;
1495
+ const agent = url.searchParams.get('agent') || undefined;
1496
+ const platform = url.searchParams.get('platform') || undefined;
1497
+ const userId = url.searchParams.get('user') || undefined;
1498
+ const intent = url.searchParams.get('intent') || undefined;
1499
+ const rows = queryInvocations({ limit, days, agent, platform, userId, intent });
1500
+ const stats = getStats();
1501
+ sendJson(res, 200, { invocations: rows, stats });
1502
+ }
1503
+ catch (err) {
1504
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1505
+ }
1506
+ }
1507
+ /**
1508
+ * Per-agent operational health snapshot. Drives the Health tab in /tasks.
1509
+ *
1510
+ * Combines three independent live data sources:
1511
+ * - circuit breaker phase / cooldown remaining (core/circuit-breaker.ts)
1512
+ * - rate-limiter remaining tokens & config (core/rate-limiter.ts agentLimiter)
1513
+ * - latency p50 / p95 / p99 + invocation totals (core/metrics.ts snapshot)
1514
+ *
1515
+ * No persistence — pure read of in-memory state. Cheap to call (<1 ms for
1516
+ * a typical agent fleet) so the page is happy to poll on a 5 s tick.
1517
+ */
1518
+ async function handleAgentHealth(_req, res) {
1519
+ try {
1520
+ const { circuitBreaker } = await import('../core/circuit-breaker.js');
1521
+ const { agentLimiter } = await import('../core/rate-limiter.js');
1522
+ const { snapshot } = await import('../core/metrics.js');
1523
+ const snap = snapshot();
1524
+ const now = Date.now();
1525
+ const agents = registry.listAgents().map((name) => {
1526
+ const breaker = circuitBreaker.getStatus(name);
1527
+ const cooldownRemainingMs = breaker.openedAt && breaker.phase !== 'closed'
1528
+ ? Math.max(0, breaker.cooldownMs - (now - breaker.openedAt))
1529
+ : 0;
1530
+ const rate = agentLimiter.status(name);
1531
+ const m = snap.agents.find((a) => a.agent === name);
1532
+ return {
1533
+ agent: name,
1534
+ breaker: {
1535
+ phase: breaker.phase,
1536
+ failures: breaker.failures,
1537
+ cooldownMs: breaker.cooldownMs,
1538
+ cooldownRemainingMs,
1539
+ },
1540
+ rate: {
1541
+ remaining: rate.remaining,
1542
+ rate: rate.rate,
1543
+ intervalSec: rate.intervalSec,
1544
+ },
1545
+ invocations: m
1546
+ ? {
1547
+ total: m.total,
1548
+ success: m.success,
1549
+ failure: m.failure,
1550
+ successRate: m.successRate,
1551
+ costSum: m.costSum,
1552
+ sampleCount: m.sampleCount,
1553
+ p50Ms: m.p50Ms,
1554
+ p95Ms: m.p95Ms,
1555
+ p99Ms: m.p99Ms,
1556
+ }
1557
+ : null,
1558
+ };
1559
+ });
1560
+ sendJson(res, 200, { agents, uptimeSec: snap.uptimeSec });
1561
+ }
1562
+ catch (err) {
1563
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1564
+ }
1565
+ }
1566
+ /**
1567
+ * List every currently-pending HITL approval across all sessions /
1568
+ * platforms. Used by the global Approvals tab in /tasks so the operator
1569
+ * can see at a glance whether something is waiting on a y/n that nobody
1570
+ * is around to give.
1571
+ */
1572
+ async function handleListApprovals(_req, res) {
1573
+ try {
1574
+ const { approvalBus } = await import('../core/approval-bus.js');
1575
+ const pending = approvalBus.listPending();
1576
+ const metrics = approvalBus.getMetrics();
1577
+ sendJson(res, 200, { pending, metrics });
1578
+ }
1579
+ catch (err) {
1580
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1581
+ }
1582
+ }
1583
+ /**
1584
+ * Resolve an approval by reqId (admin / dashboard path). Body:
1585
+ * { behavior: 'allow' | 'deny', autoAllowFurther?: boolean, message?: string }
1586
+ *
1587
+ * Uses resolvePendingByReqId for precise targeting — avoids the FIFO head
1588
+ * ambiguity when multiple approvals are queued on the same thread.
1589
+ */
1590
+ async function handleResolveApproval(req, res, reqId) {
1591
+ try {
1592
+ const body = await readBody(req, res);
1593
+ let parsed;
1594
+ try {
1595
+ parsed = JSON.parse(body);
1596
+ }
1597
+ catch {
1598
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
1599
+ return;
1600
+ }
1601
+ if (parsed.behavior !== 'allow' && parsed.behavior !== 'deny') {
1602
+ sendJson(res, 400, { ok: false, error: 'behavior must be "allow" or "deny"' });
1603
+ return;
1604
+ }
1605
+ const { approvalBus } = await import('../core/approval-bus.js');
1606
+ const decision = parsed.behavior === 'allow'
1607
+ ? { behavior: 'allow', autoAllowFurther: parsed.autoAllowFurther === true }
1608
+ : { behavior: 'deny', message: parsed.message || 'denied via dashboard' };
1609
+ const resolved = approvalBus.resolvePendingByReqId(reqId, decision);
1610
+ if (!resolved) {
1611
+ sendJson(res, 404, { ok: false, error: 'Approval not pending (may have already resolved or timed out)' });
1612
+ return;
1613
+ }
1614
+ sendJson(res, 200, { ok: true, resolved });
1615
+ }
1616
+ catch (err) {
1617
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
1618
+ }
1619
+ }
1620
+ /** Hard cap on file content we'll ship over the wire — avoids OOM and keeps
1621
+ * the browser responsive. Matches the soft limit our log-tail endpoints use. */
1622
+ const WORKSPACE_FILE_MAX_BYTES = 1 * 1024 * 1024;
1623
+ /** Bytes scanned for a binary heuristic. Null byte in this window → binary. */
1624
+ const BINARY_PROBE_BYTES = 8 * 1024;
1625
+ /**
1626
+ * Resolve a user-supplied path under a workspace base and verify that the
1627
+ * *real* filesystem path (after symlink resolution) still lives under `base`.
1628
+ * Returns the validated real path, or null + sends an error response.
1629
+ */
1630
+ async function validateWorkspacePath(base, userPath, res) {
1631
+ const target = resolvePath(base, userPath);
1632
+ if (target !== base && !target.startsWith(base + pathSep)) {
1633
+ sendJson(res, 400, { error: 'Path escapes workspace root' });
1634
+ return null;
1635
+ }
1636
+ let real;
1637
+ try {
1638
+ real = await realpath(target);
1639
+ }
1640
+ catch (err) {
1641
+ const e = err;
1642
+ if (e.code === 'ENOENT') {
1643
+ sendJson(res, 404, { error: 'Not found', path: relativePath(base, target) });
1644
+ return null;
1645
+ }
1646
+ throw err;
1647
+ }
1648
+ const realBase = await realpath(base);
1649
+ if (real !== realBase && !real.startsWith(realBase + pathSep)) {
1650
+ sendJson(res, 403, { error: 'Path resolves outside workspace root (symlink escape)' });
1651
+ return null;
1652
+ }
1653
+ return real;
1654
+ }
1655
+ /**
1656
+ * Read-only view of `~/.im-hub-workspaces/<agent>/`. The Files tab in /tasks
1657
+ * uses it to inspect what an IM-context agent is reading and writing into its
1658
+ * pinned workspace (CLAUDE.md, AGENTS.md, scratch notes, etc.).
1659
+ *
1660
+ * Query params:
1661
+ * agent — required, MUST match a registered agent name. We reject anything
1662
+ * else to keep `agent` from sneaking traversal segments past the
1663
+ * join (e.g. `?agent=../../etc`).
1664
+ * path — optional relative path under the agent's workspace. Defaults to ''.
1665
+ *
1666
+ * Response shape:
1667
+ * { type:'dir', entries:[{name,isDir,size,mtime}] }
1668
+ * { type:'file', content, size, encoding:'utf-8'|'base64', truncated }
1669
+ *
1670
+ * Path-traversal defense: after `resolvePath(base, userPath)` we verify the
1671
+ * result is exactly `base` or starts with `base + sep`. A `..`-laden path
1672
+ * collapses outside the base and gets rejected before any read.
1673
+ *
1674
+ * Edits / writes intentionally not exposed; ops use plain ssh.
1675
+ */
1676
+ async function handleWorkspaceFiles(_req, res, url) {
1677
+ try {
1678
+ const agent = url.searchParams.get('agent') || '';
1679
+ const userPath = url.searchParams.get('path') || '';
1680
+ if (!agent) {
1681
+ sendJson(res, 400, { error: 'Missing required ?agent=' });
1682
+ return;
1683
+ }
1684
+ // Whitelist agent against the registry. Even an empty registry won't
1685
+ // expose anything because a non-registered name fails this check.
1686
+ const known = new Set(registry.listAgents());
1687
+ if (!known.has(agent)) {
1688
+ sendJson(res, 404, { error: `Unknown agent "${agent}"` });
1689
+ return;
1690
+ }
1691
+ const { defaultAgentCwd } = await import('../core/agent-cwd.js');
1692
+ const base = resolvePath(defaultAgentCwd(agent));
1693
+ const target = await validateWorkspacePath(base, userPath, res);
1694
+ if (!target)
1695
+ return;
1696
+ const st = await stat(target);
1697
+ if (st.isDirectory()) {
1698
+ const realBase = await realpath(base);
1699
+ const names = await readdir(target);
1700
+ const entries = await Promise.all(names.map(async (name) => {
1701
+ try {
1702
+ const childPath = join(target, name);
1703
+ let childReal;
1704
+ try {
1705
+ childReal = await realpath(childPath);
1706
+ }
1707
+ catch {
1708
+ return { name, isDir: false, size: null, mtime: null, broken: true };
1709
+ }
1710
+ if (childReal !== realBase && !childReal.startsWith(realBase + pathSep)) {
1711
+ return { name, isDir: false, size: null, mtime: null, symlink_escape: true };
1712
+ }
1713
+ const sub = await stat(childPath);
1714
+ return {
1715
+ name,
1716
+ isDir: sub.isDirectory(),
1717
+ size: sub.isDirectory() ? null : sub.size,
1718
+ mtime: sub.mtime.toISOString(),
1719
+ };
1720
+ }
1721
+ catch {
1722
+ return { name, isDir: false, size: null, mtime: null, broken: true };
1723
+ }
1724
+ }));
1725
+ entries.sort((a, b) => {
1726
+ if (a.isDir !== b.isDir)
1727
+ return a.isDir ? -1 : 1;
1728
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
1729
+ });
1730
+ sendJson(res, 200, {
1731
+ type: 'dir',
1732
+ agent,
1733
+ path: relativePath(base, target),
1734
+ base,
1735
+ entries,
1736
+ });
1737
+ return;
1738
+ }
1739
+ if (!st.isFile()) {
1740
+ sendJson(res, 400, { error: 'Not a regular file' });
1741
+ return;
1742
+ }
1743
+ const truncated = st.size > WORKSPACE_FILE_MAX_BYTES;
1744
+ let buf;
1745
+ if (truncated) {
1746
+ const fd = await open(target, 'r');
1747
+ try {
1748
+ buf = Buffer.alloc(WORKSPACE_FILE_MAX_BYTES);
1749
+ const { bytesRead } = await fd.read(buf, 0, WORKSPACE_FILE_MAX_BYTES, 0);
1750
+ buf = buf.subarray(0, bytesRead);
1751
+ }
1752
+ finally {
1753
+ await fd.close();
1754
+ }
1755
+ }
1756
+ else {
1757
+ buf = await readFile(target);
1758
+ }
1759
+ const probe = buf.subarray(0, Math.min(buf.length, BINARY_PROBE_BYTES));
1760
+ const isBinary = probe.includes(0);
1761
+ sendJson(res, 200, {
1762
+ type: 'file',
1763
+ agent,
1764
+ path: relativePath(base, target),
1765
+ size: st.size,
1766
+ mtime: st.mtime.toISOString(),
1767
+ encoding: isBinary ? 'base64' : 'utf-8',
1768
+ content: isBinary ? buf.toString('base64') : buf.toString('utf-8'),
1769
+ truncated,
1770
+ });
1771
+ }
1772
+ catch (err) {
1773
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Inline edit of a workspace file. UI use-case: annotate the agent's
1778
+ * CLAUDE.md / AGENTS.md / scratch notes from the dashboard without
1779
+ * shelling into the host.
1780
+ *
1781
+ * Body: { content: string } — UTF-8 text only (binary writes refused).
1782
+ * Response: { ok: true, size, mtime } on success.
1783
+ *
1784
+ * Safety:
1785
+ * - Same agent + path traversal guards as the GET handler.
1786
+ * - 1 MiB hard cap on `content` (matches the read cap so a roundtrip
1787
+ * edit can't grow a file beyond what the read can show).
1788
+ * - Atomic write: stage to `<target>.tmp.<rand>` then `rename` so a
1789
+ * crash mid-write can't leave a half-truncated file. The .tmp file
1790
+ * is unlinked on any error path.
1791
+ * - Refuses to overwrite a directory; refuses to create a parent dir
1792
+ * that doesn't exist (no implicit mkdir-p — keeps surprises out).
1793
+ */
1794
+ async function handleWorkspaceFileWrite(req, res, url) {
1795
+ try {
1796
+ const agent = url.searchParams.get('agent') || '';
1797
+ const userPath = url.searchParams.get('path') || '';
1798
+ if (!agent) {
1799
+ sendJson(res, 400, { error: 'Missing required ?agent=' });
1800
+ return;
1801
+ }
1802
+ if (!userPath) {
1803
+ sendJson(res, 400, { error: 'Missing required ?path=' });
1804
+ return;
1805
+ }
1806
+ const known = new Set(registry.listAgents());
1807
+ if (!known.has(agent)) {
1808
+ sendJson(res, 404, { error: `Unknown agent "${agent}"` });
1809
+ return;
1810
+ }
1811
+ const { defaultAgentCwd } = await import('../core/agent-cwd.js');
1812
+ const base = resolvePath(defaultAgentCwd(agent));
1813
+ // For writes, the file may not yet exist so realpath would fail.
1814
+ // Validate the parent directory via realpath and verify the logical
1815
+ // path stays under base as a prefix check.
1816
+ const logicalTarget = resolvePath(base, userPath);
1817
+ if (logicalTarget !== base && !logicalTarget.startsWith(base + pathSep)) {
1818
+ sendJson(res, 400, { error: 'Path escapes workspace root' });
1819
+ return;
1820
+ }
1821
+ if (logicalTarget === base) {
1822
+ sendJson(res, 400, { error: 'Cannot overwrite workspace root' });
1823
+ return;
1824
+ }
1825
+ // Verify the parent directory resolves inside the workspace.
1826
+ const parentDir = dirname(logicalTarget);
1827
+ try {
1828
+ const realParent = await realpath(parentDir);
1829
+ const realBase = await realpath(base);
1830
+ if (realParent !== realBase && !realParent.startsWith(realBase + pathSep)) {
1831
+ sendJson(res, 403, { error: 'Parent path resolves outside workspace root (symlink escape)' });
1832
+ return;
1833
+ }
1834
+ }
1835
+ catch (err) {
1836
+ const e = err;
1837
+ if (e.code === 'ENOENT') {
1838
+ sendJson(res, 400, { error: 'Parent directory does not exist' });
1839
+ return;
1840
+ }
1841
+ throw err;
1842
+ }
1843
+ // If the file itself already exists, ensure its real path is inside too.
1844
+ try {
1845
+ const realTarget = await realpath(logicalTarget);
1846
+ const realBase = await realpath(base);
1847
+ if (realTarget !== realBase && !realTarget.startsWith(realBase + pathSep)) {
1848
+ sendJson(res, 403, { error: 'Path resolves outside workspace root (symlink escape)' });
1849
+ return;
1850
+ }
1851
+ }
1852
+ catch {
1853
+ // ENOENT is fine — the file doesn't exist yet
1854
+ }
1855
+ const target = logicalTarget;
1856
+ const body = await readBody(req, res);
1857
+ let parsed;
1858
+ try {
1859
+ parsed = JSON.parse(body);
1860
+ }
1861
+ catch {
1862
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1863
+ return;
1864
+ }
1865
+ if (typeof parsed.content !== 'string') {
1866
+ sendJson(res, 400, { error: 'content must be a string' });
1867
+ return;
1868
+ }
1869
+ const content = parsed.content;
1870
+ // Encode early so the size check is on bytes, not chars (a single
1871
+ // CJK char is 3 bytes UTF-8 — char-count would lie about file size).
1872
+ const buf = Buffer.from(content, 'utf-8');
1873
+ if (buf.length > WORKSPACE_FILE_MAX_BYTES) {
1874
+ sendJson(res, 413, { error: 'Content exceeds 1 MiB cap' });
1875
+ return;
1876
+ }
1877
+ // Reject content that contains a NUL byte. UTF-8 text never has one;
1878
+ // accidental binary upload here would corrupt the editor on next read.
1879
+ if (buf.includes(0)) {
1880
+ sendJson(res, 400, { error: 'NUL byte in content — only UTF-8 text accepted' });
1881
+ return;
1882
+ }
1883
+ // Existing-target guards: must not be a directory; parent dir must
1884
+ // exist (no implicit mkdir-p — too easy to typo a deep path and
1885
+ // create a hidden mess).
1886
+ try {
1887
+ const st = await stat(target);
1888
+ if (st.isDirectory()) {
1889
+ sendJson(res, 400, { error: 'Target is a directory' });
1890
+ return;
1891
+ }
1892
+ }
1893
+ catch (err) {
1894
+ const e = err;
1895
+ if (e.code !== 'ENOENT')
1896
+ throw err;
1897
+ // Doesn't exist yet — that's fine for new-file writes, but the
1898
+ // parent directory must be present.
1899
+ const parent = dirname(target);
1900
+ try {
1901
+ const ps = await stat(parent);
1902
+ if (!ps.isDirectory()) {
1903
+ sendJson(res, 400, { error: 'Parent path is not a directory' });
1904
+ return;
1905
+ }
1906
+ }
1907
+ catch {
1908
+ sendJson(res, 400, { error: 'Parent directory does not exist' });
1909
+ return;
1910
+ }
1911
+ }
1912
+ // Atomic write. crypto.randomBytes is the cheapest unique suffix and
1913
+ // already imported up top.
1914
+ const tmp = `${target}.tmp.${randomBytes(6).toString('hex')}`;
1915
+ try {
1916
+ await writeFile(tmp, buf, { mode: 0o600 });
1917
+ await rename(tmp, target);
1918
+ }
1919
+ catch (err) {
1920
+ try {
1921
+ await unlink(tmp);
1922
+ }
1923
+ catch { /* tmp may not have been created */ }
1924
+ throw err;
1925
+ }
1926
+ const finalSt = await stat(target);
1927
+ sendJson(res, 200, {
1928
+ ok: true,
1929
+ agent,
1930
+ path: relativePath(base, target),
1931
+ size: finalSt.size,
1932
+ mtime: finalSt.mtime.toISOString(),
1933
+ });
1934
+ }
1935
+ catch (err) {
1936
+ const e = err;
1937
+ if (e?.handled)
1938
+ return;
1939
+ if (!res.headersSent)
1940
+ sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
1941
+ }
1942
+ }
1943
+ /**
1944
+ * Run cancel/run across an array of job ids in one request. Saves N round
1945
+ * trips when the user multi-selects a long list in the Jobs tab.
1946
+ *
1947
+ * Body: { ids: number[] }
1948
+ * Response: { results: Array<{ id, ok, error?, traceId? }> }
1949
+ *
1950
+ * Per-id failures don't fail the whole request — each entry carries its own
1951
+ * status so the UI can mark partial success.
1952
+ */
1953
+ async function handleBatchJob(req, res, action, defaultAgent) {
1954
+ try {
1955
+ const body = await readBody(req, res);
1956
+ let parsed;
1957
+ try {
1958
+ parsed = JSON.parse(body);
1959
+ }
1960
+ catch {
1961
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1962
+ return;
1963
+ }
1964
+ if (!Array.isArray(parsed.ids) || parsed.ids.length === 0) {
1965
+ sendJson(res, 400, { error: 'ids must be a non-empty array of numbers' });
1966
+ return;
1967
+ }
1968
+ // Cap so a runaway client can't queue thousands of spawns at once. Same
1969
+ // ceiling we use elsewhere for batched ops.
1970
+ if (parsed.ids.length > 100) {
1971
+ sendJson(res, 400, { error: 'Maximum 100 ids per batch' });
1972
+ return;
1973
+ }
1974
+ const ids = parsed.ids
1975
+ .map((x) => (typeof x === 'number' ? x : parseInt(String(x), 10)))
1976
+ .filter((n) => Number.isFinite(n) && n > 0);
1977
+ if (ids.length === 0) {
1978
+ sendJson(res, 400, { error: 'No valid ids in array' });
1979
+ return;
1980
+ }
1981
+ const { getJob, cancelJob, runJob } = await import('../core/job-board.js');
1982
+ const { AgentBase } = await import('../core/agent-base.js');
1983
+ const results = await Promise.all(ids.map(async (id) => {
1984
+ try {
1985
+ if (action === 'cancel') {
1986
+ return { id, ok: cancelJob(id) };
1987
+ }
1988
+ const job = getJob(id);
1989
+ if (!job)
1990
+ return { id, ok: false, error: 'Job not found' };
1991
+ const agent = registry.findAgent(job.agent);
1992
+ if (!agent)
1993
+ return { id, ok: false, error: `Agent "${job.agent}" not registered` };
1994
+ const traceId = generateTraceId();
1995
+ const log = createLogger({ traceId, platform: 'web', component: 'job-run-batch' });
1996
+ // Same fire-and-forget pattern as handleRunJob — the dashboard
1997
+ // streams status from /events / /api/jobs.
1998
+ void runJob(id, async function* (j, _logger, signal) {
1999
+ if (agent instanceof AgentBase) {
2000
+ const text = await agent.spawnAndCollect(j.prompt, signal);
2001
+ if (text)
2002
+ yield text;
2003
+ }
2004
+ else {
2005
+ for await (const chunk of agent.sendPrompt(`web-job-${j.id}`, j.prompt, [])) {
2006
+ if (signal.aborted)
2007
+ break;
2008
+ yield chunk;
2009
+ }
2010
+ }
2011
+ }, log).catch(() => { });
2012
+ return { id, ok: true, traceId };
2013
+ }
2014
+ catch (err) {
2015
+ return { id, ok: false, error: err instanceof Error ? err.message : String(err) };
2016
+ }
2017
+ }));
2018
+ // defaultAgent isn't used directly — runJob reads job.agent — but we
2019
+ // accept it to keep the call-site symmetric with handleInvoke.
2020
+ void defaultAgent;
2021
+ sendJson(res, 200, { results });
2022
+ }
2023
+ catch (err) {
2024
+ const e = err;
2025
+ if (e?.handled)
2026
+ return;
2027
+ if (!res.headersSent)
2028
+ sendJson(res, e?.statusCode || 500, { error: e instanceof Error ? e.message : String(err) });
2029
+ }
2030
+ }
2031
+ /**
2032
+ * Server-Sent Events stream for real-time dashboard updates. Subscribes
2033
+ * to the in-process event-bus and forwards every event as an SSE frame.
2034
+ *
2035
+ * On connect we replay the last ~200 events from the bus's recent ring
2036
+ * so a freshly-opened tab doesn't have to wait for the next event to
2037
+ * have any context.
2038
+ *
2039
+ * Heartbeats every 25 s — Node's default keepalive isn't enough for some
2040
+ * proxies (nginx default is 60s idle close, browsers reconnect EventSource
2041
+ * automatically but we'd rather avoid the churn).
2042
+ *
2043
+ * Token-gated like every other /api endpoint via the upstream guard.
2044
+ */
2045
+ async function handleEventsSSE(req, res) {
2046
+ const { eventBus } = await import('../core/event-bus.js');
2047
+ res.writeHead(200, {
2048
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2049
+ 'Cache-Control': 'no-cache, no-transform',
2050
+ 'Connection': 'keep-alive',
2051
+ 'X-Accel-Buffering': 'no',
2052
+ });
2053
+ // Tell the client what we count as "now" so it can disambiguate replay
2054
+ // from live events.
2055
+ res.write(`event: hello\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`);
2056
+ // Replay recent buffer.
2057
+ for (const e of eventBus.getRecent()) {
2058
+ res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
2059
+ }
2060
+ const onEvent = (e) => {
2061
+ try {
2062
+ res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
2063
+ }
2064
+ catch {
2065
+ // Likely socket closed. The 'close' handler below will clean up;
2066
+ // swallow here so a downstream listener error doesn't propagate.
2067
+ }
2068
+ };
2069
+ eventBus.on('event', onEvent);
2070
+ // Periodic keepalive comment so proxies don't close idle connections.
2071
+ // SSE comments start with ':' and are ignored by EventSource clients.
2072
+ const heartbeat = setInterval(() => {
2073
+ try {
2074
+ res.write(': keepalive\n\n');
2075
+ }
2076
+ catch { /* socket closed */ }
2077
+ }, 25_000);
2078
+ if (typeof heartbeat === 'object' && heartbeat && 'unref' in heartbeat) {
2079
+ heartbeat.unref();
2080
+ }
2081
+ const cleanup = () => {
2082
+ clearInterval(heartbeat);
2083
+ eventBus.off('event', onEvent);
2084
+ };
2085
+ req.on('close', cleanup);
2086
+ req.on('error', cleanup);
2087
+ }
2088
+ async function handleAcpDiscover(req, res) {
2089
+ try {
2090
+ const body = await readBody(req, res);
2091
+ const { baseUrl, register } = JSON.parse(body);
2092
+ if (!baseUrl) {
2093
+ sendJson(res, 400, { error: 'Missing baseUrl' });
2094
+ return;
2095
+ }
2096
+ const { discoverAgents } = await import('../plugins/agents/acp/discovery.js');
2097
+ const result = await discoverAgents(baseUrl);
2098
+ if (register) {
2099
+ await registry.loadACPAgents(result.agents);
2100
+ }
2101
+ sendJson(res, 200, { ok: true, baseUrl: result.baseUrl, agents: result.agents });
2102
+ }
2103
+ catch (err) {
2104
+ const e = err;
2105
+ if (e?.handled)
2106
+ return;
2107
+ const status = e?.statusCode || 500;
2108
+ const msg = e instanceof Error ? e.message : String(err);
2109
+ if (!res.headersSent)
2110
+ sendJson(res, status, { ok: false, error: msg });
2111
+ }
2112
+ }
2113
+ async function handleAcpTest(req, res) {
2114
+ try {
2115
+ const body = await readBody(req, res);
2116
+ // M11: bare JSON.parse threw a SyntaxError that bubbled out into the
2117
+ // outer catch and surfaced as a "500-ish 400" with the parser's raw
2118
+ // message. Validate explicitly so malformed bodies get a clean 400.
2119
+ let parsed;
2120
+ try {
2121
+ parsed = JSON.parse(body);
2122
+ }
2123
+ catch {
2124
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
2125
+ return;
2126
+ }
2127
+ const { endpoint, auth } = parsed;
2128
+ if (!endpoint || typeof endpoint !== 'string') {
2129
+ sendJson(res, 400, { ok: false, error: 'Missing or invalid "endpoint"' });
2130
+ return;
2131
+ }
2132
+ // Dynamic import to avoid circular deps
2133
+ const { ACPClient } = await import('../plugins/agents/acp/acp-client.js');
2134
+ const client = new ACPClient({ name: 'test', endpoint, auth: auth });
2135
+ const manifest = await client.fetchManifest();
2136
+ sendJson(res, 200, {
2137
+ ok: true,
2138
+ name: manifest.name,
2139
+ description: manifest.description,
2140
+ });
2141
+ }
2142
+ catch (err) {
2143
+ const msg = err instanceof Error ? err.message : String(err);
2144
+ sendJson(res, 400, { ok: false, error: msg });
2145
+ }
2146
+ }
2147
+ // ============================================
2148
+ // Helpers
2149
+ // ============================================
2150
+ async function getAgentStatuses() {
2151
+ const agents = registry.listAgents();
2152
+ const status = {};
2153
+ await Promise.all(agents.map(async (name) => {
2154
+ const agent = registry.findAgent(name);
2155
+ if (agent) {
2156
+ try {
2157
+ status[name] = await agent.isAvailable();
2158
+ }
2159
+ catch {
2160
+ status[name] = false;
2161
+ }
2162
+ }
2163
+ }));
2164
+ return status;
2165
+ }
2166
+ function mask(value) {
2167
+ if (!value)
2168
+ return '';
2169
+ if (value.length <= 4)
2170
+ return '****';
2171
+ return `${value.slice(0, 2)}****${value.slice(-2)}`;
2172
+ }
2173
+ /** Hard cap on inbound JSON bodies for the Web REST API. */
2174
+ const MAX_API_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
2175
+ function readBody(req, res) {
2176
+ return new Promise((resolve, reject) => {
2177
+ const chunks = [];
2178
+ let total = 0;
2179
+ let aborted = false;
2180
+ req.on('data', (chunk) => {
2181
+ if (aborted)
2182
+ return;
2183
+ total += chunk.length;
2184
+ if (total > MAX_API_BODY_BYTES) {
2185
+ aborted = true;
2186
+ if (res && !res.headersSent) {
2187
+ sendJson(res, 413, { error: 'Request body too large' });
2188
+ }
2189
+ const err = new Error('Request body too large');
2190
+ err.statusCode = 413;
2191
+ err.handled = !!res;
2192
+ reject(err);
2193
+ return;
2194
+ }
2195
+ chunks.push(chunk);
2196
+ });
2197
+ req.on('end', () => {
2198
+ if (aborted)
2199
+ return;
2200
+ resolve(Buffer.concat(chunks).toString('utf-8'));
2201
+ });
2202
+ req.on('error', (err) => {
2203
+ if (!aborted)
2204
+ reject(err);
2205
+ });
2206
+ });
2207
+ }
2208
+ function sendJson(res, status, data) {
2209
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2210
+ res.end(JSON.stringify(data));
2211
+ }
2212
+ // ============================================
2213
+ // WebSocket chat handlers
2214
+ // ============================================
2215
+ /**
2216
+ * Handle a message from a web client
2217
+ */
2218
+ async function handleClientMessage(client, msg, defaultAgent) {
2219
+ const { ws, id: clientId } = client;
2220
+ switch (msg.type) {
2221
+ case 'message': {
2222
+ if (!msg.text?.trim())
2223
+ return;
2224
+ const text = msg.text.trim();
2225
+ const traceId = generateTraceId();
2226
+ const logger = createLogger({ traceId, platform: 'web', component: 'web' });
2227
+ if (msg.agent && msg.agent !== client.agent) {
2228
+ client.agent = msg.agent;
2229
+ }
2230
+ const parsed = parseMessage(text);
2231
+ try {
2232
+ const routeCtx = {
2233
+ threadId: clientId,
2234
+ channelId: 'web',
2235
+ platform: 'web',
2236
+ defaultAgent: client.agent,
2237
+ traceId,
2238
+ logger,
2239
+ userId: `web:${clientId}`,
2240
+ };
2241
+ logger.info({ event: 'message.received', text: text.substring(0, 120) });
2242
+ const result = await routeMessage(parsed, routeCtx);
2243
+ // String response (built-in commands, errors)
2244
+ if (typeof result === 'string') {
2245
+ sendToClient(ws, { type: 'done', text: result });
2246
+ return;
2247
+ }
2248
+ // Streaming response (agent responses)
2249
+ let fullText = '';
2250
+ for await (const chunk of result) {
2251
+ fullText += chunk;
2252
+ // L1: defer when the per-socket send buffer is full. Without
2253
+ // this, a slow client lets the chunk producer keep allocating
2254
+ // frames into the kernel + ws-internal queue, which can grow
2255
+ // to GBs for a long agent response.
2256
+ await awaitWsDrain(ws);
2257
+ if (ws.readyState !== ws.OPEN)
2258
+ break;
2259
+ sendToClient(ws, { type: 'chunk', text: chunk });
2260
+ }
2261
+ sendToClient(ws, { type: 'done', text: fullText });
2262
+ }
2263
+ catch (err) {
2264
+ const errorMsg = err instanceof Error ? err.message : String(err);
2265
+ const stack = err instanceof Error ? err.stack : undefined;
2266
+ logger.error({ event: 'web.handle.error', err: errorMsg, stack }, 'Error handling client message');
2267
+ sendToClient(ws, { type: 'error', message: `Agent error: ${errorMsg}` });
2268
+ }
2269
+ break;
2270
+ }
2271
+ case 'switch-agent': {
2272
+ if (!msg.agent)
2273
+ return;
2274
+ const agent = registry.findAgent(msg.agent);
2275
+ if (!agent) {
2276
+ sendToClient(ws, { type: 'error', message: `Agent "${msg.agent}" not found` });
2277
+ return;
2278
+ }
2279
+ if (!(await isAgentAvailableCached(agent.name))) {
2280
+ sendToClient(ws, { type: 'error', message: `Agent "${agent.name}" is not available` });
2281
+ return;
2282
+ }
2283
+ client.agent = agent.name;
2284
+ await sessionManager.switchAgent('web', 'web', clientId, agent.name);
2285
+ sendToClient(ws, { type: 'agent-switched', agent: agent.name });
2286
+ break;
2287
+ }
2288
+ case 'get-agents': {
2289
+ const agents = registry.listAgents();
2290
+ sendToClient(ws, { type: 'agents', agents });
2291
+ break;
2292
+ }
2293
+ case 'get-history': {
2294
+ await sendSessionHistory(ws, clientId, defaultAgent);
2295
+ break;
2296
+ }
2297
+ }
2298
+ }
2299
+ /**
2300
+ * Send session history to a client
2301
+ */
2302
+ async function sendSessionHistory(ws, clientId, _defaultAgent) {
2303
+ const history = await sessionManager.getSessionWithHistory('web', 'web', clientId);
2304
+ if (history && history.messages.length > 0) {
2305
+ sendToClient(ws, {
2306
+ type: 'history',
2307
+ messages: history.messages,
2308
+ agent: history.session.agent,
2309
+ });
2310
+ }
2311
+ }
2312
+ /** L1: backpressure threshold for the WS send path. When `ws.bufferedAmount`
2313
+ * exceeds this, the streaming chunk loop awaits a tick instead of piling up
2314
+ * more frames. 4 MiB tolerates a few seconds of slow client without
2315
+ * unbounded memory growth — Node's WebSocket impl honors the kernel
2316
+ * send buffer behind this number. */
2317
+ const WS_BACKPRESSURE_HIGHWATER_BYTES = 4 * 1024 * 1024;
2318
+ /**
2319
+ * Send a JSON message to a WebSocket client
2320
+ */
2321
+ function sendToClient(ws, data) {
2322
+ if (ws.readyState === ws.OPEN) {
2323
+ ws.send(JSON.stringify(data));
2324
+ }
2325
+ }
2326
+ /**
2327
+ * Wait until `ws.bufferedAmount` drops below the highwater mark, or the
2328
+ * socket closes, or the timeout fires. Used by the streaming chunk loop to
2329
+ * stop piling up frames at slow clients.
2330
+ *
2331
+ * Polls every 50 ms — node's `ws` doesn't emit a `drain` event we can hook,
2332
+ * but the buffered amount drops monotonically once the kernel ACKs flush.
2333
+ * Bounded by IMHUB_WS_BACKPRESSURE_TIMEOUT_MS (default 5 s) so a frozen
2334
+ * client can't wedge the agent's chunk producer indefinitely.
2335
+ */
2336
+ async function awaitWsDrain(ws) {
2337
+ if (ws.bufferedAmount < WS_BACKPRESSURE_HIGHWATER_BYTES)
2338
+ return;
2339
+ const timeoutMs = (() => {
2340
+ const raw = process.env.IMHUB_WS_BACKPRESSURE_TIMEOUT_MS;
2341
+ if (raw) {
2342
+ const n = parseInt(raw, 10);
2343
+ if (Number.isFinite(n) && n > 0)
2344
+ return n;
2345
+ }
2346
+ return 5_000;
2347
+ })();
2348
+ const startedAt = Date.now();
2349
+ while (ws.readyState === ws.OPEN
2350
+ && ws.bufferedAmount >= WS_BACKPRESSURE_HIGHWATER_BYTES
2351
+ && Date.now() - startedAt < timeoutMs) {
2352
+ await new Promise((r) => setTimeout(r, 50));
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Serve a static file (no token injection needed)
2357
+ */
2358
+ function serveStatic(res, filePath, contentType) {
2359
+ if (!existsSync(filePath)) {
2360
+ res.writeHead(404);
2361
+ res.end('Not found');
2362
+ return;
2363
+ }
2364
+ const content = readFileSync(filePath);
2365
+ res.writeHead(200, { 'Content-Type': contentType });
2366
+ res.end(content);
2367
+ }
2368
+ /**
2369
+ * Serve HTML pages with security headers. Token is no longer injected —
2370
+ * the browser authenticates via httpOnly session cookie set at /login.
2371
+ */
2372
+ function servePageHtml(res, filePath) {
2373
+ if (!existsSync(filePath)) {
2374
+ res.writeHead(404);
2375
+ res.end('Not found');
2376
+ return;
2377
+ }
2378
+ const html = readFileSync(filePath, 'utf-8');
2379
+ res.writeHead(200, {
2380
+ 'Content-Type': 'text/html; charset=utf-8',
2381
+ 'Cache-Control': 'no-cache, must-revalidate',
2382
+ 'X-Frame-Options': 'DENY',
2383
+ 'X-Content-Type-Options': 'nosniff',
2384
+ 'Referrer-Policy': 'no-referrer',
2385
+ 'Content-Security-Policy': [
2386
+ "default-src 'self'",
2387
+ "connect-src 'self' ws: wss:",
2388
+ "script-src 'self' 'unsafe-inline'",
2389
+ "style-src 'self' 'unsafe-inline'",
2390
+ "img-src 'self' data:",
2391
+ "font-src 'self' data:",
2392
+ "frame-ancestors 'none'",
2393
+ "base-uri 'self'",
2394
+ "form-action 'self'",
2395
+ ].join('; '),
2396
+ });
2397
+ res.end(html);
2398
+ }
2399
+ //# sourceMappingURL=server.js.map