@songsid/agend 2.1.11 → 2.1.12-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +23 -15
  2. package/README.zh-TW.md +50 -11
  3. package/dist/agent-cli.js +36 -4
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +3 -1
  6. package/dist/agent-endpoint.js +7 -1
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/backend/claude-code.d.ts +4 -0
  9. package/dist/backend/claude-code.js +65 -2
  10. package/dist/backend/claude-code.js.map +1 -1
  11. package/dist/backend/session-signals.d.ts +154 -0
  12. package/dist/backend/session-signals.js +430 -0
  13. package/dist/backend/session-signals.js.map +1 -0
  14. package/dist/backend/types.d.ts +25 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.d.ts +38 -1
  17. package/dist/channel/adapters/discord.js +131 -8
  18. package/dist/channel/adapters/discord.js.map +1 -1
  19. package/dist/channel/adapters/telegram.d.ts +19 -1
  20. package/dist/channel/adapters/telegram.js +55 -0
  21. package/dist/channel/adapters/telegram.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +5 -1
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/channel/message-queue.d.ts +6 -0
  25. package/dist/channel/message-queue.js +21 -0
  26. package/dist/channel/message-queue.js.map +1 -1
  27. package/dist/channel/sticker-download.d.ts +14 -0
  28. package/dist/channel/sticker-download.js +42 -0
  29. package/dist/channel/sticker-download.js.map +1 -0
  30. package/dist/channel/tool-router.js +12 -5
  31. package/dist/channel/tool-router.js.map +1 -1
  32. package/dist/channel/types.d.ts +52 -0
  33. package/dist/classic-bindings.d.ts +48 -0
  34. package/dist/classic-bindings.js +128 -0
  35. package/dist/classic-bindings.js.map +1 -0
  36. package/dist/classic-channel-manager.d.ts +21 -0
  37. package/dist/classic-channel-manager.js +31 -0
  38. package/dist/classic-channel-manager.js.map +1 -1
  39. package/dist/cli-flags.d.ts +8 -0
  40. package/dist/cli-flags.js +22 -0
  41. package/dist/cli-flags.js.map +1 -0
  42. package/dist/config-validator.js +25 -0
  43. package/dist/config-validator.js.map +1 -1
  44. package/dist/daemon.d.ts +61 -0
  45. package/dist/daemon.js +454 -19
  46. package/dist/daemon.js.map +1 -1
  47. package/dist/delivery-outbox.d.ts +8 -0
  48. package/dist/delivery-outbox.js.map +1 -1
  49. package/dist/delivery-reconciliation.d.ts +13 -0
  50. package/dist/delivery-reconciliation.js +71 -7
  51. package/dist/delivery-reconciliation.js.map +1 -1
  52. package/dist/delivery-reconciliation.test.js +115 -2
  53. package/dist/delivery-reconciliation.test.js.map +1 -1
  54. package/dist/event-loop-watch.d.ts +25 -0
  55. package/dist/event-loop-watch.js +40 -0
  56. package/dist/event-loop-watch.js.map +1 -0
  57. package/dist/fleet-manager.d.ts +52 -4
  58. package/dist/fleet-manager.js +323 -20
  59. package/dist/fleet-manager.js.map +1 -1
  60. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +4 -2
  61. package/dist/instance-lifecycle.js +4 -2
  62. package/dist/instance-lifecycle.js.map +1 -1
  63. package/dist/interaction-observation.d.ts +32 -0
  64. package/dist/interaction-observation.js +118 -0
  65. package/dist/interaction-observation.js.map +1 -0
  66. package/dist/kiro-engine-status.d.ts +2 -0
  67. package/dist/kiro-engine-status.js +3 -1
  68. package/dist/kiro-engine-status.js.map +1 -1
  69. package/dist/locale.js +26 -0
  70. package/dist/locale.js.map +1 -1
  71. package/dist/outbound-handlers.d.ts +2 -0
  72. package/dist/outbound-handlers.js +28 -11
  73. package/dist/outbound-handlers.js.map +1 -1
  74. package/dist/outbound-schemas.d.ts +15 -1
  75. package/dist/outbound-schemas.js +20 -2
  76. package/dist/outbound-schemas.js.map +1 -1
  77. package/dist/output-log-tail.d.ts +21 -0
  78. package/dist/output-log-tail.js +89 -0
  79. package/dist/output-log-tail.js.map +1 -0
  80. package/dist/tool-permissions.js +8 -1
  81. package/dist/tool-permissions.js.map +1 -1
  82. package/dist/turn-reply-guard.d.ts +7 -0
  83. package/dist/turn-reply-guard.js +11 -0
  84. package/dist/turn-reply-guard.js.map +1 -1
  85. package/dist/turn-resume.d.ts +80 -0
  86. package/dist/turn-resume.js +217 -0
  87. package/dist/turn-resume.js.map +1 -0
  88. package/dist/ui/view.html +8 -2
  89. package/dist/usage/format-rich.js +6 -1
  90. package/dist/usage/format-rich.js.map +1 -1
  91. package/dist/usage/i18n.d.ts +2 -0
  92. package/dist/usage/i18n.js +14 -1
  93. package/dist/usage/i18n.js.map +1 -1
  94. package/dist/usage/providers.d.ts +21 -0
  95. package/dist/usage/providers.js +36 -7
  96. package/dist/usage/providers.js.map +1 -1
  97. package/dist/usage/usage-api.d.ts +9 -0
  98. package/dist/usage/usage-api.js +31 -2
  99. package/dist/usage/usage-api.js.map +1 -1
  100. package/package.json +1 -1
