slackhive 0.1.37 → 0.1.39

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 (542) hide show
  1. package/.dockerignore +14 -0
  2. package/.env.example +44 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.yml +65 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +38 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +27 -0
  7. package/.github/dependabot.yml +20 -0
  8. package/.github/workflows/audit.yml +149 -0
  9. package/.github/workflows/ci.yml +135 -0
  10. package/CHANGELOG.md +52 -0
  11. package/CODE_OF_CONDUCT.md +37 -0
  12. package/CONTRIBUTING.md +204 -0
  13. package/LICENSE +21 -0
  14. package/README.md +19 -0
  15. package/SECURITY.md +47 -0
  16. package/apps/runner/Dockerfile +33 -0
  17. package/apps/runner/dist/__tests__/channel-restrictions.test.d.ts +8 -0
  18. package/apps/runner/dist/__tests__/channel-restrictions.test.js +63 -0
  19. package/apps/runner/dist/__tests__/channel-restrictions.test.js.map +1 -0
  20. package/apps/runner/dist/__tests__/claude-handler-resolve.test.d.ts +20 -0
  21. package/apps/runner/dist/__tests__/claude-handler-resolve.test.js +178 -0
  22. package/apps/runner/dist/__tests__/claude-handler-resolve.test.js.map +1 -0
  23. package/apps/runner/dist/__tests__/compile-claude-md.test.d.ts +13 -0
  24. package/apps/runner/dist/__tests__/compile-claude-md.test.js +144 -0
  25. package/apps/runner/dist/__tests__/compile-claude-md.test.js.map +1 -0
  26. package/apps/runner/dist/__tests__/memory-sync.test.d.ts +11 -0
  27. package/apps/runner/dist/__tests__/memory-sync.test.js +56 -0
  28. package/apps/runner/dist/__tests__/memory-sync.test.js.map +1 -0
  29. package/apps/runner/dist/__tests__/slack-file-support.test.d.ts +9 -0
  30. package/apps/runner/dist/__tests__/slack-file-support.test.js +271 -0
  31. package/apps/runner/dist/__tests__/slack-file-support.test.js.map +1 -0
  32. package/apps/runner/dist/__tests__/slack-formatting.test.d.ts +12 -0
  33. package/apps/runner/dist/__tests__/slack-formatting.test.js +400 -0
  34. package/apps/runner/dist/__tests__/slack-formatting.test.js.map +1 -0
  35. package/apps/runner/dist/__tests__/thread-context.test.d.ts +12 -0
  36. package/apps/runner/dist/__tests__/thread-context.test.js +182 -0
  37. package/apps/runner/dist/__tests__/thread-context.test.js.map +1 -0
  38. package/apps/runner/dist/agent-runner.d.ts +118 -0
  39. package/apps/runner/dist/agent-runner.js +352 -0
  40. package/apps/runner/dist/agent-runner.js.map +1 -0
  41. package/apps/runner/dist/claude-handler.d.ts +122 -0
  42. package/apps/runner/dist/claude-handler.js +402 -0
  43. package/apps/runner/dist/claude-handler.js.map +1 -0
  44. package/apps/runner/dist/compile-claude-md.d.ts +59 -0
  45. package/apps/runner/dist/compile-claude-md.js +291 -0
  46. package/apps/runner/dist/compile-claude-md.js.map +1 -0
  47. package/apps/runner/dist/correction-handler.d.ts +46 -0
  48. package/apps/runner/dist/correction-handler.js +162 -0
  49. package/apps/runner/dist/correction-handler.js.map +1 -0
  50. package/apps/runner/dist/correction-manager.d.ts +53 -0
  51. package/apps/runner/dist/correction-manager.js +241 -0
  52. package/apps/runner/dist/correction-manager.js.map +1 -0
  53. package/apps/runner/dist/db.d.ts +193 -0
  54. package/apps/runner/dist/db.js +492 -0
  55. package/apps/runner/dist/db.js.map +1 -0
  56. package/apps/runner/dist/index.d.ts +9 -0
  57. package/apps/runner/dist/index.js +43 -0
  58. package/apps/runner/dist/index.js.map +1 -0
  59. package/apps/runner/dist/job-scheduler.d.ts +57 -0
  60. package/apps/runner/dist/job-scheduler.js +150 -0
  61. package/apps/runner/dist/job-scheduler.js.map +1 -0
  62. package/apps/runner/dist/logger.d.ts +32 -0
  63. package/apps/runner/dist/logger.js +52 -0
  64. package/apps/runner/dist/logger.js.map +1 -0
  65. package/apps/runner/dist/mcp-process-manager.d.ts +38 -0
  66. package/apps/runner/dist/mcp-process-manager.js +189 -0
  67. package/apps/runner/dist/mcp-process-manager.js.map +1 -0
  68. package/apps/runner/dist/memory-mcp.d.ts +14 -0
  69. package/apps/runner/dist/memory-mcp.js +88 -0
  70. package/apps/runner/dist/memory-mcp.js.map +1 -0
  71. package/apps/runner/dist/memory-watcher.d.ts +78 -0
  72. package/apps/runner/dist/memory-watcher.js +220 -0
  73. package/apps/runner/dist/memory-watcher.js.map +1 -0
  74. package/apps/runner/dist/slack-handler.d.ts +120 -0
  75. package/apps/runner/dist/slack-handler.js +843 -0
  76. package/apps/runner/dist/slack-handler.js.map +1 -0
  77. package/apps/runner/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  78. package/apps/runner/package.json +42 -0
  79. package/apps/runner/src/__tests__/channel-restrictions.test.ts +75 -0
  80. package/apps/runner/src/__tests__/claude-handler-resolve.test.ts +160 -0
  81. package/apps/runner/src/__tests__/compile-claude-md.test.ts +139 -0
  82. package/apps/runner/src/__tests__/memory-sync.test.ts +59 -0
  83. package/apps/runner/src/__tests__/slack-file-support.test.ts +376 -0
  84. package/apps/runner/src/__tests__/slack-formatting.test.ts +495 -0
  85. package/apps/runner/src/__tests__/thread-context.test.ts +215 -0
  86. package/apps/runner/src/agent-runner.ts +397 -0
  87. package/apps/runner/src/claude-handler.ts +475 -0
  88. package/apps/runner/src/compile-claude-md.ts +283 -0
  89. package/apps/runner/src/correction-handler.ts +191 -0
  90. package/apps/runner/src/correction-manager.ts +285 -0
  91. package/apps/runner/src/db.ts +604 -0
  92. package/apps/runner/src/index.ts +46 -0
  93. package/apps/runner/src/job-scheduler.ts +165 -0
  94. package/apps/runner/src/logger.ts +49 -0
  95. package/apps/runner/src/mcp-process-manager.ts +195 -0
  96. package/apps/runner/src/memory-mcp.ts +85 -0
  97. package/apps/runner/src/memory-watcher.ts +215 -0
  98. package/apps/runner/src/slack-handler.ts +929 -0
  99. package/apps/runner/tsconfig.json +17 -0
  100. package/apps/runner/vitest.config.mts +17 -0
  101. package/apps/web/.eslintrc.json +3 -0
  102. package/apps/web/.next/app-build-manifest.json +323 -0
  103. package/apps/web/.next/app-path-routes-manifest.json +46 -0
  104. package/apps/web/.next/build-manifest.json +33 -0
  105. package/apps/web/.next/cache/.previewinfo +1 -0
  106. package/apps/web/.next/cache/.rscinfo +1 -0
  107. package/apps/web/.next/cache/webpack/client-production/0.pack +0 -0
  108. package/apps/web/.next/cache/webpack/client-production/1.pack +0 -0
  109. package/apps/web/.next/cache/webpack/client-production/2.pack +0 -0
  110. package/apps/web/.next/cache/webpack/client-production/3.pack +0 -0
  111. package/apps/web/.next/cache/webpack/client-production/4.pack +0 -0
  112. package/apps/web/.next/cache/webpack/client-production/index.pack +0 -0
  113. package/apps/web/.next/cache/webpack/client-production/index.pack.old +0 -0
  114. package/apps/web/.next/cache/webpack/edge-server-production/0.pack +0 -0
  115. package/apps/web/.next/cache/webpack/edge-server-production/1.pack +0 -0
  116. package/apps/web/.next/cache/webpack/edge-server-production/index.pack +0 -0
  117. package/apps/web/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
  118. package/apps/web/.next/cache/webpack/server-production/0.pack +0 -0
  119. package/apps/web/.next/cache/webpack/server-production/1.pack +0 -0
  120. package/apps/web/.next/cache/webpack/server-production/2.pack +0 -0
  121. package/apps/web/.next/cache/webpack/server-production/index.pack +0 -0
  122. package/apps/web/.next/cache/webpack/server-production/index.pack.old +0 -0
  123. package/apps/web/.next/diagnostics/build-diagnostics.json +6 -0
  124. package/apps/web/.next/diagnostics/framework.json +1 -0
  125. package/apps/web/.next/package.json +1 -0
  126. package/apps/web/.next/react-loadable-manifest.json +1 -0
  127. package/apps/web/.next/server/app/_not-found/page.js +2 -0
  128. package/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -0
  129. package/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  130. package/apps/web/.next/server/app/agents/[slug]/page.js +4 -0
  131. package/apps/web/.next/server/app/agents/[slug]/page.js.nft.json +1 -0
  132. package/apps/web/.next/server/app/agents/[slug]/page_client-reference-manifest.js +1 -0
  133. package/apps/web/.next/server/app/agents/new/page.js +2 -0
  134. package/apps/web/.next/server/app/agents/new/page.js.nft.json +1 -0
  135. package/apps/web/.next/server/app/agents/new/page_client-reference-manifest.js +1 -0
  136. package/apps/web/.next/server/app/api/agents/[id]/access/route.js +1 -0
  137. package/apps/web/.next/server/app/api/agents/[id]/access/route.js.nft.json +1 -0
  138. package/apps/web/.next/server/app/api/agents/[id]/access/route_client-reference-manifest.js +1 -0
  139. package/apps/web/.next/server/app/api/agents/[id]/claude-md/route.js +6 -0
  140. package/apps/web/.next/server/app/api/agents/[id]/claude-md/route.js.nft.json +1 -0
  141. package/apps/web/.next/server/app/api/agents/[id]/claude-md/route_client-reference-manifest.js +1 -0
  142. package/apps/web/.next/server/app/api/agents/[id]/logs/route.js +3 -0
  143. package/apps/web/.next/server/app/api/agents/[id]/logs/route.js.nft.json +1 -0
  144. package/apps/web/.next/server/app/api/agents/[id]/logs/route_client-reference-manifest.js +1 -0
  145. package/apps/web/.next/server/app/api/agents/[id]/manifest/route.js +1 -0
  146. package/apps/web/.next/server/app/api/agents/[id]/manifest/route.js.nft.json +1 -0
  147. package/apps/web/.next/server/app/api/agents/[id]/manifest/route_client-reference-manifest.js +1 -0
  148. package/apps/web/.next/server/app/api/agents/[id]/mcps/route.js +1 -0
  149. package/apps/web/.next/server/app/api/agents/[id]/mcps/route.js.nft.json +1 -0
  150. package/apps/web/.next/server/app/api/agents/[id]/mcps/route_client-reference-manifest.js +1 -0
  151. package/apps/web/.next/server/app/api/agents/[id]/memories/[memId]/route.js +1 -0
  152. package/apps/web/.next/server/app/api/agents/[id]/memories/[memId]/route.js.nft.json +1 -0
  153. package/apps/web/.next/server/app/api/agents/[id]/memories/[memId]/route_client-reference-manifest.js +1 -0
  154. package/apps/web/.next/server/app/api/agents/[id]/memories/route.js +1 -0
  155. package/apps/web/.next/server/app/api/agents/[id]/memories/route.js.nft.json +1 -0
  156. package/apps/web/.next/server/app/api/agents/[id]/memories/route_client-reference-manifest.js +1 -0
  157. package/apps/web/.next/server/app/api/agents/[id]/permissions/route.js +1 -0
  158. package/apps/web/.next/server/app/api/agents/[id]/permissions/route.js.nft.json +1 -0
  159. package/apps/web/.next/server/app/api/agents/[id]/permissions/route_client-reference-manifest.js +1 -0
  160. package/apps/web/.next/server/app/api/agents/[id]/reload/route.js +1 -0
  161. package/apps/web/.next/server/app/api/agents/[id]/reload/route.js.nft.json +1 -0
  162. package/apps/web/.next/server/app/api/agents/[id]/reload/route_client-reference-manifest.js +1 -0
  163. package/apps/web/.next/server/app/api/agents/[id]/restrictions/route.js +1 -0
  164. package/apps/web/.next/server/app/api/agents/[id]/restrictions/route.js.nft.json +1 -0
  165. package/apps/web/.next/server/app/api/agents/[id]/restrictions/route_client-reference-manifest.js +1 -0
  166. package/apps/web/.next/server/app/api/agents/[id]/route.js +33 -0
  167. package/apps/web/.next/server/app/api/agents/[id]/route.js.nft.json +1 -0
  168. package/apps/web/.next/server/app/api/agents/[id]/route_client-reference-manifest.js +1 -0
  169. package/apps/web/.next/server/app/api/agents/[id]/skills/[skillId]/route.js +1 -0
  170. package/apps/web/.next/server/app/api/agents/[id]/skills/[skillId]/route.js.nft.json +1 -0
  171. package/apps/web/.next/server/app/api/agents/[id]/skills/[skillId]/route_client-reference-manifest.js +1 -0
  172. package/apps/web/.next/server/app/api/agents/[id]/skills/route.js +1 -0
  173. package/apps/web/.next/server/app/api/agents/[id]/skills/route.js.nft.json +1 -0
  174. package/apps/web/.next/server/app/api/agents/[id]/skills/route_client-reference-manifest.js +1 -0
  175. package/apps/web/.next/server/app/api/agents/[id]/slack-info/route.js +1 -0
  176. package/apps/web/.next/server/app/api/agents/[id]/slack-info/route.js.nft.json +1 -0
  177. package/apps/web/.next/server/app/api/agents/[id]/slack-info/route_client-reference-manifest.js +1 -0
  178. package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/restore/route.js +1 -0
  179. package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/restore/route.js.nft.json +1 -0
  180. package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/restore/route_client-reference-manifest.js +1 -0
  181. package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/route.js +1 -0
  182. package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/route.js.nft.json +1 -0
  183. package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/route_client-reference-manifest.js +1 -0
  184. package/apps/web/.next/server/app/api/agents/[id]/snapshots/route.js +1 -0
  185. package/apps/web/.next/server/app/api/agents/[id]/snapshots/route.js.nft.json +1 -0
  186. package/apps/web/.next/server/app/api/agents/[id]/snapshots/route_client-reference-manifest.js +1 -0
  187. package/apps/web/.next/server/app/api/agents/[id]/start/route.js +1 -0
  188. package/apps/web/.next/server/app/api/agents/[id]/start/route.js.nft.json +1 -0
  189. package/apps/web/.next/server/app/api/agents/[id]/start/route_client-reference-manifest.js +1 -0
  190. package/apps/web/.next/server/app/api/agents/[id]/stop/route.js +1 -0
  191. package/apps/web/.next/server/app/api/agents/[id]/stop/route.js.nft.json +1 -0
  192. package/apps/web/.next/server/app/api/agents/[id]/stop/route_client-reference-manifest.js +1 -0
  193. package/apps/web/.next/server/app/api/agents/route.js +91 -0
  194. package/apps/web/.next/server/app/api/agents/route.js.nft.json +1 -0
  195. package/apps/web/.next/server/app/api/agents/route_client-reference-manifest.js +1 -0
  196. package/apps/web/.next/server/app/api/auth/login/route.js +1 -0
  197. package/apps/web/.next/server/app/api/auth/login/route.js.nft.json +1 -0
  198. package/apps/web/.next/server/app/api/auth/login/route_client-reference-manifest.js +1 -0
  199. package/apps/web/.next/server/app/api/auth/logout/route.js +1 -0
  200. package/apps/web/.next/server/app/api/auth/logout/route.js.nft.json +1 -0
  201. package/apps/web/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -0
  202. package/apps/web/.next/server/app/api/auth/me/route.js +1 -0
  203. package/apps/web/.next/server/app/api/auth/me/route.js.nft.json +1 -0
  204. package/apps/web/.next/server/app/api/auth/me/route_client-reference-manifest.js +1 -0
  205. package/apps/web/.next/server/app/api/auth/users/[id]/route.js +1 -0
  206. package/apps/web/.next/server/app/api/auth/users/[id]/route.js.nft.json +1 -0
  207. package/apps/web/.next/server/app/api/auth/users/[id]/route_client-reference-manifest.js +1 -0
  208. package/apps/web/.next/server/app/api/auth/users/route.js +1 -0
  209. package/apps/web/.next/server/app/api/auth/users/route.js.nft.json +1 -0
  210. package/apps/web/.next/server/app/api/auth/users/route_client-reference-manifest.js +1 -0
  211. package/apps/web/.next/server/app/api/env-vars/[key]/route.js +1 -0
  212. package/apps/web/.next/server/app/api/env-vars/[key]/route.js.nft.json +1 -0
  213. package/apps/web/.next/server/app/api/env-vars/[key]/route_client-reference-manifest.js +1 -0
  214. package/apps/web/.next/server/app/api/env-vars/route.js +1 -0
  215. package/apps/web/.next/server/app/api/env-vars/route.js.nft.json +1 -0
  216. package/apps/web/.next/server/app/api/env-vars/route_client-reference-manifest.js +1 -0
  217. package/apps/web/.next/server/app/api/jobs/[id]/route.js +1 -0
  218. package/apps/web/.next/server/app/api/jobs/[id]/route.js.nft.json +1 -0
  219. package/apps/web/.next/server/app/api/jobs/[id]/route_client-reference-manifest.js +1 -0
  220. package/apps/web/.next/server/app/api/jobs/[id]/runs/route.js +1 -0
  221. package/apps/web/.next/server/app/api/jobs/[id]/runs/route.js.nft.json +1 -0
  222. package/apps/web/.next/server/app/api/jobs/[id]/runs/route_client-reference-manifest.js +1 -0
  223. package/apps/web/.next/server/app/api/jobs/route.js +1 -0
  224. package/apps/web/.next/server/app/api/jobs/route.js.nft.json +1 -0
  225. package/apps/web/.next/server/app/api/jobs/route_client-reference-manifest.js +1 -0
  226. package/apps/web/.next/server/app/api/mcps/[id]/route.js +1 -0
  227. package/apps/web/.next/server/app/api/mcps/[id]/route.js.nft.json +1 -0
  228. package/apps/web/.next/server/app/api/mcps/[id]/route_client-reference-manifest.js +1 -0
  229. package/apps/web/.next/server/app/api/mcps/[id]/test/route.js +1 -0
  230. package/apps/web/.next/server/app/api/mcps/[id]/test/route.js.nft.json +1 -0
  231. package/apps/web/.next/server/app/api/mcps/[id]/test/route_client-reference-manifest.js +1 -0
  232. package/apps/web/.next/server/app/api/mcps/route.js +1 -0
  233. package/apps/web/.next/server/app/api/mcps/route.js.nft.json +1 -0
  234. package/apps/web/.next/server/app/api/mcps/route_client-reference-manifest.js +1 -0
  235. package/apps/web/.next/server/app/api/settings/route.js +1 -0
  236. package/apps/web/.next/server/app/api/settings/route.js.nft.json +1 -0
  237. package/apps/web/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
  238. package/apps/web/.next/server/app/icon.svg/route.js +1 -0
  239. package/apps/web/.next/server/app/icon.svg/route.js.nft.json +1 -0
  240. package/apps/web/.next/server/app/jobs/page.js +2 -0
  241. package/apps/web/.next/server/app/jobs/page.js.nft.json +1 -0
  242. package/apps/web/.next/server/app/jobs/page_client-reference-manifest.js +1 -0
  243. package/apps/web/.next/server/app/login/page.js +2 -0
  244. package/apps/web/.next/server/app/login/page.js.nft.json +1 -0
  245. package/apps/web/.next/server/app/login/page_client-reference-manifest.js +1 -0
  246. package/apps/web/.next/server/app/page.js +2 -0
  247. package/apps/web/.next/server/app/page.js.nft.json +1 -0
  248. package/apps/web/.next/server/app/page_client-reference-manifest.js +1 -0
  249. package/apps/web/.next/server/app/settings/env-vars/page.js +2 -0
  250. package/apps/web/.next/server/app/settings/env-vars/page.js.nft.json +1 -0
  251. package/apps/web/.next/server/app/settings/env-vars/page_client-reference-manifest.js +1 -0
  252. package/apps/web/.next/server/app/settings/mcps/page.js +2 -0
  253. package/apps/web/.next/server/app/settings/mcps/page.js.nft.json +1 -0
  254. package/apps/web/.next/server/app/settings/mcps/page_client-reference-manifest.js +1 -0
  255. package/apps/web/.next/server/app/settings/page.js +2 -0
  256. package/apps/web/.next/server/app/settings/page.js.nft.json +1 -0
  257. package/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -0
  258. package/apps/web/.next/server/app-paths-manifest.json +46 -0
  259. package/apps/web/.next/server/chunks/1157.js +9 -0
  260. package/apps/web/.next/server/chunks/2287.js +1 -0
  261. package/apps/web/.next/server/chunks/3444.js +1 -0
  262. package/apps/web/.next/server/chunks/383.js +6 -0
  263. package/apps/web/.next/server/chunks/4012.js +58 -0
  264. package/apps/web/.next/server/chunks/6791.js +1 -0
  265. package/apps/web/.next/server/chunks/7171.js +1 -0
  266. package/apps/web/.next/server/chunks/8819.js +22 -0
  267. package/apps/web/.next/server/edge-runtime-webpack.js +2 -0
  268. package/apps/web/.next/server/edge-runtime-webpack.js.map +1 -0
  269. package/apps/web/.next/server/interception-route-rewrite-manifest.js +1 -0
  270. package/apps/web/.next/server/middleware-build-manifest.js +1 -0
  271. package/apps/web/.next/server/middleware-manifest.json +32 -0
  272. package/apps/web/.next/server/middleware-react-loadable-manifest.js +1 -0
  273. package/apps/web/.next/server/next-font-manifest.js +1 -0
  274. package/apps/web/.next/server/next-font-manifest.json +1 -0
  275. package/apps/web/.next/server/pages/_app.js +1 -0
  276. package/apps/web/.next/server/pages/_app.js.nft.json +1 -0
  277. package/apps/web/.next/server/pages/_document.js +1 -0
  278. package/apps/web/.next/server/pages/_document.js.nft.json +1 -0
  279. package/apps/web/.next/server/pages/_error.js +19 -0
  280. package/apps/web/.next/server/pages/_error.js.nft.json +1 -0
  281. package/apps/web/.next/server/pages-manifest.json +5 -0
  282. package/apps/web/.next/server/server-reference-manifest.js +1 -0
  283. package/apps/web/.next/server/server-reference-manifest.json +1 -0
  284. package/apps/web/.next/server/src/middleware.js +14 -0
  285. package/apps/web/.next/server/src/middleware.js.map +1 -0
  286. package/apps/web/.next/server/webpack-runtime.js +1 -0
  287. package/apps/web/.next/static/chunks/18-90b700ea37b686a2.js +1 -0
  288. package/apps/web/.next/static/chunks/87c73c54-24122e7b92478d00.js +1 -0
  289. package/apps/web/.next/static/chunks/9664-af80478aa73ba424.js +1 -0
  290. package/apps/web/.next/static/chunks/app/_not-found/page-b9cee17ed89ca24a.js +1 -0
  291. package/apps/web/.next/static/chunks/app/agents/[slug]/page-18369fc3fe1a9a7b.js +1 -0
  292. package/apps/web/.next/static/chunks/app/agents/new/page-bf11cf8901c7e2cd.js +1 -0
  293. package/apps/web/.next/static/chunks/app/api/agents/[id]/access/route-07f0f73ac9839899.js +1 -0
  294. package/apps/web/.next/static/chunks/app/api/agents/[id]/claude-md/route-07f0f73ac9839899.js +1 -0
  295. package/apps/web/.next/static/chunks/app/api/agents/[id]/logs/route-07f0f73ac9839899.js +1 -0
  296. package/apps/web/.next/static/chunks/app/api/agents/[id]/manifest/route-07f0f73ac9839899.js +1 -0
  297. package/apps/web/.next/static/chunks/app/api/agents/[id]/mcps/route-07f0f73ac9839899.js +1 -0
  298. package/apps/web/.next/static/chunks/app/api/agents/[id]/memories/[memId]/route-07f0f73ac9839899.js +1 -0
  299. package/apps/web/.next/static/chunks/app/api/agents/[id]/memories/route-07f0f73ac9839899.js +1 -0
  300. package/apps/web/.next/static/chunks/app/api/agents/[id]/permissions/route-07f0f73ac9839899.js +1 -0
  301. package/apps/web/.next/static/chunks/app/api/agents/[id]/reload/route-07f0f73ac9839899.js +1 -0
  302. package/apps/web/.next/static/chunks/app/api/agents/[id]/restrictions/route-07f0f73ac9839899.js +1 -0
  303. package/apps/web/.next/static/chunks/app/api/agents/[id]/route-07f0f73ac9839899.js +1 -0
  304. package/apps/web/.next/static/chunks/app/api/agents/[id]/skills/[skillId]/route-07f0f73ac9839899.js +1 -0
  305. package/apps/web/.next/static/chunks/app/api/agents/[id]/skills/route-07f0f73ac9839899.js +1 -0
  306. package/apps/web/.next/static/chunks/app/api/agents/[id]/slack-info/route-07f0f73ac9839899.js +1 -0
  307. package/apps/web/.next/static/chunks/app/api/agents/[id]/snapshots/[sid]/restore/route-07f0f73ac9839899.js +1 -0
  308. package/apps/web/.next/static/chunks/app/api/agents/[id]/snapshots/[sid]/route-07f0f73ac9839899.js +1 -0
  309. package/apps/web/.next/static/chunks/app/api/agents/[id]/snapshots/route-07f0f73ac9839899.js +1 -0
  310. package/apps/web/.next/static/chunks/app/api/agents/[id]/start/route-07f0f73ac9839899.js +1 -0
  311. package/apps/web/.next/static/chunks/app/api/agents/[id]/stop/route-07f0f73ac9839899.js +1 -0
  312. package/apps/web/.next/static/chunks/app/api/agents/route-07f0f73ac9839899.js +1 -0
  313. package/apps/web/.next/static/chunks/app/api/auth/login/route-07f0f73ac9839899.js +1 -0
  314. package/apps/web/.next/static/chunks/app/api/auth/logout/route-07f0f73ac9839899.js +1 -0
  315. package/apps/web/.next/static/chunks/app/api/auth/me/route-07f0f73ac9839899.js +1 -0
  316. package/apps/web/.next/static/chunks/app/api/auth/users/[id]/route-07f0f73ac9839899.js +1 -0
  317. package/apps/web/.next/static/chunks/app/api/auth/users/route-07f0f73ac9839899.js +1 -0
  318. package/apps/web/.next/static/chunks/app/api/env-vars/[key]/route-07f0f73ac9839899.js +1 -0
  319. package/apps/web/.next/static/chunks/app/api/env-vars/route-07f0f73ac9839899.js +1 -0
  320. package/apps/web/.next/static/chunks/app/api/jobs/[id]/route-07f0f73ac9839899.js +1 -0
  321. package/apps/web/.next/static/chunks/app/api/jobs/[id]/runs/route-07f0f73ac9839899.js +1 -0
  322. package/apps/web/.next/static/chunks/app/api/jobs/route-07f0f73ac9839899.js +1 -0
  323. package/apps/web/.next/static/chunks/app/api/mcps/[id]/route-07f0f73ac9839899.js +1 -0
  324. package/apps/web/.next/static/chunks/app/api/mcps/[id]/test/route-07f0f73ac9839899.js +1 -0
  325. package/apps/web/.next/static/chunks/app/api/mcps/route-07f0f73ac9839899.js +1 -0
  326. package/apps/web/.next/static/chunks/app/api/settings/route-07f0f73ac9839899.js +1 -0
  327. package/apps/web/.next/static/chunks/app/jobs/page-f5aa89a47c50efd8.js +1 -0
  328. package/apps/web/.next/static/chunks/app/layout-2079f4964aa7314e.js +1 -0
  329. package/apps/web/.next/static/chunks/app/login/layout-07f0f73ac9839899.js +1 -0
  330. package/apps/web/.next/static/chunks/app/login/page-aa259283dc38e8f9.js +1 -0
  331. package/apps/web/.next/static/chunks/app/page-e83437b608104dff.js +1 -0
  332. package/apps/web/.next/static/chunks/app/settings/env-vars/page-06479dbdfb78b76b.js +1 -0
  333. package/apps/web/.next/static/chunks/app/settings/mcps/page-75650686ed6490c7.js +1 -0
  334. package/apps/web/.next/static/chunks/app/settings/page-e1e62fc41ff6cddd.js +1 -0
  335. package/apps/web/.next/static/chunks/framework-811407f832a33072.js +1 -0
  336. package/apps/web/.next/static/chunks/main-3f1cddbdd67b1546.js +1 -0
  337. package/apps/web/.next/static/chunks/main-app-cebd8a6a5ccbf72d.js +1 -0
  338. package/apps/web/.next/static/chunks/pages/_app-50fa07b56b2d29ac.js +1 -0
  339. package/apps/web/.next/static/chunks/pages/_error-fed8688bdd23f211.js +1 -0
  340. package/apps/web/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  341. package/apps/web/.next/static/chunks/webpack-6c05566dba553c97.js +1 -0
  342. package/apps/web/.next/static/css/15371687405525e2.css +5 -0
  343. package/apps/web/.next/static/ikfNbLhuw7jntn35bz0lk/_buildManifest.js +1 -0
  344. package/apps/web/.next/static/ikfNbLhuw7jntn35bz0lk/_ssgManifest.js +1 -0
  345. package/apps/web/.next/trace +5 -0
  346. package/apps/web/.next/types/app/agents/[slug]/page.ts +84 -0
  347. package/apps/web/.next/types/app/agents/new/page.ts +84 -0
  348. package/apps/web/.next/types/app/api/agents/[id]/access/route.ts +347 -0
  349. package/apps/web/.next/types/app/api/agents/[id]/claude-md/route.ts +347 -0
  350. package/apps/web/.next/types/app/api/agents/[id]/logs/route.ts +347 -0
  351. package/apps/web/.next/types/app/api/agents/[id]/manifest/route.ts +347 -0
  352. package/apps/web/.next/types/app/api/agents/[id]/mcps/route.ts +347 -0
  353. package/apps/web/.next/types/app/api/agents/[id]/memories/[memId]/route.ts +347 -0
  354. package/apps/web/.next/types/app/api/agents/[id]/memories/route.ts +347 -0
  355. package/apps/web/.next/types/app/api/agents/[id]/permissions/route.ts +347 -0
  356. package/apps/web/.next/types/app/api/agents/[id]/reload/route.ts +347 -0
  357. package/apps/web/.next/types/app/api/agents/[id]/restrictions/route.ts +347 -0
  358. package/apps/web/.next/types/app/api/agents/[id]/route.ts +347 -0
  359. package/apps/web/.next/types/app/api/agents/[id]/skills/[skillId]/route.ts +347 -0
  360. package/apps/web/.next/types/app/api/agents/[id]/skills/route.ts +347 -0
  361. package/apps/web/.next/types/app/api/agents/[id]/slack-info/route.ts +347 -0
  362. package/apps/web/.next/types/app/api/agents/[id]/snapshots/[sid]/restore/route.ts +347 -0
  363. package/apps/web/.next/types/app/api/agents/[id]/snapshots/[sid]/route.ts +347 -0
  364. package/apps/web/.next/types/app/api/agents/[id]/snapshots/route.ts +347 -0
  365. package/apps/web/.next/types/app/api/agents/[id]/start/route.ts +347 -0
  366. package/apps/web/.next/types/app/api/agents/[id]/stop/route.ts +347 -0
  367. package/apps/web/.next/types/app/api/agents/route.ts +347 -0
  368. package/apps/web/.next/types/app/api/auth/login/route.ts +347 -0
  369. package/apps/web/.next/types/app/api/auth/logout/route.ts +347 -0
  370. package/apps/web/.next/types/app/api/auth/me/route.ts +347 -0
  371. package/apps/web/.next/types/app/api/auth/users/[id]/route.ts +347 -0
  372. package/apps/web/.next/types/app/api/auth/users/route.ts +347 -0
  373. package/apps/web/.next/types/app/api/env-vars/[key]/route.ts +347 -0
  374. package/apps/web/.next/types/app/api/env-vars/route.ts +347 -0
  375. package/apps/web/.next/types/app/api/jobs/[id]/route.ts +347 -0
  376. package/apps/web/.next/types/app/api/jobs/[id]/runs/route.ts +347 -0
  377. package/apps/web/.next/types/app/api/jobs/route.ts +347 -0
  378. package/apps/web/.next/types/app/api/mcps/[id]/route.ts +347 -0
  379. package/apps/web/.next/types/app/api/mcps/[id]/test/route.ts +347 -0
  380. package/apps/web/.next/types/app/api/mcps/route.ts +347 -0
  381. package/apps/web/.next/types/app/api/settings/route.ts +347 -0
  382. package/apps/web/.next/types/app/jobs/page.ts +84 -0
  383. package/apps/web/.next/types/app/login/layout.ts +84 -0
  384. package/apps/web/.next/types/app/login/page.ts +84 -0
  385. package/apps/web/.next/types/app/page.ts +84 -0
  386. package/apps/web/.next/types/app/settings/env-vars/page.ts +84 -0
  387. package/apps/web/.next/types/app/settings/mcps/page.ts +84 -0
  388. package/apps/web/.next/types/app/settings/page.ts +84 -0
  389. package/apps/web/.next/types/cache-life.d.ts +141 -0
  390. package/apps/web/.next/types/package.json +1 -0
  391. package/apps/web/.next/types/routes.d.ts +114 -0
  392. package/apps/web/.next/types/validator.ts +448 -0
  393. package/apps/web/Dockerfile +37 -0
  394. package/apps/web/next-env.d.ts +6 -0
  395. package/apps/web/next.config.js +6 -0
  396. package/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  397. package/apps/web/package.json +48 -0
  398. package/apps/web/postcss.config.js +3 -0
  399. package/apps/web/public/logo.svg +17 -0
  400. package/apps/web/src/app/agents/[slug]/page.tsx +2235 -0
  401. package/apps/web/src/app/agents/new/page.tsx +1161 -0
  402. package/apps/web/src/app/api/agents/[id]/access/route.ts +76 -0
  403. package/apps/web/src/app/api/agents/[id]/claude-md/route.ts +111 -0
  404. package/apps/web/src/app/api/agents/[id]/logs/route.ts +84 -0
  405. package/apps/web/src/app/api/agents/[id]/manifest/route.ts +32 -0
  406. package/apps/web/src/app/api/agents/[id]/mcps/route.ts +73 -0
  407. package/apps/web/src/app/api/agents/[id]/memories/[memId]/route.ts +31 -0
  408. package/apps/web/src/app/api/agents/[id]/memories/route.ts +56 -0
  409. package/apps/web/src/app/api/agents/[id]/permissions/route.ts +74 -0
  410. package/apps/web/src/app/api/agents/[id]/reload/route.ts +33 -0
  411. package/apps/web/src/app/api/agents/[id]/restrictions/route.ts +85 -0
  412. package/apps/web/src/app/api/agents/[id]/route.ts +81 -0
  413. package/apps/web/src/app/api/agents/[id]/skills/[skillId]/route.ts +52 -0
  414. package/apps/web/src/app/api/agents/[id]/skills/route.ts +80 -0
  415. package/apps/web/src/app/api/agents/[id]/slack-info/route.ts +38 -0
  416. package/apps/web/src/app/api/agents/[id]/snapshots/[sid]/restore/route.ts +61 -0
  417. package/apps/web/src/app/api/agents/[id]/snapshots/[sid]/route.ts +53 -0
  418. package/apps/web/src/app/api/agents/[id]/snapshots/route.ts +84 -0
  419. package/apps/web/src/app/api/agents/[id]/start/route.ts +35 -0
  420. package/apps/web/src/app/api/agents/[id]/stop/route.ts +35 -0
  421. package/apps/web/src/app/api/agents/route.ts +99 -0
  422. package/apps/web/src/app/api/auth/login/route.ts +39 -0
  423. package/apps/web/src/app/api/auth/logout/route.ts +21 -0
  424. package/apps/web/src/app/api/auth/me/route.ts +24 -0
  425. package/apps/web/src/app/api/auth/users/[id]/route.ts +48 -0
  426. package/apps/web/src/app/api/auth/users/route.ts +63 -0
  427. package/apps/web/src/app/api/env-vars/[key]/route.ts +66 -0
  428. package/apps/web/src/app/api/env-vars/route.ts +59 -0
  429. package/apps/web/src/app/api/jobs/[id]/route.ts +51 -0
  430. package/apps/web/src/app/api/jobs/[id]/runs/route.ts +24 -0
  431. package/apps/web/src/app/api/jobs/route.ts +42 -0
  432. package/apps/web/src/app/api/mcps/[id]/route.ts +60 -0
  433. package/apps/web/src/app/api/mcps/[id]/test/route.ts +195 -0
  434. package/apps/web/src/app/api/mcps/route.ts +72 -0
  435. package/apps/web/src/app/api/settings/route.ts +42 -0
  436. package/apps/web/src/app/globals.css +124 -0
  437. package/apps/web/src/app/icon.svg +17 -0
  438. package/apps/web/src/app/jobs/page.tsx +543 -0
  439. package/apps/web/src/app/layout-shell.tsx +89 -0
  440. package/apps/web/src/app/layout.tsx +18 -0
  441. package/apps/web/src/app/login/layout.tsx +9 -0
  442. package/apps/web/src/app/login/page.tsx +150 -0
  443. package/apps/web/src/app/page.tsx +573 -0
  444. package/apps/web/src/app/settings/env-vars/page.tsx +216 -0
  445. package/apps/web/src/app/settings/mcps/page.tsx +763 -0
  446. package/apps/web/src/app/settings/page.tsx +528 -0
  447. package/apps/web/src/app/sidebar.tsx +345 -0
  448. package/apps/web/src/lib/__tests__/api-guard.test.ts +189 -0
  449. package/apps/web/src/lib/__tests__/auth.test.ts +262 -0
  450. package/apps/web/src/lib/__tests__/boss-registry.test.ts +323 -0
  451. package/apps/web/src/lib/__tests__/compile.test.ts +161 -0
  452. package/apps/web/src/lib/__tests__/db-agent-hierarchy.test.ts +136 -0
  453. package/apps/web/src/lib/__tests__/db-env-vars.test.ts +216 -0
  454. package/apps/web/src/lib/__tests__/db-restrictions.test.ts +117 -0
  455. package/apps/web/src/lib/__tests__/db.integration.test.ts +271 -0
  456. package/apps/web/src/lib/__tests__/diff.test.ts +102 -0
  457. package/apps/web/src/lib/__tests__/mcp-mask.test.ts +274 -0
  458. package/apps/web/src/lib/__tests__/skill-templates.test.ts +237 -0
  459. package/apps/web/src/lib/__tests__/slack-manifest.test.ts +105 -0
  460. package/apps/web/src/lib/api-guard.ts +68 -0
  461. package/apps/web/src/lib/auth-context.tsx +71 -0
  462. package/apps/web/src/lib/auth.ts +128 -0
  463. package/apps/web/src/lib/boss-registry.ts +90 -0
  464. package/apps/web/src/lib/compile.ts +51 -0
  465. package/apps/web/src/lib/db.ts +1196 -0
  466. package/apps/web/src/lib/diff.ts +43 -0
  467. package/apps/web/src/lib/mcp-mask.ts +91 -0
  468. package/apps/web/src/lib/portal.tsx +23 -0
  469. package/apps/web/src/lib/skill-templates.ts +148 -0
  470. package/apps/web/src/lib/slack-manifest.ts +85 -0
  471. package/apps/web/src/middleware.ts +68 -0
  472. package/apps/web/tailwind.config.js +6 -0
  473. package/apps/web/tsconfig.json +23 -0
  474. package/apps/web/vitest.config.mts +21 -0
  475. package/cli/.claude/settings.local.json +6 -0
  476. package/cli/README.md +281 -0
  477. package/cli/node_modules/.package-lock.json +427 -0
  478. package/cli/node_modules/commander/LICENSE +22 -0
  479. package/cli/node_modules/commander/Readme.md +1157 -0
  480. package/cli/node_modules/commander/esm.mjs +16 -0
  481. package/cli/node_modules/commander/index.js +24 -0
  482. package/cli/node_modules/commander/lib/argument.js +149 -0
  483. package/cli/node_modules/commander/lib/command.js +2509 -0
  484. package/cli/node_modules/commander/lib/error.js +39 -0
  485. package/cli/node_modules/commander/lib/help.js +520 -0
  486. package/cli/node_modules/commander/lib/option.js +330 -0
  487. package/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
  488. package/cli/node_modules/commander/package-support.json +16 -0
  489. package/cli/node_modules/commander/package.json +84 -0
  490. package/cli/node_modules/commander/typings/esm.d.mts +3 -0
  491. package/cli/node_modules/commander/typings/index.d.ts +969 -0
  492. package/cli/package-lock.json +449 -0
  493. package/cli/package.json +44 -0
  494. package/cli/src/commands/init.ts +514 -0
  495. package/cli/src/commands/manage.ts +115 -0
  496. package/cli/src/index.ts +63 -0
  497. package/cli/tsconfig.json +14 -0
  498. package/docker-compose.yml +122 -0
  499. package/docs/agents/boss-agents.mdx +108 -0
  500. package/docs/agents/creating-agents.mdx +132 -0
  501. package/docs/agents/memory.mdx +113 -0
  502. package/docs/agents/tools.mdx +103 -0
  503. package/docs/configuration/env-vars.mdx +166 -0
  504. package/docs/configuration/mcp-servers.mdx +203 -0
  505. package/docs/configuration/slack-app.mdx +175 -0
  506. package/docs/docs.json +79 -0
  507. package/docs/favicon.svg +17 -0
  508. package/docs/features/history.mdx +60 -0
  509. package/docs/features/import-export.mdx +77 -0
  510. package/docs/features/logs.mdx +131 -0
  511. package/docs/features/multi-workspace.mdx +90 -0
  512. package/docs/features/scheduled-jobs.mdx +231 -0
  513. package/docs/features/users.mdx +92 -0
  514. package/docs/introduction.mdx +160 -0
  515. package/docs/logo/dark.svg +17 -0
  516. package/docs/logo/light.svg +17 -0
  517. package/docs/logo/wide-dark.svg +12 -0
  518. package/docs/logo/wide-light.svg +12 -0
  519. package/docs/quickstart.mdx +270 -0
  520. package/docs/self-hosting/docker.mdx +151 -0
  521. package/docs/self-hosting/production.mdx +176 -0
  522. package/package.json +20 -36
  523. package/packages/shared/dist/index.d.ts +8 -0
  524. package/packages/shared/dist/index.d.ts.map +1 -0
  525. package/packages/shared/dist/index.js +24 -0
  526. package/packages/shared/dist/index.js.map +1 -0
  527. package/packages/shared/dist/types.d.ts +584 -0
  528. package/packages/shared/dist/types.d.ts.map +1 -0
  529. package/packages/shared/dist/types.js +39 -0
  530. package/packages/shared/dist/types.js.map +1 -0
  531. package/packages/shared/package.json +15 -0
  532. package/packages/shared/src/db/schema.sql +354 -0
  533. package/packages/shared/src/index.ts +8 -0
  534. package/packages/shared/src/types.ts +683 -0
  535. package/packages/shared/tsconfig.json +17 -0
  536. package/scripts/dev.sh +45 -0
  537. /package/{dist → cli/dist}/commands/init.d.ts +0 -0
  538. /package/{dist → cli/dist}/commands/init.js +0 -0
  539. /package/{dist → cli/dist}/commands/manage.d.ts +0 -0
  540. /package/{dist → cli/dist}/commands/manage.js +0 -0
  541. /package/{dist → cli/dist}/index.d.ts +0 -0
  542. /package/{dist → cli/dist}/index.js +0 -0
