@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.
@@ -1,12 +1,13 @@
1
1
  import { type FleetMemory } from "./process-memory.js";
2
2
  import { ReplyDeduper } from "./reply-dedup.js";
3
3
  import type { FleetConfig, RawFleetConfig, InstanceConfig } from "./types.js";
4
+ import { type DeliveryStatus, type ResolvedStatusEmojis } from "./status-emojis.js";
4
5
  import { type RouteTarget } from "./fleet-context.js";
5
6
  import { EventLog } from "./event-log.js";
6
7
  import { AdapterWorld } from "./adapter-world.js";
7
8
  import { CostGuard } from "./cost-guard.js";
8
9
  import { IpcClient } from "./channel/ipc-bridge.js";
9
- import type { AdapterHealthSnapshot, ChannelAdapter } from "./channel/types.js";
10
+ import type { AdapterHealthSnapshot, ChannelAdapter, InboundReaction } from "./channel/types.js";
10
11
  import { type Logger } from "./logger.js";
11
12
  import { Scheduler } from "./scheduler/index.js";
12
13
  import type { FleetContext } from "./fleet-context.js";
@@ -1076,12 +1077,63 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1076
1077
  private deliveryStatusChains;
1077
1078
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
1078
1079
  private pendingCliEnvProbes;
1079
- reactMessageStatus(instanceName: string, chatId: string, messageId: string, emoji: string, threadId?: string): void;
1080
1080
  /**
1081
- * Apply a terminal delivery verdict. Telegram maps βœ… to πŸ‘€ and ❌ to πŸ‘Ž in
1082
- * reactMessageStatus; Discord continues to show the verdict emoji directly.
1081
+ * The status emojis for an instance (#1005): its own `status_emojis`, then
1082
+ * its channel's `options.status_emojis`, then the platform built-in. An
1083
+ * unusable value warns once and falls through, never failing the reaction.
1084
+ * `adapterId` pins the channel for paths that already know the bot (classic).
1083
1085
  */
1084
- finishDeliveryStatus(instanceName: string, chatId: string, messageId: string, emoji: string, threadId?: string): void;
1086
+ resolveStatusEmojisFor(instanceName: string, adapterId?: string, adapter?: ChannelAdapter | null): ResolvedStatusEmojis & {
1087
+ platform: string | undefined;
1088
+ };
1089
+ private warnedStatusEmojis;
1090
+ /**
1091
+ * A Discord connection's server emojis for the Settings picker (#1005).
1092
+ * Cached per connection β€” a server's emoji set changes rarely β€” and one
1093
+ * fetch at a time; `refresh` forces a new one. Uses the running adapter, so
1094
+ * the bot token never leaves the server.
1095
+ */
1096
+ listGuildEmojis(channelId: string, refresh?: boolean): Promise<{
1097
+ ok: true;
1098
+ fetched_at: number;
1099
+ emojis: Array<{
1100
+ id: string;
1101
+ name: string;
1102
+ animated: boolean;
1103
+ available: boolean;
1104
+ }>;
1105
+ } | {
1106
+ ok: false;
1107
+ error: string;
1108
+ }>;
1109
+ private static GUILD_EMOJI_TTL_MS;
1110
+ private guildEmojiCache;
1111
+ private guildEmojiFetches;
1112
+ /** What the instructions tell `instanceName` not to react with (its own status set). */
1113
+ statusEmojiAvoidList(instanceName: string): string[];
1114
+ /** The progress bubble's leading emoji, in the form the channel renders. */
1115
+ private progressPrefixFor;
1116
+ /**
1117
+ * Is this inbound reaction one of the fleet's own delivery-status stamps?
1118
+ * Those are machine indicators and must not reach any instance as a user
1119
+ * reaction (#1005 point 3). When every bot's user id is known, the check
1120
+ * keys on WHO reacted: a fleet bot's reaction is a status stamp only if it
1121
+ * is in that bot's instances' status set (other bot reactions are agent
1122
+ * signals and pass), and a human's reaction always passes β€” whatever emoji
1123
+ * a persona picked. Without every id, fall back to the emoji alone.
1124
+ */
1125
+ isOwnStatusReaction(r: InboundReaction): boolean;
1126
+ /**
1127
+ * The "received" reaction for paths that react with the inbound message's
1128
+ * own adapter rather than through reactMessageStatus (classic bots).
1129
+ */
1130
+ private receivedReactionFor;
1131
+ reactMessageStatus(instanceName: string, chatId: string, messageId: string, status: DeliveryStatus, threadId?: string): void;
1132
+ /**
1133
+ * Apply a terminal delivery verdict. On Telegram the built-in delivered is
1134
+ * πŸ‘€ and failed is πŸ‘Ž; Discord shows βœ…/❌ β€” both configurable (#1005).
1135
+ */
1136
+ finishDeliveryStatus(instanceName: string, chatId: string, messageId: string, status: DeliveryStatus, threadId?: string): void;
1085
1137
  private queueDeliveryStatusReaction;
