@geminixiang/mikan 0.3.1 → 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 +23 -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 +31 -22
  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 @@
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../src/web/admin/portal.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAuB5D,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACpE,OAAO,KAAK,EAAsB,aAAa,EAAE,MAAM,YAAY,CAAC;AAIpE,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\";\n\nimport {\n loadConversationAutoReplyConfig,\n loadGlobalSettings,\n resolveConversationSettings,\n saveConversationAutoReplyConfig,\n updateConversationSettings,\n updateGlobalSettings,\n type AgentConfig,\n} from \"../../config.js\";\nimport { escapeHtml } from \"../../utils/html.js\";\nimport { readRawBody } from \"../../utils/http-body.js\";\nimport { renderPortalShell } from \"../../portal-shell.js\";\nimport { resolveExistingSessionFile } from \"../session-view/service.js\";\nimport { PRODUCT_NAME } from \"../../platform-messages.js\";\nimport { resolveActorVaultKey } from \"../../vault/routing.js\";\nimport { sharedVaultKey } from \"../../vault/index.js\";\nimport type { AdminToken } from \"./store.js\";\n\nexport type { AdminRuntimeBridge, AdminServices } from \"./types.js\";\nimport type { AdminRuntimeBridge, AdminServices } from \"./types.js\";\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/slack\") {\n serveConversationSlackUpdate(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 if (url.pathname === \"/admin/api/settings/slack\") {\n serveGlobalSlackUpdate(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 resolveConversationId(\n requested: string,\n token: AdminToken,\n): { conversationId: string; error?: string } {\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 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 return resolveConversationId(requested, token);\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 const globalConfig = loadGlobalSettings();\n const conversationConfig = resolveConversationSettings(dir);\n const autoReply = loadConversationAutoReplyConfig(dir);\n\n jsonRes(res, 200, {\n conversationId,\n provider: conversationConfig.provider,\n model: conversationConfig.model,\n thinkingLevel: conversationConfig.thinkingLevel,\n globalProvider: globalConfig.provider,\n globalModel: globalConfig.model,\n globalThinkingLevel: globalConfig.thinkingLevel,\n sandboxImageWorkspaceMount: conversationConfig.sandboxImageWorkspaceMount ?? null,\n globalSandboxImageWorkspaceMount: globalConfig.sandboxImageWorkspaceMount ?? null,\n autoReplyEnabled: autoReply.enabled,\n autoReplyRules: autoReply.rules,\n slack: {\n replyMode:\n conversationConfig.slack?.replyMode ?? globalConfig.slack?.replyMode ?? \"top-level\",\n globalReplyMode: globalConfig.slack?.replyMode ?? \"top-level\",\n },\n });\n}\n\nfunction serveGlobalSettings(res: ServerResponse): void {\n try {\n const config = loadGlobalSettings();\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 slack: {\n replyMode: config.slack?.replyMode ?? \"top-level\",\n },\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 updateConversationSettings(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 updateConversationSettings(dir, { sandboxImageWorkspaceMount: 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 serveConversationSlackUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const replyMode = body.replyMode;\n if (replyMode !== \"top-level\" && replyMode !== \"thread\") {\n jsonRes(res, 400, { error: \"replyMode must be 'top-level' or 'thread'\" });\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 updateConversationSettings(dir, { slack: { replyMode } });\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 updateGlobalSettings({\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 serveGlobalSlackUpdate(res: ServerResponse, body: Record<string, unknown>): void {\n const replyMode = body.replyMode;\n if (replyMode !== \"top-level\" && replyMode !== \"thread\") {\n jsonRes(res, 400, { error: \"replyMode must be 'top-level' or 'thread'\" });\n return;\n }\n\n try {\n updateGlobalSettings({ slack: { replyMode } });\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 updateGlobalSettings(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 return resolveConversationId(requested, token);\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 const data = await readRawBody(req, res, 32 * 1024);\n if (data === null) 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 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 // ignore; conversation selector stays empty\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 const replyModes = ['top-level','thread'];\n const replyModeOpts = replyModes.map((m) =>\n '<option value=\"' + m + '\"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n const globalReplyMode = (data.slack && data.slack.globalReplyMode) || 'top-level';\n const globalModel = [data.globalProvider, data.globalModel].filter(Boolean).join('/');\n const globalModelLabel = globalModel + (data.globalThinkingLevel ? ':' + data.globalThinkingLevel : '');\n const globalMount = data.globalSandboxImageWorkspaceMount || 'private';\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 '<p class=\"muted-note\">Global default: ' + escHtml(globalModelLabel) + '</p>',\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 '<p class=\"muted-note\">Global default: ' + escHtml(globalMount) + '</p>',\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 class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Slack</h3>',\n '<div class=\"config-row\"><label>Reply mode</label><select id=\"m-slack-reply-mode\">' + replyModeOpts + '</select></div>',\n '<p class=\"muted-note\">Global default: ' + escHtml(globalReplyMode) + '</p>',\n '<button class=\"primary-action-btn\" onclick=\"saveSlack(this)\">Save Slack</button>',\n '<div id=\"slack-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 async function saveSlack(btn) {\n const replyMode = document.getElementById('m-slack-reply-mode').value;\n const result = document.getElementById('slack-save-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/conversations/slack', {\n conversationId: activeConversationId, replyMode,\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 Slack';\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 const replyModes = ['top-level','thread'];\n const replyModeOpts = replyModes.map((m) =>\n '<option value=\"' + m + '\"' + (((data.slack && data.slack.replyMode) || 'top-level') === 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 class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Slack</h3>',\n '<div class=\"config-row\"><label>Reply mode</label><select id=\"g-slack-reply-mode\">' + replyModeOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveGlobalSlack(this)\">Save Slack</button>',\n '<div id=\"g-slack-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 saveGlobalSlack(btn) {\n const replyMode = document.getElementById('g-slack-reply-mode').value;\n const result = document.getElementById('g-slack-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/settings/slack', { replyMode });\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 Slack';\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"]}
@@ -2,13 +2,14 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join, resolve as pathResolve, sep as pathSep } from "path";
4
4
  import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
