@geminixiang/mikan 0.3.2 โ†’ 0.4.0-beta.0

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 (371) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/adapter.d.ts +1 -138
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +1 -4
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +25 -33
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +28 -0
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/discord/types.d.ts +6 -0
  13. package/dist/adapters/discord/types.d.ts.map +1 -0
  14. package/dist/adapters/discord/types.js +2 -0
  15. package/dist/adapters/discord/types.js.map +1 -0
  16. package/dist/adapters/intake.d.ts +11 -0
  17. package/dist/adapters/intake.d.ts.map +1 -0
  18. package/dist/adapters/intake.js +42 -0
  19. package/dist/adapters/intake.js.map +1 -0
  20. package/dist/adapters/shared.d.ts +7 -31
  21. package/dist/adapters/shared.d.ts.map +1 -1
  22. package/dist/adapters/shared.js +18 -2
  23. package/dist/adapters/shared.js.map +1 -1
  24. package/dist/adapters/slack/bot.d.ts +14 -33
  25. package/dist/adapters/slack/bot.d.ts.map +1 -1
  26. package/dist/adapters/slack/bot.js +148 -116
  27. package/dist/adapters/slack/bot.js.map +1 -1
  28. package/dist/adapters/slack/context.d.ts +3 -4
  29. package/dist/adapters/slack/context.d.ts.map +1 -1
  30. package/dist/adapters/slack/context.js +97 -14
  31. package/dist/adapters/slack/context.js.map +1 -1
  32. package/dist/adapters/slack/session.d.ts +5 -20
  33. package/dist/adapters/slack/session.d.ts.map +1 -1
  34. package/dist/adapters/slack/session.js.map +1 -1
  35. package/dist/adapters/slack/types.d.ts +84 -0
  36. package/dist/adapters/slack/types.d.ts.map +1 -0
  37. package/dist/adapters/slack/types.js +2 -0
  38. package/dist/adapters/slack/types.js.map +1 -0
  39. package/dist/adapters/streaming.d.ts +18 -0
  40. package/dist/adapters/streaming.d.ts.map +1 -0
  41. package/dist/adapters/streaming.js +44 -0
  42. package/dist/adapters/streaming.js.map +1 -0
  43. package/dist/adapters/telegram/bot.d.ts +1 -4
  44. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  45. package/dist/adapters/telegram/bot.js +32 -39
  46. package/dist/adapters/telegram/bot.js.map +1 -1
  47. package/dist/adapters/telegram/context.d.ts.map +1 -1
  48. package/dist/adapters/telegram/context.js +33 -0
  49. package/dist/adapters/telegram/context.js.map +1 -1
  50. package/dist/adapters/telegram/types.d.ts +6 -0
  51. package/dist/adapters/telegram/types.d.ts.map +1 -0
  52. package/dist/adapters/telegram/types.js +2 -0
  53. package/dist/adapters/telegram/types.js.map +1 -0
  54. package/dist/adapters/types.d.ts +58 -0
  55. package/dist/adapters/types.d.ts.map +1 -0
  56. package/dist/adapters/types.js +2 -0
  57. package/dist/adapters/types.js.map +1 -0
  58. package/dist/agent.d.ts +4 -16
  59. package/dist/agent.d.ts.map +1 -1
  60. package/dist/agent.js +27 -20
  61. package/dist/agent.js.map +1 -1
  62. package/dist/commands/admin.d.ts.map +1 -1
  63. package/dist/commands/admin.js +1 -1
  64. package/dist/commands/admin.js.map +1 -1
  65. package/dist/commands/auto-reply.d.ts.map +1 -1
  66. package/dist/commands/auto-reply.js +1 -8
  67. package/dist/commands/auto-reply.js.map +1 -1
  68. package/dist/commands/login.d.ts.map +1 -1
  69. package/dist/commands/login.js +3 -3
  70. package/dist/commands/login.js.map +1 -1
  71. package/dist/commands/model.d.ts +5 -8
  72. package/dist/commands/model.d.ts.map +1 -1
  73. package/dist/commands/model.js +15 -20
  74. package/dist/commands/model.js.map +1 -1
  75. package/dist/commands/new.d.ts.map +1 -1
  76. package/dist/commands/new.js +5 -10
  77. package/dist/commands/new.js.map +1 -1
  78. package/dist/commands/parse.d.ts.map +1 -1
  79. package/dist/commands/parse.js +1 -4
  80. package/dist/commands/parse.js.map +1 -1
  81. package/dist/commands/registry.d.ts +1 -0
  82. package/dist/commands/registry.d.ts.map +1 -1
  83. package/dist/commands/registry.js +23 -0
  84. package/dist/commands/registry.js.map +1 -1
  85. package/dist/commands/sandbox.d.ts +2 -5
  86. package/dist/commands/sandbox.d.ts.map +1 -1
  87. package/dist/commands/sandbox.js +11 -16
  88. package/dist/commands/sandbox.js.map +1 -1
  89. package/dist/commands/session-view.d.ts.map +1 -1
  90. package/dist/commands/session-view.js +10 -15
  91. package/dist/commands/session-view.js.map +1 -1
  92. package/dist/commands/types.d.ts +11 -2
  93. package/dist/commands/types.d.ts.map +1 -1
  94. package/dist/commands/types.js.map +1 -1
  95. package/dist/config.d.ts +6 -28
  96. package/dist/config.d.ts.map +1 -1
  97. package/dist/config.js +43 -41
  98. package/dist/config.js.map +1 -1
  99. package/dist/context.d.ts +1 -15
  100. package/dist/context.d.ts.map +1 -1
  101. package/dist/context.js.map +1 -1
  102. package/dist/events.d.ts +3 -44
  103. package/dist/events.d.ts.map +1 -1
  104. package/dist/events.js +2 -9
  105. package/dist/events.js.map +1 -1
  106. package/dist/execution-resolver.d.ts +3 -7
  107. package/dist/execution-resolver.d.ts.map +1 -1
  108. package/dist/execution-resolver.js +8 -8
  109. package/dist/execution-resolver.js.map +1 -1
  110. package/dist/index.d.ts +3 -3
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +2 -2
  113. package/dist/index.js.map +1 -1
  114. package/dist/log.d.ts +2 -6
  115. package/dist/log.d.ts.map +1 -1
  116. package/dist/log.js +1 -37
  117. package/dist/log.js.map +1 -1
  118. package/dist/main.d.ts +1 -1
  119. package/dist/main.d.ts.map +1 -1
  120. package/dist/main.js +16 -16
  121. package/dist/main.js.map +1 -1
  122. package/dist/observability/instrument.d.ts.map +1 -0
  123. package/dist/{instrument.js โ†’ observability/instrument.js} +2 -2
  124. package/dist/observability/instrument.js.map +1 -0
  125. package/dist/{sentry.d.ts โ†’ observability/sentry.d.ts} +2 -30
  126. package/dist/observability/sentry.d.ts.map +1 -0
  127. package/dist/observability/sentry.js.map +1 -0
  128. package/dist/observability/types.d.ts +31 -0
  129. package/dist/observability/types.d.ts.map +1 -0
  130. package/dist/observability/types.js +2 -0
  131. package/dist/observability/types.js.map +1 -0
  132. package/dist/{ui-copy.d.ts โ†’ platform-messages.d.ts} +1 -1
  133. package/dist/platform-messages.d.ts.map +1 -0
  134. package/dist/{ui-copy.js โ†’ platform-messages.js} +1 -1
  135. package/dist/platform-messages.js.map +1 -0
  136. package/dist/portal-shell.d.ts +2 -28
  137. package/dist/portal-shell.d.ts.map +1 -1
  138. package/dist/portal-shell.js +2 -2
  139. package/dist/portal-shell.js.map +1 -1
  140. package/dist/provisioner.d.ts +2 -23
  141. package/dist/provisioner.d.ts.map +1 -1
  142. package/dist/provisioner.js +1 -1
  143. package/dist/provisioner.js.map +1 -1
  144. package/dist/runtime/conversation-orchestrator.d.ts +4 -19
  145. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -1
  146. package/dist/runtime/conversation-orchestrator.js +3 -3
  147. package/dist/runtime/conversation-orchestrator.js.map +1 -1
  148. package/dist/runtime/session-runtime.d.ts +2 -23
  149. package/dist/runtime/session-runtime.d.ts.map +1 -1
  150. package/dist/runtime/session-runtime.js +7 -9
  151. package/dist/runtime/session-runtime.js.map +1 -1
  152. package/dist/runtime/types.d.ts +35 -0
  153. package/dist/runtime/types.d.ts.map +1 -0
  154. package/dist/runtime/types.js +2 -0
  155. package/dist/runtime/types.js.map +1 -0
  156. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  157. package/dist/sandbox/cloudflare.js +1 -1
  158. package/dist/sandbox/cloudflare.js.map +1 -1
  159. package/dist/sandbox/container.d.ts.map +1 -1
  160. package/dist/sandbox/container.js +1 -4
  161. package/dist/sandbox/container.js.map +1 -1
  162. package/dist/sessions/chat-session-manager.d.ts +2 -46
  163. package/dist/sessions/chat-session-manager.d.ts.map +1 -1
  164. package/dist/sessions/chat-session-manager.js +12 -40
  165. package/dist/sessions/chat-session-manager.js.map +1 -1
  166. package/dist/sessions/metadata.d.ts +1 -13
  167. package/dist/sessions/metadata.d.ts.map +1 -1
  168. package/dist/sessions/metadata.js.map +1 -1
  169. package/dist/sessions/policy.d.ts +3 -10
  170. package/dist/sessions/policy.d.ts.map +1 -1
  171. package/dist/sessions/policy.js.map +1 -1
  172. package/dist/sessions/store.d.ts +1 -12
  173. package/dist/sessions/store.d.ts.map +1 -1
  174. package/dist/sessions/store.js +4 -7
  175. package/dist/sessions/store.js.map +1 -1
  176. package/dist/sessions/types.d.ts +76 -0
  177. package/dist/sessions/types.d.ts.map +1 -0
  178. package/dist/sessions/types.js +2 -0
  179. package/dist/sessions/types.js.map +1 -0
  180. package/dist/store.d.ts +2 -19
  181. package/dist/store.d.ts.map +1 -1
  182. package/dist/store.js +1 -1
  183. package/dist/store.js.map +1 -1
  184. package/dist/tools/event.d.ts +30 -36
  185. package/dist/tools/event.d.ts.map +1 -1
  186. package/dist/tools/event.js +207 -26
  187. package/dist/tools/event.js.map +1 -1
  188. package/dist/tools/index.d.ts +2 -2
  189. package/dist/tools/index.d.ts.map +1 -1
  190. package/dist/tools/index.js.map +1 -1
  191. package/dist/tools/sandbox.d.ts.map +1 -1
  192. package/dist/tools/sandbox.js +1 -1
  193. package/dist/tools/sandbox.js.map +1 -1
  194. package/dist/tools/truncate.d.ts +2 -26
  195. package/dist/tools/truncate.d.ts.map +1 -1
  196. package/dist/tools/truncate.js.map +1 -1
  197. package/dist/tools/types.d.ts +54 -0
  198. package/dist/tools/types.d.ts.map +1 -0
  199. package/dist/tools/types.js +2 -0
  200. package/dist/tools/types.js.map +1 -0
  201. package/dist/trigger.d.ts +2 -13
  202. package/dist/trigger.d.ts.map +1 -1
  203. package/dist/trigger.js.map +1 -1
  204. package/dist/types.d.ts +307 -0
  205. package/dist/types.d.ts.map +1 -0
  206. package/dist/types.js +4 -0
  207. package/dist/types.js.map +1 -0
  208. package/dist/utils/date.d.ts +10 -0
  209. package/dist/utils/date.d.ts.map +1 -0
  210. package/dist/utils/date.js +23 -0
  211. package/dist/utils/date.js.map +1 -0
  212. package/dist/utils/env.d.ts.map +1 -0
  213. package/dist/utils/env.js.map +1 -0
  214. package/dist/utils/file-guards.d.ts.map +1 -0
  215. package/dist/utils/file-guards.js.map +1 -0
  216. package/dist/utils/fs-atomic.d.ts.map +1 -0
  217. package/dist/utils/fs-atomic.js.map +1 -0
  218. package/dist/utils/html.d.ts.map +1 -0
  219. package/dist/utils/html.js.map +1 -0
  220. package/dist/utils/http-body.d.ts +10 -0
  221. package/dist/utils/http-body.d.ts.map +1 -0
  222. package/dist/utils/http-body.js +34 -0
  223. package/dist/utils/http-body.js.map +1 -0
  224. package/dist/vault/index.d.ts +34 -0
  225. package/dist/vault/index.d.ts.map +1 -0
  226. package/dist/{vault.js โ†’ vault/index.js} +4 -4
  227. package/dist/vault/index.js.map +1 -0
  228. package/dist/{vault-routing.d.ts โ†’ vault/routing.d.ts} +2 -2
  229. package/dist/vault/routing.d.ts.map +1 -0
  230. package/dist/{vault-routing.js โ†’ vault/routing.js} +2 -2
  231. package/dist/vault/routing.js.map +1 -0
  232. package/dist/{vault.d.ts โ†’ vault/types.d.ts} +3 -34
  233. package/dist/vault/types.d.ts.map +1 -0
  234. package/dist/vault/types.js +2 -0
  235. package/dist/vault/types.js.map +1 -0
  236. package/dist/web/admin/portal.d.ts +5 -0
  237. package/dist/web/admin/portal.d.ts.map +1 -0
  238. package/dist/{admin โ†’ web/admin}/portal.js +140 -52
  239. package/dist/web/admin/portal.js.map +1 -0
  240. package/dist/web/admin/store.d.ts +13 -0
  241. package/dist/web/admin/store.d.ts.map +1 -0
  242. package/dist/web/admin/store.js +23 -0
  243. package/dist/web/admin/store.js.map +1 -0
  244. package/dist/web/admin/types.d.ts +28 -0
  245. package/dist/web/admin/types.d.ts.map +1 -0
  246. package/dist/web/admin/types.js +2 -0
  247. package/dist/web/admin/types.js.map +1 -0
  248. package/dist/web/login/oauth.d.ts +6 -0
  249. package/dist/web/login/oauth.d.ts.map +1 -0
  250. package/dist/{login/index.js โ†’ web/login/oauth.js} +33 -30
  251. package/dist/web/login/oauth.js.map +1 -0
  252. package/dist/{login โ†’ web/login}/portal.d.ts +5 -5
  253. package/dist/web/login/portal.d.ts.map +1 -0
  254. package/dist/{login โ†’ web/login}/portal.js +16 -35
  255. package/dist/web/login/portal.js.map +1 -0
  256. package/dist/web/login/store.d.ts +12 -0
  257. package/dist/web/login/store.d.ts.map +1 -0
  258. package/dist/web/login/store.js +28 -0
  259. package/dist/web/login/store.js.map +1 -0
  260. package/dist/web/login/types.d.ts +50 -0
  261. package/dist/web/login/types.d.ts.map +1 -0
  262. package/dist/web/login/types.js +2 -0
  263. package/dist/web/login/types.js.map +1 -0
  264. package/dist/web/session-view/command.d.ts +4 -0
  265. package/dist/web/session-view/command.d.ts.map +1 -0
  266. package/dist/{session-view โ†’ web/session-view}/command.js +1 -1
  267. package/dist/web/session-view/command.js.map +1 -0
  268. package/dist/{session-view โ†’ web/session-view}/portal.d.ts +2 -5
  269. package/dist/web/session-view/portal.d.ts.map +1 -0
  270. package/dist/{session-view โ†’ web/session-view}/portal.js +5 -5
  271. package/dist/web/session-view/portal.js.map +1 -0
  272. package/dist/web/session-view/service.d.ts +6 -0
  273. package/dist/web/session-view/service.d.ts.map +1 -0
  274. package/dist/{session-view โ†’ web/session-view}/service.js +6 -36
  275. package/dist/web/session-view/service.js.map +1 -0
  276. package/dist/web/session-view/store.d.ts +8 -0
  277. package/dist/web/session-view/store.d.ts.map +1 -0
  278. package/dist/web/session-view/store.js +20 -0
  279. package/dist/web/session-view/store.js.map +1 -0
  280. package/dist/{session-view/service.d.ts โ†’ web/session-view/types.d.ts} +20 -4
  281. package/dist/web/session-view/types.d.ts.map +1 -0
  282. package/dist/web/session-view/types.js +2 -0
  283. package/dist/web/session-view/types.js.map +1 -0
  284. package/dist/web/token-store.d.ts +19 -0
  285. package/dist/web/token-store.d.ts.map +1 -0
  286. package/dist/web/token-store.js +45 -0
  287. package/dist/web/token-store.js.map +1 -0
  288. package/dist/web/types.d.ts +5 -0
  289. package/dist/web/types.d.ts.map +1 -0
  290. package/dist/web/types.js +2 -0
  291. package/dist/web/types.js.map +1 -0
  292. package/package.json +1 -1
  293. package/dist/adapters/discord/index.d.ts +0 -3
  294. package/dist/adapters/discord/index.d.ts.map +0 -1
  295. package/dist/adapters/discord/index.js +0 -3
  296. package/dist/adapters/discord/index.js.map +0 -1
  297. package/dist/adapters/slack/index.d.ts +0 -3
  298. package/dist/adapters/slack/index.d.ts.map +0 -1
  299. package/dist/adapters/slack/index.js +0 -3
  300. package/dist/adapters/slack/index.js.map +0 -1
  301. package/dist/adapters/slack/thread-manager.d.ts +0 -19
  302. package/dist/adapters/slack/thread-manager.d.ts.map +0 -1
  303. package/dist/adapters/slack/thread-manager.js +0 -11
  304. package/dist/adapters/slack/thread-manager.js.map +0 -1
  305. package/dist/adapters/telegram/index.d.ts +0 -3
  306. package/dist/adapters/telegram/index.d.ts.map +0 -1
  307. package/dist/adapters/telegram/index.js +0 -3
  308. package/dist/adapters/telegram/index.js.map +0 -1
  309. package/dist/admin/portal.d.ts +0 -27
  310. package/dist/admin/portal.d.ts.map +0 -1
  311. package/dist/admin/portal.js.map +0 -1
  312. package/dist/admin/store.d.ts +0 -22
  313. package/dist/admin/store.d.ts.map +0 -1
  314. package/dist/admin/store.js +0 -39
  315. package/dist/admin/store.js.map +0 -1
  316. package/dist/commands/index.d.ts +0 -5
  317. package/dist/commands/index.d.ts.map +0 -1
  318. package/dist/commands/index.js +0 -20
  319. package/dist/commands/index.js.map +0 -1
  320. package/dist/env.d.ts.map +0 -1
  321. package/dist/env.js.map +0 -1
  322. package/dist/file-guards.d.ts.map +0 -1
  323. package/dist/file-guards.js.map +0 -1
  324. package/dist/fs-atomic.d.ts.map +0 -1
  325. package/dist/fs-atomic.js.map +0 -1
  326. package/dist/html.d.ts.map +0 -1
  327. package/dist/html.js.map +0 -1
  328. package/dist/instrument.d.ts.map +0 -1
  329. package/dist/instrument.js.map +0 -1
  330. package/dist/login/index.d.ts +0 -43
  331. package/dist/login/index.d.ts.map +0 -1
  332. package/dist/login/index.js.map +0 -1
  333. package/dist/login/portal.d.ts.map +0 -1
  334. package/dist/login/portal.js.map +0 -1
  335. package/dist/login/store.d.ts +0 -26
  336. package/dist/login/store.d.ts.map +0 -1
  337. package/dist/login/store.js +0 -56
  338. package/dist/login/store.js.map +0 -1
  339. package/dist/runtime/index.d.ts +0 -2
  340. package/dist/runtime/index.d.ts.map +0 -1
  341. package/dist/runtime/index.js +0 -2
  342. package/dist/runtime/index.js.map +0 -1
  343. package/dist/sentry.d.ts.map +0 -1
  344. package/dist/sentry.js.map +0 -1
  345. package/dist/session-view/command.d.ts +0 -5
  346. package/dist/session-view/command.d.ts.map +0 -1
  347. package/dist/session-view/command.js.map +0 -1
  348. package/dist/session-view/portal.d.ts.map +0 -1
  349. package/dist/session-view/portal.js.map +0 -1
  350. package/dist/session-view/service.d.ts.map +0 -1
  351. package/dist/session-view/service.js.map +0 -1
  352. package/dist/session-view/store.d.ts +0 -18
  353. package/dist/session-view/store.d.ts.map +0 -1
  354. package/dist/session-view/store.js +0 -36
  355. package/dist/session-view/store.js.map +0 -1
  356. package/dist/ui-copy.d.ts.map +0 -1
  357. package/dist/ui-copy.js.map +0 -1
  358. package/dist/vault-routing.d.ts.map +0 -1
  359. package/dist/vault-routing.js.map +0 -1
  360. package/dist/vault.d.ts.map +0 -1
  361. package/dist/vault.js.map +0 -1
  362. /package/dist/{instrument.d.ts โ†’ observability/instrument.d.ts} +0 -0
  363. /package/dist/{sentry.js โ†’ observability/sentry.js} +0 -0
  364. /package/dist/{env.d.ts โ†’ utils/env.d.ts} +0 -0
  365. /package/dist/{env.js โ†’ utils/env.js} +0 -0
  366. /package/dist/{file-guards.d.ts โ†’ utils/file-guards.d.ts} +0 -0
  367. /package/dist/{file-guards.js โ†’ utils/file-guards.js} +0 -0
  368. /package/dist/{fs-atomic.d.ts โ†’ utils/fs-atomic.d.ts} +0 -0
  369. /package/dist/{fs-atomic.js โ†’ utils/fs-atomic.js} +0 -0
  370. /package/dist/{html.d.ts โ†’ utils/html.d.ts} +0 -0
  371. /package/dist/{html.js โ†’ utils/html.js} +0 -0
