@songsid/agend 2.1.7-beta.6 → 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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Configurable delivery-status emojis (#1005).
3
+ *
4
+ * One module owns how a configured status emoji is read, checked, resolved
5
+ * and compared, so the reaction a bot stamps, the reaction it later removes,
6
+ * the reactions the fleet filters out as its own, and the list the
7
+ * instructions tell agents to avoid all come from the same value.
8
+ *
9
+ * Resolution, per status key: instance `status_emojis` → the bound channel's
10
+ * `options.status_emojis` → the built-in value for that platform. The
11
+ * built-ins are exactly what AgEnD stamped before this was configurable.
12
+ */
13
+ export const STATUS_EMOJI_KEYS = ["received", "queued", "processing", "delivered", "failed"];
14
+ export const STATUS_EMOJI_CONFIG_KEYS = [...STATUS_EMOJI_KEYS, "progress_prefix"];
15
+ /**
16
+ * Telegram accepts only this fixed reaction set (Bot API ReactionTypeEmoji).
17
+ * Spelled as the Bot API spells them: without the U+FE0F variation selector.
18
+ */
19
+ export const TELEGRAM_REACTION_EMOJIS = new Set([
20
+ "👍", "👎", "❤", "🔥", "🥰", "👏", "😁", "🤔", "🤯", "😱", "🤬", "😢", "🎉", "🤩", "🤮", "💩", "🙏", "👌", "🕊", "🤡",
21
+ "🥱", "🥴", "😍", "🐳", "❤‍🔥", "🌚", "🌭", "💯", "🤣", "⚡", "🍌", "🏆", "💔", "🤨", "😐", "🍓", "🍾", "💋", "🖕", "😈",
22
+ "😴", "😭", "🤓", "👻", "👨‍💻", "👀", "🎃", "🙈", "😇", "😨", "🤝", "✍", "🤗", "🫡", "🎅", "🎄", "☃", "💅", "🤪", "🗿",
23
+ "🆒", "💘", "🙉", "🦄", "😘", "💊", "🙊", "😎", "👾", "🤷‍♂", "🤷", "🤷‍♀", "😡",
24
+ ]);
25
+ const BUILTIN_DEFAULT = {
26
+ received: "👀", queued: "⏳", processing: "👀", delivered: "✅", failed: "❌", progress_prefix: "👀",
27
+ };
28
+ /** Telegram's smaller vocabulary: ⏳/✅/❌ are not valid Telegram reactions. */
29
+ const BUILTIN_TELEGRAM = {
30
+ received: "👀", queued: "👀", processing: "👀", delivered: "👀", failed: "👎", progress_prefix: "👀",
31
+ };
32
+ export function builtinStatusEmojis(platform) {
33
+ return { ...(platform === "telegram" ? BUILTIN_TELEGRAM : BUILTIN_DEFAULT) };
34
+ }
35
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
36
+ function graphemeCount(s) {
37
+ let n = 0;
38
+ for (const _ of GRAPHEMES.segment(s))
39
+ if (++n > 1)
40
+ break;
41
+ return n;
42
+ }
43
+ const CUSTOM_TAG_RE = /^<(a?):([A-Za-z0-9_]{2,32}):(\d{15,25})>$/;
44
+ const CUSTOM_BARE_RE = /^(?:(a):)?([A-Za-z0-9_]{2,32}):(\d{15,25})$/;
45
+ const PICTOGRAPH_RE = /\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20E3/u;
46
+ /**
47
+ * Parse one configured value. Discord custom emoji may be written `<:name:id>`,
48
+ * `<a:name:id>` or bare `name:id`; anything else must be exactly one emoji
49
+ * grapheme (no spaces, no words, not two emojis). Returns null otherwise.
50
+ */
51
+ export function normalizeEmoji(raw) {
52
+ if (typeof raw !== "string")
53
+ return null;
54
+ const s = raw.trim();
55
+ if (!s)
56
+ return null;
57
+ const custom = CUSTOM_TAG_RE.exec(s) ?? CUSTOM_BARE_RE.exec(s);
58
+ if (custom)
59
+ return { kind: "custom", animated: custom[1] === "a", name: custom[2], id: custom[3] };
60
+ // Exactly one grapheme: 👀✅ is two emojis that no platform can stamp as
61
+ // one reaction — accepting it lost that status silently. Segmenting by
62
+ // grapheme (not code points) keeps ZWJ sequences (👨‍👩‍👧), flags (🇹🇼) and
63
+ // skin tones (👍🏽) as the single emoji they are.
64
+ if (/[\sA-Za-z0-9<>:]/.test(s) || !PICTOGRAPH_RE.test(s) || graphemeCount(s) !== 1)
65
+ return null;
66
+ return { kind: "unicode", value: s };
67
+ }
68
+ const stripVariation = (s) => s.replace(/\uFE0F/g, "");
69
+ /**
70
+ * Why a value cannot be used on `platform`, or null when it can. Telegram has
71
+ * no custom emoji and only the fixed reaction set; Discord takes any emoji.
72
+ * `progress_prefix` is message text, not a reaction, so any emoji works there.
73
+ */
74
+ export function statusEmojiProblem(platform, key, raw) {
75
+ const e = normalizeEmoji(raw);
76
+ if (!e)
77
+ return `"${String(raw)}" is not an emoji (use a single emoji, or <:name:id> for a Discord server emoji)`;
78
+ if (platform !== "telegram")
79
+ return null;
80
+ if (e.kind === "custom")
81
+ return "Telegram has no server custom emoji";
82
+ if (key === "progress_prefix")
83
+ return null;
84
+ if (!TELEGRAM_REACTION_EMOJIS.has(stripVariation(e.value)))
85
+ return `"${e.value}" is not in Telegram's allowed reaction set`;
86
+ return null;
87
+ }
88
+ /**
89
+ * The string handed to adapter.react()/unreact(). The same form must go to
90
+ * both, or the old status reaction is never removed when the status advances.
91
+ * Discord's REST route and discord.js both take `name:id` for a custom emoji;
92
+ * Telegram wants its own spelling of the emoji (no variation selector).
93
+ */
94
+ export function reactionForm(platform, raw) {
95
+ const e = normalizeEmoji(raw);
96
+ if (!e)
97
+ return raw;
98
+ if (e.kind === "custom")
99
+ return `${e.name}:${e.id}`;
100
+ return platform === "telegram" ? stripVariation(e.value) : e.value;
101
+ }
102
+ /** How a status emoji reads in text: custom emoji as `:name:`, not `<:name:id>`. */
103
+ export function displayForm(raw) {
104
+ const e = normalizeEmoji(raw);
105
+ if (!e)
106
+ return raw;
107
+ return e.kind === "custom" ? `:${e.name}:` : e.value;
108
+ }
109
+ /** Message-text form (progress bubble): Discord renders `<:name:id>` inline. */
110
+ export function textForm(platform, raw) {
111
+ const e = normalizeEmoji(raw);
112
+ if (!e)
113
+ return raw;
114
+ if (e.kind === "custom")
115
+ return platform === "discord" ? `<${e.animated ? "a" : ""}:${e.name}:${e.id}>` : `:${e.name}:`;
116
+ return e.value;
117
+ }
118
+ /**
119
+ * Comparison key for "is this reaction one of the status emojis": a custom
120
+ * emoji by id (its name can be renamed and is not unique), a unicode emoji
121
+ * without variation selectors (👁️ and 👁 are the same reaction).
122
+ */
123
+ export function statusMatchKey(raw) {
124
+ const e = normalizeEmoji(raw);
125
+ if (e?.kind === "custom")
126
+ return `id:${e.id}`;
127
+ return stripVariation((e?.value ?? raw).trim());
128
+ }
129
+ /** The same key computed from an inbound reaction. */
130
+ export function reactionMatchKey(emoji, emojiId) {
131
+ return emojiId ? `id:${emojiId}` : stripVariation(emoji.trim());
132
+ }
133
+ /**
134
+ * Resolve every status key. An unusable value never fails a reaction: it is
135
+ * reported through onInvalid and that key falls through to the next layer.
136
+ */
137
+ export function resolveStatusEmojis(input) {
138
+ const out = builtinStatusEmojis(input.platform);
139
+ const layers = [["platform", input.platformConfig], ["instance", input.instanceConfig]];
140
+ for (const [source, cfg] of layers) {
141
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg))
142
+ continue;
143
+ for (const key of STATUS_EMOJI_CONFIG_KEYS) {
144
+ const value = cfg[key];
145
+ if (value === undefined || value === null || value === "")
146
+ continue;
147
+ const problem = statusEmojiProblem(input.platform, key, value);
148
+ if (problem) {
149
+ input.onInvalid?.(source, key, value, problem);
150
+ continue;
151
+ }
152
+ out[key] = value.trim();
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+ /** The status reactions (not progress_prefix), deduplicated, in display form. */
158
+ export function statusAvoidList(resolved) {
159
+ return [...new Set(STATUS_EMOJI_KEYS.map(k => displayForm(resolved[k])))];
160
+ }
161
+ /** Match keys for every status reaction in `resolved`. */
162
+ export function statusMatchKeys(resolved) {
163
+ return STATUS_EMOJI_KEYS.map(k => statusMatchKey(resolved[k]));
164
+ }
165
+ // ── Settings picker / preview (#1005 phase 3.2) ─────────────────────────────
166
+ // The page asks the server to resolve and render, so what it previews is what
167
+ // resolveStatusEmojis and reactionForm produce — picked == previewed == reacted.
168
+ /** A handful of unicode status emojis to offer on Discord (Telegram offers its whole set). */
169
+ export const STATUS_EMOJI_SUGGESTIONS = [
170
+ "👀", "⏳", "✅", "❌", "📥", "📨", "📬", "📭", "🟢", "🟡", "🔴", "⚪", "🔵", "✔️", "☑️", "✖️", "⚠️", "🚫",
171
+ "🔄", "⏱️", "⌛", "💬", "💭", "🧠", "⚙️", "🛠️", "🚀", "🎯", "🏁", "🎉", "👍", "👎", "👌", "🙏", "🤔", "🫡",
172
+ "🦊", "🍎", "🐱", "🐶", "🐼", "🦉", "🐙", "🌟", "⭐", "🔥", "💡", "📌",
173
+ ];
174
+ /** Stored config form of a Discord server emoji. */
175
+ export function customEmojiValue(e) {
176
+ return `<${e.animated ? "a" : ""}:${e.name}:${e.id}>`;
177
+ }
178
+ /** Discord's public CDN renders a known custom emoji id without any token. */
179
+ export function emojiImageUrl(raw) {
180
+ const e = normalizeEmoji(raw);
181
+ if (e?.kind !== "custom")
182
+ return null;
183
+ return `https://cdn.discordapp.com/emojis/${e.id}.${e.animated ? "gif" : "png"}`;
184
+ }
185
+ export function previewStatusEmojis(input) {
186
+ const problems = [];
187
+ const resolved = resolveStatusEmojis({ ...input, onInvalid: (source, key, value, problem) => problems.push({ source, key, value, problem }) });
188
+ const usable = (cfg, key) => {
189
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg))
190
+ return false;
191
+ const v = cfg[key];
192
+ return v !== undefined && v !== null && v !== "" && !statusEmojiProblem(input.platform, key, v);
193
+ };
194
+ const entries = STATUS_EMOJI_CONFIG_KEYS.map((key) => {
195
+ const value = resolved[key];
196
+ return {
197
+ key, value,
198
+ source: usable(input.instanceConfig, key) ? "instance" : usable(input.platformConfig, key) ? "platform" : "builtin",
199
+ applied: key === "progress_prefix" ? textForm(input.platform, value) : reactionForm(input.platform, value),
200
+ display: displayForm(value),
201
+ image_url: emojiImageUrl(value),
202
+ };
203
+ });
204
+ return { platform: input.platform, entries, problems, avoid: statusAvoidList(resolved) };
205
+ }
206
+ //# sourceMappingURL=status-emojis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-emojis.js","sourceRoot":"","sources":["../src/status-emojis.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -89,6 +89,12 @@ export interface InstanceConfig {
89
89
  warm_cap?: number;
90
90
  /** Agent display name (e.g. "Kuro", "Luna") — chosen by the agent itself */
91
91
  display_name?: string;
92
+ /**
93
+ * #1005: this instance's own delivery-status emojis, overriding its
94
+ * channel's `options.status_emojis` per key. Keys: received, queued,
95
+ * processing, delivered, failed, progress_prefix.
96
+ */
97
+ status_emojis?: import("./status-emojis.js").StatusEmojiConfig;
92
98
  /** Human-readable description of what this instance does */
93
99
  description?: string;
94
100
  /** Tags for capability discovery (e.g. ["code-reviewer", "researcher", "executor"]) */
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@songsid/agend",
3
- "version": "2.1.7-beta.6",
3
+ "version": "2.1.7-beta.7",
4
4
  "description": "Multi-agent fleet daemon — run any coding CLI (Claude, Gemini, Codex, OpenCode) from Telegram",
5
5
  "type": "module",
6
6
  "bin": {