5
- import { loadAgentConfig, loadAgentConfigForConversation, loadConversationAutoReplyConfig, saveAgentConfig, saveConversationAutoReplyConfig, saveConversationModelConfig, saveConversationSandboxConfig, } from "../config.js";
6
- import { escapeHtml } from "../html.js";
7
- import { renderPortalShell } from "../portal-shell.js";
5
+ import { loadConversationAutoReplyConfig, loadGlobalSettings, resolveConversationSettings, saveConversationAutoReplyConfig, updateConversationSettings, updateGlobalSettings, } from "../../config.js";
6
+ import { escapeHtml } from "../../utils/html.js";
7
+ import { readRawBody } from "../../utils/http-body.js";
8
+ import { renderPortalShell } from "../../portal-shell.js";
8
9
  import { resolveExistingSessionFile } from "../session-view/service.js";
9
- import { PRODUCT_NAME } from "../ui-copy.js";
10
- import { resolveActorVaultKey } from "../vault-routing.js";
11
- import { sharedVaultKey } from "../vault.js";
10
+ import { PRODUCT_NAME } from "../../platform-messages.js";
11
+ import { resolveActorVaultKey } from "../../vault/routing.js";
12
+ import { sharedVaultKey } from "../../vault/index.js";
12
13
  // ── Handler ────────────────────────────────────────────────────────────────────
13
14
  export function handleAdminRequest(req, res, url, services) {
14
15
  if (!url.pathname.startsWith("/admin"))
@@ -113,6 +114,10 @@ function routeApiRequest(req, res, url, services) {
113
114
  serveConversationAutoReplyUpdate(res, body, services, token);
114
115
  return;
115
116
  }
117
+ if (url.pathname === "/admin/api/conversations/slack") {
118
+ serveConversationSlackUpdate(res, body, services, token);
119
+ return;
120
+ }
116
121
  if (url.pathname === "/admin/api/conversations/session-link") {
117
122
  serveConversationSessionLink(res, body, services, token);
118
123
  return;
@@ -133,6 +138,10 @@ function routeApiRequest(req, res, url, services) {
133
138
  serveGlobalSandboxUpdate(res, body);
134
139
  return;
135
140
  }
141
+ if (url.pathname === "/admin/api/settings/slack") {
142
+ serveGlobalSlackUpdate(res, body);
143
+ return;
144
+ }
136
145
  jsonRes(res, 404, { error: "Not found" });
137
146
  });
