@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
@@ -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
  /**
@@ -286,6 +267,10 @@ export function deriveSpawnConcurrency(freeMemMB, cores) {
286
267
  const byMemory = Math.floor(freeMemMB / 300);
287
268
  return Math.max(2, Math.min(10, byMemory, Math.max(1, cores)));
288
269
  }
270
+ /** #926: how often reply obligations are checked, the owner-reminder grace, and the default overdue window. */
271
+ const REPLY_OBLIGATION_SWEEP_MS = 30_000;
272
+ const REPLY_REMINDER_GRACE_MS = 60_000;
273
+ const DEFAULT_REPLY_OVERDUE_MINUTES = 15;
289
274
  export class FleetManager {
290
275
  dataDir;
291
276
  static signalTarget = null;
@@ -303,6 +288,8 @@ export class FleetManager {
303
288
  deliveryPumpScheduled = false;
304
289
  deliveryPumpRunning = false;
305
290
  deliveryPumpTimer = null;
291
+ /** #926: periodic reply-obligation sweep (owner reminders, requester overdue notices). */
292
+ replyObligationTimer = null;
306
293
  activeDurableTargets = new Set();
307
294
  /** Live view of lifecycle.daemons β€” used throughout; not deprecated. */
308
295
  get daemons() { return this.lifecycle.daemons; }
@@ -595,6 +582,14 @@ export class FleetManager {
595
582
  }
596
583
  });
597
584
  outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
585
+ this.replyObligationTimer = setInterval(() => this.sweepReplyObligations(), REPLY_OBLIGATION_SWEEP_MS);
586
+ this.replyObligationTimer.unref?.();
587
+ // #856: the text the target daemon received differs from what was
588
+ // admitted. Transport has never been seen to do this; a warning and the
589
+ // attempt row's flag are the whole response until it has.
590
+ outbox.on("content_digest_mismatch", (event) => {
591
+ this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
592
+ });
598
593
  outbox.on("expired", (event) => {
599
594
  this.scheduleDeliveryOutboxPump();
600
595
  if (event.count)
@@ -652,8 +647,38 @@ export class FleetManager {
652
647
  this.logger.error({ err, source: name, operationId: prior.operationId }, "Could not persist post-restart delivery outcome notice");
653
648
  }
654
649
  }
650
+ // #926: a restart kills the turn that was working on a request, and a
651
+ // resumed CLI does not pick it back up. Remind the owner once per restart.
652
+ try {
653
+ outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, since: new Date() });
654
+ }
655
+ catch (err) {
656
+ this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
657
+ }
655
658
  this.scheduleDeliveryOutboxPump();
656
659
  }