@@ -26,6 +26,7 @@ import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_
26
26
  import { EventLog } from "./event-log.js";
27
27
  import { AdapterWorld } from "./adapter-world.js";
28
28
  import { CostGuard, formatCents } from "./cost-guard.js";
29
+ import { startEventLoopWatch } from "./event-loop-watch.js";
29
30
  import { TmuxManager } from "./tmux-manager.js";
30
31
  import { AccessManager } from "./channel/access-manager.js";
31
32
  import { IpcClient } from "./channel/ipc-bridge.js";
@@ -75,6 +76,7 @@ import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
75
76
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
76
77
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
77
78
  import { validateFleetConfig } from "./config-validator.js";
79
+ import { presentationState, interactionSummary } from "./interaction-observation.js";
78
80
  import { readLastInboundAt } from "./daemon.js";
79
81
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
80
82
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
@@ -97,7 +99,8 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
97
99
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
98
100
  const SELF_RESTART_DEADLINE_MS = 300_000;
99
101
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
100
- import { instanceCredentialProfile } from "./backend/credential-profile.js";
102
+ import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
103
+ import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
101
104
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
102
105
  import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
103
106
  import { launchFullRestartHelper } from "./full-restart.js";
@@ -297,6 +300,21 @@ export function deriveSpawnConcurrency(freeMemMB, cores) {
297
300
  const REPLY_OBLIGATION_SWEEP_MS = 30_000;
298
301
  const REPLY_REMINDER_GRACE_MS = 60_000;
299
302
  const DEFAULT_REPLY_OVERDUE_MINUTES = 15;
303
+ /**
304
+ * The filter `list_emojis` and `list_stickers` take (#1226). Values may come from the agent CLI as strings, so a
305
+ * limit is read as a number and the flags accept "true". No limit means all of them — never zero.
306
+ */
307
+ function emojiListFilter(opts) {
308
+ const name = typeof opts.name === "string" ? opts.name.trim().toLowerCase() : "";
309
+ const n = Number(opts.limit);
310
+ const flag = (v) => v === true || v === "true";
311
+ return {
312
+ matches: (...texts) => !name || texts.some(t => t.toLowerCase().includes(name)),
313
+ limit: Number.isInteger(n) && n >= 1 ? n : Infinity,
314
+ primaryOnly: flag(opts.primary_only),
315
+ withImageUrls: flag(opts.with_image_urls),
316
+ };
317
+ }
300
318
  export class FleetManager {
301
319
  dataDir;
302
320
  static signalTarget = null;
@@ -550,6 +568,7 @@ export class FleetManager {
550
568
  /** Days of event/activity history to keep. */
551
569
  static EVENT_LOG_RETENTION_DAYS = 30;
552
570
  watchdogTimer = null;
571
+ eventLoopWatch = null;
553
572
  startedAt = 0;
554
573
  stormOpenNotifyTimer = null;
555
574
  memoryLogAt = null;
@@ -1774,6 +1793,21 @@ export class FleetManager {
1774
1793
  return null;
1775
1794
  return this.instanceStateCache.get(name)?.state ?? null;
1776
1795
  }
1796
+ /** Pure read of the single daemon-owned observation; no probe/capture or second tracker. */
1797
+ getInstanceInteraction(name) {
1798
+ if (this.lifecycle.isPaused(name) || this.instanceProcessStatus.has(name))
1799
+ return null;
1800
+ const daemon = this.lifecycle.daemons.get(name);
1801
+ if (daemon?.getProcessStatus?.() !== "running")
1802
+ return null;
1803
+ return daemon.getInteractionSnapshot?.() ?? null;
1804
+ }
1805
+ instancePresentation(name) {
1806
+ const execution_state = this.getInstanceExecutionState(name);
1807
+ const interaction = this.getInstanceInteraction(name);
1808
+ return { state: presentationState(execution_state, interaction), execution_state,
1809
+ interaction, interaction_summary: interactionSummary(interaction) };
1810
+ }
1777
1811
  /**
1778
1812
  * Subscription providers currently relevant to this fleet. Stopped/crashed
1779
1813
  * rows do not keep a usage card alive, while persisted paused instances do.
@@ -1805,6 +1839,34 @@ export class FleetManager {
1805
1839
  }
1806
1840
  return providers;
1807
1841
  }
1842
+ /** #1220: the login a running classic instance was launched on, from its own config; null when not running. */
1843
+ classicLaunchedProfile(name) {
1844
+ const launched = this.daemons.get(name)?.getConfigSnapshot?.();
1845
+ return launched ? profileOf(launched.backend_options, launched.backend ?? "claude-code") : null;
1846
+ }
1847
+ /**
1848
+ * #1220: whether a running classic instance's login differs from what its
1849
+ * configuration now says — compared with what it was launched on, not with an
1850
+ * earlier reading of the config (a reload consumes that), and with an invalid
1851
+ * setting as a value of its own rather than the shared login it would fall to.
1852
+ * A switch on the same backend that loses the conversation (kiro) starts fresh.
1853
+ */
1854
+ classicProfileChange(name, backendChanged) {
1855
+ const launched = this.classicLaunchedProfile(name);
1856
+ if (!launched || !this.classicChannels)
1857
+ return { changed: false, startsFresh: false };
1858
+ const next = this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults);
1859
+ if (profileKey(launched) === profileKey(next))
1860
+ return { changed: false, startsFresh: false };
1861
+ const backend = this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend);
1862
+ return {
1863
+ changed: true,
1864
+ // A backend change keeps its own (existing) restart semantics.
1865
+ startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
1866
+ from: profileKey(launched),
1867
+ to: profileKey(next),
1868
+ };
1869
+ }
1808
1870
  /** `[instance, effective backend, credential profile]` for everything that is
1809
1871
  * running or paused — the one place both usage views agree on who is live. */
1810
1872
  activeBackendBindings() {
@@ -1823,8 +1885,9 @@ export class FleetManager {
1823
1885
  }
1824
1886
  for (const channel of this.classicChannels?.getAll() ?? []) {
1825
1887
  const backend = this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend);
1826
- // Classic channels carry no backend_options, so they run the shared login.
1827
- add(channel.instanceName, backend, null);
1888
+ // The login it was launched on; before a launch, the one it would get (#1220).
1889
+ add(channel.instanceName, backend, profileName(this.classicLaunchedProfile(channel.instanceName)
1890
+ ?? this.classicChannels.getCredentialProfileByInstance(channel.instanceName, this.fleetConfig?.defaults)));
1828
1891
  }
1829
1892
  return bindings;
1830
1893
  }
