@songsid/agend 2.2.0-alpha.9 → 2.2.0-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 (173) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-cli-instructions.md +10 -7
  4. package/dist/agent-endpoint.js +21 -9
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-whats-new.d.ts +47 -0
  7. package/dist/agent-whats-new.js +141 -0
  8. package/dist/agent-whats-new.js.map +1 -0
  9. package/dist/auth-api.d.ts +2 -2
  10. package/dist/auth-api.js +2 -2
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/backend/kiro-engine-ledger.d.ts +5 -0
  13. package/dist/backend/kiro-engine-ledger.js +20 -0
  14. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  15. package/dist/backend/kiro-identity.d.ts +6 -0
  16. package/dist/backend/kiro-identity.js +19 -5
  17. package/dist/backend/kiro-identity.js.map +1 -1
  18. package/dist/backend/kiro.d.ts +14 -2
  19. package/dist/backend/kiro.js +84 -9
  20. package/dist/backend/kiro.js.map +1 -1
  21. package/dist/backend/types.d.ts +4 -2
  22. package/dist/backend/types.js.map +1 -1
  23. package/dist/channel/adapters/discord.d.ts +13 -1
  24. package/dist/channel/adapters/discord.js +34 -2
  25. package/dist/channel/adapters/discord.js.map +1 -1
  26. package/dist/channel/adapters/telegram.d.ts +11 -1
  27. package/dist/channel/adapters/telegram.js +28 -2
  28. package/dist/channel/adapters/telegram.js.map +1 -1
  29. package/dist/channel/mcp-server.js +9 -1
  30. package/dist/channel/mcp-server.js.map +1 -1
  31. package/dist/channel/mcp-tools.d.ts +11 -0
  32. package/dist/channel/mcp-tools.js +23 -12
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/channel/tool-router.js +7 -2
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +13 -1
  37. package/dist/cli.js +55 -16
  38. package/dist/cli.js.map +1 -1
  39. package/dist/command-table.js +1 -1
  40. package/dist/command-table.js.map +1 -1
  41. package/dist/config-validator.js +48 -0
  42. package/dist/config-validator.js.map +1 -1
  43. package/dist/cross-instance-envelope.d.ts +15 -2
  44. package/dist/cross-instance-envelope.js +27 -5
  45. package/dist/cross-instance-envelope.js.map +1 -1
  46. package/dist/daemon.d.ts +36 -0
  47. package/dist/daemon.js +157 -6
  48. package/dist/daemon.js.map +1 -1
  49. package/dist/delivery-outbox.d.ts +6 -0
  50. package/dist/delivery-outbox.js.map +1 -1
  51. package/dist/fleet-manager.d.ts +48 -0
  52. package/dist/fleet-manager.js +275 -6
  53. package/dist/fleet-manager.js.map +1 -1
  54. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  55. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
  56. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
  57. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  58. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  59. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  60. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  61. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  62. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  63. package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
  64. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  65. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  66. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  67. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  68. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
  69. package/dist/general-knowledge/steering/core-rules.md +8 -7
  70. package/dist/instance-avatar.d.ts +36 -0
  71. package/dist/instance-avatar.js +57 -0
  72. package/dist/instance-avatar.js.map +1 -0
  73. package/dist/instance-config-impact.js +5 -0
  74. package/dist/instance-config-impact.js.map +1 -1
  75. package/dist/instance-lifecycle.d.ts +4 -0
  76. package/dist/instance-lifecycle.js +3 -1
  77. package/dist/instance-lifecycle.js.map +1 -1
  78. package/dist/instructions.d.ts +2 -0
  79. package/dist/instructions.js +10 -7
  80. package/dist/instructions.js.map +1 -1
  81. package/dist/locale.js +36 -26
  82. package/dist/locale.js.map +1 -1
  83. package/dist/next-steps.js +1 -1
  84. package/dist/next-steps.js.map +1 -1
  85. package/dist/outbound-handlers.js +7 -4
  86. package/dist/outbound-handlers.js.map +1 -1
  87. package/dist/outbound-schemas.d.ts +8 -0
  88. package/dist/outbound-schemas.js +15 -5
  89. package/dist/outbound-schemas.js.map +1 -1
  90. package/dist/public-web-gateway.js +3 -0
  91. package/dist/public-web-gateway.js.map +1 -1
  92. package/dist/service-activation.js +4 -3
  93. package/dist/service-activation.js.map +1 -1
  94. package/dist/settings-api.js +71 -21
  95. package/dist/settings-api.js.map +1 -1
  96. package/dist/settings-change.js +70 -1
  97. package/dist/settings-change.js.map +1 -1
  98. package/dist/settings-effect.js +26 -4
  99. package/dist/settings-effect.js.map +1 -1
  100. package/dist/settings-http-confirmation.js +7 -0
  101. package/dist/settings-http-confirmation.js.map +1 -1
  102. package/dist/setup-form.d.ts +1 -1
  103. package/dist/setup-form.js +1 -1
  104. package/dist/status-emojis.d.ts +10 -3
  105. package/dist/status-emojis.js +11 -2
  106. package/dist/status-emojis.js.map +1 -1
  107. package/dist/tips.js +4 -4
  108. package/dist/tips.js.map +1 -1
  109. package/dist/transcript-monitor.d.ts +8 -0
  110. package/dist/transcript-monitor.js +26 -2
  111. package/dist/transcript-monitor.js.map +1 -1
  112. package/dist/transcript-sources.d.ts +54 -0
  113. package/dist/transcript-sources.js +188 -27
  114. package/dist/transcript-sources.js.map +1 -1
  115. package/dist/transcript-turns.d.ts +15 -2
  116. package/dist/transcript-turns.js +15 -2
  117. package/dist/transcript-turns.js.map +1 -1
  118. package/dist/types.d.ts +11 -0
  119. package/dist/ui/chat-emojis.js +33 -0
  120. package/dist/ui/chat-render.js +12 -0
  121. package/dist/ui/chat-thread.js +1 -1
  122. package/dist/ui/panel-chat.js +13 -3
  123. package/dist/ui/panel-needs.js +5 -2
  124. package/dist/ui/panel-settings.js +117 -26
  125. package/dist/ui/settings-dialogs.js +7 -2
  126. package/dist/ui/settings-model.js +2 -1
  127. package/dist/ui/settings-strings.js +38 -2
  128. package/dist/ui/shared/app-i18n.js +2 -2
  129. package/dist/ui/shared/app-route.js +1 -1
  130. package/dist/ui/shared/app.css +29 -1
  131. package/dist/ui/shared/instance-nav.js +77 -8
  132. package/dist/ui/shared/signin.js +7 -7
  133. package/dist/ui/shared/tokens.css +9 -0
  134. package/dist/ui/shared/view-strings.js +2 -2
  135. package/dist/ui/signin.html +1 -1
  136. package/dist/update-install.d.ts +15 -1
  137. package/dist/update-install.js +6 -0
  138. package/dist/update-install.js.map +1 -1
  139. package/dist/update-preflight.d.ts +100 -0
  140. package/dist/update-preflight.js +264 -0
  141. package/dist/update-preflight.js.map +1 -0
  142. package/dist/updater-caller.d.ts +31 -0
  143. package/dist/updater-caller.js +109 -0
  144. package/dist/updater-caller.js.map +1 -0
  145. package/dist/view-api.d.ts +1 -1
  146. package/dist/view-api.js +33 -8
  147. package/dist/view-api.js.map +1 -1
  148. package/dist/wake-coordinator.d.ts +11 -0
  149. package/dist/wake-coordinator.js +38 -4
  150. package/dist/wake-coordinator.js.map +1 -1
  151. package/dist/web-api.d.ts +11 -0
  152. package/dist/web-api.js +54 -0
  153. package/dist/web-api.js.map +1 -1
  154. package/dist/web-auth.d.ts +2 -2
  155. package/dist/web-auth.js +5 -3
  156. package/dist/web-auth.js.map +1 -1
  157. package/dist/web-host-guard.d.ts +3 -11
  158. package/dist/web-host-guard.js +3 -12
  159. package/dist/web-host-guard.js.map +1 -1
  160. package/dist/web-images.d.ts +47 -0
  161. package/dist/web-images.js +169 -0
  162. package/dist/web-images.js.map +1 -0
  163. package/dist/web-session.d.ts +36 -1
  164. package/dist/web-session.js +52 -5
  165. package/dist/web-session.js.map +1 -1
  166. package/dist/web-settings-patch.d.ts +21 -0
  167. package/dist/web-settings-patch.js +74 -0
  168. package/dist/web-settings-patch.js.map +1 -0
  169. package/dist/web-shell-routes.d.ts +1 -1
  170. package/dist/web-shell-routes.js +1 -1
  171. package/dist/web-shell-routes.js.map +1 -1
  172. package/dist/workflow-templates/default.md +1 -1
  173. package/package.json +1 -1