660
+ /**
661
+ * #926: the two reply-obligation safety nets. Idle owners with an unanswered
662
+ * request get one reminder (after the grace); requesters get one overdue
663
+ * notice when the owner is not working and nothing came back in time.
664
+ */
665
+ sweepReplyObligations(now = new Date()) {
666
+ const outbox = this.deliveryOutbox;
667
+ if (!outbox || this.shuttingDown)
668
+ return;
669
+ const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
670
+ try {
671
+ for (const name of this.daemons.keys()) {
672
+ if (stateOf(name) === "idle")
673
+ outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
674
+ }
675
+ const minutes = this.fleetConfig?.defaults?.reply_overdue_minutes ?? DEFAULT_REPLY_OVERDUE_MINUTES;
676
+ outbox.notifyOverdueReplyObligations({ now, overdueMs: minutes * 60_000, ownerIdle: name => stateOf(name) !== "working" });
677
+ }
678
+ catch (err) {
679
+ this.logger.error({ err }, "Reply-obligation sweep failed");
680
+ }
681
+ }
657
682
  scheduleDeliveryOutboxPump(delayMs = 0) {
658
683
  if (!this.deliveryOutbox || this.shuttingDown)
659
684
  return;
@@ -4688,7 +4713,7 @@ export class FleetManager {
4688
4713
  this.logger.debug({ emoji: r.emoji, chatId: r.chatId }, "Reaction in an unrouted channel β€” ignoring");
4689
4714
  return;
4690
4715
  }
4691
- if (DELIVERY_STATUS_EMOJIS.has(r.emoji)) {
4716
+ if (this.isOwnStatusReaction(r)) {
4692
4717
  this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
4693
4718
  return;
4694
4719
  }
@@ -5229,7 +5254,7 @@ export class FleetManager {
5229
5254
  // path, so a later delivery status replaces (not stacks onto) this
5230
5255
  // acknowledgement.
5231
5256
  if (msg.chatId && msg.messageId) {
5232
- this.reactMessageStatus(generalInstance, msg.chatId, msg.messageId, "πŸ‘€", msg.threadId || undefined);
5257
+ this.reactMessageStatus(generalInstance, msg.chatId, msg.messageId, "received", msg.threadId || undefined);
5233
5258
  }
5234
5259
  this.warnIfRateLimited(generalInstance, msg);
5235
5260
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
@@ -5321,7 +5346,7 @@ export class FleetManager {
5321
5346
  // status path, so a later delivery status replaces (not stacks onto) this
5322
5347
  // acknowledgement. Same bound-adapter routing as the status path itself.
5323
5348
  if (msg.chatId && msg.messageId) {
5324
- this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "πŸ‘€", msg.threadId || undefined);
5349
+ this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "received", msg.threadId || undefined);
5325
5350
  }
5326
5351
  // These may hit Discord API (topic icon, archive) β€” do after react
5327
5352
  if (this.topicArchiver.isArchived(threadId)) {
@@ -5363,7 +5388,7 @@ export class FleetManager {
5363
5388
  // Reconciled status path (not a bare add): a retry that later
5364
5389
  // succeeds replaces this ❌ with βœ… instead of leaving both. Chat and
5365
5390
  // thread travel separately so Telegram addresses the supergroup.
5366
- this.finishDeliveryStatus(instanceName, msg.chatId, msg.messageId, "❌", msg.threadId || undefined);
5391
+ this.finishDeliveryStatus(instanceName, msg.chatId, msg.messageId, "failed", msg.threadId || undefined);
5367
5392
  }
5368
5393
  return;
5369
5394
  }
@@ -6809,7 +6834,129 @@ export class FleetManager {
6809
6834
  deliveryStatusChains = new Map();
6810
6835
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6811
6836
  pendingCliEnvProbes = new Map();
6812
- 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) {
6813
6960
  // React via the adapter BOUND to this instance β€” NOT the first discord world.
6814
6961
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound πŸ‘€
6815
6962
  // (bound bot) and the delivery/confirm reactions (some other bot) come from
@@ -6817,40 +6964,41 @@ export class FleetManager {
6817
6964
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
6818
6965
  if (!adapter)
6819
6966
  return;
6820
- const statusEmoji = adapter instanceof TelegramAdapter
6821
- ? TELEGRAM_DELIVERY_STATUS_REACTIONS.get(emoji)
6822
- : emoji;
6823
- // Telegram has a deliberately smaller status vocabulary, and every mapped
6824
- // value is accepted by ReactionTypeEmoji. Unknown statuses stay a no-op.
6825
- 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)
6826
6972
  return;
6973
+ const statusEmoji = reactionForm(resolved.platform, configured);
6827
6974
  // Bot-scoped: sibling bots reacting on the same message must not clear
6828
6975
  // each other's state β€” every removal below targets this adapter's own
6829
6976
  // reactions (@me on Discord, the bot's list on Telegram).
6830
6977
  const adapterId = typeof adapter.id === "string"
6831
6978
  ? adapter.id : "?";
6832
6979
  const key = `${adapterId}:${chatId}:${threadId ?? ""}:${messageId}`;
6833
- this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId);
6980
+ this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId, status);
6834
6981
  }
6835
6982
  /**
6836
- * Apply a terminal delivery verdict. Telegram maps βœ… to πŸ‘€ and ❌ to πŸ‘Ž in
6837
- * 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).
6838
6985
  */
6839
- finishDeliveryStatus(instanceName, chatId, messageId, emoji, threadId) {
6840
- this.reactMessageStatus(instanceName, chatId, messageId, emoji, threadId);
6986
+ finishDeliveryStatus(instanceName, chatId, messageId, status, threadId) {
6987
+ this.reactMessageStatus(instanceName, chatId, messageId, status, threadId);
6841
6988
  }
6842
- queueDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
6989
+ queueDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId, status) {
6843
6990
  const prev = this.deliveryStatusChains.get(key) ?? Promise.resolve();
6844
- 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));
6845
6992
  this.deliveryStatusChains.set(key, run);
6846
6993
  void run.then(() => { if (this.deliveryStatusChains.get(key) === run)
6847
6994
  this.deliveryStatusChains.delete(key); }, () => { if (this.deliveryStatusChains.get(key) === run)
6848
6995
  this.deliveryStatusChains.delete(key); });
6849
6996
  }