@@ -2693,9 +2756,15 @@ export class FleetManager {
2693
2756
  const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
2694
2757
  const modelChanged = oldModels.get(ch.instanceName) !== newModel;
2695
2758
  const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
2696
- if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
2697
- this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
2759
+ // #1220: a running CLI keeps the login it was launched with, so a new
2760
+ // credential_profile only takes effect through a restart.
2761
+ const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
2762
+ if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
2763
+ this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
2764
+ ...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
2698
2765
  await this.stopInstance(ch.instanceName).catch(() => { });
2766
+ if (profileSwitchStartsFresh)
2767
+ this.writeFreshStartMarker(ch.instanceName);
2699
2768
  // Small delay to let tmux window clean up
2700
2769
  await new Promise(r => setTimeout(r, 2000));
2701
2770
  // The manager already holds the new backend/model/auto-pause; the
@@ -4012,6 +4081,10 @@ export class FleetManager {
4012
4081
  // instance would get a restart loop. Those conditions surface through /health
4013
4082
  // (which now returns 503) and through the General-topic notifications instead.
4014
4083
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
4084
+ // #1231: a stalled loop is what turns a slash command into "did not respond";
4085
+ // put each long stall in the log instead of leaving it to be guessed at.
4086
+ this.eventLoopWatch?.stop();
4087
+ this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
4015
4088
  // EventLog.prune() existed but was never called, so `events` and `activity`
4016
4089
  // grew without bound for the life of the install. Prune once at startup and
4017
4090
  // daily after that; the timer is unref'd so it never holds the loop open.
@@ -5897,7 +5970,16 @@ export class FleetManager {
5897
5970
  // still in flight and about to succeed. One real send, everyone gets its
5898
5971
  // outcome; a genuinely failed send clears the entry so a retry passes.
5899
5972
  if (tool === "reply") {
5900
- const ticket = this.replyDeduper.begin(instanceName, String(args.text ?? ""), Array.isArray(args.files) ? args.files : []);
5973
+ // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
5974
+ const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
5975
+ if (stickerProblem) {
5976
+ respond(null, stickerProblem);
5977
+ return;
5978
+ }
5979
+ const stickers = Array.isArray(args.stickers) ? args.stickers : [];
5980
+ const ticket = this.replyDeduper.begin(instanceName,
5981
+ // A reply with stickers is not the same reply as its text alone.
5982
+ stickers.length ? `${String(args.text ?? "")}\u0000stickers:${stickers.join(",")}` : String(args.text ?? ""), Array.isArray(args.files) ? args.files : []);
5901
5983
  if (ticket.duplicate) {
5902
5984
  this.logger.info({ instanceName }, "Concurrent duplicate reply joined — awaiting the original send's outcome");
5903
5985
  ticket.subscribe(respond);
@@ -6188,7 +6270,8 @@ export class FleetManager {
6188
6270
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
6189
6271
  : type === "fleet_task" ? "fleet_task_response"
6190
6272
  : type === "fleet_set_display_name" ? "fleet_display_name_response"
6191
- : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
6273
+ : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis"
6274
+ || type === "fleet_list_stickers" || type === "fleet_preview_stickers" ? "fleet_persona_emoji_response"
6192
6275
  : "fleet_description_response";
6193
6276
  ipc.send({ type: responseType, fleetRequestId, error: message });
6194
6277
  }
@@ -6214,21 +6297,24 @@ export class FleetManager {
6214
6297
  this.handleSetDescription(name, msg);
6215
6298
  return;
6216
6299
  }
6217
- if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
6300
+ if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis"
6301
+ || type === "fleet_list_stickers" || type === "fleet_preview_stickers") {
6218
6302
  this.handlePersonaEmoji(name, msg);
6219
6303
  return;
6220
6304
  }
6221
6305
  }
6222
- /** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
6306
+ /** `list_emojis` / `set_persona_emoji` / `preview_emojis` / `list_stickers` / `preview_stickers` over IPC; the agent endpoint calls the same methods. */
6223
6307
  handlePersonaEmoji(instanceName, msg) {
6224
6308
  const fleetRequestId = msg.fleetRequestId;
6225
6309
  const payload = (msg.payload ?? {});
6226
6310
  const ipc = this.instanceIpcClients.get(instanceName);
6227
6311
  if (!ipc)
6228
6312
  return;
6229
- const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
6313
+ const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true, payload)
6230
6314
  : msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
6231
- : this.setPersonaEmoji(instanceName, payload);
6315
+ : msg.type === "fleet_list_stickers" ? this.listStickersFor(instanceName, payload)
6316
+ : msg.type === "fleet_preview_stickers" ? this.previewStickers(instanceName, payload)
6317
+ : this.setPersonaEmoji(instanceName, payload);
6232
6318
  op.then((r) => typeof r.error === "string"
6233
6319
  ? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
6234
6320
  : ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
@@ -6579,8 +6665,14 @@ export class FleetManager {
6579
6665
  return { error: `Instance '${instance}' not found` };
6580
6666
  return { display_name: name };
6581
6667
  }
6582
- async handleListEmojisHttp(instance, refresh) {
6583
- return this.listEmojisFor(instance, refresh);
6668
+ async handleListEmojisHttp(instance, refresh, args = {}) {
6669
+ return this.listEmojisFor(instance, refresh, args);
6670
+ }
6671
+ async handleListStickersHttp(instance, args) {
6672
+ return this.listStickersFor(instance, args);
6673
+ }
6674
+ async handlePreviewStickersHttp(instance, args) {
6675
+ return this.previewStickers(instance, args);
6584
6676
  }
6585
6677
  async handleSetPersonaEmojiHttp(instance, args) {
6586
6678
  return this.setPersonaEmoji(instance, args);
@@ -7311,7 +7403,7 @@ export class FleetManager {
7311
7403
  * bot can draw on — the same lists, and the same resolution, as the
7312
7404
  * Settings picker (#1005/#1021), so an agent and an operator see one truth.
7313
7405
  */
7314
- async listEmojisFor(instanceName, refresh = false) {
7406
+ async listEmojisFor(instanceName, refresh = false, opts = {}) {
7315
7407
  // Seeing is not setting: a ClassicBot instance has no per-instance stamp,
7316
7408
  // but it reacts in its own channel like any bot and must be able to see
7317
7409
  // the emojis it can use (only set_persona_emoji refuses it).
@@ -7334,18 +7426,25 @@ export class FleetManager {
7334
7426
  ...(classic ? { note: "ClassicBot instances have no per-instance status emojis: the statuses above are the connection's, which an operator sets in Settings. You can still use any emoji listed here in your own reactions." } : {}),
7335
7427
  };
7336
7428
  if (platform === "discord" && worldId) {
7429
+ // #1226: a busy server's list was ~10k characters for an agent that wanted one emoji. Filter by name, cap
7430
+ // the count, keep to the primary server if asked, and leave the image URLs out unless asked
7431
+ // (preview_emojis is how an agent looks at one).
7432
+ const filter = emojiListFilter(opts);
7337
7433
  const listed = await this.listGuildEmojis(worldId, refresh);
7434
+ let left = filter.limit;
7338
7435
  out.server_emojis = listed.ok
7339
- ? listed.guilds.map(g => g.emojis
7436
+ ? listed.guilds.filter(g => !filter.primaryOnly || g.primary).map(g => g.emojis
7340
7437
  ? {
7341
7438
  server: g.name || g.id, primary: g.primary,
7342
- emojis: g.emojis.filter(e => e.available).map(e => {
7439
+ emojis: g.emojis.filter(e => e.available && filter.matches(e.name)).filter(() => left-- > 0).map(e => {
7343
7440
  const value = customEmojiValue(e);
7344
- return { value, image_url: emojiImageUrl(value) };
7441
+ return filter.withImageUrls ? { value, image_url: emojiImageUrl(value) } : { value };
7345
7442
  }),
7346
7443
  }
7347
7444
  : { server: g.name || g.id, primary: g.primary, error: g.error })
7348
7445
  : { error: listed.error };
7446
+ if (!filter.withImageUrls)
7447
+ out.server_emojis_note = "Image URLs are left out; preview_emojis downloads the ones you want to look at (or pass with_image_urls).";
7349
7448
  }
7350
7449
  return out;
7351
7450
  }
@@ -7403,6 +7502,193 @@ export class FleetManager {
7403
7502
  }
7404
7503
  return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
7405
7504
  }
7505
+ // ── Stickers (#1226) ─────────────────────────────────────────────────────
7506
+ /** Where an instance talks: its connection, and its chat (and topic). The same address its notices go to. */
7507
+ instanceChatTarget(instanceName) {
7508
+ const worldId = this.getInstanceAdapterId(instanceName);
7509
+ const adapter = (worldId ? this.worlds.get(worldId)?.adapter : undefined) ?? this.getAdapterForInstance(instanceName) ?? this.adapter ?? undefined;
7510
+ const topic = this.fleetConfig?.instances[instanceName]?.topic_id;
7511
+ const groupId = this.getChannelConfig(worldId)?.group_id;
7512
+ if (topic != null && groupId != null)
7513
+ return { worldId, adapter, chatId: String(groupId), threadId: String(topic) };
7514
+ const classicChat = this.classicChannels?.getChannelIdByInstance(instanceName);
7515
+ return { worldId, adapter, chatId: classicChat ?? (groupId != null ? String(groupId) : undefined) };
7516
+ }
7517
+ static STICKER_LIST_TTL_MS = 10 * 60_000;
7518
+ /** Keyed `<connection>\0<scope key>` (a Discord channel, or "set:<name>"). */
7519
+ stickerCache = new Map();
7520
+ /** One sticker list through the adapter, cached; `refresh` forces a fetch. Errors are the platform's, reworded. */
7521
+ async stickerListFor(worldId, adapter, target, refresh = false) {
7522
+ const key = `${worldId}\0${target.set ? `set:${target.set}` : `chat:${target.threadId ?? target.chatId ?? ""}`}`;
7523
+ const cached = this.stickerCache.get(key);
7524
+ if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.STICKER_LIST_TTL_MS)
7525
+ return cached.list;
7526
+ const list = await adapter.listStickers(target);
7527
+ this.stickerCache.set(key, { fetched_at: Date.now(), list });
7528
+ return list;
7529
+ }
7530
+ /** The Telegram sticker sets an instance lists by default: its connection's `options.sticker_sets`. */
7531
+ defaultStickerSets(worldId) {
7532
+ const sets = this.getChannelConfig(worldId)?.options?.sticker_sets;
7533
+ return Array.isArray(sets) ? sets.filter((x) => typeof x === "string" && x.trim() !== "").slice(0, 10) : [];
7534
+ }
7535
+ /**
7536
+ * `list_stickers` (#1226): the stickers this instance can send where it talks, one shape on both platforms —
7537
+ * `{ id, name, emoji_or_tags, format }` and no image URLs (preview_stickers shows them). Discord: its channel's
7538
+ * server only — a bot cannot send another server's stickers, so listing them would only cost context.
7539
+ * Telegram: the set named, or the connection's `options.sticker_sets`.
7540
+ */
7541
+ async listStickersFor(instanceName, args) {
7542
+ if (!this.ownInstanceConfig(instanceName) && !this.isClassicPersonaTarget(instanceName)) {
7543
+ return { error: this.personaEmojiMissing(instanceName) };
7544
+ }
7545
+ const { worldId, adapter, chatId, threadId } = this.instanceChatTarget(instanceName);
7546
+ if (!worldId || !adapter?.listStickers)
7547
+ return { error: "this channel has no stickers to list" };
7548
+ const filter = emojiListFilter(args);
7549
+ const refresh = args.refresh === true || args.refresh === "true";
7550
+ const telegram = adapter.type === "telegram";
7551
+ const requested = typeof args.set === "string" && args.set.trim() ? [args.set.trim()] : [];
7552
+ const sets = telegram ? (requested.length ? requested : this.defaultStickerSets(worldId)) : [];
7553
+ if (telegram && !sets.length) {
7554
+ return { error: "name a sticker set: set=<name> (the <name> in t.me/addstickers/<name>), or have an operator add options.sticker_sets to this connection" };
7555
+ }
7556
+ const targets = telegram ? sets.map(set => ({ set })) : [{ chatId, threadId }];
7557
+ const lists = [];
7558
+ let left = filter.limit;
7559
+ for (const target of targets) {
7560
+ try {
7561
+ const list = await this.stickerListFor(worldId, adapter, target, refresh);
7562
+ const stickers = list.stickers
7563
+ .filter(st => st.available && filter.matches(st.name, st.emoji_or_tags))
7564
+ .filter(() => left-- > 0)
7565
+ .map(({ id, name, emoji_or_tags, format }) => ({ id, name, emoji_or_tags, format }));
7566
+ lists.push({ scope: list.scope, stickers });
7567
+ }
7568
+ catch (err) {
7569
+ lists.push({ scope: target.set ? `set ${target.set}` : "this server", error: `cannot list stickers: ${err.message}` });
7570
+ }
7571
+ }
7572
+ return {
7573
+ platform: adapter.type,
7574
+ lists,
7575
+ note: "Send one with reply({ stickers: [id] }) — up to 3, never in the text. preview_stickers shows what they look like.",
7576
+ };
7577
+ }
7578
+ /**
7579
+ * `preview_stickers` (#1226), the sticker twin of preview_emojis: the fleet downloads a still picture of each named
7580
+ * sticker and returns a local path to Read. Only ids this instance's list_stickers returned (from the cache it
7581
+ * filled) — nothing the caller writes reaches a URL. A Lottie sticker, or an animated one with no thumbnail, has no
7582
+ * still picture: it is listed as preview_unavailable.
7583
+ */
7584
+ async previewStickers(instanceName, args) {
7585
+ if (!this.ownInstanceConfig(instanceName) && !this.isClassicPersonaTarget(instanceName)) {
7586
+ return { error: this.personaEmojiMissing(instanceName) };
7587
+ }
7588
+ const wanted = args.stickers;
7589
+ if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(x => typeof x !== "string" || !x)) {
7590
+ return { error: "stickers is required: a list of ids from list_stickers" };
7591
+ }
7592
+ if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX)
7593
+ return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
7594
+ const { worldId, adapter } = this.instanceChatTarget(instanceName);
7595
+ if (!worldId || !adapter?.fetchStickerPreview)
7596
+ return { error: "this channel has no stickers to preview" };
7597
+ const known = new Map();
7598
+ for (const [key, entry] of this.stickerCache) {
7599
+ if (key.startsWith(`${worldId}\0`))
7600
+ for (const st of entry.list.stickers)
7601
+ known.set(st.id, st);
7602
+ }
7603
+ const previews = [];
7604
+ const unavailable = [];
7605
+ const errors = [];
7606
+ // In parallel: eight sequential downloads could outlast the tool's budget.
7607
+ const outcomes = await Promise.all(wanted.map(async (id) => {
7608
+ const st = known.get(id);
7609
+ if (!st)
7610
+ return { id, error: "not a sticker list_stickers returned here; list them first" };
7611
+ try {
7612
+ return { id, st, picture: await adapter.fetchStickerPreview(st) };
7613
+ }
7614
+ catch (err) {
7615
+ return { id, error: `download failed: ${err.message}` };
7616
+ }
7617
+ }));
7618
+ for (const o of outcomes) {
7619
+ if ("error" in o) {
7620
+ errors.push({ sticker: o.id, error: o.error });
7621
+ continue;
7622
+ }
7623
+ if (!o.picture) {
7624
+ unavailable.push({ sticker: o.id, name: o.st.name, reason: `preview_unavailable: a ${o.st.format} sticker has no still picture` });
7625
+ continue;
7626
+ }
7627
+ previews.push({ sticker: o.id, name: o.st.name, path: this.storeStickerPreview(o.id, o.picture) });
7628
+ }
7629
+ return { previews, unavailable, errors, note: "Read each path to see the sticker (a still picture)." };
7630
+ }
7631
+ /** A preview under the inbox, named by a hash of the id (a Telegram file_id is long), pruned after a day. */
7632
+ storeStickerPreview(id, picture) {
7633
+ const dir = join(this.dataDir, "inbox", "sticker-previews");
7634
+ mkdirSync(dir, { recursive: true });
7635
+ const now = Date.now();
7636
+ for (const name of readdirSync(dir)) {
7637
+ try {
7638
+ if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
7639
+ unlinkSync(join(dir, name));
7640
+ }
7641
+ catch { /* raced */ }
7642
+ }
7643
+ const path = join(dir, `${createHash("sha256").update(id).digest("hex").slice(0, 24)}.${picture.ext}`);
7644
+ const tmp = `${path}.${process.pid}.tmp`;
7645
+ writeFileSync(tmp, picture.bytes);
7646
+ renameSync(tmp, path);
7647
+ return path;
7648
+ }
7649
+ /**
7650
+ * `reply.stickers` (#1226), checked before anything is sent — a refused sticker is an error the agent sees, never
7651
+ * a reply that silently arrives without it. At most 3, text or stickers required. Discord: each must be an available
7652
+ * sticker of the server the reply goes to (another server's cannot be sent). Telegram: any sticker's file_id can be
7653
+ * sent anywhere; only its shape is checked here, and Telegram's own refusal still comes back as the reply's error.
7654
+ */
7655
+ async replyStickerProblem(adapter, args, threadId, worldId) {
7656
+ const raw = args.stickers;
7657
+ const hasText = typeof args.text === "string" && args.text.length > 0;
7658
+ if (raw === undefined || (Array.isArray(raw) && raw.length === 0))
7659
+ return hasText ? null : "reply: text is required (or stickers)";
7660
+ if (!Array.isArray(raw) || raw.some(x => typeof x !== "string" || !x.trim()))
7661
+ return "reply: stickers must be a list of sticker ids from list_stickers";
7662
+ const ids = raw;
7663
+ if (ids.length > 3)
7664
+ return "reply: at most 3 stickers per message";
7665
+ if (new Set(ids).size !== ids.length)
7666
+ return "reply: the same sticker twice";
7667
+ if (!adapter.sendStickers || !adapter.listStickers)
7668
+ return "reply: this channel cannot send stickers";
7669
+ if (adapter.type === "telegram") {
7670
+ const bad = ids.find(id => !/^[A-Za-z0-9_-]{10,255}$/.test(id));
7671
+ return bad ? `reply: ${bad} is not a Telegram sticker id (use an id from list_stickers)` : null;
7672
+ }
7673
+ const target = { chatId: typeof args.chat_id === "string" ? args.chat_id : undefined, threadId };
7674
+ let list;
7675
+ try {
7676
+ list = await this.stickerListFor(worldId ?? adapter.id ?? adapter.type, adapter, target);
7677
+ if (ids.some(id => !list.stickers.some(st => st.id === id)))
7678
+ list = await this.stickerListFor(worldId ?? adapter.id ?? adapter.type, adapter, target, true);
7679
+ }
7680
+ catch (err) {
7681
+ return `reply: cannot check the stickers for this channel: ${err.message}`;
7682
+ }
7683
+ for (const id of ids) {
7684
+ const st = list.stickers.find(x => x.id === id);
7685
+ if (!st)
7686
+ return `reply: sticker ${id} cannot be sent here — only stickers of this channel's server can (${list.scope}); call list_stickers`;
7687
+ if (!st.available)
7688
+ return `reply: sticker ${st.name} (${id}) is unavailable on this server (it lost the boost level it needs)`;
7689
+ }
7690
+ return null;
7691
+ }
7406
7692
  static EMOJI_PREVIEW_MAX = 8;
7407
7693
  static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
7408
7694
  static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
@@ -12123,6 +12409,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12123
12409
  ...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
12124
12410
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
12125
12411
  };
12412
+ // #1220: a channel's own backend_options (credential_profile) override the
12413
+ // fleet defaults' per backend, as a fleet.yaml instance's do.
12414
+ config.backend_options = mergeBackendOptions(config.backend_options, classicIdentity?.backendOptions);
12415
+ // ClassicBot has no validator in front of its launch, and a backend that
12416
+ // cannot read a profile name falls back to the shared login. Refuse here,
12417
+ // so a bad name never runs the agent on the wrong subscription.
12418
+ const profile = profileOf(config.backend_options, config.backend ?? "claude-code");
12419
+ if (profile.state === "invalid") {
12420
+ throw new Error(`Classic instance '${instanceName}' has an invalid credential_profile (${profile.reason}) — `
12421
+ + "fix it in classicBot.yaml; it will not start on another login");
12422
+ }
12126
12423
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
12127
12424
  await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
12128
12425
  }
@@ -12209,6 +12506,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12209
12506
  clearInterval(this.watchdogTimer);
12210
12507
  this.watchdogTimer = null;
12211
12508
  }
12509
+ this.eventLoopWatch?.stop();
12510
+ this.eventLoopWatch = null;
12212
12511
  // A login/install window is a dedicated tmux server with its own TTL
12213
12512
  // timer and HTTP listener living in THIS process: without an explicit
12214
12513
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
@@ -12631,10 +12930,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12631
12930
  const backend = this.classicChannels.getBackend(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.backend);
12632
12931
  const model = this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model);
12633
12932
  const autoPauseAfter = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
12634
- if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter) {
12635
- this.logger.info({ instanceName: ch.instanceName }, "Classic cold config changed — restarting");
12933
+ const profile = this.classicProfileChange(ch.instanceName, old.backend !== backend);
12934
+ if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
12935
+ this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
12636
12936
  observe?.(ch.instanceName, "restart", "running");
12637
12937
  await this.stopInstance(ch.instanceName).catch(() => { });
12938
+ if (profile.startsFresh)
12939
+ this.writeFreshStartMarker(ch.instanceName);
12638
12940
  await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
12639
12941
  observe?.(ch.instanceName, "restart", "done");
12640
12942
  continue;
@@ -14247,7 +14549,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14247
14549
  lastActivity,
14248
14550
  currentTask,
14249
14551
  idle: this.getInstanceIdle(inst.name),
14250
- state: this.getInstanceExecutionState(inst.name),
14552
+ ...this.instancePresentation(inst.name),
14251
14553
  };
14252
14554
  });
14253
14555
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -14504,6 +14806,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14504
14806
  backend,
14505
14807
  effort,
14506
14808
  effort_source,
14809
+ ...this.instancePresentation(name),
14507
14810
  };
14508
14811
  });
14509
14812
  return {