1086
1138
  private applyDeliveryStatusReaction;
1087
1139
  private static FAILOVER_TRIGGER_PCT;
@@ -1324,7 +1376,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1324
1376
  * progress indicator (#409) β€” the channel showed nothing at all during long work,
1325
1377
  * and once the agent had replied once there was no sign it was still going.
1326
1378
  */
1327
- static progressText(elapsedMs: number, activity?: string | null, minElapsedMs?: number): string;
1379
+ static progressText(elapsedMs: number, activity?: string | null, minElapsedMs?: number, prefix?: string): string;
1328
1380
  /** Render elapsed time consistently in live and retained progress bubbles. */
1329
1381
  private static formatProgressElapsed;
1330
1382
  /** Final read-only form of a bubble that contains semantic tool history.
@@ -1366,7 +1418,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1366
1418
  */
1367
1419
  private composeBubbleText;
1368
1420
  /** Pure composition of header + tool list, exposed for tests. */
1369
- static bubbleText(elapsedMs: number, activity: string | undefined, minElapsedMs: number, progress: string | undefined): string;
1421
+ static bubbleText(elapsedMs: number, activity: string | undefined, minElapsedMs: number, progress: string | undefined, prefix?: string): string;
1370
1422
  /** Recompose and edit the bubble in place; skips when nothing changed. */
1371
1423
  private refreshBubble;
1372
1424
  /**
@@ -16,17 +16,7 @@ const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  /** Fallback access policy for a channel with no `access:` block β€” open (no gate). */
18
18
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
19
- /**
20
- * Telegram bot reactions replace a single slot per message. Keep delivery state
21
- * presentation here so supported emoji choices can change without altering the
22
- * status reconciliation logic.
23
- */
24
- const TELEGRAM_DELIVERY_STATUS_REACTIONS = new Map([
25
- ["πŸ‘€", "πŸ‘€"], // queued / processing
26
- ["⏳", "πŸ‘€"],
27
- ["βœ…", "πŸ‘€"], // confirmed
28
- ["❌", "πŸ‘Ž"], // failed
29
- ]);
19
+ import { STATUS_EMOJI_KEYS, builtinStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
30
20
  import { isProbeableRouteTarget } from "./fleet-context.js";
31
21
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
32
22
  import { EventLog } from "./event-log.js";
@@ -196,19 +186,10 @@ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
196
186
  const PROGRESS_MIN_ELAPSED_MS = 30_000;
197
187
  /** How much of a tool summary the progress line will show before eliding. */
198
188
  const PROGRESS_ACTIVITY_MAX_CHARS = 48;
199
- /**
200
- * Emoji AgEnD itself stamps on messages as the delivery-status ladder
201
- * (⏳ queued, πŸ‘€ delivered, βœ… confirmed, ❌ failed). These are machine
202
- * indicators, not opinions, so they never enter the reactions queue β€” from
203
- * anyone. This exact-emoji filter is the ONLY bot filtering left: bot-to-bot
204
- * reactions are otherwise delivered on purpose (agents signal each other), and
205
- * 🫑 passes too β€” it reads as a deliberate acknowledgement, not plumbing.
206
- */
207
- const DELIVERY_STATUS_EMOJIS = new Set(["πŸ‘€", "⏳", "βœ…", "❌"]);
208
189
  /**
209
190
  * Reactions that are neither delivery plumbing nor meaningful conversational
210
- * feedback. Keep this separate from DELIVERY_STATUS_EMOJIS so adding a UI-only
211
- * emoji never changes the documented delivery-state protocol.
191
+ * feedback. Delivery-status emojis are configurable (#1005) and filtered by
192
+ * FleetManager.isOwnStatusReaction, not by a fixed set.
212
193
  */
213
194
  const IGNORED_REACTION_EMOJIS = new Set(["πŸ“·"]);
214
195
  /**
@@ -4732,7 +4713,7 @@ export class FleetManager {
4732
4713
  this.logger.debug({ emoji: r.emoji, chatId: r.chatId }, "Reaction in an unrouted channel β€” ignoring");
4733
4714
  return;
4734
4715
  }
4735
- if (DELIVERY_STATUS_EMOJIS.has(r.emoji)) {
4716
+ if (this.isOwnStatusReaction(r)) {
4736
4717
  this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
4737
4718
  return;
4738
4719
  }
@@ -5273,7 +5254,7 @@ export class FleetManager {
5273
5254
  // path, so a later delivery status replaces (not stacks onto) this
5274
5255
  // acknowledgement.
5275
5256
  if (msg.chatId && msg.messageId) {
5276
- this.reactMessageStatus(generalInstance, msg.chatId, msg.messageId, "πŸ‘€", msg.threadId || undefined);
5257
+ this.reactMessageStatus(generalInstance, msg.chatId, msg.messageId, "received", msg.threadId || undefined);
5277
5258
  }
5278
5259
  this.warnIfRateLimited(generalInstance, msg);
5279
5260
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
@@ -5365,7 +5346,7 @@ export class FleetManager {
5365
5346
  // status path, so a later delivery status replaces (not stacks onto) this
5366
5347
  // acknowledgement. Same bound-adapter routing as the status path itself.
5367
5348
  if (msg.chatId && msg.messageId) {
5368
- this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "πŸ‘€", msg.threadId || undefined);
5349
+ this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "received", msg.threadId || undefined);
5369
5350
  }
5370
5351
  // These may hit Discord API (topic icon, archive) β€” do after react
5371
5352
  if (this.topicArchiver.isArchived(threadId)) {
@@ -5407,7 +5388,7 @@ export class FleetManager {
5407
5388
  // Reconciled status path (not a bare add): a retry that later
5408
5389
  // succeeds replaces this ❌ with βœ… instead of leaving both. Chat and
5409
5390
  // thread travel separately so Telegram addresses the supergroup.
5410
- this.finishDeliveryStatus(instanceName, msg.chatId, msg.messageId, "❌", msg.threadId || undefined);
5391
+ this.finishDeliveryStatus(instanceName, msg.chatId, msg.messageId, "failed", msg.threadId || undefined);
5411
5392
  }
5412
5393
  return;
5413
5394
  }
@@ -6853,7 +6834,129 @@ export class FleetManager {
6853
6834
  deliveryStatusChains = new Map();
6854
6835
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6855
6836
  pendingCliEnvProbes = new Map();
6856
- reactMessageStatus(instanceName, chatId, messageId, emoji, threadId) {
6837
+ /**
6838
+ * The status emojis for an instance (#1005): its own `status_emojis`, then
6839
+ * its channel's `options.status_emojis`, then the platform built-in. An
6840
+ * unusable value warns once and falls through, never failing the reaction.
6841
+ * `adapterId` pins the channel for paths that already know the bot (classic).
6842
+ */
6843
+ resolveStatusEmojisFor(instanceName, adapterId, adapter) {
6844
+ const worldId = adapterId ?? this.getInstanceAdapterId(instanceName);
6845
+ const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
6846
+ // The adapter that will react decides the vocabulary: a Telegram bot gets
6847
+ // Telegram's reaction set even if the channel lookup fell back elsewhere.
6848
+ const reacting = adapter ?? (worldId ? this.worlds.get(worldId)?.adapter : undefined) ?? this.adapter;
6849
+ const platform = reacting instanceof TelegramAdapter ? "telegram" : channel?.type;
6850
+ const resolved = resolveStatusEmojis({
6851
+ platform,
6852
+ platformConfig: channel?.options?.status_emojis,
6853
+ instanceConfig: this.fleetConfig?.instances[instanceName]?.status_emojis,
6854
+ onInvalid: (source, key, value, problem) => {
6855
+ const at = source === "instance" ? `instances.${instanceName}` : `channel ${channel?.id ?? platform ?? "?"}`;
6856
+ const once = `${at}:${key}:${String(value)}`;
6857
+ if (this.warnedStatusEmojis.has(once))
6858
+ return;
6859
+ this.warnedStatusEmojis.add(once);
6860
+ this.logger.warn({ at, key, value, problem }, `Ignoring status_emojis.${key}: ${problem} β€” using the default`);
6861
+ },
6862
+ });
6863
+ return { ...resolved, platform };
6864
+ }
6865
+ warnedStatusEmojis = new Set();
6866
+ /**
6867
+ * A Discord connection's server emojis for the Settings picker (#1005).
6868
+ * Cached per connection β€” a server's emoji set changes rarely β€” and one
6869
+ * fetch at a time; `refresh` forces a new one. Uses the running adapter, so
6870
+ * the bot token never leaves the server.
6871
+ */
6872
+ async listGuildEmojis(channelId, refresh = false) {
6873
+ const world = this.worlds.get(channelId);
6874
+ if (!world)
6875
+ return { ok: false, error: `connection "${channelId}" is not running` };
6876
+ if (world.type !== "discord")
6877
+ return { ok: false, error: "only Discord has server custom emoji" };
6878
+ const adapter = world.adapter;
6879
+ if (!adapter.listGuildEmojis)
6880
+ return { ok: false, error: "this adapter cannot list server emojis" };
6881
+ const cached = this.guildEmojiCache.get(channelId);
6882
+ if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
6883
+ return { ok: true, ...cached };
6884
+ let pending = this.guildEmojiFetches.get(channelId);
6885
+ if (!pending) {
6886
+ pending = adapter.listGuildEmojis().then(emojis => {
6887
+ const entry = { fetched_at: Date.now(), emojis };
6888
+ this.guildEmojiCache.set(channelId, entry);
6889
+ return entry;
6890
+ }).finally(() => this.guildEmojiFetches.delete(channelId));
6891
+ this.guildEmojiFetches.set(channelId, pending);
6892
+ }
6893
+ try {
6894
+ return { ok: true, ...(await pending) };
6895
+ }
6896
+ catch (e) {
6897
+ return { ok: false, error: `Discord refused the emoji list: ${e.message}` };
6898
+ }
6899
+ }
6900
+ static GUILD_EMOJI_TTL_MS = 10 * 60_000;
6901
+ guildEmojiCache = new Map();
6902
+ guildEmojiFetches = new Map();
6903
+ /** What the instructions tell `instanceName` not to react with (its own status set). */
6904
+ statusEmojiAvoidList(instanceName) {
6905
+ return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
6906
+ }
6907
+ /** The progress bubble's leading emoji, in the form the channel renders. */
6908
+ progressPrefixFor(instanceName) {
6909
+ const r = this.resolveStatusEmojisFor(instanceName);
6910
+ const builtin = builtinStatusEmojis(r.platform).progress_prefix;
6911
+ return r.progress_prefix === builtin ? undefined : textForm(r.platform, r.progress_prefix);
6912
+ }
6913
+ /**
6914
+ * Is this inbound reaction one of the fleet's own delivery-status stamps?
6915
+ * Those are machine indicators and must not reach any instance as a user
6916
+ * reaction (#1005 point 3). When every bot's user id is known, the check
6917
+ * keys on WHO reacted: a fleet bot's reaction is a status stamp only if it
6918
+ * is in that bot's instances' status set (other bot reactions are agent
6919
+ * signals and pass), and a human's reaction always passes β€” whatever emoji
6920
+ * a persona picked. Without every id, fall back to the emoji alone.
6921
+ */
6922
+ isOwnStatusReaction(r) {
6923
+ const key = reactionMatchKey(r.emoji, r.emojiId);
6924
+ const worlds = [...this.worlds.values()];
6925
+ const names = new Set([
6926
+ ...Object.keys(this.fleetConfig?.instances ?? {}),
6927
+ ...(this.classicChannels?.getAll?.() ?? []).map(c => c.instanceName),
6928
+ ...this.instanceWorldBinding.keys(),
6929
+ ]);
6930
+ if (worlds.length > 0 && worlds.every(w => !!w.botUserId)) {
6931
+ const reactorWorlds = new Set(worlds.filter(w => w.botUserId === r.userId).map(w => w.id));
6932
+ if (reactorWorlds.size === 0)
6933
+ return false; // a human, or a bot outside this fleet
6934
+ return [...names]
6935
+ .filter(n => reactorWorlds.has(this.getInstanceAdapterId(n) ?? ""))
6936
+ .some(n => statusMatchKeys(this.resolveStatusEmojisFor(n)).includes(key));
6937
+ }
6938
+ // Fallback: the built-in ladder (as before #1005) plus every value an
6939
+ // operator configured. Not the Telegram built-ins β€” πŸ‘Ž is AgEnD's failed
6940
+ // stamp there, but also far too common a human reaction to swallow blind.
6941
+ const keys = new Set(statusMatchKeys(builtinStatusEmojis(undefined)));
6942
+ for (const name of names) {
6943
+ const resolved = this.resolveStatusEmojisFor(name);
6944
+ const builtin = builtinStatusEmojis(resolved.platform);
6945
+ for (const k of STATUS_EMOJI_KEYS)
6946
+ if (resolved[k] !== builtin[k])
6947
+ keys.add(statusMatchKey(resolved[k]));
6948
+ }
6949
+ return keys.has(key);
6950
+ }
6951
+ /**
6952
+ * The "received" reaction for paths that react with the inbound message's
6953
+ * own adapter rather than through reactMessageStatus (classic bots).
6954
+ */
6955
+ receivedReactionFor(instanceName, adapter, adapterId) {
6956
+ const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
6957
+ return reactionForm(resolved.platform, resolved.received);
6958
+ }
6959
+ reactMessageStatus(instanceName, chatId, messageId, status, threadId) {
6857
6960
  // React via the adapter BOUND to this instance β€” NOT the first discord world.
6858
6961
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound πŸ‘€
6859
6962
  // (bound bot) and the delivery/confirm reactions (some other bot) come from
@@ -6861,40 +6964,41 @@ export class FleetManager {
6861
6964
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
6862
6965
  if (!adapter)
6863
6966
  return;
6864
- const statusEmoji = adapter instanceof TelegramAdapter
6865
- ? TELEGRAM_DELIVERY_STATUS_REACTIONS.get(emoji)
6866
- : emoji;
6867
- // Telegram has a deliberately smaller status vocabulary, and every mapped
6868
- // value is accepted by ReactionTypeEmoji. Unknown statuses stay a no-op.
6869
- if (!statusEmoji)
6967
+ const resolved = this.resolveStatusEmojisFor(instanceName, undefined, adapter);
6968
+ const configured = resolved[status];
6969
+ // Unknown statuses stay a no-op. Every resolved Telegram value has been
6970
+ // checked against the ReactionTypeEmoji set (status-emojis.ts).
6971
+ if (!configured)
6870
6972
  return;
6973
+ const statusEmoji = reactionForm(resolved.platform, configured);
6871
6974
  // Bot-scoped: sibling bots reacting on the same message must not clear
6872
6975
  // each other's state β€” every removal below targets this adapter's own
6873
6976
  // reactions (@me on Discord, the bot's list on Telegram).
6874
6977
  const adapterId = typeof adapter.id === "string"
6875
6978
  ? adapter.id : "?";
6876
6979
  const key = `${adapterId}:${chatId}:${threadId ?? ""}:${messageId}`;
6877
- this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId);
6980
+ this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId, status);
6878
6981
  }
6879
6982
  /**
6880
- * Apply a terminal delivery verdict. Telegram maps βœ… to πŸ‘€ and ❌ to πŸ‘Ž in
6881
- * reactMessageStatus; Discord continues to show the verdict emoji directly.
6983
+ * Apply a terminal delivery verdict. On Telegram the built-in delivered is
6984
+ * πŸ‘€ and failed is πŸ‘Ž; Discord shows βœ…/❌ β€” both configurable (#1005).
6882
6985
  */
6883
- finishDeliveryStatus(instanceName, chatId, messageId, emoji, threadId) {
6884
- this.reactMessageStatus(instanceName, chatId, messageId, emoji, threadId);
6986
+ finishDeliveryStatus(instanceName, chatId, messageId, status, threadId) {
6987
+ this.reactMessageStatus(instanceName, chatId, messageId, status, threadId);
6885
6988
  }
6886
- queueDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
6989
+ queueDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId, status) {
6887
6990
  const prev = this.deliveryStatusChains.get(key) ?? Promise.resolve();
6888
- const run = prev.then(() => this.applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId));
6991
+ const run = prev.then(() => this.applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId, status));
6889
6992
  this.deliveryStatusChains.set(key, run);