138
147
  return;
@@ -140,8 +149,7 @@ function routeApiRequest(req, res, url, services) {
140
149
  jsonRes(res, 405, { error: "Method not allowed" });
141
150
  }
142
151
  // ── Scope helpers ──────────────────────────────────────────────────────────────
143
- function resolveTargetConversation(body, token) {
144
- const requested = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
152
+ function resolveConversationId(requested, token) {
145
153
  if (!requested)
146
154
  return { conversationId: token.conversationId };
147
155
  if (requested === token.conversationId)
@@ -151,6 +159,10 @@ function resolveTargetConversation(body, token) {
151
159
  }
152
160
  return { conversationId: requested };
153
161
  }
162
+ function resolveTargetConversation(body, token) {
163
+ const requested = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
164
+ return resolveConversationId(requested, token);
165
+ }
154
166
  function requireAdminWorkingDir(res, services) {
155
167
  if (!services.workingDir) {
156
168
  jsonRes(res, 503, { error: "Working directory not available" });
@@ -259,27 +271,30 @@ function serveConversationState(res, url, services, token) {
259
271
  return;
260
272
  }
261
273
  const dir = join(workingDir, conversationId);
262
- let modelConfig = null;
263
- try {
264
- modelConfig = loadAgentConfigForConversation(dir);
265
- }
266
- catch {
267
- modelConfig = null;
268
- }
274
+ const globalConfig = loadGlobalSettings();
275
+ const conversationConfig = resolveConversationSettings(dir);
269
276
  const autoReply = loadConversationAutoReplyConfig(dir);
270
277
  jsonRes(res, 200, {
271
278
  conversationId,
272
- provider: modelConfig?.provider ?? null,
273
- model: modelConfig?.model ?? null,
274
- thinkingLevel: modelConfig?.thinkingLevel ?? null,
275
- sandboxImageWorkspaceMount: modelConfig?.sandboxImageWorkspaceMount ?? null,
279
+ provider: conversationConfig.provider,
280
+ model: conversationConfig.model,
281
+ thinkingLevel: conversationConfig.thinkingLevel,
282
+ globalProvider: globalConfig.provider,
283
+ globalModel: globalConfig.model,
284
+ globalThinkingLevel: globalConfig.thinkingLevel,
285
+ sandboxImageWorkspaceMount: conversationConfig.sandboxImageWorkspaceMount ?? null,
286
+ globalSandboxImageWorkspaceMount: globalConfig.sandboxImageWorkspaceMount ?? null,
276
287
  autoReplyEnabled: autoReply.enabled,
277
288
  autoReplyRules: autoReply.rules,
289
+ slack: {
290
+ replyMode: conversationConfig.slack?.replyMode ?? globalConfig.slack?.replyMode ?? "top-level",
291
+ globalReplyMode: globalConfig.slack?.replyMode ?? "top-level",
292
+ },
278
293
  });
279
294
  }
280
295
  function serveGlobalSettings(res) {
281
296
  try {
282
- const config = loadAgentConfig();
297
+ const config = loadGlobalSettings();
283
298
  jsonRes(res, 200, {
284
299
  provider: config.provider,
285
300
  model: config.model,
@@ -290,6 +305,9 @@ function serveGlobalSettings(res) {
290
305
  sandboxBoostMemory: config.sandboxBoostMemory ?? null,
291
306
  sandboxImageWorkspaceMount: config.sandboxImageWorkspaceMount ?? null,
292
307
  defaultSharedVault: config.defaultSharedVault ?? null,
308
+ slack: {
309
+ replyMode: config.slack?.replyMode ?? "top-level",
310
+ },
293
311
  });
294
312
  }
295
313
  catch (err) {
@@ -336,7 +354,7 @@ function serveConversationModelUpdate(res, body, services, token) {
336
354
  return;
337
355
  const dir = join(workingDir, scope.conversationId);
338
356
  try {
339
- saveConversationModelConfig(dir, {
357
+ updateConversationSettings(dir, {
340
358
  provider,
341
359
  model,
342
360
  ...(thinkingLevel ? { thinkingLevel } : {}),
@@ -367,7 +385,30 @@ function serveConversationSandboxUpdate(res, body, services, token) {
367
385
  return;
368
386
  const dir = join(workingDir, scope.conversationId);
369
387
  try {
370
- saveConversationSandboxConfig(dir, { imageWorkspaceMount: workspaceMount });
388
+ updateConversationSettings(dir, { sandboxImageWorkspaceMount: workspaceMount });
389
+ jsonRes(res, 200, { ok: true });
390
+ }
391
+ catch (err) {
392
+ jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
393
+ }
394
+ }
395
+ function serveConversationSlackUpdate(res, body, services, token) {
396
+ const replyMode = body.replyMode;
397
+ if (replyMode !== "top-level" && replyMode !== "thread") {
398
+ jsonRes(res, 400, { error: "replyMode must be 'top-level' or 'thread'" });
399
+ return;
400
+ }
401
+ const scope = resolveTargetConversation(body, token);
402
+ if (scope.error) {
403
+ jsonRes(res, 403, { error: scope.error });
404
+ return;
405
+ }
406
+ const workingDir = requireAdminWorkingDir(res, services);
407
+ if (!workingDir)
408
+ return;
409
+ const dir = join(workingDir, scope.conversationId);
410
+ try {
411
+ updateConversationSettings(dir, { slack: { replyMode } });
371
412
  jsonRes(res, 200, { ok: true });
372
413
  }
373
414
  catch (err) {
@@ -486,7 +527,7 @@ function serveGlobalModelUpdate(res, body) {
486
527
  return;
487
528
  }
488
529
  try {
489
- saveAgentConfig({
530
+ updateGlobalSettings({
490
531
  provider,
491
532
  model,
492
533
  ...(thinkingLevel ? { thinkingLevel } : {}),
@@ -497,6 +538,20 @@ function serveGlobalModelUpdate(res, body) {
497
538
  jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
498
539
  }
499
540
  }
541
+ function serveGlobalSlackUpdate(res, body) {
542
+ const replyMode = body.replyMode;
543
+ if (replyMode !== "top-level" && replyMode !== "thread") {
544
+ jsonRes(res, 400, { error: "replyMode must be 'top-level' or 'thread'" });
545
+ return;
546
+ }
547
+ try {
548
+ updateGlobalSettings({ slack: { replyMode } });
549
+ jsonRes(res, 200, { ok: true });
550
+ }
551
+ catch (err) {
552
+ jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
553
+ }
554
+ }
500
555
  function serveGlobalSandboxUpdate(res, body) {
501
556
  const cpus = typeof body.cpus === "string" ? body.cpus.trim() : "";
502
557
  const memory = typeof body.memory === "string" ? body.memory.trim() : "";
@@ -520,7 +575,7 @@ function serveGlobalSandboxUpdate(res, body) {
520
575
  return;
521
576
  }
522
577
  try {
523
- saveAgentConfig(update);
578
+ updateGlobalSettings(update);
524
579
  jsonRes(res, 200, { ok: true });
525
580
  }
526
581
  catch (err) {
@@ -551,14 +606,7 @@ function isWorkspacePathAllowed(rel) {
551
606
  }
552
607
  function resolveConversationFromQuery(url, token) {
553
608
  const requested = (url.searchParams.get("conversationId") ?? "").trim();
554
- if (!requested)
555
- return { conversationId: token.conversationId };
556
- if (requested === token.conversationId)
557
- return { conversationId: requested };
558
- if (requested.includes("/") || requested.includes("..")) {
559
- return { conversationId: requested, error: "Invalid conversationId." };
560
- }
561
- return { conversationId: requested };
609
+ return resolveConversationId(requested, token);
562
610
  }
563
611
  function safeJoinUnderRoot(rootDir, relative) {
564
612
  if (relative.startsWith("/") || relative.includes("\0")) {
@@ -1034,24 +1082,8 @@ function jsonRes(res, status, body) {
1034
1082
  res.end(JSON.stringify(body));
1035
1083
  }
1036
1084
  async function readJsonBody(req, res, callback) {
1037
- let data = "";
1038
- let tooLarge = false;
1039
- await new Promise((resolve) => {
1040
- req.on("data", (chunk) => {
1041
- if (tooLarge)
1042
- return;
1043
- data += chunk.toString();
1044
- if (data.length > 32 * 1024) {
1045
- tooLarge = true;
1046
- res.writeHead(413);
1047
- res.end();
1048
- req.destroy();
1049
- }
1050
- });
1051
- req.on("end", resolve);
1052
- req.on("error", resolve);
1053
- });
1054
- if (tooLarge)
1085
+ const data = await readRawBody(req, res, 32 * 1024);
1086
+ if (data === null)
1055
1087
  return;
1056
1088
  let parsed;
1057
1089
  try {
@@ -1237,7 +1269,6 @@ function renderAdminPage(token) {
1237
1269
  const data = await apiGet('/admin/api/models');
1238
1270
  availableModels = Array.isArray(data.models) ? data.models : [];
1239
1271
  } catch (err) {
1240
- console.error('Failed to load models', err);
1241
1272
  availableModels = [];
1242
1273
  } finally {
1243
1274
  modelsLoaded = true;
@@ -1303,7 +1334,7 @@ function renderAdminPage(token) {
1303
1334
  }).join('');
1304
1335
  sel.addEventListener('change', () => setActiveConversation(sel.value));
1305
1336
  } catch (err) {
1306
- console.error('Failed to load conversations', err);
1337
+ // ignore; conversation selector stays empty
1307
1338
  }
1308
1339
  }
1309
1340
 
@@ -1344,12 +1375,21 @@ function renderAdminPage(token) {
1344
1375
  '<option value="' + m + '"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'
1345
1376
  ).join('');
1346
1377
  const rulesText = (data.autoReplyRules || []).join('\\n');
1378
+ const replyModes = ['top-level','thread'];
1379
+ const replyModeOpts = replyModes.map((m) =>
1380
+ '<option value="' + m + '"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'
1381
+ ).join('');
1382
+ const globalReplyMode = (data.slack && data.slack.globalReplyMode) || 'top-level';
1383
+ const globalModel = [data.globalProvider, data.globalModel].filter(Boolean).join('/');
1384
+ const globalModelLabel = globalModel + (data.globalThinkingLevel ? ':' + data.globalThinkingLevel : '');
1385
+ const globalMount = data.globalSandboxImageWorkspaceMount || 'private';
1347
1386
  return [
1348
1387
  '<div class="config-grid">',
1349
1388
  '<div class="config-block">',
1350
1389
  '<h3 class="card-subtitle">Model</h3>',
1351
1390
  '<div class="config-row config-row-stack"><label>Model</label><select id="m-model-ref">' + renderModelOptions(data.provider, data.model) + '</select></div>',
1352
1391
  '<div class="config-row"><label>Thinking</label><select id="m-thinking">' + thinkingOpts + '</select></div>',
1392
+ '<p class="muted-note">Global default: ' + escHtml(globalModelLabel) + '</p>',
1353
1393
  '<button class="primary-action-btn" onclick="saveModel(this)">Save model</button>',
1354
1394
  '<div id="model-save-result" class="inline-result" style="display:none"></div>',
1355
1395
  '</div>',
@@ -1363,9 +1403,17 @@ function renderAdminPage(token) {
1363
1403
  '<div class="config-block">',
1364
1404
  '<h3 class="card-subtitle">Workspace mount</h3>',
1365
1405
  '<div class="config-row"><label>Mode</label><select id="m-mount">' + mountOpts + '</select></div>',
1406
+ '<p class="muted-note">Global default: ' + escHtml(globalMount) + '</p>',
1366
1407
  '<button class="primary-action-btn" onclick="saveMount(this)">Save mount</button>',
1367
1408
  '<div id="mount-save-result" class="inline-result" style="display:none"></div>',
1368
1409
  '</div>',
1410
+ '<div class="config-block">',
1411
+ '<h3 class="card-subtitle">Slack</h3>',
1412
+ '<div class="config-row"><label>Reply mode</label><select id="m-slack-reply-mode">' + replyModeOpts + '</select></div>',
1413
+ '<p class="muted-note">Global default: ' + escHtml(globalReplyMode) + '</p>',
1414
+ '<button class="primary-action-btn" onclick="saveSlack(this)">Save Slack</button>',
1415
+ '<div id="slack-save-result" class="inline-result" style="display:none"></div>',
1416
+ '</div>',
1369
1417
  '</div>',
1370
1418
  ].join('');
1371
1419
  }
@@ -1431,6 +1479,22 @@ function renderAdminPage(token) {
1431
1479
  }
1432
1480
  }
1433
1481
 
1482
+ async function saveSlack(btn) {
1483
+ const replyMode = document.getElementById('m-slack-reply-mode').value;
1484
+ const result = document.getElementById('slack-save-result');
1485
+ btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';
1486
+ try {
1487
+ await apiPost('/admin/api/conversations/slack', {
1488
+ conversationId: activeConversationId, replyMode,
1489
+ });
1490
+ result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';
1491
+ } catch (err) {
1492
+ result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;
1493
+ } finally {
1494
+ btn.disabled = false; btn.textContent = 'Save Slack';
1495
+ }
1496
+ }
1497
+
1434
1498
  // ── Workspace ────────────────────────────────────────────────────────────────
1435
1499
 
1436
1500
  async function loadWorkspace() {
@@ -1696,6 +1760,10 @@ function renderAdminPage(token) {
1696
1760
  const mountOpts = mounts.map((m) =>
1697
1761
  '<option value="' + m + '"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'
1698
1762
  ).join('');
1763
+ const replyModes = ['top-level','thread'];
1764
+ const replyModeOpts = replyModes.map((m) =>
1765
+ '<option value="' + m + '"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'
1766
+ ).join('');
1699
1767
  return [
1700
1768
  '<div class="config-grid">',
1701
1769
  '<div class="config-block">',
@@ -1715,6 +1783,12 @@ function renderAdminPage(token) {
1715
1783
  '<button class="primary-action-btn" onclick="saveGlobalSandbox(this)">Save sandbox</button>',
1716
1784
  '<div id="g-sandbox-result" class="inline-result" style="display:none"></div>',
1717
1785
  '</div>',
1786
+ '<div class="config-block">',
1787
+ '<h3 class="card-subtitle">Slack</h3>',
1788
+ '<div class="config-row"><label>Reply mode</label><select id="g-slack-reply-mode">' + replyModeOpts + '</select></div>',
1789
+ '<button class="primary-action-btn" onclick="saveGlobalSlack(this)">Save Slack</button>',
1790
+ '<div id="g-slack-result" class="inline-result" style="display:none"></div>',
1791
+ '</div>',
1718
1792
  '</div>',
1719
1793
  ].join('');
1720
1794
  }
@@ -1758,6 +1832,20 @@ function renderAdminPage(token) {
1758
1832
  }
1759
1833
  }
1760
1834
 
1835
+ async function saveGlobalSlack(btn) {
1836
+ const replyMode = document.getElementById('g-slack-reply-mode').value;
1837
+ const result = document.getElementById('g-slack-result');
1838
+ btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';
1839
+ try {
1840
+ await apiPost('/admin/api/settings/slack', { replyMode });
1841
+ result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';
1842
+ } catch (err) {
1843
+ result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;
1844
+ } finally {
1845
+ btn.disabled = false; btn.textContent = 'Save Slack';
1846
+ }
1847
+ }
1848
+
1761
1849
  async function loadGlobalSkills() {
1762
1850
  const container = document.getElementById('global-skills-content');
1763
1851
  container.innerHTML = '<div class="loading-msg">Loading…</div>';