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

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 (172) 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 +2 -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 +9 -1
  27. package/dist/channel/adapters/telegram.js +22 -2
  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/tool-router.js +7 -2
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +11 -1
  37. package/dist/cli.js +55 -16
  38. package/dist/cli.js.map +1 -1
  39. package/dist/command-table.js +1 -1
  40. package/dist/command-table.js.map +1 -1
  41. package/dist/config-validator.js +48 -0
  42. package/dist/config-validator.js.map +1 -1
  43. package/dist/cross-instance-envelope.d.ts +15 -2
  44. package/dist/cross-instance-envelope.js +27 -5
  45. package/dist/cross-instance-envelope.js.map +1 -1
  46. package/dist/daemon.d.ts +36 -0
  47. package/dist/daemon.js +157 -6
  48. package/dist/daemon.js.map +1 -1
  49. package/dist/delivery-outbox.d.ts +6 -0
  50. package/dist/delivery-outbox.js.map +1 -1
  51. package/dist/fleet-manager.d.ts +41 -0
  52. package/dist/fleet-manager.js +218 -5
  53. package/dist/fleet-manager.js.map +1 -1
  54. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  55. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
  56. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
  57. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  58. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  59. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  60. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  61. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  62. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  63. package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
  64. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  65. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  66. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  67. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  68. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
  69. package/dist/general-knowledge/steering/core-rules.md +8 -7
  70. package/dist/instance-avatar.d.ts +36 -0
  71. package/dist/instance-avatar.js +57 -0
  72. package/dist/instance-avatar.js.map +1 -0
  73. package/dist/instance-config-impact.js +5 -0
  74. package/dist/instance-config-impact.js.map +1 -1
  75. package/dist/instance-lifecycle.d.ts +4 -0
  76. package/dist/instance-lifecycle.js +3 -1
  77. package/dist/instance-lifecycle.js.map +1 -1
  78. package/dist/instructions.d.ts +2 -0
  79. package/dist/instructions.js +10 -7
  80. package/dist/instructions.js.map +1 -1
  81. package/dist/locale.js +32 -26
  82. package/dist/locale.js.map +1 -1
  83. package/dist/next-steps.js +1 -1
  84. package/dist/next-steps.js.map +1 -1
  85. package/dist/outbound-handlers.js +7 -4
  86. package/dist/outbound-handlers.js.map +1 -1
  87. package/dist/outbound-schemas.d.ts +8 -0
  88. package/dist/outbound-schemas.js +15 -5
  89. package/dist/outbound-schemas.js.map +1 -1
  90. package/dist/public-web-gateway.js +3 -0
  91. package/dist/public-web-gateway.js.map +1 -1
  92. package/dist/service-activation.js +4 -3
  93. package/dist/service-activation.js.map +1 -1
  94. package/dist/settings-api.js +71 -21
  95. package/dist/settings-api.js.map +1 -1
  96. package/dist/settings-change.js +70 -1
  97. package/dist/settings-change.js.map +1 -1
  98. package/dist/settings-effect.js +26 -4
  99. package/dist/settings-effect.js.map +1 -1
  100. package/dist/settings-http-confirmation.js +7 -0
  101. package/dist/settings-http-confirmation.js.map +1 -1
  102. package/dist/setup-form.d.ts +1 -1
  103. package/dist/setup-form.js +1 -1
  104. package/dist/status-emojis.d.ts +10 -3
  105. package/dist/status-emojis.js +11 -2
  106. package/dist/status-emojis.js.map +1 -1
  107. package/dist/tips.js +4 -4
  108. package/dist/tips.js.map +1 -1
  109. package/dist/transcript-monitor.d.ts +8 -0
  110. package/dist/transcript-monitor.js +26 -2
  111. package/dist/transcript-monitor.js.map +1 -1
  112. package/dist/transcript-sources.d.ts +54 -0
  113. package/dist/transcript-sources.js +188 -27
  114. package/dist/transcript-sources.js.map +1 -1
  115. package/dist/transcript-turns.d.ts +15 -2
  116. package/dist/transcript-turns.js +15 -2
  117. package/dist/transcript-turns.js.map +1 -1
  118. package/dist/types.d.ts +11 -0
  119. package/dist/ui/chat-emojis.js +33 -0
  120. package/dist/ui/chat-render.js +12 -0
  121. package/dist/ui/chat-thread.js +1 -1
  122. package/dist/ui/panel-chat.js +13 -3
  123. package/dist/ui/panel-needs.js +5 -2
  124. package/dist/ui/panel-settings.js +117 -26
  125. package/dist/ui/settings-dialogs.js +7 -2
  126. package/dist/ui/settings-model.js +2 -1
  127. package/dist/ui/settings-strings.js +38 -2
  128. package/dist/ui/shared/app-i18n.js +2 -2
  129. package/dist/ui/shared/app-route.js +1 -1
  130. package/dist/ui/shared/app.css +25 -0
  131. package/dist/ui/shared/instance-nav.js +77 -8
  132. package/dist/ui/shared/signin.js +7 -7
  133. package/dist/ui/shared/view-strings.js +2 -2
  134. package/dist/ui/signin.html +1 -1
  135. package/dist/update-install.d.ts +15 -1
  136. package/dist/update-install.js +6 -0
  137. package/dist/update-install.js.map +1 -1
  138. package/dist/update-preflight.d.ts +100 -0
  139. package/dist/update-preflight.js +264 -0
  140. package/dist/update-preflight.js.map +1 -0
  141. package/dist/updater-caller.d.ts +31 -0
  142. package/dist/updater-caller.js +109 -0
  143. package/dist/updater-caller.js.map +1 -0
  144. package/dist/view-api.d.ts +1 -1
  145. package/dist/view-api.js +33 -8
  146. package/dist/view-api.js.map +1 -1
  147. package/dist/wake-coordinator.d.ts +11 -0
  148. package/dist/wake-coordinator.js +38 -4
  149. package/dist/wake-coordinator.js.map +1 -1
  150. package/dist/web-api.d.ts +11 -0
  151. package/dist/web-api.js +54 -0
  152. package/dist/web-api.js.map +1 -1
  153. package/dist/web-auth.d.ts +2 -2
  154. package/dist/web-auth.js +5 -3
  155. package/dist/web-auth.js.map +1 -1
  156. package/dist/web-host-guard.d.ts +3 -11
  157. package/dist/web-host-guard.js +3 -12
  158. package/dist/web-host-guard.js.map +1 -1
  159. package/dist/web-images.d.ts +47 -0
  160. package/dist/web-images.js +169 -0
  161. package/dist/web-images.js.map +1 -0
  162. package/dist/web-session.d.ts +36 -1
  163. package/dist/web-session.js +52 -5
  164. package/dist/web-session.js.map +1 -1
  165. package/dist/web-settings-patch.d.ts +21 -0
  166. package/dist/web-settings-patch.js +74 -0
  167. package/dist/web-settings-patch.js.map +1 -0
  168. package/dist/web-shell-routes.d.ts +1 -1
  169. package/dist/web-shell-routes.js +1 -1
  170. package/dist/web-shell-routes.js.map +1 -1
  171. package/dist/workflow-templates/default.md +1 -1
  172. package/package.json +1 -1