@@ -51,6 +51,7 @@ import { startEventLoopWatch } from "./event-loop-watch.js";
51
51
  import { TmuxManager } from "./tmux-manager.js";
52
52
  import { AccessManager } from "./channel/access-manager.js";
53
53
  import { IpcClient } from "./channel/ipc-bridge.js";
54
+ import { usesChannelAddress } from "./channel/mcp-tools.js";
54
55
  import { formatFleetTime } from "./tz-utils.js";
55
56
  import { createAdapter } from "./channel/factory.js";
56
57
  import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
@@ -132,6 +133,9 @@ import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtons
132
133
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
133
134
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
134
135
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
136
+ import { downloadStickerImage } from "./channel/sticker-download.js";
137
+ import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
138
+ import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
135
139
  import { envFileKeys } from "./token-env-name.js";
136
140
  import { discordGeneralChannelId } from "./discord-general.js";
137
141
  import { parseEnvText } from "./env-file.js";
@@ -180,7 +184,7 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
180
184
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
181
185
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
182
186
  import { isWebPageNavigation } from "./web-shell-routes.js";
183
- import { tokenEpoch, WebSessionStore } from "./web-session.js";
187
+ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
184
188
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
185
189
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
186
190
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
@@ -1274,7 +1278,20 @@ export class FleetManager {
1274
1278
  }