6890
6993
  void run.then(() => { if (this.deliveryStatusChains.get(key) === run)
6891
6994
  this.deliveryStatusChains.delete(key); }, () => { if (this.deliveryStatusChains.get(key) === run)
6892
6995
  this.deliveryStatusChains.delete(key); });
6893
6996
  }
6894
- async applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
6997
+ async applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId, status) {
6895
6998
  try {
6896
6999
  const target = adapter instanceof TelegramAdapter ? chatId : (threadId ?? chatId);
6897
- const prev = this.lastStatusEmoji.get(key);
7000
+ const last = this.lastStatusEmoji.get(key);
7001
+ const prev = last?.emoji;
6898
7002
  if (emoji == null) {
6899
7003
  if (prev && adapter.unreact) {
6900
7004
  await adapter.unreact(target, messageId, prev, threadId);
@@ -6909,15 +7013,16 @@ export class FleetManager {
6909
7013
  return;
6910
7014
  // Thread-aware adapters (Discord) react where the thread is; Telegram
6911
7015
  // addresses the supergroup chat and ignores the thread part.
6912
- // #972: add-only by default β€” only remove ❌ when leaving the failed
6913
- // state. πŸ‘€/⏳/βœ… stacking together is harmless; leaving ❌ visible
6914
- // after a recovery would be misleading.
6915
- if (prev === "❌" && adapter.unreact && !(adapter instanceof TelegramAdapter)) {
7016
+ // #972: add-only by default β€” only remove the failed emoji when leaving
7017
+ // the failed state. πŸ‘€/⏳/βœ… stacking together is harmless; leaving ❌
7018
+ // visible after a recovery would be misleading. `prev` is the exact
7019
+ // form it was added with, so a custom emoji comes off too (#1005).
7020
+ if (last?.status === "failed" && adapter.unreact && !(adapter instanceof TelegramAdapter)) {
6916
7021
  // Best effort: a failed removal must not block the new status.
6917
- await adapter.unreact(target, messageId, prev, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
7022
+ await adapter.unreact(target, messageId, last.emoji, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
6918
7023
  }
6919
7024
  await adapter.react(target, messageId, emoji, threadId);
6920
- this.lastStatusEmoji.set(key, emoji);
7025
+ this.lastStatusEmoji.set(key, { emoji, status });
6921
7026
  }
6922
7027
  catch (e) {
6923
7028
  this.logger.debug({ err: e.message }, "Message status react failed");
@@ -8124,11 +8229,12 @@ export class FleetManager {
8124
8229
  return;
8125
8230
  }
8126
8231
  const publication = this.beginCancelButtonPublication(instanceName, correlationId);
8232
+ const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName));
8127
8233
  try {
8128
8234
  const sent = await adapter.notifyAlert(chatId, {
8129
8235
  type: "cancel",
8130
8236
  instanceName,
8131
- message: "πŸ‘€ 處理中…",
8237
+ message: initialProgressText,
8132
8238
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
8133
8239
  }, threadId ? { threadId } : undefined);
8134
8240
  publication.inFlight = false;
@@ -8147,7 +8253,7 @@ export class FleetManager {
8147
8253
  // Matches the text notifyAlert just posted, so the first 60s tick does
8148
8254
  // not re-edit identical text β€” which put a "(edited)" mark on Discord
8149
8255
  // with nothing visibly changed.
8150
- lastProgressText: "πŸ‘€ 處理中…",
8256
+ lastProgressText: initialProgressText,
8151
8257
  // A reply can re-post the same in-flight bubble below the reply. Carry
8152
8258
  // its current list into that replacement; fresh inbound work must start
8153
8259
  // empty even if the daemon's reset broadcast is still in flight.
@@ -8234,14 +8340,16 @@ export class FleetManager {
8234
8340
  * progress indicator (#409) β€” the channel showed nothing at all during long work,
8235
8341
  * and once the agent had replied once there was no sign it was still going.
8236
8342
  */
8237
- static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
8343
+ static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS, prefix) {
8344
+ // A configured progress_prefix (#1005) leads both phases; the built-in
8345
+ // switches πŸ‘€ β†’ ⏳ once the elapsed time shows.
8238
8346
  if (elapsedMs < minElapsedMs)
8239
- return "πŸ‘€ 處理中…";
8347
+ return `${prefix ?? "πŸ‘€"} 處理中…`;
8240
8348
  const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
8241
8349
  const detail = FleetManager.sanitizeActivity(activity);
8242
8350
  return detail
8243
- ? `⏳ 處理中… (ε·²ι€²θ‘Œ ${elapsed} Β· ${detail})`
8244
- : `⏳ 處理中… (ε·²ι€²θ‘Œ ${elapsed})`;
8351
+ ? `${prefix ?? "⏳"} 處理中… (ε·²ι€²θ‘Œ ${elapsed} Β· ${detail})`
8352
+ : `${prefix ?? "⏳"} 處理中… (ε·²ι€²θ‘Œ ${elapsed})`;
8245
8353
  }
8246
8354
  /** Render elapsed time consistently in live and retained progress bubbles. */
8247
8355
  static formatProgressElapsed(elapsedMs) {
@@ -8337,13 +8445,13 @@ export class FleetManager {
8337
8445
  * wiped by the next elapsed tick (#528 trap 2).
8338
8446
  */
8339
8447
  composeBubbleText(entry) {
8340
- return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
8448
+ return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress, this.progressPrefixFor(entry.instanceName));
8341
8449
  }
8342
8450
  /** Pure composition of header + tool list, exposed for tests. */
8343
- static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
8451
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress, prefix) {
8344
8452
  const header = FleetManager.progressText(elapsedMs,
8345
8453
  // The single-line activity detail is redundant once a tool list exists.
8346
- progress ? undefined : activity, minElapsedMs);
8454
+ progress ? undefined : activity, minElapsedMs, prefix);
8347
8455
  return progress ? `${header}\n${progress}` : header;
8348
8456
  }
8349
8457
  /** Recompose and edit the bubble in place; skips when nothing changed. */
@@ -9963,7 +10071,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9963
10071
  const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
9964
10072
  const collabReactChatId = msg.threadId ?? msg.chatId;
9965
10073
  if (classicAdapter && collabReactChatId && msg.messageId) {
9966
- classicAdapter.react(collabReactChatId, msg.messageId, "πŸ‘€")
10074
+ classicAdapter.react(collabReactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicAdapter, msg.adapterId))
9967
10075
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
9968
10076
  }
9969
10077
  // Block /raw bypass β€” check the mention-stripped text so the self-marker
@@ -10068,7 +10176,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10068
10176
  }
10069
10177
  if (msg.chatId && msg.messageId) {
10070
10178
  const reactChatId = msg.threadId ?? msg.chatId;
10071
- classicMsgAdapter.react(reactChatId, msg.messageId, "πŸ‘€")
10179
+ classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
10072
10180
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10073
10181
  if (saved) {
10074
10182
  const savedEmoji = msg.source === "telegram"