@@ -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;
@@ -570,17 +571,13 @@ function Classic({ ctx, search, openDialog }) {
570
571
  * ClassicBot defaults, web) — each only with what differs from what this form showed, so changing one hot default
571
572
  * never also stages untouched settings as a fleet restart.
572
573
  */
573
- function General({ ctx, staged }) {
574
+ function General({ ctx }) {
574
575
  const { schema } = ctx;
575
576
  const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
576
577
  const hang = d.hang_detector || {}, startup = d.startup || {};
577
578
  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) }), []);
579
+ const original = useMemo(() => ({ c: structuredClone(c) }), []);
582
580
  const [f, setF] = useState(() => ({
583
- echo: originalEcho, pub: originalPublic.allow_public, ttl: String(originalPublic.ttl_minutes), protocol: originalPublic.protocol,
584
581
  backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
585
582
  agent_mode: d.agent_mode || "mcp", tool_set: d.tool_set || "full", tool_progress: d.tool_progress ?? "off",
586
583
  visibility: visibilityDefault(d), reply: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
@@ -598,7 +595,6 @@ function General({ ctx, staged }) {
598
595
  auto: nonNegative(f.auto), hang: positive(f.hang),
599
596
  concurrency: !Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20 ? "concurrencyRange" : "",
600
597
  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
598
  };
603
599
  const warnings = { concurrency: num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
604
600
  const ready = !Object.values(errors).some(Boolean);
@@ -638,29 +634,12 @@ function General({ ctx, staged }) {
638
634
  ctx.stage("defaults:classic", { label: tn("updateClassicDefaults"), impact: hotOnly ? "now" : "fleet",
639
635
  request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } });
640
636
  }
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
637
  setMsg(tn("readyApply"));
649
638
  };
650
639
  const L = (text, field, id) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} />`;
651
640
  const fleetReply = replyGuardSupported(f.backend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.backend, f.agent_mode);
652
641
  const classicReply = replyGuardSupported(f.classicBackend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.classicBackend, f.agent_mode);
653
642
  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
643
  <section class="card"><h3>${tn("defaultsTitle")}</h3>
665
644
  <div class="grid2">
666
645
  <div class="field">${L(tn("language"), "defaults.locale", "g-locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
@@ -714,6 +693,118 @@ function General({ ctx, staged }) {
714
693
  </div>`;
715
694
  }
716
695
 
