@songsid/agend 2.1.12 → 2.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/auth/one-time-code.d.ts +19 -0
  4. package/dist/auth/one-time-code.js +49 -0
  5. package/dist/auth/one-time-code.js.map +1 -0
  6. package/dist/auth-api.d.ts +52 -0
  7. package/dist/auth-api.js +346 -0
  8. package/dist/auth-api.js.map +1 -0
  9. package/dist/backend/claude-code.d.ts +4 -0
  10. package/dist/backend/claude-code.js +97 -0
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +59 -30
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-agent.d.ts +66 -0
  15. package/dist/backend/kiro-agent.js +302 -0
  16. package/dist/backend/kiro-agent.js.map +1 -0
  17. package/dist/backend/kiro-identity.d.ts +80 -0
  18. package/dist/backend/kiro-identity.js +537 -0
  19. package/dist/backend/kiro-identity.js.map +1 -0
  20. package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
  21. package/dist/backend/kiro-v2-store-worker.js +87 -0
  22. package/dist/backend/kiro-v2-store-worker.js.map +1 -0
  23. package/dist/backend/kiro-v2-store.d.ts +54 -0
  24. package/dist/backend/kiro-v2-store.js +131 -0
  25. package/dist/backend/kiro-v2-store.js.map +1 -0
  26. package/dist/backend/kiro-v3-identity.d.ts +5 -0
  27. package/dist/backend/kiro-v3-identity.js +22 -0
  28. package/dist/backend/kiro-v3-identity.js.map +1 -1
  29. package/dist/backend/kiro.d.ts +83 -7
  30. package/dist/backend/kiro.js +418 -91
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +45 -3
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/channel/adapters/discord.d.ts +1 -0
  35. package/dist/channel/adapters/discord.js +29 -10
  36. package/dist/channel/adapters/discord.js.map +1 -1
  37. package/dist/channel/adapters/telegram.d.ts +1 -0
  38. package/dist/channel/adapters/telegram.js +13 -1
  39. package/dist/channel/adapters/telegram.js.map +1 -1
  40. package/dist/channel/types.d.ts +9 -1
  41. package/dist/classic-channel-manager.d.ts +18 -10
  42. package/dist/classic-channel-manager.js +45 -18
  43. package/dist/classic-channel-manager.js.map +1 -1
  44. package/dist/cli.js +225 -127
  45. package/dist/cli.js.map +1 -1
  46. package/dist/command-table.d.ts +5 -3
  47. package/dist/command-table.js +29 -27
  48. package/dist/command-table.js.map +1 -1
  49. package/dist/config-validator.js +46 -0
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.js +12 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +6 -0
  54. package/dist/connection-secrets.js +4 -0
  55. package/dist/connection-secrets.js.map +1 -1
  56. package/dist/daemon.d.ts +89 -4
  57. package/dist/daemon.js +661 -77
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.d.ts +6 -1
  60. package/dist/darwin-memory.js +17 -6
  61. package/dist/darwin-memory.js.map +1 -1
  62. package/dist/delivery-consumed-watch.d.ts +85 -0
  63. package/dist/delivery-consumed-watch.js +244 -0
  64. package/dist/delivery-consumed-watch.js.map +1 -0
  65. package/dist/delivery-outbox.d.ts +61 -0
  66. package/dist/delivery-outbox.js +120 -2
  67. package/dist/delivery-outbox.js.map +1 -1
  68. package/dist/delivery-reconciliation.d.ts +14 -5
  69. package/dist/delivery-reconciliation.js +27 -4
  70. package/dist/delivery-reconciliation.js.map +1 -1
  71. package/dist/delivery-reconciliation.test.js +35 -1
  72. package/dist/delivery-reconciliation.test.js.map +1 -1
  73. package/dist/event-loop-watch.d.ts +28 -0
  74. package/dist/event-loop-watch.js +75 -3
  75. package/dist/event-loop-watch.js.map +1 -1
  76. package/dist/fleet-context.d.ts +22 -2
  77. package/dist/fleet-level-config.d.ts +2 -2
  78. package/dist/fleet-level-config.js +6 -1
  79. package/dist/fleet-level-config.js.map +1 -1
  80. package/dist/fleet-manager.d.ts +293 -12
  81. package/dist/fleet-manager.js +2364 -449
  82. package/dist/fleet-manager.js.map +1 -1
  83. package/dist/host-memory.d.ts +4 -0
  84. package/dist/host-memory.js.map +1 -1
  85. package/dist/instance-config-impact.js +5 -0
  86. package/dist/instance-config-impact.js.map +1 -1
  87. package/dist/instance-lifecycle.d.ts +10 -5
  88. package/dist/instance-lifecycle.js +162 -53
  89. package/dist/instance-lifecycle.js.map +1 -1
  90. package/dist/locale.js +150 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/login-controller.d.ts +2 -0
  93. package/dist/login-controller.js +17 -5
  94. package/dist/login-controller.js.map +1 -1
  95. package/dist/memory-pressure.d.ts +7 -8
  96. package/dist/memory-pressure.js +76 -15
  97. package/dist/memory-pressure.js.map +1 -1
  98. package/dist/monotonic-budget.d.ts +2 -0
  99. package/dist/monotonic-budget.js +28 -0
  100. package/dist/monotonic-budget.js.map +1 -0
  101. package/dist/needs-you-hub.d.ts +114 -0
  102. package/dist/needs-you-hub.js +370 -0
  103. package/dist/needs-you-hub.js.map +1 -0
  104. package/dist/needs-you-live.d.ts +116 -0
  105. package/dist/needs-you-live.js +228 -0
  106. package/dist/needs-you-live.js.map +1 -0
  107. package/dist/needs-you.d.ts +132 -0
  108. package/dist/needs-you.js +164 -0
  109. package/dist/needs-you.js.map +1 -0
  110. package/dist/node-version-guard.d.ts +30 -0
  111. package/dist/node-version-guard.js +50 -0
  112. package/dist/node-version-guard.js.map +1 -0
  113. package/dist/operator-control-paths.d.ts +4 -0
  114. package/dist/operator-control-paths.js +24 -0
  115. package/dist/operator-control-paths.js.map +1 -0
  116. package/dist/outbound-handlers.d.ts +2 -0
  117. package/dist/outbound-handlers.js +9 -2
  118. package/dist/outbound-handlers.js.map +1 -1
  119. package/dist/outbound-schemas.d.ts +5 -5
  120. package/dist/profile-control.js +4 -20
  121. package/dist/profile-control.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +18 -0
  123. package/dist/public-web-gateway.js +141 -0
  124. package/dist/public-web-gateway.js.map +1 -0
  125. package/dist/public-web-link.d.ts +53 -0
  126. package/dist/public-web-link.js +175 -0
  127. package/dist/public-web-link.js.map +1 -0
  128. package/dist/quickstart-api.d.ts +8 -1
  129. package/dist/quickstart-api.js +86 -48
  130. package/dist/quickstart-api.js.map +1 -1
  131. package/dist/quickstart.d.ts +1 -0
  132. package/dist/quickstart.js +6 -2
  133. package/dist/quickstart.js.map +1 -1
  134. package/dist/scheduler/db.d.ts +7 -1
  135. package/dist/scheduler/db.js +38 -0
  136. package/dist/scheduler/db.js.map +1 -1
  137. package/dist/scheduler/index.d.ts +1 -1
  138. package/dist/scheduler/retry-label.d.ts +11 -0
  139. package/dist/scheduler/retry-label.js +24 -0
  140. package/dist/scheduler/retry-label.js.map +1 -0
  141. package/dist/scheduler/scheduler.d.ts +55 -2
  142. package/dist/scheduler/scheduler.js +220 -8
  143. package/dist/scheduler/scheduler.js.map +1 -1
  144. package/dist/scheduler/types.d.ts +22 -0
  145. package/dist/scheduler/types.js.map +1 -1
  146. package/dist/secret-store.d.ts +5 -0
  147. package/dist/secret-store.js +20 -0
  148. package/dist/secret-store.js.map +1 -1
  149. package/dist/service-activation.d.ts +110 -0
  150. package/dist/service-activation.js +302 -0
  151. package/dist/service-activation.js.map +1 -0
  152. package/dist/service-installer.d.ts +7 -0
  153. package/dist/service-installer.js +7 -4
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-restart-selection.d.ts +6 -5
  156. package/dist/service-restart-selection.js +6 -5
  157. package/dist/service-restart-selection.js.map +1 -1
  158. package/dist/settings-api.d.ts +10 -2
  159. package/dist/settings-api.js +175 -72
  160. package/dist/settings-api.js.map +1 -1
  161. package/dist/settings-baseline.d.ts +20 -0
  162. package/dist/settings-baseline.js +104 -0
  163. package/dist/settings-baseline.js.map +1 -0
  164. package/dist/settings-change.d.ts +21 -0
  165. package/dist/settings-change.js +133 -0
  166. package/dist/settings-change.js.map +1 -0
  167. package/dist/settings-confirmation.d.ts +105 -0
  168. package/dist/settings-confirmation.js +232 -0
  169. package/dist/settings-confirmation.js.map +1 -0
  170. package/dist/settings-control.d.ts +41 -0
  171. package/dist/settings-control.js +294 -0
  172. package/dist/settings-control.js.map +1 -0
  173. package/dist/settings-effect.d.ts +24 -0
  174. package/dist/settings-effect.js +169 -0
  175. package/dist/settings-effect.js.map +1 -0
  176. package/dist/settings-http-confirmation.d.ts +29 -0
  177. package/dist/settings-http-confirmation.js +123 -0
  178. package/dist/settings-http-confirmation.js.map +1 -0
  179. package/dist/settings-instance-patch.d.ts +4 -0
  180. package/dist/settings-instance-patch.js +26 -0
  181. package/dist/settings-instance-patch.js.map +1 -0
  182. package/dist/settings-request-capability.d.ts +16 -0
  183. package/dist/settings-request-capability.js +32 -0
  184. package/dist/settings-request-capability.js.map +1 -0
  185. package/dist/settings-transaction.d.ts +65 -0
  186. package/dist/settings-transaction.js +223 -0
  187. package/dist/settings-transaction.js.map +1 -0
  188. package/dist/setup-auth.d.ts +10 -18
  189. package/dist/setup-auth.js +14 -38
  190. package/dist/setup-auth.js.map +1 -1
  191. package/dist/setup-form.d.ts +1 -1
  192. package/dist/setup-form.js +75 -18
  193. package/dist/setup-form.js.map +1 -1
  194. package/dist/setup-guide.d.ts +13 -0
  195. package/dist/setup-guide.js +25 -0
  196. package/dist/setup-guide.js.map +1 -0
  197. package/dist/setup-host.d.ts +16 -0
  198. package/dist/setup-host.js +197 -32
  199. package/dist/setup-host.js.map +1 -1
  200. package/dist/setup-wizard.js +3 -0
  201. package/dist/setup-wizard.js.map +1 -1
  202. package/dist/slash-authz.d.ts +17 -3
  203. package/dist/slash-authz.js +5 -0
  204. package/dist/slash-authz.js.map +1 -1
  205. package/dist/spawn-gate.js +3 -3
  206. package/dist/spawn-gate.js.map +1 -1
  207. package/dist/statusline-watcher.d.ts +5 -0
  208. package/dist/statusline-watcher.js +2 -0
  209. package/dist/statusline-watcher.js.map +1 -1
  210. package/dist/steer-capability.d.ts +6 -0
  211. package/dist/steer-capability.js +8 -0
  212. package/dist/steer-capability.js.map +1 -1
  213. package/dist/sync-work-attribution.d.ts +14 -0
  214. package/dist/sync-work-attribution.js +43 -8
  215. package/dist/sync-work-attribution.js.map +1 -1
  216. package/dist/tips.js +10 -5
  217. package/dist/tips.js.map +1 -1
  218. package/dist/tmux-control.d.ts +35 -5
  219. package/dist/tmux-control.js +291 -57
  220. package/dist/tmux-control.js.map +1 -1
  221. package/dist/tmux-manager.d.ts +20 -6
  222. package/dist/tmux-manager.js +90 -47
  223. package/dist/tmux-manager.js.map +1 -1
  224. package/dist/tmux-read.d.ts +59 -0
  225. package/dist/tmux-read.js +213 -0
  226. package/dist/tmux-read.js.map +1 -0
  227. package/dist/topic-commands.d.ts +21 -10
  228. package/dist/topic-commands.js +191 -58
  229. package/dist/topic-commands.js.map +1 -1
  230. package/dist/transcript-delta-reader.js +2 -1
  231. package/dist/transcript-delta-reader.js.map +1 -1
  232. package/dist/tunnel/cloudflared-install.d.ts +2 -0
  233. package/dist/tunnel/cloudflared-install.js +1 -1
  234. package/dist/tunnel/cloudflared-install.js.map +1 -1
  235. package/dist/tunnel/purpose-lane.d.ts +14 -0
  236. package/dist/tunnel/purpose-lane.js +43 -0
  237. package/dist/tunnel/purpose-lane.js.map +1 -0
  238. package/dist/turn-reply-guard.d.ts +2 -0
  239. package/dist/turn-reply-guard.js +10 -1
  240. package/dist/turn-reply-guard.js.map +1 -1
  241. package/dist/types.d.ts +33 -0
  242. package/dist/ui/app.html +18 -0
  243. package/dist/ui/chat-render.js +478 -0
  244. package/dist/ui/chat-store.js +289 -0
  245. package/dist/ui/chat-strings.js +69 -0
  246. package/dist/ui/chat-thread.js +209 -0
  247. package/dist/ui/chat-tour.js +116 -0
  248. package/dist/ui/fleet-org.js +142 -0
  249. package/dist/ui/panel-chat.js +418 -0
  250. package/dist/ui/panel-fleet.js +413 -0
  251. package/dist/ui/panel-needs.js +202 -0
  252. package/dist/ui/panel-settings.js +750 -0
  253. package/dist/ui/preview.js +266 -0
  254. package/dist/ui/settings-apply.js +239 -0
  255. package/dist/ui/settings-confirm.js +186 -0
  256. package/dist/ui/settings-dialogs.js +546 -0
  257. package/dist/ui/settings-model.js +277 -0
  258. package/dist/ui/settings-strings.js +652 -0
  259. package/dist/ui/settings-wizard.js +140 -0
  260. package/dist/ui/shared/agend-auth.js +93 -0
  261. package/dist/ui/shared/app-ctx.js +80 -0
  262. package/dist/ui/shared/app-html.js +9 -0
  263. package/dist/ui/shared/app-i18n.js +95 -0
  264. package/dist/ui/shared/app-nav.js +73 -0
  265. package/dist/ui/shared/app-needs.js +114 -0
  266. package/dist/ui/shared/app-route.js +73 -0
  267. package/dist/ui/shared/app-session.js +66 -0
  268. package/dist/ui/shared/app-shell.js +344 -0
  269. package/dist/ui/shared/app-store.js +51 -0
  270. package/dist/ui/shared/app-stream.js +201 -0
  271. package/dist/ui/shared/app.css +676 -0
  272. package/dist/ui/shared/app.js +88 -0
  273. package/dist/ui/shared/fonts/OFL.txt +92 -0
  274. package/dist/ui/shared/fonts/README.md +21 -0
  275. package/dist/ui/shared/fonts/inter.woff2 +0 -0
  276. package/dist/ui/shared/htm.module.js +1 -0
  277. package/dist/ui/shared/panel-view.js +655 -0
  278. package/dist/ui/shared/preact-hooks.module.js +2 -0
  279. package/dist/ui/shared/preact.module.js +2 -0
  280. package/dist/ui/shared/signin.js +130 -0
  281. package/dist/ui/shared/theme.js +29 -0
  282. package/dist/ui/shared/tokens.css +153 -0
  283. package/dist/ui/shared/ui-confirm.js +43 -0
  284. package/dist/ui/shared/ui-dialog.js +45 -0
  285. package/dist/ui/shared/ui-icons.js +61 -0
  286. package/dist/ui/shared/ui-menu.js +39 -0
  287. package/dist/ui/shared/ui-states.js +20 -0
  288. package/dist/ui/shared/ui-toast.js +18 -0
  289. package/dist/ui/shared/vendor/htm.LICENSE +202 -0
  290. package/dist/ui/shared/vendor/preact.LICENSE +21 -0
  291. package/dist/ui/shared/vendor/vendor.json +39 -0
  292. package/dist/ui/shared/view-strings.js +309 -0
  293. package/dist/ui/signin.html +51 -0
  294. package/dist/update-check.d.ts +6 -2
  295. package/dist/update-check.js +12 -3
  296. package/dist/update-check.js.map +1 -1
  297. package/dist/update-install.d.ts +71 -0
  298. package/dist/update-install.js +162 -0
  299. package/dist/update-install.js.map +1 -0
  300. package/dist/upgrade-notices.d.ts +17 -0
  301. package/dist/upgrade-notices.js +93 -0
  302. package/dist/upgrade-notices.js.map +1 -0
  303. package/dist/usage/statusline-usage.d.ts +2 -0
  304. package/dist/usage/statusline-usage.js +14 -10
  305. package/dist/usage/statusline-usage.js.map +1 -1
  306. package/dist/view-api.d.ts +6 -1
  307. package/dist/view-api.js +62 -51
  308. package/dist/view-api.js.map +1 -1
  309. package/dist/web-api.d.ts +71 -14
  310. package/dist/web-api.js +492 -77
  311. package/dist/web-api.js.map +1 -1
  312. package/dist/web-auth.d.ts +71 -27
  313. package/dist/web-auth.js +169 -69
  314. package/dist/web-auth.js.map +1 -1
  315. package/dist/web-body.d.ts +3 -0
  316. package/dist/web-body.js +55 -0
  317. package/dist/web-body.js.map +1 -0
  318. package/dist/web-channel-echo.d.ts +9 -0
  319. package/dist/web-channel-echo.js +55 -0
  320. package/dist/web-channel-echo.js.map +1 -0
  321. package/dist/web-chat-history.d.ts +125 -0
  322. package/dist/web-chat-history.js +184 -0
  323. package/dist/web-chat-history.js.map +1 -0
  324. package/dist/web-continuation.d.ts +7 -0
  325. package/dist/web-continuation.js +22 -0
  326. package/dist/web-continuation.js.map +1 -0
  327. package/dist/web-host-guard.d.ts +34 -0
  328. package/dist/web-host-guard.js +57 -1
  329. package/dist/web-host-guard.js.map +1 -1
  330. package/dist/web-login.d.ts +72 -0
  331. package/dist/web-login.js +132 -0
  332. package/dist/web-login.js.map +1 -0
  333. package/dist/web-org.d.ts +55 -0
  334. package/dist/web-org.js +67 -0
  335. package/dist/web-org.js.map +1 -0
  336. package/dist/web-preview.d.ts +77 -0
  337. package/dist/web-preview.js +240 -0
  338. package/dist/web-preview.js.map +1 -0
  339. package/dist/web-request-context.d.ts +10 -0
  340. package/dist/web-request-context.js +13 -0
  341. package/dist/web-request-context.js.map +1 -0
  342. package/dist/web-session.d.ts +167 -0
  343. package/dist/web-session.js +389 -0
  344. package/dist/web-session.js.map +1 -0
  345. package/dist/web-shell-routes.d.ts +59 -0
  346. package/dist/web-shell-routes.js +96 -0
  347. package/dist/web-shell-routes.js.map +1 -0
  348. package/dist/web-upload.d.ts +183 -0
  349. package/dist/web-upload.js +468 -0
  350. package/dist/web-upload.js.map +1 -0
  351. package/dist/website-tips-renderer.d.ts +2 -2
  352. package/dist/website-tips-renderer.js +3 -3
  353. package/dist/website-tips-renderer.js.map +1 -1
  354. package/package.json +8 -5
  355. package/scripts/preinstall-guard.cjs +36 -0
  356. package/dist/ui/dashboard.html +0 -774
  357. package/dist/ui/settings.html +0 -2165
  358. package/dist/ui/view.html +0 -1139
