@songsid/agend 2.1.7-beta.5 → 2.1.7-beta.7

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 (63) hide show
  1. package/dist/backend/codex.d.ts +34 -9
  2. package/dist/backend/codex.js +197 -33
  3. package/dist/backend/codex.js.map +1 -1
  4. package/dist/backend/grok.js +51 -0
  5. package/dist/backend/grok.js.map +1 -1
  6. package/dist/channel/adapters/discord.d.ts +11 -0
  7. package/dist/channel/adapters/discord.js +16 -2
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/channel/types.d.ts +2 -0
  10. package/dist/cli.js +79 -132
  11. package/dist/cli.js.map +1 -1
  12. package/dist/completion-install.d.ts +32 -0
  13. package/dist/completion-install.js +125 -1
  14. package/dist/completion-install.js.map +1 -1
  15. package/dist/config-validator.js +45 -0
  16. package/dist/config-validator.js.map +1 -1
  17. package/dist/daemon.d.ts +4 -1
  18. package/dist/daemon.js +23 -6
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/delivery-outbox.d.ts +109 -0
  21. package/dist/delivery-outbox.js +306 -10
  22. package/dist/delivery-outbox.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +67 -7
  24. package/dist/fleet-manager.js +215 -59
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +10 -0
  27. package/dist/instance-config-impact.js +5 -0
  28. package/dist/instance-config-impact.js.map +1 -1
  29. package/dist/instance-lifecycle.d.ts +10 -2
  30. package/dist/instance-lifecycle.js +50 -32
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/instructions.d.ts +17 -0
  33. package/dist/instructions.js +24 -1
  34. package/dist/instructions.js.map +1 -1
  35. package/dist/locale.js +2 -0
  36. package/dist/locale.js.map +1 -1
  37. package/dist/ls-rows.d.ts +50 -0
  38. package/dist/ls-rows.js +152 -0
  39. package/dist/ls-rows.js.map +1 -0
  40. package/dist/outbound-handlers.d.ts +1 -1
  41. package/dist/outbound-handlers.js +6 -7
  42. package/dist/outbound-handlers.js.map +1 -1
  43. package/dist/outbound-schemas.d.ts +2 -1
  44. package/dist/outbound-schemas.js +3 -2
  45. package/dist/outbound-schemas.js.map +1 -1
  46. package/dist/quickstart.js +9 -3
  47. package/dist/quickstart.js.map +1 -1
  48. package/dist/remote-credential-check.d.ts +26 -7
  49. package/dist/remote-credential-check.js +157 -10
  50. package/dist/remote-credential-check.js.map +1 -1
  51. package/dist/settings-api.d.ts +17 -0
  52. package/dist/settings-api.js +62 -1
  53. package/dist/settings-api.js.map +1 -1
  54. package/dist/status-emojis.d.ts +118 -0
  55. package/dist/status-emojis.js +206 -0
  56. package/dist/status-emojis.js.map +1 -0
  57. package/dist/types.d.ts +12 -0
  58. package/dist/ui/dashboard.html +7 -5
  59. package/dist/ui/settings.html +168 -6
  60. package/dist/ui/view.html +138 -45
  61. package/dist/usage/usage-api.js +30 -0
  62. package/dist/usage/usage-api.js.map +1 -1
  63. package/package.json +1 -1
@@ -182,8 +182,10 @@
182
182
  }
183
183
  .detail-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 6px 0; }