696
+ /**
697
+ * #1620: Settings → Web — the dashboard's own settings, in one write (PUT /api/settings/fleet/web). Editable: the local
698
+ * session limits, the browser terminal (on/off, its minutes), web echo and the public link's menu; everything else
699
+ * under web: / web_terminal: is listed read-only (it decides what the dashboard listens on and exposes: fleet.yaml, then
700
+ * agend reload). The server judges each change: loosening one waits for a fleet admin in General, tightening applies at
701
+ * once. Over
702
+ * the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
703
+ */
704
+ export const WEB_READ_ONLY = [
705
+ "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin",
706
+ "web.usage_panel", "web.provider_secrets", "web_terminal.bind", "web_terminal.tunnel",
707
+ ];
708
+ function WebSection({ ctx, staged }) {
709
+ const { schema } = ctx;
710
+ const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
711
+ const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
712
+ const original = useMemo(() => ({
713
+ echo: web.echo_to_channel ?? true,
714
+ 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" },
715
+ session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
716
+ terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
717
+ }), []);
718
+ const text = (v) => (v === null || v === undefined ? "" : String(v));
719
+ const [f, setF] = useState(() => ({
720
+ echo: original.echo, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
721
+ abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
722
+ termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
723
+ }));
724
+ const [msg, setMsg] = useState(null);
725
+ const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
726
+ // Empty = the default (null in the write: the key is removed from fleet.yaml).
727
+ const value = (v) => (v.trim() === "" ? null : Number(v));
728
+ const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
729
+ const abs = value(f.abs) ?? 12, idle = value(f.idle) ?? 2;
730
+ const errors = {
731
+ ttl: !Number.isInteger(Number(f.ttl)) || Number(f.ttl) < 1 || Number(f.ttl) > 480 ? "publicTtl" : "",
732
+ abs: hours(f.abs),
733
+ idle: hours(f.idle) || (!hours(f.abs) && idle > abs ? "sessionIdleOver" : ""),
734
+ termTtl: value(f.termTtl) !== null && (!Number.isInteger(value(f.termTtl)) || value(f.termTtl) < 1 || value(f.termTtl) > 20) ? "webTerminalTtlRange" : "",
735
+ };
736
+ const ready = !overPublicLink && !Object.values(errors).some(Boolean);
737
+ const review = () => {
738
+ if (!ready) return;
739
+ const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol }, original.pub);
740
+ const sessionPatch = changedFields({ absolute_hours: value(f.abs), idle_hours: value(f.idle) }, original.session);
741
+ // The checkbox shows the default (on) for an unset key: only a real toggle is written; on again = back to the default.
742
+ const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
743
+ ttl_minutes: value(f.termTtl) }, original.terminal);
744
+ const body = {
745
+ ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
746
+ ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
747
+ ...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
748
+ ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
749
+ };
750
+ const fields = [
751
+ ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
752
+ ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
753
+ ...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
754
+ ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
755
+ ];
756
+ if (fields.length) {
757
+ ctx.stage("web", { label: tn("sec_web"), impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
758
+ request: { method: "PUT", url: "/api/settings/fleet/web", body } });
759
+ } else if (staged.has("web")) ctx.unstage("web");
760
+ setMsg(tn("readyApply"));
761
+ };
762
+ const shown = (path) => {
763
+ const [top, key] = path.split(".");
764
+ const v = (top === "web" ? web : term)[key];
765
+ return v === undefined ? tn("notSet") : typeof v === "object" ? JSON.stringify(v) : String(v);
766
+ };
767
+ return html`<div class="form s-web">
768
+ <p class="sub">${tn("webLead")}</p>
769
+ ${overPublicLink ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
770
+ <fieldset class="s-web-fields" disabled=${overPublicLink}>
771
+ <section class="card"><h3>${tn("webSessionTitle")}</h3>
772
+ <p class="note">${tn("webSessionHint")}</p>
773
+ <div class="grid2">
774
+ <div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 12)}
775
+ class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} /></div>
776
+ <div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 2)}
777
+ class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} /></div>
778
+ </div>
779
+ </section>
780
+ <section class="card"><h3>${tn("webTerminalTitle")}</h3>
781
+ <p class="note">${tn("webTerminalHint")}</p>
782
+ <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>
783
+ <div class="grid2">
784
+ <div class="field"><label for="webTerminalTtl">${tn("webTerminalTtl")}</label><input id="webTerminalTtl" type="number" min="1" max="20" step="1" placeholder=${tn("defaultHours", 10)}
785
+ class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl} /></div>
786
+ </div>
787
+ </section>
788
+ <section class="card"><h3>${tn("webChat")}</h3>
789
+ <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>
790
+ <p class="note">${tn("webEchoHint")}</p>
791
+ <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>
792
+ <p class="note">${tn("publicLinkHint")}</p>
793
+ <div class="grid2">
794
+ <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>
795
+ <div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
796
+ </div>
797
+ </section>
798
+ </fieldset>
799
+ <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
800
+ <p class="note">${tn("webReadOnlyLead")}</p>
801
+ <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>
802
+ </section>
803
+ <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
804
+ ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
805
+ </div>`;
806
+ }
807
+
717
808
  /** Developer: the fleet model as YAML or JSON, to read, copy, download — or edit and apply as one operation. */