@@ -0,0 +1,750 @@
1
+ // #1408 step 3: the Settings panel (/settings[/<section>]) — agents, connections, ClassicBot rooms, general defaults,
2
+ // the developer YAML. Behind the session gate (/ui/js/), loaded the first time it is opened.
3
+ //
4
+ // What it owns (§5):
5
+ // - The configuration it shows: read when it mounts (a person's navigation, so it counts as use, #1374), dropped when
6
+ // it goes. Nothing recurring. A section change is the same panel and keeps everything.
7
+ // - The staged changes: data, kept across sections. Leaving the panel with any asks "Discard N pending changes?" (the
8
+ // router's leave guard; the browser's own prompt for a reload or another site).
9
+ // - Not the Apply: confirming hands the staged changes to the app's runner (settings-apply.js) before the first write.
10
+ // This panel renders that operation from the store, and finds it again when it mounts.
11
+ import { html, useEffect, useMemo, useRef, useState } from "/assets/app-html.js";
12
+ import { t } from "/assets/app-i18n.js";
13
+ import { appStore, useStore } from "/assets/app-store.js";
14
+ import { useLease } from "/assets/app-ctx.js";
15
+ import { PanelHeader, setTitle } from "/assets/app-shell.js";
16
+ import { navigate, setLeaveGuard } from "/assets/app-nav.js";
17
+ import { settingsPath, SETTINGS_SECTIONS } from "/assets/app-route.js";
18
+ import { Dialog } from "/assets/ui-dialog.js";
19
+ import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
20
+ import { Icon } from "/assets/ui-icons.js";
21
+ import { Menu } from "/assets/ui-menu.js";
22
+ import { toast } from "/assets/ui-toast.js";
23
+ import "./settings-strings.js";
24
+ import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
25
+ import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
26
+ import {
27
+ ACCESS_MODES, AGENT_MODES, DEFAULT_SCHEMA, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
28
+ changedFields, channelId, channelsOf, chLabel, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
29
+ hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, shortName, STATUS_ORDER, toYaml,
30
+ visibilityDefault, worstImpact,
31
+ } from "./settings-model.js";
32
+ import {
33
+ AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, Drawer, Feedback, Impact, Label, NewBotDialog, Override,
34
+ Select, ask, impactText,
35
+ } from "./settings-dialogs.js";
36
+ import { SetupWizard } from "./settings-wizard.js";
37
+ import { CreateInstanceDialog } from "./panel-fleet.js";
38
+
39
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
40
+ const ICONS = { agents: "bot", bots: "plug", classic: "room", general: "sliders", advanced: "code" };
41
+ const RELEASES = "https://github.com/songsid/AgEnD/releases";
42
+
43
+ /** GET under the panel's lease → { ok, status, body }. */
44
+ async function read(lease, path) {
45
+ const r = await lease.fetch(path);
46
+ let body = null;
47
+ try { body = await r.json(); } catch { /* not JSON */ }
48
+ return { ok: r.ok, status: r.status, body };
49
+ }
50
+
51
+ let loads = 0;
52
+ /** Everything the panel shows, in the order the old page read it. Returns null if the lease ended meanwhile. */
53
+ async function loadAll(lease) {
54
+ const out = { schema: DEFAULT_SCHEMA };
55
+ const schema = await read(lease, "/api/settings/schema");
56
+ if (!lease.current()) return null;
57
+ if (schema.ok && schema.body) out.schema = schema.body;
58
+ // Forms edit the user-authored config, not defaults-expanded runtime values.
59
+ const fleet = await read(lease, "/api/settings/fleet/raw");
60
+ if (!lease.current()) return null;
61
+ if (!fleet.ok) return { error: true };
62
+ out.fleet = fleet.body || {};
63
+ const classic = await read(lease, "/api/settings/classic");
64
+ if (!lease.current()) return null;
65
+ out.classic = classic.body || {};
66
+ const connections = await read(lease, "/api/settings/connections");
67
+ if (!lease.current()) return null;
68
+ out.connections = connections.ok && Array.isArray(connections.body) ? connections.body : [];
69
+ const secrets = await read(lease, "/api/settings/provider-secrets");
70
+ if (!lease.current()) return null;
71
+ out.providerOk = secrets.ok;
72
+ out.providerSecrets = secrets.ok && Array.isArray(secrets.body) ? secrets.body : [];
73
+ const live = await loadLive(lease);
74
+ if (!live) return null;
75
+ return { ...out, ...live, loadedAt: ++loads };
76
+ }
77
+ async function loadLive(lease) {
78
+ const f = await read(lease, "/api/fleet");
79
+ if (!lease.current()) return null;
80
+ const live = {}, classicLive = {};
81
+ if (f.ok && f.body && Array.isArray(f.body.instances)) for (const i of f.body.instances) {
82
+ live[i.name] = { status: i.status, state: i.state, backend: i.backend, model: i.model, model_display: i.model_display, effort: i.effort, effort_supported: i.effort_supported };
83
+ if (i.classic) classicLive[i.name] = { status: i.status, state: i.state };
84
+ }
85
+ // /api/fleet carries live state for ClassicBot rooms; the profiles are the fallback for one not listed there yet.
86
+ const pr = await read(lease, "/api/profiles");
87
+ if (!lease.current()) return null;
88
+ const classicStatus = {};
89
+ if (pr.ok && Array.isArray(pr.body)) for (const r of pr.body) classicStatus[r.instance_name] = r.status;
90
+ return { live, classicLive, classicStatus, fleetUp: f.ok, version: (f.ok && f.body && f.body.version) || "" };
91
+ }
92
+
93
+ export function SettingsPanel({ route, navKey }) {
94
+ const section = SETTINGS_SECTIONS.includes(route.section) ? route.section : "agents";
95
+ const mount = useLease("settings"); // the panel's life on screen: its data, its guard
96
+ const [data, setData] = useState(null);
97
+ const [staged, setStaged] = useState(() => new Map());
98
+ const stagedRef = useRef(staged);
99
+ stagedRef.current = staged;
100
+ const [dialog, setDialog] = useState(null); // { kind, ...props, key: navKey }
101
+ const [search, setSearch] = useState("");
102
+ const { settingsOp: op, pendingChanges } = useStore(appStore);
103
+ const gen = useRef(0);
104
+
105
+ useEffect(() => { setTitle(`${tn("title")} · ${tn(`sec_${section}`)}`); }, [section, navKey]);
106
+
107
+ const reload = async () => {
108
+ const mine = ++gen.current;
109
+ const next = await loadAll(mount);
110
+ if (!next || mine !== gen.current || !mount.current()) return;
111
+ setData(next);
112
+ };
113
+ const reloadLive = async () => {
114
+ const mine = gen.current;
115
+ const live = await loadLive(mount);
116
+ if (!live || mine !== gen.current || !mount.current()) return;
117
+ setData((d) => (d && !d.error ? { ...d, ...live } : d));
118
+ };
119
+
120
+ // Mount: the data; requests still waiting for an admin from before a reload; the leave guard; the browser's prompt.
121
+ useEffect(() => {
122
+ reload();
123
+ attach();
124
+ // Leaving with staged changes asks in the app's dialog; the navigation waits, and goes on (past the guard) on a yes.
125
+ let asking = false;
126
+ mount.hold(setLeaveGuard((to, path) => {
127
+ const n = stagedRef.current.size;
128
+ if (!n || (to && to.panel === "settings")) return true;
129
+ if (!asking) {
130
+ asking = true;
131
+ ask(tn("discardOnLeave", n), { confirmLabel: tn("discard"), danger: true }).then((yes) => {
132
+ asking = false;
133
+ if (!yes || !mount.current()) return;
134
+ dropSecrets(stagedRef.current);
135
+ stagedRef.current = new Map();
136
+ navigate(path, { force: true });
137
+ });
138
+ }
139
+ return false;
140
+ }));
141
+ mount.on(window, "beforeunload", (e) => { if (stagedRef.current.size) { e.preventDefault(); e.returnValue = ""; } });
142
+ }, [mount]);
143
+
144
+ // Changes a failed Apply did not land come back to be staged again (secrets excepted: they are entered again).
145
+ useEffect(() => {
146
+ if (!op || !op.leftover || !op.leftover.length) return;
147
+ const back = takeLeftover();
148
+ if (!back.length) return;
149
+ setStaged((s) => { const m = new Map(s); for (const c of back) m.set(c.stageKey || newKey("left"), c); return m; });
150
+ }, [op && op.leftover]);
151
+
152
+ // An Apply that finished, or a confirmed request that applied, changed the configuration: read it again.
153
+ const lastPhase = useRef(op ? op.phase : null);
154
+ useEffect(() => {
155
+ const phase = op ? op.phase : null;
156
+ if (lastPhase.current !== phase && (phase === "done" || phase === "failed")) reload();
157
+ lastPhase.current = phase;
158
+ }, [op && op.phase]);
159
+ const applied = (pendingChanges || []).filter((p) => p.state === "applied").map((p) => p.id).join(",");
160
+ useEffect(() => { if (applied) reload(); }, [applied]);
161
+
162
+ const stage = (key, change) => {
163
+ setStaged((s) => {
164
+ const m = new Map(s);
165
+ const prev = m.get(key);
166
+ // Two edits of the same object before Apply: one change, the later values on top of the earlier ones.
167
+ if (prev && prev.request && change.request && prev.request.method === "PATCH" && change.request.method === "PATCH") {
168
+ change = { ...change, request: { ...change.request, body: { ...prev.request.body, ...change.request.body } } };
169
+ }
170
+ m.set(key, { ...change, stageKey: key });
171
+ return m;
172
+ });
173
+ toast(tn("queued", change.label));
174
+ };
175
+ const unstage = (key) => setStaged((s) => { if (!s.has(key)) return s; const m = new Map(s); m.delete(key); return m; });
176
+ const discard = () => { dropSecrets(staged); setStaged(new Map()); reload(); };
177
+ const applying = useRef(false);
178
+ const apply = async () => {
179
+ if (applying.current || operationActive() || !staged.size) return;
180
+ // Every confirmation before the first write: a cancelled one must not leave earlier changes applied and later
181
+ // ones pending (a surprising partial apply).
182
+ applying.current = true;
183
+ try {
184
+ // A panel that went while a question was open asks nothing more, and says nothing.
185
+ for (const c of [...stagedRef.current.values()]) {
186
+ for (const k of c.confirms || []) {
187
+ const yes = await ask(tn(k));
188
+ if (!mount.current()) return;
189
+ if (!yes) { toast(tn("accessChangeCancelled"), false); return; }
190
+ }
191
+ }
192
+ } finally { applying.current = false; }
193
+ if (!mount.current() || operationActive()) return;
194
+ const list = [...stagedRef.current.values()].map(({ label, impact, request, connectionSecret, stageKey, key }) => ({ label, impact, request, connectionSecret, stageKey, key }));
195
+ if (startOperation(list)) setStaged(new Map());
196
+ };
197
+
198
+ const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current()) : null;
199
+ const sectionBody = !data ? html`<${Skeleton} lines=${6} />`
200
+ : data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
201
+ : section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
202
+ : section === "bots" ? html`<${Bots} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
203
+ : section === "classic" ? html`<${Classic} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
204
+ : section === "general" ? html`<${General} key=${data.loadedAt} ctx=${ctx} staged=${staged} />`
205
+ : html`<${Developer} ctx=${ctx} />`;
206
+ // A dialog belongs to the navigation that opened it (its draft names one object, never another).
207
+ const open = dialog && dialog.key === navKey ? dialog : null;
208
+ const close = () => setDialog(null);
209
+ const searchable = section === "agents" || section === "bots" || section === "classic";
210
+ return html`<div class="panel p-settings">
211
+ <${PanelHeader} title=${tn("title")} sub=${data && data.version ? html`<a class="pill ver" href=${data.version.includes("-") ? `${RELEASES}/latest` : `${RELEASES}/tag/v${data.version}`} target="_blank" rel="noopener">v${data.version}</a>` : null}>
212
+ <button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx} onClick=${() => setDialog({ kind: "wizard", key: navKey })}><${Icon} name="wand" size=${16} /><span class="hide-narrow">${tn("wizardButton")}</span></button>
213
+ <button type="button" class="icon-btn" aria-label=${tn("helpButton")} title=${tn("helpButton")} onClick=${() => setDialog({ kind: "help", key: navKey })}><${Icon} name="info" /></button>
214
+ </${PanelHeader}>
215
+ <nav class="seg" aria-label=${tn("title")}>${SETTINGS_SECTIONS.map((k) => html`<a key=${k} href=${settingsPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
216
+ aria-current=${k === section ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${tn(`sec_${k}`)}</span></a>`)}</nav>
217
+ <div class="panel-body"><div class="col">
218
+ ${searchable ? html`<div class="s-toolbar"><label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchAll")}</span>
219
+ <input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
220
+ ${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
221
+ ${sectionBody}
222
+ </div></div>
223
+ ${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} />` : null}
224
+ ${open && ctx ? renderDialog(open, ctx, close) : null}
225
+ ${open && open.kind === "help" ? html`<${HelpDialog} onClose=${close} />` : null}
226
+ </div>`;
227
+ }
228
+
229
+ /** The operation in hand still owns Apply: its writes and job, or a restart of AgEnD from the press to its end. */
230
+ const opBusy = (op) => !!op && (["writing", "posting", "watching"].includes(op.phase) || op.restart === "busy");
231
+ function dropSecrets(staged) { for (const c of staged.values()) if (c.connectionSecret) c.connectionSecret.secret = ""; }
232
+
233
+ function renderDialog(d, ctx, close) {
234
+ if (d.kind === "agent") return html`<${AgentDialog} name=${d.name} inst=${d.inst} ctx=${ctx} onClose=${close} />`;
235
+ if (d.kind === "bot") return html`<${BotDialog} key=${d.id} id=${d.id} ctx=${ctx} onClose=${close} />`;
236
+ if (d.kind === "newBot") return html`<${NewBotDialog} ctx=${ctx} onClose=${close} />`;
237
+ if (d.kind === "classic") return html`<${ClassicDialog} room=${d.room} ctx=${ctx} onClose=${close} />`;
238
+ if (d.kind === "create") return html`<${CreateInstanceDialog} onClose=${() => { close(); ctx.reload(); }} />`;
239
+ if (d.kind === "wizard") return html`<${SetupWizard} ctx=${ctx} onClose=${close} />`;
240
+ return null;
241
+ }
242
+
243
+ /** What the sections and dialogs work with: the data, and the ways to change it (staging, or a direct write). */
244
+ function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
245
+ const { fleet, classic, schema } = data;
246
+ const chs = channelsOf(fleet);
247
+ const edit = (fn) => setData((d) => { const next = structuredClone(d); fn(next); return next; });
248
+ return {
249
+ ...data, reload, reloadLive, stage, unstage, shortName,
250
+ channelType: (id) => { const ch = chs.find((c, i) => channelId(c, i) === id); return ch ? ch.type : undefined; },
251
+ classicRuntime: (instanceName) => {
252
+ const live = data.classicLive[instanceName] || {};
253
+ const status = live.status || data.classicStatus[instanceName] || "stopped";
254
+ return { status, execution: status === "paused" ? "paused" : (live.state || status) };
255
+ },
256
+ stageAgent(name, inst, patch) {
257
+ edit((d) => {
258
+ const next = { ...inst, ...patch };
259
+ for (const [k, v] of Object.entries(patch)) if (v === null) delete next[k];
260
+ if (patch.hang_detector && patch.hang_detector.timeout_minutes === null) {
261
+ next.hang_detector = { ...(inst.hang_detector || {}) };
262
+ delete next.hang_detector.timeout_minutes;
263
+ }
264
+ d.fleet.instances[name] = next;
265
+ });
266
+ stage(`agent:${name}`, {
267
+ label: tn("updateAgent", shortName(name, { ...inst, ...patch })),
268
+ impact: batchImpact(schema, Object.keys(patch), (k) => impactOf(schema, `instance.${k}`)),
269
+ request: { method: "PATCH", url: `/api/settings/fleet/instances/${encodeURIComponent(name)}`, body: patch },
270
+ });
271
+ },
272
+ async deleteAgent(name) {
273
+ if (!(await ask(tn("deleteAgent", name), { confirmLabel: tn("deleteAgentMenu"), danger: true }))) return;
274
+ if (!alive()) return; // the panel went while it asked: nothing staged, nothing said
275
+ edit((d) => { delete d.fleet.instances[name]; });
276
+ stage(`agent:${name}`, { label: tn("deleteAgentLabel", name), impact: impactOf(schema, "instance.delete"),
277
+ request: { method: "DELETE", url: `/api/settings/fleet/instances/${encodeURIComponent(name)}` } });
278
+ },
279
+ stageChannels(key, nextChannels, meta) {
280
+ edit((d) => { d.fleet.channels = nextChannels; delete d.fleet.channel; });
281
+ stage(key, { ...meta, request: { method: "PUT", url: "/api/settings/fleet/channels", body: nextChannels } });
282
+ },
283
+ stageClassic(c, patch) {
284
+ edit((d) => {
285
+ const room = { ...d.classic.channels[c.key], ...patch };
286
+ for (const k of ["model", "auto_pause_after", "tool_progress", "reply_completion_guard"]) if (patch[k] === null) delete room[k];
287
+ d.classic.channels[c.key] = room;
288
+ });
289
+ const hotOnly = Object.keys(patch).every((k) => impactOf(schema, `classic.${k}`) === "now");
290
+ stage(`classic:${c.key}`, { label: tn("updateAgent", c.name || c.instanceName), impact: hotOnly ? "now" : "instance",
291
+ request: { method: "PATCH", url: `/api/settings/classic/channels/${encodeURIComponent(c.key)}`, body: patch } });
292
+ },
293
+ async pauseWake(name, action) {
294
+ const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
295
+ if (!res.ok) toast((res.body && res.body.error) || tn("actionFailed", action), false);
296
+ await reloadLive();
297
+ },
298
+ async startStop(name, running) {
299
+ const res = await api(running ? `/stop/${encodeURIComponent(name)}` : `/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
300
+ if (!res.ok) toast(`${tn("actionFailed", running ? tn("stop") : tn("start"))}: ${(res.body && res.body.error) || res.status || ""}`, false);
301
+ await new Promise((r) => setTimeout(r, 800));
302
+ await reloadLive();
303
+ },
304
+ classic: classic || {},
305
+ };
306
+ }
307
+
308
+ // ── Sections ──
309
+
310
+ const STATUS_KEY = { running: "stRunning", paused: "stPaused", crashed: "stCrashed", stopped: "stStopped" };
311
+ const dotClass = (st) => `dot ${st === "running" || st === "idle" ? "ok" : st === "crashed" || st === "stuck" ? "bad" : st === "working" ? "busy" : "off"}`;
312
+
313
+ function agentChannelLabel(inst, chs) {
314
+ if (inst.general_topic) return "general";
315
+ if (!inst.channel_id) return "";
316
+ const idx = chs.findIndex((c, i) => channelId(c, i) === inst.channel_id);
317
+ return idx >= 0 ? `${chs[idx].type} (${chLabel(idx)})` : inst.channel_id;
318
+ }
319
+
320
+ function Agents({ ctx, search, openDialog }) {
321
+ const insts = ctx.fleet.instances || {};
322
+ const [collapsed, setCollapsed] = useState(() => new Set());
323
+ const [busy, setBusy] = useState(() => new Map()); // name → the action under way
324
+ const chs = channelsOf(ctx.fleet);
325
+ const groups = groupAgents(insts, ctx.live, search);
326
+ // One action per agent at a time: the whole row is off (Settings and ⋯ too), and the pressed button says it is working.
327
+ const run = async (name, action, work) => {
328
+ if (busy.has(name)) return;
329
+ setBusy((b) => new Map(b).set(name, action));
330
+ try { await work(); } finally { setBusy((b) => { const n = new Map(b); n.delete(name); return n; }); }
331
+ };
332
+ const head = html`<div class="list-head"><span>${tn("countAgents", Object.keys(insts).length)}</span>
333
+ <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
334
+ if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />`;
335
+ if (!groups.length) return html`${head}<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />`;
336
+ return html`${head}<div class="s-list">${groups.map(({ group, names }) => {
337
+ const folded = collapsed.has(group) && !search.trim(); // searching opens every group
338
+ return html`<section key=${group} class="s-group">
339
+ <button type="button" class="s-group-head" aria-expanded=${folded ? "false" : "true"}
340
+ onClick=${() => setCollapsed((c) => { const n = new Set(c); n.has(group) ? n.delete(group) : n.add(group); return n; })}>
341
+ <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${group}</span><span class="count">${names.length}</span></button>
342
+ ${folded ? null : names.map((name) => {
343
+ const inst = insts[name];
344
+ const st = (ctx.live[name] && ctx.live[name].status) || "stopped";
345
+ const sum = effectiveSummary(ctx.live[name] || {}, inst, ctx.fleet.defaults || {});
346
+ const ch = agentChannelLabel(inst, chs);
347
+ const working = busy.has(name), doing = busy.get(name);
348
+ const label = (action, text) => (doing === action ? tn("working") : text);
349
+ return html`<div key=${name} class="s-row">
350
+ <span class=${dotClass(st)} title=${tn(STATUS_KEY[st] || "stStopped")} aria-hidden="true"></span>
351
+ <span class="s-name" title=${name}>${shortName(name, inst)}</span>
352
+ <span class="s-meta" title=${sum.backend}>${sum.model}</span>
353
+ <span class="tag">${tn("effortTag", sum.effort)}</span>
354
+ ${ch ? html`<span class=${`tag${ch.includes("persona") ? " persona" : ""}`}>${ch}</span>` : null}
355
+ <span class="sr-only">${tn(STATUS_KEY[st] || "stStopped")}</span>
356
+ <span class="s-actions">
357
+ <button type="button" class="btn btn-sm" disabled=${working} onClick=${() => openDialog({ kind: "agent", name, inst })}>${tn("settingsButton")}</button>
358
+ ${st === "running" ? html`<button type="button" class="btn btn-sm" disabled=${working} onClick=${() => run(name, "pause", () => ctx.pauseWake(name, "pause"))}>${label("pause", tn("pause"))}</button>` : null}
359
+ ${st === "paused" ? html`<button type="button" class="btn btn-sm" disabled=${working} onClick=${() => run(name, "wake", () => ctx.pauseWake(name, "wake"))}>${label("wake", tn("wake"))}</button>` : null}
360
+ ${st !== "paused" ? html`<button type="button" class="btn btn-sm" disabled=${working} onClick=${() => run(name, "startStop", () => ctx.startStop(name, st === "running"))}>${label("startStop", st === "running" ? tn("stop") : tn("start"))}</button>` : null}
361
+ <${Menu} label=${t("app.more")} items=${[{ key: "delete", label: tn("deleteAgentMenu"), icon: "trash", danger: true, disabled: working, onSelect: () => ctx.deleteAgent(name) }]} />
362
+ </span>
363
+ </div>`;
364
+ })}
365
+ </section>`;
366
+ })}</div>`;
367
+ }
368
+
369
+ function Bots({ ctx, search, openDialog }) {
370
+ const all = channelsOf(ctx.fleet);
371
+ const needle = search.trim().toLowerCase();
372
+ const rows = all.map((ch, i) => ({ ch, i })).filter(({ ch }) => !needle || [ch.id, ch.type, ch.group_id, ch.bot_token_env].some((v) => String(v ?? "").toLowerCase().includes(needle)));
373
+ const head = html`<div class="list-head"><span>${tn("countBots", all.length)}</span>
374
+ <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
375
+ return html`${head}
376
+ ${!rows.length ? html`<${Empty} icon="plug" title=${needle ? tn("noAgentMatch", search.trim()) : tn("noBots")} />` : html`<div class="s-list">${rows.map(({ ch, i }) => {
377
+ const users = (ch.access && ch.access.allowed_users) || [];
378
+ const token = (ctx.connections.find((c, j) => channelId(c, j) === channelId(ch, i)) || {}).token_present;
379
+ // #1408 step 5: one line on a desktop — who it is, its facts in one clipped line (all of them in its tooltip),
380
+ // then the cluster; on a phone the facts go under it. It no longer wraps fact by fact.
381
+ const facts = [ch.bot_token_env || tn("noTokenEnv"),
382
+ ch.group_id ? (ch.type === "telegram" ? tn("groupTag", ch.group_id) : tn("guildTag", ch.group_id)) : null,
383
+ tn("accessTag", (ch.access && ch.access.mode) || "locked"),
384
+ users.length ? `${users.slice(0, 3).join(", ")}${users.length > 3 ? ` +${users.length - 3}` : ""}` : tn("noAllowedUsers")].filter(Boolean).join(" · ");
385
+ return html`<div key=${channelId(ch, i)} class="s-row s-conn">
386
+ <span class="s-ident"><span class=${`dot ${ctx.fleetUp ? "ok" : "bad"}`} title=${ctx.fleetUp ? tn("connected") : tn("problem")} aria-hidden="true"></span>
387
+ <span class="s-name">${ch.type === "telegram" ? "Telegram" : "Discord"}</span>
388
+ <span class=${`tag${i === 0 ? "" : " persona"}`}>${ch.id || chLabel(i)}</span></span>
389
+ <span class="s-facts" title=${facts}>${facts}</span>
390
+ <span class="s-actions">
391
+ <span class=${`tag${token ? "" : " warn"}`}>${token ? tn("tokenConfigured") : tn("tokenMissing")}</span>
392
+ <span class=${`s-state ${ctx.fleetUp ? "ok" : "bad"}`}>${ctx.fleetUp ? tn("connected") : tn("problem")}</span>
393
+ <button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "bot", id: channelId(ch, i) })}>${tn("settingsButton")}</button></span>
394
+ </div>`;
395
+ })}</div>`}
396
+ ${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`;
397
+ }
398
+
399
+ /**
400
+ * Provider API keys: verified by AgEnD, then applied (it may need an admin's confirmation, #1423). A provider whose
401
+ * key cannot be positively verified gets no input at all — never an "unverified" write.
402
+ */
403
+ function ProviderKeys({ ctx }) {
404
+ // Its own lease: a section change unmounts this card, and a verification started here that answers after that is
405
+ // abandoned before its apply — never applied over a key entered since (#1453 review). A write already sent is not
406
+ // undone; it is followed to its end on the server.
407
+ const lease = useLease("provider-keys");
408
+ const [keys, setKeys] = useState({});
409
+ const [notes, setNotes] = useState({});
410
+ // One request per provider at a time, each owning only its own provider's busy state (#1453 review): a second
411
+ // provider's key never unlocks the first while the first is still on its way. The ref decides at once; the state
412
+ // only draws it.
413
+ const inFlight = useRef(new Set());
414
+ const [busy, setBusy] = useState(() => new Set());
415
+ const say = (id, note) => { if (lease.current()) setNotes((n) => ({ ...n, [id]: note })); };
416
+ const go = async (spec) => {
417
+ const secret = keys[spec.id] || "";
418
+ if (!secret) { say(spec.id, { error: tn("enterKeyFirst") }); return; }
419
+ if (inFlight.current.has(spec.id)) return;
420
+ inFlight.current.add(spec.id);
421
+ setBusy(new Set(inFlight.current));
422
+ setKeys((k) => ({ ...k, [spec.id]: "" })); // out of the form at once; only this request holds it
423
+ say(spec.id, { text: tn("verifying") });
424
+ const key = newKey("provider");
425
+ const base = `/api/settings/secrets/${encodeURIComponent(spec.id)}`;
426
+ try {
427
+ const verified = await api(`${base}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ secret, idempotency_key: key }) });
428
+ if (!lease.current()) return;
429
+ if (!verified.ok) { say(spec.id, { error: (verified.body && verified.body.error) || tn("verifyFailed") }); return; }
430
+ say(spec.id, { text: tn("applyingKey") });
431
+ const result = await confirmedWrite(`${base}/apply`, { method: "POST", key, label: tn("providerKeyLabel", spec.display_name),
432
+ body: { verification_id: verified.body && verified.body.verification_id, idempotency_key: key } });
433
+ let body = result.body || {};
434
+ for (let n = 0; result.ok && body.job_id && body.result === "applying" && n < 60; n++) {
435
+ await new Promise((r) => setTimeout(r, 250));
436
+ const status = await api(`${base}/apply/${encodeURIComponent(body.job_id)}`).catch(() => null);
437
+ if (status && status.ok) body = status.body || body;
438
+ }
439
+ const labels = { applied_next_use: "keyAppliedNextUse", reloaded: "keyApplied", rolled_back: "keyRolledBack", rollback_failed: "keyRollbackFailed" };
440
+ const ok = body.result === "applied_next_use" || body.result === "reloaded";
441
+ say(spec.id, ok ? { text: tn(labels[body.result]) } : { error: labels[body.result] ? tn(labels[body.result]) : (body.error || tn("applyFailed")) });
442
+ if (ok && lease.current()) ctx.reload();
443
+ } catch { say(spec.id, { error: tn("failed") }); }
444
+ finally { inFlight.current.delete(spec.id); if (lease.current()) setBusy(new Set(inFlight.current)); }
445
+ };
446
+ return html`<section class="card"><h3>${tn("providerKeys")}</h3>
447
+ ${!ctx.providerSecrets.length ? html`<p class="note">${tn("noProviderVerifiers")}</p>` : ctx.providerSecrets.map((spec) => html`<div key=${spec.id} class="s-key">
448
+ <div class="s-key-row"><strong>${spec.display_name}</strong><span class="tag">${spec.token_present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
449
+ ${spec.verifier === "available" ? html`<input type="password" autocomplete="new-password" aria-label=${tn("providerKeyInput", spec.display_name)}
450
+ placeholder=${spec.token_present ? tn("keyConfigured") : tn("keyNotConfigured")} value=${keys[spec.id] || ""} onInput=${(e) => setKeys((k) => ({ ...k, [spec.id]: e.target.value }))} />
451
+ <button type="button" class="btn btn-sm btn-primary" disabled=${busy.has(spec.id)} onClick=${() => go(spec)}>${tn("verifyApply")}</button>`
452
+ : html`<span class="tag">${tn("unsupportedVerifier")}</span>`}</div>
453
+ ${spec.verifier === "available" ? null : html`<p class="note">${tn("unsupportedVerifierHint")}</p>`}
454
+ ${notes[spec.id] ? html`<p class=${`feedback${notes[spec.id].error ? " error" : ""}`} role="status">${notes[spec.id].error || notes[spec.id].text}</p>` : null}
455
+ </div>`)}
456
+ </section>`;
457
+ }
458
+
459
+ function Classic({ ctx, search, openDialog }) {
460
+ const rooms = Object.entries((ctx.classic && ctx.classic.channels) || {}).map(([key, ch]) => ({ key, ...ch }));
461
+ if (!rooms.length) return html`<${Empty} icon="room" title=${tn("noClassic")} />`;
462
+ const needle = search.trim().toLowerCase();
463
+ const list = rooms.filter((c) => !needle || String(c.name || "").toLowerCase().includes(needle) || String(c.instanceName || "").toLowerCase().includes(needle))
464
+ .sort((a, b) => ((STATUS_ORDER[ctx.classicRuntime(a.instanceName).status] ?? 9) - (STATUS_ORDER[ctx.classicRuntime(b.instanceName).status] ?? 9))
465
+ || String(a.name || "").localeCompare(String(b.name || "")));
466
+ if (!list.length) return html`<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />`;
467
+ return html`<div class="list-head"><span>${tn("countRooms", rooms.length)}</span></div><div class="s-list">${list.map((c) => {
468
+ const rt = ctx.classicRuntime(c.instanceName);
469
+ const sum = effectiveSummary(ctx.live[c.instanceName] || {}, c, ctx.fleet.defaults || {}, (ctx.classic && ctx.classic.defaults) || {});
470
+ const adapter = c.adapterId || "discord";
471
+ return html`<div key=${c.key} class="s-row">
472
+ <span class=${dotClass(rt.execution)} title=${tn(`exec_${rt.execution}`)} aria-hidden="true"></span>
473
+ <span class="s-name" title=${c.instanceName || ""}>${shortName(c.name || c.instanceName || "?", { display_name: c.display_name })}</span>
474
+ <span class="s-meta" title=${sum.backend}>${sum.model}</span>
475
+ <span class="tag">${tn("effortTag", sum.effort)}</span>
476
+ <span class=${`tag${adapter !== "discord" ? " persona" : ""}`}>${adapter}</span>
477
+ ${c.collab ? html`<span class="tag">collab</span>` : null}
478
+ <span class="tag">${tn(`exec_${rt.execution}`)}</span>
479
+ <span class="s-actions">
480
+ <button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "classic", room: c })}>${tn("settingsButton")}</button>
481
+ ${rt.status === "running" ? html`<button type="button" class="btn btn-sm" onClick=${() => ctx.pauseWake(c.instanceName, "pause")}>${tn("pause")}</button>` : null}
482
+ ${rt.status === "paused" ? html`<button type="button" class="btn btn-sm" onClick=${() => ctx.pauseWake(c.instanceName, "wake")}>${tn("wake")}</button>` : null}
483
+ </span>
484
+ </div>`;
485
+ })}</div>`;
486
+ }
487
+
488
+ /**
489
+ * General: the fleet's defaults, ClassicBot's, web chat. "Review changes" stages up to three changes (defaults,
490
+ * ClassicBot defaults, web) — each only with what differs from what this form showed, so changing one hot default
491
+ * never also stages untouched settings as a fleet restart.
492
+ */
493
+ function General({ ctx, staged }) {
494
+ const { schema } = ctx;
495
+ const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
496
+ const hang = d.hang_detector || {}, startup = d.startup || {};
497
+ const primary = channelsOf(ctx.fleet)[0];
498
+ const web = ctx.fleet.web || {};
499
+ const originalPublic = useMemo(() => ({ allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2" }), []);
500
+ const originalEcho = web.echo_to_channel ?? true;
501
+ const original = useMemo(() => ({ c: structuredClone(c), web: structuredClone(ctx.fleet.web) }), []);
502
+ const [f, setF] = useState(() => ({
503
+ echo: originalEcho, pub: originalPublic.allow_public, ttl: String(originalPublic.ttl_minutes), protocol: originalPublic.protocol,
504
+ backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
505
+ agent_mode: d.agent_mode || "mcp", tool_set: d.tool_set || "full", tool_progress: d.tool_progress ?? "off",
506
+ visibility: visibilityDefault(d), reply: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
507
+ hangOn: hang.enabled !== false, hang: String(hang.timeout_minutes ?? 15),
508
+ concurrency: String(startup.concurrency ?? 10), stagger: String(startup.stagger_delay_ms ?? 500),
509
+ classicBackend: c.backend || d.backend || "claude-code",
510
+ classicToolProgress: c.tool_progress ?? d.tool_progress ?? "off", classicToolProgressInherit: !hasOwn(c, "tool_progress"),
511
+ classicReply: c.reply_completion_guard ?? d.reply_completion_guard ?? true, classicReplyInherit: !hasOwn(c, "reply_completion_guard"),
512
+ admins: [...(c.admin_users || [])], guilds: [...(c.allowed_guilds || [])],
513
+ }));
514
+ const [msg, setMsg] = useState(null);
515
+ const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
516
+ const num = (v) => Number(v);
517
+ const errors = {
518
+ auto: nonNegative(f.auto), hang: positive(f.hang),
519
+ concurrency: !Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20 ? "concurrencyRange" : "",
520
+ stagger: !Number.isFinite(num(f.stagger)) || f.stagger.trim() === "" || num(f.stagger) < 0 || num(f.stagger) > 30000 ? "staggerRange" : "",
521
+ ttl: !Number.isInteger(num(f.ttl)) || num(f.ttl) < 1 || num(f.ttl) > 480 ? "publicTtl" : "",
522
+ };
523
+ const warnings = { concurrency: num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
524
+ const ready = !Object.values(errors).some(Boolean);
525
+ const review = () => {
526
+ if (!ready) return;
527
+ const defaultsPatch = changedFields({
528
+ backend: f.backend, model: f.model || undefined, locale: f.locale || null, auto_pause_after: num(f.auto),
529
+ agent_mode: f.agent_mode, tool_set: f.tool_set, tool_progress: f.tool_progress, cross_instance_visibility: f.visibility,
530
+ reply_completion_guard: f.reply, log_level: f.log_level,
531
+ hang_detector: { enabled: f.hangOn, timeout_minutes: num(f.hang) },
532
+ startup: { concurrency: num(f.concurrency), stagger_delay_ms: num(f.stagger) },
533
+ }, {
534
+ backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
535
+ agent_mode: d.agent_mode || "mcp", tool_set: d.tool_set || "full", tool_progress: d.tool_progress ?? "off",
536
+ cross_instance_visibility: visibilityDefault(d), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
537
+ hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? 15 },
538
+ startup: { concurrency: startup.concurrency ?? 10, stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
539
+ });
540
+ const oc = original.c;
541
+ const classicPatch = changedFields({
542
+ backend: f.classicBackend,
543
+ tool_progress: f.classicToolProgressInherit ? null : f.classicToolProgress,
544
+ reply_completion_guard: f.classicReplyInherit ? null : f.classicReply,
545
+ admin_users: f.admins, allowed_guilds: f.guilds,
546
+ }, {
547
+ backend: oc.backend || d.backend || "claude-code",
548
+ tool_progress: hasOwn(oc, "tool_progress") ? oc.tool_progress : null,
549
+ reply_completion_guard: hasOwn(oc, "reply_completion_guard") ? oc.reply_completion_guard : null,
550
+ admin_users: oc.admin_users || [], allowed_guilds: oc.allowed_guilds || [],
551
+ });
552
+ if (Object.keys(defaultsPatch).length) {
553
+ ctx.stage("defaults:fleet", { label: tn("updateDefaults"), impact: batchImpact(schema, Object.keys(defaultsPatch), (k) => defaultsImpact(schema, k)),
554
+ request: { method: "PUT", url: "/api/settings/fleet/defaults", body: defaultsPatch } });
555
+ }
556
+ if (Object.keys(classicPatch).length) {
557
+ const hotOnly = Object.keys(classicPatch).every((k) => impactOf(schema, `classic_defaults.${k}`) === "now");
558
+ ctx.stage("defaults:classic", { label: tn("updateClassicDefaults"), impact: hotOnly ? "now" : "fleet",
559
+ request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } });
560
+ }
561
+ const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: num(f.ttl), protocol: f.protocol }, originalPublic);
562
+ const webPatch = { ...(f.echo !== originalEcho ? { echo_to_channel: f.echo } : {}), ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}) };
563
+ if (Object.keys(webPatch).length) {
564
+ ctx.stage("web:echo", { label: tn("publicLink"),
565
+ impact: batchImpact(schema, [...(webPatch.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []), ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`)], (k) => impactOf(schema, k)),
566
+ request: { method: "PUT", url: "/api/settings/fleet/web", body: webPatch } });
567
+ } else if (staged.has("web:echo")) ctx.unstage("web:echo");
568
+ setMsg(tn("readyApply"));
569
+ };
570
+ const L = (text, field, id) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} />`;
571
+ const fleetReply = replyGuardSupported(f.backend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.backend, f.agent_mode);
572
+ const classicReply = replyGuardSupported(f.classicBackend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.classicBackend, f.agent_mode);
573
+ return html`<div class="form s-general">
574
+ <section class="card"><h3>${tn("webChat")}</h3>
575
+ <label class="check"><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
576
+ <p class="note">${tn("webEchoHint")}</p>
577
+ <label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
578
+ <p class="note">${tn("publicLinkHint")}</p>
579
+ <div class="grid2">
580
+ <div class="field"><label for="publicWebTtl">${tn("publicTtl")}</label><input id="publicWebTtl" type="number" min="1" max="480" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
581
+ <div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
582
+ </div>
583
+ </section>
584
+ <section class="card"><h3>${tn("defaultsTitle")}</h3>
585
+ <div class="grid2">
586
+ <div class="field">${L(tn("language"), "defaults.locale", "g-locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
587
+ options=${localeOptions(f.locale).map((o) => (o.auto ? { value: "", label: tn("localeAuto") } : o))} /></div>
588
+ <div class="field">${L(tn("defaultBackend"), "defaults.backend", "g-be")}<${BackendSelect} id="g-be" value=${f.backend} onChange=${set("backend")} /></div>
589
+ <div class="field">${L(tn("defaultModel"), "defaults.model", "g-model")}<input id="g-model" type="text" placeholder="e.g. claude-opus-4-8" value=${f.model} onInput=${(e) => set("model")(e.target.value)} /></div>
590
+ <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "defaults.auto_pause_after", "g-auto")}<input id="g-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} value=${f.auto} onInput=${(e) => set("auto")(e.target.value)} /><${Feedback} error=${errors.auto} /></div>
591
+ <div class="field">${L(tn("toolProgress"), "defaults.tool_progress", "g-tp")}<${Select} id="g-tp" value=${f.tool_progress} onChange=${set("tool_progress")} options=${TOOL_PROGRESS} /></div>
592
+ <div class="field">${L(tn("crossInstanceVisibility"), "defaults.cross_instance_visibility", "g-vis")}<${Select} id="g-vis" value=${f.visibility} onChange=${set("visibility")} options=${VISIBILITY_MODES} />
593
+ <p class="note">${tn("crossInstanceVisibilityHint")}</p></div>
594
+ <div class="field"><label class="check"><input type="checkbox" checked=${f.reply} onChange=${(e) => set("reply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span><${Impact} schema=${schema} field="defaults.reply_completion_guard" /></label>
595
+ <p class="note">${fleetReply}</p></div>
596
+ </div>
597
+ </section>
598
+ <section class="card"><h3>ClassicBot</h3>
599
+ <div class="grid2">
600
+ <div class="field">${L(tn("classicBackend"), "classic_defaults.backend", "g-cbe")}<${BackendSelect} id="g-cbe" value=${f.classicBackend} onChange=${set("classicBackend")} /></div>
601
+ <div class="field">${L(tn("toolProgress"), "classic_defaults.tool_progress", "g-ctp")}
602
+ <${Override} inherit=${f.classicToolProgressInherit} onInherit=${set("classicToolProgressInherit")}>
603
+ <${Select} id="g-ctp" disabled=${f.classicToolProgressInherit} value=${f.classicToolProgressInherit ? (d.tool_progress ?? "off") : f.classicToolProgress} onChange=${set("classicToolProgress")} options=${TOOL_PROGRESS} /></${Override}></div>
604
+ <div class="field">${L(tn("replyCompletionGuard"), "classic_defaults.reply_completion_guard")}
605
+ <${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")}>
606
+ <label class="check"><input type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
607
+ <p class="note">${classicReply}</p></div>
608
+ </div>
609
+ <h4>${tn("whoUsesClassic")}</h4>
610
+ <div class="grid2">
611
+ <div class="field">${L(tn("classicAdmins"), "classic.admin_users")}<${ChipList} items=${f.admins} onChange=${set("admins")} placeholder=${tn("userIdPlaceholder")} label=${tn("classicAdmins")} /></div>
612
+ <div class="field">${L(tn("allowedGuilds"), "classic.allowed_guilds")}<${ChipList} items=${f.guilds} onChange=${set("guilds")} placeholder=${tn("guildIdPlaceholder")} label=${tn("allowedGuilds")} /></div>
613
+ </div>
614
+ </section>
615
+ <${Drawer} title=${tn("advancedSection")}>
616
+ <section class="card"><h3>${tn("runtimeResources")}</h3><div class="grid2">
617
+ <div class="field">${L(tn("toolSet"), "defaults.tool_set", "g-ts")}<${Select} id="g-ts" value=${f.tool_set} onChange=${set("tool_set")} options=${TOOL_SETS} /></div>
618
+ <div class="field">${L(tn("logLevel"), "defaults.log_level", "g-ll")}<${Select} id="g-ll" value=${f.log_level} onChange=${set("log_level")} options=${LOG_LEVELS} /></div>
619
+ <div class="field">${L(tn("agentMode"), "defaults.agent_mode", "g-am")}<${Select} id="g-am" value=${f.agent_mode} onChange=${set("agent_mode")} options=${AGENT_MODES} /></div>
620
+ </div></section>
621
+ <section class="card"><h3>${tn("detection")}</h3><div class="grid2">
622
+ <label class="check"><input type="checkbox" checked=${f.hangOn} onChange=${(e) => set("hangOn")(e.target.checked)} /><span>${tn("hangEnabled")}</span><${Impact} schema=${schema} field="defaults.hang_detector" /></label>
623
+ <div class="field">${L(`${tn("hangTimeout")} (${tn("minutes")})`, "defaults.hang_detector", "g-hang")}<input id="g-hang" type="number" min="0.1" step="0.5" class=${errors.hang ? "invalid" : ""} value=${f.hang} onInput=${(e) => set("hang")(e.target.value)} /><${Feedback} error=${errors.hang} /></div>
624
+ </div></section>
625
+ <section class="card"><h3>${tn("startup")}</h3><div class="grid2">
626
+ <div class="field">${L(tn("concurrency"), "fleet.spawn_concurrency", "g-conc")}<input id="g-conc" type="number" min="1" max="20" step="1" class=${errors.concurrency ? "invalid" : ""} value=${f.concurrency} onInput=${(e) => set("concurrency")(e.target.value)} /><${Feedback} error=${errors.concurrency} warning=${warnings.concurrency} /></div>
627
+ <div class="field">${L(`${tn("staggerDelay")} (ms)`, "fleet.spawn_stagger_ms", "g-stag")}<input id="g-stag" type="number" min="0" max="30000" step="100" class=${errors.stagger ? "invalid" : ""} value=${f.stagger} onInput=${(e) => set("stagger")(e.target.value)} /><${Feedback} error=${errors.stagger} warning=${warnings.stagger} /></div>
628
+ </div></section>
629
+ <section class="card"><h3>${tn("accessTitle")}</h3>
630
+ <p class="note">${primary ? tn("accessMovedHint", primary.id || chLabel(0)) : tn("noBots")}</p></section>
631
+ </${Drawer}>
632
+ <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
633
+ ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
634
+ </div>`;
635
+ }
636
+
637
+ /** Developer: the fleet model as YAML or JSON, to read, copy, download — or edit and apply as one operation. */
638
+ function Developer({ ctx }) {
639
+ const [fmt, setFmt] = useState("yaml");
640
+ const [editing, setEditing] = useState(false);
641
+ const model = fleetModel(ctx.fleet);
642
+ const text = fmt === "yaml" ? toYaml(model) : JSON.stringify(model, null, 2);
643
+ const [draft, setDraft] = useState(text);
644
+ const [msg, setMsg] = useState(null);
645
+ useEffect(() => { if (!editing) setDraft(text); }, [text, editing]);
646
+ const copy = () => { try { navigator.clipboard.writeText(text).then(() => setMsg({ text: tn("copied") }), () => setMsg({ error: tn("failed") })); } catch { setMsg({ error: tn("failed") }); } };
647
+ const download = () => {
648
+ const a = document.createElement("a");
649
+ a.href = URL.createObjectURL(new Blob([toYaml(fleetModel(ctx.fleet))], { type: "text/plain" }));
650
+ a.download = "fleet.yaml"; a.click(); URL.revokeObjectURL(a.href);
651
+ };
652
+ const save = () => {
653
+ let parsed;
654
+ try { parsed = fmt === "yaml" ? fromYaml(draft) : JSON.parse(draft || "{}"); } catch (e) { setMsg({ error: e.message }); return; }
655
+ if (!parsed || typeof parsed !== "object") { setMsg({ error: tn("expectedMapping") }); return; }
656
+ const requests = fullModelRequests(parsed, ctx.fleet);
657
+ if (!requests.length) { setMsg({ text: tn("noChanges") }); return; }
658
+ if (operationActive()) { setMsg({ error: tn("applyBusyLocal") }); return; }
659
+ // The whole edit is one operation: its writes in order, then the apply — the same runner as every Apply.
660
+ if (startOperation(requests.map((r) => ({ label: `${r.method} ${decodeURIComponent(r.url.replace("/api/settings/", ""))}`, impact: requestImpact(ctx.schema, r), request: r })))) {
661
+ setEditing(false); setMsg(null);
662
+ }
663
+ };
664
+ return html`<section class="card s-dev">
665
+ <div class="s-dev-bar">
666
+ <div class="seg-inline" role="group" aria-label=${tn("format")}>
667
+ ${["yaml", "json"].map((k) => html`<button key=${k} type="button" class=${`btn btn-sm${fmt === k ? " on" : ""}`} aria-pressed=${fmt === k ? "true" : "false"} onClick=${() => setFmt(k)}>${k.toUpperCase()}</button>`)}</div>
668
+ <button type="button" class="btn btn-sm" onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
669
+ <button type="button" class="btn btn-sm" onClick=${copy}><${Icon} name="copy" size=${14} />${tn("copy")}</button>
670
+ <button type="button" class="btn btn-sm" onClick=${download}><${Icon} name="download" size=${14} />fleet.yaml</button>
671
+ </div>
672
+ ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck="false" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
673
+ <div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
674
+ : html`<pre class="s-yaml">${text}</pre>`}
675
+ ${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
676
+ </section>`;
677
+ }
678
+
679
+ /** Staged, not applied: the count, what it will cost, Discard and Apply. */
680
+ function PendingBar({ staged, busy, onApply, onDiscard }) {
681
+ const kinds = [...new Set([...staged.values()].map((c) => c.impact))];
682
+ return html`<div class="s-pending" role="region" aria-label=${tn("pendingRegion")}>
683
+ <strong>${tn("changesPending", staged.size)}</strong>
684
+ <span class="note">${kinds.map(impactText).join(" · ")}</span>
685
+ <span class="grow"></span>
686
+ <button type="button" class="btn" onClick=${onDiscard} disabled=${busy}>${tn("discard")}</button>
687
+ <button type="button" class="btn btn-primary" onClick=${onApply} disabled=${busy} title=${busy ? tn("applyBusyLocal") : undefined}>${tn("applyChanges")}</button>
688
+ </div>`;
689
+ }
690
+
691
+ /** The Apply in hand (the app's, settings-apply.js): its writes, its job, and a restart when one is needed. */
692
+ function OperationCard({ op, schema }) {
693
+ const [restarting, setRestarting] = useState(false);
694
+ const busy = opBusy(op); // nothing to dismiss while it, or its restart, runs
695
+ const phaseBusy = ["writing", "posting", "watching"].includes(op.phase);
696
+ const job = op.job;
697
+ const rows = (job && job.targets) || [];
698
+ const fleetRestarting = job && job.status === "running" && rows.some((r) => r.target === "fleet" && r.status === "running");
699
+ const needsRestart = rows.some((r) => r.status === "restart-required");
700
+ const failedRows = rows.some((r) => r.status === "failed");
701
+ const mismatch = schema && schema.fleet_signature_mismatch;
702
+ const title = op.phase === "failed" ? tn("applyPartial") : fleetRestarting ? tn("restartFleetTitle") : busy ? tn("applying")
703
+ : failedRows ? tn("applyPartial") : needsRestart ? tn("applyRestartNeeded") : tn("applyDone");
704
+ const note = op.phase === "failed" ? (op.error === "busy" ? tn("applyBusy") : op.error ? tn("applyStepFailed", op.error) : "")
705
+ : fleetRestarting ? tn("restartFleetWaiting") : op.lostJob ? tn("applyLostJob")
706
+ : job && job.overdue ? tn("applyStillWorking", Math.round((job.elapsed_ms || 0) / 1000))
707
+ : mismatch && needsRestart ? tn("signatureMismatch", mismatch.join(", ")) : needsRestart ? tn("applyRestartHint") : "";
708
+ const lease = useLease("operation-card");
709
+ const restart = async () => {
710
+ // The yes is for the job this card shows: it restarts AgEnD for that job only, and only while the card stands.
711
+ const forJob = { opId: op.id, jobId: op.job && op.job.id };
712
+ if (!(await ask(tn("restartFleetConfirm"), { title: tn("restartFleetButton"), confirmLabel: tn("restartFleetButton"), danger: true }))) return;
713
+ if (!lease.current()) return;
714
+ setRestarting(true);
715
+ const res = await restartFleet(forJob);
716
+ if (!lease.current()) return;
717
+ setRestarting(false);
718
+ if (!res.ok) {
719
+ const secs = res.body && res.body.retry_after_seconds;
720
+ toast(secs ? tn("restartFleetRateLimited", (res.body && res.body.error) || "", Math.ceil(secs / 60)) : tn("restartFleetRefused", (res.body && res.body.error) || tn("applyFailed")), false);
721
+ }
722
+ };
723
+ const icon = (s) => (s === "done" ? "check" : s === "failed" ? "alert" : s === "waiting" ? "clock" : s === "running" ? "restart" : null);
724
+ return html`<section class=${`card s-op ${op.phase}`} role="status" aria-live="polite">
725
+ <div class="s-op-head"><strong>${title}</strong><span class="grow"></span>
726
+ ${busy ? null : html`<button type="button" class="icon-btn" aria-label=${t("app.close")} title=${t("app.close")} onClick=${dismissOperation}><${Icon} name="close" size=${16} /></button>`}</div>
727
+ ${note ? html`<p class="note">${note}</p>` : null}
728
+ ${op.steps.length ? html`<ul class="s-op-steps">${op.steps.map((s, i) => html`<li key=${i} class=${`s-step ${s.status}`}>
729
+ <span class="s-step-icon" aria-hidden="true">${icon(s.status) ? html`<${Icon} name=${icon(s.status)} size=${14} />` : "·"}</span>
730
+ <span class="grow">${s.label}</span>
731
+ <span class="note">${s.status === "waiting" ? tn("stepWaiting") : s.status === "skipped" ? tn("stepSkipped") : s.status === "failed" ? (s.error || tn("applyFailed")) : impactText(s.impact)}</span></li>`)}</ul>` : null}
732
+ ${rows.length ? html`<ul class="s-op-steps">${rows.map((r) => html`<li key=${r.target} class=${`s-step ${r.status}`}>
733
+ <span class="s-step-icon" aria-hidden="true">${r.status === "done" ? html`<${Icon} name="check" size=${14} />` : r.status === "failed" ? html`<${Icon} name="alert" size=${14} />` : r.status === "restart-required" ? html`<${Icon} name="restart" size=${14} />` : "·"}</span>
734
+ <span class="grow">${r.target === "fleet" ? "AgEnD" : shortName(r.target, {})}</span>
735
+ <span class="note">${r.target === "fleet" && r.status === "running" ? tn("restartFleetTitle") : r.status === "restart-required" ? tn("applyRestartNeeded")
736
+ : r.settled_by === "no-change" ? tn("applyNoChange") : r.settled_by === "fleet-restart" ? tn("viaRestart") : r.kind === "hot" ? tn("impactNow") : tn("impactAgent")}</span>
737
+ ${r.error ? html`<span class="feedback error">${r.error}</span>` : null}</li>`)}</ul>` : null}
738
+ ${job && job.error ? html`<p class="feedback error">${job.error}</p>` : null}
739
+ ${needsRestart && !mismatch && !phaseBusy ? html`<div class="save-row"><button type="button" class="btn btn-primary" disabled=${restarting || op.restart === "busy"} onClick=${restart}>
740
+ ${restarting || op.restart === "busy" ? tn("restartFleetBusy") : tn("restartFleetButton")}</button></div>` : null}
741
+ </section>`;
742
+ }
743
+
744
+ function HelpDialog({ onClose }) {
745
+ return html`<${Dialog} title=${tn("helpTitle")} onClose=${onClose} actions=${html`<button type="button" class="btn btn-primary" onClick=${onClose}>${tn("close")}</button>`}>
746
+ <ul class="help-list">${["help1", "help2", "help3", "help4", "help5", "help6"].map((k) => html`<li key=${k}>${tn(k)}</li>`)}</ul>
747
+ </${Dialog}>`;
748
+ }
749
+
750
+ export { worstImpact, ACCESS_MODES };