@@ -0,0 +1,2235 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * @fileoverview Agent detail page — tabbed control panel.
5
+ *
6
+ * Tabs: Overview · Skills · MCPs · Permissions · Memory · Logs
7
+ *
8
+ * Route: /agents/[slug]
9
+ * @module web/app/agents/[slug]
10
+ */
11
+
12
+ import React, { useEffect, useState, useRef, use } from 'react';
13
+ import { Brain, Camera, Clock, History, Upload, Download } from 'lucide-react';
14
+ import Link from 'next/link';
15
+ import { useRouter } from 'next/navigation';
16
+ import type { Agent, Skill, McpServer, Memory, Permission, Restriction, AgentSnapshot } from '@slackhive/shared';
17
+ import { Portal } from '@/lib/portal';
18
+ import { useAuth } from '@/lib/auth-context';
19
+ import { lineDiff, type DiffLine } from '@/lib/diff';
20
+
21
+ type Tab = 'overview' | 'skills' | 'claude-md' | 'mcps' | 'permissions' | 'memory' | 'logs' | 'history';
22
+
23
+ interface AgentExportPayload {
24
+ version: number;
25
+ exportedAt?: string;
26
+ claudeMd: string;
27
+ skills: { category: string; filename: string; content: string; sortOrder: number }[];
28
+ }
29
+
30
+ const TABS: { id: Tab; label: string }[] = [
31
+ { id: 'overview', label: 'Overview' },
32
+ { id: 'skills', label: 'Skills' },
33
+ { id: 'claude-md', label: 'System Prompt' },
34
+ { id: 'mcps', label: 'MCPs' },
35
+ { id: 'permissions', label: 'Tools' },
36
+ { id: 'memory', label: 'Memory' },
37
+ { id: 'logs', label: 'Logs' },
38
+ { id: 'history', label: 'History' },
39
+ ];
40
+
41
+ const STATUS_COLOR = { running: '#16a34a', stopped: 'var(--border-2)', error: '#ef4444' } as const;
42
+
43
+ // ─── Page ─────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Agent detail page — loads the agent by slug then renders the tabbed UI.
47
+ *
48
+ * @param {{ params: Promise<{ slug: string }> }} props
49
+ */
50
+ export default function AgentPage({ params }: { params: Promise<{ slug: string }> }) {
51
+ const { slug } = use(params);
52
+ const { role, canManageUsers } = useAuth();
53
+ const [agent, setAgent] = useState<Agent | null>(null);
54
+ const [allAgents, setAllAgents] = useState<Agent[]>([]);
55
+ const [canEdit, setCanEdit] = useState(false);
56
+ const [tab, setTab] = useState<Tab>('overview');
57
+ const [loading, setLoading] = useState(true);
58
+ const [actionMsg, setActionMsg] = useState('');
59
+ const [exporting, setExporting] = useState(false);
60
+ const [importPreview, setImportPreview] = useState<AgentExportPayload | null>(null);
61
+ const [importing, setImporting] = useState(false);
62
+ const [importError, setImportError] = useState('');
63
+ const fileInputRef = useRef<HTMLInputElement>(null);
64
+
65
+ useEffect(() => {
66
+ fetch('/api/agents')
67
+ .then(r => r.json())
68
+ .then((agents: Agent[]) => {
69
+ setAllAgents(agents);
70
+ const found = agents.find(a => a.slug === slug) ?? null;
71
+ setAgent(found);
72
+ if (found) {
73
+ if (role === 'admin' || role === 'superadmin') {
74
+ setCanEdit(true);
75
+ } else if (role === 'editor' || role === 'viewer') {
76
+ fetch(`/api/agents/${found.id}/access`)
77
+ .then(r => r.json())
78
+ .then(data => setCanEdit(role === 'editor' && (data.canWrite ?? false)));
79
+ }
80
+ }
81
+ })
82
+ .finally(() => setLoading(false));
83
+ }, [slug, role]);
84
+
85
+ const triggerAction = async (action: 'start' | 'stop' | 'reload') => {
86
+ if (!agent) return;
87
+ setActionMsg(action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Reloading…');
88
+ await fetch(`/api/agents/${agent.id}/${action}`, { method: 'POST' });
89
+ const r = await fetch(`/api/agents/${agent.id}`);
90
+ setAgent(await r.json());
91
+ setActionMsg('Done');
92
+ setTimeout(() => setActionMsg(''), 2000);
93
+ };
94
+
95
+ const handleExport = async () => {
96
+ if (!agent) return;
97
+ setExporting(true);
98
+ try {
99
+ const [skillsRes, mdRes] = await Promise.all([
100
+ fetch(`/api/agents/${agent.id}/skills`),
101
+ fetch(`/api/agents/${agent.id}/claude-md`),
102
+ ]);
103
+ const skills: Skill[] = await skillsRes.json();
104
+ const claudeMd = await mdRes.text();
105
+ const payload: AgentExportPayload = {
106
+ version: 1,
107
+ exportedAt: new Date().toISOString(),
108
+ claudeMd,
109
+ skills: skills.map(s => ({ category: s.category, filename: s.filename, content: s.content, sortOrder: s.sortOrder })),
110
+ };
111
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
112
+ const url = URL.createObjectURL(blob);
113
+ const a = document.createElement('a');
114
+ a.href = url; a.download = `${agent.slug}-export.json`; a.click();
115
+ URL.revokeObjectURL(url);
116
+ } finally { setExporting(false); }
117
+ };
118
+
119
+ const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
120
+ const file = e.target.files?.[0];
121
+ if (!file) return;
122
+ e.target.value = '';
123
+ setImportError('');
124
+ const reader = new FileReader();
125
+ reader.onload = (ev) => {
126
+ try {
127
+ const data = JSON.parse(ev.target?.result as string);
128
+ if (typeof data.claudeMd !== 'string' || !Array.isArray(data.skills)) {
129
+ setImportError('Invalid export file'); return;
130
+ }
131
+ setImportPreview(data);
132
+ } catch { setImportError('Could not parse file'); }
133
+ };
134
+ reader.readAsText(file);
135
+ };
136
+
137
+ const applyImport = async () => {
138
+ if (!agent || !importPreview) return;
139
+ setImporting(true);
140
+ try {
141
+ await fetch(`/api/agents/${agent.id}/claude-md`, {
142
+ method: 'PUT', headers: { 'Content-Type': 'text/plain' },
143
+ body: importPreview.claudeMd,
144
+ });
145
+ await Promise.all(importPreview.skills.map(s =>
146
+ fetch(`/api/agents/${agent.id}/skills?noSnapshot=1`, {
147
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify(s),
149
+ })
150
+ ));
151
+ const updated = await fetch(`/api/agents/${agent.id}`).then(r => r.json());
152
+ setAgent(updated);
153
+ setImportPreview(null);
154
+ } finally { setImporting(false); }
155
+ };
156
+
157
+ if (loading) return <PageLoader />;
158
+ if (!agent) return <NotFound slug={slug} />;
159
+
160
+ const statusColor = STATUS_COLOR[agent.status] ?? 'var(--border-2)';
161
+
162
+ return (
163
+ <div style={{ minHeight: '100vh' }} className="fade-up">
164
+
165
+ {/* ── Top bar ──────────────────────────────────────────────────────── */}
166
+ <div style={{
167
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
168
+ padding: '28px 40px 0',
169
+ borderBottom: '1px solid var(--border)',
170
+ paddingBottom: 0,
171
+ flexWrap: 'wrap', gap: 12,
172
+ }}>
173
+ <div>
174
+ {/* Breadcrumb */}
175
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10, fontSize: 12, color: 'var(--muted)' }}>
176
+ <Link href="/" style={{ color: 'var(--muted)', textDecoration: 'none' }}>Agents</Link>
177
+ <span style={{ color: 'var(--subtle)' }}>/</span>
178
+ <span style={{ color: 'var(--text)' }}>{agent.name}</span>
179
+ </div>
180
+
181
+ {/* Agent name + status */}
182
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
183
+ <div style={{
184
+ width: 36, height: 36, borderRadius: 10, flexShrink: 0,
185
+ background: agent.isBoss
186
+ ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
187
+ : 'linear-gradient(135deg, #3b82f6 0%, #6366f1 100%)',
188
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
189
+ fontSize: 14, fontWeight: 700, color: '#fff',
190
+ }}>
191
+ {agent.name.charAt(0)}
192
+ </div>
193
+ <div>
194
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
195
+ <h1 style={{ margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--text)' }}>
196
+ {agent.name}
197
+ </h1>
198
+ {agent.isBoss && (
199
+ <span style={{
200
+ fontSize: 10, fontWeight: 600, letterSpacing: '0.06em',
201
+ background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
202
+ padding: '2px 7px', borderRadius: 5,
203
+ border: '1px solid rgba(245,158,11,0.25)',
204
+ textTransform: 'uppercase',
205
+ }}>Boss</span>
206
+ )}
207
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
208
+ <div
209
+ className={agent.status === 'running' ? 'status-running' : ''}
210
+ style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor }}
211
+ />
212
+ <span style={{ fontSize: 12, color: statusColor, fontWeight: 500, textTransform: 'capitalize' }}>
213
+ {agent.status}
214
+ </span>
215
+ </div>
216
+ </div>
217
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--muted)', marginTop: 1 }}>
218
+ @{agent.slug} · {agent.model.replace('claude-', '').split('-20')[0]}
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Controls */}
225
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBottom: 16 }}>
226
+ {actionMsg && <span style={{ fontSize: 12, color: 'var(--muted)' }}>{actionMsg}</span>}
227
+ {importError && <span style={{ fontSize: 12, color: 'var(--danger)' }}>{importError}</span>}
228
+
229
+ {/* Export / Import icon buttons */}
230
+ <IconBtn title="Export config" onClick={handleExport} loading={exporting}>
231
+ <Download size={15} />
232
+ </IconBtn>
233
+ {canEdit && (
234
+ <IconBtn title="Import config" onClick={() => fileInputRef.current?.click()}>
235
+ <Upload size={15} />
236
+ </IconBtn>
237
+ )}
238
+ <input ref={fileInputRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImportFile} />
239
+
240
+ <div style={{ width: 1, height: 20, background: 'var(--border)', margin: '0 2px' }} />
241
+
242
+ {canEdit && agent.status !== 'running' && (
243
+ <Btn color="#22c55e" onClick={() => triggerAction('start')}>Start</Btn>
244
+ )}
245
+ {canEdit && agent.status === 'running' && (
246
+ <Btn color="var(--border-2)" textColor="var(--muted)" onClick={() => triggerAction('reload')}>Reload</Btn>
247
+ )}
248
+ {canEdit && agent.status === 'running' && (
249
+ <Btn color="#ef4444" onClick={() => triggerAction('stop')}>Stop</Btn>
250
+ )}
251
+ </div>
252
+ </div>
253
+
254
+ {/* Import confirmation modal */}
255
+ {importPreview && (
256
+ <Portal>
257
+ <div style={{
258
+ position: 'fixed', inset: 0, zIndex: 1000,
259
+ background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)',
260
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
261
+ }} onClick={() => setImportPreview(null)}>
262
+ <div style={{
263
+ background: '#fff', borderRadius: 14, padding: '28px 32px',
264
+ maxWidth: 480, width: '90%',
265
+ boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
266
+ }} onClick={e => e.stopPropagation()}>
267
+ <h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.02em' }}>
268
+ Import agent config
269
+ </h3>
270
+
271
+ {/* Danger warning — shown first */}
272
+ <div style={{
273
+ display: 'flex', gap: 10, padding: '12px 14px', marginBottom: 16,
274
+ background: '#fff1f2', border: '1.5px solid #fecdd3', borderRadius: 8,
275
+ }}>
276
+ <span style={{ fontSize: 16, flexShrink: 0 }}>⚠️</span>
277
+ <div>
278
+ <div style={{ fontSize: 13, fontWeight: 600, color: '#be123c', marginBottom: 2 }}>
279
+ This will overwrite current CLAUDE.md and skills
280
+ </div>
281
+ <div style={{ fontSize: 12, color: '#9f1239' }}>
282
+ Existing CLAUDE.md will be replaced. Skills with matching category/filename will be overwritten. A snapshot is saved automatically before applying.
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
288
+ {importPreview.exportedAt && <InfoRow label="Exported at" value={new Date(importPreview.exportedAt).toLocaleString()} />}
289
+ <InfoRow label="Skills" value={`${importPreview.skills.length} skill${importPreview.skills.length !== 1 ? 's' : ''} will be upserted`} />
290
+ </div>
291
+ <div style={{ display: 'flex', gap: 10 }}>
292
+ <PrimaryBtn onClick={applyImport} loading={importing}>Apply Import</PrimaryBtn>
293
+ <GhostBtn onClick={() => setImportPreview(null)}>Cancel</GhostBtn>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </Portal>
298
+ )}
299
+
300
+ {/* ── Tab bar ──────────────────────────────────────────────────────── */}
301
+ <div style={{
302
+ display: 'flex', gap: 0, padding: '0 36px',
303
+ borderBottom: '1px solid var(--border)',
304
+ background: 'var(--surface)',
305
+ overflowX: 'auto', WebkitOverflowScrolling: 'touch',
306
+ }}>
307
+ {TABS.map(t => (
308
+ <button
309
+ key={t.id}
310
+ onClick={() => setTab(t.id)}
311
+ className={tab === t.id ? 'tab-active' : ''}
312
+ style={{
313
+ background: 'none', border: 'none', cursor: 'pointer',
314
+ padding: '10px 14px', fontSize: 13,
315
+ color: tab === t.id ? 'var(--text)' : 'var(--muted)',
316
+ fontWeight: tab === t.id ? 500 : 400,
317
+ transition: 'color 0.15s',
318
+ fontFamily: 'var(--font-sans)',
319
+ }}
320
+ >
321
+ {t.label}
322
+ </button>
323
+ ))}
324
+ </div>
325
+
326
+ {/* ── Tab content ──────────────────────────────────────────────────── */}
327
+ <div style={{ padding: '28px 36px' }}>
328
+ {tab === 'overview' && <OverviewTab agent={agent} onUpdate={setAgent} canEdit={canEdit} allAgents={allAgents} role={role} />}
329
+ {tab === 'skills' && <SkillsTab agentId={agent.id} canEdit={canEdit} />}
330
+ {tab === 'claude-md' && <ClaudeMdTab agentId={agent.id} canEdit={canEdit} />}
331
+ {tab === 'mcps' && <McpsTab agentId={agent.id} canEdit={canEdit} />}
332
+ {tab === 'permissions' && <PermissionsTab agentId={agent.id} canEdit={canEdit} />}
333
+ {tab === 'memory' && <MemoryTab agentId={agent.id} canEdit={canEdit} />}
334
+ {tab === 'logs' && <LogsTab agentId={agent.id} slug={agent.slug} />}
335
+ {tab === 'history' && <HistoryTab agentId={agent.id} canEdit={canEdit} />}
336
+ </div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ // ─── Overview ─────────────────────────────────────────────────────────────────
342
+
343
+ function OverviewTab({ agent, onUpdate, canEdit, allAgents, role }: { agent: Agent; onUpdate: (a: Agent) => void; canEdit: boolean; allAgents: Agent[]; role: string | null }) {
344
+ const [form, setForm] = useState({
345
+ name: agent.name,
346
+ description: agent.description ?? '',
347
+ persona: agent.persona ?? '',
348
+ model: agent.model,
349
+ slackBotToken: agent.slackBotToken,
350
+ slackAppToken: agent.slackAppToken,
351
+ slackSigningSecret: agent.slackSigningSecret,
352
+ isBoss: agent.isBoss,
353
+ reportsTo: agent.reportsTo ?? [] as string[],
354
+ });
355
+ const [saving, setSaving] = useState(false);
356
+ const [msg, setMsg] = useState('');
357
+ const [manifest, setManifest] = useState('');
358
+ const [showManifest, setShowManifest] = useState(false);
359
+ const [deleting, setDeleting] = useState(false);
360
+ const [slackInfo, setSlackInfo] = useState<{ displayName: string; handle: string; teamName: string } | null>(null);
361
+ const router = useRouter();
362
+
363
+ useEffect(() => {
364
+ if (!agent.slackBotToken) return;
365
+ fetch(`/api/agents/${agent.id}/slack-info`)
366
+ .then(r => r.ok ? r.json() : null)
367
+ .then(d => d && setSlackInfo(d))
368
+ .catch(() => {});
369
+ }, [agent.id, agent.slackBotToken]);
370
+
371
+ // Channel restrictions state
372
+ const [allowedChannels, setAllowedChannels] = useState('');
373
+
374
+ useEffect(() => {
375
+ fetch(`/api/agents/${agent.id}/restrictions`)
376
+ .then(r => r.json())
377
+ .then((d: Restriction) => setAllowedChannels((d.allowedChannels ?? []).join('\n')));
378
+ }, [agent.id]);
379
+
380
+ const save = async () => {
381
+ setSaving(true);
382
+ try {
383
+ const [r] = await Promise.all([
384
+ fetch(`/api/agents/${agent.id}`, {
385
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify(form),
387
+ }),
388
+ fetch(`/api/agents/${agent.id}/restrictions`, {
389
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
390
+ body: JSON.stringify({ allowedChannels: allowedChannels.split('\n').map(s => s.trim()).filter(Boolean) }),
391
+ }),
392
+ ]);
393
+ const data = await r.json();
394
+ if (r.ok) { onUpdate(data); setMsg('Saved'); } else setMsg(data.error ?? 'Error');
395
+ } finally { setSaving(false); setTimeout(() => setMsg(''), 3000); }
396
+ };
397
+
398
+ const loadManifest = async () => {
399
+ const r = await fetch(`/api/agents/${agent.id}/manifest`);
400
+ setManifest(JSON.stringify(await r.json(), null, 2));
401
+ setShowManifest(true);
402
+ };
403
+
404
+ const handleDelete = async () => {
405
+ if (!confirm(`Permanently delete agent "${agent.name}"? This cannot be undone.`)) return;
406
+ setDeleting(true);
407
+ const r = await fetch(`/api/agents/${agent.id}`, { method: 'DELETE' });
408
+ if (r.ok) {
409
+ router.push('/');
410
+ } else {
411
+ const err = await r.json();
412
+ setMsg(err.error ?? 'Delete failed');
413
+ setDeleting(false);
414
+ }
415
+ };
416
+
417
+ const isAdmin = role === 'admin' || role === 'superadmin';
418
+
419
+ return (
420
+ <div style={{ maxWidth: 640 }} className="fade-up">
421
+ <Section title="Identity">
422
+ <Grid2>
423
+ <Field label="Name" value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} readOnly={!canEdit}
424
+ hint="This is the internal agent name. To update the Slack bot display name, change it in your Slack App settings → App Home." />
425
+ <Field label="Model" value={form.model} onChange={v => setForm(f => ({ ...f, model: v }))}
426
+ hint="claude-opus-4-6 · claude-sonnet-4-6 · claude-haiku-4-5-20251001" readOnly={!canEdit} />
427
+ </Grid2>
428
+ <Field label="Description" value={form.description}
429
+ onChange={v => setForm(f => ({ ...f, description: v }))}
430
+ hint="Shown to the boss agent for delegation decisions." readOnly={!canEdit} />
431
+ <TextArea label="Persona" value={form.persona}
432
+ onChange={v => setForm(f => ({ ...f, persona: v }))}
433
+ hint="Injected into CLAUDE.md — who is this agent?" rows={4} readOnly={!canEdit} />
434
+ </Section>
435
+
436
+ <Section title="Role & Hierarchy">
437
+ {/* Boss toggle */}
438
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
439
+ <div>
440
+ <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)', marginBottom: 2 }}>Boss Agent</div>
441
+ <div style={{ fontSize: 12, color: 'var(--muted)' }}>Boss agents orchestrate other agents and delegate tasks</div>
442
+ </div>
443
+ <button
444
+ disabled={!canEdit}
445
+ onClick={() => setForm(f => ({ ...f, isBoss: !f.isBoss }))}
446
+ style={{
447
+ width: 44, height: 24, borderRadius: 12, border: 'none',
448
+ background: form.isBoss ? '#d97706' : 'var(--border-2)',
449
+ cursor: canEdit ? 'pointer' : 'default',
450
+ position: 'relative', transition: 'background 0.2s', flexShrink: 0,
451
+ }}
452
+ >
453
+ <div style={{
454
+ position: 'absolute', top: 3, left: form.isBoss ? 23 : 3,
455
+ width: 18, height: 18, borderRadius: '50%', background: '#fff',
456
+ transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
457
+ }} />
458
+ </button>
459
+ </div>
460
+
461
+ {/* Reports To — only show for non-boss agents */}
462
+ {!form.isBoss && (() => {
463
+ const bosses = allAgents.filter(a => a.isBoss && a.id !== agent.id);
464
+ if (bosses.length === 0) return (
465
+ <div style={{ fontSize: 12, color: 'var(--subtle)', fontStyle: 'italic' }}>
466
+ No boss agents available. Create a boss agent first.
467
+ </div>
468
+ );
469
+ return (
470
+ <div>
471
+ <div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text)', marginBottom: 6 }}>Reports To</div>
472
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
473
+ {bosses.map(boss => {
474
+ const checked = form.reportsTo.includes(boss.id);
475
+ return (
476
+ <label key={boss.id} style={{
477
+ display: 'flex', alignItems: 'center', gap: 10,
478
+ padding: '8px 12px', borderRadius: 8,
479
+ border: `1px solid ${checked ? 'rgba(217,119,6,0.3)' : 'var(--border)'}`,
480
+ background: checked ? 'rgba(217,119,6,0.04)' : 'var(--surface)',
481
+ cursor: canEdit ? 'pointer' : 'default',
482
+ transition: 'all 0.15s',
483
+ }}>
484
+ <input
485
+ type="checkbox"
486
+ checked={checked}
487
+ disabled={!canEdit}
488
+ onChange={() => setForm(f => ({
489
+ ...f,
490
+ reportsTo: checked
491
+ ? f.reportsTo.filter(id => id !== boss.id)
492
+ : [...f.reportsTo, boss.id],
493
+ }))}
494
+ style={{ accentColor: '#d97706', width: 14, height: 14 }}
495
+ />
496
+ <div style={{
497
+ width: 24, height: 24, borderRadius: 6, background: '#171717',
498
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
499
+ fontSize: 11, fontWeight: 600, color: '#fff', flexShrink: 0,
500
+ }}>
501
+ {boss.name.charAt(0).toUpperCase()}
502
+ </div>
503
+ <div style={{ minWidth: 0 }}>
504
+ <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{boss.name}</div>
505
+ <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>@{boss.slug}</div>
506
+ </div>
507
+ {checked && (
508
+ <span style={{
509
+ marginLeft: 'auto', fontSize: 10, fontWeight: 600,
510
+ color: '#d97706', letterSpacing: '0.04em', textTransform: 'uppercase',
511
+ }}>Reports to</span>
512
+ )}
513
+ </label>
514
+ );
515
+ })}
516
+ </div>
517
+ <div style={{ fontSize: 11.5, color: 'var(--subtle)', marginTop: 8 }}>
518
+ An agent can report to multiple bosses.
519
+ </div>
520
+ </div>
521
+ );
522
+ })()}
523
+ </Section>
524
+
525
+ <Section title="Slack Credentials">
526
+ <Field label="Bot Token" value={form.slackBotToken}
527
+ onChange={v => setForm(f => ({ ...f, slackBotToken: v }))} type="password" readOnly={!canEdit}
528
+ hint={<>api.slack.com/apps → your app → <strong>OAuth &amp; Permissions</strong> → Bot User OAuth Token</>} />
529
+ <Field label="App-Level Token" value={form.slackAppToken}
530
+ onChange={v => setForm(f => ({ ...f, slackAppToken: v }))} type="password" readOnly={!canEdit}
531
+ hint={<>Basic Information → <strong>App-Level Tokens</strong> → Generate with scope <code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>connections:write</code></>} />
532
+ <Field label="Signing Secret" value={form.slackSigningSecret}
533
+ onChange={v => setForm(f => ({ ...f, slackSigningSecret: v }))} type="password" readOnly={!canEdit}
534
+ hint="Basic Information → App Credentials → Signing Secret" />
535
+ {slackInfo && (
536
+ <div style={{
537
+ background: '#f0fdf4', border: '1px solid #bbf7d0',
538
+ borderRadius: 7, padding: '10px 14px', fontSize: 12,
539
+ }}>
540
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
541
+ <div style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0 }} />
542
+ <span style={{ color: '#15803d', fontWeight: 600 }}>Connected to Slack</span>
543
+ <span style={{ color: '#86efac', marginLeft: 'auto', fontSize: 11 }}>{slackInfo.teamName}</span>
544
+ </div>
545
+ <div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px' }}>
546
+ <span style={{ color: '#6b7280' }}>Display name</span>
547
+ <span style={{ color: '#166534', fontWeight: 500 }}>{slackInfo.displayName}</span>
548
+ <span style={{ color: '#6b7280' }}>@handle</span>
549
+ <span style={{ color: '#166534', fontFamily: 'var(--font-mono)' }}>@{slackInfo.handle}</span>
550
+ {agent.slackBotUserId && <>
551
+ <span style={{ color: '#6b7280' }}>Bot User ID</span>
552
+ <span style={{ color: '#166534', fontFamily: 'var(--font-mono)' }}>{agent.slackBotUserId}</span>
553
+ </>}
554
+ </div>
555
+ </div>
556
+ )}
557
+ {!slackInfo && agent.slackBotUserId && (
558
+ <div style={{
559
+ display: 'flex', alignItems: 'center', gap: 8,
560
+ background: '#f0fdf4', border: '1px solid #bbf7d0',
561
+ borderRadius: 7, padding: '8px 12px', fontSize: 12,
562
+ }}>
563
+ <div style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0 }} />
564
+ <span style={{ color: '#15803d' }}>Connected ·</span>
565
+ <span style={{ color: '#166534', fontFamily: 'var(--font-mono)' }}>Bot User ID: {agent.slackBotUserId}</span>
566
+ </div>
567
+ )}
568
+ </Section>
569
+
570
+ <Section title="Allowed Channels">
571
+ <p style={{ margin: '0 0 10px', fontSize: 12.5, color: 'var(--muted)', lineHeight: 1.6 }}>
572
+ Restrict this bot to specific Slack channels. Enter one Slack channel ID per line (e.g. <code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>C01234ABCDE</code>).
573
+ If empty, the bot responds in all channels it's invited to.
574
+ When invited to a non-allowed channel, it will post a notice and leave automatically.
575
+ Bot-initiated messages from scheduled jobs are not affected.
576
+ </p>
577
+ <textarea
578
+ value={allowedChannels}
579
+ onChange={e => setAllowedChannels(e.target.value)}
580
+ rows={4}
581
+ readOnly={!canEdit}
582
+ placeholder={'C01234ABCDE\nC09876ZYXWV'}
583
+ style={{
584
+ width: '100%', background: 'var(--surface)', border: '1px solid var(--border)',
585
+ borderRadius: 8, padding: '10px 12px', color: 'var(--text)',
586
+ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
587
+ outline: 'none', resize: 'vertical', boxSizing: 'border-box',
588
+ }}
589
+ onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
590
+ onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
591
+ />
592
+ </Section>
593
+
594
+ <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
595
+ {canEdit && <PrimaryBtn onClick={save} loading={saving}>Save Changes</PrimaryBtn>}
596
+ <GhostBtn onClick={loadManifest}>View Slack Manifest</GhostBtn>
597
+ {msg && <span style={{ fontSize: 12, color: '#16a34a' }}>{msg}</span>}
598
+ </div>
599
+
600
+ {showManifest && (
601
+ <div style={{
602
+ marginTop: 20, background: 'var(--surface-2)',
603
+ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden',
604
+ }}>
605
+ <div style={{
606
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
607
+ padding: '10px 16px', borderBottom: '1px solid var(--border)',
608
+ background: 'var(--surface-2)',
609
+ }}>
610
+ <span style={{ fontSize: 11.5, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
611
+ slack-manifest.json
612
+ </span>
613
+ <button
614
+ onClick={() => navigator.clipboard.writeText(manifest)}
615
+ style={{ fontSize: 11.5, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}
616
+ >Copy</button>
617
+ </div>
618
+ <pre style={{
619
+ margin: 0, padding: '16px', fontSize: 11.5, color: 'var(--accent)',
620
+ fontFamily: 'var(--font-mono)', overflow: 'auto', maxHeight: 320,
621
+ }}>{manifest}</pre>
622
+ </div>
623
+ )}
624
+
625
+ {/* ── Danger Zone ── */}
626
+ {isAdmin && (
627
+ <div style={{
628
+ marginTop: 40, borderTop: '1px solid #fecaca', paddingTop: 28,
629
+ }}>
630
+ <div style={{ fontSize: 11, fontWeight: 700, color: '#dc2626', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 16 }}>
631
+ Danger Zone
632
+ </div>
633
+ <div style={{
634
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
635
+ background: '#fff8f8', border: '1px solid #fecaca', borderRadius: 8, padding: '14px 18px',
636
+ }}>
637
+ <div>
638
+ <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)', marginBottom: 3 }}>Delete this agent</div>
639
+ <div style={{ fontSize: 12, color: 'var(--muted)' }}>Permanently removes the agent, all its skills, memories, and history. This cannot be undone.</div>
640
+ </div>
641
+ <button
642
+ onClick={handleDelete}
643
+ disabled={deleting}
644
+ style={{
645
+ flexShrink: 0, marginLeft: 24,
646
+ padding: '8px 18px', borderRadius: 7, border: '1px solid #dc2626',
647
+ background: deleting ? '#fef2f2' : '#fff', color: '#dc2626',
648
+ fontSize: 13, fontWeight: 600, cursor: deleting ? 'not-allowed' : 'pointer',
649
+ fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap',
650
+ }}
651
+ >{deleting ? 'Deleting…' : 'Delete Agent'}</button>
652
+ </div>
653
+ </div>
654
+ )}
655
+ </div>
656
+ );
657
+ }
658
+
659
+ // ─── CLAUDE.md viewer ─────────────────────────────────────────────────────────
660
+
661
+ function ClaudeMdTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
662
+ const [content, setContent] = useState<string>('');
663
+ const [draft, setDraft] = useState<string>('');
664
+ const [editing, setEditing] = useState(false);
665
+ const [loading, setLoading] = useState(true);
666
+ const [saving, setSaving] = useState(false);
667
+ const [msg, setMsg] = useState('');
668
+
669
+ const load = () => {
670
+ setLoading(true);
671
+ fetch(`/api/agents/${agentId}/claude-md`)
672
+ .then(r => r.text())
673
+ .then(t => { setContent(t); setDraft(t); })
674
+ .catch(() => setContent('Failed to load CLAUDE.md'))
675
+ .finally(() => setLoading(false));
676
+ };
677
+
678
+ useEffect(() => { load(); }, [agentId]);
679
+
680
+ const save = async () => {
681
+ setSaving(true);
682
+ try {
683
+ const res = await fetch(`/api/agents/${agentId}/claude-md`, {
684
+ method: 'PUT',
685
+ headers: { 'Content-Type': 'text/plain' },
686
+ body: draft,
687
+ });
688
+ if (!res.ok) throw new Error(await res.text());
689
+ setContent(draft);
690
+ setEditing(false);
691
+ setMsg('Saved — agent will use this on next reload.');
692
+ setTimeout(() => setMsg(''), 4000);
693
+ } catch (e: any) {
694
+ setMsg(`Error: ${e.message}`);
695
+ } finally {
696
+ setSaving(false);
697
+ }
698
+ };
699
+
700
+ if (loading) return <p style={{ color: 'var(--muted)', fontSize: 14 }}>Loading...</p>;
701
+
702
+ return (
703
+ <div>
704
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
705
+ <div>
706
+ <h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>CLAUDE.md</h3>
707
+ <p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--muted)' }}>
708
+ {editing ? 'Editing raw system prompt — this overrides all individual skills.' : 'Compiled system prompt sent to Claude.'}
709
+ </p>
710
+ </div>
711
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
712
+ {!editing && (
713
+ <span style={{ fontSize: 12, color: 'var(--muted)', background: 'var(--surface-2)', padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border)' }}>
714
+ {(content.length / 1024).toFixed(1)} KB · {content.split('\n').length} lines
715
+ </span>
716
+ )}
717
+ {editing ? (
718
+ <>
719
+ <button onClick={() => { setEditing(false); setDraft(content); }} style={{ padding: '6px 14px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer' }}>
720
+ Cancel
721
+ </button>
722
+ <button onClick={save} disabled={saving} style={{ padding: '6px 16px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: saving ? 0.7 : 1 }}>
723
+ {saving ? 'Saving…' : 'Save'}
724
+ </button>
725
+ </>
726
+ ) : (
727
+ canEdit && <button onClick={() => setEditing(true)} style={{ padding: '6px 16px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer' }}>
728
+ Edit
729
+ </button>
730
+ )}
731
+ </div>
732
+ </div>
733
+
734
+ {msg && <p style={{ fontSize: 13, color: msg.startsWith('Error') ? 'var(--danger)' : 'var(--success)', marginBottom: 12 }}>{msg}</p>}
735
+
736
+ {editing ? (
737
+ <textarea
738
+ value={draft}
739
+ onChange={e => setDraft(e.target.value)}
740
+ style={{
741
+ width: '100%', height: '70vh', background: 'var(--surface)',
742
+ border: '1px solid var(--accent)', borderRadius: 10,
743
+ padding: '20px 24px', fontSize: 12.5, lineHeight: 1.7,
744
+ color: 'var(--text)', fontFamily: 'var(--font-mono)',
745
+ resize: 'vertical', outline: 'none', boxSizing: 'border-box',
746
+ }}
747
+ />
748
+ ) : (
749
+ <pre style={{
750
+ background: 'var(--surface-2)', border: '1px solid var(--border)',
751
+ borderRadius: 10, padding: '20px 24px', fontSize: 12.5, lineHeight: 1.7,
752
+ overflowX: 'auto', overflowY: 'auto', maxHeight: '70vh', margin: 0,
753
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
754
+ color: 'var(--text)', fontFamily: 'var(--font-mono)',
755
+ }}>
756
+ {content}
757
+ </pre>
758
+ )}
759
+ </div>
760
+ );
761
+ }
762
+
763
+ // ─── Skills ───────────────────────────────────────────────────────────────────
764
+
765
+ function SkillsTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
766
+ const [skills, setSkills] = useState<Skill[]>([]);
767
+ const [selected, setSelected] = useState<Skill | null>(null);
768
+ const [content, setContent] = useState('');
769
+ const [saving, setSaving] = useState(false);
770
+ const [msg, setMsg] = useState('');
771
+ const [showNew, setShowNew] = useState(false);
772
+ const [newSkill, setNewSkill] = useState({ category: '', filename: '', content: '' });
773
+
774
+ const load = () =>
775
+ fetch(`/api/agents/${agentId}/skills`).then(r => r.json()).then(setSkills);
776
+
777
+ useEffect(() => { load(); }, [agentId]);
778
+
779
+ const select = (s: Skill) => { setSelected(s); setContent(s.content); };
780
+
781
+ const save = async () => {
782
+ if (!selected) return;
783
+ setSaving(true);
784
+ await fetch(`/api/agents/${agentId}/skills`, {
785
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
786
+ body: JSON.stringify({ category: selected.category, filename: selected.filename, content, sortOrder: selected.sortOrder }),
787
+ });
788
+ setSaving(false); setMsg('Saved'); setTimeout(() => setMsg(''), 2000); load();
789
+ };
790
+
791
+ const remove = async (s: Skill) => {
792
+ if (!confirm(`Delete ${s.category}/${s.filename}?`)) return;
793
+ await fetch(`/api/agents/${agentId}/skills/${s.id}`, { method: 'DELETE' });
794
+ if (selected?.id === s.id) { setSelected(null); setContent(''); }
795
+ load();
796
+ };
797
+
798
+ const create = async () => {
799
+ if (!newSkill.category || !newSkill.filename) return;
800
+ await fetch(`/api/agents/${agentId}/skills`, {
801
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
802
+ body: JSON.stringify(newSkill),
803
+ });
804
+ setShowNew(false); setNewSkill({ category: '', filename: '', content: '' }); load();
805
+ };
806
+
807
+ const grouped = skills.reduce<Record<string, Skill[]>>((acc, s) => {
808
+ (acc[s.category] ??= []).push(s); return acc;
809
+ }, {});
810
+
811
+ return (
812
+ <div className="fade-up" style={{ display: 'flex', gap: 14, height: 580 }}>
813
+ {/* File tree */}
814
+ <div style={{
815
+ width: 220, flexShrink: 0,
816
+ background: 'var(--surface)', border: '1px solid var(--border)',
817
+ borderRadius: 10, overflow: 'auto', display: 'flex', flexDirection: 'column',
818
+ }}>
819
+ <div style={{
820
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
821
+ padding: '10px 12px', borderBottom: '1px solid var(--border)',
822
+ }}>
823
+ <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
824
+ Files
825
+ </span>
826
+ {canEdit && <button onClick={() => setShowNew(true)} style={{
827
+ background: 'none', border: 'none', cursor: 'pointer',
828
+ fontSize: 12, color: 'var(--accent)', fontFamily: 'var(--font-sans)',
829
+ }}>+ New</button>}
830
+ </div>
831
+ <div style={{ padding: '6px 6px', flex: 1, overflow: 'auto' }}>
832
+ {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([cat, catSkills]) => (
833
+ <div key={cat}>
834
+ <div style={{
835
+ fontSize: 10.5, color: 'var(--subtle)', padding: '6px 6px 2px',
836
+ fontFamily: 'var(--font-mono)', letterSpacing: '0.02em',
837
+ }}>{cat}/</div>
838
+ {catSkills.map(s => (
839
+ <div
840
+ key={s.id}
841
+ onClick={() => select(s)}
842
+ className="skill-row"
843
+ style={{
844
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
845
+ padding: '5px 8px', borderRadius: 6, cursor: 'pointer',
846
+ fontSize: 12, fontFamily: 'var(--font-mono)',
847
+ background: selected?.id === s.id ? 'rgba(59,130,246,0.12)' : 'transparent',
848
+ color: selected?.id === s.id ? 'var(--accent)' : 'var(--muted)',
849
+ transition: 'background 0.12s, color 0.12s',
850
+ }}
851
+ onMouseEnter={e => {
852
+ if (selected?.id !== s.id) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.04)';
853
+ const btn = (e.currentTarget as HTMLElement).querySelector('.delete-btn') as HTMLElement | null;
854
+ if (btn) btn.style.opacity = '1';
855
+ }}
856
+ onMouseLeave={e => {
857
+ if (selected?.id !== s.id) (e.currentTarget as HTMLElement).style.background = 'transparent';
858
+ const btn = (e.currentTarget as HTMLElement).querySelector('.delete-btn') as HTMLElement | null;
859
+ if (btn) btn.style.opacity = '0';
860
+ }}
861
+ >
862
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.filename}</span>
863
+ {canEdit && <button
864
+ onClick={e => { e.stopPropagation(); remove(s); }}
865
+ className="delete-btn"
866
+ style={{
867
+ background: 'none', border: 'none', cursor: 'pointer',
868
+ color: '#ef4444', fontSize: 14, opacity: 0, transition: 'opacity 0.12s',
869
+ fontFamily: 'var(--font-sans)', lineHeight: 1, padding: '0 2px', flexShrink: 0,
870
+ }}
871
+ >×</button>}
872
+ </div>
873
+ ))}
874
+ </div>
875
+ ))}
876
+ </div>
877
+ </div>
878
+
879
+ {/* Editor */}
880
+ <div style={{
881
+ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)',
882
+ borderRadius: 10, display: 'flex', flexDirection: 'column', overflow: 'hidden',
883
+ }}>
884
+ {selected ? (
885
+ <>
886
+ <div style={{
887
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
888
+ padding: '10px 16px', borderBottom: '1px solid var(--border)',
889
+ background: 'var(--surface-2)',
890
+ }}>
891
+ <span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
892
+ {selected.category}/{selected.filename}
893
+ </span>
894
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
895
+ {msg && <span style={{ fontSize: 11.5, color: '#16a34a' }}>{msg}</span>}
896
+ {canEdit && <button
897
+ onClick={save} disabled={saving}
898
+ style={{
899
+ background: saving ? 'var(--border)' : 'var(--accent)',
900
+ color: '#fff', border: 'none', borderRadius: 6,
901
+ padding: '5px 14px', fontSize: 12, fontWeight: 500,
902
+ cursor: saving ? 'not-allowed' : 'pointer',
903
+ fontFamily: 'var(--font-sans)',
904
+ }}
905
+ >
906
+ {saving ? 'Saving…' : 'Save'}
907
+ </button>}
908
+ </div>
909
+ </div>
910
+ <textarea
911
+ value={content}
912
+ onChange={e => setContent(e.target.value)}
913
+ readOnly={!canEdit}
914
+ style={{
915
+ flex: 1, border: 'none', outline: 'none', resize: 'none',
916
+ background: 'transparent', color: 'var(--text)',
917
+ fontFamily: 'var(--font-mono)', fontSize: 12.5, lineHeight: 1.65,
918
+ padding: '16px', caretColor: 'var(--accent)',
919
+ }}
920
+ spellCheck={false}
921
+ />
922
+ </>
923
+ ) : (
924
+ <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--subtle)', fontSize: 13 }}>
925
+ Select a file to edit
926
+ </div>
927
+ )}
928
+ </div>
929
+
930
+ {/* New skill modal */}
931
+ {showNew && (
932
+ <Modal title="New Skill File" onClose={() => setShowNew(false)}>
933
+ <Field label="Category" value={newSkill.category}
934
+ onChange={v => setNewSkill(s => ({ ...s, category: v }))} hint="e.g. 00-core" />
935
+ <Field label="Filename" value={newSkill.filename}
936
+ onChange={v => setNewSkill(s => ({ ...s, filename: v }))} hint="e.g. identity.md" />
937
+ <TextArea label="Content (optional)" value={newSkill.content}
938
+ onChange={v => setNewSkill(s => ({ ...s, content: v }))} rows={4} />
939
+ <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
940
+ <PrimaryBtn onClick={create}>Create</PrimaryBtn>
941
+ <GhostBtn onClick={() => setShowNew(false)}>Cancel</GhostBtn>
942
+ </div>
943
+ </Modal>
944
+ )}
945
+ </div>
946
+ );
947
+ }
948
+
949
+ // ─── MCPs ─────────────────────────────────────────────────────────────────────
950
+
951
+ function McpsTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
952
+ const [all, setAll] = useState<McpServer[]>([]);
953
+ const [assigned, setAssigned] = useState<Set<string>>(new Set());
954
+ const [saving, setSaving] = useState(false);
955
+ const [msg, setMsg] = useState('');
956
+
957
+ useEffect(() => {
958
+ Promise.all([
959
+ fetch('/api/mcps').then(r => r.json()),
960
+ fetch(`/api/agents/${agentId}/mcps`).then(r => r.json()),
961
+ ]).then(([a, b]: [McpServer[], McpServer[]]) => {
962
+ setAll(a); setAssigned(new Set(b.map(m => m.id)));
963
+ });
964
+ }, [agentId]);
965
+
966
+ const toggle = (id: string) =>
967
+ setAssigned(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
968
+
969
+ const save = async () => {
970
+ setSaving(true);
971
+ await fetch(`/api/agents/${agentId}/mcps`, {
972
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
973
+ body: JSON.stringify({ mcpIds: [...assigned] }),
974
+ });
975
+ setSaving(false); setMsg('Saved & reload triggered');
976
+ setTimeout(() => setMsg(''), 3000);
977
+ };
978
+
979
+ return (
980
+ <div style={{ maxWidth: 560 }} className="fade-up">
981
+ <p style={{ margin: '0 0 16px', fontSize: 13, color: 'var(--muted)' }}>
982
+ Select MCP servers from the platform catalog to enable for this agent.
983
+ </p>
984
+ <div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 16 }}>
985
+ {all.length === 0 ? (
986
+ <div style={{ padding: '24px', textAlign: 'center', color: 'var(--muted)', fontSize: 13 }}>
987
+ No MCP servers yet.{' '}
988
+ <Link href="/settings/mcps" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Add some →</Link>
989
+ </div>
990
+ ) : all.map((mcp, i) => (
991
+ <label
992
+ key={mcp.id}
993
+ style={{
994
+ display: 'flex', alignItems: 'center', gap: 12,
995
+ padding: '13px 16px', cursor: mcp.enabled ? 'pointer' : 'not-allowed',
996
+ borderBottom: i < all.length - 1 ? '1px solid var(--border)' : 'none',
997
+ background: 'transparent', transition: 'background 0.12s',
998
+ opacity: mcp.enabled ? 1 : 0.45,
999
+ }}
1000
+ onMouseEnter={e => { if (mcp.enabled) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.03)'; }}
1001
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
1002
+ >
1003
+ <input
1004
+ type="checkbox"
1005
+ checked={assigned.has(mcp.id)}
1006
+ onChange={() => toggle(mcp.id)}
1007
+ disabled={!mcp.enabled || !canEdit}
1008
+ style={{ accentColor: 'var(--accent)', width: 14, height: 14, flexShrink: 0 }}
1009
+ />
1010
+ <div style={{ flex: 1, minWidth: 0 }}>
1011
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1012
+ <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{mcp.name}</span>
1013
+ <span style={{
1014
+ fontSize: 10.5, fontFamily: 'var(--font-mono)',
1015
+ color: 'var(--muted)', background: 'var(--border)',
1016
+ padding: '1px 6px', borderRadius: 4,
1017
+ }}>{mcp.type}</span>
1018
+ {!mcp.enabled && <span style={{ fontSize: 11, color: 'var(--subtle)' }}>disabled</span>}
1019
+ </div>
1020
+ {mcp.description && <p style={{ margin: 0, fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{mcp.description}</p>}
1021
+ </div>
1022
+ </label>
1023
+ ))}
1024
+ </div>
1025
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
1026
+ {canEdit && <PrimaryBtn onClick={save} loading={saving}>Save Assignments</PrimaryBtn>}
1027
+ {msg && <span style={{ fontSize: 12, color: '#16a34a' }}>{msg}</span>}
1028
+ </div>
1029
+ </div>
1030
+ );
1031
+ }
1032
+
1033
+ // ─── Permissions ──────────────────────────────────────────────────────────────
1034
+
1035
+ const QUICK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebFetch', 'WebSearch'];
1036
+
1037
+ function PermissionsTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
1038
+ const [allowed, setAllowed] = useState('');
1039
+ const [denied, setDenied] = useState('');
1040
+ const [saving, setSaving] = useState(false);
1041
+ const [msg, setMsg] = useState('');
1042
+
1043
+ useEffect(() => {
1044
+ fetch(`/api/agents/${agentId}/permissions`).then(r => r.json()).then((p: Permission) => {
1045
+ setAllowed((p.allowedTools ?? []).join('\n'));
1046
+ setDenied((p.deniedTools ?? []).join('\n'));
1047
+ });
1048
+ }, [agentId]);
1049
+
1050
+ const addTool = (tool: string, list: 'allowed' | 'denied') => {
1051
+ const setter = list === 'allowed' ? setAllowed : setDenied;
1052
+ const current = (list === 'allowed' ? allowed : denied).split('\n').map(s => s.trim()).filter(Boolean);
1053
+ if (!current.includes(tool)) setter([...current, tool].join('\n'));
1054
+ };
1055
+
1056
+ const save = async () => {
1057
+ setSaving(true);
1058
+ await fetch(`/api/agents/${agentId}/permissions`, {
1059
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
1060
+ body: JSON.stringify({
1061
+ allowedTools: allowed.split('\n').map(s => s.trim()).filter(Boolean),
1062
+ deniedTools: denied.split('\n').map(s => s.trim()).filter(Boolean),
1063
+ }),
1064
+ });
1065
+ setSaving(false); setMsg('Saved & reload triggered');
1066
+ setTimeout(() => setMsg(''), 3000);
1067
+ };
1068
+
1069
+ return (
1070
+ <div style={{ maxWidth: 660 }} className="fade-up">
1071
+ {/* Quick add */}
1072
+ <div style={{
1073
+ background: 'var(--surface)', border: '1px solid var(--border)',
1074
+ borderRadius: 10, padding: '12px 16px', marginBottom: 18,
1075
+ }}>
1076
+ <div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, fontWeight: 500, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
1077
+ Quick add built-in tools
1078
+ </div>
1079
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
1080
+ {QUICK_TOOLS.map(t => (
1081
+ <button
1082
+ key={t}
1083
+ onClick={() => addTool(t, 'allowed')}
1084
+ disabled={!canEdit}
1085
+ style={{
1086
+ background: 'var(--border)', border: '1px solid var(--border-2)',
1087
+ color: 'var(--text)', padding: '3px 10px', borderRadius: 5,
1088
+ fontSize: 11.5, fontFamily: 'var(--font-mono)', cursor: 'pointer',
1089
+ transition: 'background 0.12s, border-color 0.12s',
1090
+ }}
1091
+ onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'var(--border-2)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'; }}
1092
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'var(--border)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--border-2)'; }}
1093
+ >{t}</button>
1094
+ ))}
1095
+ </div>
1096
+ <p style={{ margin: '8px 0 0', fontSize: 11, color: 'var(--subtle)' }}>
1097
+ MCP tools pattern: <code style={{ fontFamily: 'var(--font-mono)', color: 'var(--muted)' }}>mcp__serverName__toolName</code>
1098
+ </p>
1099
+ </div>
1100
+
1101
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 16 }}>
1102
+ <div>
1103
+ <label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 6 }}>
1104
+ Allowed Tools <span style={{ color: 'var(--subtle)', fontWeight: 400 }}>· one per line</span>
1105
+ </label>
1106
+ <textarea
1107
+ value={allowed} onChange={e => setAllowed(e.target.value)}
1108
+ rows={12} readOnly={!canEdit}
1109
+ style={{
1110
+ width: '100%', background: 'var(--surface)', border: '1px solid var(--border)',
1111
+ borderRadius: 8, padding: '10px 12px', color: 'var(--text)',
1112
+ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
1113
+ outline: 'none', resize: 'vertical',
1114
+ }}
1115
+ onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
1116
+ onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
1117
+ placeholder={'Read\nWrite\nmcp__redshift-mcp__query'}
1118
+ />
1119
+ </div>
1120
+ <div>
1121
+ <label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 6 }}>
1122
+ Denied Tools <span style={{ color: 'var(--subtle)', fontWeight: 400 }}>· overrides allowed</span>
1123
+ </label>
1124
+ <textarea
1125
+ value={denied} onChange={e => setDenied(e.target.value)}
1126
+ rows={12} readOnly={!canEdit}
1127
+ style={{
1128
+ width: '100%', background: 'var(--surface)', border: '1px solid var(--border)',
1129
+ borderRadius: 8, padding: '10px 12px', color: 'var(--danger)',
1130
+ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
1131
+ outline: 'none', resize: 'vertical',
1132
+ }}
1133
+ onFocus={e => (e.currentTarget.style.borderColor = '#ef4444')}
1134
+ onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
1135
+ placeholder={'Bash'}
1136
+ />
1137
+ </div>
1138
+ </div>
1139
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
1140
+ {canEdit && <PrimaryBtn onClick={save} loading={saving}>Save Permissions</PrimaryBtn>}
1141
+ {msg && <span style={{ fontSize: 12, color: '#16a34a' }}>{msg}</span>}
1142
+ </div>
1143
+ </div>
1144
+ );
1145
+ }
1146
+
1147
+ // ─── Memory ───────────────────────────────────────────────────────────────────
1148
+
1149
+ const MEM_TYPE_STYLE: Record<string, { bg: string; color: string }> = {
1150
+ user: { bg: '#f3f0ff', color: '#7c3aed' },
1151
+ feedback: { bg: '#eff6ff', color: '#2563eb' },
1152
+ project: { bg: '#fffbeb', color: '#b45309' },
1153
+ reference: { bg: '#f0fdf4', color: '#15803d' },
1154
+ };
1155
+
1156
+ function MemoryTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
1157
+ const [memories, setMemories] = useState<Memory[]>([]);
1158
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
1159
+
1160
+ const load = () => fetch(`/api/agents/${agentId}/memories`).then(r => r.json()).then(setMemories);
1161
+ useEffect(() => { load(); }, [agentId]);
1162
+
1163
+ const remove = async (id: string) => {
1164
+ await fetch(`/api/agents/${agentId}/memories/${id}`, { method: 'DELETE' });
1165
+ load();
1166
+ };
1167
+
1168
+ const toggle = (id: string) =>
1169
+ setExpanded(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
1170
+
1171
+ const grouped = memories.reduce<Record<string, Memory[]>>((acc, m) => {
1172
+ (acc[m.type] ??= []).push(m); return acc;
1173
+ }, {});
1174
+
1175
+ if (memories.length === 0) {
1176
+ return (
1177
+ <div className="fade-up" style={{
1178
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
1179
+ paddingTop: 80, color: 'var(--muted)',
1180
+ }}>
1181
+ <Brain size={36} style={{ marginBottom: 12, color: 'var(--border-2)' }} />
1182
+ <p style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600, color: 'var(--text)', textAlign: 'center' }}>
1183
+ No memories yet
1184
+ </p>
1185
+ <p style={{ fontSize: 13, color: 'var(--muted)', maxWidth: 300, margin: '0', textAlign: 'center' }}>
1186
+ The agent will automatically accumulate memories as it interacts in Slack.
1187
+ </p>
1188
+ </div>
1189
+ );
1190
+ }
1191
+
1192
+ return (
1193
+ <div style={{ maxWidth: 720 }} className="fade-up">
1194
+ <div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 18 }}>
1195
+ {memories.length} memories across {Object.keys(grouped).length} categories
1196
+ </div>
1197
+ {(['feedback', 'user', 'project', 'reference'] as const).map(type => {
1198
+ const items = grouped[type];
1199
+ if (!items?.length) return null;
1200
+ const style = MEM_TYPE_STYLE[type] ?? { bg: 'var(--border)', color: 'var(--muted)' };
1201
+ return (
1202
+ <div key={type} style={{ marginBottom: 20 }}>
1203
+ <div style={{
1204
+ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
1205
+ }}>
1206
+ <span style={{
1207
+ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.06em',
1208
+ textTransform: 'uppercase',
1209
+ background: style.bg, color: style.color,
1210
+ padding: '2px 8px', borderRadius: 5,
1211
+ }}>{type}</span>
1212
+ <span style={{ fontSize: 11.5, color: 'var(--subtle)' }}>{items.length}</span>
1213
+ </div>
1214
+ <div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
1215
+ {items.map((m, i) => (
1216
+ <div key={m.id} style={{ borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none' }}>
1217
+ <div style={{
1218
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
1219
+ padding: '10px 14px', cursor: 'pointer',
1220
+ }} onClick={() => toggle(m.id)}>
1221
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
1222
+ <span style={{ fontSize: 11, color: 'var(--subtle)' }}>
1223
+ {expanded.has(m.id) ? '▼' : '▶'}
1224
+ </span>
1225
+ <span style={{ fontSize: 13, color: 'var(--text)', fontWeight: 500, fontFamily: 'var(--font-mono)' }}>
1226
+ {m.name}
1227
+ </span>
1228
+ </div>
1229
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
1230
+ <span style={{ fontSize: 11, color: 'var(--subtle)' }}>
1231
+ {new Date(m.updatedAt).toLocaleDateString()}
1232
+ </span>
1233
+ {canEdit && <button
1234
+ onClick={e => { e.stopPropagation(); remove(m.id); }}
1235
+ style={{
1236
+ background: 'none', border: 'none', cursor: 'pointer',
1237
+ color: '#ef4444', fontSize: 13, opacity: 0.5, transition: 'opacity 0.12s',
1238
+ fontFamily: 'var(--font-sans)',
1239
+ }}
1240
+ onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
1241
+ onMouseLeave={e => (e.currentTarget.style.opacity = '0.5')}
1242
+ >Delete</button>}
1243
+ </div>
1244
+ </div>
1245
+ {expanded.has(m.id) && (
1246
+ <pre style={{
1247
+ margin: 0, padding: '12px 14px',
1248
+ background: 'var(--surface-2)',
1249
+ borderTop: '1px solid var(--border)',
1250
+ fontFamily: 'var(--font-mono)', fontSize: 11.5,
1251
+ color: 'var(--muted)', whiteSpace: 'pre-wrap', lineHeight: 1.6,
1252
+ }}>{m.content}</pre>
1253
+ )}
1254
+ </div>
1255
+ ))}
1256
+ </div>
1257
+ </div>
1258
+ );
1259
+ })}
1260
+ </div>
1261
+ );
1262
+ }
1263
+
1264
+ // ─── Logs ─────────────────────────────────────────────────────────────────────
1265
+
1266
+ type LogLevel = 'all' | 'debug' | 'info' | 'warn' | 'error';
1267
+
1268
+ interface ParsedLog {
1269
+ raw: string;
1270
+ level: LogLevel;
1271
+ time: string;
1272
+ message: string;
1273
+ fields: Record<string, string>;
1274
+ }
1275
+
1276
+ function parseLine(raw: string): ParsedLog {
1277
+ const stripped = raw.replace(/\x1b\[[0-9;]*m/g, '');
1278
+ try {
1279
+ const obj = JSON.parse(stripped);
1280
+ const level: LogLevel =
1281
+ obj.level === 'error' || obj.level === 50 ? 'error' :
1282
+ obj.level === 'warn' || obj.level === 40 ? 'warn' :
1283
+ obj.level === 'debug' || obj.level === 20 ? 'debug' : 'info';
1284
+ const ts = obj.timestamp ? new Date(obj.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
1285
+ const rawMsg = obj.message ?? obj.msg ?? '';
1286
+ const msg = rawMsg.replace(/^(error|warn|info|debug|trace):\s*/i, '');
1287
+ const skip = new Set(['level', 'message', 'msg', 'timestamp', 'agent', 'service']);
1288
+ const fields: Record<string, string> = {};
1289
+ for (const [k, v] of Object.entries(obj)) {
1290
+ if (!skip.has(k)) fields[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
1291
+ }
1292
+ return { raw: stripped, level, time: ts, message: msg, fields };
1293
+ } catch {
1294
+ const lo = stripped.toLowerCase();
1295
+ const level: LogLevel =
1296
+ lo.includes('"level":"error"') || lo.includes('"level":50') || lo.includes('error:') ? 'error' :
1297
+ lo.includes('"level":"warn"') || lo.includes('"level":40') || lo.includes('warn:') ? 'warn' :
1298
+ lo.includes('"level":"debug"') || lo.includes('"level":20') || lo.includes('debug:') ? 'debug' : 'info';
1299
+ const plainMsg = stripped.replace(/^(error|warn|info|debug|trace):\s*/i, '');
1300
+ const tsMatch = stripped.match(/"timestamp":"([^"]+)"/);
1301
+ const plainTime = tsMatch ? new Date(tsMatch[1]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
1302
+ return { raw: stripped, level, time: plainTime, message: plainMsg, fields: {} };
1303
+ }
1304
+ }
1305
+
1306
+ const LOG_META: Record<LogLevel, { label: string; color: string; bg: string; border: string; rowBg: string }> = {
1307
+ all: { label: 'ALL', color: '#6b7280', bg: '#f3f4f6', border: '#e5e7eb', rowBg: 'transparent' },
1308
+ info: { label: 'INFO', color: '#1d4ed8', bg: '#eff6ff', border: '#bfdbfe', rowBg: 'transparent' },
1309
+ debug: { label: 'DEBUG', color: '#9ca3af', bg: '#f9fafb', border: '#e5e7eb', rowBg: 'transparent' },
1310
+ warn: { label: 'WARN', color: '#92400e', bg: '#fffbeb', border: '#fde68a', rowBg: '#fffdf0' },
1311
+ error: { label: 'ERR', color: '#991b1b', bg: '#fef2f2', border: '#fecaca', rowBg: '#fff8f8' },
1312
+ };
1313
+
1314
+ function LogRow({ log }: { log: ParsedLog }) {
1315
+ const [expanded, setExpanded] = useState(false);
1316
+ const [hovered, setHovered] = useState(false);
1317
+ const m = LOG_META[log.level];
1318
+ const hasFields = Object.keys(log.fields).length > 0;
1319
+ const msgColor = log.level === 'error' ? '#7f1d1d' : log.level === 'warn' ? '#78350f' : log.level === 'debug' ? '#9ca3af' : 'var(--text)';
1320
+
1321
+ return (
1322
+ <div
1323
+ onClick={() => setExpanded(e => !e)}
1324
+ onMouseEnter={() => setHovered(true)}
1325
+ onMouseLeave={() => setHovered(false)}
1326
+ style={{
1327
+ cursor: 'pointer',
1328
+ background: hovered ? 'var(--surface-2)' : (expanded ? 'var(--surface-2)' : m.rowBg),
1329
+ borderLeft: `3px solid ${expanded ? m.border : 'transparent'}`,
1330
+ borderBottom: '1px solid var(--border)',
1331
+ transition: 'background 0.1s',
1332
+ }}
1333
+ >
1334
+ {/* Compact single row */}
1335
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 10px', minHeight: 28 }}>
1336
+ <span style={{ color: 'var(--subtle)', flexShrink: 0, fontSize: 10.5, fontVariantNumeric: 'tabular-nums', minWidth: 68 }}>
1337
+ {log.time}
1338
+ </span>
1339
+ <span style={{
1340
+ flexShrink: 0, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em',
1341
+ padding: '1px 6px', borderRadius: 3, border: `1px solid ${m.border}`,
1342
+ background: m.bg, color: m.color, minWidth: 34, textAlign: 'center',
1343
+ }}>{m.label}</span>
1344
+ <span style={{ flex: 1, color: msgColor, fontSize: 11.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
1345
+ {log.message}
1346
+ </span>
1347
+ {!expanded && hasFields && (
1348
+ <span style={{ flexShrink: 0, display: 'flex', gap: 3 }}>
1349
+ {Object.keys(log.fields).slice(0, 3).map(k => (
1350
+ <span key={k} style={{ fontSize: 9.5, color: 'var(--muted)', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 3, padding: '0 4px' }}>{k}</span>
1351
+ ))}
1352
+ {Object.keys(log.fields).length > 3 && <span style={{ fontSize: 9.5, color: 'var(--subtle)' }}>+{Object.keys(log.fields).length - 3}</span>}
1353
+ </span>
1354
+ )}
1355
+ <span style={{ flexShrink: 0, color: 'var(--subtle)', fontSize: 9, transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>▶</span>
1356
+ </div>
1357
+
1358
+ {/* Expanded detail */}
1359
+ {expanded && (
1360
+ <div style={{ padding: '8px 14px 12px 92px', borderTop: '1px solid var(--border)', background: 'var(--surface-2)' }}>
1361
+ {log.message.includes('\n') && (
1362
+ <pre style={{ margin: '0 0 10px', color: 'var(--text)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{log.message}</pre>
1363
+ )}
1364
+ {hasFields && (
1365
+ <div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px', marginBottom: 8 }}>
1366
+ {Object.entries(log.fields).map(([k, v]) => (
1367
+ <>
1368
+ <span key={`k-${k}`} style={{ color: 'var(--accent)', fontSize: 11, fontWeight: 500 }}>{k}</span>
1369
+ <span key={`v-${k}`} style={{ color: 'var(--muted)', fontSize: 11, wordBreak: 'break-all' }}>{v}</span>
1370
+ </>
1371
+ ))}
1372
+ </div>
1373
+ )}
1374
+ {log.raw && (
1375
+ <>
1376
+ <div style={{ fontSize: 10, color: 'var(--subtle)', marginTop: 8, marginBottom: 4 }}>Raw</div>
1377
+ <pre style={{
1378
+ margin: 0, padding: '8px 10px', background: 'var(--surface-2)',
1379
+ border: '1px solid var(--border)', borderRadius: 4,
1380
+ fontSize: 10.5, color: 'var(--muted)', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 180, overflow: 'auto',
1381
+ }}>{log.raw}</pre>
1382
+ </>
1383
+ )}
1384
+ </div>
1385
+ )}
1386
+ </div>
1387
+ );
1388
+ }
1389
+
1390
+ function LogsTab({ agentId, slug }: { agentId: string; slug: string }) {
1391
+ const [lines, setLines] = useState<ParsedLog[]>([]);
1392
+ const [connected, setConnected] = useState(false);
1393
+ const [levelFilter, setLevelFilter] = useState<LogLevel>('all');
1394
+ const [search, setSearch] = useState('');
1395
+ const [autoScroll, setAutoScroll] = useState(true);
1396
+ const bottomRef = useRef<HTMLDivElement>(null);
1397
+ const containerRef = useRef<HTMLDivElement>(null);
1398
+
1399
+ useEffect(() => {
1400
+ const es = new EventSource(`/api/agents/${agentId}/logs`);
1401
+ setConnected(true);
1402
+ es.onmessage = e => {
1403
+ const raw = JSON.parse(e.data) as string;
1404
+ setLines(prev => [...prev.slice(-1000), parseLine(raw)]);
1405
+ };
1406
+ es.onerror = () => setConnected(false);
1407
+ return () => es.close();
1408
+ }, [agentId]);
1409
+
1410
+ useEffect(() => {
1411
+ if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
1412
+ }, [lines, autoScroll]);
1413
+
1414
+ const LEVEL_ORDER: LogLevel[] = ['error', 'warn', 'info', 'debug'];
1415
+
1416
+ const counts = lines.reduce<Record<LogLevel, number>>((acc, l) => {
1417
+ acc[l.level] = (acc[l.level] ?? 0) + 1; return acc;
1418
+ }, { all: lines.length, error: 0, warn: 0, info: 0, debug: 0 });
1419
+
1420
+ const visibleLines = lines.filter(l => {
1421
+ if (levelFilter !== 'all' && l.level !== levelFilter) return false;
1422
+ if (search) {
1423
+ const q = search.toLowerCase();
1424
+ return l.message.toLowerCase().includes(q) ||
1425
+ Object.values(l.fields).some(v => v.toLowerCase().includes(q));
1426
+ }
1427
+ return true;
1428
+ });
1429
+
1430
+ return (
1431
+ <div className="fade-up">
1432
+ {/* Toolbar */}
1433
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
1434
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
1435
+ <div className={connected ? 'status-running' : ''}
1436
+ style={{ width: 6, height: 6, borderRadius: '50%', background: connected ? '#16a34a' : 'var(--border-2)' }} />
1437
+ <span style={{ fontSize: 11, color: 'var(--muted)' }}>{connected ? 'Live' : 'Disconnected'}</span>
1438
+ </div>
1439
+ <div style={{ width: 1, height: 14, background: 'var(--border)', margin: '0 2px' }} />
1440
+ {/* Level filters with counts */}
1441
+ {(['all', ...LEVEL_ORDER] as LogLevel[]).map(lvl => {
1442
+ const m = LOG_META[lvl];
1443
+ const active = levelFilter === lvl;
1444
+ return (
1445
+ <button key={lvl} onClick={() => setLevelFilter(lvl)} style={{
1446
+ padding: '2px 8px', borderRadius: 4,
1447
+ border: `1px solid ${active ? m.border : 'var(--border)'}`,
1448
+ fontSize: 10.5, fontFamily: 'var(--font-sans)', cursor: 'pointer',
1449
+ background: active ? m.bg : 'transparent',
1450
+ color: active ? m.color : 'var(--muted)',
1451
+ fontWeight: active ? 700 : 400,
1452
+ display: 'flex', alignItems: 'center', gap: 4,
1453
+ }}>
1454
+ {m.label}
1455
+ {counts[lvl] > 0 && <span style={{ fontSize: 9.5, opacity: 0.75 }}>{counts[lvl]}</span>}
1456
+ </button>
1457
+ );
1458
+ })}
1459
+ <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Filter logs…"
1460
+ style={{
1461
+ padding: '3px 10px', borderRadius: 4, border: '1px solid var(--border)',
1462
+ fontSize: 11, fontFamily: 'var(--font-mono)', background: 'transparent',
1463
+ color: 'var(--text)', outline: 'none', width: 180,
1464
+ }} />
1465
+ <button onClick={() => setLines([])} style={{
1466
+ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer',
1467
+ fontSize: 11, color: 'var(--subtle)', fontFamily: 'var(--font-sans)',
1468
+ }}>Clear</button>
1469
+ </div>
1470
+
1471
+ {/* Log pane */}
1472
+ <div ref={containerRef} onScroll={e => {
1473
+ const el = e.currentTarget;
1474
+ setAutoScroll(el.scrollTop + el.clientHeight >= el.scrollHeight - 40);
1475
+ }} style={{
1476
+ background: '#fff', border: '1px solid var(--border)', borderRadius: 8,
1477
+ height: 520, overflow: 'auto', fontFamily: 'var(--font-mono)',
1478
+ }}>
1479
+ {visibleLines.length === 0 ? (
1480
+ <div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--subtle)', fontSize: 12 }}>
1481
+ {lines.length === 0 ? 'Waiting for log lines…' : 'No matching lines.'}
1482
+ </div>
1483
+ ) : (
1484
+ visibleLines.map((log, i) => <LogRow key={i} log={log} />)
1485
+ )}
1486
+ <div ref={bottomRef} />
1487
+ </div>
1488
+
1489
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 5, padding: '0 2px' }}>
1490
+ <span style={{ fontSize: 10.5, color: 'var(--subtle)' }}>
1491
+ {visibleLines.length}{visibleLines.length !== lines.length ? ` / ${lines.length}` : ''} line{visibleLines.length !== 1 ? 's' : ''}
1492
+ </span>
1493
+ {!autoScroll && (
1494
+ <button onClick={() => { setAutoScroll(true); bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }}
1495
+ style={{ fontSize: 10.5, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}>
1496
+ ↓ Jump to latest
1497
+ </button>
1498
+ )}
1499
+ </div>
1500
+ </div>
1501
+ );
1502
+ }
1503
+
1504
+ // ─── Shared UI primitives ─────────────────────────────────────────────────────
1505
+
1506
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
1507
+ return (
1508
+ <div style={{
1509
+ marginBottom: 32, paddingBottom: 28,
1510
+ borderBottom: '1px solid var(--border)',
1511
+ }}>
1512
+ <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: '0.08em',
1513
+ textTransform: 'uppercase', marginBottom: 16 }}>
1514
+ {title}
1515
+ </div>
1516
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>{children}</div>
1517
+ </div>
1518
+ );
1519
+ }
1520
+
1521
+ function Grid2({ children }: { children: React.ReactNode }) {
1522
+ return <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>{children}</div>;
1523
+ }
1524
+
1525
+ function Field({ label, value, onChange, hint, type = 'text', readOnly }: {
1526
+ label: string; value: string; onChange: (v: string) => void;
1527
+ hint?: React.ReactNode; type?: string; readOnly?: boolean;
1528
+ }) {
1529
+ return (
1530
+ <div>
1531
+ <label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 5 }}>
1532
+ {label}
1533
+ </label>
1534
+ <input
1535
+ type={type} value={value} onChange={e => onChange(e.target.value)} readOnly={readOnly}
1536
+ style={{
1537
+ width: '100%', background: 'var(--surface)', border: '1.5px solid var(--border)',
1538
+ borderRadius: 'var(--radius)', padding: '10px 14px', color: 'var(--text)',
1539
+ fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none',
1540
+ transition: 'border-color 0.15s',
1541
+ }}
1542
+ onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
1543
+ onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
1544
+ />
1545
+ {hint && <p style={{ margin: '5px 0 0', fontSize: 12, color: 'var(--subtle)' }}>{hint}</p>}
1546
+ </div>
1547
+ );
1548
+ }
1549
+
1550
+ function TextArea({ label, value, onChange, hint, rows = 3, readOnly }: {
1551
+ label: string; value: string; onChange: (v: string) => void;
1552
+ hint?: string; rows?: number; readOnly?: boolean;
1553
+ }) {
1554
+ return (
1555
+ <div>
1556
+ <label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 5 }}>
1557
+ {label}
1558
+ </label>
1559
+ <textarea
1560
+ value={value} onChange={e => onChange(e.target.value)} rows={rows} readOnly={readOnly}
1561
+ style={{
1562
+ width: '100%', background: 'var(--surface)', border: '1.5px solid var(--border)',
1563
+ borderRadius: 'var(--radius)', padding: '10px 14px', color: 'var(--text)',
1564
+ fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', resize: 'vertical',
1565
+ transition: 'border-color 0.15s',
1566
+ }}
1567
+ onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
1568
+ onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
1569
+ />
1570
+ {hint && <p style={{ margin: '5px 0 0', fontSize: 12, color: 'var(--subtle)' }}>{hint}</p>}
1571
+ </div>
1572
+ );
1573
+ }
1574
+
1575
+ function PrimaryBtn({ children, onClick, loading }: {
1576
+ children: React.ReactNode; onClick?: () => void; loading?: boolean;
1577
+ }) {
1578
+ return (
1579
+ <button onClick={onClick} disabled={loading} style={{
1580
+ background: loading ? 'var(--border)' : 'var(--accent)',
1581
+ color: '#fff', border: 'none', borderRadius: 'var(--radius)',
1582
+ padding: '10px 22px', fontSize: 14, fontWeight: 600,
1583
+ letterSpacing: '-0.01em',
1584
+ cursor: loading ? 'not-allowed' : 'pointer',
1585
+ fontFamily: 'var(--font-sans)',
1586
+ boxShadow: loading ? 'none' : 'var(--shadow-sm)',
1587
+ transition: 'opacity 0.15s, transform 0.15s, box-shadow 0.15s',
1588
+ }}
1589
+ onMouseEnter={e => { if (!loading) { (e.currentTarget as HTMLElement).style.opacity = '0.88'; (e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'; (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-hover)'; }}}
1590
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '1'; (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-sm)'; }}
1591
+ >{loading ? 'Saving…' : children}</button>
1592
+ );
1593
+ }
1594
+
1595
+ function GhostBtn({ children, onClick, loading }: { children: React.ReactNode; onClick?: () => void; loading?: boolean }) {
1596
+ return (
1597
+ <button onClick={onClick} disabled={loading} style={{
1598
+ background: 'transparent', color: 'var(--muted)',
1599
+ border: '1.5px solid var(--border-2)', borderRadius: 'var(--radius)',
1600
+ padding: '10px 20px', fontSize: 14, fontWeight: 500, fontFamily: 'var(--font-sans)',
1601
+ cursor: loading ? 'default' : 'pointer', opacity: loading ? 0.6 : 1,
1602
+ transition: 'border-color 0.15s, color 0.15s',
1603
+ }}
1604
+ onMouseEnter={e => { if (!loading) { (e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'; (e.currentTarget as HTMLElement).style.color = 'var(--text)'; }}}
1605
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--border-2)'; (e.currentTarget as HTMLElement).style.color = 'var(--muted)'; }}
1606
+ >{loading ? '…' : children}</button>
1607
+ );
1608
+ }
1609
+
1610
+ function InfoRow({ label, value }: { label: string; value: string }) {
1611
+ return (
1612
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
1613
+ <span style={{ color: 'var(--muted)' }}>{label}</span>
1614
+ <span style={{ color: 'var(--text)', fontWeight: 500 }}>{value}</span>
1615
+ </div>
1616
+ );
1617
+ }
1618
+
1619
+ function IconBtn({ children, onClick, title, loading }: { children: React.ReactNode; onClick?: () => void; title?: string; loading?: boolean }) {
1620
+ return (
1621
+ <button
1622
+ onClick={onClick}
1623
+ title={title}
1624
+ disabled={loading}
1625
+ style={{
1626
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1627
+ width: 32, height: 32, borderRadius: 8, border: '1px solid var(--border)',
1628
+ background: 'var(--surface)', color: 'var(--muted)',
1629
+ cursor: loading ? 'default' : 'pointer', opacity: loading ? 0.5 : 1,
1630
+ transition: 'all 0.15s',
1631
+ }}
1632
+ onMouseEnter={e => { if (!loading) { const el = e.currentTarget as HTMLElement; el.style.borderColor = 'var(--border-2)'; el.style.color = 'var(--text)'; el.style.background = 'var(--surface-2)'; }}}
1633
+ onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.borderColor = 'var(--border)'; el.style.color = 'var(--muted)'; el.style.background = 'var(--surface)'; }}
1634
+ >
1635
+ {loading ? <span style={{ fontSize: 11 }}>…</span> : children}
1636
+ </button>
1637
+ );
1638
+ }
1639
+
1640
+ function Btn({ children, onClick, color, textColor }: {
1641
+ children: React.ReactNode; onClick?: () => void;
1642
+ color?: string; textColor?: string;
1643
+ }) {
1644
+ return (
1645
+ <button onClick={onClick} style={{
1646
+ background: color ?? 'var(--border)', color: textColor ?? '#fff',
1647
+ border: 'none', borderRadius: 'var(--radius)', padding: '8px 18px',
1648
+ fontSize: 13, fontWeight: 600, cursor: 'pointer',
1649
+ fontFamily: 'var(--font-sans)', transition: 'opacity 0.15s, transform 0.15s',
1650
+ }}
1651
+ onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '0.85'; (e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'; }}
1652
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '1'; (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; }}
1653
+ >{children}</button>
1654
+ );
1655
+ }
1656
+
1657
+ function Modal({ title, children, onClose }: {
1658
+ title: string; children: React.ReactNode; onClose: () => void;
1659
+ }) {
1660
+ return (
1661
+ <Portal>
1662
+ <div style={{
1663
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)',
1664
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
1665
+ backdropFilter: 'blur(4px)',
1666
+ }}>
1667
+ <div style={{
1668
+ background: 'var(--surface)', border: '1px solid var(--border)',
1669
+ borderRadius: 'var(--radius-lg)', padding: '28px', width: 440,
1670
+ boxShadow: 'var(--shadow-modal)',
1671
+ display: 'flex', flexDirection: 'column', gap: 16,
1672
+ maxHeight: '90vh', overflow: 'auto',
1673
+ }}>
1674
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1675
+ <h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>{title}</h3>
1676
+ <button onClick={onClose} style={{
1677
+ background: 'none', border: 'none', cursor: 'pointer',
1678
+ color: 'var(--muted)', fontSize: 18, lineHeight: 1, fontFamily: 'var(--font-sans)',
1679
+ }}>×</button>
1680
+ </div>
1681
+ {children}
1682
+ </div>
1683
+ </div>
1684
+ </Portal>
1685
+ );
1686
+ }
1687
+
1688
+ function PageLoader() {
1689
+ return (
1690
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--muted)', fontSize: 13 }}>
1691
+ Loading…
1692
+ </div>
1693
+ );
1694
+ }
1695
+
1696
+ function NotFound({ slug }: { slug: string }) {
1697
+ return (
1698
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', gap: 12 }}>
1699
+ <p style={{ color: 'var(--muted)', fontSize: 13 }}>Agent not found: <code style={{ fontFamily: 'var(--font-mono)' }}>{slug}</code></p>
1700
+ <Link href="/" style={{ color: 'var(--accent)', fontSize: 13, textDecoration: 'none' }}>← Back to dashboard</Link>
1701
+ </div>
1702
+ );
1703
+ }
1704
+
1705
+ // ─── History ──────────────────────────────────────────────────────────────────
1706
+
1707
+ // ── Diff panel ───────────────────────────────────────────────────────────────
1708
+
1709
+ function SkillDiff({ snapshot, current }: { snapshot: AgentSnapshot; current: AgentSnapshot | null }) {
1710
+ const snapSkills = snapshot.skillsJson;
1711
+ const currSkills = current ? current.skillsJson : null;
1712
+
1713
+ // Build lookup maps
1714
+ const snapMap = new Map(snapSkills.map(s => [`${s.category}/${s.filename}`, s.content]));
1715
+ const currMap = currSkills ? new Map(currSkills.map(s => [`${s.category}/${s.filename}`, s.content])) : new Map<string, string>();
1716
+
1717
+ const allKeys = new Set([...snapMap.keys(), ...currMap.keys()]);
1718
+ const files: { key: string; status: 'added' | 'removed' | 'modified' | 'same'; diff?: DiffLine[] }[] = [];
1719
+
1720
+ for (const key of allKeys) {
1721
+ const snapContent = snapMap.get(key);
1722
+ const currContent = currMap.get(key);
1723
+ if (snapContent === undefined) {
1724
+ files.push({ key, status: 'added' });
1725
+ } else if (currContent === undefined) {
1726
+ files.push({ key, status: 'removed' });
1727
+ } else if (snapContent !== currContent) {
1728
+ files.push({ key, status: 'modified', diff: lineDiff(snapContent, currContent) });
1729
+ }
1730
+ }
1731
+
1732
+ if (files.length === 0) {
1733
+ return <p style={{ fontSize: 13, color: 'var(--subtle)', margin: 0 }}>No skill changes.</p>;
1734
+ }
1735
+
1736
+ return (
1737
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
1738
+ {files.map(f => (
1739
+ <div key={f.key} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
1740
+ <div style={{
1741
+ padding: '7px 12px', background: 'var(--surface-2)',
1742
+ borderBottom: '1px solid var(--border)',
1743
+ display: 'flex', alignItems: 'center', gap: 8,
1744
+ }}>
1745
+ <span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{f.key}</span>
1746
+ <span style={{
1747
+ fontSize: 10, padding: '1px 6px', borderRadius: 4, fontWeight: 600,
1748
+ background: f.status === 'added' ? 'rgba(22,163,74,0.15)' : f.status === 'removed' ? 'rgba(239,68,68,0.15)' : 'rgba(234,179,8,0.15)',
1749
+ color: f.status === 'added' ? '#16a34a' : f.status === 'removed' ? '#ef4444' : '#ca8a04',
1750
+ }}>{f.status}</span>
1751
+ </div>
1752
+ {f.diff && (
1753
+ <pre style={{
1754
+ margin: 0, padding: '10px 0', fontSize: 11.5, fontFamily: 'var(--font-mono)',
1755
+ lineHeight: 1.6, overflow: 'auto', maxHeight: 320,
1756
+ }}>
1757
+ {f.diff.map((line, i) => (
1758
+ <div key={i} style={{
1759
+ padding: '0 12px',
1760
+ background: line.type === 'add' ? 'rgba(22,163,74,0.1)' : line.type === 'remove' ? 'rgba(239,68,68,0.1)' : 'transparent',
1761
+ color: line.type === 'add' ? '#16a34a' : line.type === 'remove' ? '#ef4444' : 'var(--muted)',
1762
+ }}>
1763
+ {line.type === 'add' ? '+ ' : line.type === 'remove' ? '- ' : ' '}{line.line}
1764
+ </div>
1765
+ ))}
1766
+ </pre>
1767
+ )}
1768
+ {f.status === 'added' && <p style={{ margin: '8px 12px', fontSize: 12, color: '#16a34a' }}>File added since this snapshot.</p>}
1769
+ {f.status === 'removed' && <p style={{ margin: '8px 12px', fontSize: 12, color: '#ef4444' }}>File deleted since this snapshot.</p>}
1770
+ </div>
1771
+ ))}
1772
+ </div>
1773
+ );
1774
+ }
1775
+
1776
+ function PermsDiff({ snapshot, current }: { snapshot: AgentSnapshot; current: AgentSnapshot | null }) {
1777
+ const currAllowed = new Set(current ? current.allowedTools : []);
1778
+ const currDenied = new Set(current ? current.deniedTools : []);
1779
+ const snapAllowed = new Set(snapshot.allowedTools);
1780
+ const snapDenied = new Set(snapshot.deniedTools);
1781
+
1782
+ const addedAllowed = [...currAllowed].filter(t => !snapAllowed.has(t));
1783
+ const removedAllowed = [...snapAllowed].filter(t => !currAllowed.has(t));
1784
+ const addedDenied = [...currDenied].filter(t => !snapDenied.has(t));
1785
+ const removedDenied = [...snapDenied].filter(t => !currDenied.has(t));
1786
+
1787
+ if (!addedAllowed.length && !removedAllowed.length && !addedDenied.length && !removedDenied.length) {
1788
+ return <p style={{ fontSize: 13, color: 'var(--subtle)', margin: 0 }}>No permission changes.</p>;
1789
+ }
1790
+ const row = (label: string, items: string[], color: string) => items.length > 0 && (
1791
+ <div key={label}>
1792
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', marginBottom: 4 }}>{label}</div>
1793
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
1794
+ {items.map(t => (
1795
+ <span key={t} style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', padding: '2px 8px', borderRadius: 4, background: `${color}22`, color }}>{t}</span>
1796
+ ))}
1797
+ </div>
1798
+ </div>
1799
+ );
1800
+ return (
1801
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
1802
+ {row('Allowed tools added', addedAllowed, '#16a34a')}
1803
+ {row('Allowed tools removed', removedAllowed, '#ef4444')}
1804
+ {row('Denied tools added', addedDenied, '#ef4444')}
1805
+ {row('Denied tools removed', removedDenied, '#16a34a')}
1806
+ </div>
1807
+ );
1808
+ }
1809
+
1810
+ function McpsDiff({ snapshot, current, allMcps }: { snapshot: AgentSnapshot; current: AgentSnapshot | null; allMcps: McpServer[] }) {
1811
+ const nameFor = (id: string) => allMcps.find(m => m.id === id)?.name ?? id;
1812
+ const currIds = new Set(current ? current.mcpIds : []);
1813
+ const snapIds = new Set(snapshot.mcpIds);
1814
+ const added = [...currIds].filter(id => !snapIds.has(id));
1815
+ const removed = [...snapIds].filter(id => !currIds.has(id));
1816
+ if (!added.length && !removed.length) return <p style={{ fontSize: 13, color: 'var(--subtle)', margin: 0 }}>No MCP changes.</p>;
1817
+ return (
1818
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
1819
+ {added.length > 0 && <div>
1820
+ <div style={{ fontSize: 11, fontWeight: 600, color: '#16a34a', marginBottom: 4 }}>Added</div>
1821
+ {added.map(id => <div key={id} style={{ fontSize: 12.5, color: '#16a34a' }}>+ {nameFor(id)}</div>)}
1822
+ </div>}
1823
+ {removed.length > 0 && <div>
1824
+ <div style={{ fontSize: 11, fontWeight: 600, color: '#ef4444', marginBottom: 4 }}>Removed</div>
1825
+ {removed.map(id => <div key={id} style={{ fontSize: 12.5, color: '#ef4444' }}>- {nameFor(id)}</div>)}
1826
+ </div>}
1827
+ </div>
1828
+ );
1829
+ }
1830
+
1831
+ // ── Trigger badge ─────────────────────────────────────────────────────────────
1832
+
1833
+ const TRIGGER_COLORS: Record<string, { bg: string; color: string }> = {
1834
+ skills: { bg: 'rgba(59,130,246,0.12)', color: '#3b82f6' },
1835
+ permissions: { bg: 'rgba(234,179,8,0.12)', color: '#ca8a04' },
1836
+ mcps: { bg: 'rgba(168,85,247,0.12)', color: '#a855f7' },
1837
+ 'claude-md': { bg: 'rgba(236,72,153,0.12)', color: '#ec4899' },
1838
+ manual: { bg: 'rgba(22,163,74,0.12)', color: '#16a34a' },
1839
+ };
1840
+
1841
+ function TriggerBadge({ trigger }: { trigger: string }) {
1842
+ const c = TRIGGER_COLORS[trigger] ?? { bg: 'var(--surface-2)', color: 'var(--muted)' };
1843
+ const label: Record<string, string> = {
1844
+ skills: 'Skills', permissions: 'Tools', mcps: 'MCPs',
1845
+ 'claude-md': 'System Prompt', manual: 'Manual', restrictions: 'Channels',
1846
+ };
1847
+ return (
1848
+ <span style={{
1849
+ fontSize: 10.5, fontWeight: 600, padding: '3px 8px', borderRadius: 6,
1850
+ background: c.bg, color: c.color, letterSpacing: '0.03em',
1851
+ }}>{label[trigger] ?? trigger}</span>
1852
+ );
1853
+ }
1854
+
1855
+ // ── Main HistoryTab component ─────────────────────────────────────────────────
1856
+
1857
+ function HistoryTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
1858
+ const [snapshots, setSnapshots] = useState<AgentSnapshot[]>([]);
1859
+ const [loading, setLoading] = useState(true);
1860
+ const [selectedId, setSelectedId] = useState<string | null>(null);
1861
+ const [fullSnapshot, setFullSnapshot] = useState<AgentSnapshot | null>(null);
1862
+ const [compareId, setCompareId] = useState<string>('__current__');
1863
+ const [compareSnapshot, setCompareSnapshot] = useState<AgentSnapshot | null>(null);
1864
+ // Live current state — fetched once and used as the "Current state" comparison target
1865
+ const [liveSnapshot, setLiveSnapshot] = useState<AgentSnapshot | null>(null);
1866
+ const [allMcps, setAllMcps] = useState<McpServer[]>([]);
1867
+ const [restoring, setRestoring] = useState(false);
1868
+ const [loadingDetail, setLoadingDetail] = useState(false);
1869
+ const [msg, setMsg] = useState('');
1870
+
1871
+ // Load snapshot list + MCP catalog (fast path — no live state on mount)
1872
+ useEffect(() => {
1873
+ Promise.all([
1874
+ fetch(`/api/agents/${agentId}/snapshots`).then(r => r.json()),
1875
+ fetch('/api/mcps').then(r => r.json()),
1876
+ ]).then(([snaps, mcps]) => {
1877
+ setSnapshots(Array.isArray(snaps) ? snaps : []);
1878
+ setAllMcps(mcps);
1879
+ setLoading(false);
1880
+ });
1881
+ }, [agentId]);
1882
+
1883
+ // Lazy-load live state only when user picks "Compare with current"
1884
+ useEffect(() => {
1885
+ if (compareId !== '__current__' || liveSnapshot) return;
1886
+ Promise.all([
1887
+ fetch(`/api/agents/${agentId}/skills`).then(r => r.json()),
1888
+ fetch(`/api/agents/${agentId}/permissions`).then(r => r.json()),
1889
+ fetch(`/api/agents/${agentId}/mcps`).then(r => r.json()),
1890
+ fetch(`/api/agents/${agentId}/claude-md`).then(r => r.text()),
1891
+ ]).then(([skills, perms, agentMcps, claudeMd]) => {
1892
+ setLiveSnapshot({
1893
+ id: '__current__',
1894
+ agentId,
1895
+ trigger: 'manual',
1896
+ createdBy: 'current',
1897
+ skillsJson: skills.map((s: Skill) => ({
1898
+ category: s.category,
1899
+ filename: s.filename,
1900
+ content: s.content,
1901
+ sort_order: s.sortOrder,
1902
+ })),
1903
+ allowedTools: perms?.allowedTools ?? [],
1904
+ deniedTools: perms?.deniedTools ?? [],
1905
+ mcpIds: (agentMcps as McpServer[]).map(m => m.id),
1906
+ compiledMd: claudeMd ?? '',
1907
+ allowedChannels: [],
1908
+ createdAt: new Date(),
1909
+ });
1910
+ });
1911
+ }, [agentId, compareId, liveSnapshot]);
1912
+
1913
+ // Load full snapshot when selected
1914
+ useEffect(() => {
1915
+ if (!selectedId) { setFullSnapshot(null); return; }
1916
+ setFullSnapshot(null);
1917
+ setLoadingDetail(true);
1918
+ fetch(`/api/agents/${agentId}/snapshots/${selectedId}`)
1919
+ .then(r => r.json())
1920
+ .then(snap => { setFullSnapshot(snap); setLoadingDetail(false); })
1921
+ .catch(() => setLoadingDetail(false));
1922
+ }, [agentId, selectedId]);
1923
+
1924
+ // Load compare snapshot when compareId changes
1925
+ useEffect(() => {
1926
+ if (compareId === '__current__') { setCompareSnapshot(null); return; }
1927
+ fetch(`/api/agents/${agentId}/snapshots/${compareId}`)
1928
+ .then(r => r.json())
1929
+ .then(setCompareSnapshot);
1930
+ }, [agentId, compareId]);
1931
+
1932
+ const handleCreateManual = async () => {
1933
+ const label = window.prompt('Snapshot label (optional):') ?? '';
1934
+ const r = await fetch(`/api/agents/${agentId}/snapshots`, {
1935
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1936
+ body: JSON.stringify({ label: label || null }),
1937
+ });
1938
+ if (r.ok) {
1939
+ const snap = await r.json();
1940
+ setSnapshots(prev => [snap, ...prev]);
1941
+ setMsg('Snapshot created.');
1942
+ }
1943
+ };
1944
+
1945
+ const handleDelete = async (id: string) => {
1946
+ if (!window.confirm('Delete this snapshot?')) return;
1947
+ await fetch(`/api/agents/${agentId}/snapshots/${id}`, { method: 'DELETE' });
1948
+ setSnapshots(prev => prev.filter(s => s.id !== id));
1949
+ if (selectedId === id) { setSelectedId(null); setFullSnapshot(null); }
1950
+ setMsg('Snapshot deleted.');
1951
+ };
1952
+
1953
+ const handleRestore = async (snap: AgentSnapshot) => {
1954
+ if (!window.confirm(`Restore to snapshot from ${new Date(snap.createdAt).toLocaleString()}?\n\nThis will replace current skills, permissions, and MCPs.`)) return;
1955
+ setRestoring(true);
1956
+ const r = await fetch(`/api/agents/${agentId}/snapshots/${snap.id}/restore`, { method: 'POST' });
1957
+ setRestoring(false);
1958
+ if (r.ok) {
1959
+ setMsg('Restored. Agent is reloading.');
1960
+ } else {
1961
+ const err = await r.json();
1962
+ setMsg(`Restore failed: ${err.error}`);
1963
+ }
1964
+ };
1965
+
1966
+ const fmt = (d: Date | string) => {
1967
+ const dt = new Date(d);
1968
+ return `${dt.toLocaleDateString()} ${dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
1969
+ };
1970
+
1971
+ // Build comparison target: live state or a selected historical snapshot
1972
+ const currentAsSnapshot: AgentSnapshot | null = compareId === '__current__' ? liveSnapshot : compareSnapshot;
1973
+
1974
+ if (loading) return (
1975
+ <div style={{ display: 'flex', gap: 20, minHeight: 500 }}>
1976
+ <div style={{ width: 280, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
1977
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
1978
+ <div style={{ width: 70, height: 13, borderRadius: 5, background: 'var(--surface-2)' }} />
1979
+ <div style={{ width: 110, height: 30, borderRadius: 8, background: 'var(--surface-2)' }} />
1980
+ </div>
1981
+ {[1, 2, 3, 4].map(i => (
1982
+ <div key={i} style={{
1983
+ background: '#fff', borderRadius: 'var(--radius)', padding: '14px 16px',
1984
+ boxShadow: 'var(--shadow-card)', opacity: 1 - (i - 1) * 0.2,
1985
+ }}>
1986
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
1987
+ <div style={{ width: 70, height: 18, borderRadius: 6, background: 'var(--surface-2)' }} />
1988
+ <div style={{ width: 50, height: 11, borderRadius: 4, background: 'var(--surface-2)' }} />
1989
+ </div>
1990
+ <div style={{ width: '55%', height: 11, borderRadius: 4, background: 'var(--surface-2)' }} />
1991
+ </div>
1992
+ ))}
1993
+ </div>
1994
+ <div style={{ flex: 1, background: '#fff', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-card)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
1995
+ <div style={{ fontSize: 13, color: 'var(--subtle)' }}>Loading history…</div>
1996
+ </div>
1997
+ </div>
1998
+ );
1999
+
2000
+ return (
2001
+ <div style={{ display: 'flex', gap: 20, minHeight: 500, alignItems: 'flex-start' }}>
2002
+
2003
+ {/* ── Left: snapshot list ────────────────────────────────────────────── */}
2004
+ <div style={{ width: 280, flexShrink: 0 }}>
2005
+ {/* Header */}
2006
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
2007
+ <span style={{
2008
+ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em',
2009
+ color: 'var(--subtle)', textTransform: 'uppercase',
2010
+ }}>
2011
+ {snapshots.length} snapshot{snapshots.length !== 1 ? 's' : ''}
2012
+ </span>
2013
+ {canEdit && (
2014
+ <button onClick={handleCreateManual} style={{
2015
+ background: 'var(--accent)', color: '#fff', border: 'none',
2016
+ borderRadius: 'var(--radius-sm)', padding: '7px 13px',
2017
+ fontSize: 12, fontWeight: 600, cursor: 'pointer',
2018
+ fontFamily: 'var(--font-sans)', letterSpacing: '-0.01em',
2019
+ boxShadow: 'var(--shadow-sm)', transition: 'opacity 0.15s',
2020
+ }}
2021
+ onMouseEnter={e => (e.currentTarget.style.opacity = '0.85')}
2022
+ onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
2023
+ >+ Snapshot</button>
2024
+ )}
2025
+ </div>
2026
+
2027
+ {msg && (
2028
+ <div style={{
2029
+ fontSize: 12, color: '#16a34a', background: '#f0fdf4',
2030
+ border: '1px solid #bbf7d0', borderRadius: 8,
2031
+ padding: '8px 12px', marginBottom: 10,
2032
+ }}>{msg}</div>
2033
+ )}
2034
+
2035
+ {snapshots.length === 0 ? (
2036
+ <div style={{
2037
+ background: '#fff', borderRadius: 'var(--radius)',
2038
+ boxShadow: 'var(--shadow-card)', padding: '28px 20px',
2039
+ textAlign: 'center',
2040
+ }}>
2041
+ <Camera size={22} style={{ marginBottom: 10, color: 'var(--border-2)' }} />
2042
+ <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', marginBottom: 6 }}>No snapshots yet</div>
2043
+ <div style={{ fontSize: 12, color: 'var(--muted)', lineHeight: 1.6 }}>
2044
+ Snapshots are saved automatically when you change skills, MCPs, or permissions.
2045
+ </div>
2046
+ </div>
2047
+ ) : (
2048
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
2049
+ {snapshots.map(snap => {
2050
+ const isSelected = snap.id === selectedId;
2051
+ return (
2052
+ <div
2053
+ key={snap.id}
2054
+ onClick={() => { setSelectedId(isSelected ? null : snap.id); setCompareId('__current__'); setCompareSnapshot(null); }}
2055
+ style={{
2056
+ background: '#fff',
2057
+ borderRadius: 'var(--radius)',
2058
+ boxShadow: isSelected ? '0 0 0 2px var(--accent), var(--shadow-card)' : 'var(--shadow-card)',
2059
+ padding: '13px 15px', cursor: 'pointer',
2060
+ transition: 'box-shadow 0.15s',
2061
+ }}
2062
+ onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-hover)'; }}
2063
+ onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-card)'; }}
2064
+ >
2065
+ {/* Top row: badge + author */}
2066
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 7 }}>
2067
+ <TriggerBadge trigger={snap.trigger} />
2068
+ <span style={{ fontSize: 11, color: 'var(--subtle)', fontFamily: 'var(--font-mono)' }}>{snap.createdBy}</span>
2069
+ </div>
2070
+
2071
+ {/* Timestamp */}
2072
+ <div style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text)', marginBottom: snap.label ? 4 : 0 }}>
2073
+ {fmt(snap.createdAt)}
2074
+ </div>
2075
+
2076
+ {/* Optional label */}
2077
+ {snap.label && (
2078
+ <div style={{
2079
+ fontSize: 11.5, color: 'var(--muted)', fontStyle: 'italic',
2080
+ marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
2081
+ }}>{snap.label}</div>
2082
+ )}
2083
+
2084
+ {/* Actions — only when selected */}
2085
+ {isSelected && canEdit && (
2086
+ <div style={{ display: 'flex', gap: 7, marginTop: 11, paddingTop: 11, borderTop: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
2087
+ <button
2088
+ onClick={() => handleRestore(snap)}
2089
+ disabled={restoring}
2090
+ style={{
2091
+ flex: 1, fontSize: 12, padding: '6px 0', borderRadius: 6, cursor: restoring ? 'not-allowed' : 'pointer',
2092
+ background: '#16a34a', color: '#fff', border: 'none',
2093
+ fontFamily: 'var(--font-sans)', fontWeight: 600, transition: 'opacity 0.15s',
2094
+ }}
2095
+ onMouseEnter={e => { if (!restoring) (e.currentTarget.style.opacity = '0.85'); }}
2096
+ onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
2097
+ >{restoring ? 'Restoring…' : 'Restore'}</button>
2098
+ <button
2099
+ onClick={() => handleDelete(snap.id)}
2100
+ style={{
2101
+ fontSize: 12, padding: '6px 12px', borderRadius: 6, cursor: 'pointer',
2102
+ background: 'transparent', color: 'var(--red)',
2103
+ border: '1.5px solid rgba(220,38,38,0.25)',
2104
+ fontFamily: 'var(--font-sans)', fontWeight: 500, transition: 'all 0.15s',
2105
+ }}
2106
+ onMouseEnter={e => { (e.currentTarget.style.background = 'var(--red)'); (e.currentTarget.style.color = '#fff'); (e.currentTarget.style.borderColor = 'var(--red)'); }}
2107
+ onMouseLeave={e => { (e.currentTarget.style.background = 'transparent'); (e.currentTarget.style.color = 'var(--red)'); (e.currentTarget.style.borderColor = 'rgba(220,38,38,0.25)'); }}
2108
+ >Delete</button>
2109
+ </div>
2110
+ )}
2111
+ </div>
2112
+ );
2113
+ })}
2114
+ </div>
2115
+ )}
2116
+ </div>
2117
+
2118
+ {/* ── Right: diff panel ─────────────────────────────────────────────── */}
2119
+ {loadingDetail ? (
2120
+ <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
2121
+ {/* Compare bar skeleton */}
2122
+ <div style={{ background: '#fff', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-card)', padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
2123
+ <div style={{ width: 90, height: 14, borderRadius: 4, background: 'var(--surface-2)' }} />
2124
+ <div style={{ flex: 1, height: 34, borderRadius: 8, background: 'var(--surface-2)' }} />
2125
+ </div>
2126
+ {/* Section skeletons */}
2127
+ {[120, 80, 60, 200].map((h, i) => (
2128
+ <div key={i} style={{ background: '#fff', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-card)', overflow: 'hidden', opacity: 1 - i * 0.15 }}>
2129
+ <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)' }}>
2130
+ <div style={{ width: 70, height: 11, borderRadius: 4, background: 'var(--surface-2)' }} />
2131
+ </div>
2132
+ <div style={{ padding: '16px 18px' }}>
2133
+ <div style={{ height: h, borderRadius: 6, background: 'var(--surface-2)' }} />
2134
+ </div>
2135
+ </div>
2136
+ ))}
2137
+ </div>
2138
+ ) : fullSnapshot ? (
2139
+ <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
2140
+
2141
+ {/* Compare bar */}
2142
+ <div style={{
2143
+ background: '#fff', borderRadius: 'var(--radius)',
2144
+ boxShadow: 'var(--shadow-card)', padding: '14px 18px',
2145
+ display: 'flex', alignItems: 'center', gap: 12,
2146
+ }}>
2147
+ <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--muted)', letterSpacing: '0.02em', whiteSpace: 'nowrap' }}>
2148
+ Compare with
2149
+ </span>
2150
+ <select
2151
+ value={compareId}
2152
+ onChange={e => setCompareId(e.target.value)}
2153
+ style={{
2154
+ flex: 1, fontSize: 13, padding: '7px 12px', borderRadius: 8,
2155
+ border: '1.5px solid var(--border)', background: 'var(--surface-2)',
2156
+ color: 'var(--text)', fontFamily: 'var(--font-sans)', outline: 'none', cursor: 'pointer',
2157
+ }}
2158
+ onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
2159
+ onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
2160
+ >
2161
+ <option value="__current__">Current state</option>
2162
+ {snapshots.filter(s => s.id !== selectedId).map(s => (
2163
+ <option key={s.id} value={s.id}>{fmt(s.createdAt)} — {s.trigger}{s.label ? ` · ${s.label}` : ''}</option>
2164
+ ))}
2165
+ </select>
2166
+ </div>
2167
+
2168
+ {/* Diff sections — wait for compare target to load */}
2169
+ {!currentAsSnapshot ? (
2170
+ <div style={{
2171
+ background: '#fff', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-card)',
2172
+ padding: '24px 18px', textAlign: 'center', color: 'var(--subtle)', fontSize: 13,
2173
+ }}>
2174
+ Loading comparison…
2175
+ </div>
2176
+ ) : [
2177
+ { title: 'Skills', content: <SkillDiff snapshot={fullSnapshot} current={currentAsSnapshot} /> },
2178
+ { title: 'Tools', content: <PermsDiff snapshot={fullSnapshot} current={currentAsSnapshot} /> },
2179
+ { title: 'MCPs', content: <McpsDiff snapshot={fullSnapshot} current={currentAsSnapshot} allMcps={allMcps} /> },
2180
+ { title: 'System Prompt', content: (() => {
2181
+ if (!fullSnapshot.compiledMd || !currentAsSnapshot.compiledMd)
2182
+ return <p style={{ fontSize: 12.5, color: 'var(--subtle)', margin: 0 }}>Not available for this snapshot</p>;
2183
+ const diff = lineDiff(fullSnapshot.compiledMd.trim(), currentAsSnapshot.compiledMd.trim());
2184
+ const changed = diff.some(l => l.type !== 'same');
2185
+ if (!changed) return <p style={{ fontSize: 12.5, color: 'var(--subtle)', margin: 0 }}>No changes</p>;
2186
+ return (
2187
+ <pre style={{
2188
+ margin: 0, padding: '14px 16px', borderRadius: 8, fontSize: 12,
2189
+ fontFamily: 'var(--font-mono)', background: 'var(--surface-2)',
2190
+ border: '1px solid var(--border)', overflow: 'auto', maxHeight: 380,
2191
+ color: 'var(--text)', lineHeight: 1.7,
2192
+ }}>
2193
+ {diff.map((l, i) => (
2194
+ <div key={i} style={{
2195
+ background: l.type === 'add' ? 'rgba(34,197,94,0.12)' : l.type === 'remove' ? 'rgba(239,68,68,0.10)' : 'transparent',
2196
+ color: l.type === 'add' ? '#16a34a' : l.type === 'remove' ? '#dc2626' : 'inherit',
2197
+ padding: '1px 6px', borderRadius: 3, marginBottom: 1,
2198
+ }}>
2199
+ {l.type === 'add' ? '+ ' : l.type === 'remove' ? '- ' : ' '}{l.line}
2200
+ </div>
2201
+ ))}
2202
+ </pre>
2203
+ );
2204
+ })(),
2205
+ },
2206
+ ].map(({ title, content }) => (
2207
+ <div key={title} style={{
2208
+ background: '#fff', borderRadius: 'var(--radius)',
2209
+ boxShadow: 'var(--shadow-card)', overflow: 'hidden',
2210
+ }}>
2211
+ <div style={{
2212
+ padding: '12px 18px', borderBottom: '1px solid var(--border)',
2213
+ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em',
2214
+ color: 'var(--muted)', textTransform: 'uppercase',
2215
+ }}>{title}</div>
2216
+ <div style={{ padding: '16px 18px' }}>{content}</div>
2217
+ </div>
2218
+ ))}
2219
+ </div>
2220
+ ) : (
2221
+ <div style={{
2222
+ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
2223
+ background: '#fff', borderRadius: 'var(--radius-lg)',
2224
+ boxShadow: 'var(--shadow-card)', gap: 10, padding: 40,
2225
+ }}>
2226
+ <History size={32} style={{ color: 'var(--border-2)' }} />
2227
+ <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Select a snapshot</div>
2228
+ <div style={{ fontSize: 13, color: 'var(--muted)', textAlign: 'center', maxWidth: 260, lineHeight: 1.6 }}>
2229
+ Click any snapshot on the left to view what changed at that point in time.
2230
+ </div>
2231
+ </div>
2232
+ )}
2233
+ </div>
2234
+ );
2235
+ }