718
809
  function Developer({ ctx }) {
719
810
  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",
@@ -277,6 +277,24 @@ register("settings", {
277
277
  keyRollbackFailed: "Rollback failed",
278
278
  accessTitle: "Access",
279
279
  webChat: "Web chat",
280
+ sec_web: "Web",
281
+ 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.",
282
+ webPublicRefused: "Web settings can't be changed over a temporary public link. Open the dashboard at its own address to change them.",
283
+ webSessionTitle: "Sign-in sessions",
284
+ 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.",
285
+ sessionAbsolute: "Session length, hours (1–720)",
286
+ sessionIdle: "Sign out after unused for, hours (1–720)",
287
+ sessionRange: "Must be a number of hours from 1 to 720",
288
+ sessionIdleOver: "Must not exceed the session length",
289
+ defaultHours: "{0} (default)",
290
+ webTerminalTitle: "Browser terminal",
291
+ webTerminalHint: "The token-gated terminal /login opens to sign a CLI in. Off: /login refuses. Applies to the next /login.",
292
+ webTerminalEnabled: "Allow the browser terminal",
293
+ webTerminalTtl: "Terminal session minutes (1–20)",
294
+ webTerminalTtlRange: "Must be a whole number from 1 to 20",
295
+ webReadOnlyTitle: "Set in fleet.yaml",
296
+ 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).",
297
+ notSet: "not set (default)",
280
298
  defaultsTitle: "Defaults",
281
299
  classicBackend: "ClassicBot default backend",
282
300
  whoUsesClassic: "Who can use ClassicBot",
@@ -420,7 +438,7 @@ register("settings", {
420
438
  crossInstanceVisibility: "Agent 之間的訊息在主題中",
421
439
  crossInstanceVisibilityHint: "full:完整訊息 · summary:一行摘要 · hidden:不顯示。訊息一律照常送達,Mirror Topic 也照常顯示。",
422
440
  replyCompletionGuard: "要求送出回覆",
423
- replyGuardSupported: "Claude Code 與 Kiro legacy/TUI 的 MCP 模式可用。",
441
+ replyGuardSupported: "Claude Code、Codex、Muse 與 Kiro legacy/TUI 的 MCP 模式可用。",
424
442
  replyGuardUnsupported: "設定會保留,但不會生效:{0} 的 {1} 模式不支援回覆遺失補救。",
425
443
  logLevel: "Log 等級",
426
444
  concurrency: "啟動併發數",
@@ -622,6 +640,24 @@ register("settings", {
622
640
  keyRollbackFailed: "還原失敗",
623
641
  accessTitle: "存取",
624
642
  webChat: "網頁聊天",
643
+ sec_web: "網頁",
644
+ webLead: "網頁儀表板的行為。放寬存取的變更(延長 session、重新開啟或延長瀏覽器終端機、公開連結)需要 fleet admin 在 General 確認;收緊的變更會立即套用。",
645
+ webPublicRefused: "無法透過臨時公開連結變更網頁設定。請從儀表板本身的網址開啟後再變更。",
646
+ webSessionTitle: "登入 session",
647
+ webSessionHint: "適用於從儀表板本身網址登入的 session(本機、區域網路、tailnet)。臨時公開連結固定為 4 小時 / 30 分鐘。縮短會讓已超過新限制的 session 結束;延長會套用到新的登入,以及既有 session 下次使用時的閒置時間。留空 = 預設值。",
648
+ sessionAbsolute: "Session 時長,小時(1–720)",
649
+ sessionIdle: "閒置多久後登出,小時(1–720)",
650
+ sessionRange: "必須是 1 到 720 之間的小時數",
651
+ sessionIdleOver: "不能超過 session 時長",
652
+ defaultHours: "{0}(預設)",
653
+ webTerminalTitle: "瀏覽器終端機",
654
+ webTerminalHint: "/login 用來登入 CLI 的 token 保護終端機。關閉時 /login 會拒絕。下一次 /login 起生效。",
655
+ webTerminalEnabled: "允許瀏覽器終端機",
656
+ webTerminalTtl: "終端機 session 分鐘數(1–20)",
657
+ webTerminalTtlRange: "必須是 1 到 20 之間的整數",
658
+ webReadOnlyTitle: "在 fleet.yaml 設定",
659
+ webReadOnlyLead: "這裡僅供檢視:這些設定決定儀表板監聽和公開的內容。請編輯 fleet.yaml 後執行 agend reload(web.preview_port 與 web.preview_origin 需要重新啟動 AgEnD)。",
660
+ notSet: "未設定(預設)",
625
661
  defaultsTitle: "預設值",
626
662
  classicBackend: "ClassicBot 預設 backend",
627
663
  whoUsesClassic: "誰可以使用 ClassicBot",
@@ -44,7 +44,7 @@ register("app", {
44
44
  previews: "Allow HTML previews on this device", tour: "Tour", tourTitle: "Show the web chat tour again",
45
45
  needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
46
46
  textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
47
- statusRunning: "running", statusStopped: "stopped", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
47
+ statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
48
48
  connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
49
49
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
50
50
  skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", loading: "Loading…",
@@ -73,7 +73,7 @@ register("app", {
73
73
  previews: "允許這台裝置預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
74
74
  needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
75
75
  textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
76
- statusRunning: "執行中", statusStopped: "已停止", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
76
+ statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
77
77
  connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
78
78
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
79
79
  skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", loading: "載入中…",
@@ -2,7 +2,7 @@
2
2
  // a test runs both over one list of cases). Pure: no DOM, no history.
3
3
 
4
4
  export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
5
- export const SETTINGS_SECTIONS = ["device", "agents", "bots", "classic", "general", "advanced"]; // #1604: device first
5
+ export const SETTINGS_SECTIONS = ["device", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
6
6
  const MAX_INSTANCE_NAME = 128;
7
7
  const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
8
8
 
@@ -103,6 +103,22 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
103
103
  .inst:hover { background: var(--surface-2); }
104
104
  .inst.active { background: var(--surface-2); }
105
105
  .inst .dot { margin-top: 8px; }
106
+ /* #1630: the avatar (a picture, an emoji or the first character), its status dot, and an optional ring in its bot's colour (--ring, set through the CSSOM). */
107
+ .av { position: relative; flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--surface-2); color: var(--text-2);
108
+ display: inline-flex; align-items: center; justify-content: center; font-size: var(--fs-sm); font-weight: var(--fw-semibold); line-height: 1; }
109
+ .av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
110
+ .av.av-emoji { background: transparent; font-size: 18px; font-weight: normal; }
111
+ .av.av-emoji img { width: 22px; height: 22px; border-radius: 0; object-fit: contain; }
112
+ .av.ring { box-shadow: 0 0 0 2px var(--ring, transparent); }
113
+ .av-text { overflow: hidden; }
114
+ .av .av-dot { position: absolute; right: -2px; bottom: -2px; margin: 0; box-shadow: 0 0 0 2px var(--bg-sidebar); }
115
+ .inst.active .av .av-dot, .inst:hover .av .av-dot { box-shadow: 0 0 0 2px var(--surface-2); }
116
+ .dot.hollow { background: transparent; border: 1.5px solid var(--text-3); }
117
+ /* On a grey avatar a grey dot needs more contrast: stopped is a stronger grey, paused a clear ring around the sidebar colour. */
118
+ .av .av-dot.off { background: var(--text-2); }
119
+ .av .av-dot.hollow { box-sizing: border-box; width: 10px; height: 10px; background: var(--bg-sidebar); border: 2.5px solid var(--text-2); }
120
+ .inst .av { margin-top: 1px; }
121
+ .inst-id { font-family: var(--font-mono); }
106
122
  .inst-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
107
123
  .inst-name { font-size: var(--fs-sm); line-height: var(--lh-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108
124
  .inst.active .inst-name { font-weight: var(--fw-semibold); }
@@ -128,6 +144,13 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
128
144
  .s-device { display: flex; flex-direction: column; gap: var(--s-3); }
129
145
  .s-device .sub { margin: 0; color: var(--text-3); font-size: var(--fs-sm); }
130
146
  .s-device-row { display: flex; gap: var(--s-2); }
147
+ /* #1620: Settings → Web — the lead, and the read-only list of what is set in fleet.yaml. */
148
+ .s-web .sub { margin: 0; color: var(--text-3); font-size: var(--fs-sm); }
149
+ .s-web-fields:disabled select { opacity: 0.6; } /* over the public link: dimmed like the inputs (.field input:disabled) */
150
+ .s-web-ro { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s-1) var(--s-3); margin: 0; font-size: var(--fs-sm); }
151
+ .s-web-ro > div { display: contents; }
152
+ .s-web-ro dt { color: var(--text-3); }
153
+ .s-web-ro dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
131
154
  .prefs { display: flex; gap: var(--s-2); padding: var(--s-1) var(--s-3); }
132
155
  .pref { display: flex; align-items: center; gap: var(--s-1); color: var(--text-2); flex: 1; min-width: 0; }
133
156
  .pref select { flex: 1; min-width: 0; height: 30px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text-2); font-size: var(--fs-xs); cursor: pointer; }
@@ -934,3 +957,5 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
934
957
  .cmd-palette .cmd-desc { white-space: normal; flex-basis: 100%; }
935
958
  .cmd-choices .btn, .quick-actions .btn { min-height: var(--tap); }
936
959
  }
960
+ /* #1630: a Discord server emoji in a chat message, the size of the text around it. */
961
+ .md img.emoji { display: inline-block; width: 1.375em; height: 1.375em; object-fit: contain; vertical-align: -0.3em; margin: 0 0.05em; }
@@ -7,7 +7,7 @@
7
7
  // - a row opens the view you are in (View → View, everywhere else → Chat);
8
8
  // - the list is never remounted on navigation, and only its own scrollTop follows the active row (#1515).
9
9
  // Public (/assets/): an anonymous View reader gets the same list, fed by View's roster read instead of status frames.
10
- import { html, useLayoutEffect, useRef } from "./app-html.js";
10
+ import { html, useLayoutEffect, useRef, useState } from "./app-html.js";
11
11
  import { t } from "./app-i18n.js";
12
12
  import { appStore, createStore, useStore } from "./app-store.js";
13
13
  import { chatPath, detailsPath, viewPath } from "./app-route.js";
@@ -177,11 +177,76 @@ function persistOrder(groupNames, instByGroup) {
177
177
  navStore.set({ order: { groups: new Map(groupNames.map((g, i) => [g, i])), insts: new Map(rows.filter((r) => r.item_type === "instance").map((r) => [r.item_name, r.sort_index])) } });
178
178
  }
179
179
 
180
+ // ── #1630: what stands for an instance (design: the comment on #1630) ──
181
+ // The server decides (the status frame's `avatar` / `accent`); the page only draws it. The anonymous View roster has
182
+ // neither: its rows get the first character. A picture is only ever a same-origin /ui/img/ path — never a platform's
183
+ // CDN (CSP img-src stays 'self'); one that fails to load falls back to the first character, never a broken image.
184
+
185
+ const GRAPHEMES = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
186
+ /** The first user-perceived character, upper-cased where that means anything. */
187
+ export function firstCharacter(text) {
188
+ const s = String(text == null ? "" : text).trim();
189
+ if (!s) return "?";
190
+ if (GRAPHEMES) for (const { segment } of GRAPHEMES.segment(s)) return segment.toLocaleUpperCase();
191
+ return Array.from(s)[0].toLocaleUpperCase();
192
+ }
193
+ /** What a row is called: the display name when it has one, else the instance name (shown on hover and in Details). */
194
+ export function labelOf(it) {
195
+ const d = it && typeof it.display_name === "string" ? it.display_name.trim() : "";
196
+ return d || nameOf(it);
197
+ }
198
+ // One path segment under /ui/img/<kind>/ — no further slash or backslash, and not a dot segment ("." / ".." or their
199
+ // %2e spellings, which a browser resolves too): the picture can only ever be one of those routes (#1642 review).
200
+ const sameOrigin = (src) => typeof src === "string"
201
+ && /^\/ui\/img\/(bot|avatar|emoji)\/(?!(?:\.|%2[eE]){1,2}(?:\?|$))[^/\\?#\s]+(\?v=[0-9A-Za-z%._-]*)?$/.test(src);
202
+ /** The avatar to draw: the server's, if it is one the page may load; else the first character. */
203
+ export function avatarOf(it) {
204
+ const a = it && it.avatar;
205
+ if (a && a.kind === "image" && sameOrigin(a.src)) return { kind: "image", src: a.src };
206
+ if (a && a.kind === "emoji" && sameOrigin(a.src)) return { kind: "emoji", src: a.src, alt: typeof a.alt === "string" ? a.alt : "" };
207
+ if (a && a.kind === "emoji" && typeof a.text === "string" && a.text.trim()) return { kind: "emoji", text: a.text.trim() };
208
+ return { kind: "initial", text: a && a.kind === "initial" && typeof a.text === "string" && a.text ? a.text : firstCharacter(labelOf(it)) };
209
+ }
210
+ const ACCENT = /^#[0-9a-f]{6}$/i;
211
+ /** The status dot on the avatar: needs you, crashed, paused, stopped, looks stuck, working, idle — colour AND a label. */
212
+ export function dotState(it, exec, awaiting) {
213
+ if (awaiting != null) return "needs";
214
+ if (!it || it.status === "crashed") return "crashed";
215
+ if (it.status === "paused") return "paused";
216
+ if (it.status !== "running") return "stopped";
217
+ return exec === "stuck" ? "stuck" : exec === "working" ? "working" : "idle";
218
+ }
219
+ const DOT = {
220
+ needs: ["warn", () => t("app.needsYou")], crashed: ["bad", () => t("app.statusCrashed")], paused: ["off hollow", () => t("app.statusPaused")],
221
+ stopped: ["off", () => t("app.statusStopped")], stuck: ["warn", () => t("app.statusStuck")], working: ["busy", () => t("app.statusWorking")],
222
+ idle: ["ok", () => t("app.statusIdle")],
223
+ };
224
+ export function dotLabel(state) { return (DOT[state] || DOT.stopped)[1](); }
225
+
226
+ /** An instance's avatar with its status dot. The ring (its bot's colour) is a CSS custom property set through the CSSOM. */
227
+ export function InstanceAvatar({ it, state }) {
228
+ const a = avatarOf(it);
229
+ const [broken, setBroken] = useState("");
230
+ const el = useRef(null);
231
+ const accent = typeof it.accent === "string" && ACCENT.test(it.accent) && a.kind === "image" ? it.accent : "";
232
+ useLayoutEffect(() => {
233
+ const node = el.current;
234
+ if (!node || !node.style) return;
235
+ if (accent) node.style.setProperty("--ring", accent); else node.style.removeProperty("--ring");
236
+ }, [accent]);
237
+ const src = a.kind !== "initial" && a.src && broken !== a.src ? a.src : "";
238
+ const text = a.kind === "emoji" && a.text ? a.text : a.kind === "initial" ? a.text : firstCharacter(labelOf(it));
239
+ const [cls] = DOT[state] || DOT.stopped;
240
+ const label = dotLabel(state);
241
+ return html`<span ref=${el} class=${`av${accent ? " ring" : ""}${a.kind === "emoji" ? " av-emoji" : ""}`}>
242
+ ${src ? html`<img src=${src} alt="" decoding="async" onError=${() => setBroken(src)} />` : html`<span class="av-text" aria-hidden="true">${text}</span>`}
243
+ <span class=${`av-dot dot ${cls}`} role="img" aria-label=${label} title=${label}></span></span>`;
244
+ }
245
+
180
246
  // ── The list ──
181
247
 
182
248
  const statusText = (facet) => (facet === "needs" ? t("app.needsYou") : facet === "working" ? t("app.statusWorking") : facet === "idle" ? t("app.statusIdle")
183
249
  : facet === "crashed" ? t("app.statusCrashed") : t("app.statusStopped"));
184
- const dotClass = (facet, exec) => (facet === "needs" ? "warn" : facet === "crashed" ? "bad" : facet === "stopped" ? "off" : exec === "stuck" ? "warn" : facet === "working" ? "busy" : "ok");
185
250
 
186
251
  /**
187
252
  * The sidebar's instance list. `route`: the page's (which instance is open, and which view a row opens). `items`: the
@@ -207,6 +272,9 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
207
272
  const f = nav.filter;
208
273
  const shown = filterSidebar(groupNames, instByGroup, byName, f.q, backendLabel, { status: f.status, cli: f.cli, statusOf: facetOf });
209
274
  const backends = [...new Set(items.map((it) => it.backend).filter((b) => typeof b === "string" && b))].sort((a, b) => backendLabel(a).localeCompare(backendLabel(b)));
275
+ // #1630: a display name two rows share would tell them apart from nothing — only then does the row keep its name too.
276
+ const labelCount = new Map();
277
+ for (const it of items) { const l = labelOf(it); labelCount.set(l, (labelCount.get(l) || 0) + 1); }
210
278
  const toggle = (g) => { const c = new Set(navStore.get().collapsed); if (c.has(g)) c.delete(g); else c.add(g); setCollapsed(c); };
211
279
  // From the store, not this render's copy: two clicks before the next render must both count.
212
280
  const flip = (key, value) => { const cur = new Set(navStore.get().filter[key]); if (cur.has(value)) cur.delete(value); else cur.add(value); setNavFilter({ [key]: [...cur] }); };
@@ -257,17 +325,18 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
257
325
  <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${g}</span><span class="count">${names.length}</span></button>
258
326
  ${folded ? null : html`<ul class="inst-list">${names.map((name) => {
259
327
  const it = byName.get(name);
260
- const alias = typeof it.display_name === "string" && it.display_name.trim() && it.display_name.trim() !== name ? it.display_name.trim() : "";
328
+ const label = labelOf(it);
329
+ // The instance name under a display name only when another row has the same display name.
330
+ const twin = label !== name && (labelCount.get(label) || 0) > 1 ? name : "";
261
331
  const facet = facetOf(name);
262
332
  const waiting = own(awaiting, name) ? awaiting[name] : null;
263
333
  const on = name === active;
264
334
  return html`<li key=${name}><a class=${`inst v-inst${on ? " active" : ""}`} href=${rowPath(name)} title=${instanceTooltip(it)}
265
335
  aria-current=${on ? "page" : undefined} onClick=${onPick} ...${dragProps({ type: "instance", name, group: g })}>
266
- <span class=${`dot ${dotClass(facet, own(exec, name) ? exec[name] : null)}`} aria-hidden="true"></span>
267
- <span class="inst-text"><span class="inst-name">${name}</span>
268
- ${alias || facet === "needs" ? html`<span class="inst-sub">${alias ? html`<span class="inst-alias">${alias}</span>` : null}
269
- ${facet === "needs" ? html`<span class="badge-await" title=${waiting || t("app.approxNote")}>${t("app.needsYou")}</span>` : null}</span>` : null}
270
- <span class="sr-only">${statusText(facet)}</span></span>
336
+ <${InstanceAvatar} it=${it} state=${dotState(it, own(exec, name) ? exec[name] : null, waiting)} />
337
+ <span class="inst-text"><span class="inst-name">${label}</span>
338
+ ${twin || facet === "needs" ? html`<span class="inst-sub">${twin ? html`<span class="inst-alias inst-id">${twin}</span>` : null}
339
+ ${facet === "needs" ? html`<span class="badge-await" title=${waiting || t("app.approxNote")}>${t("app.needsYou")}</span>` : null}</span>` : null}</span>
271
340
  <span class="v-meta">${it.context_pct != null ? `${Math.round(it.context_pct)}%` : ""}</span>
272
341
  <span class=${`cli-icon cli-${KNOWN_BACKENDS.has(it.backend) ? it.backend : "other"}`} title=${backendLabel(it.backend)} aria-label=${tn("tooltipBackend", { value: backendLabel(it.backend) })}></span>
273
342
  </a></li>`;
@@ -4,7 +4,7 @@
4
4
  // (a SameSite=Strict cookie is not sent on a navigation that came from a chat app), probe the session
5
5
  // from *here* — a same-site request does carry the cookie — and carry on to the page that was asked
6
6
  // for; (2) otherwise take the one-time code and exchange it. A browser that signed in before with a code from
7
- // /dashboard (a returning device, #1570) is also offered "Send me a new code": the server sends it privately to the
7
+ // /web (a returning device, #1570) is also offered "Send me a new code": the server sends it privately to the
8
8
  // person who received that earlier code — the page never learns who that is.
9
9
  (() => {
10
10
  "use strict";
@@ -13,24 +13,24 @@
13
13
  en: {
14
14
  checking: "Checking your session…", title: "Sign in to AgEnD", lead: "Enter the one-time code from your chat channel.",
15
15
  label: "One-time code", submit: "Sign in", working: "Signing in…",
16
- hint: "Send /dashboard to your AgEnD bot, or run “agend web --code” on the host. A code works once and expires in 5 minutes.",
17
- refused: "That code did not work — it may be wrong, expired or already used. Ask for a new one with /dashboard.",
16
+ hint: "Send /web to your AgEnD bot, or run “agend web --code” on the host. A code works once and expires in 5 minutes.",
17
+ refused: "That code did not work — it may be wrong, expired or already used. Ask for a new one with /web.",
18
18
  paused: "Too many wrong codes — sign-in is paused for a few minutes.",
19
19
  network: "Could not reach AgEnD. Check the connection and try again.",
20
20
  resend: "Send me a new code", resent: "A new code was sent to you privately in chat. Enter it above.",
21
21
  resend_wait: "A code was asked for a moment ago — wait a little and try again.",
22
- resend_refused: "A new code could not be sent. Ask for one with /dashboard.",
22
+ resend_refused: "A new code could not be sent. Ask for one with /web.",
23
23
  },
24
24
  "zh-TW": {
25
25
  checking: "正在確認登入狀態…", title: "登入 AgEnD", lead: "請輸入聊天頻道給你的一次性登入碼。",
26
26
  label: "一次性登入碼", submit: "登入", working: "登入中…",
27
- hint: "在 AgEnD bot 傳 /dashboard,或在主機執行「agend web --code」。登入碼只能用一次,5 分鐘內有效。",
28
- refused: "登入碼無效:可能輸入錯誤、已過期或已使用。請用 /dashboard 再要一個。",
27
+ hint: "在 AgEnD bot 傳 /web,或在主機執行「agend web --code」。登入碼只能用一次,5 分鐘內有效。",
28
+ refused: "登入碼無效:可能輸入錯誤、已過期或已使用。請用 /web 再要一個。",
29
29
  paused: "錯誤次數過多,登入暫停幾分鐘。",
30
30
  network: "連不上 AgEnD,請檢查連線後再試。",
31
31
  resend: "傳一組新的登入碼給我", resent: "新的登入碼已私訊給你,請在上方輸入。",
32
32
  resend_wait: "剛剛才要過登入碼,請稍候再試。",
33
- resend_refused: "無法傳送新的登入碼。請用 /dashboard 再要一個。",
33
+ resend_refused: "無法傳送新的登入碼。請用 /web 再要一個。",
34
34
  },
35
35
  };
36
36
  const lang = /^zh/i.test(navigator.language || "") ? "zh-TW" : "en";
@@ -18,7 +18,7 @@ register("view", {
18
18
  "displayName": "Display name",
19
19
  "role": "Role",
20
20
  "description": "Description",
21
- "saveHint": "Saving needs a signed-in session. Send /dashboard to your AgEnD bot for a sign-in code.",
21
+ "saveHint": "Saving needs a signed-in session. Send /web to your AgEnD bot for a sign-in code.",
22
22
  "helpTitle": "📖 Help",
23
23
  "help1": "Pick an instance in the list: its live terminal shows here.",
24
24
  "help2": "Click a group's name to fold it or open it again.",
@@ -180,7 +180,7 @@ register("view", {
180
180
  "displayName": "顯示名稱",
181
181
  "role": "角色",
182
182
  "description": "描述",
183
- "saveHint": "儲存需要已登入的 session。請在 AgEnD bot 傳 /dashboard 取得登入碼。",
183
+ "saveHint": "儲存需要已登入的 session。請在 AgEnD bot 傳 /web 取得登入碼。",
184
184
  "helpTitle": "📖 使用說明",
185
185
  "help1": "在清單裡選一個 instance:它的即時終端畫面會顯示在這裡。",
186
186
  "help2": "點群組名稱可以收合或展開。",
@@ -49,7 +49,7 @@
49
49
  <button id="go" type="submit" data-i18n="submit">Sign in</button>
50
50
  <button id="resend" type="button" class="secondary" hidden data-i18n="resend">Send me a new code</button>
51
51
  <div id="msg" role="alert" aria-live="polite"></div>
52
- <div class="hint" data-i18n="hint">Send /dashboard to your AgEnD bot, or run “<code>agend web --code</code>” on the host. A code works once and expires in 5 minutes.</div>
52
+ <div class="hint" data-i18n="hint">Send /web to your AgEnD bot, or run “<code>agend web --code</code>” on the host. A code works once and expires in 5 minutes.</div>
53
53
  </form>
54
54
  <noscript>This page needs JavaScript to sign you in.</noscript>
55
55
  </main>
@@ -49,6 +49,20 @@ export interface UpdateInstallPlan {
49
49
  * on that exact selection, never on a fresh `nvm use 22` that could pick another Node 22 in between (#1472 review).
50
50
  */
51
51
  nvmBin?: string;
52
+ /**
53
+ * #1441 B: decide whether the target can run, in the install environment, before the lock and npm — called once the
54
+ * install environment is fixed (for an nvm install: after the exact Node 22 is selected). `run` runs argv there, the
55
+ * way the install does; `npm` is the npm binary the install invokes. A refusal stops the update with nothing changed.
56
+ */
57
+ preflight?(env: {
58
+ run(argv: string[], timeoutMs: number): CommandResult;
59
+ npm: string;
60
+ }): {
61
+ ok: true;
62
+ } | {
63
+ ok: false;
64
+ message: string;
65
+ };
52
66
  }
53
67
  export type UpdateInstallOutcome = {
54
68
  ok: true;
@@ -67,7 +81,7 @@ export type UpdateInstallOutcome = {
67
81
  npmPath?: string;
68
82
  } | {
69
83
  ok: false;
70
- stage: "lock" | "install" | "verify";
84
+ stage: "preflight" | "lock" | "install" | "verify";
71
85
  message: string;
72
86
  };
73
87
  /**
@@ -197,6 +197,12 @@ export function runUpdateInstall(plan, runner) {
197
197
  // The npm binary to invoke: for nvm installs, the absolute path from nvmBin so that
198
198
  // every npm call uses the same binary that will be recorded in the outcome.
199
199
  const npmBin = resolvedNpmPath ?? "npm";
200
+ if (plan.preflight) {
201
+ const fixed = plan;
202
+ const verdict = plan.preflight({ run: (argv, timeoutMs) => inInstallEnv(runner, fixed, argv, { timeoutMs }), npm: npmBin });
203
+ if (!verdict.ok)
204
+ return { ok: false, stage: "preflight", message: verdict.message };
205
+ }
200
206
  // C1: lock the prefix npm is about to change — read in the install environment, as npm itself sees it — before npm.
201
207
  // #1487: this updater never unlinks first, so a refused install keeps the previous one; the install hooks read this to
202
208
  // tell that apart from AgEnD 2.1's updater, which removes it before installing (launcher/old-updater-note.cjs).