@songsid/agend 2.2.0-beta.1 → 2.2.0-beta.3

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 (197) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-cli-instructions.md +10 -7
  4. package/dist/agent-endpoint.js +21 -9
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-whats-new.d.ts +47 -0
  7. package/dist/agent-whats-new.js +141 -0
  8. package/dist/agent-whats-new.js.map +1 -0
  9. package/dist/auth-api.d.ts +2 -2
  10. package/dist/auth-api.js +4 -2
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/backend/kiro-engine-ledger.d.ts +5 -0
  13. package/dist/backend/kiro-engine-ledger.js +20 -0
  14. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  15. package/dist/backend/kiro-identity.d.ts +6 -0
  16. package/dist/backend/kiro-identity.js +19 -5
  17. package/dist/backend/kiro-identity.js.map +1 -1
  18. package/dist/backend/kiro.d.ts +14 -2
  19. package/dist/backend/kiro.js +84 -9
  20. package/dist/backend/kiro.js.map +1 -1
  21. package/dist/backend/types.d.ts +4 -2
  22. package/dist/backend/types.js.map +1 -1
  23. package/dist/channel/adapters/discord.d.ts +11 -1
  24. package/dist/channel/adapters/discord.js +23 -1
  25. package/dist/channel/adapters/discord.js.map +1 -1
  26. package/dist/channel/adapters/telegram.d.ts +19 -1
  27. package/dist/channel/adapters/telegram.js +60 -9
  28. package/dist/channel/adapters/telegram.js.map +1 -1
  29. package/dist/channel/mcp-server.js +9 -1
  30. package/dist/channel/mcp-server.js.map +1 -1
  31. package/dist/channel/mcp-tools.d.ts +11 -0
  32. package/dist/channel/mcp-tools.js +23 -12
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/channel/telegram-markdown.d.ts +6 -0
  35. package/dist/channel/telegram-markdown.js +104 -0
  36. package/dist/channel/telegram-markdown.js.map +1 -0
  37. package/dist/channel/tool-router.js +9 -3
  38. package/dist/channel/tool-router.js.map +1 -1
  39. package/dist/channel/types.d.ts +14 -2
  40. package/dist/cli.js +55 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/command-table.js +1 -1
  43. package/dist/command-table.js.map +1 -1
  44. package/dist/config-validator.js +49 -0
  45. package/dist/config-validator.js.map +1 -1
  46. package/dist/cross-instance-envelope.d.ts +15 -2
  47. package/dist/cross-instance-envelope.js +27 -5
  48. package/dist/cross-instance-envelope.js.map +1 -1
  49. package/dist/daemon.d.ts +48 -0
  50. package/dist/daemon.js +250 -22
  51. package/dist/daemon.js.map +1 -1
  52. package/dist/delivery-outbox.d.ts +17 -2
  53. package/dist/delivery-outbox.js +13 -2
  54. package/dist/delivery-outbox.js.map +1 -1
  55. package/dist/delivery-reconciliation.d.ts +18 -1
  56. package/dist/delivery-reconciliation.js +67 -2
  57. package/dist/delivery-reconciliation.js.map +1 -1
  58. package/dist/fleet-manager.d.ts +63 -1
  59. package/dist/fleet-manager.js +314 -20
  60. package/dist/fleet-manager.js.map +1 -1
  61. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  62. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
  63. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
  64. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  65. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  66. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  67. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  68. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  69. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  70. package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
  71. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  72. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  73. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  74. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  75. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
  76. package/dist/general-knowledge/steering/core-rules.md +8 -7
  77. package/dist/instance-avatar.d.ts +36 -0
  78. package/dist/instance-avatar.js +57 -0
  79. package/dist/instance-avatar.js.map +1 -0
  80. package/dist/instance-config-impact.js +5 -0
  81. package/dist/instance-config-impact.js.map +1 -1
  82. package/dist/instance-lifecycle.d.ts +4 -0
  83. package/dist/instance-lifecycle.js +3 -1
  84. package/dist/instance-lifecycle.js.map +1 -1
  85. package/dist/instructions.d.ts +2 -0
  86. package/dist/instructions.js +10 -7
  87. package/dist/instructions.js.map +1 -1
  88. package/dist/locale.js +36 -26
  89. package/dist/locale.js.map +1 -1
  90. package/dist/next-steps.js +1 -1
  91. package/dist/next-steps.js.map +1 -1
  92. package/dist/outbound-handlers.d.ts +2 -0
  93. package/dist/outbound-handlers.js +28 -15
  94. package/dist/outbound-handlers.js.map +1 -1
  95. package/dist/outbound-schemas.d.ts +10 -2
  96. package/dist/outbound-schemas.js +15 -5
  97. package/dist/outbound-schemas.js.map +1 -1
  98. package/dist/public-web-gateway.js +7 -2
  99. package/dist/public-web-gateway.js.map +1 -1
  100. package/dist/service-activation.js +4 -3
  101. package/dist/service-activation.js.map +1 -1
  102. package/dist/settings-api.js +71 -21
  103. package/dist/settings-api.js.map +1 -1
  104. package/dist/settings-change.js +70 -1
  105. package/dist/settings-change.js.map +1 -1
  106. package/dist/settings-effect.js +26 -4
  107. package/dist/settings-effect.js.map +1 -1
  108. package/dist/settings-http-confirmation.js +7 -0
  109. package/dist/settings-http-confirmation.js.map +1 -1
  110. package/dist/setup-form.d.ts +1 -1
  111. package/dist/setup-form.js +1 -1
  112. package/dist/status-emojis.d.ts +10 -3
  113. package/dist/status-emojis.js +11 -2
  114. package/dist/status-emojis.js.map +1 -1
  115. package/dist/tips.js +4 -4
  116. package/dist/tips.js.map +1 -1
  117. package/dist/transcript-delta-reader.d.ts +3 -3
  118. package/dist/transcript-delta-reader.js +8 -8
  119. package/dist/transcript-delta-reader.js.map +1 -1
  120. package/dist/transcript-monitor.d.ts +8 -0
  121. package/dist/transcript-monitor.js +26 -2
  122. package/dist/transcript-monitor.js.map +1 -1
  123. package/dist/transcript-sources.d.ts +54 -0
  124. package/dist/transcript-sources.js +188 -27
  125. package/dist/transcript-sources.js.map +1 -1
  126. package/dist/transcript-turns.d.ts +15 -2
  127. package/dist/transcript-turns.js +15 -2
  128. package/dist/transcript-turns.js.map +1 -1
  129. package/dist/types.d.ts +11 -0
  130. package/dist/ui/chat-emojis.js +33 -0
  131. package/dist/ui/chat-render.js +12 -0
  132. package/dist/ui/chat-store.js +1 -1
  133. package/dist/ui/chat-strings.js +12 -12
  134. package/dist/ui/chat-thread.js +23 -4
  135. package/dist/ui/panel-chat.js +16 -3
  136. package/dist/ui/panel-needs.js +5 -2
  137. package/dist/ui/panel-settings.js +129 -31
  138. package/dist/ui/settings-dialogs.js +7 -2
  139. package/dist/ui/settings-model.js +2 -1
  140. package/dist/ui/settings-strings.js +44 -4
  141. package/dist/ui/shared/app-i18n.js +37 -15
  142. package/dist/ui/shared/app-prefs.js +249 -0
  143. package/dist/ui/shared/app-route.js +1 -1
  144. package/dist/ui/shared/app-shell.js +17 -4
  145. package/dist/ui/shared/app-store.js +3 -1
  146. package/dist/ui/shared/app.css +55 -0
  147. package/dist/ui/shared/app.js +6 -1
  148. package/dist/ui/shared/header-tools.js +55 -5
  149. package/dist/ui/shared/instance-nav.js +169 -32
  150. package/dist/ui/shared/panel-view.js +6 -0
  151. package/dist/ui/shared/signin.js +7 -7
  152. package/dist/ui/shared/theme.js +39 -7
  153. package/dist/ui/shared/ui-icons.js +1 -0
  154. package/dist/ui/shared/usage-dialog.js +13 -4
  155. package/dist/ui/shared/view-strings.js +22 -4
  156. package/dist/ui/signin.html +1 -1
  157. package/dist/update-install.d.ts +15 -1
  158. package/dist/update-install.js +6 -0
  159. package/dist/update-install.js.map +1 -1
  160. package/dist/update-preflight.d.ts +100 -0
  161. package/dist/update-preflight.js +264 -0
  162. package/dist/update-preflight.js.map +1 -0
  163. package/dist/updater-caller.d.ts +31 -0
  164. package/dist/updater-caller.js +109 -0
  165. package/dist/updater-caller.js.map +1 -0
  166. package/dist/view-api.d.ts +1 -1
  167. package/dist/view-api.js +38 -8
  168. package/dist/view-api.js.map +1 -1
  169. package/dist/wake-coordinator.d.ts +11 -0
  170. package/dist/wake-coordinator.js +38 -4
  171. package/dist/wake-coordinator.js.map +1 -1
  172. package/dist/web-api.d.ts +21 -1
  173. package/dist/web-api.js +157 -1
  174. package/dist/web-api.js.map +1 -1
  175. package/dist/web-auth.d.ts +2 -2
  176. package/dist/web-auth.js +7 -4
  177. package/dist/web-auth.js.map +1 -1
  178. package/dist/web-host-guard.d.ts +3 -11
  179. package/dist/web-host-guard.js +3 -12
  180. package/dist/web-host-guard.js.map +1 -1
  181. package/dist/web-images.d.ts +47 -0
  182. package/dist/web-images.js +169 -0
  183. package/dist/web-images.js.map +1 -0
  184. package/dist/web-prefs.d.ts +63 -0
  185. package/dist/web-prefs.js +263 -0
  186. package/dist/web-prefs.js.map +1 -0
  187. package/dist/web-session.d.ts +36 -1
  188. package/dist/web-session.js +53 -6
  189. package/dist/web-session.js.map +1 -1
  190. package/dist/web-settings-patch.d.ts +21 -0
  191. package/dist/web-settings-patch.js +74 -0
  192. package/dist/web-settings-patch.js.map +1 -0
  193. package/dist/web-shell-routes.d.ts +1 -1
  194. package/dist/web-shell-routes.js +1 -1
  195. package/dist/web-shell-routes.js.map +1 -1
  196. package/dist/workflow-templates/default.md +1 -1
  197. package/package.json +1 -1