6850
- async applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
6997
+ async applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId, status) {
6851
6998
  try {
6852
6999
  const target = adapter instanceof TelegramAdapter ? chatId : (threadId ?? chatId);
6853
- const prev = this.lastStatusEmoji.get(key);
7000
+ const last = this.lastStatusEmoji.get(key);
7001
+ const prev = last?.emoji;
6854
7002
  if (emoji == null) {
6855
7003
  if (prev && adapter.unreact) {
6856
7004
  await adapter.unreact(target, messageId, prev, threadId);
@@ -6865,15 +7013,16 @@ export class FleetManager {
6865
7013
  return;
6866
7014
  // Thread-aware adapters (Discord) react where the thread is; Telegram
6867
7015
  // addresses the supergroup chat and ignores the thread part.
6868
- // #972: add-only by default β€” only remove ❌ when leaving the failed
6869
- // state. πŸ‘€/⏳/βœ… stacking together is harmless; leaving ❌ visible
6870
- // after a recovery would be misleading.
6871
- 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)) {
6872
7021
  // Best effort: a failed removal must not block the new status.
6873
- 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"));
6874
7023
  }
6875
7024
  await adapter.react(target, messageId, emoji, threadId);
6876
- this.lastStatusEmoji.set(key, emoji);
7025
+ this.lastStatusEmoji.set(key, { emoji, status });
6877
7026
  }
6878
7027
  catch (e) {
6879
7028
  this.logger.debug({ err: e.message }, "Message status react failed");
@@ -8080,11 +8229,12 @@ export class FleetManager {
8080
8229
  return;
8081
8230
  }
8082
8231
  const publication = this.beginCancelButtonPublication(instanceName, correlationId);
8232
+ const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName));
8083
8233
  try {
8084
8234
  const sent = await adapter.notifyAlert(chatId, {
8085
8235
  type: "cancel",
8086
8236
  instanceName,
8087
- message: "πŸ‘€ 處理中…",
8237
+ message: initialProgressText,
8088
8238
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
8089
8239
  }, threadId ? { threadId } : undefined);
8090
8240
  publication.inFlight = false;
@@ -8103,7 +8253,7 @@ export class FleetManager {
8103
8253
  // Matches the text notifyAlert just posted, so the first 60s tick does
8104
8254
  // not re-edit identical text β€” which put a "(edited)" mark on Discord
8105
8255
  // with nothing visibly changed.
8106
- lastProgressText: "πŸ‘€ 處理中…",
8256
+ lastProgressText: initialProgressText,
8107
8257
  // A reply can re-post the same in-flight bubble below the reply. Carry
8108
8258
  // its current list into that replacement; fresh inbound work must start
8109
8259
  // empty even if the daemon's reset broadcast is still in flight.
@@ -8190,14 +8340,16 @@ export class FleetManager {
8190
8340
  * progress indicator (#409) β€” the channel showed nothing at all during long work,
8191
8341
  * and once the agent had replied once there was no sign it was still going.
8192
8342
  */
8193
- 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.
8194
8346
  if (elapsedMs < minElapsedMs)
8195
- return "πŸ‘€ 處理中…";
8347
+ return `${prefix ?? "πŸ‘€"} 處理中…`;
8196
8348
  const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
8197
8349
  const detail = FleetManager.sanitizeActivity(activity);
8198
8350
  return detail
8199
- ? `⏳ 處理中… (ε·²ι€²θ‘Œ ${elapsed} Β· ${detail})`
8200
- : `⏳ 處理中… (ε·²ι€²θ‘Œ ${elapsed})`;
8351
+ ? `${prefix ?? "⏳"} 處理中… (ε·²ι€²θ‘Œ ${elapsed} Β· ${detail})`
8352
+ : `${prefix ?? "⏳"} 處理中… (ε·²ι€²θ‘Œ ${elapsed})`;
8201
8353
  }
8202
8354
  /** Render elapsed time consistently in live and retained progress bubbles. */
8203
8355
  static formatProgressElapsed(elapsedMs) {
@@ -8293,13 +8445,13 @@ export class FleetManager {
8293
8445
  * wiped by the next elapsed tick (#528 trap 2).
8294
8446
  */
8295
8447
  composeBubbleText(entry) {
8296
- 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));
8297
8449
  }
8298
8450
  /** Pure composition of header + tool list, exposed for tests. */
8299
- static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
8451
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress, prefix) {
8300
8452
  const header = FleetManager.progressText(elapsedMs,
8301
8453
  // The single-line activity detail is redundant once a tool list exists.
8302
- progress ? undefined : activity, minElapsedMs);
8454
+ progress ? undefined : activity, minElapsedMs, prefix);
8303
8455
  return progress ? `${header}\n${progress}` : header;
8304
8456
  }
8305
8457
  /** Recompose and edit the bubble in place; skips when nothing changed. */
@@ -9919,7 +10071,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9919
10071
  const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
9920
10072
  const collabReactChatId = msg.threadId ?? msg.chatId;
9921
10073
  if (classicAdapter && collabReactChatId && msg.messageId) {
9922
- classicAdapter.react(collabReactChatId, msg.messageId, "πŸ‘€")
10074
+ classicAdapter.react(collabReactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicAdapter, msg.adapterId))
9923
10075
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
9924
10076
  }
9925
10077
  // Block /raw bypass β€” check the mention-stripped text so the self-marker
@@ -10024,7 +10176,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10024
10176
  }
10025
10177
  if (msg.chatId && msg.messageId) {
10026
10178
  const reactChatId = msg.threadId ?? msg.chatId;
10027
- classicMsgAdapter.react(reactChatId, msg.messageId, "πŸ‘€")
10179
+ classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
10028
10180
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10029
10181
  if (saved) {
10030
10182
  const savedEmoji = msg.source === "telegram"
@@ -11412,6 +11564,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11412
11564
  clearInterval(this.eventLogPruneTimer);
11413
11565
  this.eventLogPruneTimer = null;
11414
11566
  }
11567
+ if (this.replyObligationTimer) {
11568
+ clearInterval(this.replyObligationTimer);
11569
+ this.replyObligationTimer = null;
11570
+ }
11415
11571
  if (this.logRotateTimer) {
11416
11572
  clearInterval(this.logRotateTimer);
11417
11573
  this.logRotateTimer = null;