openryoko 0.9.4-ryoko.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (514) hide show
  1. package/dist/bin/jimmy.d.ts +3 -0
  2. package/dist/bin/jimmy.d.ts.map +1 -0
  3. package/dist/bin/jimmy.js +149 -0
  4. package/dist/bin/jimmy.js.map +1 -0
  5. package/dist/package.json +54 -0
  6. package/dist/src/cli/__tests__/migrate.test.d.ts +2 -0
  7. package/dist/src/cli/__tests__/migrate.test.d.ts.map +1 -0
  8. package/dist/src/cli/__tests__/migrate.test.js +79 -0
  9. package/dist/src/cli/__tests__/migrate.test.js.map +1 -0
  10. package/dist/src/cli/chrome-allow.d.ts +5 -0
  11. package/dist/src/cli/chrome-allow.d.ts.map +1 -0
  12. package/dist/src/cli/chrome-allow.js +250 -0
  13. package/dist/src/cli/chrome-allow.js.map +1 -0
  14. package/dist/src/cli/create.d.ts +2 -0
  15. package/dist/src/cli/create.d.ts.map +1 -0
  16. package/dist/src/cli/create.js +72 -0
  17. package/dist/src/cli/create.js.map +1 -0
  18. package/dist/src/cli/instances.d.ts +14 -0
  19. package/dist/src/cli/instances.d.ts.map +1 -0
  20. package/dist/src/cli/instances.js +43 -0
  21. package/dist/src/cli/instances.js.map +1 -0
  22. package/dist/src/cli/list.d.ts +2 -0
  23. package/dist/src/cli/list.d.ts.map +1 -0
  24. package/dist/src/cli/list.js +38 -0
  25. package/dist/src/cli/list.js.map +1 -0
  26. package/dist/src/cli/migrate.d.ts +5 -0
  27. package/dist/src/cli/migrate.d.ts.map +1 -0
  28. package/dist/src/cli/migrate.js +222 -0
  29. package/dist/src/cli/migrate.js.map +1 -0
  30. package/dist/src/cli/nuke.d.ts +2 -0
  31. package/dist/src/cli/nuke.d.ts.map +1 -0
  32. package/dist/src/cli/nuke.js +91 -0
  33. package/dist/src/cli/nuke.js.map +1 -0
  34. package/dist/src/cli/remove.d.ts +4 -0
  35. package/dist/src/cli/remove.d.ts.map +1 -0
  36. package/dist/src/cli/remove.js +47 -0
  37. package/dist/src/cli/remove.js.map +1 -0
  38. package/dist/src/cli/setup.d.ts +4 -0
  39. package/dist/src/cli/setup.d.ts.map +1 -0
  40. package/dist/src/cli/setup.js +491 -0
  41. package/dist/src/cli/setup.js.map +1 -0
  42. package/dist/src/cli/skills.d.ts +28 -0
  43. package/dist/src/cli/skills.d.ts.map +1 -0
  44. package/dist/src/cli/skills.js +284 -0
  45. package/dist/src/cli/skills.js.map +1 -0
  46. package/dist/src/cli/start.d.ts +5 -0
  47. package/dist/src/cli/start.d.ts.map +1 -0
  48. package/dist/src/cli/start.js +34 -0
  49. package/dist/src/cli/start.js.map +1 -0
  50. package/dist/src/cli/status.d.ts +2 -0
  51. package/dist/src/cli/status.d.ts.map +1 -0
  52. package/dist/src/cli/status.js +69 -0
  53. package/dist/src/cli/status.js.map +1 -0
  54. package/dist/src/cli/stop.d.ts +2 -0
  55. package/dist/src/cli/stop.d.ts.map +1 -0
  56. package/dist/src/cli/stop.js +11 -0
  57. package/dist/src/cli/stop.js.map +1 -0
  58. package/dist/src/connectors/cron/index.d.ts +21 -0
  59. package/dist/src/connectors/cron/index.d.ts.map +1 -0
  60. package/dist/src/connectors/cron/index.js +75 -0
  61. package/dist/src/connectors/cron/index.js.map +1 -0
  62. package/dist/src/connectors/discord/format.d.ts +3 -0
  63. package/dist/src/connectors/discord/format.d.ts.map +1 -0
  64. package/dist/src/connectors/discord/format.js +33 -0
  65. package/dist/src/connectors/discord/format.js.map +1 -0
  66. package/dist/src/connectors/discord/index.d.ts +47 -0
  67. package/dist/src/connectors/discord/index.d.ts.map +1 -0
  68. package/dist/src/connectors/discord/index.js +308 -0
  69. package/dist/src/connectors/discord/index.js.map +1 -0
  70. package/dist/src/connectors/discord/remote.d.ts +33 -0
  71. package/dist/src/connectors/discord/remote.d.ts.map +1 -0
  72. package/dist/src/connectors/discord/remote.js +89 -0
  73. package/dist/src/connectors/discord/remote.js.map +1 -0
  74. package/dist/src/connectors/discord/threads.d.ts +5 -0
  75. package/dist/src/connectors/discord/threads.d.ts.map +1 -0
  76. package/dist/src/connectors/discord/threads.js +22 -0
  77. package/dist/src/connectors/discord/threads.js.map +1 -0
  78. package/dist/src/connectors/slack/__tests__/active-threads.test.d.ts +2 -0
  79. package/dist/src/connectors/slack/__tests__/active-threads.test.d.ts.map +1 -0
  80. package/dist/src/connectors/slack/__tests__/active-threads.test.js +57 -0
  81. package/dist/src/connectors/slack/__tests__/active-threads.test.js.map +1 -0
  82. package/dist/src/connectors/slack/__tests__/format.test.d.ts +2 -0
  83. package/dist/src/connectors/slack/__tests__/format.test.d.ts.map +1 -0
  84. package/dist/src/connectors/slack/__tests__/format.test.js +115 -0
  85. package/dist/src/connectors/slack/__tests__/format.test.js.map +1 -0
  86. package/dist/src/connectors/slack/__tests__/speaker.test.d.ts +2 -0
  87. package/dist/src/connectors/slack/__tests__/speaker.test.d.ts.map +1 -0
  88. package/dist/src/connectors/slack/__tests__/speaker.test.js +59 -0
  89. package/dist/src/connectors/slack/__tests__/speaker.test.js.map +1 -0
  90. package/dist/src/connectors/slack/__tests__/triage-prompt.test.d.ts +2 -0
  91. package/dist/src/connectors/slack/__tests__/triage-prompt.test.d.ts.map +1 -0
  92. package/dist/src/connectors/slack/__tests__/triage-prompt.test.js +102 -0
  93. package/dist/src/connectors/slack/__tests__/triage-prompt.test.js.map +1 -0
  94. package/dist/src/connectors/slack/__tests__/triage.test.d.ts +2 -0
  95. package/dist/src/connectors/slack/__tests__/triage.test.d.ts.map +1 -0
  96. package/dist/src/connectors/slack/__tests__/triage.test.js +88 -0
  97. package/dist/src/connectors/slack/__tests__/triage.test.js.map +1 -0
  98. package/dist/src/connectors/slack/active-threads.d.ts +39 -0
  99. package/dist/src/connectors/slack/active-threads.d.ts.map +1 -0
  100. package/dist/src/connectors/slack/active-threads.js +71 -0
  101. package/dist/src/connectors/slack/active-threads.js.map +1 -0
  102. package/dist/src/connectors/slack/format.d.ts +17 -0
  103. package/dist/src/connectors/slack/format.d.ts.map +1 -0
  104. package/dist/src/connectors/slack/format.js +85 -0
  105. package/dist/src/connectors/slack/format.js.map +1 -0
  106. package/dist/src/connectors/slack/index.d.ts +51 -0
  107. package/dist/src/connectors/slack/index.d.ts.map +1 -0
  108. package/dist/src/connectors/slack/index.js +562 -0
  109. package/dist/src/connectors/slack/index.js.map +1 -0
  110. package/dist/src/connectors/slack/speaker.d.ts +39 -0
  111. package/dist/src/connectors/slack/speaker.d.ts.map +1 -0
  112. package/dist/src/connectors/slack/speaker.js +30 -0
  113. package/dist/src/connectors/slack/speaker.js.map +1 -0
  114. package/dist/src/connectors/slack/threads.d.ts +12 -0
  115. package/dist/src/connectors/slack/threads.d.ts.map +1 -0
  116. package/dist/src/connectors/slack/threads.js +41 -0
  117. package/dist/src/connectors/slack/threads.js.map +1 -0
  118. package/dist/src/connectors/slack/threads.test.d.ts +2 -0
  119. package/dist/src/connectors/slack/threads.test.d.ts.map +1 -0
  120. package/dist/src/connectors/slack/threads.test.js +79 -0
  121. package/dist/src/connectors/slack/threads.test.js.map +1 -0
  122. package/dist/src/connectors/slack/triage-prompt.d.ts +50 -0
  123. package/dist/src/connectors/slack/triage-prompt.d.ts.map +1 -0
  124. package/dist/src/connectors/slack/triage-prompt.js +127 -0
  125. package/dist/src/connectors/slack/triage-prompt.js.map +1 -0
  126. package/dist/src/connectors/slack/triage.d.ts +41 -0
  127. package/dist/src/connectors/slack/triage.d.ts.map +1 -0
  128. package/dist/src/connectors/slack/triage.js +120 -0
  129. package/dist/src/connectors/slack/triage.js.map +1 -0
  130. package/dist/src/connectors/telegram/__tests__/connector.test.d.ts +2 -0
  131. package/dist/src/connectors/telegram/__tests__/connector.test.d.ts.map +1 -0
  132. package/dist/src/connectors/telegram/__tests__/connector.test.js +257 -0
  133. package/dist/src/connectors/telegram/__tests__/connector.test.js.map +1 -0
  134. package/dist/src/connectors/telegram/__tests__/format.test.d.ts +2 -0
  135. package/dist/src/connectors/telegram/__tests__/format.test.d.ts.map +1 -0
  136. package/dist/src/connectors/telegram/__tests__/format.test.js +79 -0
  137. package/dist/src/connectors/telegram/__tests__/format.test.js.map +1 -0
  138. package/dist/src/connectors/telegram/__tests__/threads.test.d.ts +2 -0
  139. package/dist/src/connectors/telegram/__tests__/threads.test.d.ts.map +1 -0
  140. package/dist/src/connectors/telegram/__tests__/threads.test.js +51 -0
  141. package/dist/src/connectors/telegram/__tests__/threads.test.js.map +1 -0
  142. package/dist/src/connectors/telegram/format.d.ts +12 -0
  143. package/dist/src/connectors/telegram/format.d.ts.map +1 -0
  144. package/dist/src/connectors/telegram/format.js +61 -0
  145. package/dist/src/connectors/telegram/format.js.map +1 -0
  146. package/dist/src/connectors/telegram/index.d.ts +28 -0
  147. package/dist/src/connectors/telegram/index.d.ts.map +1 -0
  148. package/dist/src/connectors/telegram/index.js +213 -0
  149. package/dist/src/connectors/telegram/index.js.map +1 -0
  150. package/dist/src/connectors/telegram/threads.d.ts +29 -0
  151. package/dist/src/connectors/telegram/threads.d.ts.map +1 -0
  152. package/dist/src/connectors/telegram/threads.js +26 -0
  153. package/dist/src/connectors/telegram/threads.js.map +1 -0
  154. package/dist/src/connectors/whatsapp/__tests__/format.test.d.ts +2 -0
  155. package/dist/src/connectors/whatsapp/__tests__/format.test.d.ts.map +1 -0
  156. package/dist/src/connectors/whatsapp/__tests__/format.test.js +70 -0
  157. package/dist/src/connectors/whatsapp/__tests__/format.test.js.map +1 -0
  158. package/dist/src/connectors/whatsapp/format.d.ts +12 -0
  159. package/dist/src/connectors/whatsapp/format.d.ts.map +1 -0
  160. package/dist/src/connectors/whatsapp/format.js +53 -0
  161. package/dist/src/connectors/whatsapp/format.js.map +1 -0
  162. package/dist/src/connectors/whatsapp/index.d.ts +42 -0
  163. package/dist/src/connectors/whatsapp/index.d.ts.map +1 -0
  164. package/dist/src/connectors/whatsapp/index.js +279 -0
  165. package/dist/src/connectors/whatsapp/index.js.map +1 -0
  166. package/dist/src/cron/jobs.d.ts +5 -0
  167. package/dist/src/cron/jobs.d.ts.map +1 -0
  168. package/dist/src/cron/jobs.js +23 -0
  169. package/dist/src/cron/jobs.js.map +1 -0
  170. package/dist/src/cron/runner.d.ts +4 -0
  171. package/dist/src/cron/runner.d.ts.map +1 -0
  172. package/dist/src/cron/runner.js +88 -0
  173. package/dist/src/cron/runner.js.map +1 -0
  174. package/dist/src/cron/scheduler.d.ts +8 -0
  175. package/dist/src/cron/scheduler.d.ts.map +1 -0
  176. package/dist/src/cron/scheduler.js +64 -0
  177. package/dist/src/cron/scheduler.js.map +1 -0
  178. package/dist/src/engines/__tests__/gemini.test.d.ts +2 -0
  179. package/dist/src/engines/__tests__/gemini.test.d.ts.map +1 -0
  180. package/dist/src/engines/__tests__/gemini.test.js +404 -0
  181. package/dist/src/engines/__tests__/gemini.test.js.map +1 -0
  182. package/dist/src/engines/claude.d.ts +20 -0
  183. package/dist/src/engines/claude.d.ts.map +1 -0
  184. package/dist/src/engines/claude.js +520 -0
  185. package/dist/src/engines/claude.js.map +1 -0
  186. package/dist/src/engines/codex.d.ts +15 -0
  187. package/dist/src/engines/codex.d.ts.map +1 -0
  188. package/dist/src/engines/codex.js +351 -0
  189. package/dist/src/engines/codex.js.map +1 -0
  190. package/dist/src/engines/gemini.d.ts +32 -0
  191. package/dist/src/engines/gemini.d.ts.map +1 -0
  192. package/dist/src/engines/gemini.js +328 -0
  193. package/dist/src/engines/gemini.js.map +1 -0
  194. package/dist/src/engines/mock.d.ts +16 -0
  195. package/dist/src/engines/mock.d.ts.map +1 -0
  196. package/dist/src/engines/mock.js +47 -0
  197. package/dist/src/engines/mock.js.map +1 -0
  198. package/dist/src/gateway/__tests__/api-last-n.test.d.ts +2 -0
  199. package/dist/src/gateway/__tests__/api-last-n.test.d.ts.map +1 -0
  200. package/dist/src/gateway/__tests__/api-last-n.test.js +32 -0
  201. package/dist/src/gateway/__tests__/api-last-n.test.js.map +1 -0
  202. package/dist/src/gateway/__tests__/costs.test.d.ts +2 -0
  203. package/dist/src/gateway/__tests__/costs.test.d.ts.map +1 -0
  204. package/dist/src/gateway/__tests__/costs.test.js +32 -0
  205. package/dist/src/gateway/__tests__/costs.test.js.map +1 -0
  206. package/dist/src/gateway/__tests__/org-hierarchy.test.d.ts +2 -0
  207. package/dist/src/gateway/__tests__/org-hierarchy.test.d.ts.map +1 -0
  208. package/dist/src/gateway/__tests__/org-hierarchy.test.js +161 -0
  209. package/dist/src/gateway/__tests__/org-hierarchy.test.js.map +1 -0
  210. package/dist/src/gateway/__tests__/org-update.test.d.ts +2 -0
  211. package/dist/src/gateway/__tests__/org-update.test.d.ts.map +1 -0
  212. package/dist/src/gateway/__tests__/org-update.test.js +99 -0
  213. package/dist/src/gateway/__tests__/org-update.test.js.map +1 -0
  214. package/dist/src/gateway/__tests__/org.test.d.ts +2 -0
  215. package/dist/src/gateway/__tests__/org.test.d.ts.map +1 -0
  216. package/dist/src/gateway/__tests__/org.test.js +77 -0
  217. package/dist/src/gateway/__tests__/org.test.js.map +1 -0
  218. package/dist/src/gateway/__tests__/services.test.d.ts +2 -0
  219. package/dist/src/gateway/__tests__/services.test.d.ts.map +1 -0
  220. package/dist/src/gateway/__tests__/services.test.js +153 -0
  221. package/dist/src/gateway/__tests__/services.test.js.map +1 -0
  222. package/dist/src/gateway/api.d.ts +19 -0
  223. package/dist/src/gateway/api.d.ts.map +1 -0
  224. package/dist/src/gateway/api.js +2261 -0
  225. package/dist/src/gateway/api.js.map +1 -0
  226. package/dist/src/gateway/budgets.d.ts +16 -0
  227. package/dist/src/gateway/budgets.d.ts.map +1 -0
  228. package/dist/src/gateway/budgets.js +39 -0
  229. package/dist/src/gateway/budgets.js.map +1 -0
  230. package/dist/src/gateway/costs.d.ts +19 -0
  231. package/dist/src/gateway/costs.d.ts.map +1 -0
  232. package/dist/src/gateway/costs.js +39 -0
  233. package/dist/src/gateway/costs.js.map +1 -0
  234. package/dist/src/gateway/daemon-entry.d.ts +2 -0
  235. package/dist/src/gateway/daemon-entry.d.ts.map +1 -0
  236. package/dist/src/gateway/daemon-entry.js +12 -0
  237. package/dist/src/gateway/daemon-entry.js.map +1 -0
  238. package/dist/src/gateway/files.d.ts +6 -0
  239. package/dist/src/gateway/files.d.ts.map +1 -0
  240. package/dist/src/gateway/files.js +442 -0
  241. package/dist/src/gateway/files.js.map +1 -0
  242. package/dist/src/gateway/goals.d.ts +11 -0
  243. package/dist/src/gateway/goals.d.ts.map +1 -0
  244. package/dist/src/gateway/goals.js +72 -0
  245. package/dist/src/gateway/goals.js.map +1 -0
  246. package/dist/src/gateway/lifecycle.d.ts +10 -0
  247. package/dist/src/gateway/lifecycle.d.ts.map +1 -0
  248. package/dist/src/gateway/lifecycle.js +152 -0
  249. package/dist/src/gateway/lifecycle.js.map +1 -0
  250. package/dist/src/gateway/org-hierarchy.d.ts +5 -0
  251. package/dist/src/gateway/org-hierarchy.d.ts.map +1 -0
  252. package/dist/src/gateway/org-hierarchy.js +174 -0
  253. package/dist/src/gateway/org-hierarchy.js.map +1 -0
  254. package/dist/src/gateway/org.d.ts +17 -0
  255. package/dist/src/gateway/org.d.ts.map +1 -0
  256. package/dist/src/gateway/org.js +136 -0
  257. package/dist/src/gateway/org.js.map +1 -0
  258. package/dist/src/gateway/server.d.ts +4 -0
  259. package/dist/src/gateway/server.d.ts.map +1 -0
  260. package/dist/src/gateway/server.js +764 -0
  261. package/dist/src/gateway/server.js.map +1 -0
  262. package/dist/src/gateway/services.d.ts +28 -0
  263. package/dist/src/gateway/services.d.ts.map +1 -0
  264. package/dist/src/gateway/services.js +112 -0
  265. package/dist/src/gateway/services.js.map +1 -0
  266. package/dist/src/gateway/watcher.d.ts +14 -0
  267. package/dist/src/gateway/watcher.d.ts.map +1 -0
  268. package/dist/src/gateway/watcher.js +111 -0
  269. package/dist/src/gateway/watcher.js.map +1 -0
  270. package/dist/src/index.d.ts +5 -0
  271. package/dist/src/index.d.ts.map +1 -0
  272. package/dist/src/index.js +4 -0
  273. package/dist/src/index.js.map +1 -0
  274. package/dist/src/mcp/gateway-server.d.ts +13 -0
  275. package/dist/src/mcp/gateway-server.d.ts.map +1 -0
  276. package/dist/src/mcp/gateway-server.js +376 -0
  277. package/dist/src/mcp/gateway-server.js.map +1 -0
  278. package/dist/src/mcp/resolver.d.ts +19 -0
  279. package/dist/src/mcp/resolver.d.ts.map +1 -0
  280. package/dist/src/mcp/resolver.js +159 -0
  281. package/dist/src/mcp/resolver.js.map +1 -0
  282. package/dist/src/sessions/__tests__/callbacks.test.d.ts +2 -0
  283. package/dist/src/sessions/__tests__/callbacks.test.d.ts.map +1 -0
  284. package/dist/src/sessions/__tests__/callbacks.test.js +154 -0
  285. package/dist/src/sessions/__tests__/callbacks.test.js.map +1 -0
  286. package/dist/src/sessions/callbacks.d.ts +31 -0
  287. package/dist/src/sessions/callbacks.d.ts.map +1 -0
  288. package/dist/src/sessions/callbacks.js +112 -0
  289. package/dist/src/sessions/callbacks.js.map +1 -0
  290. package/dist/src/sessions/context.d.ts +36 -0
  291. package/dist/src/sessions/context.d.ts.map +1 -0
  292. package/dist/src/sessions/context.js +741 -0
  293. package/dist/src/sessions/context.js.map +1 -0
  294. package/dist/src/sessions/fork.d.ts +30 -0
  295. package/dist/src/sessions/fork.d.ts.map +1 -0
  296. package/dist/src/sessions/fork.js +178 -0
  297. package/dist/src/sessions/fork.js.map +1 -0
  298. package/dist/src/sessions/manager.d.ts +27 -0
  299. package/dist/src/sessions/manager.d.ts.map +1 -0
  300. package/dist/src/sessions/manager.js +751 -0
  301. package/dist/src/sessions/manager.js.map +1 -0
  302. package/dist/src/sessions/queue.d.ts +36 -0
  303. package/dist/src/sessions/queue.d.ts.map +1 -0
  304. package/dist/src/sessions/queue.js +101 -0
  305. package/dist/src/sessions/queue.js.map +1 -0
  306. package/dist/src/sessions/queue.test.d.ts +2 -0
  307. package/dist/src/sessions/queue.test.d.ts.map +1 -0
  308. package/dist/src/sessions/queue.test.js +28 -0
  309. package/dist/src/sessions/queue.test.js.map +1 -0
  310. package/dist/src/sessions/registry.d.ts +116 -0
  311. package/dist/src/sessions/registry.d.ts.map +1 -0
  312. package/dist/src/sessions/registry.js +480 -0
  313. package/dist/src/sessions/registry.js.map +1 -0
  314. package/dist/src/sessions/registry.test.d.ts +2 -0
  315. package/dist/src/sessions/registry.test.d.ts.map +1 -0
  316. package/dist/src/sessions/registry.test.js +41 -0
  317. package/dist/src/sessions/registry.test.js.map +1 -0
  318. package/dist/src/shared/__tests__/rateLimit.test.d.ts +2 -0
  319. package/dist/src/shared/__tests__/rateLimit.test.d.ts.map +1 -0
  320. package/dist/src/shared/__tests__/rateLimit.test.js +108 -0
  321. package/dist/src/shared/__tests__/rateLimit.test.js.map +1 -0
  322. package/dist/src/shared/__tests__/usageAwareness.test.d.ts +2 -0
  323. package/dist/src/shared/__tests__/usageAwareness.test.d.ts.map +1 -0
  324. package/dist/src/shared/__tests__/usageAwareness.test.js +58 -0
  325. package/dist/src/shared/__tests__/usageAwareness.test.js.map +1 -0
  326. package/dist/src/shared/config.d.ts +3 -0
  327. package/dist/src/shared/config.d.ts.map +1 -0
  328. package/dist/src/shared/config.js +11 -0
  329. package/dist/src/shared/config.js.map +1 -0
  330. package/dist/src/shared/effort.d.ts +17 -0
  331. package/dist/src/shared/effort.d.ts.map +1 -0
  332. package/dist/src/shared/effort.js +44 -0
  333. package/dist/src/shared/effort.js.map +1 -0
  334. package/dist/src/shared/logger.d.ts +12 -0
  335. package/dist/src/shared/logger.d.ts.map +1 -0
  336. package/dist/src/shared/logger.js +35 -0
  337. package/dist/src/shared/logger.js.map +1 -0
  338. package/dist/src/shared/paths.d.ts +31 -0
  339. package/dist/src/shared/paths.d.ts.map +1 -0
  340. package/dist/src/shared/paths.js +92 -0
  341. package/dist/src/shared/paths.js.map +1 -0
  342. package/dist/src/shared/rateLimit.d.ts +20 -0
  343. package/dist/src/shared/rateLimit.d.ts.map +1 -0
  344. package/dist/src/shared/rateLimit.js +61 -0
  345. package/dist/src/shared/rateLimit.js.map +1 -0
  346. package/dist/src/shared/types.d.ts +448 -0
  347. package/dist/src/shared/types.d.ts.map +1 -0
  348. package/dist/src/shared/types.js +4 -0
  349. package/dist/src/shared/types.js.map +1 -0
  350. package/dist/src/shared/usageAwareness.d.ts +10 -0
  351. package/dist/src/shared/usageAwareness.d.ts.map +1 -0
  352. package/dist/src/shared/usageAwareness.js +69 -0
  353. package/dist/src/shared/usageAwareness.js.map +1 -0
  354. package/dist/src/shared/version.d.ts +15 -0
  355. package/dist/src/shared/version.d.ts.map +1 -0
  356. package/dist/src/shared/version.js +56 -0
  357. package/dist/src/shared/version.js.map +1 -0
  358. package/dist/src/stt/stt.d.ts +24 -0
  359. package/dist/src/stt/stt.d.ts.map +1 -0
  360. package/dist/src/stt/stt.js +203 -0
  361. package/dist/src/stt/stt.js.map +1 -0
  362. package/dist/web/404.html +1 -0
  363. package/dist/web/__next.__PAGE__.txt +9 -0
  364. package/dist/web/__next._full.txt +19 -0
  365. package/dist/web/__next._head.txt +6 -0
  366. package/dist/web/__next._index.txt +6 -0
  367. package/dist/web/__next._tree.txt +2 -0
  368. package/dist/web/_next/static/chunks/00yn.jp4tr9l6.js +62 -0
  369. package/dist/web/_next/static/chunks/0398g9fy2i2l..js +5 -0
  370. package/dist/web/_next/static/chunks/03~yq9q893hmn.js +1 -0
  371. package/dist/web/_next/static/chunks/07t-qdutaizlf.js +5 -0
  372. package/dist/web/_next/static/chunks/0_pxvi.u4tzw1.js +5 -0
  373. package/dist/web/_next/static/chunks/0dtbl8648b3u9.js +7 -0
  374. package/dist/web/_next/static/chunks/0e3pdjkorr5zl.js +2 -0
  375. package/dist/web/_next/static/chunks/0ewpxy-mzj3j3.js +1 -0
  376. package/dist/web/_next/static/chunks/0ihbb7~gq42g~.js +5 -0
  377. package/dist/web/_next/static/chunks/0j4uz-g9x1_~s.js +1 -0
  378. package/dist/web/_next/static/chunks/0mlo32yrc7ziu.css +1 -0
  379. package/dist/web/_next/static/chunks/0ru56x83x4rkk.js +1 -0
  380. package/dist/web/_next/static/chunks/0svvev77pb2gu.js +1 -0
  381. package/dist/web/_next/static/chunks/0tf9kpygstdls.js +1 -0
  382. package/dist/web/_next/static/chunks/0wzpea61sn2-k.js +5 -0
  383. package/dist/web/_next/static/chunks/0y3dnnq~kxyz_.js +1 -0
  384. package/dist/web/_next/static/chunks/0y~qqhmf2o7pt.js +1 -0
  385. package/dist/web/_next/static/chunks/0~6m1p5hnsx2f.js +1 -0
  386. package/dist/web/_next/static/chunks/10-liamt-ipcu.js +1 -0
  387. package/dist/web/_next/static/chunks/10l08hq.acixl.js +5 -0
  388. package/dist/web/_next/static/chunks/11rbefjn.e-3-.js +1 -0
  389. package/dist/web/_next/static/chunks/11rodb4s1x4of.js +1 -0
  390. package/dist/web/_next/static/chunks/12_uxtnrqhoo1.js +1 -0
  391. package/dist/web/_next/static/chunks/12h4dws.tz7yb.js +5 -0
  392. package/dist/web/_next/static/chunks/13juizw904g.e.js +1 -0
  393. package/dist/web/_next/static/chunks/14ge_q22f0n.f.js +1 -0
  394. package/dist/web/_next/static/chunks/16b0jvgccyt2i.js +5 -0
  395. package/dist/web/_next/static/chunks/17-u9c_weg12-.js +31 -0
  396. package/dist/web/_next/static/chunks/turbopack-0dt987bw_1nyf.js +1 -0
  397. package/dist/web/_next/static/media/apple-icon.0.wpl4.d-5bkf.jpg +0 -0
  398. package/dist/web/_next/static/media/icon.0.wpl4.d-5bkf.jpg +0 -0
  399. package/dist/web/_next/static/uwjABwQIFELOHhVlstvOe/_buildManifest.js +11 -0
  400. package/dist/web/_next/static/uwjABwQIFELOHhVlstvOe/_clientMiddlewareManifest.js +1 -0
  401. package/dist/web/_next/static/uwjABwQIFELOHhVlstvOe/_ssgManifest.js +1 -0
  402. package/dist/web/_not-found/__next._full.txt +17 -0
  403. package/dist/web/_not-found/__next._head.txt +6 -0
  404. package/dist/web/_not-found/__next._index.txt +6 -0
  405. package/dist/web/_not-found/__next._not-found.__PAGE__.txt +5 -0
  406. package/dist/web/_not-found/__next._not-found.txt +5 -0
  407. package/dist/web/_not-found/__next._tree.txt +2 -0
  408. package/dist/web/_not-found.html +1 -0
  409. package/dist/web/_not-found.txt +17 -0
  410. package/dist/web/apple-icon.jpg +0 -0
  411. package/dist/web/chat/__next._full.txt +21 -0
  412. package/dist/web/chat/__next._head.txt +6 -0
  413. package/dist/web/chat/__next._index.txt +6 -0
  414. package/dist/web/chat/__next._tree.txt +2 -0
  415. package/dist/web/chat/__next.chat.__PAGE__.txt +9 -0
  416. package/dist/web/chat/__next.chat.txt +5 -0
  417. package/dist/web/chat.html +1 -0
  418. package/dist/web/chat.txt +21 -0
  419. package/dist/web/cron/__next._full.txt +21 -0
  420. package/dist/web/cron/__next._head.txt +6 -0
  421. package/dist/web/cron/__next._index.txt +6 -0
  422. package/dist/web/cron/__next._tree.txt +2 -0
  423. package/dist/web/cron/__next.cron.__PAGE__.txt +9 -0
  424. package/dist/web/cron/__next.cron.txt +5 -0
  425. package/dist/web/cron.html +1 -0
  426. package/dist/web/cron.txt +21 -0
  427. package/dist/web/icon.jpg +0 -0
  428. package/dist/web/index.html +1 -0
  429. package/dist/web/index.txt +19 -0
  430. package/dist/web/kanban/__next._full.txt +21 -0
  431. package/dist/web/kanban/__next._head.txt +6 -0
  432. package/dist/web/kanban/__next._index.txt +6 -0
  433. package/dist/web/kanban/__next._tree.txt +2 -0
  434. package/dist/web/kanban/__next.kanban.__PAGE__.txt +9 -0
  435. package/dist/web/kanban/__next.kanban.txt +5 -0
  436. package/dist/web/kanban.html +1 -0
  437. package/dist/web/kanban.txt +21 -0
  438. package/dist/web/logs/__next._full.txt +21 -0
  439. package/dist/web/logs/__next._head.txt +6 -0
  440. package/dist/web/logs/__next._index.txt +6 -0
  441. package/dist/web/logs/__next._tree.txt +2 -0
  442. package/dist/web/logs/__next.logs.__PAGE__.txt +9 -0
  443. package/dist/web/logs/__next.logs.txt +5 -0
  444. package/dist/web/logs.html +7 -0
  445. package/dist/web/logs.txt +21 -0
  446. package/dist/web/org/__next._full.txt +21 -0
  447. package/dist/web/org/__next._head.txt +6 -0
  448. package/dist/web/org/__next._index.txt +6 -0
  449. package/dist/web/org/__next._tree.txt +2 -0
  450. package/dist/web/org/__next.org.__PAGE__.txt +9 -0
  451. package/dist/web/org/__next.org.txt +5 -0
  452. package/dist/web/org.html +1 -0
  453. package/dist/web/org.txt +21 -0
  454. package/dist/web/sessions/__next._full.txt +18 -0
  455. package/dist/web/sessions/__next._head.txt +6 -0
  456. package/dist/web/sessions/__next._index.txt +6 -0
  457. package/dist/web/sessions/__next._tree.txt +2 -0
  458. package/dist/web/sessions/__next.sessions.__PAGE__.txt +6 -0
  459. package/dist/web/sessions/__next.sessions.txt +5 -0
  460. package/dist/web/sessions.html +1 -0
  461. package/dist/web/sessions.txt +18 -0
  462. package/dist/web/settings/__next._full.txt +21 -0
  463. package/dist/web/settings/__next._head.txt +6 -0
  464. package/dist/web/settings/__next._index.txt +6 -0
  465. package/dist/web/settings/__next._tree.txt +2 -0
  466. package/dist/web/settings/__next.settings.__PAGE__.txt +9 -0
  467. package/dist/web/settings/__next.settings.txt +5 -0
  468. package/dist/web/settings.html +1 -0
  469. package/dist/web/settings.txt +21 -0
  470. package/dist/web/skills/__next._full.txt +21 -0
  471. package/dist/web/skills/__next._head.txt +6 -0
  472. package/dist/web/skills/__next._index.txt +6 -0
  473. package/dist/web/skills/__next._tree.txt +2 -0
  474. package/dist/web/skills/__next.skills.__PAGE__.txt +9 -0
  475. package/dist/web/skills/__next.skills.txt +5 -0
  476. package/dist/web/skills.html +1 -0
  477. package/dist/web/skills.txt +21 -0
  478. package/package.json +54 -0
  479. package/template/AGENTS.md +285 -0
  480. package/template/CLAUDE.md +285 -0
  481. package/template/config.default.yaml +71 -0
  482. package/template/docs/architecture.md +74 -0
  483. package/template/docs/connectors.md +72 -0
  484. package/template/docs/cron.md +137 -0
  485. package/template/docs/mcp.md +108 -0
  486. package/template/docs/org.md +105 -0
  487. package/template/docs/overview.md +39 -0
  488. package/template/docs/self-modification.md +65 -0
  489. package/template/docs/skills.md +58 -0
  490. package/template/knowledge/.gitkeep +0 -0
  491. package/template/migrations/.gitkeep +0 -0
  492. package/template/migrations/0.1.0/MIGRATION.md +25 -0
  493. package/template/migrations/0.2.0/MIGRATION.md +41 -0
  494. package/template/migrations/0.2.0/files/config.default.yaml +30 -0
  495. package/template/migrations/0.3.0/MIGRATION.md +57 -0
  496. package/template/migrations/0.3.0/files/config.default.yaml +62 -0
  497. package/template/migrations/0.3.0/files/docs/mcp.md +108 -0
  498. package/template/migrations/0.7.3/MIGRATION.md +63 -0
  499. package/template/migrations/0.7.3/files/AGENTS.md +188 -0
  500. package/template/migrations/0.7.3/files/CLAUDE.md +127 -0
  501. package/template/migrations/0.7.5/MIGRATION.md +78 -0
  502. package/template/migrations/0.8.0/MIGRATION.md +73 -0
  503. package/template/migrations/0.9.0/MIGRATION.md +82 -0
  504. package/template/skills/cron-manager/SKILL.md +127 -0
  505. package/template/skills/find-and-install/SKILL.md +92 -0
  506. package/template/skills/management/SKILL.md +226 -0
  507. package/template/skills/migrate/SKILL.md +154 -0
  508. package/template/skills/new/SKILL.md +19 -0
  509. package/template/skills/onboarding/SKILL.md +106 -0
  510. package/template/skills/self-heal/SKILL.md +114 -0
  511. package/template/skills/skill-creator/SKILL.md +112 -0
  512. package/template/skills/status/SKILL.md +19 -0
  513. package/template/skills/sync/SKILL.md +67 -0
  514. package/template/skills.json +3 -0