@@ -0,0 +1,45 @@
1
+ import { randomBytes } from "crypto";
2
+ /**
3
+ * Generic in-memory TTL token store.
4
+ *
5
+ * Subclasses call `mintToken(ttlMs)` to create a new token string and
6
+ * expiry, then assemble the full record. The base class provides `peek`,
7
+ * `consume`, and `purge`.
8
+ */
9
+ export class InMemoryTokenStore {
10
+ constructor() {
11
+ this.tokens = new Map();
12
+ }
13
+ mintToken(ttlMs) {
14
+ return {
15
+ token: randomBytes(16).toString("hex"),
16
+ expiresAt: Date.now() + ttlMs,
17
+ };
18
+ }
19
+ peek(rawToken) {
20
+ const entry = this.tokens.get(rawToken);
21
+ if (!entry || Date.now() > entry.expiresAt)
22
+ return undefined;
23
+ return entry;
24
+ }
25
+ /** One-shot consume. Returns undefined if missing or expired. */
26
+ consume(rawToken) {
27
+ const entry = this.tokens.get(rawToken);
28
+ if (!entry)
29
+ return undefined;
30
+ this.tokens.delete(rawToken);
31
+ if (Date.now() > entry.expiresAt)
32
+ return undefined;
33
+ return entry;
34
+ }
35
+ /** Remove expired tokens. Call periodically to bound memory usage. */
36
+ purge() {
37
+ const now = Date.now();
38
+ for (const [key, t] of this.tokens) {
39
+ if (now > t.expiresAt) {
40
+ this.tokens.delete(key);
41
+ }
42
+ }
43
+ }
44
+ }
45
+ //# sourceMappingURL=token-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-store.js","sourceRoot":"","sources":["../../src/web/token-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAIrC;;;;;;GAMG;AACH,MAAM,OAAO,kBAAkB;IAA/B;QACqB,WAAM,GAAG,IAAI,GAAG,EAAa,CAAC;IAiCnD,CAAC;IA/BW,SAAS,CAAC,KAAa;QAC/B,OAAO;YACL,KAAK,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;YACtC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK;SAC9B,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,QAAgB;QACnB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,SAAS;YAAE,OAAO,SAAS,CAAC;QAC7D,OAAO,KAAK,CAAC;IACf,CAAC;IAED,iEAAiE;IACjE,OAAO,CAAC,QAAgB;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,CAAC,KAAK;YAAE,OAAO,SAAS,CAAC;QAC7B,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC7B,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,SAAS;YAAE,OAAO,SAAS,CAAC;QACnD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,sEAAsE;IACtE,KAAK;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACnC,IAAI,GAAG,GAAG,CAAC,CAAC,SAAS,EAAE,CAAC;gBACtB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;IACH,CAAC;CACF","sourcesContent":["import { randomBytes } from \"crypto\";\nexport type { TokenRecord } from \"./types.js\";\nimport type { TokenRecord } from \"./types.js\";\n\n/**\n * Generic in-memory TTL token store.\n *\n * Subclasses call `mintToken(ttlMs)` to create a new token string and\n * expiry, then assemble the full record. The base class provides `peek`,\n * `consume`, and `purge`.\n */\nexport class InMemoryTokenStore<T extends TokenRecord> {\n protected readonly tokens = new Map<string, T>();\n\n protected mintToken(ttlMs: number): Pick<TokenRecord, \"token\" | \"expiresAt\"> {\n return {\n token: randomBytes(16).toString(\"hex\"),\n expiresAt: Date.now() + ttlMs,\n };\n }\n\n peek(rawToken: string): T | undefined {\n const entry = this.tokens.get(rawToken);\n if (!entry || Date.now() > entry.expiresAt) return undefined;\n return entry;\n }\n\n /** One-shot consume. Returns undefined if missing or expired. */\n consume(rawToken: string): T | undefined {\n const entry = this.tokens.get(rawToken);\n if (!entry) return undefined;\n this.tokens.delete(rawToken);\n if (Date.now() > entry.expiresAt) return undefined;\n return entry;\n }\n\n /** Remove expired tokens. Call periodically to bound memory usage. */\n purge(): void {\n const now = Date.now();\n for (const [key, t] of this.tokens) {\n if (now > t.expiresAt) {\n this.tokens.delete(key);\n }\n }\n }\n}\n"]}
@@ -0,0 +1,5 @@
1
+ export interface TokenRecord {
2
+ token: string;
3
+ expiresAt: number;
4
+ }
5
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/web/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB","sourcesContent":["export interface TokenRecord {\n token: string;\n expiresAt: number;\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/web/types.ts"],"names":[],"mappings":"","sourcesContent":["export interface TokenRecord {\n token: string;\n expiresAt: number;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminixiang/mikan",
3
- "version": "0.3.2",
3
+ "version": "0.4.0-beta.0",
4
4
  "description": "Multi-platform AI coding agent for Slack, Telegram, and Discord",
5
5
  "keywords": [
6
6
  "agent",
@@ -1,3 +0,0 @@
1
- export * from "./bot.js";
2
- export * from "./context.js";
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/discord/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,3 +0,0 @@
1
- export * from "./bot.js";
2
- export * from "./context.js";
3
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/adapters/discord/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,3 +0,0 @@
1
- export * from "./bot.js";
2
- export * from "./context.js";
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,3 +0,0 @@
1
- export * from "./bot.js";
2
- export * from "./context.js";
3
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/adapters/slack/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,19 +0,0 @@
1
- import { type ThreadBootstrapWaitOptions } from "../../sessions/chat-session-manager.js";
2
- import type { ResolvedSessionScope } from "../../sessions/store.js";
3
- export type SlackResolvedSessionScope = ResolvedSessionScope;
4
- export interface ResolveSlackSessionScopeOptions {
5
- conversationDir: string;
6
- sessionKey: string;
7
- cwd?: string;
8
- currentMessageId?: string;
9
- }
10
- export interface RegisterSlackThreadSessionOptions {
11
- conversationDir: string;
12
- sessionKey: string;
13
- cwd?: string;
14
- }
15
- export type SlackThreadBootstrapWaitOptions = ThreadBootstrapWaitOptions;
16
- export declare function registerSlackThreadSession(options: RegisterSlackThreadSessionOptions): string | null;
17
- export declare function waitForSlackThreadBootstrap(options: SlackThreadBootstrapWaitOptions): Promise<boolean>;
18
- export declare function resolveSlackSessionScope(options: ResolveSlackSessionScopeOptions): Promise<SlackResolvedSessionScope>;
19
- //# sourceMappingURL=thread-manager.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"thread-manager.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/thread-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,0BAA0B,EAChC,MAAM,wCAAwC,CAAC;AAChD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAEpE,MAAM,MAAM,yBAAyB,GAAG,oBAAoB,CAAC;AAE7D,MAAM,WAAW,+BAA+B;IAC9C,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iCAAiC;IAChD,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,+BAA+B,GAAG,0BAA0B,CAAC;AAEzE,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,MAAM,GAAG,IAAI,CAEf;AAED,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,OAAO,CAAC,CAElB;AAED,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,yBAAyB,CAAC,CAEpC","sourcesContent":["import {\n ChatSessionManager,\n registerThreadSession,\n waitForThreadSessionBootstrap,\n type ThreadBootstrapWaitOptions,\n} from \"../../sessions/chat-session-manager.js\";\nimport type { ResolvedSessionScope } from \"../../sessions/store.js\";\n\nexport type SlackResolvedSessionScope = ResolvedSessionScope;\n\nexport interface ResolveSlackSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n cwd?: string;\n currentMessageId?: string;\n}\n\nexport interface RegisterSlackThreadSessionOptions {\n conversationDir: string;\n sessionKey: string;\n cwd?: string;\n}\n\nexport type SlackThreadBootstrapWaitOptions = ThreadBootstrapWaitOptions;\n\nexport function registerSlackThreadSession(\n options: RegisterSlackThreadSessionOptions,\n): string | null {\n return registerThreadSession(options);\n}\n\nexport async function waitForSlackThreadBootstrap(\n options: SlackThreadBootstrapWaitOptions,\n): Promise<boolean> {\n return waitForThreadSessionBootstrap(options);\n}\n\nexport async function resolveSlackSessionScope(\n options: ResolveSlackSessionScopeOptions,\n): Promise<SlackResolvedSessionScope> {\n return new ChatSessionManager().resolveSessionScope(options);\n}\n"]}
@@ -1,11 +0,0 @@
1
- import { ChatSessionManager, registerThreadSession, waitForThreadSessionBootstrap, } from "../../sessions/chat-session-manager.js";
2
- export function registerSlackThreadSession(options) {
3
- return registerThreadSession(options);
4
- }
5
- export async function waitForSlackThreadBootstrap(options) {
6
- return waitForThreadSessionBootstrap(options);
7
- }
8
- export async function resolveSlackSessionScope(options) {
9
- return new ChatSessionManager().resolveSessionScope(options);
10
- }
11
- //# sourceMappingURL=thread-manager.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"thread-manager.js","sourceRoot":"","sources":["../../../src/adapters/slack/thread-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,6BAA6B,GAE9B,MAAM,wCAAwC,CAAC;AAoBhD,MAAM,UAAU,0BAA0B,CACxC,OAA0C;IAE1C,OAAO,qBAAqB,CAAC,OAAO,CAAC,CAAC;AACxC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAC/C,OAAwC;IAExC,OAAO,6BAA6B,CAAC,OAAO,CAAC,CAAC;AAChD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC5C,OAAwC;IAExC,OAAO,IAAI,kBAAkB,EAAE,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC;AAC/D,CAAC","sourcesContent":["import {\n ChatSessionManager,\n registerThreadSession,\n waitForThreadSessionBootstrap,\n type ThreadBootstrapWaitOptions,\n} from \"../../sessions/chat-session-manager.js\";\nimport type { ResolvedSessionScope } from \"../../sessions/store.js\";\n\nexport type SlackResolvedSessionScope = ResolvedSessionScope;\n\nexport interface ResolveSlackSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n cwd?: string;\n currentMessageId?: string;\n}\n\nexport interface RegisterSlackThreadSessionOptions {\n conversationDir: string;\n sessionKey: string;\n cwd?: string;\n}\n\nexport type SlackThreadBootstrapWaitOptions = ThreadBootstrapWaitOptions;\n\nexport function registerSlackThreadSession(\n options: RegisterSlackThreadSessionOptions,\n): string | null {\n return registerThreadSession(options);\n}\n\nexport async function waitForSlackThreadBootstrap(\n options: SlackThreadBootstrapWaitOptions,\n): Promise<boolean> {\n return waitForThreadSessionBootstrap(options);\n}\n\nexport async function resolveSlackSessionScope(\n options: ResolveSlackSessionScopeOptions,\n): Promise<SlackResolvedSessionScope> {\n return new ChatSessionManager().resolveSessionScope(options);\n}\n"]}
@@ -1,3 +0,0 @@
1
- export * from "./bot.js";
2
- export * from "./context.js";
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/telegram/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,3 +0,0 @@
1
- export * from "./bot.js";
2
- export * from "./context.js";
3
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/adapters/telegram/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,27 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from "http";
2
- import type { Bot, PlatformName, RunningSession } from "../adapter.js";
3
- import type { SandboxConfig } from "../sandbox/index.js";
4
- import type { InMemorySessionViewTokenStore } from "../session-view/store.js";
5
- import { type VaultManager } from "../vault.js";
6
- import type { InMemoryAdminTokenStore } from "./store.js";
7
- export interface AdminRuntimeBridge {
8
- getRunningSessions(): RunningSession[];
9
- switchConversationModel(conversationId: string, provider: string, model: string): boolean;
10
- }
11
- export interface AdminServices {
12
- vaultManager: VaultManager;
13
- linkTokenStore: {
14
- create(platform: PlatformName, platformUserId: string, conversationId: string, vaultId: string, providerId: string): {
15
- token: string;
16
- };
17
- };
18
- sessionViewTokenStore?: InMemorySessionViewTokenStore;
19
- adminTokenStore: InMemoryAdminTokenStore;
20
- portalBaseUrl?: string;
21
- workingDir?: string;
22
- sandbox?: SandboxConfig;
23
- runtime?: AdminRuntimeBridge;
24
- botsByPlatform?: Partial<Record<PlatformName, Bot>>;
25
- }
26
- export declare function handleAdminRequest(req: IncomingMessage, res: ServerResponse, url: URL, services: AdminServices): boolean;
27
- //# sourceMappingURL=portal.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../src/admin/portal.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAI5D,OAAO,KAAK,EAAE,GAAG,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAavE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,0BAA0B,CAAC;AAG9E,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,KAAK,EAAc,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAItE,MAAM,WAAW,kBAAkB;IACjC,kBAAkB,IAAI,cAAc,EAAE,CAAC;IACvC,uBAAuB,CAAC,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC3F;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,YAAY,CAAC;IAC3B,cAAc,EAAE;QACd,MAAM,CACJ,QAAQ,EAAE,YAAY,EACtB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC;KACtB,CAAC;IACF,qBAAqB,CAAC,EAAE,6BAA6B,CAAC;IACtD,eAAe,EAAE,uBAAuB,CAAC;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B,cAAc,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,CAAC,CAAC,CAAC;CACrD;AAID,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,aAAa,GACtB,OAAO,CA6BT","sourcesContent":["import { existsSync, readdirSync, readFileSync, rmSync, statSync } from \"fs\";\nimport type { IncomingMessage, ServerResponse } from \"http\";\nimport { homedir } from \"os\";\nimport { join, resolve as pathResolve, sep as pathSep } from \"path\";\nimport { AuthStorage, ModelRegistry } from \"@earendil-works/pi-coding-agent\";\nimport type { Bot, PlatformName, RunningSession } from \"../adapter.js\";\nimport {\n loadAgentConfig,\n loadAgentConfigForConversation,\n loadConversationAutoReplyConfig,\n saveAgentConfig,\n saveConversationAutoReplyConfig,\n saveConversationModelConfig,\n saveConversationSandboxConfig,\n type AgentConfig,\n} from \"../config.js\";\nimport { escapeHtml } from \"../html.js\";\nimport { renderPortalShell } from \"../portal-shell.js\";\nimport type { SandboxConfig } from \"../sandbox/index.js\";\nimport { resolveExistingSessionFile } from \"../session-view/service.js\";\nimport type { InMemorySessionViewTokenStore } from \"../session-view/store.js\";\nimport { PRODUCT_NAME } from \"../ui-copy.js\";\nimport { resolveActorVaultKey } from \"../vault-routing.js\";\nimport { sharedVaultKey, type VaultManager } from \"../vault.js\";\nimport type { AdminToken, InMemoryAdminTokenStore } from \"./store.js\";\n\n// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nexport interface AdminRuntimeBridge {\n getRunningSessions(): RunningSession[];\n switchConversationModel(conversationId: string, provider: string, model: string): boolean;\n}\n\nexport interface AdminServices {\n vaultManager: VaultManager;\n linkTokenStore: {\n create(\n platform: PlatformName,\n platformUserId: string,\n conversationId: string,\n vaultId: string,\n providerId: string,\n ): { token: string };\n };\n sessionViewTokenStore?: InMemorySessionViewTokenStore;\n adminTokenStore: InMemoryAdminTokenStore;\n portalBaseUrl?: string;\n workingDir?: string;\n sandbox?: SandboxConfig;\n runtime?: AdminRuntimeBridge;\n botsByPlatform?: Partial<Record<PlatformName, Bot>>;\n}\n\n// โ”€โ”€ Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nexport function handleAdminRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n): boolean {\n if (!url.pathname.startsWith(\"/admin\")) return false;\n\n if (req.method === \"GET\" && url.pathname === \"/admin\") {\n const provided = url.searchParams.get(\"token\") ?? \"\";\n const token = services.adminTokenStore.peek(provided);\n if (!token) {\n res.writeHead(403, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderAdminErrorPage(\n \"Admin link is missing, invalid, or expired. Send `/admin` to the bot to get a fresh link.\",\n ),\n );\n return true;\n }\n res.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(renderAdminPage(token));\n return true;\n }\n\n if (url.pathname.startsWith(\"/admin/api/\")) {\n routeApiRequest(req, res, url, services);\n return true;\n }\n\n return false;\n}\n\n// โ”€โ”€ API routing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nfunction routeApiRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n): void {\n if (req.method === \"GET\") {\n const token = services.adminTokenStore.peek(url.searchParams.get(\"token\") ?? \"\");\n if (!token) {\n jsonRes(res, 403, { error: \"Unauthorized\" });\n return;\n }\n if (url.pathname === \"/admin/api/me\") {\n serveMe(res, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations\") {\n serveConversationsList(res, services);\n return;\n }\n if (url.pathname === \"/admin/api/conversation-state\") {\n serveConversationState(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/settings/global\") {\n serveGlobalSettings(res);\n return;\n }\n if (url.pathname === \"/admin/api/models\") {\n void serveModelsList(res);\n return;\n }\n if (url.pathname === \"/admin/api/workspace/tree\") {\n serveWorkspaceTree(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/workspace/file\") {\n serveWorkspaceFile(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/skills\") {\n serveSkillsList(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/skills/file\") {\n serveSkillFile(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/events\") {\n serveEventsList(res, services);\n return;\n }\n if (url.pathname === \"/admin/api/events/file\") {\n serveEventsFile(res, url, services);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/events\") {\n serveConversationEventsList(res, url, services, token);\n return;\n }\n jsonRes(res, 404, { error: \"Not found\" });\n return;\n }\n\n if (req.method === \"POST\") {\n void readJsonBody(req, res, (body) => {\n const rawToken = typeof body.token === \"string\" ? body.token : \"\";\n const token = services.adminTokenStore.peek(rawToken);\n if (!token) {\n jsonRes(res, 403, { error: \"Unauthorized\" });\n return;\n }\n if (url.pathname === \"/admin/api/conversations/model\") {\n serveConversationModelUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/sandbox\") {\n serveConversationSandboxUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/auto-reply\") {\n serveConversationAutoReplyUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/session-link\") {\n serveConversationSessionLink(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/login-link\") {\n serveConversationLoginLink(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/events/delete\") {\n serveConversationEventDelete(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/settings/model\") {\n serveGlobalModelUpdate(res, body);\n return;\n }\n if (url.pathname === \"/admin/api/settings/sandbox\") {\n serveGlobalSandboxUpdate(res, body);\n return;\n }\n jsonRes(res, 404, { error: \"Not found\" });\n });\n return;\n }\n\n jsonRes(res, 405, { error: \"Method not allowed\" });\n}\n\n// โ”€โ”€ Scope helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nfunction resolveTargetConversation(\n body: Record<string, unknown>,\n token: AdminToken,\n): { conversationId: string; error?: string } {\n const requested = typeof body.conversationId === \"string\" ? body.conversationId.trim() : \"\";\n if (!requested) return { conversationId: token.conversationId };\n if (requested === token.conversationId) return { conversationId: requested };\n if (requested.includes(\"/\") || requested.includes(\"..\")) {\n return { conversationId: requested, error: \"Invalid conversationId.\" };\n }\n return { conversationId: requested };\n}\n\nfunction requireAdminWorkingDir(res: ServerResponse, services: AdminServices): string | null {\n if (!services.workingDir) {\n jsonRes(res, 503, { error: \"Working directory not available\" });\n return null;\n }\n return services.workingDir;\n}\n\n// โ”€โ”€ API handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nfunction serveMe(res: ServerResponse, token: AdminToken): void {\n jsonRes(res, 200, {\n platform: token.platform,\n platformUserId: token.platformUserId,\n platformUserName: token.platformUserName ?? null,\n conversationId: token.conversationId,\n expiresAt: token.expiresAt,\n });\n}\n\nconst SETTINGS_FILES = new Set([\"settings.json\", \"auto-reply\", \"auto-reply.disabled\"]);\n\nfunction listConversationDirs(workingDir: string): string[] {\n if (!existsSync(workingDir)) return [];\n const skip = new Set([\"vaults\", \"skills\", \"events\", \"node_modules\", \".git\"]);\n return readdirSync(workingDir, { withFileTypes: true })\n .filter((entry) => entry.isDirectory() && !entry.name.startsWith(\".\") && !skip.has(entry.name))\n .map((entry) => entry.name)\n .filter((name) => {\n const dir = join(workingDir, name);\n try {\n const items = readdirSync(dir);\n return items.some((item) => SETTINGS_FILES.has(item) || item.endsWith(\".jsonl\"));\n } catch {\n return false;\n }\n })\n .toSorted((a, b) => a.localeCompare(b));\n}\n\nfunction conversationLastActivity(workingDir: string, conversationId: string): number | null {\n const dir = join(workingDir, conversationId);\n if (!existsSync(dir)) return null;\n let latest = 0;\n const visit = (path: string, depth: number): void => {\n if (depth > 3) return;\n let entries;\n try {\n entries = readdirSync(path, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n const full = join(path, entry.name);\n if (entry.isDirectory()) {\n visit(full, depth + 1);\n continue;\n }\n try {\n const stats = statSync(full);\n if (stats.mtimeMs > latest) latest = stats.mtimeMs;\n } catch {\n // ignore\n }\n }\n };\n visit(dir, 0);\n return latest > 0 ? latest : null;\n}\n\nfunction conversationDisplayLabel(services: AdminServices, conversationId: string): string {\n for (const [platform, bot] of Object.entries(services.botsByPlatform ?? {})) {\n const channel = bot?.getPlatformInfo().channels.find((c) => c.id === conversationId);\n if (channel) return `${platform}:#${channel.name}:${conversationId}`;\n }\n return conversationId;\n}\n\nfunction serveConversationsList(res: ServerResponse, services: AdminServices): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n\n const ids = listConversationDirs(workingDir);\n\n const runningKeys = new Set<string>(\n services.runtime?.getRunningSessions().map((s) => s.sessionKey) ?? [],\n );\n\n const conversations = ids.map((conversationId) => {\n const lastActivity = conversationLastActivity(workingDir, conversationId);\n const running = Array.from(runningKeys).some(\n (key) => key === conversationId || key.startsWith(`${conversationId}:`),\n );\n return {\n conversationId,\n label: conversationDisplayLabel(services, conversationId),\n running,\n lastActivityAt: lastActivity,\n };\n });\n\n jsonRes(res, 200, { conversations });\n}\n\nfunction serveConversationState(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n\n const requested = url.searchParams.get(\"conversationId\")?.trim() ?? \"\";\n const conversationId = requested || token.conversationId;\n if (conversationId.includes(\"/\") || conversationId.includes(\"..\")) {\n jsonRes(res, 400, { error: \"Invalid conversationId\" });\n return;\n }\n\n const dir = join(workingDir, conversationId);\n let modelConfig: AgentConfig | null = null;\n try {\n modelConfig = loadAgentConfigForConversation(dir);\n } catch {\n modelConfig = null;\n }\n const autoReply = loadConversationAutoReplyConfig(dir);\n\n jsonRes(res, 200, {\n conversationId,\n provider: modelConfig?.provider ?? null,\n model: modelConfig?.model ?? null,\n thinkingLevel: modelConfig?.thinkingLevel ?? null,\n sandboxImageWorkspaceMount: modelConfig?.sandboxImageWorkspaceMount ?? null,\n autoReplyEnabled: autoReply.enabled,\n autoReplyRules: autoReply.rules,\n });\n}\n\nfunction serveGlobalSettings(res: ServerResponse): void {\n try {\n const config = loadAgentConfig();\n jsonRes(res, 200, {\n provider: config.provider,\n model: config.model,\n thinkingLevel: config.thinkingLevel,\n sandboxCpus: config.sandboxCpus ?? null,\n sandboxMemory: config.sandboxMemory ?? null,\n sandboxBoostCpus: config.sandboxBoostCpus ?? null,\n sandboxBoostMemory: config.sandboxBoostMemory ?? null,\n sandboxImageWorkspaceMount: config.sandboxImageWorkspaceMount ?? null,\n defaultSharedVault: config.defaultSharedVault ?? null,\n });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nasync function serveModelsList(res: ServerResponse): Promise<void> {\n try {\n const authStorage = AuthStorage.create(join(homedir(), \".pi\", \"mikan\", \"auth.json\"));\n const registry = ModelRegistry.create(authStorage);\n const models = (await registry.getAvailable()).map((model) => ({\n provider: model.provider,\n id: model.id,\n name: model.name ?? model.id,\n reasoning: model.reasoning,\n input: model.input,\n contextWindow: model.contextWindow,\n maxTokens: model.maxTokens,\n }));\n jsonRes(res, 200, { models });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nconst VALID_THINKING_LEVELS = new Set([\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"]);\n\nfunction serveConversationModelUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const provider = typeof body.provider === \"string\" ? body.provider.trim() : \"\";\n const model = typeof body.model === \"string\" ? body.model.trim() : \"\";\n const thinkingLevel =\n typeof body.thinkingLevel === \"string\" && VALID_THINKING_LEVELS.has(body.thinkingLevel)\n ? (body.thinkingLevel as AgentConfig[\"thinkingLevel\"])\n : undefined;\n\n if (!provider || !model) {\n jsonRes(res, 400, { error: \"Missing provider or model\" });\n return;\n }\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n\n try {\n saveConversationModelConfig(dir, {\n provider,\n model,\n ...(thinkingLevel ? { thinkingLevel } : {}),\n });\n let runtimeSwitched: boolean | null = null;\n if (services.runtime) {\n runtimeSwitched = services.runtime.switchConversationModel(\n scope.conversationId,\n provider,\n model,\n );\n }\n jsonRes(res, 200, { ok: true, runtimeSwitched });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationSandboxUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const workspaceMount = body.workspaceMount;\n if (workspaceMount !== \"private\" && workspaceMount !== \"full\") {\n jsonRes(res, 400, { error: \"workspaceMount must be 'private' or 'full'\" });\n return;\n }\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n try {\n saveConversationSandboxConfig(dir, { imageWorkspaceMount: workspaceMount });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationAutoReplyUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const enabled = body.enabled === true;\n const rules = Array.isArray(body.rules)\n ? body.rules.filter((r): r is string => typeof r === \"string\")\n : undefined;\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n try {\n const existing = loadConversationAutoReplyConfig(dir);\n saveConversationAutoReplyConfig(dir, {\n enabled,\n rules: rules ?? existing.rules,\n });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationSessionLink(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n if (!services.sessionViewTokenStore) {\n jsonRes(res, 503, { error: \"Session view token store not available\" });\n return;\n }\n if (!services.portalBaseUrl) {\n jsonRes(res, 503, {\n error: \"Portal URL not configured. Set MIKAN_LINK_URL to enable link generation.\",\n });\n return;\n }\n\n const sessionFile = resolveExistingSessionFile(\n workingDir,\n scope.conversationId,\n scope.conversationId,\n );\n if (!sessionFile) {\n jsonRes(res, 404, { error: \"No session file found for this conversation\" });\n return;\n }\n\n try {\n const { token: viewToken } = services.sessionViewTokenStore.create(\n token.platform,\n token.platformUserId,\n scope.conversationId,\n scope.conversationId,\n sessionFile,\n token.platformUserName,\n );\n const url = `${services.portalBaseUrl}/session?token=${encodeURIComponent(viewToken)}`;\n jsonRes(res, 200, { ok: true, url });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationLoginLink(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n if (!services.portalBaseUrl) {\n jsonRes(res, 503, { error: \"Portal URL not configured.\" });\n return;\n }\n if (!services.sandbox) {\n jsonRes(res, 503, { error: \"Sandbox config not available.\" });\n return;\n }\n const sharedName = typeof body.sharedVault === \"string\" ? body.sharedVault.trim() : \"\";\n let vaultId: string;\n if (sharedName) {\n const key = sharedVaultKey(sharedName);\n if (!key) {\n jsonRes(res, 400, { error: \"Invalid shared vault name\" });\n return;\n }\n vaultId = key;\n } else {\n try {\n vaultId = resolveActorVaultKey(services.sandbox, token.platformUserId, scope.conversationId);\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n return;\n }\n }\n try {\n const { token: linkToken } = services.linkTokenStore.create(\n token.platform,\n token.platformUserId,\n scope.conversationId,\n vaultId,\n \"\",\n );\n const url = `${services.portalBaseUrl}/link?token=${encodeURIComponent(linkToken)}`;\n jsonRes(res, 200, { ok: true, url, vaultId });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveGlobalModelUpdate(res: ServerResponse, body: Record<string, unknown>): void {\n const provider = typeof body.provider === \"string\" ? body.provider.trim() : \"\";\n const model = typeof body.model === \"string\" ? body.model.trim() : \"\";\n const thinkingLevel =\n typeof body.thinkingLevel === \"string\" && VALID_THINKING_LEVELS.has(body.thinkingLevel)\n ? (body.thinkingLevel as AgentConfig[\"thinkingLevel\"])\n : undefined;\n\n if (!provider || !model) {\n jsonRes(res, 400, { error: \"Missing provider or model\" });\n return;\n }\n\n try {\n saveAgentConfig({\n provider,\n model,\n ...(thinkingLevel ? { thinkingLevel } : {}),\n });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveGlobalSandboxUpdate(res: ServerResponse, body: Record<string, unknown>): void {\n const cpus = typeof body.cpus === \"string\" ? body.cpus.trim() : \"\";\n const memory = typeof body.memory === \"string\" ? body.memory.trim() : \"\";\n const boostCpus = typeof body.boostCpus === \"string\" ? body.boostCpus.trim() : \"\";\n const boostMemory = typeof body.boostMemory === \"string\" ? body.boostMemory.trim() : \"\";\n const workspaceMount = body.workspaceMount;\n const validMount = workspaceMount === \"private\" || workspaceMount === \"full\";\n\n const update: Partial<AgentConfig> = {};\n if (cpus) update.sandboxCpus = cpus;\n if (memory) update.sandboxMemory = memory;\n if (boostCpus) update.sandboxBoostCpus = boostCpus;\n if (boostMemory) update.sandboxBoostMemory = boostMemory;\n if (validMount) update.sandboxImageWorkspaceMount = workspaceMount as \"private\" | \"full\";\n\n if (Object.keys(update).length === 0) {\n jsonRes(res, 400, { error: \"No valid sandbox fields provided\" });\n return;\n }\n\n try {\n saveAgentConfig(update);\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\n// โ”€โ”€ Workspace โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nconst WORKSPACE_TREE_MAX_DEPTH = 4;\nconst WORKSPACE_TREE_MAX_ENTRIES = 800;\nconst PREVIEW_FILE_MAX_BYTES = 256 * 1024;\n\nconst WORKSPACE_TOP_FILES = new Set([\"auto-reply\", \"auto-reply.disabled\"]);\nconst WORKSPACE_TOP_DIRS = new Set([\"scratch\"]);\n\n/**\n * Limit what the admin UI can browse under a conversation directory.\n * Allowed: top-level \"scratch/\" subtree, and the two auto-reply marker files.\n */\nfunction isWorkspacePathAllowed(rel: string): boolean {\n if (rel === \"\") return true;\n const segments = rel.split(\"/\").filter(Boolean);\n if (segments.length === 0) return true;\n const first = segments[0];\n if (segments.length === 1) {\n return WORKSPACE_TOP_DIRS.has(first) || WORKSPACE_TOP_FILES.has(first);\n }\n return WORKSPACE_TOP_DIRS.has(first);\n}\n\nfunction resolveConversationFromQuery(\n url: URL,\n token: AdminToken,\n): { conversationId: string; error?: string } {\n const requested = (url.searchParams.get(\"conversationId\") ?? \"\").trim();\n if (!requested) return { conversationId: token.conversationId };\n if (requested === token.conversationId) return { conversationId: requested };\n if (requested.includes(\"/\") || requested.includes(\"..\")) {\n return { conversationId: requested, error: \"Invalid conversationId.\" };\n }\n return { conversationId: requested };\n}\n\ninterface SafePathResult {\n absolute: string;\n error?: string;\n}\n\nfunction safeJoinUnderRoot(rootDir: string, relative: string): SafePathResult {\n if (relative.startsWith(\"/\") || relative.includes(\"\\0\")) {\n return { absolute: \"\", error: \"Invalid path\" };\n }\n if (relative.split(/[\\\\/]+/).some((part) => part === \"..\" || part === \"\")) {\n if (relative !== \"\") return { absolute: \"\", error: \"Invalid path\" };\n }\n const target = pathResolve(rootDir, relative);\n const rootAbs = pathResolve(rootDir);\n if (target !== rootAbs && !target.startsWith(rootAbs + pathSep)) {\n return { absolute: \"\", error: \"Path escapes conversation directory\" };\n }\n return { absolute: target };\n}\n\ninterface TreeNode {\n name: string;\n path: string;\n type: \"dir\" | \"file\";\n size?: number;\n mtimeMs?: number;\n children?: TreeNode[];\n truncated?: boolean;\n}\n\nfunction buildTree(startDir: string, relPrefix: string): TreeNode | null {\n let counter = { value: 0 };\n const walk = (dir: string, rel: string, depth: number): TreeNode | null => {\n if (counter.value >= WORKSPACE_TREE_MAX_ENTRIES) return null;\n let stats;\n try {\n stats = statSync(dir);\n } catch {\n return null;\n }\n const name = rel === \"\" ? \".\" : (rel.split(/[\\\\/]/).pop() ?? rel);\n if (!stats.isDirectory()) {\n counter.value += 1;\n return {\n name,\n path: rel,\n type: \"file\",\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n };\n }\n counter.value += 1;\n if (depth >= WORKSPACE_TREE_MAX_DEPTH) {\n return { name, path: rel, type: \"dir\", truncated: true };\n }\n let entries;\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n return { name, path: rel, type: \"dir\" };\n }\n const children: TreeNode[] = [];\n let truncated = false;\n for (const entry of entries.toSorted((a, b) => {\n if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;\n return a.name.localeCompare(b.name);\n })) {\n const childRel = rel === \"\" ? entry.name : `${rel}/${entry.name}`;\n if (!isWorkspacePathAllowed(childRel)) continue;\n if (counter.value >= WORKSPACE_TREE_MAX_ENTRIES) {\n truncated = true;\n break;\n }\n const node = walk(join(dir, entry.name), childRel, depth + 1);\n if (node) children.push(node);\n }\n return {\n name,\n path: rel,\n type: \"dir\",\n children,\n ...(truncated ? { truncated: true } : {}),\n };\n };\n const node = walk(startDir, relPrefix, 0);\n return node;\n}\n\nfunction serveWorkspaceTree(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const convDir = join(workingDir, scope.conversationId);\n if (!existsSync(convDir)) {\n jsonRes(res, 200, { conversationId: scope.conversationId, tree: null });\n return;\n }\n const requestedSub = (url.searchParams.get(\"path\") ?? \"\").trim();\n if (!isWorkspacePathAllowed(requestedSub)) {\n jsonRes(res, 403, { error: \"Workspace path is not exposed\" });\n return;\n }\n const startSafe = safeJoinUnderRoot(convDir, requestedSub);\n if (startSafe.error) {\n jsonRes(res, 400, { error: startSafe.error });\n return;\n }\n const tree = buildTree(startSafe.absolute, requestedSub);\n jsonRes(res, 200, {\n conversationId: scope.conversationId,\n root: requestedSub || \".\",\n tree,\n });\n}\n\nconst BINARY_PROBE_BYTES = 4096;\n\nfunction looksTextual(buf: Buffer): boolean {\n const limit = Math.min(buf.length, BINARY_PROBE_BYTES);\n for (let i = 0; i < limit; i++) {\n const byte = buf[i];\n if (byte === 0) return false;\n if (byte < 9) return false;\n if (byte === 11 || byte === 12) return false;\n if (byte > 13 && byte < 32) return false;\n }\n return true;\n}\n\nfunction servePreviewFile(\n res: ServerResponse,\n absolutePath: string,\n metadata: Record<string, unknown>,\n notFoundMessage: string,\n): void {\n let stats;\n try {\n stats = statSync(absolutePath);\n } catch {\n jsonRes(res, 404, { error: notFoundMessage });\n return;\n }\n if (!stats.isFile()) {\n jsonRes(res, 400, { error: \"Not a file\" });\n return;\n }\n if (stats.size > PREVIEW_FILE_MAX_BYTES) {\n jsonRes(res, 413, {\n error: \"File too large to preview\",\n size: stats.size,\n limit: PREVIEW_FILE_MAX_BYTES,\n });\n return;\n }\n let buf: Buffer;\n try {\n buf = readFileSync(absolutePath);\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n return;\n }\n if (!looksTextual(buf)) {\n jsonRes(res, 200, {\n ...metadata,\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n binary: true,\n content: null,\n });\n return;\n }\n jsonRes(res, 200, {\n ...metadata,\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n binary: false,\n content: buf.toString(\"utf-8\"),\n });\n}\n\nfunction serveWorkspaceFile(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const requestedPath = (url.searchParams.get(\"path\") ?? \"\").trim();\n if (!requestedPath) {\n jsonRes(res, 400, { error: \"Missing path\" });\n return;\n }\n if (!isWorkspacePathAllowed(requestedPath)) {\n jsonRes(res, 403, { error: \"Workspace path is not exposed\" });\n return;\n }\n const convDir = join(workingDir, scope.conversationId);\n const safe = safeJoinUnderRoot(convDir, requestedPath);\n if (safe.error) {\n jsonRes(res, 400, { error: safe.error });\n return;\n }\n servePreviewFile(res, safe.absolute, { path: requestedPath }, \"File not found\");\n}\n\n// โ”€โ”€ Skills โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\ninterface SkillEntry {\n name: string;\n description: string;\n source: \"global\" | \"conversation\";\n path: string;\n directory: string;\n}\n\nfunction parseSkillFrontmatter(filePath: string): { name?: string; description?: string } {\n let text: string;\n try {\n text = readFileSync(filePath, \"utf-8\");\n } catch {\n return {};\n }\n if (!text.startsWith(\"---\")) return {};\n const end = text.indexOf(\"\\n---\", 3);\n if (end < 0) return {};\n const block = text.slice(3, end);\n const out: { name?: string; description?: string } = {};\n for (const line of block.split(\"\\n\")) {\n const colon = line.indexOf(\":\");\n if (colon < 0) continue;\n const key = line.slice(0, colon).trim().toLowerCase();\n let value = line.slice(colon + 1).trim();\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n if (key === \"name\") out.name = value;\n if (key === \"description\") out.description = value;\n }\n return out;\n}\n\nfunction readSkillsFromDir(skillsDir: string, source: SkillEntry[\"source\"]): SkillEntry[] {\n if (!existsSync(skillsDir)) return [];\n const out: SkillEntry[] = [];\n let entries;\n try {\n entries = readdirSync(skillsDir, { withFileTypes: true });\n } catch {\n return [];\n }\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name.startsWith(\".\")) continue;\n const skillMd = join(skillsDir, entry.name, \"SKILL.md\");\n if (!existsSync(skillMd)) continue;\n const meta = parseSkillFrontmatter(skillMd);\n out.push({\n name: meta.name ?? entry.name,\n description: meta.description ?? \"\",\n source,\n path: skillMd,\n directory: entry.name,\n });\n }\n return out.toSorted((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction serveSkillsList(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const global = readSkillsFromDir(join(workingDir, \"skills\"), \"global\");\n const conversation = readSkillsFromDir(\n join(workingDir, scope.conversationId, \"skills\"),\n \"conversation\",\n );\n jsonRes(res, 200, {\n conversationId: scope.conversationId,\n skills: [...global, ...conversation],\n });\n}\n\nfunction serveSkillFile(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n\n const source = (url.searchParams.get(\"source\") ?? \"\").trim();\n const directory = (url.searchParams.get(\"directory\") ?? \"\").trim();\n if (source !== \"global\" && source !== \"conversation\") {\n jsonRes(res, 400, { error: \"Invalid skill source\" });\n return;\n }\n if (\n !directory ||\n directory.includes(\"/\") ||\n directory.includes(\"\\\\\") ||\n directory.includes(\"..\")\n ) {\n jsonRes(res, 400, { error: \"Invalid skill directory\" });\n return;\n }\n\n const skillsRoot =\n source === \"global\"\n ? join(workingDir, \"skills\")\n : join(workingDir, scope.conversationId, \"skills\");\n const safe = safeJoinUnderRoot(skillsRoot, join(directory, \"SKILL.md\"));\n if (safe.error) {\n jsonRes(res, 400, { error: safe.error });\n return;\n }\n\n servePreviewFile(res, safe.absolute, { source, directory }, \"Skill file not found\");\n}\n\n// โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nconst EVENTS_FILE_MAX_BYTES = 64 * 1024;\n\ninterface EventSummary {\n name: string;\n size: number;\n mtimeMs: number;\n type: string | null;\n platform: string | null;\n conversationId: string | null;\n text: string | null;\n at: string | null;\n schedule: string | null;\n timezone: string | null;\n}\n\nfunction listAllEvents(workingDir: string): EventSummary[] {\n const dir = join(workingDir, \"events\");\n if (!existsSync(dir)) return [];\n let entries;\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n return [];\n }\n return entries\n .filter((e) => e.isFile() && e.name.endsWith(\".json\"))\n .map((e): EventSummary | null => {\n const filePath = join(dir, e.name);\n let stats;\n try {\n stats = statSync(filePath);\n } catch {\n return null;\n }\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(readFileSync(filePath, \"utf-8\"));\n } catch {\n // Keep entry; just omit parsed fields.\n }\n const meta = parsed && typeof parsed === \"object\" ? (parsed as Record<string, unknown>) : {};\n // events.ts accepts `channelId` as a legacy alias for `conversationId`.\n const conversationId =\n typeof meta.conversationId === \"string\"\n ? meta.conversationId\n : typeof meta.channelId === \"string\"\n ? meta.channelId\n : null;\n return {\n name: e.name,\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n type: typeof meta.type === \"string\" ? meta.type : null,\n platform: typeof meta.platform === \"string\" ? meta.platform : null,\n conversationId,\n text: typeof meta.text === \"string\" ? meta.text : null,\n at: typeof meta.at === \"string\" ? meta.at : null,\n schedule: typeof meta.schedule === \"string\" ? meta.schedule : null,\n timezone: typeof meta.timezone === \"string\" ? meta.timezone : null,\n };\n })\n .filter((e): e is EventSummary => e !== null)\n .toSorted((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction serveEventsList(res: ServerResponse, services: AdminServices): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n jsonRes(res, 200, { events: listAllEvents(workingDir) });\n}\n\n/** Per-conversation listing โ€” filter all events by conversationId match. */\nfunction serveConversationEventsList(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const events = listAllEvents(workingDir).filter((e) => e.conversationId === scope.conversationId);\n jsonRes(res, 200, { conversationId: scope.conversationId, events });\n}\n\nfunction serveEventsFile(res: ServerResponse, url: URL, services: AdminServices): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const name = (url.searchParams.get(\"name\") ?? \"\").trim();\n if (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"..\")) {\n jsonRes(res, 400, { error: \"Invalid name\" });\n return;\n }\n const filePath = join(workingDir, \"events\", name);\n let stats;\n try {\n stats = statSync(filePath);\n } catch {\n jsonRes(res, 404, { error: \"Not found\" });\n return;\n }\n if (!stats.isFile()) {\n jsonRes(res, 400, { error: \"Not a file\" });\n return;\n }\n if (stats.size > EVENTS_FILE_MAX_BYTES) {\n jsonRes(res, 413, { error: \"File too large\" });\n return;\n }\n let raw: string;\n try {\n raw = readFileSync(filePath, \"utf-8\");\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n return;\n }\n jsonRes(res, 200, { name, content: raw });\n}\n\n/** Delete a single event file scoped to the caller's conversation. */\nfunction serveConversationEventDelete(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const name = typeof body.name === \"string\" ? body.name.trim() : \"\";\n if (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"..\")) {\n jsonRes(res, 400, { error: \"Invalid name\" });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const filePath = join(workingDir, \"events\", name);\n let raw: string;\n try {\n raw = readFileSync(filePath, \"utf-8\");\n } catch {\n jsonRes(res, 404, { error: \"Event not found\" });\n return;\n }\n let parsed: Record<string, unknown> = {};\n try {\n const j = JSON.parse(raw);\n if (j && typeof j === \"object\") parsed = j as Record<string, unknown>;\n } catch {\n // Malformed events cannot be associated with a conversation below.\n }\n const eventConvId =\n typeof parsed.conversationId === \"string\"\n ? parsed.conversationId\n : typeof parsed.channelId === \"string\"\n ? parsed.channelId\n : null;\n if (eventConvId !== scope.conversationId) {\n jsonRes(res, 403, { error: \"Event does not belong to this conversation.\" });\n return;\n }\n try {\n rmSync(filePath, { force: true });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\n// โ”€โ”€ Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nfunction jsonRes(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(JSON.stringify(body));\n}\n\nasync function readJsonBody(\n req: IncomingMessage,\n res: ServerResponse,\n callback: (body: Record<string, unknown>) => void,\n): Promise<void> {\n let data = \"\";\n let tooLarge = false;\n\n await new Promise<void>((resolve) => {\n req.on(\"data\", (chunk: Buffer) => {\n if (tooLarge) return;\n data += chunk.toString();\n if (data.length > 32 * 1024) {\n tooLarge = true;\n res.writeHead(413);\n res.end();\n req.destroy();\n }\n });\n req.on(\"end\", resolve);\n req.on(\"error\", resolve);\n });\n\n if (tooLarge) return;\n\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(data) as Record<string, unknown>;\n } catch {\n jsonRes(res, 400, { error: \"Invalid JSON\" });\n return;\n }\n\n callback(parsed);\n}\n\nconst esc = escapeHtml;\n\n// โ”€โ”€ HTML โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nfunction renderAdminPage(token: AdminToken): string {\n const userLabel = token.platformUserName ?? token.platformUserId;\n const body = `<nav class=\"tab-nav\" role=\"tablist\" aria-label=\"Admin sections\">\n <button class=\"tab-btn active\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-conversation\" data-tab=\"conversation\">Conversation</button>\n <button class=\"tab-btn\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-global\" data-tab=\"global\">Global</button>\n </nav>\n\n <div class=\"tab-panel active\" id=\"panel-conversation\">\n <section class=\"card sect\" id=\"sect-settings\" data-section=\"settings\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Settings</p>\n <h2 class=\"card-title\">ๆจกๅž‹ / Thinking / Auto-reply / Workspace mount</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadSettings()\">โ†ป</button>\n </header>\n <div id=\"settings-content\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n </section>\n\n <section class=\"card sect\" id=\"sect-workspace\" data-section=\"workspace\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Workspace</p>\n <h2 class=\"card-title\">ๆช”ๆกˆ็€่ฆฝ (ๅช่ฎ€)</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadWorkspace()\">โ†ป</button>\n </header>\n <div class=\"workspace-split\">\n <div id=\"workspace-tree\" class=\"workspace-tree\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n <div id=\"workspace-preview\" class=\"workspace-preview\"><div class=\"placeholder-msg\">Click a file to preview</div></div>\n </div>\n </section>\n\n <section class=\"card sect\" id=\"sect-skills\" data-section=\"skills\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Skills</p>\n <h2 class=\"card-title\">ๅฏ็”จ็š„ skills</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadSkills()\">โ†ป</button>\n </header>\n <div class=\"workspace-split\">\n <div id=\"skills-content\" class=\"workspace-tree\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n <div id=\"skills-preview\" class=\"workspace-preview\"><div class=\"placeholder-msg\">Click a skill to preview SKILL.md</div></div>\n </div>\n </section>\n\n <section class=\"card sect\" id=\"sect-vault\" data-section=\"vault\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Vault</p>\n <h2 class=\"card-title\">่ฉฒๅฐ่ฉฑ็š„ๆ†‘่ญ‰</h2>\n </div>\n <button class=\"primary-action-btn\" onclick=\"openLogin()\">Open login form</button>\n </header>\n <div id=\"vault-link-result\" class=\"link-result\" style=\"display:none\"></div>\n <iframe id=\"login-frame\" class=\"portal-frame\" title=\"Login\" style=\"display:none\"></iframe>\n </section>\n\n <section class=\"card sect\" id=\"sect-events\" data-section=\"events\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Events</p>\n <h2 class=\"card-title\">้—œ่ฏๆญคๅฐ่ฉฑ็š„ events</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadConversationEvents()\">โ†ป</button>\n </header>\n <div id=\"events-content\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n </section>\n\n <section class=\"card sect\" id=\"sect-session\" data-section=\"session\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Session View</p>\n <h2 class=\"card-title\">ๅฐ่ฉฑๆญทๅฒๆชข่ฆ–</h2>\n </div>\n <button class=\"primary-action-btn\" onclick=\"openSessionView()\">Open session view</button>\n </header>\n <div id=\"session-link-result\" class=\"link-result\" style=\"display:none\"></div>\n <iframe id=\"session-frame\" class=\"portal-frame\" title=\"Session View\" style=\"display:none\"></iframe>\n </section>\n </div>\n\n <div class=\"tab-panel\" id=\"panel-global\">\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">All Conversations</p>\n <h2 class=\"card-title\">ๆ‰€ๆœ‰ๅฐ่ฉฑ</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadAllConversations()\">โ†ป</button>\n </header>\n <div id=\"all-conv-content\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n </section>\n\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Global Settings</p>\n <h2 class=\"card-title\">ๅ…จๅŸŸ้ ่จญ</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadGlobalSettings()\">โ†ป</button>\n </header>\n <div id=\"global-settings-content\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n </section>\n\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Global Skills</p>\n <h2 class=\"card-title\">ๅ…จๅŸŸ skills</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadGlobalSkills()\">โ†ป</button>\n </header>\n <div id=\"global-skills-content\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n </section>\n\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Global Events</p>\n <h2 class=\"card-title\">ๅ…จๅŸŸ events.json</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadEvents()\">โ†ป</button>\n </header>\n <div id=\"global-events-content\"><div class=\"loading-msg\">Loadingโ€ฆ</div></div>\n </section>\n </div>`;\n\n const script = `\n const adminToken = ${JSON.stringify(token.token)};\n const defaultConversationId = ${JSON.stringify(token.conversationId)};\n let activeConversationId = defaultConversationId;\n let availableModels = [];\n let modelsLoaded = false;\n\n // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n function escHtml(str) {\n return String(str).replace(/[&<>\"']/g, (c) => (\n {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]\n ));\n }\n function escAttr(str) {\n return String(str).replace(/[\"'&<>]/g, (c) => (\n {'\"':'&quot;',\"'\":'&#39;','&':'&amp;','<':'&lt;','>':'&gt;'}[c]\n ));\n }\n async function copyToClipboard(text) {\n try { await navigator.clipboard.writeText(text); } catch { prompt('Copy this link:', text); }\n }\n async function apiGet(path) {\n const url = path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(adminToken);\n const r = await fetch(url);\n const data = await r.json();\n if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));\n return data;\n }\n async function apiPost(path, body) {\n const r = await fetch(path, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: adminToken, ...body }),\n });\n const data = await r.json();\n if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));\n return data;\n }\n async function loadModels() {\n try {\n const data = await apiGet('/admin/api/models');\n availableModels = Array.isArray(data.models) ? data.models : [];\n } catch (err) {\n console.error('Failed to load models', err);\n availableModels = [];\n } finally {\n modelsLoaded = true;\n }\n }\n function modelRef(provider, model) {\n return provider && model ? provider + '/' + model : '';\n }\n function parseModelRef(value) {\n const slash = value.indexOf('/');\n if (slash <= 0 || slash === value.length - 1) return { provider: '', model: '' };\n return { provider: value.slice(0, slash), model: value.slice(slash + 1) };\n }\n function renderModelOptions(currentProvider, currentModel) {\n const current = modelRef(currentProvider, currentModel);\n const seen = new Set();\n const options = [];\n if (current) {\n seen.add(current);\n options.push('<option value=\"' + escAttr(current) + '\">' + escHtml(current + ' (current)') + '</option>');\n }\n for (const model of availableModels) {\n const ref = modelRef(model.provider, model.id);\n if (!ref || seen.has(ref)) continue;\n seen.add(ref);\n const details = [model.name && model.name !== model.id ? model.name : '', model.reasoning ? 'thinking' : '', Array.isArray(model.input) && model.input.includes('image') ? 'image' : '']\n .filter(Boolean)\n .join(' ยท ');\n options.push('<option value=\"' + escAttr(ref) + '\">' + escHtml(details ? ref + ' โ€” ' + details : ref) + '</option>');\n }\n if (options.length === 0) {\n return '<option value=\"\">No available models</option>';\n }\n return options.join('');\n }\n\n // โ”€โ”€ Tab switching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n const tabBtns = document.querySelectorAll('.tab-btn');\n const tabPanels = document.querySelectorAll('.tab-panel');\n\n function switchTab(tabId) {\n tabBtns.forEach((btn) => {\n const active = btn.dataset.tab === tabId;\n btn.classList.toggle('active', active);\n btn.setAttribute('aria-selected', active ? 'true' : 'false');\n });\n tabPanels.forEach((panel) => panel.classList.toggle('active', panel.id === 'panel-' + tabId));\n if (tabId === 'global') initGlobal();\n }\n tabBtns.forEach((btn) => btn.addEventListener('click', () => switchTab(btn.dataset.tab)));\n\n // โ”€โ”€ Conversation switcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function initConvSwitcher() {\n const sel = document.getElementById('conv-switcher');\n try {\n const data = await apiGet('/admin/api/conversations');\n sel.innerHTML = data.conversations.map((c) => {\n const label = (c.label || c.conversationId) + (c.running ? ' (running)' : '');\n const selected = c.conversationId === defaultConversationId ? ' selected' : '';\n return '<option value=\"' + escAttr(c.conversationId) + '\"' + selected + '>' + escHtml(label) + '</option>';\n }).join('');\n sel.addEventListener('change', () => setActiveConversation(sel.value));\n } catch (err) {\n console.error('Failed to load conversations', err);\n }\n }\n\n function setActiveConversation(id) {\n activeConversationId = id;\n const sel = document.getElementById('conv-switcher');\n if (sel && sel.value !== id) sel.value = id;\n // Reset all conversation sections.\n loadSettings();\n loadWorkspace();\n loadSkills();\n loadConversationEvents();\n openLogin(true);\n openSessionView(true);\n }\n\n // โ”€โ”€ Settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function loadSettings() {\n const container = document.getElementById('settings-content');\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n if (!modelsLoaded) await loadModels();\n try {\n const data = await apiGet('/admin/api/conversation-state?conversationId=' + encodeURIComponent(activeConversationId));\n container.innerHTML = renderSettings(data);\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderSettings(data) {\n const thinking = ['off','minimal','low','medium','high','xhigh'];\n const thinkingOpts = thinking.map((t) =>\n '<option value=\"' + t + '\"' + (data.thinkingLevel === t ? ' selected' : '') + '>' + t + '</option>'\n ).join('');\n const mounts = ['private','full'];\n const mountOpts = mounts.map((m) =>\n '<option value=\"' + m + '\"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n const rulesText = (data.autoReplyRules || []).join('\\\\n');\n return [\n '<div class=\"config-grid\">',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Model</h3>',\n '<div class=\"config-row config-row-stack\"><label>Model</label><select id=\"m-model-ref\">' + renderModelOptions(data.provider, data.model) + '</select></div>',\n '<div class=\"config-row\"><label>Thinking</label><select id=\"m-thinking\">' + thinkingOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveModel(this)\">Save model</button>',\n '<div id=\"model-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Auto-reply</h3>',\n '<div class=\"config-row\"><label>Enabled</label><label class=\"toggle\"><input type=\"checkbox\" id=\"a-enabled\"' + (data.autoReplyEnabled ? ' checked' : '') + '> on</label></div>',\n '<div class=\"config-row config-row-stack\"><label>Rules</label><textarea id=\"a-rules\" rows=\"5\" placeholder=\"ไธ€่กŒไธ€ๆข่ฆๅ‰‡\">' + escHtml(rulesText) + '</textarea></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveAutoReply(this)\">Save auto-reply</button>',\n '<div id=\"auto-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Workspace mount</h3>',\n '<div class=\"config-row\"><label>Mode</label><select id=\"m-mount\">' + mountOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveMount(this)\">Save mount</button>',\n '<div id=\"mount-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '</div>',\n ].join('');\n }\n\n async function saveModel(btn) {\n const selectedModel = parseModelRef(document.getElementById('m-model-ref').value.trim());\n const provider = selectedModel.provider;\n const model = selectedModel.model;\n const thinkingLevel = document.getElementById('m-thinking').value;\n const result = document.getElementById('model-save-result');\n if (!provider || !model) {\n result.style.display = 'block'; result.className = 'inline-result err';\n result.textContent = 'Provider and model are required';\n return;\n }\n btn.disabled = true; btn.textContent = 'Savingโ€ฆ'; result.style.display = 'none';\n try {\n const data = await apiPost('/admin/api/conversations/model', {\n conversationId: activeConversationId, provider, model, thinkingLevel,\n });\n result.style.display = 'block'; result.className = 'inline-result ok';\n result.textContent = data.runtimeSwitched === false\n ? 'Saved โ€” running session pinned; new model applies on next start.'\n : 'Saved โœ“';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err';\n result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save model';\n }\n }\n\n async function saveAutoReply(btn) {\n const enabled = document.getElementById('a-enabled').checked;\n const rules = document.getElementById('a-rules').value.split('\\\\n').map((s) => s.trim()).filter(Boolean);\n const result = document.getElementById('auto-save-result');\n btn.disabled = true; btn.textContent = 'Savingโ€ฆ'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/conversations/auto-reply', {\n conversationId: activeConversationId, enabled, rules,\n });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved โœ“';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save auto-reply';\n }\n }\n\n async function saveMount(btn) {\n const workspaceMount = document.getElementById('m-mount').value;\n const result = document.getElementById('mount-save-result');\n btn.disabled = true; btn.textContent = 'Savingโ€ฆ'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/conversations/sandbox', {\n conversationId: activeConversationId, workspaceMount,\n });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved โœ“';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save mount';\n }\n }\n\n // โ”€โ”€ Workspace โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function loadWorkspace() {\n const treeEl = document.getElementById('workspace-tree');\n const previewEl = document.getElementById('workspace-preview');\n treeEl.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n previewEl.innerHTML = '<div class=\"placeholder-msg\">Click a file to preview</div>';\n try {\n const data = await apiGet('/admin/api/workspace/tree?conversationId=' + encodeURIComponent(activeConversationId));\n if (!data.tree) {\n treeEl.innerHTML = '<div class=\"empty-state\">No files</div>';\n return;\n }\n treeEl.innerHTML = '<ul class=\"tree-root\">' + renderTreeChildren(data.tree) + '</ul>';\n } catch (err) {\n treeEl.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderTreeChildren(node) {\n if (node.type === 'file') {\n return '<li><button class=\"tree-file\" onclick=\"previewFile(\\\\'' + escAttr(node.path) + '\\\\')\">' + escHtml(node.name) + '</button></li>';\n }\n if (!node.children || node.children.length === 0) {\n return '<li><span class=\"tree-dir empty\">' + escHtml(node.name || '.') + '/</span></li>';\n }\n const inner = node.children.map((c) =>\n c.type === 'file'\n ? '<li><button class=\"tree-file\" onclick=\"previewFile(\\\\'' + escAttr(c.path) + '\\\\')\">' + escHtml(c.name) + '</button></li>'\n : '<li><details open><summary class=\"tree-dir\">' + escHtml(c.name) + '/</summary><ul>' + renderTreeChildren(c) + '</ul></details></li>'\n ).join('');\n return inner;\n }\n\n function renderPreviewFileResult(previewEl, label, data) {\n if (data.binary) {\n previewEl.innerHTML = '<div class=\"preview-meta\">' + escHtml(label) + ' ยท ' + data.size + ' bytes ยท binary</div><div class=\"placeholder-msg\">Binary file โ€” preview not available</div>';\n return;\n }\n previewEl.innerHTML =\n '<div class=\"preview-meta\">' + escHtml(label) + ' ยท ' + data.size + ' bytes</div>' +\n '<pre class=\"preview-body\">' + escHtml(data.content || '') + '</pre>';\n }\n\n async function previewFile(path) {\n const previewEl = document.getElementById('workspace-preview');\n previewEl.innerHTML = '<div class=\"loading-msg\">Loading ' + escHtml(path) + 'โ€ฆ</div>';\n try {\n const data = await apiGet('/admin/api/workspace/file?conversationId=' + encodeURIComponent(activeConversationId) + '&path=' + encodeURIComponent(path));\n renderPreviewFileResult(previewEl, path, data);\n } catch (err) {\n previewEl.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n // โ”€โ”€ Skills โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function loadSkills() {\n const container = document.getElementById('skills-content');\n const previewEl = document.getElementById('skills-preview');\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n if (previewEl) previewEl.innerHTML = '<div class=\"placeholder-msg\">Click a skill to preview SKILL.md</div>';\n try {\n const data = await apiGet('/admin/api/skills?conversationId=' + encodeURIComponent(activeConversationId));\n if (data.skills.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No skills available</div>';\n return;\n }\n container.innerHTML = '<div class=\"skills-list\">' +\n data.skills.map((s) =>\n '<button class=\"skill-row skill-row-btn\" data-skill-source=\"' + escAttr(s.source) + '\" data-skill-directory=\"' + escAttr(s.directory) + '\" data-skill-name=\"' + escAttr(s.name) + '\">' +\n '<div class=\"skill-name\">' + escHtml(s.name) + '<span class=\"skill-source skill-source-' + s.source + '\">' + s.source + '</span></div>' +\n (s.description ? '<div class=\"skill-desc\">' + escHtml(s.description) + '</div>' : '') +\n '</button>'\n ).join('') + '</div>';\n\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n async function previewSkill(source, directory, name) {\n const previewEl = document.getElementById('skills-preview');\n if (!source || !directory) {\n previewEl.innerHTML = '<div class=\"err-msg\">Missing skill source or directory</div>';\n return;\n }\n previewEl.innerHTML = '<div class=\"loading-msg\">Loading ' + escHtml(name || directory) + 'โ€ฆ</div>';\n try {\n const data = await apiGet('/admin/api/skills/file?conversationId=' + encodeURIComponent(activeConversationId) + '&source=' + encodeURIComponent(source) + '&directory=' + encodeURIComponent(directory));\n renderPreviewFileResult(previewEl, source + '/' + directory + '/SKILL.md', data);\n } catch (err) {\n previewEl.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n document.getElementById('skills-content').addEventListener('click', (event) => {\n const btn = event.target.closest('[data-skill-source]');\n if (!btn) return;\n previewSkill(btn.dataset.skillSource, btn.dataset.skillDirectory, btn.dataset.skillName);\n });\n\n // โ”€โ”€ Vault (Login link) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function openLogin(silent) {\n const result = document.getElementById('vault-link-result');\n const frame = document.getElementById('login-frame');\n if (silent) { frame.removeAttribute('src'); frame.style.display = 'none'; result.style.display = 'none'; return; }\n result.style.display = 'block'; result.className = 'link-result loading'; result.textContent = 'Generating linkโ€ฆ';\n try {\n const data = await apiPost('/admin/api/conversations/login-link', { conversationId: activeConversationId });\n result.className = 'link-result ok';\n result.innerHTML =\n '<span class=\"link-vault\">vault: <code>' + escHtml(data.vaultId) + '</code></span>' +\n '<a href=\"' + escAttr(data.url) + '\" target=\"_blank\" rel=\"noopener\">' + escHtml(data.url) + '</a>' +\n '<button class=\"copy-link-btn\" onclick=\"copyToClipboard(' + JSON.stringify(data.url) + ')\">Copy</button>';\n frame.src = data.url; frame.style.display = 'block';\n } catch (err) {\n result.className = 'link-result err'; result.textContent = err.message;\n frame.removeAttribute('src'); frame.style.display = 'none';\n }\n }\n\n // โ”€โ”€ Session View โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function openSessionView(silent) {\n const result = document.getElementById('session-link-result');\n const frame = document.getElementById('session-frame');\n if (silent) { frame.removeAttribute('src'); frame.style.display = 'none'; result.style.display = 'none'; return; }\n result.style.display = 'block'; result.className = 'link-result loading'; result.textContent = 'Generating linkโ€ฆ';\n try {\n const data = await apiPost('/admin/api/conversations/session-link', { conversationId: activeConversationId });\n result.className = 'link-result ok';\n result.innerHTML =\n '<a href=\"' + escAttr(data.url) + '\" target=\"_blank\" rel=\"noopener\">' + escHtml(data.url) + '</a>' +\n '<button class=\"copy-link-btn\" onclick=\"copyToClipboard(' + JSON.stringify(data.url) + ')\">Copy</button>';\n frame.src = data.url; frame.style.display = 'block';\n } catch (err) {\n result.className = 'link-result err'; result.textContent = err.message;\n frame.removeAttribute('src'); frame.style.display = 'none';\n }\n }\n\n // โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n async function loadConversationEvents() {\n const container = document.getElementById('events-content');\n if (!container) return;\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n try {\n const data = await apiGet('/admin/api/conversations/events?conversationId=' + encodeURIComponent(activeConversationId));\n if (data.events.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">ๆฒ’ๆœ‰้—œ่ฏๆญคๅฐ่ฉฑ็š„ event</div>';\n return;\n }\n container.innerHTML = '<div class=\"events-list\">' +\n data.events.map((e) => renderEventRow(e, true)).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n async function loadEvents() {\n const container = document.getElementById('global-events-content');\n if (!container) return;\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n try {\n const data = await apiGet('/admin/api/events');\n if (data.events.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No events scheduled</div>';\n return;\n }\n container.innerHTML = '<div class=\"events-list\">' +\n data.events.map((e) => renderEventRow(e, false)).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderEventRow(e, allowDelete) {\n const meta = [e.type, e.platform, e.conversationId, e.schedule || e.at]\n .filter(Boolean).map(escHtml).join(' ยท ');\n const preview = e.text ? '<div class=\"event-text\">' + escHtml(e.text.length > 240 ? e.text.slice(0, 237) + 'โ€ฆ' : e.text) + '</div>' : '';\n const deleteBtn = allowDelete\n ? '<button class=\"event-delete-btn\" onclick=\"deleteEvent(\\\\'' + escAttr(e.name) + '\\\\', this)\">Delete</button>'\n : '';\n return '<div class=\"event-row\">' +\n '<div class=\"event-row-top\">' +\n '<div class=\"event-name\"><code>' + escHtml(e.name) + '</code></div>' +\n deleteBtn +\n '</div>' +\n '<div class=\"event-meta\">' + meta + '</div>' +\n preview +\n '</div>';\n }\n\n async function deleteEvent(name, btn) {\n if (!confirm('Delete event \"' + name + '\"?')) return;\n btn.disabled = true; btn.textContent = 'Deletingโ€ฆ';\n try {\n await apiPost('/admin/api/conversations/events/delete', {\n conversationId: activeConversationId, name,\n });\n await loadConversationEvents();\n } catch (err) {\n btn.disabled = false; btn.textContent = 'Delete';\n alert(err.message);\n }\n }\n\n // โ”€โ”€ Global section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n let globalLoaded = false;\n function initGlobal() {\n if (globalLoaded) return;\n globalLoaded = true;\n loadAllConversations();\n loadGlobalSettings();\n loadGlobalSkills();\n loadEvents();\n }\n\n async function loadAllConversations() {\n const container = document.getElementById('all-conv-content');\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n try {\n const data = await apiGet('/admin/api/conversations');\n if (data.conversations.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No conversations found</div>';\n return;\n }\n container.innerHTML = '<div class=\"conv-list\">' + data.conversations.map((c) => {\n const last = c.lastActivityAt ? new Date(c.lastActivityAt).toLocaleString() : 'โ€”';\n return '<button class=\"conv-row-btn\" onclick=\"setActiveConversation(\\\\'' + escAttr(c.conversationId) + '\\\\'); switchTab(\\\\'conversation\\\\');\">' +\n '<span class=\"conv-id\">' + escHtml(c.label || c.conversationId) + '</span>' +\n (c.running ? '<span class=\"status-pill running\">running</span>' : '') +\n '<span class=\"conv-last\">' + escHtml(last) + '</span>' +\n '</button>';\n }).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n async function loadGlobalSettings() {\n const container = document.getElementById('global-settings-content');\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n if (!modelsLoaded) await loadModels();\n try {\n const data = await apiGet('/admin/api/settings/global');\n container.innerHTML = renderGlobalSettings(data);\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderGlobalSettings(data) {\n const thinking = ['off','minimal','low','medium','high','xhigh'];\n const thinkingOpts = thinking.map((t) =>\n '<option value=\"' + t + '\"' + (data.thinkingLevel === t ? ' selected' : '') + '>' + t + '</option>'\n ).join('');\n const mounts = ['private','full'];\n const mountOpts = mounts.map((m) =>\n '<option value=\"' + m + '\"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n return [\n '<div class=\"config-grid\">',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Default model</h3>',\n '<div class=\"config-row config-row-stack\"><label>Model</label><select id=\"g-model-ref\">' + renderModelOptions(data.provider, data.model) + '</select></div>',\n '<div class=\"config-row\"><label>Thinking</label><select id=\"g-thinking\">' + thinkingOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveGlobalModel(this)\">Save model</button>',\n '<div id=\"g-model-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Sandbox limits</h3>',\n '<div class=\"config-row\"><label>CPUs</label><input id=\"g-cpus\" placeholder=\"0.5\" value=\"' + escAttr(data.sandboxCpus || '') + '\"></div>',\n '<div class=\"config-row\"><label>Memory</label><input id=\"g-mem\" placeholder=\"1g\" value=\"' + escAttr(data.sandboxMemory || '') + '\"></div>',\n '<div class=\"config-row\"><label>Boost CPUs</label><input id=\"g-bcpus\" placeholder=\"2\" value=\"' + escAttr(data.sandboxBoostCpus || '') + '\"></div>',\n '<div class=\"config-row\"><label>Boost Mem</label><input id=\"g-bmem\" placeholder=\"4g\" value=\"' + escAttr(data.sandboxBoostMemory || '') + '\"></div>',\n '<div class=\"config-row\"><label>Mount</label><select id=\"g-mount\">' + mountOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveGlobalSandbox(this)\">Save sandbox</button>',\n '<div id=\"g-sandbox-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '</div>',\n ].join('');\n }\n\n async function saveGlobalModel(btn) {\n const selectedModel = parseModelRef(document.getElementById('g-model-ref').value.trim());\n const provider = selectedModel.provider;\n const model = selectedModel.model;\n const thinkingLevel = document.getElementById('g-thinking').value;\n const result = document.getElementById('g-model-result');\n if (!provider || !model) {\n result.style.display = 'block'; result.className = 'inline-result err';\n result.textContent = 'Provider and model are required'; return;\n }\n btn.disabled = true; btn.textContent = 'Savingโ€ฆ'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/settings/model', { provider, model, thinkingLevel });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved โœ“';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save model';\n }\n }\n\n async function saveGlobalSandbox(btn) {\n const cpus = document.getElementById('g-cpus').value.trim();\n const memory = document.getElementById('g-mem').value.trim();\n const boostCpus = document.getElementById('g-bcpus').value.trim();\n const boostMemory = document.getElementById('g-bmem').value.trim();\n const workspaceMount = document.getElementById('g-mount').value;\n const result = document.getElementById('g-sandbox-result');\n btn.disabled = true; btn.textContent = 'Savingโ€ฆ'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/settings/sandbox', { cpus, memory, boostCpus, boostMemory, workspaceMount });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved โœ“';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save sandbox';\n }\n }\n\n async function loadGlobalSkills() {\n const container = document.getElementById('global-skills-content');\n container.innerHTML = '<div class=\"loading-msg\">Loadingโ€ฆ</div>';\n try {\n // Reuse skills endpoint scoped to a conversation that doesn't have any of its own; the global half is what we want.\n const data = await apiGet('/admin/api/skills?conversationId=' + encodeURIComponent(activeConversationId));\n const globals = data.skills.filter((s) => s.source === 'global');\n if (globals.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No global skills</div>';\n return;\n }\n container.innerHTML = '<div class=\"skills-list\">' + globals.map((s) =>\n '<div class=\"skill-row\"><div class=\"skill-name\">' + escHtml(s.name) + '</div>' +\n (s.description ? '<div class=\"skill-desc\">' + escHtml(s.description) + '</div>' : '') + '</div>'\n ).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n // โ”€โ”€ Init โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n initConvSwitcher();\n loadModels().finally(() => {\n loadSettings();\n loadWorkspace();\n loadSkills();\n loadConversationEvents();\n });\n `;\n\n return renderPortalShell({\n activeView: \"admin\",\n pageTitle: \"Admin\",\n identity: { primary: token.platform, secondary: userLabel },\n conversationSwitcher: { currentId: token.conversationId },\n body,\n extraStyles: adminViewStyles,\n inlineScript: script,\n });\n}\n\nfunction renderAdminErrorPage(message: string): string {\n return renderPortalShell({\n activeView: \"admin\",\n pageTitle: \"Admin\",\n body: `<section class=\"card\" style=\"text-align:center;padding:40px 32px\">\n <p class=\"eyebrow\">${PRODUCT_NAME} admin</p>\n <h1 class=\"page-title\" style=\"margin:12px 0 16px\">Access Denied</h1>\n <div class=\"err-msg\">${esc(message)}</div>\n </section>`,\n });\n}\n\n// โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\nconst adminViewStyles = `\n .tab-nav {\n display: flex; gap: 6px; padding: 6px;\n border: 1px solid var(--border); border-radius: 16px;\n background: rgba(255,255,255,0.72); backdrop-filter: blur(8px);\n overflow-x: auto; scrollbar-width: none;\n }\n .tab-nav::-webkit-scrollbar { display: none; }\n .tab-btn {\n flex: 1; min-width: 80px; padding: 10px 16px;\n border: none; border-radius: 10px; background: transparent;\n color: var(--muted);\n font: 500 0.88rem/1.2 'DM Sans', sans-serif;\n cursor: pointer; white-space: nowrap;\n transition: background 140ms, color 140ms;\n }\n .tab-btn:hover { background: rgba(0,0,0,0.04); color: var(--text); }\n .tab-btn.active { background: var(--text); color: #fafafa; font-weight: 600; }\n .tab-btn:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }\n\n .tab-panel { display: none; flex-direction: column; gap: 14px; }\n .tab-panel.active { display: flex; }\n\n .card-desc { color: var(--muted); font-size: 0.9rem; line-height: 1.55; margin-bottom: 12px; }\n\n .link-result {\n margin-top: 12px; padding: 10px 14px; border-radius: 10px;\n display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\n font-size: 0.84rem;\n }\n .link-result.ok { background: var(--ok-bg); border: 1px solid var(--ok-border); }\n .link-result.err { background: var(--err-bg); border: 1px solid var(--err-border); color: var(--err-text); }\n .link-result.loading { background: rgba(0,0,0,0.025); border: 1px solid var(--border); color: var(--muted); }\n .link-result a {\n color: var(--ok-text);\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.78rem; word-break: break-all; flex: 1; min-width: 0;\n }\n .link-vault { color: var(--muted); font-size: 0.78rem; flex-shrink: 0; }\n .copy-link-btn {\n padding: 5px 12px; border: 1px solid var(--ok-border); border-radius: 7px;\n background: rgba(255,255,255,0.7); color: var(--ok-text);\n font: 500 0.78rem/1.2 'DM Sans', sans-serif;\n cursor: pointer; flex-shrink: 0;\n }\n\n .portal-frame {\n width: 100%; min-height: 720px;\n border: 1px solid var(--border); border-radius: 14px; background: #fff;\n }\n\n .config-grid {\n display: grid; grid-template-columns: 1fr 1fr; gap: 18px;\n }\n .config-block { display: flex; flex-direction: column; gap: 10px; }\n .config-row { display: grid; grid-template-columns: 110px 1fr; gap: 10px; align-items: center; }\n .config-row.config-row-stack { grid-template-columns: 1fr; }\n .config-row label { font-size: 0.82rem; color: var(--muted); }\n .config-row input, .config-row select, .config-row textarea {\n padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;\n font-family: inherit; font-size: 0.84rem; width: 100%;\n }\n .config-row textarea {\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n resize: vertical;\n }\n .toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 0.84rem; }\n\n .inline-result {\n padding: 8px 12px; border-radius: 8px; font-size: 0.82rem; margin-top: 4px;\n }\n .inline-result.ok { background: var(--ok-bg); color: var(--ok-text); border: 1px solid var(--ok-border); }\n .inline-result.err { background: var(--err-bg); color: var(--err-text); border: 1px solid var(--err-border); }\n\n /* โ”€โ”€ Sections (Conversation page stack) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n .sect-head {\n display: flex; align-items: flex-start; justify-content: space-between;\n gap: 12px; margin-bottom: 14px; flex-wrap: wrap;\n }\n .sect-head .card-title { margin-bottom: 0; }\n .sect-disabled { opacity: 0.7; }\n\n .refresh-btn {\n flex-shrink: 0; padding: 6px 12px;\n border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.025); color: var(--muted);\n font: 500 0.84rem/1.2 'DM Sans', sans-serif; cursor: pointer;\n }\n .refresh-btn:hover { background: rgba(0,0,0,0.06); color: var(--text); }\n\n /* โ”€โ”€ Workspace โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n .workspace-split {\n display: grid; grid-template-columns: 260px 1fr; gap: 14px;\n min-height: 360px;\n }\n .workspace-tree {\n border: 1px solid var(--border); border-radius: 12px; padding: 10px;\n background: rgba(0,0,0,0.02); overflow: auto; max-height: 480px;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.78rem;\n }\n .workspace-tree ul { list-style: none; padding-left: 12px; margin: 0; }\n .workspace-tree .tree-root { padding-left: 0; }\n .workspace-tree details { margin: 1px 0; }\n .workspace-tree summary { cursor: pointer; padding: 2px 4px; border-radius: 4px; }\n .workspace-tree summary:hover { background: rgba(0,0,0,0.05); }\n .tree-dir { color: var(--text); font-weight: 600; }\n .tree-dir.empty { color: var(--subtle); font-weight: 400; }\n .tree-file {\n display: block; width: 100%; text-align: left;\n background: transparent; border: none; cursor: pointer;\n padding: 2px 4px; border-radius: 4px;\n font-family: inherit; font-size: inherit; color: var(--muted);\n }\n .tree-file:hover { background: rgba(0,0,0,0.05); color: var(--text); }\n\n .workspace-preview {\n border: 1px solid var(--border); border-radius: 12px;\n background: #fff; padding: 12px; overflow: auto; max-height: 480px;\n }\n .preview-meta {\n font-size: 0.74rem; color: var(--subtle);\n margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border);\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n }\n .preview-body {\n margin: 0; white-space: pre-wrap; word-break: break-word;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.78rem; color: var(--text);\n }\n .placeholder-msg { color: var(--subtle); font-size: 0.86rem; padding: 24px 8px; text-align: center; }\n\n /* โ”€โ”€ Skills โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n .skills-list { display: flex; flex-direction: column; gap: 8px; }\n .skill-row {\n padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.02);\n }\n .skill-row-btn {\n width: 100%; text-align: left; cursor: pointer; font-family: inherit;\n }\n .skill-row-btn:hover { background: rgba(0,0,0,0.05); }\n .skill-name {\n font-weight: 650; font-size: 0.9rem; color: var(--text);\n display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\n }\n .skill-source {\n padding: 1px 8px; border-radius: 999px; font-size: 0.7rem;\n font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;\n }\n .skill-source-global { background: rgba(59,130,246,0.1); color: #1d4ed8; }\n .skill-source-conversation { background: rgba(217,119,6,0.1); color: var(--accent); }\n .skill-desc { color: var(--muted); font-size: 0.82rem; margin-top: 4px; line-height: 1.5; }\n\n /* โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n .events-list { display: flex; flex-direction: column; gap: 8px; }\n .event-row {\n padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.02);\n }\n .event-row-top {\n display: flex; align-items: center; justify-content: space-between;\n gap: 10px;\n }\n .event-name { min-width: 0; flex: 1; word-break: break-all; }\n .event-name code { font-size: 0.82rem; background: transparent; padding: 0; }\n .event-meta { font-size: 0.74rem; color: var(--muted); margin-top: 3px; }\n .event-text {\n font-size: 0.82rem; color: var(--text); margin-top: 6px;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n white-space: pre-wrap; word-break: break-word;\n }\n .event-delete-btn {\n flex-shrink: 0; padding: 4px 10px;\n border-radius: 7px; border: 1px solid rgba(185, 28, 28, 0.18);\n background: rgba(0,0,0,0.03); color: var(--err-text);\n font: 500 0.76rem/1.2 'DM Sans', sans-serif; cursor: pointer;\n }\n .event-delete-btn:hover:not(:disabled) {\n background: var(--err-bg); border-color: rgba(185, 28, 28, 0.28);\n }\n .event-delete-btn:disabled { opacity: 0.5; cursor: wait; }\n\n /* โ”€โ”€ All Conversations list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n .conv-list { display: flex; flex-direction: column; gap: 6px; }\n .conv-row-btn {\n display: flex; align-items: center; gap: 12px;\n padding: 10px 14px; border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.02); cursor: pointer; text-align: left;\n transition: background 120ms, border-color 120ms;\n }\n .conv-row-btn:hover { background: rgba(0,0,0,0.05); border-color: rgba(0,0,0,0.14); }\n .conv-id { flex: 1; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 0.84rem; }\n .conv-last { color: var(--subtle); font-size: 0.78rem; }\n\n .status-pill {\n display: inline-flex; padding: 2px 9px; border-radius: 999px;\n font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;\n }\n .status-pill.running { background: var(--ok-bg); color: var(--ok-text); border: 1px solid var(--ok-border); }\n\n @media (max-width: 640px) {\n .tab-btn { padding: 9px 12px; font-size: 0.82rem; min-width: 60px; }\n .config-grid { grid-template-columns: 1fr; }\n .config-row { grid-template-columns: 1fr; gap: 4px; }\n .portal-frame { min-height: 520px; }\n .workspace-split { grid-template-columns: 1fr; }\n .workspace-tree, .workspace-preview { max-height: 260px; }\n }\n`;\n"]}