184
184
  .detail-label { color: var(--text-muted); min-width: 100px; font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
185
- .progress-bar { display: inline-block; width: 80px; height: 4px; background: rgba(255,255,255,0.06); border-radius: 2px; vertical-align: middle; margin-left: 8px; overflow: hidden; }
186
- .progress-fill { height: 100%; border-radius: 2px; transition: width 0.4s cubic-bezier(0.4,0,0.2,1); }
185
+ /* Readable at a glance: the percentage is bold and the bar thick (usage readability). */
186
+ .progress-bar { display: inline-block; width: 120px; height: 8px; background: rgba(255,255,255,0.07); border-radius: 4px; vertical-align: middle; margin-left: 10px; overflow: hidden; }
187
+ .progress-fill { display: block; height: 100%; border-radius: 4px; transition: width 0.4s cubic-bezier(0.4,0,0.2,1); }
188
+ .pct-val { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
187
189
  .activity-item { font-size: 12px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: 'IBM Plex Mono', monospace; }
188
190
  .act-time { color: var(--text-muted); margin-right: 8px; }
189
191
  .act-event { color: var(--warn); margin-right: 4px; }
@@ -472,9 +474,9 @@ async function loadDetail() {
472
474
  <div class="detail-row"><span class="detail-label">Model</span><span>${esc(model)}${modelSource}</span></div>
473
475
  ${effortLine}
474
476
  <div class="detail-row"><span class="detail-label">Cost</span><span>$${cost.toFixed(2)} <span style="color:var(--text-muted);font-size:11px">(session accumulated)</span></span></div>
475
- <div class="detail-row"><span class="detail-label">Context</span><span>${ctxDisplay} ${ctxBar}</span></div>
476
- <div class="detail-row"><span class="detail-label">Rate 5h</span><span>${Math.round(rl5)}% <span class="progress-bar"><span class="progress-fill" style="width:${Math.min(rl5,100)}%;background:${rl5>90?'var(--error)':rl5>70?'var(--warn)':'var(--accent)'}"></span></span></span></div>
477
- <div class="detail-row"><span class="detail-label">Rate 7d</span><span>${Math.round(rl7)}% <span class="progress-bar"><span class="progress-fill" style="width:${Math.min(rl7,100)}%;background:${rl7>90?'var(--error)':rl7>70?'var(--warn)':'var(--accent)'}"></span></span></span></div></div>`;
477
+ <div class="detail-row"><span class="detail-label">Context</span><span><span class="pct-val">${ctxDisplay}</span> ${ctxBar}</span></div>
478
+ <div class="detail-row"><span class="detail-label">Rate 5h</span><span><span class="pct-val">${Math.round(rl5)}%</span> <span class="progress-bar"><span class="progress-fill" style="width:${Math.min(rl5,100)}%;background:${rl5>90?'var(--error)':rl5>70?'var(--warn)':'var(--accent)'}"></span></span></span></div>
479
+ <div class="detail-row"><span class="detail-label">Rate 7d</span><span><span class="pct-val">${Math.round(rl7)}%</span> <span class="progress-bar"><span class="progress-fill" style="width:${Math.min(rl7,100)}%;background:${rl7>90?'var(--error)':rl7>70?'var(--warn)':'var(--accent)'}"></span></span></span></div></div>`;
478
480
  if (d.recent_activity?.length) {
479
481
  h += `<div class="card"><h3>Recent Activity</h3>`;
480
482
  for (const a of d.recent_activity) { const t=a.timestamp?new Date(a.timestamp.replace(" ","T")+"Z").toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}):""; h+=`<div class="activity-item"><span class="act-time">${t}</span><span class="act-event">${esc(a.event)}</span>${esc(a.summary||"")}</div>`; }
@@ -145,6 +145,18 @@
145
145
  .pending-bar .grow { display:none; }
146
146
  .pending-bar button { flex:1; }
147
147
  }
148
+ /* #1005 status-emoji editor */
149
+ .se-preview { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; margin-bottom: 6px; }
150
+ .se-item { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
151
+ .se-glyph { font-size: 20px; line-height: 1; min-width: 22px; text-align: center; }
152
+ .se-glyph img { width: 22px; height: 22px; object-fit: contain; vertical-align: middle; }
153
+ .se-row { display: grid; grid-template-columns: 120px 1fr auto auto; gap: 6px; align-items: center; margin: 4px 0; }
154
+ .se-row label { margin: 0; }
155
+ .se-picker { grid-column: 1 / -1; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px; background: #fff; }
156
+ .se-grid { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 8px; }
157
+ .se-grid button { padding: 3px 6px; font-size: 18px; line-height: 1.2; }
158
+ .se-grid button img { width: 22px; height: 22px; object-fit: contain; vertical-align: middle; }
159
+ .se-grid button.off { opacity: .45; }
148
160
  </style>
149
161
  </head>
150
162
  <body>
@@ -238,6 +250,11 @@
238
250
  backend: "Backend", model: "Model", workingDir: "Working directory", channelBinding: "Channel binding",
239
251
  description: "Description", systemPrompt: "System prompt", tags: "Tags", generalTopicField: "General topic (fleet dispatcher)",
240
252
  runtimeResources: "Runtime & Resources", autoPause: "Auto-pause after", minutes: "minutes", inheritDefault: "Inherit from defaults",
253
+ statusEmojis: "Status emojis", statusEmojisInstance: "This agent's status emojis (override the connection's)", statusEmojisChannel: "Status emojis for this connection's bots",
254
+ se_received: "Received", se_queued: "Queued", se_processing: "Processing", se_delivered: "Delivered", se_failed: "Failed", se_progress_prefix: "Progress prefix",
255
+ se_src_instance: "agent", se_src_platform: "connection", se_pick: "Pick…", se_useDefault: "Use the default", se_fallback: "the default is used instead",
256
+ se_unicode: "Emoji", se_telegramOnly: "Telegram allows only these reactions", se_serverEmojis: "This server's emojis", se_noServerEmojis: "This server has no custom emojis.", se_unavailable: "unavailable",
257
+ se_hint: "Empty keeps the default. The preview is resolved by AgEnD exactly as the bot will react.",
241
258
  detection: "Detection", startup: "Startup", backendSection: "Backend", advancedSection: "Advanced & Access", hangEnabled: "Hang detector enabled", hangTimeout: "Stuck timeout", agentMode: "Agent mode", toolSet: "Tool set", toolProgress: "Tool progress", replyCompletionGuard: "Require a delivered reply", replyGuardSupported: "Active for Claude Code in MCP mode.", replyGuardUnsupported: "Stored but inactive: {0} in {1} mode does not support reply-drop recovery.", logLevel: "Log level", concurrency: "Startup concurrency", staggerDelay: "Stagger delay", lightweight: "Lightweight mode", modelFailover: "Fallback model", displayName: "Display name", pause: "Pause", wake: "Wake", accessMode: "Access mode", affected: "Affected instances",
242
259
  language: "Language (bot replies)", defaultBackend: "Default backend", defaultModel: "Default model", allowedUsers: "Allowed users (primary bot)",
243
260
  classicAdmins: "ClassicBot admins", allowedGuilds: "Allowed guilds", type: "Type", adapterId: "Adapter id",
@@ -282,6 +299,11 @@
282
299
  mustNonNegative: "Must be 0 or greater", mustPositive: "Must be greater than 0", nonnegativeInteger: "Must be a non-negative integer", failoverEmpty: "Empty list disables failover", concurrencyRange: "Must be an integer from 1 to 20", concurrencyWarning: "May exceed CPU capacity on smaller hosts", staggerRange: "Must be between 0 and 30000", staggerWarning: "Very low values can cause startup spikes",
283
300
  },
284
301
  "zh-TW": {
302
+ statusEmojis: "狀態 emoji", statusEmojisInstance: "此 Agent 的狀態 emoji(覆蓋連線設定)", statusEmojisChannel: "此連線 bot 的狀態 emoji",
303
+ se_received: "已收到", se_queued: "排隊中", se_processing: "處理中", se_delivered: "已送達", se_failed: "失敗", se_progress_prefix: "進度前綴",
304
+ se_src_instance: "Agent", se_src_platform: "連線", se_pick: "選擇…", se_useDefault: "使用預設", se_fallback: "改用預設值",
305
+ se_unicode: "Emoji", se_telegramOnly: "Telegram 只允許這些反應", se_serverEmojis: "此伺服器的 emoji", se_noServerEmojis: "此伺服器沒有自訂 emoji。", se_unavailable: "無法使用",
306
+ se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。",
285
307
  title: "⚙️ AgEnD 設定", reloadFleet: "↻ 重新載入", myAgents: "🤖 我的 Agent", bots: "🔌 機器人", classicChannels: "🎮 ClassicBot 頻道", noClassic: "沒有 ClassicBot 頻道 — 在聊天中用 /start 建立。",
286
308
  general: "⚙️ 一般設定", whatsNew: "🆕 更新重點", advanced: "📋 進階",
287
309
  expand: "▶ 展開", collapse: "▼ 收合", new: "+ 新增", add: "+ 加入", searchPh: "🔍 搜尋…",
@@ -451,6 +473,118 @@
451
473
  return box;
452
474
  }
453
475
 
476
+ // ── Status emojis (#1005) ──
477
+ // The page never resolves or checks an emoji itself: /status-emojis/preview
478
+ // runs the code the bots react with, so what shows here is what gets
479
+ // stamped, and a picked server emoji is stored in the form they react with.
480
+ let statusEmojiCatalog = null;
481
+ async function loadStatusEmojiCatalog() {
482
+ if (statusEmojiCatalog) return statusEmojiCatalog;
483
+ const r = await api("/api/settings/status-emojis");
484
+ statusEmojiCatalog = r.ok ? r.body : { keys: [], builtins: {}, telegram_allowed: [], suggestions: [] };
485
+ return statusEmojiCatalog;
486
+ }
487
+ const guildEmojiLists = new Map();
488
+ function fetchGuildEmojis(channelId, refresh) {
489
+ if (refresh || !guildEmojiLists.has(channelId)) {
490
+ guildEmojiLists.set(channelId, api(`/api/settings/status-emojis/guild-emojis?channel=${encodeURIComponent(channelId)}${refresh ? "&refresh=1" : ""}`)
491
+ .then(r => r.ok ? r.body : { error: r.body?.error || `HTTP ${r.status}` }));
492
+ }
493
+ return guildEmojiLists.get(channelId);
494
+ }
495
+ // no-referrer: the settings URL can carry the web token.
496
+ const emojiImg = (url, alt) => el("img", { src: url, alt, referrerpolicy: "no-referrer", loading: "lazy" });
497
+ const emojiGlyph = (entry) => el("span", { class: "se-glyph" }, entry.image_url ? emojiImg(entry.image_url, entry.display) : entry.value);
498
+ /** Keys in catalog order, blanks dropped — so an unchanged map compares equal. */
499
+ function orderedStatusMap(map, keys) {
500
+ const out = {};
501
+ for (const key of keys) { const v = map?.[key]; if (typeof v === "string" && v.trim()) out[key] = v.trim(); }
502
+ return Object.keys(out).length ? out : null;
503
+ }
504
+ /**
505
+ * Editor for one status_emojis map. `scope.platform()` and
506
+ * `scope.channelId()` say where it applies; `scope.previewBody(map)` is what
507
+ * the preview resolves (the channel map, or an instance override on top of
508
+ * its channel's).
509
+ */
510
+ function statusEmojiEditor(own, scope) {
511
+ const values = { ...(own || {}) };
512
+ let keys = [];
513
+ const box = el("div", { class: "se" });
514
+ const preview = el("div", { class: "se-preview" }, el("span", { class: "hint" }, "…"));
515
+ const problems = el("div", { class: "feedback" });
516
+ const rows = el("div", { class: "se-rows" });
517
+ let timer = null, seq = 0;
518
+ const refresh = () => { clearTimeout(timer); timer = setTimeout(runPreview, 150); };
519
+ async function runPreview() {
520
+ const mine = ++seq;
521
+ const r = await api("/api/settings/status-emojis/preview", { method: "POST", body: JSON.stringify(scope.previewBody(orderedStatusMap(values, keys) || {})) });
522
+ if (mine !== seq) return;
523
+ preview.innerHTML = "";
524
+ if (!r.ok) { problems.className = "feedback error"; problems.textContent = `✗ ${r.body?.error || "preview failed"}`; return; }
525
+ for (const entry of r.body.entries) {
526
+ preview.append(el("span", { class: "se-item", title: `${entry.key}: ${entry.value}` }, emojiGlyph(entry),
527
+ el("span", {}, t(`se_${entry.key}`)),
528
+ entry.source === "builtin" ? null : el("span", { class: "tag" }, t(`se_src_${entry.source}`))));
529
+ }
530
+ problems.className = r.body.problems.length ? "feedback error" : "feedback";
531
+ problems.textContent = r.body.problems.map(p => `⚠ ${t(`se_${p.key}`)}: ${p.problem} — ${t("se_fallback")}`).join(" ");
532
+ }
533
+ function openPicker(row, key, input) {
534
+ row.querySelector(".se-picker")?.remove();
535
+ const picker = el("div", { class: "se-picker" });
536
+ const choose = (value) => { input.value = value; values[key] = value; picker.remove(); refresh(); };
537
+ const grid = (items) => { const g = el("div", { class: "se-grid" }); for (const it of items) g.append(it); return g; };
538
+ const unicodeButton = (emoji) => el("button", { type: "button", class: "sm", title: emoji, onclick: () => choose(emoji) }, emoji);
539
+ const platform = scope.platform();
540
+ const cat = statusEmojiCatalog;
541
+ // progress_prefix is message text, so Telegram's reaction set does not bind it.
542
+ const unicode = platform === "telegram" && key !== "progress_prefix" ? cat.telegram_allowed : cat.suggestions;
543
+ picker.append(el("div", { class: "hint" }, platform === "telegram" && key !== "progress_prefix" ? t("se_telegramOnly") : t("se_unicode")), grid(unicode.map(unicodeButton)));
544
+ const channelId = scope.channelId();
545
+ if (platform === "discord" && channelId) {
546
+ const guildBox = el("div");
547
+ const load = async (refreshList) => {
548
+ guildBox.innerHTML = ""; guildBox.append(el("div", { class: "hint" }, "…"));
549
+ const list = await fetchGuildEmojis(channelId, refreshList);
550
+ guildBox.innerHTML = "";
551
+ if (list.error) { guildBox.append(el("div", { class: "feedback error" }, `✗ ${list.error}`)); return; }
552
+ if (!list.emojis.length) { guildBox.append(el("div", { class: "hint" }, t("se_noServerEmojis"))); return; }
553
+ // An unavailable emoji (e.g. the server lost the Boost tier it
554
+ // needs) cannot be reacted with; picking it would preview fine and
555
+ // then fail silently on every status. Shown, but not pickable.
556
+ guildBox.append(grid(list.emojis.map(e => el("button", {
557
+ type: "button", class: "sm" + (e.available ? "" : " off"), title: `:${e.name}:${e.available ? "" : ` (${t("se_unavailable")})`}`,
558
+ ...(e.available ? {} : { disabled: "disabled", "aria-disabled": "true" }),
559
+ onclick: () => { if (e.available) choose(e.value); },
560
+ }, emojiImg(e.image_url, `:${e.name}:`)))));
561
+ };
562
+ picker.append(el("div", { class: "hint", style: "display:flex;gap:8px;align-items:center" }, t("se_serverEmojis"),
563
+ el("button", { type: "button", class: "sm ghost", onclick: () => load(true) }, "↻")), guildBox);
564
+ load(false);
565
+ }
566
+ picker.append(el("div", { class: "actions" }, el("button", { type: "button", class: "sm ghost", onclick: () => picker.remove() }, t("close"))));
567
+ row.append(picker);
568
+ }
569
+ (async () => {
570
+ const cat = await loadStatusEmojiCatalog();
571
+ keys = cat.keys;
572
+ const builtins = cat.builtins[scope.platform() === "telegram" ? "telegram" : "discord"] || {};
573
+ for (const key of keys) {
574
+ const input = el("input", { type: "text", value: values[key] || "", placeholder: builtins[key] || "", autocomplete: "off" });
575
+ input.addEventListener("input", () => { values[key] = input.value; refresh(); });
576
+ const row = el("div", { class: "se-row" });
577
+ row.append(el("label", {}, t(`se_${key}`)), input,
578
+ el("button", { type: "button", class: "sm", onclick: () => openPicker(row, key, input) }, t("se_pick")),
579
+ el("button", { type: "button", class: "sm ghost", title: t("se_useDefault"), onclick: () => { input.value = ""; values[key] = ""; refresh(); } }, "×"));
580
+ rows.append(row);
581
+ }
582
+ runPreview();
583
+ })();
584
+ box.append(preview, problems, rows, el("div", { class: "hint" }, t("se_hint")));
585
+ return { box, refresh, value: () => orderedStatusMap(values, keys), baseline: () => orderedStatusMap(own, keys) };
586
+ }
587
+
454
588
  /**
455
589
  * One modal edits one object, and closing it stages a change — it never
456
590
  * writes. Applying stays where it was: the Apply button on the pending bar.
@@ -980,6 +1114,15 @@
980
1114
  const fallbackValue = Array.isArray(inst.model_failover) ? (inst.model_failover[0] || "") : (defaults.model_failover?.[0] || "");
981
1115
  const failover = overrideControl(inst, "model_failover", el("input", { type: "text", value: fallbackValue, placeholder: "e.g. sonnet" }), defaults.model_failover?.[0] || "");
982
1116
  const displayName = overrideControl(inst, "display_name", el("input", { type: "text", value: inst.display_name ?? defaults.display_name ?? "", placeholder: name }), defaults.display_name || "");
1117
+ // The override sits on top of the bound channel's map, so the preview is
1118
+ // resolved against whichever channel the form currently binds.
1119
+ const boundChannelId = () => fChan.value || channelIds()[0] || null;
1120
+ const statusEmojis = statusEmojiEditor(inst.status_emojis, {
1121
+ platform: () => chById(boundChannelId())?.type,
1122
+ channelId: boundChannelId,
1123
+ previewBody: map => ({ channel_id: boundChannelId(), instance_config: map }),
1124
+ });
1125
+ fChan.addEventListener("change", statusEmojis.refresh);
983
1126
  const autoFeedback = el("div", { class: "feedback" });
984
1127
  const hangFeedback = el("div", { class: "feedback" });
985
1128
  const failoverFeedback = el("div", { class: "feedback" });
@@ -1016,6 +1159,7 @@
1016
1159
  lightweight: lightweight.toggle.checked ? null : lightweight.input.checked,
1017
1160
  model_failover: failover.toggle.checked ? null : (failover.input.value.trim() ? [failover.input.value.trim()] : []),
1018
1161
  display_name: displayName.toggle.checked ? null : displayName.input.value.trim(),
1162
+ status_emojis: statusEmojis.value(),
1019
1163
  };
1020
1164
  const patch = changedFields(nextPatch, {
1021
1165
  backend: inst.backend || defaults.backend || "claude-code",
@@ -1036,6 +1180,7 @@
1036
1180
  lightweight: hasOwn(inst, "lightweight") ? inst.lightweight : null,
1037
1181
  model_failover: hasOwn(inst, "model_failover") ? inst.model_failover : null,
1038
1182
  display_name: hasOwn(inst, "display_name") ? inst.display_name : null,
1183
+ status_emojis: statusEmojis.baseline(),
1039
1184
  });
1040
1185
  state.fleet.instances[name] = { ...inst, ...patch };
1041
1186
  for (const [key, value] of Object.entries(patch)) if (value === null) delete state.fleet.instances[name][key];
@@ -1074,6 +1219,8 @@
1074
1219
  el("label", {}, t("systemPrompt"), impact("instance.systemPrompt")), fSys,
1075
1220
  el("label", {}, t("tags"), impact("instance.tags")), chipList(tags, null, "tag"),
1076
1221
  el("label", { style: "display:flex;align-items:center;gap:8px;cursor:pointer" }, fGeneral, el("span", {}, t("generalTopicField")), impact("instance.general_topic"))),
1222
+ drawer(t("statusEmojis"),
1223
+ el("label", {}, t("statusEmojisInstance"), impact("instance.status_emojis")), statusEmojis.box),
1077
1224
  el("div", { class: "actions" }, msg),
1078
1225
  );
1079
1226
  validate();
@@ -1093,7 +1240,7 @@
1093
1240
  "instance.tool_set", "instance.tool_progress",
1094
1241
  "instance.reply_completion_guard", "instance.log_level",
1095
1242
  "instance.lightweight", "instance.model_failover", "instance.systemPrompt",
1096
- "instance.tags", "instance.general_topic",
1243
+ "instance.tags", "instance.general_topic", "instance.status_emojis",
1097
1244
  ];
1098
1245
  async function toggleAgent(name, running) {
1099
1246
  if (running) await api(`/stop/${encodeURIComponent(name)}`, { method: "POST" });
@@ -1328,6 +1475,11 @@
1328
1475
  });
1329
1476
  secretFeedback.className = "feedback"; secretFeedback.textContent = "Queued — click Apply changes.";
1330
1477
  };
1478
+ const statusEmojis = statusEmojiEditor(ch.options?.status_emojis, {
1479
+ platform: () => type,
1480
+ channelId: () => connectionId,
1481
+ previewBody: map => ({ channel_id: connectionId, platform: type, channel_config: map }),
1482
+ });
1331
1483
  const validateAccess = () => setValidation(fMode, accessFeedback, "", fMode.value === "locked" && users.length === 0 ? t("accessLockedEmpty") : "");
1332
1484
  fMode.addEventListener("change", validateAccess);
1333
1485
  box.append(
@@ -1340,6 +1492,8 @@
1340
1492
  el("label", {}, type === "discord" ? "General channel id" : "Forum topic id"), fGeneral,
1341
1493
  el("div", { class: "hint" }, "The target is checked with the existing bot token before any binding is changed."),
1342
1494
  el("div", { class: "actions" }, bindingVerify, bindingFeedback)),
1495
+ drawer(t("statusEmojis"),
1496
+ el("label", {}, t("statusEmojisChannel"), impact("fleet.channel.options.status_emojis")), statusEmojis.box),
1343
1497
  drawer(t("advancedSection"),
1344
1498
  el("div", { class: "field" }, el("label", {}, t("tokenEnv")), el("div", { class: "sub" }, ch.bot_token_env || "(no token env)")),
1345
1499
  el("div", { class: "field" },
@@ -1352,7 +1506,7 @@
1352
1506
  validateAccess();
1353
1507
  return {
1354
1508
  box,
1355
- impacts: ["fleet.channel.access.mode", "fleet.channel.access.allowed_users", "fleet.channels"],
1509
+ impacts: ["fleet.channel.access.mode", "fleet.channel.access.allowed_users", "fleet.channels", "fleet.channel.options.status_emojis"],
1356
1510
  // Staged, not saved: the old form wrote channels straight to the API from
1357
1511
  // inside the editor, which is the one thing a modal must not do.
1358
1512
  stage: () => {
@@ -1361,13 +1515,21 @@
1361
1515
  const stagedAllowedUsers = [...users];
1362
1516
  const nextChannels = structuredClone(originalChannels);
1363
1517
  nextChannels[i] = { ...nextChannels[i], access: { ...nextChannels[i].access, mode: stagedAccessMode, allowed_users: stagedAllowedUsers } };
1364
- if (sameValue(nextChannels, originalChannels)) return true;
1518
+ const accessChanged = !sameValue(nextChannels, originalChannels);
1519
+ const stagedStatusEmojis = statusEmojis.value();
1520
+ const emojisChanged = !sameValue(stagedStatusEmojis, statusEmojis.baseline());
1521
+ if (emojisChanged) {
1522
+ const options = { ...(nextChannels[i].options || {}) };
1523
+ if (stagedStatusEmojis) options.status_emojis = stagedStatusEmojis; else delete options.status_emojis;
1524
+ nextChannels[i] = { ...nextChannels[i], options };
1525
+ }
1526
+ if (!accessChanged && !emojisChanged) return true;
1365
1527
  stageChange(`connections:${ch.id || chLabel(i)}`, {
1366
- label: `Update ${ch.id || chLabel(i)} access`,
1367
- impact: impactOf("fleet.channels"),
1528
+ label: `Update ${ch.id || chLabel(i)} ${accessChanged && emojisChanged ? "access & status emojis" : accessChanged ? "access" : "status emojis"}`,
1529
+ impact: impactOf(accessChanged ? "fleet.channels" : "fleet.channel.options.status_emojis"),
1368
1530
  // Confirm the immutable staged snapshot, not live form controls
1369
1531
  // that the user may edit again before pressing global Apply.
1370
- confirm: () => confirmAccessChange(previousAccess, stagedAccessMode, stagedAllowedUsers),
1532
+ confirm: () => !accessChanged || confirmAccessChange(previousAccess, stagedAccessMode, stagedAllowedUsers),
1371
1533
  cancelMessage: t("accessChangeCancelled"),
1372
1534
  apply: () => api("/api/settings/fleet/channels", { method: "PUT", body: JSON.stringify(nextChannels) }),
1373
1535
  });