1275
1279
  queryDurableDeliveryStatus(callerInstance, selector) {
1276
1280
  this.ensureDeliveryOutbox();
1277
- return this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1281
+ const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1282
+ // #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
1283
+ return {
1284
+ ...page,
1285
+ items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
1286
+ ? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
1287
+ : item),
1288
+ };
1289
+ }
1290
+ /** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
1291
+ deliveryWaitingReason(target) {
1292
+ if (this.lifecycle.isPaused(target))
1293
+ return t("delivery.hold_state", "paused");
1294
+ return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
1278
1295
  }
1279
1296
  getDaemonBootId(instanceName) {
1280
1297
  return this.daemons.get(instanceName)?.bootId;
@@ -4152,6 +4169,33 @@ export class FleetManager {
4152
4169
  names.add(channel.instanceName);
4153
4170
  return [...names];
4154
4171
  }
4172
+ /** #1624: migration cannot infer ownership from the running daemon roster.
4173
+ * Stopped/paused ClassicBot entries count too; their workspace is exactly
4174
+ * the one startClassicInstance builds, not this FleetManager's dataDir.
4175
+ */
4176
+ kiroPeerWorkingDirectories(name) {
4177
+ if (!this.fleetConfig || !this.classicChannels)
4178
+ throw new Error("Kiro peer configuration is not loaded");
4179
+ const peers = [];
4180
+ for (const [peer, cfg] of Object.entries(this.fleetConfig.instances)) {
4181
+ if (peer === name || (cfg.backend ?? this.fleetConfig.defaults?.backend ?? "claude-code") !== "kiro-cli")
4182
+ continue;
4183
+ if (typeof cfg.working_directory !== "string" || !cfg.working_directory)
4184
+ throw new Error("Kiro peer cwd is unknown");
4185
+ peers.push(cfg.working_directory);
4186
+ }
4187
+ for (const ch of this.classicChannels.getAll()) {
4188
+ if (ch.instanceName === name) {
4189
+ if (Object.hasOwn(this.fleetConfig.instances, name))
4190
+ throw new Error("Kiro instance name has two configurations");
4191
+ continue;
4192
+ }
4193
+ if (this.classicChannels.getBackendByInstance(ch.instanceName, this.fleetConfig.defaults?.backend) === "kiro-cli") {
4194
+ peers.push(join(getAgendHome(), "workspaces", ch.instanceName));
4195
+ }
4196
+ }
4197
+ return peers;
4198
+ }
4155
4199
  /**
4156
4200
  * Probe the same executable set exposed by the web backend catalog.
4157
4201
  *
@@ -4549,6 +4593,9 @@ export class FleetManager {
4549
4593
  this.webSessions = new WebSessionStore({
4550
4594
  dataDir: this.dataDir,
4551
4595
  onWarn: message => this.logger.warn(message),
4596
+ // #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
4597
+ // (shortening ends the sessions it now outlives). The public link's policy stays fixed.
4598
+ localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
4552
4599
  });
4553
4600
  }
4554
4601
  if (!this.returningDevices) {
@@ -4734,6 +4781,42 @@ export class FleetManager {
4734
4781
  this.logger.info({ err: err?.message }, "The code-only follow-up was not sent; the sign-in message holds the code");
4735
4782
  }
4736
4783
  }
4784
+ /**
4785
+ * #1610: re-arm the public link's close prompt on the SAME message — as its confirm step (確定關閉 / 取消) or back as
4786
+ * the close button — with a fresh nonce carrying the same owner gate (owner, requester, exposure, message, adapter),
4787
+ * and swap the message's buttons keyboard-only: its text is the sign-in message, which must stay. A swap that fails
4788
+ * disarms the new nonce and leaves the link open (the General menu and /dashboard revoke still close it).
4789
+ */
4790
+ async armDashboardCloseStep(prev, confirm, data) {
4791
+ const nonce = randomBytes(16).toString("hex");
4792
+ const { timer: _timer, nonce: _old, ...rest } = prev;
4793
+ const entry = { ...rest, nonce, closeStep: confirm, createdAt: Date.now() };
4794
+ entry.timer = setTimeout(() => {
4795
+ if (this.pendingNonceButtons.get(nonce) !== entry)
4796
+ return;
4797
+ this.pendingNonceButtons.delete(nonce);
4798
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err }, "Failed to expire the public link's close prompt"));
4799
+ }, 5 * 60_000);
4800
+ entry.timer.unref?.();
4801
+ this.pendingNonceButtons.set(nonce, entry);
4802
+ const choices = confirm
4803
+ ? [{ id: `dashboard:${nonce}:confirm`, label: t("dashboard.public_close_confirm") }, { id: `dashboard:${nonce}:keep`, label: t("dashboard.public_close_keep") }]
4804
+ : [{ id: `dashboard:${nonce}:close`, label: t("dashboard.public_close") }];
4805
+ try {
4806
+ if (data.replaceClickedButtons)
4807
+ await data.replaceClickedButtons(choices);
4808
+ else if (prev.adapter.replaceMessageButtons)
4809
+ await prev.adapter.replaceMessageButtons(prev.chatId, prev.messageId ?? data.messageId, choices, prev.threadId);
4810
+ else
4811
+ throw new Error("adapter cannot replace buttons");
4812
+ }
4813
+ catch (err) {
4814
+ if (this.pendingNonceButtons.get(nonce) === entry)
4815
+ this.pendingNonceButtons.delete(nonce);
4816
+ clearTimeout(entry.timer);
4817
+ this.logger.warn({ err: err?.message }, "Could not swap the public link's close buttons; the link stays open");
4818
+ }
4819
+ }
4737
4820
  /** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
4738
4821
  logDashboardDmFailure(err) {
4739
4822
  const code = err?.code;
@@ -4771,7 +4854,11 @@ export class FleetManager {
4771
4854
  return posted !== null;
4772
4855
  }
4773
4856
  async handleDashboardCallback(data, adapterId, adapter) {
4774
- const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
4857
+ // #1610: `confirm`/`keep` answer the close button's confirm step and nothing else; `close` is never a confirm step.
4858
+ // A click of the wrong kind is refused like a non-admin's — unconsumed, so the real buttons still work.
4859
+ const kind = /:([a-z]+)$/.exec(data.callbackData)?.[1];
4860
+ const stepAction = kind === "confirm" || kind === "keep";
4861
+ const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close|confirm|keep)$/, data, adapterId, adapter, undefined, (userId, e) => this.isFleetAdmin(userId, adapterId) && stepAction === (e.closeStep === true));
4775
4862
  if (claimed === null)
4776
4863
  return false;
4777
4864
  if (claimed === "consumed")
@@ -4782,7 +4869,15 @@ export class FleetManager {
4782
4869
  data.ack?.(t("not_authorized"));
4783
4870
  return true;
4784
4871
  }
4785
- if (action === "close") {
4872
+ if (action === "close" || action === "keep") {
4873
+ // A tap on 關閉公開連結 closes nothing: it offers 確定關閉 / 取消 on the same message (the button sits right above
4874
+ // the code a phone taps to copy). 取消 puts the close button back. Both only re-arm the same, owner-gated prompt.
4875
+ if (!entry.publicExposureId)
4876
+ return true;
4877
+ await this.armDashboardCloseStep(entry, action === "close", data);
4878
+ return true;
4879
+ }
4880
+ if (action === "confirm") {
4786
4881
  if (!entry.publicExposureId)
4787
4882
  return true;
4788
4883
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
@@ -7662,10 +7757,12 @@ export class FleetManager {
7662
7757
  respond(null, "No adapter available");
7663
7758
  return;
7664
7759
  }
7760
+ // Only channel-addressing schemas receive ClassicBot routing metadata.
7761
+ // Non-channel calls (notably strict delivery_status) retain their arguments.
7665
7762
  // For classic instances: force chat_id to channelId and clear thread_id
7666
7763
  // (daemon may have set chat_id to guild_id which is wrong for DC; TG may have set thread_id which causes 'thread not found')
7667
7764
  const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
7668
- if (classicChannelId) {
7765
+ if (classicChannelId && usesChannelAddress(tool)) {
7669
7766
  args.chat_id = classicChannelId;
7670
7767
  // #1085: a Telegram forum ClassicBot replies in the topic it was asked
7671
7768
  // in. Any other thread_id (threadless Telegram, where it echoes the chat
@@ -9454,6 +9551,151 @@ export class FleetManager {
9454
9551
  /** Keyed `<connection>\0<server id>`. */