@@ -21,8 +21,8 @@ register("chat", {
21
21
  dropHere: "Drop files to attach them", asText: "As text", pasteAttached: "A long paste ({0} characters) was attached as a text file", pasteKeptAsText: "No room for another attachment: the long paste stays as text",
22
22
  send: "Send", composerPlaceholder: "Message {0}…", composerHint: "Enter to send · Shift+Enter for a new line · / for commands · Esc stops the reply", attachTitle: "Attach files (images, PDF, text — or paste / drop them)",
23
23
  pvPreview: "Preview", pvStop: "Stop", pvDownload: "Download", pvMenu: "Preview options", pvHtml: "HTML",
24
- pvAllowButton: "Allow previews", pvAllow: "Allow HTML previews on this device — they may be able to send data out", pvDisallow: "Stop allowing HTML previews on this device",
25
- pvConfirm: "Allow HTML previews on this device?\n\nA preview runs the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out, and it can slow or freeze this tab. You still click Preview each time.",
24
+ pvAllowButton: "Allow previews", pvAllow: "Allow HTML previews — they may be able to send data out", pvDisallow: "Stop allowing HTML previews",
25
+ pvConfirm: "Allow HTML previews?\n\nThis applies on every device where you are signed in. A preview runs the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out, and it can slow or freeze this tab. You still click Preview each time.",
26
26
  pvNever: "Never preview HTML on this device", pvNeverUndo: "Let this device preview HTML again", pvStarting: "Starting the preview…",
27
27
  pvChanged: "Preview stopped — the message changed. Preview again.", pvTruncated: "Truncated — ask the agent to send it as a .html file.",
28
28
  pvLoading: "Reading the file…", pvAttOver: "This file is over 1 MiB; it is not previewed.",
@@ -37,8 +37,8 @@ register("chat", {
37
37
  pvNewer: "A newer version is in the reply from {0}.", pvNewerShow: "Show it", pvPanelIdle: "Click Preview to run it here.",
38
38
  lbLabel: "Image: {0}", lbLabelNoName: "Image", lbClose: "Close", lbOpenOriginal: "Open original", lbPrev: "Previous image", lbNext: "Next image", lbCount: "{0} / {1}",
39
39
  // #1554: preview.js's words (its English is the fallback there), and the server's reasons by code.
40
- pvWhyOptin: "HTML previews are off on this device (this browser, at this address). Allow them from this card's ⋯ menu or the sidebar.",
41
- pvWhyOptinPanel: "HTML previews are off on this device (this browser, at this address).",
40
+ pvWhyOptin: "HTML previews are off. Allow them from this card's ⋯ menu or Settings → Preferences.",
41
+ pvWhyOptinPanel: "HTML previews are off.",
42
42
  pvWhyNever: "Previews are switched off on this device for this session.",
43
43
  pvWhyOrigin: "Previews are off: this page is at {0}, but the fleet believes it is at {1} (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin.",
44
44
  pvUnknownAddress: "an unknown address", pvWhyServer: "Previews are not available here.",
@@ -55,8 +55,8 @@ register("chat", {
55
55
  pvServer_sameOrigin: "The preview origin cannot be the dashboard's own.",
56
56
  pvServer_publicLink: "Previews are not offered over the public link. Open the dashboard on the machine running AgEnD to preview.",
57
57
  pvServer_notOffered: "Previews are not available on this page.",
58
- previews: "Allow HTML previews on this device",
59
- copy: "Copy", copyMessage: "Copy this reply", copied: "Copied", copyFailed: "Copy failed", wrap: "Wrap", showAll: "Show all {0} lines", showLess: "Show less", jumpLatest: "Latest", newBelow: "{0} new",
58
+ previews: "Allow HTML previews",
59
+ copy: "Copy", copyMessage: "Copy this reply", copied: "Copied", copyFailed: "Copy failed", wrap: "Wrap", showAll: "Show all {0} lines", showMore: "Show more", showLess: "Show less", jumpLatest: "Latest", newBelow: "{0} new",
60
60
  disconnected: "disconnected", tick_queued: "Waiting behind another message", tick_processing: "Handed to the agent", tick_delivered: "The agent has it", tick_failed: "Not delivered", tick_cancelled: "Dropped by Stop",
61
61
  promptAnswering: "Answering…", promptGone: "This prompt is no longer open", promptFor: "{0}: {1}",
62
62
  working: "{0} is working…", stuck: "{0} looks stuck", stopReply: "Stop reply", stopReplyTitle: "Stop the current reply (like Esc) and drop the messages still waiting. The instance keeps running.", stopSent: "Stopped",
@@ -96,8 +96,8 @@ register("chat", {
96
96
  dropHere: "放開即可附加檔案", asText: "改為文字", pasteAttached: "貼上的長文字({0} 個字元)已改為文字檔附加", pasteKeptAsText: "附件已滿,貼上的長文字保留為一般文字",
97
97
  send: "送出", composerPlaceholder: "傳訊息給 {0}…", composerHint: "Enter 送出 · Shift+Enter 換行 · / 叫出指令 · Esc 中止回覆", attachTitle: "附加檔案(圖片、PDF、文字檔,也可以貼上或拖曳)",
98
98
  pvPreview: "預覽", pvStop: "停止", pvDownload: "下載", pvMenu: "預覽選項", pvHtml: "HTML",
99
- pvAllowButton: "允許預覽", pvAllow: "允許這台裝置預覽 HTML(預覽可能把資料傳出去)", pvDisallow: "不再允許這台裝置預覽 HTML",
100
- pvConfirm: "要允許這台裝置預覽 HTML 嗎?\n\n預覽會在隔離的框架裡執行 agent 的 HTML。它無法使用你的登入,但可能把資料傳出去,也可能讓這個分頁變慢或卡住。每次仍需要你按「預覽」。",
99
+ pvAllowButton: "允許預覽", pvAllow: "允許預覽 HTML(預覽可能把資料傳出去)", pvDisallow: "不再允許預覽 HTML",
100
+ pvConfirm: "要允許預覽 HTML 嗎?\n\n這會套用到你登入的每一台裝置。預覽會在隔離的框架裡執行 agent 的 HTML。它無法使用你的登入,但可能把資料傳出去,也可能讓這個分頁變慢或卡住。每次仍需要你按「預覽」。",
101
101
  pvNever: "這台裝置永不預覽 HTML", pvNeverUndo: "讓這台裝置再次可以預覽 HTML", pvStarting: "正在啟動預覽…",
102
102
  pvChanged: "預覽已停止——訊息內容變了。請再預覽一次。", pvTruncated: "內容被截斷——請 agent 改用 .html 檔案傳送。",
103
103
  pvLoading: "正在讀取檔案…", pvAttOver: "這個檔案超過 1 MiB,不提供預覽。",
@@ -111,8 +111,8 @@ register("chat", {
111
111
  pvPage: "頁面", pvCode: "程式碼", pvReload: "重新載入", pvClose: "關閉面板", pvBack: "回到對話", pvResize: "調整預覽面板寬度",
112
112
  pvNewer: "{0} 的回覆裡有較新的版本。", pvNewerShow: "顯示", pvPanelIdle: "按「預覽」在這裡執行。",
113
113
  lbLabel: "圖片:{0}", lbLabelNoName: "圖片", lbClose: "關閉", lbOpenOriginal: "開啟原圖", lbPrev: "上一張", lbNext: "下一張", lbCount: "{0} / {1}",
114
- pvWhyOptin: "這台裝置(這個瀏覽器、這個網址)還沒允許 HTML 預覽。可從這張卡片的 ⋯ 選單或側欄允許。",
115
- pvWhyOptinPanel: "這台裝置(這個瀏覽器、這個網址)還沒允許 HTML 預覽。",
114
+ pvWhyOptin: "還沒允許 HTML 預覽。可從這張卡片的 ⋯ 選單或「設定 → 偏好設定」允許。",
115
+ pvWhyOptinPanel: "還沒允許 HTML 預覽。",
116
116
  pvWhyNever: "這台裝置在這個工作階段已關閉預覽。",
117
117
  pvWhyOrigin: "預覽已關閉:這個頁面位於 {0},但艦隊認為它位於 {1}(有會改寫 Host 的代理)。請讓外部 Host 原樣傳入,或設定 web.preview_origin。",
118
118
  pvUnknownAddress: "未知的網址", pvWhyServer: "這裡無法使用預覽。",
@@ -129,8 +129,8 @@ register("chat", {
129
129
  pvServer_sameOrigin: "預覽的來源不能和儀表板相同。",
130
130
  pvServer_publicLink: "公開連結不提供預覽。要預覽,請在執行 AgEnD 的那台機器上開啟儀表板。",
131
131
  pvServer_notOffered: "這個頁面不提供預覽。",
132
- previews: "允許這台裝置預覽 HTML",
133
- copy: "複製", copyMessage: "複製這則回覆", copied: "已複製", copyFailed: "複製失敗", wrap: "換行", showAll: "展開全部 {0} 行", showLess: "收合", jumpLatest: "最新", newBelow: "{0} 則新訊息",
132
+ previews: "允許預覽 HTML",
133
+ copy: "複製", copyMessage: "複製這則回覆", copied: "已複製", copyFailed: "複製失敗", wrap: "換行", showAll: "展開全部 {0} 行", showMore: "顯示更多", showLess: "收合", jumpLatest: "最新", newBelow: "{0} 則新訊息",
134
134
  disconnected: "已斷線", tick_queued: "排在前一則訊息之後", tick_processing: "已交給 agent", tick_delivered: "agent 已收到", tick_failed: "未送達", tick_cancelled: "已被中止取消",
135
135
  promptAnswering: "回覆中…", promptGone: "這個提示已經結束", promptFor: "{0}:{1}",
136
136
  working: "{0} 處理中…", stuck: "{0} 似乎卡住了", stopReply: "中止回覆", stopReplyTitle: "中止目前的回覆(同 Esc),並取消還在排隊的訊息。instance 會繼續運行。", stopSent: "已中止",
@@ -22,6 +22,9 @@ export const TEXT_PREVIEW_NODES = 4000;
22
22
  /** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
23
23
  export const TEXT_PREVIEW_COLUMNS = 50;
24
24
  const P = () => globalThis.AgendPreview;
25
+ /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
26
+ export const STATUS_FOLD_LINES = 6;
27
+ export const STATUS_FOLD_CHARS = 600;
25
28
  const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
26
29
 
27
30
  /** Escapes & " ' < >: safe for text and inside a quoted attribute. */
@@ -50,13 +53,29 @@ export function createThread(list, scroller, opts) {
50
53
  const TICKS = () => ({ queued: tr("chat.tick_queued"), processing: tr("chat.tick_processing"), delivered: tr("chat.tick_delivered"), failed: tr("chat.tick_failed"), cancelled: tr("chat.tick_cancelled") });
51
54
 
52
55
  function msgHtml(x) {
53
- const u = opts.isUser(x);
54
56
  const time = x.ts ? new Date(x.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
57
+ if (x.role === "status") return statusHtml(x, time);
58
+ const u = opts.isUser(x);
55
59
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
56
60
  const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
57
61
  const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
58
62
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
59
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
63
+ return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis })}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
64
+ }
65
+ /**
66
+ * #1672: a status line (another instance's message, a daemon notice) is muted, read-only plain text — never Markdown,
67
+ * a preview card, a button or a person's bubble. Checked before isUser, which reads the sender: a fleet name need not
68
+ * start with "agend". A long one is folded until it is opened.
69
+ */
70
+ function statusHtml(x, time) {
71
+ const text = String(x.text == null ? "" : x.text);
72
+ const lines = R().lineCount(text);
73
+ const manyLines = lines > STATUS_FOLD_LINES;
74
+ const more = manyLines || text.length > STATUS_FOLD_CHARS
75
+ ? `<button type="button" class="st-more" data-act="toggleFold"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""}>${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}</button>`
76
+ : "";
77
+ const tools = `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
78
+ return `<div class="msg status${more ? " folded" : ""}"><div class="meta"><span class="time">${time}</span></div><div class="body"><div class="st-text">${escAttr(text)}</div>${more}</div>${tools}</div>`;
60
79
  }
61
80
  /**
62
81
  * #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
@@ -426,9 +445,9 @@ export function createThread(list, scroller, opts) {
426
445
  if (act === "copyMsg") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) copyFrom(el, x.text); }
427
446
  else if (act === "copyCode") { const pre = el.closest(".codeblock")?.querySelector("pre"); if (pre) copyFrom(el, pre.textContent); }
428
447
  else if (act === "toggleFold") {
429
- const box = el.closest(".codeblock"); if (!box) return;
448
+ const box = el.closest(".codeblock, .msg.status"); if (!box) return;
430
449
  const folded = box.classList.toggle("folded");
431
- el.textContent = folded ? trf("chat.showAll", el.dataset.lines) : tr("chat.showLess");
450
+ el.textContent = !folded ? tr("chat.showLess") : el.dataset.lines ? trf("chat.showAll", el.dataset.lines) : tr("chat.showMore");
432
451
  } else if (act === "toggleWrap") opts.toggleWrap();
433
452
  else if (act === "replyButton") {
434
453
  const m = /^([0-9a-f]{32}):(\d{1,2})$/.exec(el.dataset.arg || "");
@@ -10,6 +10,7 @@ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-
10
10
  import { t } from "/assets/app-i18n.js";
11
11
  import { appStore, useStore } from "/assets/app-store.js";
12
12
  import { useLease } from "/assets/app-ctx.js";
13
+ import { labelOf } from "/assets/instance-nav.js";
13
14
  import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
14
15
  import { detailsPath } from "/assets/app-route.js";
15
16
  import { InstanceSwitch } from "/assets/instance-switch.js";
@@ -21,10 +22,12 @@ import { Empty, Skeleton } from "/assets/ui-states.js";
21
22
  import { Icon } from "/assets/ui-icons.js";
22
23
  import { toast } from "/assets/ui-toast.js";
23
24
  import { confirmDialog } from "/assets/ui-confirm.js";
25
+ import { savePref } from "/assets/app-prefs.js";
24
26
  import "./chat-strings.js";
25
27
  import { confirmedWrite } from "./settings-confirm.js";
26
28
  import { createChatStore } from "./chat-store.js";
27
29
  import { createThread, msgKey } from "./chat-thread.js";
30
+ import { createEmojiIndex } from "./chat-emojis.js";
28
31
  import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.js";
29
32
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
30
33
  import { installTour, refreshTourSpot } from "./chat-tour.js";
@@ -40,6 +43,8 @@ export let store = null;
40
43
  // ── Announcements: one polite, visually hidden line for the coarse events only (a reply arrived, the agent started,
41
44
  // finished, or waits on you). Messages themselves are not read out as they arrive; the list is a log to browse.
42
45
  let announceTimer = null;
46
+ /** #1630: which Discord server emoji this page may draw (created with the store, in boot). */
47
+ let emojiIndex = null;
43
48
  function announce(text) {
44
49
  let el = document.getElementById("announcer");
45
50
  if (!el) { el = document.createElement("div"); el.id = "announcer"; el.className = "sr-only"; el.setAttribute("role", "status"); el.setAttribute("aria-live", "polite"); el.setAttribute("aria-atomic", "true"); document.body.appendChild(el); }
@@ -56,6 +61,8 @@ function announce(text) {
56
61
  export async function setPreviewOptIn(on) {
57
62
  if (on && !P().optedIn() && !(await confirmDialog({ message: t("chat.pvConfirm"), confirmLabel: t("chat.pvAllowButton") }))) on = false;
58
63
  P().setOptIn(on);
64
+ // #1665: kept on the host for every device (only a signed-in page writes it; never the public link).
65
+ savePref("preview.optIn", P().optedIn());
59
66
  return P().optedIn();
60
67
  }
61
68
 
@@ -65,6 +72,7 @@ export function boot({ stream, boot: bootData, deps = {} }) {
65
72
  store = createChatStore({
66
73
  fetch: (...a) => fetch(...a), toast, announce, t, setTimeout: (fn, ms) => setTimeout(fn, ms), confirm: confirmDialog, ...deps,
67
74
  });
75
+ emojiIndex = createEmojiIndex((url) => (deps.fetch || fetch)(url, { credentials: "same-origin" }));
68
76
  store.attach(stream);
69
77
  // #1306: what the server said about previews for this load. The page-wide message and storage listeners this adds
70
78
  // belong to the app, once (they are never per mount).
@@ -206,7 +214,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
206
214
  ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", inst.model)} aria-label=${t("chat.chipModel", inst.model)} onClick=${() => pick("model")}>${inst.model}</button>` : null}
207
215
  ${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
208
216
  return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
209
- <${PanelHeader} title=${name} sub=${sub} nav=${html`<${InstanceSwitch} name=${name} current="chat" />`}><${HeaderTools} navKey=${navKey} /><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
217
+ <${PanelHeader} title=${labelOf(inst) !== name ? html`<span title=${name}>${labelOf(inst)}</span>` : name} sub=${sub} nav=${html`<${InstanceSwitch} name=${name} current="chat" />`}><${HeaderTools} navKey=${navKey} /><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
210
218
  <div class="chat-split" ref=${split}>
211
219
  <div class="chat-main">
212
220
  <${Thread} name=${name} th=${thread} />
@@ -234,12 +242,17 @@ function Thread({ name, th }) {
234
242
  replyButtonBusy: (id) => store.state.rbBusy.has(id),
235
243
  panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o) }, // #1481
236
244
  openImage: openLightbox, // an image, full size, in the page
245
+ emojis: (id) => !!emojiIndex && emojiIndex.known(id), // #1630: the fleet's server emoji
237
246
  });
238
247
  th.current = thread;
248
+ // #1630: the server-emoji list is read once a message shows a tag; when it arrives, those messages are drawn again.
249
+ const wantEmojis = (msgs) => { if (emojiIndex && emojiIndex.wants(msgs)) emojiIndex.ensure(); };
250
+ const offEmojis = emojiIndex ? emojiIndex.subscribe(() => thread.render(store.state.msgs[name] || [])) : () => {};
239
251
  thread.render(store.state.msgs[name] || [], { restore: store.state.scrollMemo[name] ?? null });
252
+ wantEmojis(store.state.msgs[name]);
240
253
  const off = store.subscribe((instance, kind) => {
241
254
  if (instance !== name) return;
242
- if (kind === "msgs") thread.render(store.state.msgs[name] || []);
255
+ if (kind === "msgs") { thread.render(store.state.msgs[name] || []); wantEmojis(store.state.msgs[name]); }
243
256
  else if (kind === "sent") thread.jumpLatest();
244
257
  });
245
258
  const offPv = P() ? P().onChange(() => thread.refreshCards()) : () => {};
@@ -249,7 +262,7 @@ function Thread({ name, th }) {
249
262
  sc.addEventListener("scroll", onScroll, { passive: true });
250
263
  ls.addEventListener("click", thread.onClick);
251
264
  return () => {
252
- off(); offPv(); offPanel();
265
+ off(); offPv(); offPanel(); offEmojis();
253
266
  sc.removeEventListener("scroll", onScroll);
254
267
  ls.removeEventListener("click", thread.onClick);
255
268
  thread.dispose(); // the whole thread goes: every preview in it stops first
@@ -15,6 +15,7 @@ import { appStore, createStore, useStore } from "/assets/app-store.js";
15
15
  import { useLease } from "/assets/app-ctx.js";
16
16
  import { PanelHeader, setTitle } from "/assets/app-shell.js";
17
17
  import { chatPath } from "/assets/app-route.js";
18
+ import { labelOf } from "/assets/instance-nav.js";
18
19
  import { Empty } from "/assets/ui-states.js";
19
20
  import { Icon } from "/assets/ui-icons.js";
20
21
  import { toast } from "/assets/ui-toast.js";
@@ -107,7 +108,9 @@ export function groupNeeds(items) {
107
108
 
108
109
  export function NeedsPanel({ navKey }) {
109
110
  const lease = useLease(navKey);
110
- const { needs, chatOwner: owner } = useStore(appStore);
111
+ const { needs, chatOwner: owner, instances } = useStore(appStore);
112
+ // #1630: a group is headed by the instance's display name (its name on hover), as the sidebar shows it.
113
+ const nameFor = (instance) => { const it = (instances || []).find((i) => i && i.name === instance); return it ? labelOf(it) : instance; };
111
114
  const items = Array.isArray(needs) ? needs : [];
112
115
  const [, tick] = useState(0);
113
116
  useEffect(() => { setTitle(tn("title")); }, [navKey]);
@@ -122,7 +125,7 @@ export function NeedsPanel({ navKey }) {
122
125
  <div class="panel-body"><div class="col">
123
126
  ${!items.length ? html`<${Empty} icon="check" title=${tn("empty")} hint=${tn("emptyHint")} />`
124
127
  : groups.map((g) => html`<section key=${g.instance} class="n-group" aria-label=${g.instance}>
125
- <h2 class="n-group-head"><a href=${chatPath(g.instance)}>${g.instance}</a></h2>
128
+ <h2 class="n-group-head"><a href=${chatPath(g.instance)} title=${g.instance}>${nameFor(g.instance)}</a></h2>
126
129
  ${g.items.map((item) => html`<${NeedsItem} key=${item.id} item=${item} nowMs=${nowMs} owner=${owner} />`)}
127
130
  </section>`)}
128
131
  <${NotifyToggle} />
@@ -43,7 +43,7 @@ import { startTour } from "./chat-tour.js";
43
43
  import "./chat-strings.js";
44
44
 
45
45
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
46
- const ICONS = { device: "user", agents: "bot", bots: "plug", classic: "room", general: "sliders", advanced: "code" };
46
+ const ICONS = { device: "user", agents: "bot", bots: "plug", classic: "room", general: "sliders", web: "globe", advanced: "code" };
47
47
  const RELEASES = "https://github.com/songsid/AgEnD/releases";
48
48
 
49
49
  /** GET under the panel's lease → { ok, status, body }. */
@@ -229,7 +229,8 @@ export function SettingsPanel({ route, navKey }) {
229
229
  : section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
230
230
  : section === "bots" ? html`<${Bots} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
231
231
  : section === "classic" ? html`<${Classic} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
232
- : section === "general" ? html`<${General} key=${data.loadedAt} ctx=${ctx} staged=${staged} />`
232
+ : section === "general" ? html`<${General} key=${data.loadedAt} ctx=${ctx} />`
233
+ : section === "web" ? html`<${WebSection} key=${data.loadedAt} ctx=${ctx} staged=${staged} />`
233
234
  : html`<${Developer} ctx=${ctx} />`;
234
235
  // A dialog belongs to the navigation that opened it (its draft names one object, never another).
235
236
  const open = dialog && dialog.key === navKey ? dialog : null;
@@ -257,9 +258,10 @@ export function SettingsPanel({ route, navKey }) {
257
258
  }
258
259
 
259
260
  /**
260
- * #1604: what this browser chooses for itself — moved here from the sidebar footer, each with the storage it always had:
261
- * theme (theme.js), language (app-i18n.js), HTML previews on this device (preview.js, through the app's confirm), and
262
- * the chat tour (replayed on the chat it points at).
261
+ * #1604, #1665: Preferences. Synced across the person's devices (kept on the host, app-prefs.js): theme (theme.js),
262
+ * language (app-i18n.js) and HTML previews (preview.js, through the app's confirm). On the public link they are
263
+ * read-only: a change there stays in that browser, and it says so. This device only: the chat tour (replayed on the
264
+ * chat it points at), and the layout choices named there (their controls are where they always were).
263
265
  */
264
266
  export function DeviceSection() {
265
267
  const P = () => globalThis.AgendPreview;
@@ -290,11 +292,15 @@ export function DeviceSection() {
290
292
  };
291
293
  setTimeout(go, 0);
292
294
  };
293
- return html`<section class="card s-device"><h3>${tn("sec_device")}</h3>
294
- <p class="sub">${tn("deviceLead")}</p>
295
+ const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
296
+ return html`<section class="card s-device s-synced"><h3>${tn("syncedTitle")}</h3>
297
+ <p class="sub">${publicLink ? tn("syncedPublicLink") : tn("syncedLead")}</p>
295
298
  <${DevicePrefs} labels=${true} />
296
299
  ${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
297
300
  <span>${t("chat.previews")}</span></label>` : null}
301
+ </section>
302
+ <section class="card s-device s-local"><h3>${tn("deviceTitle")}</h3>
303
+ <p class="sub">${tn("deviceLead")}</p>
298
304
  <div class="s-device-row"><button type="button" id="tourBtn" class="btn" title=${t("chat.tourBtnTitle")} onClick=${replay}><${Icon} name="info" size=${16} />${t("chat.tour")}</button></div>
299
305
  </section>`;
300
306
  }
@@ -570,17 +576,13 @@ function Classic({ ctx, search, openDialog }) {
570
576
  * ClassicBot defaults, web) — each only with what differs from what this form showed, so changing one hot default
571
577
  * never also stages untouched settings as a fleet restart.
572
578
  */
573
- function General({ ctx, staged }) {
579
+ function General({ ctx }) {
574
580
  const { schema } = ctx;
575
581
  const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
576
582
  const hang = d.hang_detector || {}, startup = d.startup || {};
577
583
  const primary = channelsOf(ctx.fleet)[0];
578
- const web = ctx.fleet.web || {};
579
- 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" }), []);
580
- const originalEcho = web.echo_to_channel ?? true;
581
- const original = useMemo(() => ({ c: structuredClone(c), web: structuredClone(ctx.fleet.web) }), []);
584
+ const original = useMemo(() => ({ c: structuredClone(c) }), []);
582
585
  const [f, setF] = useState(() => ({
583
- echo: originalEcho, pub: originalPublic.allow_public, ttl: String(originalPublic.ttl_minutes), protocol: originalPublic.protocol,
584
586
  backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
585
587
  agent_mode: d.agent_mode || "mcp", tool_set: d.tool_set || "full", tool_progress: d.tool_progress ?? "off",
586
588
  visibility: visibilityDefault(d), reply: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
@@ -598,7 +600,6 @@ function General({ ctx, staged }) {
598
600
  auto: nonNegative(f.auto), hang: positive(f.hang),
599
601
  concurrency: !Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20 ? "concurrencyRange" : "",
600
602
  stagger: !Number.isFinite(num(f.stagger)) || f.stagger.trim() === "" || num(f.stagger) < 0 || num(f.stagger) > 30000 ? "staggerRange" : "",
601
- ttl: !Number.isInteger(num(f.ttl)) || num(f.ttl) < 1 || num(f.ttl) > 480 ? "publicTtl" : "",
602
603
  };
603
604
  const warnings = { concurrency: num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
604
605
  const ready = !Object.values(errors).some(Boolean);
@@ -638,29 +639,12 @@ function General({ ctx, staged }) {
638
639
  ctx.stage("defaults:classic", { label: tn("updateClassicDefaults"), impact: hotOnly ? "now" : "fleet",
639
640
  request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } });
640
641
  }
641
- const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: num(f.ttl), protocol: f.protocol }, originalPublic);
642
- const webPatch = { ...(f.echo !== originalEcho ? { echo_to_channel: f.echo } : {}), ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}) };
643
- if (Object.keys(webPatch).length) {
644
- ctx.stage("web:echo", { label: tn("publicLink"),
645
- 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)),
646
- request: { method: "PUT", url: "/api/settings/fleet/web", body: webPatch } });
647
- } else if (staged.has("web:echo")) ctx.unstage("web:echo");
648
642
  setMsg(tn("readyApply"));
649
643
  };
650
644
  const L = (text, field, id) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} />`;
651
645
  const fleetReply = replyGuardSupported(f.backend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.backend, f.agent_mode);
652
646
  const classicReply = replyGuardSupported(f.classicBackend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.classicBackend, f.agent_mode);
653
647
  return html`<div class="form s-general">
654
- <section class="card"><h3>${tn("webChat")}</h3>
655
- <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>
656
- <p class="note">${tn("webEchoHint")}</p>
657
- <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>
658
- <p class="note">${tn("publicLinkHint")}</p>
659
- <div class="grid2">
660
- <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>
661
- <div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
662
- </div>
663
- </section>
664
648
  <section class="card"><h3>${tn("defaultsTitle")}</h3>
665
649
  <div class="grid2">
666
650
  <div class="field">${L(tn("language"), "defaults.locale", "g-locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
@@ -714,6 +698,120 @@ function General({ ctx, staged }) {
714
698
  </div>`;
715
699
  }
716
700
 
701
+ /**
702
+ * #1620: Settings → Web — the dashboard's own settings, in one write (PUT /api/settings/fleet/web). Editable: the local
703
+ * session limits, the browser terminal (on/off, its minutes), web echo and the public link's menu; everything else
704
+ * under web: / web_terminal: is listed read-only (it decides what the dashboard listens on and exposes: fleet.yaml, then
705
+ * agend reload). The server judges each change: loosening one waits for a fleet admin in General, tightening applies at
706
+ * once. Over
707
+ * the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
708
+ */
709
+ export const WEB_READ_ONLY = [
710
+ "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin",
711
+ "web.usage_panel", "web.provider_secrets", "web_terminal.bind", "web_terminal.tunnel",
712
+ ];
713
+ function WebSection({ ctx, staged }) {
714
+ const { schema } = ctx;
715
+ const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
716
+ const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
717
+ const original = useMemo(() => ({
718
+ echo: web.echo_to_channel ?? true,
719
+ pub: { 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" },
720
+ session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
721
+ terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
722
+ }), []);
723
+ const text = (v) => (v === null || v === undefined ? "" : String(v));
724
+ const [f, setF] = useState(() => ({
725
+ echo: original.echo, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
726
+ abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
727
+ termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
728
+ }));
729
+ const [msg, setMsg] = useState(null);
730
+ const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
731
+ // Empty = the default (null in the write: the key is removed from fleet.yaml).
732
+ const value = (v) => (v.trim() === "" ? null : Number(v));
733
+ const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
734
+ // The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY); an idle time left blank follows a shorter session, as
735
+ // the store and the validator apply it — so only an idle time that is typed can exceed the session length.
736
+ const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
737
+ const errors = {
738
+ ttl: !Number.isInteger(Number(f.ttl)) || Number(f.ttl) < 1 || Number(f.ttl) > 480 ? "publicTtl" : "",
739
+ abs: hours(f.abs),
740
+ idle: hours(f.idle) || (!hours(f.abs) && idle > abs ? "sessionIdleOver" : ""),
741
+ termTtl: value(f.termTtl) !== null && (!Number.isInteger(value(f.termTtl)) || value(f.termTtl) < 1 || value(f.termTtl) > 20) ? "webTerminalTtlRange" : "",
742
+ };
743
+ const ready = !overPublicLink && !Object.values(errors).some(Boolean);
744
+ const review = () => {
745
+ if (!ready) return;
746
+ const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol }, original.pub);
747
+ const sessionPatch = changedFields({ absolute_hours: value(f.abs), idle_hours: value(f.idle) }, original.session);
748
+ // The checkbox shows the default (on) for an unset key: only a real toggle is written; on again = back to the default.
749
+ const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
750
+ ttl_minutes: value(f.termTtl) }, original.terminal);
751
+ const body = {
752
+ ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
753
+ ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
754
+ ...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
755
+ ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
756
+ };
757
+ const fields = [
758
+ ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
759
+ ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
760
+ ...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
761
+ ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
762
+ ];
763
+ if (fields.length) {
764
+ ctx.stage("web", { label: tn("sec_web"), impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
765
+ request: { method: "PUT", url: "/api/settings/fleet/web", body } });
766
+ } else if (staged.has("web")) ctx.unstage("web");
767
+ setMsg(tn("readyApply"));
768
+ };
769
+ const shown = (path) => {
770
+ const [top, key] = path.split(".");
771
+ const v = (top === "web" ? web : term)[key];
772
+ return v === undefined ? tn("notSet") : typeof v === "object" ? JSON.stringify(v) : String(v);
773
+ };
774
+ return html`<div class="form s-web">
775
+ <p class="sub">${tn("webLead")}</p>
776
+ ${overPublicLink ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
777
+ <fieldset class="s-web-fields" disabled=${overPublicLink}>
778
+ <section class="card"><h3>${tn("webSessionTitle")}</h3>
779
+ <p class="note">${tn("webSessionHint")}</p>
780
+ <div class="grid2">
781
+ <div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
782
+ class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} /></div>
783
+ <div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
784
+ class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} /></div>
785
+ </div>
786
+ </section>
787
+ <section class="card"><h3>${tn("webTerminalTitle")}</h3>
788
+ <p class="note">${tn("webTerminalHint")}</p>
789
+ <label class="check"><input id="webTerminalEnabled" type="checkbox" checked=${f.termOn} onChange=${(e) => set("termOn")(e.target.checked)} /><span>${tn("webTerminalEnabled")}</span><${Impact} schema=${schema} field="web_terminal.enabled" /></label>
790
+ <div class="grid2">
791
+ <div class="field"><label for="webTerminalTtl">${tn("webTerminalTtl")}</label><input id="webTerminalTtl" type="number" min="1" max="20" step="1" placeholder=${tn("defaultHours", 10)}
792
+ class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl} /></div>
793
+ </div>
794
+ </section>
795
+ <section class="card"><h3>${tn("webChat")}</h3>
796
+ <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>
797
+ <p class="note">${tn("webEchoHint")}</p>
798
+ <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>
799
+ <p class="note">${tn("publicLinkHint")}</p>
800
+ <div class="grid2">
801
+ <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>
802
+ <div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
803
+ </div>
804
+ </section>
805
+ </fieldset>
806
+ <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
807
+ <p class="note">${tn("webReadOnlyLead")}</p>
808
+ <dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
809
+ </section>
810
+ <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
811
+ ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
812
+ </div>`;
813
+ }
814
+
717
815
  /** Developer: the fleet model as YAML or JSON, to read, copy, download — or edit and apply as one operation. */
718
816
  function Developer({ ctx }) {
719
817
  const [fmt, setFmt] = useState("yaml");
@@ -108,8 +108,13 @@ export function orderedStatusMap(map, keys) {
108
108
  for (const key of keys) { const v = map && map[key]; if (typeof v === "string" && v.trim()) out[key] = v.trim(); }
109
109
  return Object.keys(out).length ? out : null;
110
110
  }
111
- // no-referrer: nothing here should tell a third-party image host where it was loaded from.
112
- const EmojiImg = ({ url, alt }) => html`<img src=${url} alt=${alt} referrerpolicy="no-referrer" loading="lazy" />`;
111
+ // #1630: from the dashboard's own route (/ui/img/emoji/<id>). An emoji the fleet's servers do not have is not served
112
+ // there: then its :name: as text, never a broken image.
113
+ const EmojiImg = ({ url, alt }) => {
114
+ const [broken, setBroken] = useState("");
115
+ return broken === url ? html`<span class="se-alt">${alt}</span>`
116
+ : html`<img src=${url} alt=${alt} referrerpolicy="no-referrer" loading="lazy" onError=${() => setBroken(url)} />`;
117
+ };
113
118
 
114
119
  /**
115
120
  * One status_emojis map. `platform` and `channel` say where it applies; `previewBody(map)` is what the preview resolves.
@@ -24,8 +24,9 @@ export function changedFields(next, baseline) {
24
24
  /** #1302: cross-instance notices. Unset, or a value the fleet does not know, means full. */
25
25
  export const visibilityDefault = (defaults) => (VISIBILITY_MODES.includes(defaults && defaults.cross_instance_visibility) ? defaults.cross_instance_visibility : "full");
26
26
 
27
+ /** The backends whose turn end the reply guard can see (backend replyCompletionGuard: claude-code, codex and muse since #1510, kiro legacy/tui), with the MCP server it needs. */
27
28
  export const replyGuardSupported = (backend, mode, kiroUi = "legacy") =>
28
- mode === "mcp" && (backend === "claude-code" || (backend === "kiro-cli" && ["legacy", "tui"].includes(kiroUi || "legacy")));
29
+ mode === "mcp" && (["claude-code", "codex", "muse"].includes(backend) || (backend === "kiro-cli" && ["legacy", "tui"].includes(kiroUi || "legacy")));
29
30
 
30
31
  /**
31
32
  * The backend picker's options. A current value it does not offer (the removed gemini-cli, #1280 review) stays first
@@ -75,7 +75,7 @@ register("settings", {
75
75
  crossInstanceVisibility: "Bot-to-bot messages in topics",
76
76
  crossInstanceVisibilityHint: "full: the whole message · summary: one line · hidden: nothing. Messages are always delivered; the Mirror Topic still shows them.",
77
77
  replyCompletionGuard: "Require a delivered reply",
78
- replyGuardSupported: "Available for Claude Code and Kiro legacy/TUI in MCP mode.",
78
+ replyGuardSupported: "Available for Claude Code, Codex, Muse and Kiro legacy/TUI in MCP mode.",
79
79
  replyGuardUnsupported: "Stored but inactive: {0} in {1} mode does not support reply-drop recovery.",
80
80
  logLevel: "Log level",
81
81
  concurrency: "Startup concurrency",
@@ -217,7 +217,9 @@ register("settings", {
217
217
  concurrencyWarning: "May exceed CPU capacity on smaller hosts",
218
218
  staggerRange: "Must be between 0 and 30000",
219
219
  staggerWarning: "Very low values can cause startup spikes",
220
- sec_device: "This device", deviceLead: "Choices for this browser only — other devices keep their own.",
220
+ sec_device: "Preferences", syncedTitle: "Synced across your devices", syncedLead: "Changes here apply on every device where you are signed in.",
221
+ syncedPublicLink: "On the public link these are read-only: a change here stays in this browser.",
222
+ deviceTitle: "This device only", deviceLead: "The tour, text size, view fit, the sidebar shown or hidden, panel widths and desktop notifications stay on each device.",
221
223
  sec_agents: "Agents",
222
224
  sec_bots: "Connections",
223
225
  sec_classic: "ClassicBot",
@@ -277,6 +279,24 @@ register("settings", {
277
279
  keyRollbackFailed: "Rollback failed",
278
280
  accessTitle: "Access",
279
281
  webChat: "Web chat",
282
+ sec_web: "Web",
283
+ webLead: "How the web dashboard behaves. A change that loosens access (longer sessions, the browser terminal back on or for longer, the public link) waits for a fleet admin to confirm it in General; one that tightens it applies at once.",
284
+ webPublicRefused: "Web settings can't be changed over a temporary public link. Open the dashboard at its own address to change them.",
285
+ webSessionTitle: "Sign-in sessions",
286
+ webSessionHint: "For sign-ins at the dashboard's own address (this computer, the LAN, a tailnet). A temporary public link always keeps 4 h / 30 min. Shortening ends the sessions it now outlives; lengthening applies to new sign-ins and to an existing one's idle time on its next use. Empty = the default.",
287
+ sessionAbsolute: "Session length, hours (1–720)",
288
+ sessionIdle: "Sign out after unused for, hours (1–720)",
289
+ sessionRange: "Must be a number of hours from 1 to 720",
290
+ sessionIdleOver: "Must not exceed the session length",
291
+ defaultHours: "{0} (default)",
292
+ webTerminalTitle: "Browser terminal",
293
+ webTerminalHint: "The token-gated terminal /login opens to sign a CLI in. Off: /login refuses. Applies to the next /login.",
294
+ webTerminalEnabled: "Allow the browser terminal",
295
+ webTerminalTtl: "Terminal session minutes (1–20)",
296
+ webTerminalTtlRange: "Must be a whole number from 1 to 20",
297
+ webReadOnlyTitle: "Set in fleet.yaml",
298
+ webReadOnlyLead: "Read-only here: these decide what the dashboard listens on and exposes. Edit fleet.yaml, then run agend reload (web.preview_port and web.preview_origin take a restart of AgEnD).",
299
+ notSet: "not set (default)",
280
300
  defaultsTitle: "Defaults",
281
301
  classicBackend: "ClassicBot default backend",
282
302
  whoUsesClassic: "Who can use ClassicBot",
@@ -420,7 +440,7 @@ register("settings", {
420
440
  crossInstanceVisibility: "Agent 之間的訊息在主題中",
421
441
  crossInstanceVisibilityHint: "full:完整訊息 · summary:一行摘要 · hidden:不顯示。訊息一律照常送達,Mirror Topic 也照常顯示。",
422
442
  replyCompletionGuard: "要求送出回覆",
423
- replyGuardSupported: "Claude Code 與 Kiro legacy/TUI 的 MCP 模式可用。",
443
+ replyGuardSupported: "Claude Code、Codex、Muse 與 Kiro legacy/TUI 的 MCP 模式可用。",
424
444
  replyGuardUnsupported: "設定會保留,但不會生效:{0} 的 {1} 模式不支援回覆遺失補救。",
425
445
  logLevel: "Log 等級",
426
446
  concurrency: "啟動併發數",
@@ -562,7 +582,9 @@ register("settings", {
562
582
  concurrencyWarning: "在較小的主機上可能超出 CPU 負荷",
563
583
  staggerRange: "必須介於 0 到 30000",
564
584
  staggerWarning: "數值太低可能造成啟動尖峰",
565
- sec_device: "這台裝置", deviceLead: "只套用在這個瀏覽器,其他裝置保留各自的設定。",
585
+ sec_device: "偏好設定", syncedTitle: "在你所有的裝置同步", syncedLead: "這裡的變更會套用到你登入的每一台裝置。",
586
+ syncedPublicLink: "公開連結只能讀取這些設定:在這裡變更只會留在這個瀏覽器。",
587
+ deviceTitle: "只限這台裝置", deviceLead: "導覽、文字大小、視圖寬度、側欄顯示或隱藏、面板寬度與桌面通知,每台裝置各自保留。",
566
588
  sec_agents: "Agent",
567
589
  sec_bots: "連線",
568
590
  sec_classic: "ClassicBot",
@@ -622,6 +644,24 @@ register("settings", {
622
644
  keyRollbackFailed: "還原失敗",
623
645
  accessTitle: "存取",
624
646
  webChat: "網頁聊天",
647
+ sec_web: "網頁",
648
+ webLead: "網頁儀表板的行為。放寬存取的變更(延長 session、重新開啟或延長瀏覽器終端機、公開連結)需要 fleet admin 在 General 確認;收緊的變更會立即套用。",
649
+ webPublicRefused: "無法透過臨時公開連結變更網頁設定。請從儀表板本身的網址開啟後再變更。",
650
+ webSessionTitle: "登入 session",
651
+ webSessionHint: "適用於從儀表板本身網址登入的 session(本機、區域網路、tailnet)。臨時公開連結固定為 4 小時 / 30 分鐘。縮短會讓已超過新限制的 session 結束;延長會套用到新的登入,以及既有 session 下次使用時的閒置時間。留空 = 預設值。",
652
+ sessionAbsolute: "Session 時長,小時(1–720)",
653
+ sessionIdle: "閒置多久後登出,小時(1–720)",
654
+ sessionRange: "必須是 1 到 720 之間的小時數",
655
+ sessionIdleOver: "不能超過 session 時長",
656
+ defaultHours: "{0}(預設)",
657
+ webTerminalTitle: "瀏覽器終端機",
658
+ webTerminalHint: "/login 用來登入 CLI 的 token 保護終端機。關閉時 /login 會拒絕。下一次 /login 起生效。",
659
+ webTerminalEnabled: "允許瀏覽器終端機",
660
+ webTerminalTtl: "終端機 session 分鐘數(1–20)",
661
+ webTerminalTtlRange: "必須是 1 到 20 之間的整數",
662
+ webReadOnlyTitle: "在 fleet.yaml 設定",
663
+ webReadOnlyLead: "這裡僅供檢視:這些設定決定儀表板監聽和公開的內容。請編輯 fleet.yaml 後執行 agend reload(web.preview_port 與 web.preview_origin 需要重新啟動 AgEnD)。",
664
+ notSet: "未設定(預設)",
625
665
  defaultsTitle: "預設值",
626
666
  classicBackend: "ClassicBot 預設 backend",
627
667
  whoUsesClassic: "誰可以使用 ClassicBot",