@@ -0,0 +1,2261 @@
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { isInterruptibleEngine } from "../shared/types.js";
7
+ import { buildContext } from "../sessions/context.js";
8
+ import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
9
+ import { forkEngineSession } from "../sessions/fork.js";
10
+ import { CONFIG_PATH, CRON_RUNS, ORG_DIR, SKILLS_DIR, LOGS_DIR, TMP_DIR, FILES_DIR, } from "../shared/paths.js";
11
+ import { logger } from "../shared/logger.js";
12
+ import { getSttStatus, downloadModel, transcribe as sttTranscribe, resolveLanguages, WHISPER_LANGUAGES } from "../stt/stt.js";
13
+ import { JINN_HOME } from "../shared/paths.js";
14
+ import { resolveEffort } from "../shared/effort.js";
15
+ import { computeNextRetryDelayMs, computeRateLimitDeadlineMs, detectRateLimit } from "../shared/rateLimit.js";
16
+ import { getClaudeExpectedResetAt, recordClaudeRateLimit } from "../shared/usageAwareness.js";
17
+ import { loadJobs, saveJobs } from "../cron/jobs.js";
18
+ import { reloadScheduler } from "../cron/scheduler.js";
19
+ import { runCronJob } from "../cron/runner.js";
20
+ import QRCode from "qrcode";
21
+ import { handleFilesRequest } from "./files.js";
22
+ import { notifyParentSession, notifyRateLimited, notifyRateLimitResumed, notifyDiscordChannel } from "../sessions/callbacks.js";
23
+ import { loadInstances } from "../cli/instances.js";
24
+ export function resumePendingWebQueueItems(context) {
25
+ const pending = listAllPendingQueueItems();
26
+ if (pending.length === 0)
27
+ return;
28
+ let resumed = 0;
29
+ for (const item of pending) {
30
+ let session = getSession(item.sessionId);
31
+ if (!session) {
32
+ cancelQueueItem(item.id);
33
+ continue;
34
+ }
35
+ if (session.source !== "web")
36
+ continue;
37
+ session = maybeRevertEngineOverride(session);
38
+ const config = context.getConfig();
39
+ const engine = context.sessionManager.getEngine(session.engine);
40
+ if (!engine) {
41
+ cancelQueueItem(item.id);
42
+ updateSession(session.id, { status: "error", lastActivity: new Date().toISOString(), lastError: `Engine "${session.engine}" not available` });
43
+ continue;
44
+ }
45
+ // Ensure the session is in a runnable state
46
+ updateSession(session.id, { status: "running", lastActivity: new Date().toISOString(), lastError: null });
47
+ dispatchWebSessionRun(session, item.prompt, engine, config, context, { queueItemId: item.id });
48
+ resumed++;
49
+ }
50
+ if (resumed > 0) {
51
+ logger.info(`Re-dispatched ${resumed} pending web queue item(s) after gateway restart`);
52
+ }
53
+ }
54
+ function maybeRevertEngineOverride(session) {
55
+ const meta = (session.transportMeta || {});
56
+ const override = meta["engineOverride"];
57
+ if (!override)
58
+ return session;
59
+ const originalEngine = typeof override.originalEngine === "string" ? override.originalEngine : null;
60
+ const originalEngineSessionId = typeof override.originalEngineSessionId === "string"
61
+ ? override.originalEngineSessionId
62
+ : null;
63
+ const syncSince = typeof override.syncSince === "string" ? override.syncSince : null;
64
+ const untilIso = typeof override.until === "string" ? override.until : null;
65
+ if (!originalEngine || !untilIso)
66
+ return session;
67
+ const until = new Date(untilIso);
68
+ if (Number.isNaN(until.getTime()))
69
+ return session;
70
+ if (until.getTime() > Date.now())
71
+ return session;
72
+ const engineSessionsRaw = meta["engineSessions"];
73
+ const engineSessions = (engineSessionsRaw && typeof engineSessionsRaw === "object" && !Array.isArray(engineSessionsRaw))
74
+ ? { ...engineSessionsRaw }
75
+ : {};
76
+ // Preserve the current engine session ID under its engine key
77
+ if (session.engine && session.engineSessionId) {
78
+ engineSessions[String(session.engine)] = session.engineSessionId;
79
+ }
80
+ const restoredSessionId = originalEngineSessionId
81
+ ?? (typeof engineSessions[originalEngine] === "string" ? engineSessions[originalEngine] : null);
82
+ const nextMeta = { ...meta, engineSessions };
83
+ if (originalEngine === "claude" && syncSince && session.engine !== "claude") {
84
+ nextMeta["claudeSyncSince"] = syncSince;
85
+ }
86
+ delete nextMeta["engineOverride"];
87
+ return updateSession(session.id, {
88
+ engine: originalEngine,
89
+ engineSessionId: restoredSessionId,
90
+ transportMeta: nextMeta,
91
+ lastError: null,
92
+ }) ?? session;
93
+ }
94
+ function dispatchWebSessionRun(session, prompt, engine, config, context, opts) {
95
+ const run = async () => {
96
+ await context.sessionManager.getQueue().enqueue(session.sessionKey || session.sourceRef, async () => {
97
+ context.emit("session:started", { sessionId: session.id });
98
+ await runWebSession(session, prompt, engine, config, context, opts?.attachments);
99
+ }, opts?.queueItemId);
100
+ };
101
+ const launch = () => {
102
+ run().catch((err) => {
103
+ const errMsg = err instanceof Error ? err.message : String(err);
104
+ logger.error(`Web session ${session.id} dispatch error: ${errMsg}`);
105
+ updateSession(session.id, {
106
+ status: "error",
107
+ lastActivity: new Date().toISOString(),
108
+ lastError: errMsg,
109
+ });
110
+ context.emit("session:completed", {
111
+ sessionId: session.id,
112
+ result: null,
113
+ error: errMsg,
114
+ });
115
+ });
116
+ };
117
+ if (opts?.delayMs && opts.delayMs > 0) {
118
+ setTimeout(launch, opts.delayMs);
119
+ }
120
+ else {
121
+ launch();
122
+ }
123
+ }
124
+ function readBody(req) {
125
+ return new Promise((resolve, reject) => {
126
+ const chunks = [];
127
+ req.on("data", (chunk) => chunks.push(chunk));
128
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
129
+ req.on("error", reject);
130
+ });
131
+ }
132
+ function readBodyRaw(req) {
133
+ return new Promise((resolve, reject) => {
134
+ const chunks = [];
135
+ req.on("data", (chunk) => chunks.push(chunk));
136
+ req.on("end", () => resolve(Buffer.concat(chunks)));
137
+ req.on("error", reject);
138
+ });
139
+ }
140
+ async function readJsonBody(req, res) {
141
+ const raw = await readBody(req);
142
+ try {
143
+ return { ok: true, body: JSON.parse(raw) };
144
+ }
145
+ catch {
146
+ badRequest(res, "Invalid JSON in request body");
147
+ return { ok: false };
148
+ }
149
+ }
150
+ /** Resolve an array of file IDs to local filesystem paths for engine consumption. */
151
+ function resolveAttachmentPaths(fileIds) {
152
+ if (!Array.isArray(fileIds))
153
+ return [];
154
+ const paths = [];
155
+ for (const id of fileIds) {
156
+ if (typeof id !== "string" || !id.trim())
157
+ continue;
158
+ const meta = getFile(id);
159
+ if (!meta) {
160
+ logger.warn(`Attachment file not found: ${id}`);
161
+ continue;
162
+ }
163
+ const filePath = path.join(FILES_DIR, meta.id, meta.filename);
164
+ if (fs.existsSync(filePath)) {
165
+ paths.push(filePath);
166
+ }
167
+ else if (meta.path && fs.existsSync(meta.path)) {
168
+ paths.push(meta.path);
169
+ }
170
+ else {
171
+ logger.warn(`Attachment file missing on disk: ${id} (${meta.filename})`);
172
+ }
173
+ }
174
+ return paths;
175
+ }
176
+ function json(res, data, status = 200) {
177
+ res.writeHead(status, { "Content-Type": "application/json" });
178
+ res.end(JSON.stringify(data));
179
+ }
180
+ function notFound(res) {
181
+ json(res, { error: "Not found" }, 404);
182
+ }
183
+ function badRequest(res, message) {
184
+ json(res, { error: message }, 400);
185
+ }
186
+ function serverError(res, message) {
187
+ json(res, { error: message }, 500);
188
+ }
189
+ const SANITIZED_KEYS = new Set(["token", "botToken", "signingSecret", "appToken"]);
190
+ function deepMerge(target, source) {
191
+ const result = { ...target };
192
+ for (const key of Object.keys(source)) {
193
+ const sv = source[key];
194
+ const tv = target[key];
195
+ // Skip sanitized secret placeholders — keep original value
196
+ if (SANITIZED_KEYS.has(key) && sv === "***")
197
+ continue;
198
+ if (Array.isArray(sv)) {
199
+ // For arrays (e.g. instances), preserve secrets from matching items
200
+ if (Array.isArray(tv)) {
201
+ result[key] = sv.map((item) => {
202
+ if (item && typeof item === "object" && !Array.isArray(item)) {
203
+ const srcItem = item;
204
+ // Find matching target item by id
205
+ const matchTarget = tv.find((t) => t && typeof t === "object" && t.id === srcItem.id);
206
+ if (matchTarget)
207
+ return deepMerge(matchTarget, srcItem);
208
+ }
209
+ return item;
210
+ });
211
+ }
212
+ else {
213
+ result[key] = sv;
214
+ }
215
+ }
216
+ else if (sv && typeof sv === "object" && !Array.isArray(sv) && tv && typeof tv === "object" && !Array.isArray(tv)) {
217
+ result[key] = deepMerge(tv, sv);
218
+ }
219
+ else {
220
+ result[key] = sv;
221
+ }
222
+ }
223
+ return result;
224
+ }
225
+ function matchRoute(pattern, pathname) {
226
+ const patternParts = pattern.split("/");
227
+ const pathParts = pathname.split("/");
228
+ if (patternParts.length !== pathParts.length)
229
+ return null;
230
+ const params = {};
231
+ for (let i = 0; i < patternParts.length; i++) {
232
+ if (patternParts[i].startsWith(":")) {
233
+ params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
234
+ }
235
+ else if (patternParts[i] !== pathParts[i]) {
236
+ return null;
237
+ }
238
+ }
239
+ return params;
240
+ }
241
+ function serializeSession(session, context) {
242
+ const queue = context.sessionManager.getQueue();
243
+ const queueDepth = queue.getPendingCount(session.sessionKey || session.sourceRef);
244
+ const transportState = queue.getTransportState(session.sessionKey || session.sourceRef, session.status);
245
+ return {
246
+ ...session,
247
+ queueDepth,
248
+ transportState,
249
+ };
250
+ }
251
+ function checkInstanceHealth(port) {
252
+ return new Promise((resolve) => {
253
+ const req = http.request({ hostname: "localhost", port, path: "/api/status", timeout: 2000 }, (res) => {
254
+ resolve(res.statusCode === 200);
255
+ res.resume();
256
+ });
257
+ req.on("error", () => resolve(false));
258
+ req.on("timeout", () => { req.destroy(); resolve(false); });
259
+ req.end();
260
+ });
261
+ }
262
+ export async function handleApiRequest(req, res, context) {
263
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
264
+ const pathname = url.pathname;
265
+ const method = req.method || "GET";
266
+ try {
267
+ // GET /api/status
268
+ if (method === "GET" && pathname === "/api/status") {
269
+ const config = context.getConfig();
270
+ const sessions = listSessions();
271
+ const running = sessions.filter((s) => s.status === "running").length;
272
+ const connectors = Object.fromEntries(Array.from(context.connectors.values()).map((connector) => [connector.name, connector.getHealth()]));
273
+ return json(res, {
274
+ status: "ok",
275
+ uptime: Math.floor((Date.now() - context.startTime) / 1000),
276
+ port: config.gateway.port || 7777,
277
+ engines: {
278
+ default: config.engines.default,
279
+ claude: { model: config.engines.claude.model, available: true },
280
+ codex: { model: config.engines.codex.model, available: true },
281
+ ...(config.engines.gemini ? { gemini: { model: config.engines.gemini.model, available: true } } : {}),
282
+ },
283
+ sessions: { total: sessions.length, running, active: running },
284
+ connectors,
285
+ });
286
+ }
287
+ // GET /api/instances
288
+ if (method === "GET" && pathname === "/api/instances") {
289
+ const instances = loadInstances();
290
+ const currentPort = context.getConfig().gateway.port || 7777;
291
+ const results = await Promise.all(instances.map(async (inst) => ({
292
+ name: inst.name,
293
+ port: inst.port,
294
+ running: inst.port === currentPort ? true : await checkInstanceHealth(inst.port),
295
+ current: inst.port === currentPort,
296
+ })));
297
+ return json(res, results);
298
+ }
299
+ // GET /api/sessions
300
+ if (method === "GET" && pathname === "/api/sessions") {
301
+ const sessions = listSessions();
302
+ return json(res, sessions.map((session) => serializeSession(session, context)));
303
+ }
304
+ // GET /api/sessions/interrupted — list sessions that can be resumed after a restart
305
+ if (method === "GET" && pathname === "/api/sessions/interrupted") {
306
+ const { getInterruptedSessions } = await import("../sessions/registry.js");
307
+ const interrupted = getInterruptedSessions();
308
+ return json(res, interrupted.map((session) => serializeSession(session, context)));
309
+ }
310
+ // GET /api/sessions/:id
311
+ let params = matchRoute("/api/sessions/:id", pathname);
312
+ if (method === "GET" && params) {
313
+ const session = getSession(params.id);
314
+ if (!session)
315
+ return notFound(res);
316
+ let messages = getMessages(params.id);
317
+ // Backfill from Claude Code's JSONL transcript if our DB has no messages
318
+ if (messages.length === 0 && session.engineSessionId) {
319
+ const transcriptMessages = loadTranscriptMessages(session.engineSessionId);
320
+ if (transcriptMessages.length > 0) {
321
+ for (const tm of transcriptMessages) {
322
+ insertMessage(params.id, tm.role, tm.content);
323
+ }
324
+ messages = getMessages(params.id);
325
+ }
326
+ }
327
+ // Support ?last=N to return only the N most recent messages
328
+ const lastN = parseInt(url.searchParams.get("last") || "0", 10);
329
+ if (lastN > 0 && messages.length > lastN) {
330
+ messages = messages.slice(-lastN);
331
+ }
332
+ return json(res, { ...serializeSession(session, context), messages });
333
+ }
334
+ // PUT /api/sessions/:id
335
+ params = matchRoute("/api/sessions/:id", pathname);
336
+ if (method === "PUT" && params) {
337
+ const session = getSession(params.id);
338
+ if (!session)
339
+ return notFound(res);
340
+ const _parsed = await readJsonBody(req, res);
341
+ if (!_parsed.ok)
342
+ return;
343
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
344
+ const body = _parsed.body;
345
+ const updates = {};
346
+ if (body.title !== undefined) {
347
+ if (typeof body.title !== "string")
348
+ return badRequest(res, "title must be a string");
349
+ const trimmed = body.title.trim();
350
+ if (!trimmed)
351
+ return badRequest(res, "title must not be empty");
352
+ updates.title = trimmed.slice(0, 200);
353
+ }
354
+ if (Object.keys(updates).length === 0)
355
+ return badRequest(res, "no valid fields to update");
356
+ const updated = updateSession(params.id, updates);
357
+ if (!updated)
358
+ return notFound(res);
359
+ context.emit("session:updated", { sessionId: params.id });
360
+ return json(res, serializeSession(updated, context));
361
+ }
362
+ // DELETE /api/sessions/:id
363
+ params = matchRoute("/api/sessions/:id", pathname);
364
+ if (method === "DELETE" && params) {
365
+ const session = getSession(params.id);
366
+ if (!session)
367
+ return notFound(res);
368
+ // Kill any live engine process for this session before deleting it.
369
+ const engine = context.sessionManager.getEngine(session.engine);
370
+ if (engine && isInterruptibleEngine(engine) && engine.isAlive(params.id)) {
371
+ logger.info(`Killing live engine process for deleted session ${params.id}`);
372
+ engine.kill(params.id);
373
+ }
374
+ const deleted = deleteSession(params.id);
375
+ if (!deleted)
376
+ return notFound(res);
377
+ logger.info(`Session deleted: ${params.id}`);
378
+ context.emit("session:deleted", { sessionId: params.id });
379
+ return json(res, { status: "deleted" });
380
+ }
381
+ // POST /api/sessions/:id/stop
382
+ params = matchRoute("/api/sessions/:id/stop", pathname);
383
+ if (method === "POST" && params) {
384
+ const session = getSession(params.id);
385
+ if (!session)
386
+ return notFound(res);
387
+ const engine = context.sessionManager.getEngine(session.engine);
388
+ if (engine && isInterruptibleEngine(engine) && engine.isAlive(params.id)) {
389
+ engine.kill(params.id, "Interrupted by user");
390
+ }
391
+ context.sessionManager.getQueue().clearQueue(session.sessionKey || session.sourceRef || session.id);
392
+ updateSession(params.id, { status: "idle", lastActivity: new Date().toISOString(), lastError: null });
393
+ context.emit("session:stopped", { sessionId: params.id });
394
+ return json(res, { status: "stopped", sessionId: params.id });
395
+ }
396
+ // POST /api/sessions/:id/reset — clear stuck session state (stale engine IDs, errors)
397
+ params = matchRoute("/api/sessions/:id/reset", pathname);
398
+ if (method === "POST" && params) {
399
+ const session = getSession(params.id);
400
+ if (!session)
401
+ return notFound(res);
402
+ const engine = context.sessionManager.getEngine(session.engine);
403
+ if (engine && isInterruptibleEngine(engine) && engine.isAlive(params.id)) {
404
+ engine.kill(params.id, "Interrupted by reset");
405
+ }
406
+ context.sessionManager.getQueue().clearQueue(session.sessionKey || session.sourceRef || session.id);
407
+ const meta = { ...(session.transportMeta || {}) };
408
+ delete meta["engineSessions"];
409
+ delete meta["engineOverride"];
410
+ updateSession(params.id, {
411
+ status: "idle",
412
+ engineSessionId: null,
413
+ lastActivity: new Date().toISOString(),
414
+ lastError: null,
415
+ transportMeta: meta,
416
+ });
417
+ logger.info(`Session ${params.id} reset via API (cleared engineSessions, engineOverride, engineSessionId, lastError)`);
418
+ context.emit("session:updated", { sessionId: params.id });
419
+ return json(res, { status: "reset", sessionId: params.id });
420
+ }
421
+ // POST /api/sessions/:id/duplicate — duplicate a session (snapshot fork)
422
+ params = matchRoute("/api/sessions/:id/duplicate", pathname);
423
+ if (method === "POST" && params) {
424
+ const source = getSession(params.id);
425
+ if (!source)
426
+ return notFound(res);
427
+ if (!source.engineSessionId) {
428
+ res.writeHead(400, { "Content-Type": "application/json" });
429
+ res.end(JSON.stringify({ error: "Session has no engine session ID — cannot duplicate" }));
430
+ return;
431
+ }
432
+ let newSessionId = null;
433
+ try {
434
+ // 1. Duplicate session + messages in the registry
435
+ const { session: newSession, messageCount } = duplicateSession(params.id);
436
+ newSessionId = newSession.id;
437
+ // 2. Fork the engine session (Claude/Codex/Gemini)
438
+ const forkResult = forkEngineSession(source.engine, source.engineSessionId, JINN_HOME);
439
+ // 3. Store the new engine session ID
440
+ updateSession(newSession.id, { engineSessionId: forkResult.engineSessionId });
441
+ const result = getSession(newSession.id);
442
+ logger.info(`Session duplicated: ${params.id} → ${newSession.id} (engine: ${forkResult.engineSessionId}, ${messageCount} messages)`);
443
+ context.emit("session:created", { sessionId: newSession.id });
444
+ return json(res, serializeSession(result, context));
445
+ }
446
+ catch (err) {
447
+ // Clean up orphaned session if the engine fork failed after DB insert
448
+ if (newSessionId) {
449
+ try {
450
+ deleteSession(newSessionId);
451
+ }
452
+ catch { /* best effort */ }
453
+ }
454
+ logger.error(`Failed to duplicate session ${params.id}: ${err.message}`);
455
+ res.writeHead(500, { "Content-Type": "application/json" });
456
+ res.end(JSON.stringify({ error: `Duplicate failed: ${err.message}` }));
457
+ return;
458
+ }
459
+ }
460
+ // DELETE /api/sessions/:id/queue/:itemId — cancel specific item
461
+ const queueItemParams = matchRoute("/api/sessions/:id/queue/:itemId", pathname);
462
+ if (method === "DELETE" && queueItemParams) {
463
+ const session = getSession(queueItemParams.id);
464
+ if (!session)
465
+ return notFound(res);
466
+ const cancelled = cancelQueueItem(queueItemParams.itemId);
467
+ if (!cancelled) {
468
+ res.writeHead(409, { "Content-Type": "application/json" });
469
+ res.end(JSON.stringify({ error: "Item not found or already running" }));
470
+ return;
471
+ }
472
+ context.emit("queue:updated", { sessionId: queueItemParams.id, sessionKey: session.sessionKey });
473
+ return json(res, { status: "cancelled", itemId: queueItemParams.itemId });
474
+ }
475
+ // GET /api/sessions/:id/queue
476
+ params = matchRoute("/api/sessions/:id/queue", pathname);
477
+ if (method === "GET" && params) {
478
+ const session = getSession(params.id);
479
+ if (!session)
480
+ return notFound(res);
481
+ const items = getQueueItems(session.sessionKey || session.sourceRef || session.id);
482
+ return json(res, items);
483
+ }
484
+ // DELETE /api/sessions/:id/queue — clear all pending
485
+ params = matchRoute("/api/sessions/:id/queue", pathname);
486
+ if (method === "DELETE" && params) {
487
+ const session = getSession(params.id);
488
+ if (!session)
489
+ return notFound(res);
490
+ const sessionKey = session.sessionKey || session.sourceRef || session.id;
491
+ context.sessionManager.getQueue().clearQueue(sessionKey);
492
+ const cancelled = cancelAllPendingQueueItems(sessionKey);
493
+ context.emit("queue:updated", { sessionId: params.id, sessionKey, depth: 0 });
494
+ return json(res, { status: "cleared", cancelled });
495
+ }
496
+ // POST /api/sessions/:id/queue/pause
497
+ params = matchRoute("/api/sessions/:id/queue/pause", pathname);
498
+ if (method === "POST" && params) {
499
+ const session = getSession(params.id);
500
+ if (!session)
501
+ return notFound(res);
502
+ const sessionKey = session.sessionKey || session.sourceRef || session.id;
503
+ context.sessionManager.getQueue().pauseQueue(sessionKey);
504
+ context.emit("queue:updated", { sessionId: params.id, sessionKey, paused: true });
505
+ return json(res, { status: "paused", sessionId: params.id });
506
+ }
507
+ // POST /api/sessions/:id/queue/resume
508
+ params = matchRoute("/api/sessions/:id/queue/resume", pathname);
509
+ if (method === "POST" && params) {
510
+ const session = getSession(params.id);
511
+ if (!session)
512
+ return notFound(res);
513
+ const sessionKey = session.sessionKey || session.sourceRef || session.id;
514
+ context.sessionManager.getQueue().resumeQueue(sessionKey);
515
+ context.emit("queue:updated", { sessionId: params.id, sessionKey, paused: false });
516
+ return json(res, { status: "resumed", sessionId: params.id });
517
+ }
518
+ // POST /api/sessions/bulk-delete
519
+ if (method === "POST" && pathname === "/api/sessions/bulk-delete") {
520
+ const _parsed = await readJsonBody(req, res);
521
+ if (!_parsed.ok)
522
+ return;
523
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
524
+ const body = _parsed.body;
525
+ const ids = body.ids;
526
+ if (!Array.isArray(ids) || ids.length === 0)
527
+ return badRequest(res, "ids array is required");
528
+ // Kill any live engine processes before deleting
529
+ for (const id of ids) {
530
+ const session = getSession(id);
531
+ if (!session)
532
+ continue;
533
+ const engine = context.sessionManager.getEngine(session.engine);
534
+ if (engine && isInterruptibleEngine(engine) && engine.isAlive(id)) {
535
+ engine.kill(id);
536
+ }
537
+ }
538
+ const count = deleteSessions(ids);
539
+ for (const id of ids) {
540
+ context.emit("session:deleted", { sessionId: id });
541
+ }
542
+ logger.info(`Bulk deleted ${count} sessions`);
543
+ return json(res, { status: "deleted", count });
544
+ }
545
+ // GET /api/sessions/:id/children
546
+ params = matchRoute("/api/sessions/:id/children", pathname);
547
+ if (method === "GET" && params) {
548
+ const children = listSessions().filter((s) => s.parentSessionId === params.id);
549
+ return json(res, children.map((child) => serializeSession(child, context)));
550
+ }
551
+ // GET /api/sessions/:id/transcript — return raw Claude Code session transcript
552
+ params = matchRoute("/api/sessions/:id/transcript", pathname);
553
+ if (method === "GET" && params) {
554
+ const session = getSession(params.id);
555
+ if (!session)
556
+ return notFound(res);
557
+ if (!session.engineSessionId)
558
+ return json(res, []);
559
+ const entries = loadRawTranscript(session.engineSessionId);
560
+ return json(res, entries);
561
+ }
562
+ // POST /api/sessions/stub — create a session with a pre-populated assistant
563
+ // message but do NOT run the engine. Used for lazy onboarding.
564
+ if (method === "POST" && pathname === "/api/sessions/stub") {
565
+ const _parsed = await readJsonBody(req, res);
566
+ if (!_parsed.ok)
567
+ return;
568
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
569
+ const body = _parsed.body;
570
+ const greeting = body.greeting || "Hey! Say hi when you're ready to get started.";
571
+ const config = context.getConfig();
572
+ const engineName = body.engine || config.engines.default;
573
+ const sessionKey = `web:${Date.now()}`;
574
+ const session = createSession({
575
+ engine: engineName,
576
+ source: "web",
577
+ sourceRef: sessionKey,
578
+ connector: "web",
579
+ sessionKey,
580
+ replyContext: { source: "web" },
581
+ employee: body.employee,
582
+ title: body.title,
583
+ portalName: config.portal?.portalName,
584
+ });
585
+ insertMessage(session.id, "assistant", greeting);
586
+ logger.info(`Stub session created: ${session.id}`);
587
+ return json(res, serializeSession(session, context), 201);
588
+ }
589
+ // POST /api/sessions
590
+ if (method === "POST" && pathname === "/api/sessions") {
591
+ const _parsed = await readJsonBody(req, res);
592
+ if (!_parsed.ok)
593
+ return;
594
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
595
+ const body = _parsed.body;
596
+ const prompt = body.prompt || body.message;
597
+ if (!prompt)
598
+ return badRequest(res, "prompt or message is required");
599
+ const config = context.getConfig();
600
+ const engineName = body.engine || config.engines.default;
601
+ const sessionKey = `web:${Date.now()}`;
602
+ const session = createSession({
603
+ engine: engineName,
604
+ source: "web",
605
+ sourceRef: sessionKey,
606
+ connector: "web",
607
+ sessionKey,
608
+ replyContext: { source: "web" },
609
+ employee: body.employee,
610
+ parentSessionId: body.parentSessionId,
611
+ effortLevel: body.effortLevel,
612
+ prompt,
613
+ portalName: config.portal?.portalName,
614
+ });
615
+ logger.info(`Web session created: ${session.id}`);
616
+ insertMessage(session.id, "user", prompt);
617
+ // Run engine asynchronously — respond immediately, push result via WebSocket
618
+ const engine = context.sessionManager.getEngine(engineName);
619
+ if (!engine) {
620
+ updateSession(session.id, {
621
+ status: "error",
622
+ lastError: `Engine "${engineName}" not available`,
623
+ });
624
+ return json(res, { ...serializeSession({ ...session, status: "error", lastError: `Engine "${engineName}" not available` }, context) }, 201);
625
+ }
626
+ // Set status to "running" synchronously BEFORE returning the response.
627
+ // This prevents a race condition where the caller polls immediately and
628
+ // sees "idle" status before runWebSession has a chance to set "running".
629
+ updateSession(session.id, {
630
+ status: "running",
631
+ lastActivity: new Date().toISOString(),
632
+ });
633
+ session.status = "running";
634
+ const attachmentPaths = resolveAttachmentPaths(body.attachments);
635
+ const queueSessionKey = session.sessionKey || session.sourceRef || session.id;
636
+ const queueItemId = enqueueQueueItem(session.id, queueSessionKey, prompt);
637
+ context.emit("queue:updated", { sessionId: session.id, sessionKey: queueSessionKey });
638
+ dispatchWebSessionRun(session, prompt, engine, config, context, { queueItemId, attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined });
639
+ return json(res, serializeSession(session, context), 201);
640
+ }
641
+ // POST /api/sessions/:id/message
642
+ params = matchRoute("/api/sessions/:id/message", pathname);
643
+ if (method === "POST" && params) {
644
+ let session = getSession(params.id);
645
+ if (!session)
646
+ return notFound(res);
647
+ session = maybeRevertEngineOverride(session);
648
+ const _parsed = await readJsonBody(req, res);
649
+ if (!_parsed.ok)
650
+ return;
651
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
652
+ const body = _parsed.body;
653
+ const prompt = body.message || body.prompt;
654
+ if (!prompt)
655
+ return badRequest(res, "message is required");
656
+ // Allow internal callers (e.g. child session callbacks) to specify a non-user role
657
+ const messageRole = body.role === "notification" ? "notification" : "user";
658
+ const isNotification = messageRole === "notification";
659
+ const config = context.getConfig();
660
+ const engine = context.sessionManager.getEngine(session.engine);
661
+ if (!engine)
662
+ return serverError(res, `Engine "${session.engine}" not available`);
663
+ // Persist the message immediately
664
+ insertMessage(session.id, messageRole, prompt);
665
+ // Emit notification event for UI display (renders as system banner, not user bubble)
666
+ if (isNotification) {
667
+ context.emit("session:notification", { sessionId: session.id, message: prompt });
668
+ // Don't return early — fall through to enqueue + dispatch so the engine
669
+ // (e.g. the COO) actually processes the notification and can respond.
670
+ }
671
+ if (!isNotification && session.status === "waiting") {
672
+ const expectedResetAt = getClaudeExpectedResetAt();
673
+ const resumeText = expectedResetAt
674
+ ? expectedResetAt.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
675
+ : null;
676
+ const queuedText = `⏳ Still paused due to Claude usage limit${resumeText ? ` (resets ${resumeText})` : ""}. Your message is queued and will run automatically.`;
677
+ insertMessage(session.id, "notification", queuedText);
678
+ context.emit("session:notification", { sessionId: session.id, message: queuedText });
679
+ }
680
+ // If a turn is already running, check whether we should interrupt or queue.
681
+ // Notifications (child completion callbacks) should never interrupt — just queue.
682
+ if (session.status === "running") {
683
+ if (!isNotification && (config.sessions?.interruptOnNewMessage ?? true) && isInterruptibleEngine(engine) && engine.isAlive(session.id)) {
684
+ logger.info(`Interrupting running session ${session.id} for new message`);
685
+ engine.kill(session.id, "Interrupted: new message received");
686
+ // Wait briefly for the process to exit so the queue slot frees up
687
+ await new Promise((resolve) => setTimeout(resolve, 500));
688
+ context.emit("session:interrupted", { sessionId: session.id, reason: "new message" });
689
+ }
690
+ else {
691
+ context.emit("session:queued", { sessionId: session.id, message: prompt });
692
+ }
693
+ }
694
+ // If session was interrupted by a restart, clear the error and resume
695
+ if (session.status === "interrupted") {
696
+ logger.info(`Resuming interrupted session ${session.id} (engineSessionId: ${session.engineSessionId})`);
697
+ updateSession(session.id, {
698
+ status: "running",
699
+ lastActivity: new Date().toISOString(),
700
+ lastError: null,
701
+ });
702
+ context.emit("session:resumed", { sessionId: session.id });
703
+ }
704
+ // Clear any pending cancellation so the new message runs normally.
705
+ context.sessionManager.getQueue().clearCancelled(session.sessionKey || session.sourceRef || session.id);
706
+ const attachmentPaths = resolveAttachmentPaths(body.attachments);
707
+ const sessionKey = session.sessionKey || session.sourceRef || session.id;
708
+ const queueItemId = enqueueQueueItem(session.id, sessionKey, prompt);
709
+ context.emit("queue:updated", { sessionId: session.id, sessionKey });
710
+ dispatchWebSessionRun(session, prompt, engine, config, context, { queueItemId, attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined });
711
+ return json(res, { status: "queued", sessionId: session.id });
712
+ }
713
+ // GET /api/cron
714
+ if (method === "GET" && pathname === "/api/cron") {
715
+ const jobs = loadJobs();
716
+ // Enrich with last run status
717
+ const enriched = jobs.map((job) => {
718
+ const runFile = path.join(CRON_RUNS, `${job.id}.jsonl`);
719
+ let lastRun = null;
720
+ if (fs.existsSync(runFile)) {
721
+ const lines = fs.readFileSync(runFile, "utf-8").trim().split("\n").filter(Boolean);
722
+ if (lines.length > 0) {
723
+ try {
724
+ lastRun = JSON.parse(lines[lines.length - 1]);
725
+ }
726
+ catch { }
727
+ }
728
+ }
729
+ return { ...job, lastRun };
730
+ });
731
+ return json(res, enriched);
732
+ }
733
+ // GET /api/cron/:id/runs
734
+ params = matchRoute("/api/cron/:id/runs", pathname);
735
+ if (method === "GET" && params) {
736
+ const runFile = path.join(CRON_RUNS, `${params.id}.jsonl`);
737
+ if (!fs.existsSync(runFile))
738
+ return json(res, []);
739
+ const lines = fs
740
+ .readFileSync(runFile, "utf-8")
741
+ .trim()
742
+ .split("\n")
743
+ .filter(Boolean)
744
+ .map((l) => JSON.parse(l));
745
+ return json(res, lines);
746
+ }
747
+ // POST /api/cron — create new cron job
748
+ if (method === "POST" && pathname === "/api/cron") {
749
+ const _parsed = await readJsonBody(req, res);
750
+ if (!_parsed.ok)
751
+ return;
752
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
753
+ const body = _parsed.body;
754
+ const jobs = loadJobs();
755
+ const newJob = {
756
+ id: body.id || crypto.randomUUID(),
757
+ name: body.name || "untitled",
758
+ enabled: body.enabled ?? true,
759
+ schedule: body.schedule || "0 * * * *",
760
+ timezone: body.timezone,
761
+ engine: body.engine,
762
+ model: body.model,
763
+ employee: body.employee,
764
+ prompt: body.prompt || "",
765
+ delivery: body.delivery,
766
+ };
767
+ jobs.push(newJob);
768
+ saveJobs(jobs);
769
+ reloadScheduler(jobs);
770
+ return json(res, newJob, 201);
771
+ }
772
+ // PUT /api/cron/:id
773
+ params = matchRoute("/api/cron/:id", pathname);
774
+ if (method === "PUT" && params) {
775
+ const jobs = loadJobs();
776
+ const idx = jobs.findIndex((j) => j.id === params.id);
777
+ if (idx === -1)
778
+ return notFound(res);
779
+ const _parsed = await readJsonBody(req, res);
780
+ if (!_parsed.ok)
781
+ return;
782
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
783
+ const body = _parsed.body;
784
+ jobs[idx] = { ...jobs[idx], ...body, id: params.id };
785
+ saveJobs(jobs);
786
+ reloadScheduler(jobs);
787
+ return json(res, jobs[idx]);
788
+ }
789
+ // DELETE /api/cron/:id
790
+ params = matchRoute("/api/cron/:id", pathname);
791
+ if (method === "DELETE" && params) {
792
+ const jobs = loadJobs();
793
+ const idx = jobs.findIndex((j) => j.id === params.id);
794
+ if (idx === -1)
795
+ return notFound(res);
796
+ const removed = jobs.splice(idx, 1)[0];
797
+ saveJobs(jobs);
798
+ reloadScheduler(jobs);
799
+ return json(res, { deleted: removed.id, name: removed.name });
800
+ }
801
+ // POST /api/cron/:id/trigger — manually run a cron job now
802
+ params = matchRoute("/api/cron/:id/trigger", pathname);
803
+ if (method === "POST" && params) {
804
+ const jobs = loadJobs();
805
+ const job = jobs.find((j) => j.id === params.id);
806
+ if (!job)
807
+ return notFound(res);
808
+ logger.info(`Manual trigger for cron job "${job.name}" (${job.id})`);
809
+ // Fire and forget — respond immediately, run in background
810
+ runCronJob(job, context.sessionManager, context.getConfig(), context.connectors).catch((err) => logger.error(`Manual cron trigger failed for "${job.name}": ${err}`));
811
+ return json(res, {
812
+ triggered: true,
813
+ jobId: job.id,
814
+ name: job.name,
815
+ employee: job.employee,
816
+ message: `Cron job "${job.name}" triggered manually`,
817
+ });
818
+ }
819
+ // GET /api/org
820
+ if (method === "GET" && pathname === "/api/org") {
821
+ if (!fs.existsSync(ORG_DIR))
822
+ return json(res, { departments: [], employees: [], hierarchy: { root: null, sorted: [], warnings: [] } });
823
+ const entries = fs.readdirSync(ORG_DIR, { withFileTypes: true });
824
+ const departments = entries
825
+ .filter((e) => e.isDirectory())
826
+ .map((e) => e.name);
827
+ const { scanOrg } = await import("./org.js");
828
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
829
+ const orgRegistry = scanOrg();
830
+ const hierarchy = resolveOrgHierarchy(orgRegistry);
831
+ const employees = hierarchy.sorted.map((name) => {
832
+ const node = hierarchy.nodes[name];
833
+ const emp = node.employee;
834
+ const { persona, ...rest } = emp;
835
+ return {
836
+ ...rest,
837
+ parentName: node.parentName,
838
+ directReports: node.directReports,
839
+ depth: node.depth,
840
+ chain: node.chain,
841
+ };
842
+ });
843
+ return json(res, {
844
+ departments,
845
+ employees,
846
+ hierarchy: {
847
+ root: hierarchy.root,
848
+ sorted: hierarchy.sorted,
849
+ warnings: hierarchy.warnings,
850
+ },
851
+ });
852
+ }
853
+ // GET /api/org/employees/:name
854
+ params = matchRoute("/api/org/employees/:name", pathname);
855
+ if (method === "GET" && params) {
856
+ const { scanOrg } = await import("./org.js");
857
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
858
+ const orgRegistry = scanOrg();
859
+ const emp = orgRegistry.get(params.name);
860
+ if (!emp)
861
+ return notFound(res);
862
+ const hierarchy = resolveOrgHierarchy(orgRegistry);
863
+ const node = hierarchy.nodes[params.name];
864
+ return json(res, {
865
+ ...emp,
866
+ parentName: node?.parentName ?? null,
867
+ directReports: node?.directReports ?? [],
868
+ depth: node?.depth ?? 0,
869
+ chain: node?.chain ?? [params.name],
870
+ });
871
+ }
872
+ // PATCH /api/org/employees/:name — update employee fields (currently only alwaysNotify)
873
+ params = matchRoute("/api/org/employees/:name", pathname);
874
+ if (method === "PATCH" && params) {
875
+ const _parsed = await readJsonBody(req, res);
876
+ if (!_parsed.ok)
877
+ return;
878
+ const body = _parsed.body;
879
+ const { updateEmployeeYaml } = await import("./org.js");
880
+ const updated = updateEmployeeYaml(params.name, {
881
+ alwaysNotify: typeof body.alwaysNotify === "boolean" ? body.alwaysNotify : undefined,
882
+ });
883
+ if (!updated)
884
+ return notFound(res);
885
+ context.emit("org:updated", { employee: params.name });
886
+ return json(res, { status: "ok" });
887
+ }
888
+ // GET /api/org/services — list all cross-department services
889
+ if (method === "GET" && pathname === "/api/org/services") {
890
+ const { scanOrg } = await import("./org.js");
891
+ const { buildServiceRegistry } = await import("./services.js");
892
+ const orgRegistry = scanOrg();
893
+ const services = buildServiceRegistry(orgRegistry);
894
+ const result = Array.from(services.values()).map((entry) => ({
895
+ name: entry.declaration.name,
896
+ description: entry.declaration.description,
897
+ provider: {
898
+ name: entry.provider.name,
899
+ displayName: entry.provider.displayName,
900
+ department: entry.provider.department,
901
+ rank: entry.provider.rank,
902
+ },
903
+ }));
904
+ return json(res, { services: result });
905
+ }
906
+ // POST /api/org/cross-request — route a service request to the provider
907
+ if (method === "POST" && pathname === "/api/org/cross-request") {
908
+ const parsed = await readJsonBody(req, res);
909
+ if (!parsed.ok)
910
+ return;
911
+ const body = parsed.body;
912
+ const { fromEmployee, service, prompt, parentSessionId } = body;
913
+ if (!fromEmployee || !service || !prompt) {
914
+ return badRequest(res, "Missing required fields: fromEmployee, service, prompt");
915
+ }
916
+ const { scanOrg } = await import("./org.js");
917
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
918
+ const { buildServiceRegistry, buildRoutePath, resolveManagerChain } = await import("./services.js");
919
+ const orgRegistry = scanOrg();
920
+ const requester = orgRegistry.get(fromEmployee);
921
+ if (!requester)
922
+ return notFound(res);
923
+ const services = buildServiceRegistry(orgRegistry);
924
+ const entry = services.get(service);
925
+ if (!entry) {
926
+ return json(res, { error: `Service "${service}" not found` }, 404);
927
+ }
928
+ const hierarchy = resolveOrgHierarchy(orgRegistry);
929
+ const route = buildRoutePath(fromEmployee, entry.provider.name, hierarchy);
930
+ const managers = resolveManagerChain(route, hierarchy);
931
+ const crossBrief = `## Cross-service request
932
+
933
+ **From**: ${requester.displayName} (${requester.department})
934
+ **Service**: ${service} — ${entry.declaration.description}
935
+
936
+ ### Request
937
+ ${prompt}
938
+
939
+ ---
940
+ Handle this as a priority request from a colleague.`;
941
+ const config = context.getConfig();
942
+ const session = createSession({
943
+ engine: entry.provider.engine || config.engines.default,
944
+ model: entry.provider.model || undefined,
945
+ source: "cross-request",
946
+ sourceRef: `cross:${fromEmployee}:${service}`,
947
+ connector: "web",
948
+ sessionKey: `cross:${Date.now()}`,
949
+ replyContext: { source: "cross-request" },
950
+ employee: entry.provider.name,
951
+ parentSessionId: parentSessionId || undefined,
952
+ prompt: crossBrief,
953
+ portalName: config.portal?.portalName,
954
+ title: `Cross-request: ${fromEmployee} → ${service}`,
955
+ });
956
+ insertMessage(session.id, "user", crossBrief);
957
+ logger.info(`Cross-request session created: ${session.id} (${fromEmployee} → ${service} → ${entry.provider.name})`);
958
+ return json(res, {
959
+ sessionId: session.id,
960
+ provider: {
961
+ name: entry.provider.name,
962
+ displayName: entry.provider.displayName,
963
+ department: entry.provider.department,
964
+ },
965
+ route,
966
+ managers: managers.map((m) => m.employee.name),
967
+ service,
968
+ }, 201);
969
+ }
970
+ // GET /api/org/departments/:name/board
971
+ params = matchRoute("/api/org/departments/:name/board", pathname);
972
+ if (method === "GET" && params) {
973
+ const boardPath = path.join(ORG_DIR, params.name, "board.json");
974
+ if (!fs.existsSync(boardPath))
975
+ return notFound(res);
976
+ const board = JSON.parse(fs.readFileSync(boardPath, "utf-8"));
977
+ return json(res, board);
978
+ }
979
+ // PUT /api/org/departments/:name/board
980
+ if (method === "PUT" && matchRoute("/api/org/departments/:name/board", pathname)) {
981
+ const p = matchRoute("/api/org/departments/:name/board", pathname);
982
+ const boardPath = path.join(ORG_DIR, p.name, "board.json");
983
+ const deptDir = path.join(ORG_DIR, p.name);
984
+ if (!fs.existsSync(deptDir))
985
+ return notFound(res);
986
+ const _parsed = await readJsonBody(req, res);
987
+ if (!_parsed.ok)
988
+ return;
989
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
990
+ const body = _parsed.body;
991
+ fs.writeFileSync(boardPath, JSON.stringify(body, null, 2));
992
+ context.emit("board:updated", { department: p.name });
993
+ return json(res, { status: "ok" });
994
+ }
995
+ // GET /api/skills/search?q=<query> — search the skills.sh registry
996
+ if (method === "GET" && pathname === "/api/skills/search") {
997
+ const query = url.searchParams.get("q") || "";
998
+ if (!query)
999
+ return badRequest(res, "q parameter is required");
1000
+ try {
1001
+ const { execFileSync } = await import("node:child_process");
1002
+ const output = execFileSync("npx", ["skills", "find", query], {
1003
+ encoding: "utf-8",
1004
+ timeout: 30000,
1005
+ });
1006
+ const results = parseSkillsSearchOutput(output);
1007
+ return json(res, results);
1008
+ }
1009
+ catch (err) {
1010
+ const msg = err instanceof Error ? err.stderr || err.message : String(err);
1011
+ return json(res, { results: [], error: msg });
1012
+ }
1013
+ }
1014
+ // GET /api/skills/manifest — return skills.json contents
1015
+ if (method === "GET" && pathname === "/api/skills/manifest") {
1016
+ const { readManifest } = await import("../cli/skills.js");
1017
+ return json(res, readManifest());
1018
+ }
1019
+ // POST /api/skills/install — install a skill from skills.sh
1020
+ if (method === "POST" && pathname === "/api/skills/install") {
1021
+ const _parsed = await readJsonBody(req, res);
1022
+ if (!_parsed.ok)
1023
+ return;
1024
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1025
+ const body = _parsed.body;
1026
+ const source = body.source;
1027
+ if (!source)
1028
+ return badRequest(res, "source is required");
1029
+ try {
1030
+ const { snapshotDirs, diffSnapshots, copySkillToInstance, upsertManifest, extractSkillName, findExistingSkill, } = await import("../cli/skills.js");
1031
+ const { execFileSync } = await import("node:child_process");
1032
+ const before = snapshotDirs();
1033
+ execFileSync("npx", ["skills", "add", String(source), "-g", "-y"], {
1034
+ encoding: "utf-8",
1035
+ timeout: 60000,
1036
+ });
1037
+ const after = snapshotDirs();
1038
+ const newDirs = diffSnapshots(before, after);
1039
+ let skillName;
1040
+ if (newDirs.length > 0) {
1041
+ const installed = newDirs[0];
1042
+ skillName = installed.name;
1043
+ copySkillToInstance(installed.name, path.join(installed.dir, installed.name));
1044
+ }
1045
+ else {
1046
+ skillName = extractSkillName(source);
1047
+ const existing = findExistingSkill(skillName);
1048
+ if (existing) {
1049
+ copySkillToInstance(existing.name, existing.dir);
1050
+ }
1051
+ else {
1052
+ return serverError(res, "Skill installed globally but could not locate the directory");
1053
+ }
1054
+ }
1055
+ upsertManifest(skillName, source);
1056
+ return json(res, { status: "installed", name: skillName });
1057
+ }
1058
+ catch (err) {
1059
+ const msg = err instanceof Error ? err.message : String(err);
1060
+ return serverError(res, msg);
1061
+ }
1062
+ }
1063
+ // GET /api/skills
1064
+ if (method === "GET" && pathname === "/api/skills") {
1065
+ if (!fs.existsSync(SKILLS_DIR))
1066
+ return json(res, []);
1067
+ const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
1068
+ const skills = entries.filter((e) => e.isDirectory()).map((e) => {
1069
+ const skillMdPath = path.join(SKILLS_DIR, e.name, "SKILL.md");
1070
+ let description = "";
1071
+ if (fs.existsSync(skillMdPath)) {
1072
+ const content = fs.readFileSync(skillMdPath, "utf-8");
1073
+ // Extract description from YAML frontmatter, ## Trigger section, or first paragraph
1074
+ const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
1075
+ if (frontmatterMatch) {
1076
+ const descMatch = frontmatterMatch[1].match(/^description:\s*(.+)$/m);
1077
+ if (descMatch) {
1078
+ description = descMatch[1].trim();
1079
+ }
1080
+ }
1081
+ if (!description) {
1082
+ const triggerMatch = content.match(/##\s*Trigger\s*\n+([^\n#]+)/);
1083
+ if (triggerMatch) {
1084
+ description = triggerMatch[1].trim();
1085
+ }
1086
+ else {
1087
+ // Use first non-heading, non-empty, non-frontmatter line
1088
+ const bodyContent = frontmatterMatch ? content.slice(frontmatterMatch[0].length) : content;
1089
+ const lines = bodyContent.split("\n");
1090
+ for (const line of lines) {
1091
+ const trimmed = line.trim();
1092
+ if (trimmed && !trimmed.startsWith("#")) {
1093
+ description = trimmed;
1094
+ break;
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ return { name: e.name, description };
1101
+ });
1102
+ return json(res, skills);
1103
+ }
1104
+ // GET /api/skills/:name
1105
+ params = matchRoute("/api/skills/:name", pathname);
1106
+ if (method === "GET" && params) {
1107
+ const skillMd = path.join(SKILLS_DIR, params.name, "SKILL.md");
1108
+ if (!fs.existsSync(skillMd))
1109
+ return notFound(res);
1110
+ const content = fs.readFileSync(skillMd, "utf-8");
1111
+ return json(res, { name: params.name, content });
1112
+ }
1113
+ // DELETE /api/skills/:name — remove a skill
1114
+ params = matchRoute("/api/skills/:name", pathname);
1115
+ if (method === "DELETE" && params) {
1116
+ const skillDir = path.join(SKILLS_DIR, params.name);
1117
+ if (!fs.existsSync(skillDir))
1118
+ return notFound(res);
1119
+ fs.rmSync(skillDir, { recursive: true, force: true });
1120
+ const { removeFromManifest } = await import("../cli/skills.js");
1121
+ removeFromManifest(params.name);
1122
+ logger.info(`Skill removed via API: ${params.name}`);
1123
+ return json(res, { status: "removed", name: params.name });
1124
+ }
1125
+ // GET /api/config
1126
+ if (method === "GET" && pathname === "/api/config") {
1127
+ const config = context.getConfig();
1128
+ // Sanitize: remove any secrets/tokens from connectors
1129
+ const rawConnectors = config.connectors || {};
1130
+ const sanitizedConnectors = {};
1131
+ for (const [k, v] of Object.entries(rawConnectors)) {
1132
+ if (k === "instances" && Array.isArray(v)) {
1133
+ sanitizedConnectors.instances = v.map((inst) => ({
1134
+ ...inst,
1135
+ token: inst?.token ? "***" : undefined,
1136
+ signingSecret: inst?.signingSecret ? "***" : undefined,
1137
+ botToken: inst?.botToken ? "***" : undefined,
1138
+ appToken: inst?.appToken ? "***" : undefined,
1139
+ }));
1140
+ }
1141
+ else if (v && typeof v === "object") {
1142
+ sanitizedConnectors[k] = {
1143
+ ...v,
1144
+ token: v?.token ? "***" : undefined,
1145
+ signingSecret: v?.signingSecret ? "***" : undefined,
1146
+ botToken: v?.botToken ? "***" : undefined,
1147
+ appToken: v?.appToken ? "***" : undefined,
1148
+ };
1149
+ }
1150
+ else {
1151
+ sanitizedConnectors[k] = v;
1152
+ }
1153
+ }
1154
+ const sanitized = {
1155
+ ...config,
1156
+ connectors: sanitizedConnectors,
1157
+ };
1158
+ return json(res, sanitized);
1159
+ }
1160
+ // PUT /api/config
1161
+ if (method === "PUT" && pathname === "/api/config") {
1162
+ const _parsed = await readJsonBody(req, res);
1163
+ if (!_parsed.ok)
1164
+ return;
1165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1166
+ const body = _parsed.body;
1167
+ // Basic validation: must be a plain object
1168
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1169
+ return badRequest(res, "Config must be a JSON object");
1170
+ }
1171
+ // Validate known top-level keys
1172
+ // Keep this aligned with `JinnConfig` in src/shared/types.ts
1173
+ const KNOWN_KEYS = [
1174
+ "jinn",
1175
+ "gateway",
1176
+ "engines",
1177
+ "connectors",
1178
+ "logging",
1179
+ "mcp",
1180
+ "sessions",
1181
+ "cron",
1182
+ "notifications",
1183
+ "portal",
1184
+ "context",
1185
+ "stt",
1186
+ "skills",
1187
+ "remotes",
1188
+ ];
1189
+ const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.includes(k));
1190
+ if (unknownKeys.length > 0) {
1191
+ return badRequest(res, `Unknown config keys: ${unknownKeys.join(", ")}`);
1192
+ }
1193
+ // Validate critical field types
1194
+ if (body.gateway !== undefined) {
1195
+ if (typeof body.gateway !== "object" || Array.isArray(body.gateway)) {
1196
+ return badRequest(res, "gateway must be an object");
1197
+ }
1198
+ if (body.gateway.port !== undefined && typeof body.gateway.port !== "number") {
1199
+ return badRequest(res, "gateway.port must be a number");
1200
+ }
1201
+ }
1202
+ if (body.engines !== undefined && (typeof body.engines !== "object" || Array.isArray(body.engines))) {
1203
+ return badRequest(res, "engines must be an object");
1204
+ }
1205
+ // Deep-merge incoming config with existing config to preserve
1206
+ // fields not included in the update (e.g. connector tokens).
1207
+ let existing = {};
1208
+ try {
1209
+ existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
1210
+ }
1211
+ catch { /* start fresh if unreadable */ }
1212
+ const merged = deepMerge(existing, body);
1213
+ const yamlStr = yaml.dump(merged);
1214
+ fs.writeFileSync(CONFIG_PATH, yamlStr);
1215
+ logger.info("Config updated via API");
1216
+ return json(res, { status: "ok" });
1217
+ }
1218
+ // GET /api/logs
1219
+ if (method === "GET" && pathname === "/api/logs") {
1220
+ const logFile = path.join(LOGS_DIR, "gateway.log");
1221
+ if (!fs.existsSync(logFile))
1222
+ return json(res, { lines: [] });
1223
+ const n = parseInt(url.searchParams.get("n") || "100", 10);
1224
+ // Read only the last 64KB to avoid loading the entire file into memory
1225
+ const MAX_BYTES = 64 * 1024;
1226
+ const stat = fs.statSync(logFile);
1227
+ const readSize = Math.min(stat.size, MAX_BYTES);
1228
+ const fd = fs.openSync(logFile, "r");
1229
+ const buf = Buffer.alloc(readSize);
1230
+ fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
1231
+ fs.closeSync(fd);
1232
+ const allLines = buf.toString("utf-8").split("\n").filter(Boolean);
1233
+ const lines = allLines.slice(-n);
1234
+ return json(res, { lines });
1235
+ }
1236
+ // POST /api/connectors/reload — stop all instance connectors and restart from config
1237
+ if (method === "POST" && pathname === "/api/connectors/reload") {
1238
+ if (!context.reloadConnectorInstances) {
1239
+ return json(res, { error: "Connector reload not available" }, 501);
1240
+ }
1241
+ try {
1242
+ const result = await context.reloadConnectorInstances();
1243
+ context.emit("connectors:reloaded", result);
1244
+ return json(res, result);
1245
+ }
1246
+ catch (err) {
1247
+ return json(res, { error: err instanceof Error ? err.message : String(err) }, 500);
1248
+ }
1249
+ }
1250
+ // POST /api/connectors/:id/incoming — receive proxied Discord messages from primary instance
1251
+ // Supports both the legacy /api/connectors/discord/incoming and named instance ids
1252
+ params = matchRoute("/api/connectors/:id/incoming", pathname);
1253
+ if (method === "POST" && params && params.id) {
1254
+ // Try the exact instance id first, then fall back to "discord" for the legacy path
1255
+ const connector = context.connectors.get(params.id) ?? (params.id === "discord" ? context.connectors.get("discord") : undefined);
1256
+ if (!connector)
1257
+ return notFound(res);
1258
+ if (!("deliverMessage" in connector)) {
1259
+ return json(res, { error: "Discord connector is not in remote mode" }, 400);
1260
+ }
1261
+ const _parsed = await readJsonBody(req, res);
1262
+ if (!_parsed.ok)
1263
+ return;
1264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1265
+ const body = _parsed.body;
1266
+ // Download attachments from Discord CDN URLs to local temp
1267
+ const { downloadAttachment } = await import("../connectors/discord/format.js");
1268
+ const attachments = await Promise.all((body.attachments || []).map(async (att) => {
1269
+ if (att.url) {
1270
+ try {
1271
+ const localPath = await downloadAttachment(att.url, TMP_DIR, att.name);
1272
+ return { name: att.name, url: att.url, mimeType: att.mimeType, localPath };
1273
+ }
1274
+ catch {
1275
+ return { name: att.name, url: att.url, mimeType: att.mimeType };
1276
+ }
1277
+ }
1278
+ return att;
1279
+ }));
1280
+ const incomingMsg = {
1281
+ connector: params.id,
1282
+ source: "discord",
1283
+ sessionKey: body.sessionKey,
1284
+ channel: body.channel,
1285
+ thread: body.thread,
1286
+ user: body.user,
1287
+ userId: body.userId,
1288
+ text: body.text,
1289
+ messageId: body.messageId,
1290
+ attachments,
1291
+ replyContext: body.replyContext || {},
1292
+ transportMeta: body.transportMeta,
1293
+ raw: body,
1294
+ };
1295
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1296
+ connector.deliverMessage(incomingMsg);
1297
+ return json(res, { status: "delivered" });
1298
+ }
1299
+ // POST /api/connectors/:id/proxy — proxy connector operations from remote instances
1300
+ // Supports both the legacy /api/connectors/discord/proxy and named instance ids
1301
+ params = matchRoute("/api/connectors/:id/proxy", pathname);
1302
+ if (method === "POST" && params && params.id) {
1303
+ const connector = context.connectors.get(params.id) ?? (params.id === "discord" ? context.connectors.get("discord") : undefined);
1304
+ if (!connector)
1305
+ return notFound(res);
1306
+ const _parsed = await readJsonBody(req, res);
1307
+ if (!_parsed.ok)
1308
+ return;
1309
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1310
+ const body = _parsed.body;
1311
+ const action = body.action;
1312
+ const target = body.target;
1313
+ let messageId;
1314
+ switch (action) {
1315
+ case "sendMessage":
1316
+ if (!target || !body.text)
1317
+ return badRequest(res, "target and text are required");
1318
+ messageId = (await connector.sendMessage(target, body.text));
1319
+ break;
1320
+ case "replyMessage":
1321
+ if (!target || !body.text)
1322
+ return badRequest(res, "target and text are required");
1323
+ messageId = (await connector.replyMessage(target, body.text));
1324
+ break;
1325
+ case "editMessage":
1326
+ if (!target || !body.text)
1327
+ return badRequest(res, "target and text are required");
1328
+ await connector.editMessage(target, body.text);
1329
+ break;
1330
+ case "addReaction":
1331
+ if (!target || !body.emoji)
1332
+ return badRequest(res, "target and emoji are required");
1333
+ await connector.addReaction(target, body.emoji);
1334
+ break;
1335
+ case "removeReaction":
1336
+ if (!target || !body.emoji)
1337
+ return badRequest(res, "target and emoji are required");
1338
+ await connector.removeReaction(target, body.emoji);
1339
+ break;
1340
+ case "setTypingStatus":
1341
+ if (connector.setTypingStatus) {
1342
+ await connector.setTypingStatus(body.channelId ?? "", body.threadTs, body.status ?? "");
1343
+ }
1344
+ break;
1345
+ default:
1346
+ return badRequest(res, `Unknown proxy action: ${action}`);
1347
+ }
1348
+ return json(res, { status: "ok", messageId });
1349
+ }
1350
+ // POST /api/connectors/:name/send — send a message via a connector
1351
+ params = matchRoute("/api/connectors/:name/send", pathname);
1352
+ if (method === "POST" && params) {
1353
+ const connector = context.connectors.get(params.name);
1354
+ if (!connector)
1355
+ return notFound(res);
1356
+ const _parsed = await readJsonBody(req, res);
1357
+ if (!_parsed.ok)
1358
+ return;
1359
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1360
+ const body = _parsed.body;
1361
+ if (!body.channel || !body.text)
1362
+ return badRequest(res, "channel and text are required");
1363
+ await connector.sendMessage({ channel: body.channel, thread: body.thread }, body.text);
1364
+ return json(res, { status: "sent" });
1365
+ }
1366
+ // GET /api/connectors/whatsapp/qr — return current QR code as PNG data URL
1367
+ if (method === "GET" && pathname === "/api/connectors/whatsapp/qr") {
1368
+ const waConnector = context.connectors.get("whatsapp");
1369
+ if (!waConnector)
1370
+ return notFound(res);
1371
+ const qrString = waConnector.getQrCode();
1372
+ if (!qrString)
1373
+ return json(res, { qr: null });
1374
+ const dataUrl = await QRCode.toDataURL(qrString, { width: 256, margin: 2 });
1375
+ return json(res, { qr: dataUrl });
1376
+ }
1377
+ // GET /api/connectors — list available connectors
1378
+ if (method === "GET" && pathname === "/api/connectors") {
1379
+ const connectors = Array.from(context.connectors.entries()).map(([instanceId, connector]) => ({
1380
+ name: connector.name,
1381
+ instanceId,
1382
+ employee: connector.getEmployee?.() ?? undefined,
1383
+ ...connector.getHealth(),
1384
+ }));
1385
+ return json(res, connectors);
1386
+ }
1387
+ // GET /api/activity — recent activity derived from sessions
1388
+ if (method === "GET" && pathname === "/api/activity") {
1389
+ const sessions = listSessions();
1390
+ const events = [];
1391
+ for (const s of sessions) {
1392
+ const ts = new Date(s.lastActivity || s.createdAt).getTime();
1393
+ const transportState = context.sessionManager.getQueue().getTransportState(s.sessionKey || s.sourceRef, s.status);
1394
+ if (transportState === "running") {
1395
+ events.push({ event: "session:started", payload: { sessionId: s.id, employee: s.employee, engine: s.engine, connector: s.connector }, ts });
1396
+ }
1397
+ else if (transportState === "queued") {
1398
+ events.push({ event: "session:queued", payload: { sessionId: s.id, employee: s.employee, engine: s.engine, connector: s.connector }, ts });
1399
+ }
1400
+ else if (transportState === "idle") {
1401
+ events.push({ event: "session:completed", payload: { sessionId: s.id, employee: s.employee, engine: s.engine, connector: s.connector }, ts });
1402
+ }
1403
+ else if (transportState === "error") {
1404
+ events.push({ event: "session:error", payload: { sessionId: s.id, employee: s.employee, error: s.lastError, connector: s.connector }, ts });
1405
+ }
1406
+ }
1407
+ events.sort((a, b) => b.ts - a.ts);
1408
+ return json(res, events.slice(0, 30));
1409
+ }
1410
+ // GET /api/onboarding — check if onboarding is needed
1411
+ if (method === "GET" && pathname === "/api/onboarding") {
1412
+ const sessions = listSessions();
1413
+ const hasEmployees = fs.existsSync(ORG_DIR) &&
1414
+ fs.readdirSync(ORG_DIR, { recursive: true }).some((f) => String(f).endsWith(".yaml") && !String(f).endsWith("department.yaml"));
1415
+ const config = context.getConfig();
1416
+ const onboarded = config.portal?.onboarded === true;
1417
+ return json(res, {
1418
+ needed: !onboarded && sessions.length === 0 && !hasEmployees,
1419
+ onboarded,
1420
+ sessionsCount: sessions.length,
1421
+ hasEmployees,
1422
+ portalName: config.portal?.portalName ?? null,
1423
+ operatorName: config.portal?.operatorName ?? null,
1424
+ });
1425
+ }
1426
+ // POST /api/onboarding — persist portal personalization
1427
+ if (method === "POST" && pathname === "/api/onboarding") {
1428
+ const _parsed = await readJsonBody(req, res);
1429
+ if (!_parsed.ok)
1430
+ return;
1431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1432
+ const body = _parsed.body;
1433
+ const { portalName, operatorName, language } = body;
1434
+ // Read current config and merge portal settings
1435
+ const config = context.getConfig();
1436
+ const updated = {
1437
+ ...config,
1438
+ portal: {
1439
+ ...config.portal,
1440
+ onboarded: true,
1441
+ ...(portalName !== undefined && { portalName: portalName || undefined }),
1442
+ ...(operatorName !== undefined && { operatorName: operatorName || undefined }),
1443
+ ...(language !== undefined && { language: language || undefined }),
1444
+ },
1445
+ };
1446
+ // Write updated config
1447
+ const yamlStr = yaml.dump(updated, { lineWidth: -1 });
1448
+ fs.writeFileSync(CONFIG_PATH, yamlStr);
1449
+ logger.info(`Onboarding: portal name="${portalName}", operator="${operatorName}", language="${language}"`);
1450
+ const effectiveName = portalName || "Ryoko";
1451
+ const languageSection = language && language !== "English"
1452
+ ? `\n\n## Language\nAlways respond in ${language}. All communication with the user must be in ${language}.`
1453
+ : "";
1454
+ // Update CLAUDE.md with personalized COO name and language
1455
+ const claudeMdPath = path.join(JINN_HOME, "CLAUDE.md");
1456
+ if (fs.existsSync(claudeMdPath)) {
1457
+ let claudeMd = fs.readFileSync(claudeMdPath, "utf-8");
1458
+ // Replace the identity line in CLAUDE.md
1459
+ claudeMd = claudeMd.replace(/^You are \w+, the COO of the user's AI organization\.$/m, `You are ${effectiveName}, the COO of the user's AI organization.`);
1460
+ // Remove existing language section if present, then add new one if needed
1461
+ claudeMd = claudeMd.replace(/\n\n## Language\nAlways respond in .+\. All communication with the user must be in .+\./m, "");
1462
+ if (languageSection) {
1463
+ claudeMd = claudeMd.trimEnd() + languageSection + "\n";
1464
+ }
1465
+ fs.writeFileSync(claudeMdPath, claudeMd);
1466
+ }
1467
+ // Update AGENTS.md with personalized name and language
1468
+ const agentsMdPath = path.join(JINN_HOME, "AGENTS.md");
1469
+ if (fs.existsSync(agentsMdPath)) {
1470
+ let agentsMd = fs.readFileSync(agentsMdPath, "utf-8");
1471
+ // Replace the bold identity line (e.g. "You are **Jinn**")
1472
+ agentsMd = agentsMd.replace(/You are \*\*\w+\*\*/, `You are **${effectiveName}**`);
1473
+ // Remove existing language section if present, then add new one if needed
1474
+ agentsMd = agentsMd.replace(/\n\n## Language\nAlways respond in .+\. All communication with the user must be in .+\./m, "");
1475
+ if (languageSection) {
1476
+ agentsMd = agentsMd.trimEnd() + languageSection + "\n";
1477
+ }
1478
+ fs.writeFileSync(agentsMdPath, agentsMd);
1479
+ }
1480
+ context.emit("config:updated", { portal: updated.portal });
1481
+ return json(res, { status: "ok", portal: updated.portal });
1482
+ }
1483
+ // ── STT (Speech-to-Text) ──────────────────────────────────
1484
+ if (method === "GET" && pathname === "/api/stt/status") {
1485
+ const config = context.getConfig();
1486
+ const languages = resolveLanguages(config.stt);
1487
+ const status = getSttStatus(config.stt?.model, languages);
1488
+ return json(res, status);
1489
+ }
1490
+ if (method === "POST" && pathname === "/api/stt/download") {
1491
+ const config = context.getConfig();
1492
+ const model = config.stt?.model || "small";
1493
+ downloadModel(model, (progress) => {
1494
+ context.emit("stt:download:progress", { progress });
1495
+ }).then(() => {
1496
+ // Update config to mark STT as enabled
1497
+ try {
1498
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
1499
+ const cfg = yaml.load(raw);
1500
+ if (!cfg.stt || typeof cfg.stt !== "object")
1501
+ cfg.stt = {};
1502
+ const sttCfg = cfg.stt;
1503
+ sttCfg.enabled = true;
1504
+ sttCfg.model = model;
1505
+ if (!sttCfg.languages)
1506
+ sttCfg.languages = ["en"];
1507
+ fs.writeFileSync(CONFIG_PATH, yaml.dump(cfg, { lineWidth: -1 }));
1508
+ }
1509
+ catch (err) {
1510
+ logger.error(`Failed to update config after STT download: ${err}`);
1511
+ }
1512
+ context.emit("stt:download:complete", { model });
1513
+ }).catch((err) => {
1514
+ const msg = err instanceof Error ? err.message : String(err);
1515
+ logger.error(`STT download failed: ${msg}`);
1516
+ context.emit("stt:download:error", { error: msg });
1517
+ });
1518
+ return json(res, { status: "downloading", model });
1519
+ }
1520
+ if (method === "POST" && pathname === "/api/stt/transcribe") {
1521
+ const config = context.getConfig();
1522
+ const model = config.stt?.model || "small";
1523
+ const languages = resolveLanguages(config.stt);
1524
+ // Accept language from query param, fall back to first configured language
1525
+ const requestedLang = url.searchParams.get("language");
1526
+ const language = requestedLang && languages.includes(requestedLang) ? requestedLang : languages[0];
1527
+ const audioBuffer = await readBodyRaw(req);
1528
+ if (audioBuffer.length === 0)
1529
+ return badRequest(res, "No audio data");
1530
+ if (audioBuffer.length > 100 * 1024 * 1024)
1531
+ return badRequest(res, "Audio too large (100MB max)");
1532
+ const contentType = req.headers["content-type"] || "audio/webm";
1533
+ const ext = contentType.includes("wav") ? ".wav"
1534
+ : contentType.includes("mp4") || contentType.includes("m4a") ? ".m4a"
1535
+ : contentType.includes("ogg") ? ".ogg"
1536
+ : ".webm";
1537
+ const tmpFile = path.join(TMP_DIR, `stt-${crypto.randomUUID()}${ext}`);
1538
+ fs.mkdirSync(TMP_DIR, { recursive: true });
1539
+ fs.writeFileSync(tmpFile, audioBuffer);
1540
+ try {
1541
+ const text = await sttTranscribe(tmpFile, model, language);
1542
+ return json(res, { text });
1543
+ }
1544
+ catch (err) {
1545
+ const msg = err instanceof Error ? err.message : String(err);
1546
+ logger.error(`STT transcription failed: ${msg}`);
1547
+ return serverError(res, `Transcription failed: ${msg}`);
1548
+ }
1549
+ finally {
1550
+ try {
1551
+ fs.unlinkSync(tmpFile);
1552
+ }
1553
+ catch { }
1554
+ }
1555
+ }
1556
+ if (method === "PUT" && pathname === "/api/stt/config") {
1557
+ const _parsed = await readJsonBody(req, res);
1558
+ if (!_parsed.ok)
1559
+ return;
1560
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1561
+ const body = _parsed.body;
1562
+ const langs = body.languages;
1563
+ if (!Array.isArray(langs) || langs.length === 0) {
1564
+ return badRequest(res, "languages must be a non-empty array");
1565
+ }
1566
+ const invalid = langs.filter((l) => typeof l !== "string" || !WHISPER_LANGUAGES[l]);
1567
+ if (invalid.length > 0) {
1568
+ return badRequest(res, `Invalid language codes: ${invalid.join(", ")}`);
1569
+ }
1570
+ try {
1571
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
1572
+ const cfg = yaml.load(raw);
1573
+ if (!cfg.stt || typeof cfg.stt !== "object")
1574
+ cfg.stt = {};
1575
+ const sttCfg = cfg.stt;
1576
+ sttCfg.languages = langs;
1577
+ // Remove deprecated language field if present
1578
+ delete sttCfg.language;
1579
+ fs.writeFileSync(CONFIG_PATH, yaml.dump(cfg, { lineWidth: -1 }));
1580
+ return json(res, { status: "ok", languages: langs });
1581
+ }
1582
+ catch (err) {
1583
+ const msg = err instanceof Error ? err.message : String(err);
1584
+ return serverError(res, `Failed to update STT config: ${msg}`);
1585
+ }
1586
+ }
1587
+ // /api/files — file upload/download/management
1588
+ if (pathname.startsWith("/api/files")) {
1589
+ const handled = await handleFilesRequest(req, res, pathname, method, context);
1590
+ if (handled)
1591
+ return;
1592
+ }
1593
+ // ── Goals ────────────────────────────────────────────────────────
1594
+ // GET /api/goals
1595
+ if (method === "GET" && pathname === "/api/goals") {
1596
+ const { listGoals } = await import("./goals.js");
1597
+ const db = initDb();
1598
+ return json(res, listGoals(db));
1599
+ }
1600
+ // GET /api/goals/tree
1601
+ if (method === "GET" && pathname === "/api/goals/tree") {
1602
+ const { getGoalTree } = await import("./goals.js");
1603
+ const db = initDb();
1604
+ return json(res, getGoalTree(db));
1605
+ }
1606
+ // POST /api/goals
1607
+ if (method === "POST" && pathname === "/api/goals") {
1608
+ const _parsed = await readJsonBody(req, res);
1609
+ if (!_parsed.ok)
1610
+ return;
1611
+ const { createGoal } = await import("./goals.js");
1612
+ const db = initDb();
1613
+ const goal = createGoal(db, _parsed.body);
1614
+ return json(res, goal, 201);
1615
+ }
1616
+ // GET /api/goals/:id
1617
+ params = matchRoute("/api/goals/:id", pathname);
1618
+ if (method === "GET" && params) {
1619
+ const { getGoal } = await import("./goals.js");
1620
+ const db = initDb();
1621
+ const goal = getGoal(db, params.id);
1622
+ if (!goal)
1623
+ return notFound(res);
1624
+ return json(res, goal);
1625
+ }
1626
+ // PUT /api/goals/:id
1627
+ params = matchRoute("/api/goals/:id", pathname);
1628
+ if (method === "PUT" && params) {
1629
+ const _parsed = await readJsonBody(req, res);
1630
+ if (!_parsed.ok)
1631
+ return;
1632
+ const { updateGoal } = await import("./goals.js");
1633
+ const db = initDb();
1634
+ const goal = updateGoal(db, params.id, _parsed.body);
1635
+ if (!goal)
1636
+ return notFound(res);
1637
+ return json(res, goal);
1638
+ }
1639
+ // DELETE /api/goals/:id
1640
+ params = matchRoute("/api/goals/:id", pathname);
1641
+ if (method === "DELETE" && params) {
1642
+ const { deleteGoal } = await import("./goals.js");
1643
+ const db = initDb();
1644
+ deleteGoal(db, params.id);
1645
+ return json(res, { status: "ok" });
1646
+ }
1647
+ // ── Costs ────────────────────────────────────────────────────────
1648
+ // GET /api/costs/summary
1649
+ if (method === "GET" && pathname === "/api/costs/summary") {
1650
+ const { getCostSummary } = await import("./costs.js");
1651
+ const rawPeriod = url.searchParams.get("period") ?? "month";
1652
+ const period = (rawPeriod === "day" || rawPeriod === "week" || rawPeriod === "month") ? rawPeriod : "month";
1653
+ return json(res, getCostSummary(period));
1654
+ }
1655
+ // GET /api/costs/by-employee
1656
+ if (method === "GET" && pathname === "/api/costs/by-employee") {
1657
+ const { getCostsByEmployee } = await import("./costs.js");
1658
+ const rawPeriod = url.searchParams.get("period") ?? "month";
1659
+ const period = (rawPeriod === "week") ? "week" : "month";
1660
+ return json(res, getCostsByEmployee(period));
1661
+ }
1662
+ // ── Budgets ──────────────────────────────────────────────────────
1663
+ // GET /api/budgets
1664
+ if (method === "GET" && pathname === "/api/budgets") {
1665
+ const { getBudgetStatus } = await import("./budgets.js");
1666
+ const config = context.getConfig();
1667
+ const budgetConfig = config.budgets?.employees ?? {};
1668
+ const employees = Object.keys(budgetConfig);
1669
+ const statuses = employees.map((emp) => ({
1670
+ employee: emp,
1671
+ ...getBudgetStatus(emp, budgetConfig),
1672
+ }));
1673
+ return json(res, { employees: budgetConfig, statuses });
1674
+ }
1675
+ // PUT /api/budgets
1676
+ if (method === "PUT" && pathname === "/api/budgets") {
1677
+ const _parsed = await readJsonBody(req, res);
1678
+ if (!_parsed.ok)
1679
+ return;
1680
+ const body = _parsed.body;
1681
+ let existing = {};
1682
+ try {
1683
+ existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
1684
+ }
1685
+ catch { /* start fresh if unreadable */ }
1686
+ const merged = deepMerge(existing, { budgets: { employees: body } });
1687
+ fs.writeFileSync(CONFIG_PATH, yaml.dump(merged));
1688
+ logger.info("Budget limits updated via API");
1689
+ return json(res, { status: "ok" });
1690
+ }
1691
+ // POST /api/budgets/:employee/override
1692
+ params = matchRoute("/api/budgets/:employee/override", pathname);
1693
+ if (method === "POST" && params) {
1694
+ const { overrideBudget } = await import("./budgets.js");
1695
+ const config = context.getConfig();
1696
+ const budgetConfig = config.budgets?.employees ?? {};
1697
+ return json(res, overrideBudget(params.employee, budgetConfig));
1698
+ }
1699
+ // GET /api/budgets/events
1700
+ if (method === "GET" && pathname === "/api/budgets/events") {
1701
+ const { getBudgetEvents } = await import("./budgets.js");
1702
+ return json(res, getBudgetEvents());
1703
+ }
1704
+ return notFound(res);
1705
+ }
1706
+ catch (err) {
1707
+ const msg = err instanceof Error ? err.message : String(err);
1708
+ logger.error(`API error: ${msg}`);
1709
+ return serverError(res, msg);
1710
+ }
1711
+ }
1712
+ /**
1713
+ * Parse the output of `npx skills find <query>` into structured results.
1714
+ *
1715
+ * Format:
1716
+ * ```
1717
+ * owner/repo@skill-name <N> installs
1718
+ * └ https://skills.sh/owner/repo/skill-name
1719
+ * ```
1720
+ */
1721
+ function stripAnsi(str) {
1722
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
1723
+ }
1724
+ function parseSkillsSearchOutput(output) {
1725
+ const results = [];
1726
+ const lines = output.trim().split("\n");
1727
+ for (let i = 0; i < lines.length; i++) {
1728
+ const headerLine = stripAnsi(lines[i]).trim();
1729
+ // Match "owner/repo@skill-name <N> installs"
1730
+ const headerMatch = headerLine.match(/^(\S+)\s+(\d+)\s+installs?$/);
1731
+ if (!headerMatch)
1732
+ continue;
1733
+ const source = headerMatch[1];
1734
+ const installs = parseInt(headerMatch[2], 10);
1735
+ const atIdx = source.lastIndexOf("@");
1736
+ const name = atIdx > 0 ? source.slice(atIdx + 1) : source;
1737
+ // Next line should be the URL
1738
+ let url = "";
1739
+ if (i + 1 < lines.length) {
1740
+ const urlLine = stripAnsi(lines[i + 1]).trim();
1741
+ const urlMatch = urlLine.match(/[└]\s*(https?:\/\/\S+)/);
1742
+ if (urlMatch) {
1743
+ url = urlMatch[1];
1744
+ i++; // consume the URL line
1745
+ }
1746
+ }
1747
+ results.push({ name, source, url, installs });
1748
+ }
1749
+ return results;
1750
+ }
1751
+ function loadRawTranscript(engineSessionId) {
1752
+ const claudeProjectsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "projects");
1753
+ if (!fs.existsSync(claudeProjectsDir))
1754
+ return [];
1755
+ const projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true });
1756
+ for (const dir of projectDirs) {
1757
+ if (!dir.isDirectory())
1758
+ continue;
1759
+ const jsonlPath = path.join(claudeProjectsDir, dir.name, `${engineSessionId}.jsonl`);
1760
+ if (!fs.existsSync(jsonlPath))
1761
+ continue;
1762
+ const entries = [];
1763
+ const lines = fs.readFileSync(jsonlPath, "utf-8").trim().split("\n").filter(Boolean);
1764
+ for (const line of lines) {
1765
+ try {
1766
+ const obj = JSON.parse(line);
1767
+ const type = obj.type;
1768
+ if (type !== "user" && type !== "assistant")
1769
+ continue;
1770
+ const msg = obj.message;
1771
+ if (!msg)
1772
+ continue;
1773
+ const rawContent = msg.content;
1774
+ const blocks = [];
1775
+ if (typeof rawContent === "string") {
1776
+ if (rawContent.trim())
1777
+ blocks.push({ type: "text", text: rawContent });
1778
+ }
1779
+ else if (Array.isArray(rawContent)) {
1780
+ for (const block of rawContent) {
1781
+ if (!block || typeof block !== "object")
1782
+ continue;
1783
+ const b = block;
1784
+ const blockType = String(b.type || "");
1785
+ if (blockType === "text") {
1786
+ blocks.push({ type: "text", text: String(b.text || "") });
1787
+ }
1788
+ else if (blockType === "tool_use") {
1789
+ blocks.push({
1790
+ type: "tool_use",
1791
+ name: String(b.name || ""),
1792
+ input: b.input || {},
1793
+ });
1794
+ }
1795
+ else if (blockType === "tool_result") {
1796
+ const resultContent = b.content;
1797
+ let resultText;
1798
+ if (typeof resultContent === "string") {
1799
+ resultText = resultContent;
1800
+ }
1801
+ else if (Array.isArray(resultContent)) {
1802
+ resultText = resultContent
1803
+ .filter((rc) => rc.type === "text")
1804
+ .map((rc) => String(rc.text || ""))
1805
+ .join("");
1806
+ }
1807
+ else {
1808
+ resultText = "";
1809
+ }
1810
+ blocks.push({ type: "tool_result", text: resultText });
1811
+ }
1812
+ else if (blockType === "thinking") {
1813
+ blocks.push({ type: "thinking", text: String(b.thinking || b.text || "") });
1814
+ }
1815
+ }
1816
+ }
1817
+ if (blocks.length > 0) {
1818
+ entries.push({ role: type, content: blocks });
1819
+ }
1820
+ }
1821
+ catch {
1822
+ continue;
1823
+ }
1824
+ }
1825
+ return entries;
1826
+ }
1827
+ return [];
1828
+ }
1829
+ function loadTranscriptMessages(engineSessionId) {
1830
+ // Claude Code stores transcripts in ~/.claude/projects/<project-key>/<sessionId>.jsonl
1831
+ const claudeProjectsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "projects");
1832
+ if (!fs.existsSync(claudeProjectsDir))
1833
+ return [];
1834
+ // Search all project dirs for the transcript
1835
+ const projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true });
1836
+ for (const dir of projectDirs) {
1837
+ if (!dir.isDirectory())
1838
+ continue;
1839
+ const jsonlPath = path.join(claudeProjectsDir, dir.name, `${engineSessionId}.jsonl`);
1840
+ if (!fs.existsSync(jsonlPath))
1841
+ continue;
1842
+ const messages = [];
1843
+ const lines = fs.readFileSync(jsonlPath, "utf-8").trim().split("\n").filter(Boolean);
1844
+ for (const line of lines) {
1845
+ try {
1846
+ const obj = JSON.parse(line);
1847
+ const type = obj.type;
1848
+ if (type !== "user" && type !== "assistant")
1849
+ continue;
1850
+ const msg = obj.message;
1851
+ if (!msg)
1852
+ continue;
1853
+ let content = msg.content;
1854
+ if (Array.isArray(content)) {
1855
+ content = content
1856
+ .filter((b) => b.type === "text")
1857
+ .map((b) => b.text)
1858
+ .join("");
1859
+ }
1860
+ if (typeof content === "string" && content.trim()) {
1861
+ messages.push({ role: type, content: content.trim() });
1862
+ }
1863
+ }
1864
+ catch {
1865
+ continue;
1866
+ }
1867
+ }
1868
+ return messages;
1869
+ }
1870
+ return [];
1871
+ }
1872
+ async function runWebSession(session, prompt, engine, config, context, attachments) {
1873
+ const currentSession = getSession(session.id);
1874
+ if (!currentSession) {
1875
+ logger.info(`Skipping deleted web session ${session.id} before run start`);
1876
+ return;
1877
+ }
1878
+ logger.info(`Web session ${currentSession.id} running engine "${currentSession.engine}" (model: ${currentSession.model || "default"})`);
1879
+ // Ensure status is "running" (may already be set by the POST handler)
1880
+ const currentStatus = getSession(currentSession.id);
1881
+ if (currentStatus && currentStatus.status !== "running") {
1882
+ updateSession(currentSession.id, {
1883
+ status: "running",
1884
+ lastActivity: new Date().toISOString(),
1885
+ });
1886
+ }
1887
+ // If this session has an assigned employee, load their persona
1888
+ let employee;
1889
+ if (currentSession.employee) {
1890
+ const { findEmployee } = await import("./org.js");
1891
+ const { scanOrg } = await import("./org.js");
1892
+ const registry = scanOrg();
1893
+ employee = findEmployee(currentSession.employee, registry);
1894
+ }
1895
+ const { scanOrg: scanOrgForHierarchy } = await import("./org.js");
1896
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
1897
+ const orgHierarchy = resolveOrgHierarchy(scanOrgForHierarchy());
1898
+ try {
1899
+ const systemPrompt = buildContext({
1900
+ source: "web",
1901
+ channel: currentSession.sourceRef,
1902
+ user: "web-user",
1903
+ employee,
1904
+ connectors: Array.from(context.connectors.keys()),
1905
+ config,
1906
+ sessionId: currentSession.id,
1907
+ hierarchy: orgHierarchy,
1908
+ });
1909
+ const engineConfig = currentSession.engine === "codex"
1910
+ ? config.engines.codex
1911
+ : currentSession.engine === "gemini"
1912
+ ? config.engines.gemini ?? config.engines.claude
1913
+ : config.engines.claude;
1914
+ const effortLevel = resolveEffort(engineConfig, currentSession, employee);
1915
+ let lastHeartbeatAt = 0;
1916
+ const runHeartbeat = setInterval(() => {
1917
+ updateSession(currentSession.id, {
1918
+ status: "running",
1919
+ lastActivity: new Date().toISOString(),
1920
+ });
1921
+ }, 5000);
1922
+ const syncSinceIso = currentSession.transportMeta?.claudeSyncSince;
1923
+ const syncSinceMs = typeof syncSinceIso === "string" ? new Date(syncSinceIso).getTime() : NaN;
1924
+ const syncRequested = currentSession.engine === "claude" && typeof syncSinceIso === "string" && Number.isFinite(syncSinceMs);
1925
+ const promptToRun = syncRequested
1926
+ ? (() => {
1927
+ const sinceMessages = getMessages(currentSession.id)
1928
+ .filter((m) => (m.role === "user" || m.role === "assistant") && m.timestamp >= syncSinceMs)
1929
+ .map((m) => `${m.role.toUpperCase()}: ${m.content}`);
1930
+ const transcript = sinceMessages.slice(-20).join("\n\n");
1931
+ return `We temporarily switched to GPT due to a Claude usage limit. Sync your context with this transcript (most recent last), then respond to the last USER message.\n\n${transcript}`;
1932
+ })()
1933
+ : prompt;
1934
+ const result = await engine.run({
1935
+ prompt: promptToRun,
1936
+ resumeSessionId: currentSession.engineSessionId ?? undefined,
1937
+ systemPrompt,
1938
+ cwd: JINN_HOME,
1939
+ bin: engineConfig.bin,
1940
+ model: currentSession.model ?? engineConfig.model,
1941
+ effortLevel,
1942
+ cliFlags: employee?.cliFlags,
1943
+ attachments: attachments?.length ? attachments : undefined,
1944
+ sessionId: currentSession.id,
1945
+ onStream: (delta) => {
1946
+ const now = Date.now();
1947
+ if (now - lastHeartbeatAt >= 2000) {
1948
+ lastHeartbeatAt = now;
1949
+ updateSession(currentSession.id, {
1950
+ status: "running",
1951
+ lastActivity: new Date(now).toISOString(),
1952
+ });
1953
+ }
1954
+ try {
1955
+ context.emit("session:delta", {
1956
+ sessionId: currentSession.id,
1957
+ type: delta.type,
1958
+ content: delta.content,
1959
+ toolName: delta.toolName,
1960
+ });
1961
+ }
1962
+ catch (err) {
1963
+ logger.warn(`Failed to emit stream delta for session ${currentSession.id}: ${err instanceof Error ? err.message : err}`);
1964
+ }
1965
+ },
1966
+ }).finally(() => {
1967
+ clearInterval(runHeartbeat);
1968
+ });
1969
+ if (!getSession(currentSession.id)) {
1970
+ logger.info(`Skipping completion for deleted web session ${currentSession.id}`);
1971
+ return;
1972
+ }
1973
+ const wasInterrupted = result.error?.startsWith("Interrupted");
1974
+ const rateLimit = !wasInterrupted ? detectRateLimit(result) : { limited: false };
1975
+ if (rateLimit.limited) {
1976
+ recordClaudeRateLimit(rateLimit.resetsAt);
1977
+ const strategy = config.sessions?.rateLimitStrategy ?? "fallback";
1978
+ // Optional fallback: switch to GPT (Codex) while Claude resets
1979
+ if (currentSession.engine === "claude" && strategy === "fallback") {
1980
+ const fallbackName = config.sessions?.fallbackEngine ?? "codex";
1981
+ const fallbackEngine = context.sessionManager.getEngine(fallbackName);
1982
+ if (fallbackEngine) {
1983
+ const { resumeAt } = computeNextRetryDelayMs(rateLimit.resetsAt);
1984
+ const until = resumeAt ?? new Date(Date.now() + 6 * 60 * 60_000);
1985
+ const syncSince = new Date().toISOString();
1986
+ const resumeText = resumeAt
1987
+ ? resumeAt.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
1988
+ : null;
1989
+ const notificationText = `⚠️ Claude usage limit reached${resumeText ? `. Resets ${resumeText}` : ""}. Switching to GPT for now.`;
1990
+ insertMessage(currentSession.id, "notification", notificationText);
1991
+ context.emit("session:notification", { sessionId: currentSession.id, message: notificationText });
1992
+ const nextMeta = { ...(currentSession.transportMeta || {}) };
1993
+ const engineSessionsRaw = nextMeta.engineSessions;
1994
+ const engineSessions = (engineSessionsRaw && typeof engineSessionsRaw === "object" && !Array.isArray(engineSessionsRaw))
1995
+ ? { ...engineSessionsRaw }
1996
+ : {};
1997
+ if (currentSession.engineSessionId) {
1998
+ engineSessions.claude = currentSession.engineSessionId;
1999
+ }
2000
+ nextMeta.engineSessions = engineSessions;
2001
+ nextMeta.engineOverride = { originalEngine: "claude", originalEngineSessionId: currentSession.engineSessionId, until: until.toISOString(), syncSince };
2002
+ updateSession(currentSession.id, {
2003
+ engine: fallbackName,
2004
+ transportMeta: nextMeta,
2005
+ status: "running",
2006
+ lastActivity: new Date().toISOString(),
2007
+ lastError: resumeAt
2008
+ ? `Claude usage limit — using GPT until ${resumeAt.toISOString()}`
2009
+ : "Claude usage limit — using GPT temporarily",
2010
+ });
2011
+ notifyDiscordChannel(`⚠️ Claude usage limit reached. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} switching to GPT.`);
2012
+ const fallbackConfig = config.engines.codex;
2013
+ const fallbackEffort = resolveEffort(fallbackConfig, currentSession, employee);
2014
+ const codexResume = typeof engineSessions.codex === "string" ? engineSessions.codex : undefined;
2015
+ const history = getMessages(currentSession.id)
2016
+ .filter((m) => m.role === "user" || m.role === "assistant")
2017
+ .map((m) => `${m.role.toUpperCase()}: ${m.content}`);
2018
+ const historyText = history.slice(-12).join("\n\n");
2019
+ const fallbackPrompt = codexResume
2020
+ ? prompt
2021
+ : `Continue this conversation and respond to the last USER message.\n\nConversation so far:\n\n${historyText}`;
2022
+ const fallbackResult = await fallbackEngine.run({
2023
+ prompt: fallbackPrompt,
2024
+ resumeSessionId: codexResume,
2025
+ systemPrompt,
2026
+ cwd: JINN_HOME,
2027
+ bin: fallbackConfig.bin,
2028
+ model: currentSession.model ?? fallbackConfig.model,
2029
+ effortLevel: fallbackEffort,
2030
+ cliFlags: employee?.cliFlags,
2031
+ sessionId: currentSession.id,
2032
+ onStream: (delta) => {
2033
+ context.emit("session:delta", {
2034
+ sessionId: currentSession.id,
2035
+ type: delta.type,
2036
+ content: delta.content,
2037
+ toolName: delta.toolName,
2038
+ });
2039
+ },
2040
+ });
2041
+ if (fallbackResult.result) {
2042
+ insertMessage(currentSession.id, "assistant", fallbackResult.result);
2043
+ }
2044
+ // Persist Codex thread id so future fallbacks can resume it
2045
+ const nextEngineSessions = { ...engineSessions };
2046
+ if (fallbackResult.sessionId) {
2047
+ nextEngineSessions.codex = fallbackResult.sessionId;
2048
+ }
2049
+ const metaAfter = { ...(getSession(currentSession.id)?.transportMeta || nextMeta) };
2050
+ metaAfter.engineSessions = nextEngineSessions;
2051
+ updateSession(currentSession.id, { transportMeta: metaAfter });
2052
+ const completedFallback = updateSession(currentSession.id, {
2053
+ engineSessionId: fallbackResult.sessionId,
2054
+ status: fallbackResult.error ? "error" : "idle",
2055
+ lastActivity: new Date().toISOString(),
2056
+ lastError: fallbackResult.error ?? null,
2057
+ });
2058
+ if (completedFallback) {
2059
+ notifyParentSession(completedFallback, { result: fallbackResult.result, error: fallbackResult.error ?? null, cost: fallbackResult.cost, durationMs: fallbackResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2060
+ }
2061
+ context.emit("session:completed", {
2062
+ sessionId: currentSession.id,
2063
+ employee: currentSession.employee || config.portal?.portalName || "Ryoko",
2064
+ title: currentSession.title,
2065
+ result: fallbackResult.result,
2066
+ error: fallbackResult.error || null,
2067
+ cost: fallbackResult.cost,
2068
+ durationMs: fallbackResult.durationMs,
2069
+ });
2070
+ return;
2071
+ }
2072
+ }
2073
+ // Otherwise: wait until reset and retry automatically
2074
+ const { delayMs, resumeAt } = computeNextRetryDelayMs(rateLimit.resetsAt);
2075
+ const deadlineMs = computeRateLimitDeadlineMs(rateLimit.resetsAt, rateLimit.resetsAt ? 30 * 60_000 : 6 * 60 * 60_000);
2076
+ const resumeText = resumeAt
2077
+ ? resumeAt.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
2078
+ : null;
2079
+ logger.info(`Web session ${currentSession.id} hit Claude usage limit — will auto-retry ${resumeAt ? `at ${resumeAt.toISOString()}` : `in ${Math.round(delayMs / 1000)}s`}`);
2080
+ // Send hardcoded Discord notification — does not depend on the LLM
2081
+ notifyDiscordChannel(`⚠️ Claude usage limit reached. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} paused${resumeText ? ` until ${resumeText}` : ""}.`);
2082
+ const notificationText = `⏳ Claude usage limit reached${resumeText ? `. Resets ${resumeText}` : ""} — I'll continue automatically.`;
2083
+ insertMessage(currentSession.id, "notification", notificationText);
2084
+ context.emit("session:notification", { sessionId: currentSession.id, message: notificationText });
2085
+ const waitingSession = updateSession(currentSession.id, {
2086
+ ...(result.sessionId?.trim() ? { engineSessionId: result.sessionId } : {}),
2087
+ status: "waiting",
2088
+ lastActivity: new Date().toISOString(),
2089
+ lastError: resumeAt
2090
+ ? `Claude usage limit — resumes ${resumeAt.toISOString()}`
2091
+ : "Claude usage limit — waiting for reset",
2092
+ });
2093
+ // Notify parent session about rate limit (fire-and-forget)
2094
+ notifyRateLimited((waitingSession ?? { ...currentSession, status: "waiting" }), resumeAt
2095
+ ? resumeAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })
2096
+ : undefined);
2097
+ context.emit("session:rate-limited", {
2098
+ sessionId: currentSession.id,
2099
+ employee: currentSession.employee,
2100
+ error: result.error,
2101
+ resetsAt: rateLimit.resetsAt ?? null,
2102
+ });
2103
+ // Keep lastActivity fresh while waiting (UI / status endpoints)
2104
+ const heartbeat = setInterval(() => {
2105
+ updateSession(currentSession.id, { status: "waiting", lastActivity: new Date().toISOString() });
2106
+ }, 60_000);
2107
+ try {
2108
+ let attempt = 0;
2109
+ let nextDelayMs = delayMs;
2110
+ while (Date.now() < deadlineMs) {
2111
+ await new Promise((r) => setTimeout(r, nextDelayMs));
2112
+ attempt++;
2113
+ // Check session still exists and hasn't been cancelled
2114
+ const current = getSession(currentSession.id);
2115
+ if (!current || current.status === "error") {
2116
+ logger.info(`Web session ${currentSession.id} stopped while waiting for usage reset`);
2117
+ return;
2118
+ }
2119
+ logger.info(`Web session ${currentSession.id} retrying after usage limit (attempt ${attempt})`);
2120
+ const retryResult = await engine.run({
2121
+ prompt,
2122
+ resumeSessionId: current.engineSessionId ?? undefined,
2123
+ systemPrompt,
2124
+ cwd: JINN_HOME,
2125
+ bin: engineConfig.bin,
2126
+ model: current.model ?? engineConfig.model,
2127
+ effortLevel,
2128
+ cliFlags: employee?.cliFlags,
2129
+ sessionId: currentSession.id,
2130
+ onStream: (delta) => {
2131
+ context.emit("session:delta", {
2132
+ sessionId: currentSession.id,
2133
+ type: delta.type,
2134
+ content: delta.content,
2135
+ toolName: delta.toolName,
2136
+ });
2137
+ },
2138
+ });
2139
+ const retryInterrupted = retryResult.error?.startsWith("Interrupted");
2140
+ const retryRateLimit = !retryInterrupted ? detectRateLimit(retryResult) : { limited: false };
2141
+ if (retryRateLimit.limited) {
2142
+ recordClaudeRateLimit(retryRateLimit.resetsAt);
2143
+ logger.info(`Web session ${currentSession.id} still rate limited (attempt ${attempt})`);
2144
+ const next = computeNextRetryDelayMs(retryRateLimit.resetsAt);
2145
+ nextDelayMs = next.delayMs;
2146
+ updateSession(currentSession.id, {
2147
+ ...(retryResult.sessionId?.trim() ? { engineSessionId: retryResult.sessionId } : {}),
2148
+ status: "waiting",
2149
+ lastActivity: new Date().toISOString(),
2150
+ lastError: next.resumeAt
2151
+ ? `Claude usage limit — resumes ${next.resumeAt.toISOString()}`
2152
+ : "Claude usage limit — waiting for reset",
2153
+ });
2154
+ continue;
2155
+ }
2156
+ // Usage limit cleared — handle result
2157
+ if (retryResult.result) {
2158
+ insertMessage(currentSession.id, "assistant", retryResult.result);
2159
+ }
2160
+ const completedAfterRetry = updateSession(currentSession.id, {
2161
+ ...(retryResult.sessionId?.trim() ? { engineSessionId: retryResult.sessionId } : {}),
2162
+ status: retryResult.error ? "error" : "idle",
2163
+ lastActivity: new Date().toISOString(),
2164
+ lastError: retryResult.error ?? null,
2165
+ });
2166
+ if (completedAfterRetry) {
2167
+ notifyRateLimitResumed(completedAfterRetry);
2168
+ notifyDiscordChannel(`✅ Claude usage limit cleared. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} resumed.`);
2169
+ notifyParentSession(completedAfterRetry, { result: retryResult.result, error: retryResult.error ?? null, cost: retryResult.cost, durationMs: retryResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2170
+ }
2171
+ context.emit("session:completed", {
2172
+ sessionId: currentSession.id,
2173
+ employee: currentSession.employee || config.portal?.portalName || "Ryoko",
2174
+ title: currentSession.title,
2175
+ result: retryResult.result,
2176
+ error: retryResult.error || null,
2177
+ cost: retryResult.cost,
2178
+ durationMs: retryResult.durationMs,
2179
+ });
2180
+ logger.info(`Web session ${currentSession.id} resumed after usage reset`);
2181
+ return;
2182
+ }
2183
+ // Exhausted waiting window
2184
+ notifyDiscordChannel(`❌ Claude usage limit did not clear in time. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} has been stopped.`);
2185
+ const erroredSession = updateSession(currentSession.id, {
2186
+ status: "error",
2187
+ lastActivity: new Date().toISOString(),
2188
+ lastError: "Claude usage limit did not clear in time",
2189
+ });
2190
+ if (erroredSession) {
2191
+ notifyParentSession(erroredSession, { error: "Claude usage limit did not clear in time" }, { alwaysNotify: employee?.alwaysNotify });
2192
+ }
2193
+ context.emit("session:completed", {
2194
+ sessionId: currentSession.id,
2195
+ result: null,
2196
+ error: "Claude usage limit did not clear in time",
2197
+ });
2198
+ logger.warn(`Web session ${currentSession.id} exhausted usage limit retries`);
2199
+ return;
2200
+ }
2201
+ finally {
2202
+ clearInterval(heartbeat);
2203
+ }
2204
+ }
2205
+ // Persist the assistant response
2206
+ if (result.result) {
2207
+ insertMessage(currentSession.id, "assistant", result.result);
2208
+ }
2209
+ const completedSession = updateSession(currentSession.id, {
2210
+ ...(result.sessionId?.trim() ? { engineSessionId: result.sessionId } : {}),
2211
+ status: result.error ? "error" : "idle",
2212
+ lastActivity: new Date().toISOString(),
2213
+ lastError: result.error ?? null,
2214
+ });
2215
+ if (syncRequested && !rateLimit.limited && !wasInterrupted) {
2216
+ const meta = (getSession(currentSession.id)?.transportMeta || currentSession.transportMeta || {});
2217
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
2218
+ const nextMeta = { ...meta };
2219
+ delete nextMeta["claudeSyncSince"];
2220
+ updateSession(currentSession.id, { transportMeta: nextMeta });
2221
+ }
2222
+ }
2223
+ if (completedSession) {
2224
+ notifyParentSession(completedSession, { result: result.result, error: result.error ?? null, cost: result.cost, durationMs: result.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2225
+ }
2226
+ context.emit("session:completed", {
2227
+ sessionId: currentSession.id,
2228
+ employee: currentSession.employee || config.portal?.portalName || "Ryoko",
2229
+ title: currentSession.title,
2230
+ result: result.result,
2231
+ error: result.error || null,
2232
+ cost: result.cost,
2233
+ durationMs: result.durationMs,
2234
+ });
2235
+ logger.info(`Web session ${currentSession.id} completed` +
2236
+ (result.durationMs ? ` in ${result.durationMs}ms` : "") +
2237
+ (result.cost ? ` ($${result.cost.toFixed(4)})` : ""));
2238
+ }
2239
+ catch (err) {
2240
+ const errMsg = err instanceof Error ? err.message : String(err);
2241
+ if (!getSession(currentSession.id)) {
2242
+ logger.info(`Skipping error handling for deleted web session ${currentSession.id}: ${errMsg}`);
2243
+ return;
2244
+ }
2245
+ const erroredSession = updateSession(currentSession.id, {
2246
+ status: "error",
2247
+ lastActivity: new Date().toISOString(),
2248
+ lastError: errMsg,
2249
+ });
2250
+ if (erroredSession) {
2251
+ notifyParentSession(erroredSession, { error: errMsg }, { alwaysNotify: employee?.alwaysNotify });
2252
+ }
2253
+ context.emit("session:completed", {
2254
+ sessionId: currentSession.id,
2255
+ result: null,
2256
+ error: errMsg,
2257
+ });
2258
+ logger.error(`Web session ${currentSession.id} error: ${errMsg}`);
2259
+ }
2260
+ }
2261
+ //# sourceMappingURL=api.js.map