9455
9552
  guildEmojiCache = new Map();
9456
9553
  guildEmojiFetches = new Map();
9554
+ // ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
9555
+ webImageCaches = null;
9556
+ webImageCache(kind) {
9557
+ const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
9558
+ this.webImageCaches ??= {
9559
+ avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
9560
+ emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
9561
+ };
9562
+ return this.webImageCaches[kind];
9563
+ }
9564
+ /**
9565
+ * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
9566
+ * whatever an instance still names — #1635 review), not the primary, and answering for it alone.
9567
+ */
9568
+ ownBots() {
9569
+ const instances = this.fleetConfig?.instances ?? {};
9570
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
9571
+ const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
9572
+ const assigned = (name) => { const c = this.getInstanceAdapterId(name); return c && configured.has(c) ? c : undefined; };
9573
+ const assignments = Object.keys(instances).map(n => [n, assigned(n)]);
9574
+ for (const room of this.classicChannels?.getAll() ?? []) {
9575
+ if (!Object.hasOwn(instances, room.instanceName))
9576
+ assignments.push([room.instanceName, assigned(room.instanceName)]);
9577
+ }
9578
+ return ownBotConnections(this.getPrimaryAdapterId(), assignments);
9579
+ }
9580
+ /**
9581
+ * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
9582
+ * keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
9583
+ * face or colour, and the old one's late answers land under the old key (#1635 review). null while it has no id.
9584
+ */
9585
+ botOwner(connection) {
9586
+ const adapter = this.adapters.get(connection), id = adapter?.getBotUserId?.();
9587
+ return adapter && id ? { adapter, id } : null;
9588
+ }
9589
+ /** Still the same bot behind `connection` (after an await): the same adapter object and the same bot id. */
9590
+ botStillOwns(connection, owner) {
9591
+ return this.adapters.get(connection) === owner.adapter && owner.adapter.getBotUserId?.() === owner.id;
9592
+ }
9593
+ /** keyed `<connection>\0<bot id>\0<channel>`: the bot's colour there, refreshed at most daily (monotonic), one read at a time. */
9594
+ webAccents = new Map();
9595
+ webAccentReads = new Set();
9596
+ webAccent(connection, owner, channelId) {
9597
+ const adapter = owner.adapter;
9598
+ if (!adapter.botAccentColor)
9599
+ return null;
9600
+ const key = `${connection}\0${owner.id}\0${channelId ?? ""}`, now = performance.now(), cached = this.webAccents.get(key);
9601
+ if ((!cached || now >= cached.until) && !this.webAccentReads.has(key)) {
9602
+ this.webAccentReads.add(key);
9603
+ adapter.botAccentColor(channelId)
9604
+ // Keyed by the bot's id: a replaced bot's late answer lands under its own key, which the new bot never reads.
9605
+ .then(value => { this.webAccents.set(key, { value: accentColor(value), until: performance.now() + WEB_IMAGE_MAX_AGE_MS }); }, () => { this.webAccents.set(key, { value: cached?.value ?? null, until: performance.now() + WEB_IMAGE_RETRY_MS }); })
9606
+ .finally(() => this.webAccentReads.delete(key));
9607
+ }
9608
+ return cached?.value ?? null;
9609
+ }
9610
+ /** The cache key of an own bot's avatar, and its loader (which refuses an answer from a bot no longer there). */
9611
+ botAvatarSource(connection, owner) {
9612
+ return {
9613
+ key: `bot:${connection}:${owner.id}`,
9614
+ load: async () => {
9615
+ const got = await owner.adapter.fetchBotAvatar();
9616
+ if (!this.botStillOwns(connection, owner))
9617
+ throw new Error("the bot was replaced while its avatar was fetched");
9618
+ return got;
9619
+ },
9620
+ };
9621
+ }
9622
+ /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
9623
+ webIdentityImage(name, displayName, profileAvatar, own) {
9624
+ let profileVersion = null;
9625
+ if (profileAvatar && imageMimeForFile(profileAvatar)) {
9626
+ try {
9627
+ profileVersion = Math.floor(statSync(join(this.dataDir, "avatars", basename(profileAvatar))).mtimeMs).toString(36);
9628
+ }
9629
+ catch { /* removed */ }
9630
+ }
9631
+ const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
9632
+ const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
9633
+ const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
9634
+ const cfg = this.ownInstanceConfig(name);
9635
+ const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
9636
+ personaEmoji: cfg?.status_emojis?.delivered });
9637
+ // The ring belongs to the bot's own face: only when that is what is shown.
9638
+ const accent = !profileVersion && bot && connection && owner
9639
+ ? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
9640
+ : null;
9641
+ return accent ? { avatar, accent } : { avatar };
9642
+ }
9643
+ /** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
9644
+ webImage(kind, key) {
9645
+ if (kind === "bot") {
9646
+ if (![...this.ownBots().values()].includes(key))
9647
+ return null;
9648
+ const owner = this.botOwner(key);
9649
+ return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
9650
+ }
9651
+ const known = Object.hasOwn(this.fleetConfig?.instances ?? {}, key) || this.classicChannels?.getChannelIdByInstance(key) !== undefined;
9652
+ if (!known)
9653
+ return null;
9654
+ const file = profileIdentities(this.dataDir).get(key)?.avatar_path;
9655
+ const mime = file ? imageMimeForFile(file) : null;
9656
+ if (!file || !mime)
9657
+ return null;
9658
+ try {
9659
+ const path = join(this.dataDir, "avatars", basename(file));
9660
+ return { bytes: readFileSync(path), mime, version: Math.floor(statSync(path).mtimeMs).toString(36) };
9661
+ }
9662
+ catch {
9663
+ return null;
9664
+ }
9665
+ }
9666
+ /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
9667
+ async webEmojiList() {
9668
+ const adopted = [];
9669
+ for (const [id, world] of [...this.worlds]) {
9670
+ if (world.type !== "discord")
9671
+ continue;
9672
+ const listed = await this.listGuildEmojis(id);
9673
+ if (listed.ok)
9674
+ adopted.push({ id, world, emojis: listed.emojis });
9675
+ }
9676
+ // After the LAST await (#1635 review r2): only the lists whose world is still the one that was read — a connection
9677
+ // removed or replaced while a later list was awaited takes its servers along, whenever it went.
9678
+ const out = new Map();
9679
+ for (const a of adopted) {
9680
+ if (this.worlds.get(a.id) !== a.world)
9681
+ continue;
9682
+ for (const e of a.emojis)
9683
+ if (!out.has(e.id))
9684
+ out.set(e.id, { id: e.id, name: e.name, animated: e.animated });
9685
+ }
9686
+ return [...out.values()];
9687
+ }
9688
+ /** GET /ui/img/emoji/<id>: only an emoji of those servers; fetched once from Discord's CDN, then from the host cache. */
9689
+ async webEmoji(id) {
9690
+ if (!/^\d{15,25}$/.test(id))
9691
+ return null;
9692
+ const emoji = (await this.webEmojiList()).find(e => e.id === id);
9693
+ if (!emoji)
9694
+ return null;
9695
+ const image = await this.webImageCache("emoji").get(`emoji:${id}`, () => downloadStickerImage(`https://cdn.discordapp.com/emojis/${id}.${emoji.animated ? "gif" : "png"}?size=64`, { maxBytes: WEB_IMAGE_MAX_BYTES }));
9696
+ // Still an emoji of the fleet's servers once it is here (a connection removed meanwhile takes its servers along).
9697
+ return image && (await this.webEmojiList()).some(e => e.id === id) ? image : null;
9698
+ }
9457
9699
  /**
9458
9700
  * What `instanceName` may pick as its persona emoji (the `list_emojis`
9459
9701
  * tool): its current status set with where each value comes from, the
@@ -10641,6 +10883,15 @@ export class FleetManager {
10641
10883
  if (pending && pending.prefix !== prefix)
10642
10884
  pending = undefined;
10643
10885
  if (!match || !pending) {
10886
+ // #1610 (Fable r1): the same message may already carry the prompt's NEXT step (the public link's 確定關閉 / 取消
10887
+ // on the sign-in DM, armed by the first tap). A double tap, or a late second one from an old keyboard, lands
10888
+ // here with the spent nonce — and collapsing would wipe the message's text and the live buttons. The clicker is
10889
+ // told; the message is left alone.
10890
+ // #1617: and of the same bot — two bots' DM message ids can collide; another bot's live prompt says nothing here.
10891
+ if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.adapterId === callbackAdapterId && e.chatId === data.chatId && e.messageId === data.messageId)) {
10892
+ data.ack?.(t("buttons.stale_notice"));
10893
+ return "consumed";
10894
+ }
10644
10895
  const adapter = receivingAdapter ?? this.adapter;
10645
10896
  // The clicker always hears why nothing happens (#1133); collapsing the
10646
10897
  // dead buttons is a courtesy that can fail (>48h on Telegram, message gone).
@@ -17884,7 +18135,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17884
18135
  // Never the token: fleet.log is readable by anything that can read the
17885
18136
  // data dir, is copied into bug reports, and is tailed in shared terminals.
17886
18137
  // `/dashboard` and `agend web` are the ways to get an authorized link.
17887
- this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
18138
+ this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /web or `agend web`)");
17888
18139
  this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
17889
18140
  // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
17890
18141
  const bound = this.healthServer?.address();
@@ -18026,6 +18277,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18026
18277
  // /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
18027
18278
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
18028
18279
  const profiles = profileIdentities(this.dataDir);
18280
+ // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
18281
+ let ownBots = new Map();
18282
+ try {
18283
+ ownBots = this.ownBots();
18284
+ }
18285
+ catch (err) {
18286
+ this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
18287
+ }
18029
18288
  const instances = names.map(name => {
18030
18289
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
18031
18290
  let cost = 0;
@@ -18063,9 +18322,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18063
18322
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
18064
18323
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
18065
18324
  const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
18325
+ let image;
18326
+ try {
18327
+ image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
18328
+ }
18329
+ catch (err) {
18330
+ this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
18331
+ image = { avatar: avatarFor({ name, displayName: identity.display_name }) };
18332
+ }
18066
18333
  return {
18067
18334
  name,
18068
18335
  display_name: identity.display_name || undefined,
18336
+ avatar: image.avatar,
18337
+ ...(image.accent ? { accent: image.accent } : {}),
18069
18338
  description: identity.description || undefined,
18070
18339
  role: identity.role || undefined,
18071
18340
  tags: identity.tags,