@songsid/agend 2.2.0-beta.1 → 2.2.0-beta.3

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 (197) 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 +4 -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 +11 -1
  24. package/dist/channel/adapters/discord.js +23 -1
  25. package/dist/channel/adapters/discord.js.map +1 -1
  26. package/dist/channel/adapters/telegram.d.ts +19 -1
  27. package/dist/channel/adapters/telegram.js +60 -9
  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/telegram-markdown.d.ts +6 -0
  35. package/dist/channel/telegram-markdown.js +104 -0
  36. package/dist/channel/telegram-markdown.js.map +1 -0
  37. package/dist/channel/tool-router.js +9 -3
  38. package/dist/channel/tool-router.js.map +1 -1
  39. package/dist/channel/types.d.ts +14 -2
  40. package/dist/cli.js +55 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/command-table.js +1 -1
  43. package/dist/command-table.js.map +1 -1
  44. package/dist/config-validator.js +49 -0
  45. package/dist/config-validator.js.map +1 -1
  46. package/dist/cross-instance-envelope.d.ts +15 -2
  47. package/dist/cross-instance-envelope.js +27 -5
  48. package/dist/cross-instance-envelope.js.map +1 -1
  49. package/dist/daemon.d.ts +48 -0
  50. package/dist/daemon.js +250 -22
  51. package/dist/daemon.js.map +1 -1
  52. package/dist/delivery-outbox.d.ts +17 -2
  53. package/dist/delivery-outbox.js +13 -2
  54. package/dist/delivery-outbox.js.map +1 -1
  55. package/dist/delivery-reconciliation.d.ts +18 -1
  56. package/dist/delivery-reconciliation.js +67 -2
  57. package/dist/delivery-reconciliation.js.map +1 -1
  58. package/dist/fleet-manager.d.ts +63 -1
  59. package/dist/fleet-manager.js +314 -20
  60. package/dist/fleet-manager.js.map +1 -1
  61. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  62. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
  63. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
  64. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  65. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  66. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  67. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  68. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  69. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  70. package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
  71. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  72. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  73. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  74. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  75. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
  76. package/dist/general-knowledge/steering/core-rules.md +8 -7
  77. package/dist/instance-avatar.d.ts +36 -0
  78. package/dist/instance-avatar.js +57 -0
  79. package/dist/instance-avatar.js.map +1 -0
  80. package/dist/instance-config-impact.js +5 -0
  81. package/dist/instance-config-impact.js.map +1 -1
  82. package/dist/instance-lifecycle.d.ts +4 -0
  83. package/dist/instance-lifecycle.js +3 -1
  84. package/dist/instance-lifecycle.js.map +1 -1
  85. package/dist/instructions.d.ts +2 -0
  86. package/dist/instructions.js +10 -7
  87. package/dist/instructions.js.map +1 -1
  88. package/dist/locale.js +36 -26
  89. package/dist/locale.js.map +1 -1
  90. package/dist/next-steps.js +1 -1
  91. package/dist/next-steps.js.map +1 -1
  92. package/dist/outbound-handlers.d.ts +2 -0
  93. package/dist/outbound-handlers.js +28 -15
  94. package/dist/outbound-handlers.js.map +1 -1
  95. package/dist/outbound-schemas.d.ts +10 -2
  96. package/dist/outbound-schemas.js +15 -5
  97. package/dist/outbound-schemas.js.map +1 -1
  98. package/dist/public-web-gateway.js +7 -2
  99. package/dist/public-web-gateway.js.map +1 -1
  100. package/dist/service-activation.js +4 -3
  101. package/dist/service-activation.js.map +1 -1
  102. package/dist/settings-api.js +71 -21
  103. package/dist/settings-api.js.map +1 -1
  104. package/dist/settings-change.js +70 -1
  105. package/dist/settings-change.js.map +1 -1
  106. package/dist/settings-effect.js +26 -4
  107. package/dist/settings-effect.js.map +1 -1
  108. package/dist/settings-http-confirmation.js +7 -0
  109. package/dist/settings-http-confirmation.js.map +1 -1
  110. package/dist/setup-form.d.ts +1 -1
  111. package/dist/setup-form.js +1 -1
  112. package/dist/status-emojis.d.ts +10 -3
  113. package/dist/status-emojis.js +11 -2
  114. package/dist/status-emojis.js.map +1 -1
  115. package/dist/tips.js +4 -4
  116. package/dist/tips.js.map +1 -1
  117. package/dist/transcript-delta-reader.d.ts +3 -3
  118. package/dist/transcript-delta-reader.js +8 -8
  119. package/dist/transcript-delta-reader.js.map +1 -1
  120. package/dist/transcript-monitor.d.ts +8 -0
  121. package/dist/transcript-monitor.js +26 -2
  122. package/dist/transcript-monitor.js.map +1 -1
  123. package/dist/transcript-sources.d.ts +54 -0
  124. package/dist/transcript-sources.js +188 -27
  125. package/dist/transcript-sources.js.map +1 -1
  126. package/dist/transcript-turns.d.ts +15 -2
  127. package/dist/transcript-turns.js +15 -2
  128. package/dist/transcript-turns.js.map +1 -1
  129. package/dist/types.d.ts +11 -0
  130. package/dist/ui/chat-emojis.js +33 -0
  131. package/dist/ui/chat-render.js +12 -0
  132. package/dist/ui/chat-store.js +1 -1
  133. package/dist/ui/chat-strings.js +12 -12
  134. package/dist/ui/chat-thread.js +23 -4
  135. package/dist/ui/panel-chat.js +16 -3
  136. package/dist/ui/panel-needs.js +5 -2
  137. package/dist/ui/panel-settings.js +129 -31
  138. package/dist/ui/settings-dialogs.js +7 -2
  139. package/dist/ui/settings-model.js +2 -1
  140. package/dist/ui/settings-strings.js +44 -4
  141. package/dist/ui/shared/app-i18n.js +37 -15
  142. package/dist/ui/shared/app-prefs.js +249 -0
  143. package/dist/ui/shared/app-route.js +1 -1
  144. package/dist/ui/shared/app-shell.js +17 -4
  145. package/dist/ui/shared/app-store.js +3 -1
  146. package/dist/ui/shared/app.css +55 -0
  147. package/dist/ui/shared/app.js +6 -1
  148. package/dist/ui/shared/header-tools.js +55 -5
  149. package/dist/ui/shared/instance-nav.js +169 -32
  150. package/dist/ui/shared/panel-view.js +6 -0
  151. package/dist/ui/shared/signin.js +7 -7
  152. package/dist/ui/shared/theme.js +39 -7
  153. package/dist/ui/shared/ui-icons.js +1 -0
  154. package/dist/ui/shared/usage-dialog.js +13 -4
  155. package/dist/ui/shared/view-strings.js +22 -4
  156. package/dist/ui/signin.html +1 -1
  157. package/dist/update-install.d.ts +15 -1
  158. package/dist/update-install.js +6 -0
  159. package/dist/update-install.js.map +1 -1
  160. package/dist/update-preflight.d.ts +100 -0
  161. package/dist/update-preflight.js +264 -0
  162. package/dist/update-preflight.js.map +1 -0
  163. package/dist/updater-caller.d.ts +31 -0
  164. package/dist/updater-caller.js +109 -0
  165. package/dist/updater-caller.js.map +1 -0
  166. package/dist/view-api.d.ts +1 -1
  167. package/dist/view-api.js +38 -8
  168. package/dist/view-api.js.map +1 -1
  169. package/dist/wake-coordinator.d.ts +11 -0
  170. package/dist/wake-coordinator.js +38 -4
  171. package/dist/wake-coordinator.js.map +1 -1
  172. package/dist/web-api.d.ts +21 -1
  173. package/dist/web-api.js +157 -1
  174. package/dist/web-api.js.map +1 -1
  175. package/dist/web-auth.d.ts +2 -2
  176. package/dist/web-auth.js +7 -4
  177. package/dist/web-auth.js.map +1 -1
  178. package/dist/web-host-guard.d.ts +3 -11
  179. package/dist/web-host-guard.js +3 -12
  180. package/dist/web-host-guard.js.map +1 -1
  181. package/dist/web-images.d.ts +47 -0
  182. package/dist/web-images.js +169 -0
  183. package/dist/web-images.js.map +1 -0
  184. package/dist/web-prefs.d.ts +63 -0
  185. package/dist/web-prefs.js +263 -0
  186. package/dist/web-prefs.js.map +1 -0
  187. package/dist/web-session.d.ts +36 -1
  188. package/dist/web-session.js +53 -6
  189. package/dist/web-session.js.map +1 -1
  190. package/dist/web-settings-patch.d.ts +21 -0
  191. package/dist/web-settings-patch.js +74 -0
  192. package/dist/web-settings-patch.js.map +1 -0
  193. package/dist/web-shell-routes.d.ts +1 -1
  194. package/dist/web-shell-routes.js +1 -1
  195. package/dist/web-shell-routes.js.map +1 -1
  196. package/dist/workflow-templates/default.md +1 -1
  197. 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";
@@ -74,7 +75,7 @@ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, che
74
75
  import { TopicArchiver } from "./topic-archiver.js";
75
76
  import { StatuslineWatcher } from "./statusline-watcher.js";
76
77
  import { outboundHandlers } from "./outbound-handlers.js";
77
- import { DeliveryOutbox } from "./delivery-outbox.js";
78
+ import { DeliveryOutbox, isSilentScheduleDelivery } from "./delivery-outbox.js";
78
79
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
79
80
  // deciding to defer before submission. This is an alert threshold only: the
80
81
  // active manager/target generation pair owns the lane until the daemon reports
@@ -128,10 +129,14 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
128
129
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
129
130
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
130
131
  import { WebChatDiskStore } from "./web-chat-store.js";
132
+ import { WebPrefsStore } from "./web-prefs.js";
131
133
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
132
134
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
133
135
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
134
136
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
137
+ import { downloadStickerImage } from "./channel/sticker-download.js";
138
+ import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
139
+ import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
135
140
  import { envFileKeys } from "./token-env-name.js";
136
141
  import { discordGeneralChannelId } from "./discord-general.js";
137
142
  import { parseEnvText } from "./env-file.js";
@@ -180,7 +185,7 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
180
185
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
181
186
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
182
187
  import { isWebPageNavigation } from "./web-shell-routes.js";
183
- import { tokenEpoch, WebSessionStore } from "./web-session.js";
188
+ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
184
189
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
185
190
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
186
191
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
@@ -758,6 +763,15 @@ export class FleetManager {
758
763
  webChatStore = null;
759
764
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
760
765
  webFiles = new WebFileLedger();
766
+ webPrefsStore;
767
+ /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
768
+ get webPrefs() {
769
+ // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
770
+ return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
771
+ exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
772
+ || (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
773
+ }));
774
+ }
761
775
  /**
762
776
  * Read from disk on every access rather than cached at startup: `agend
763
777
  * web-token rotate` runs in a separate process, and a cached copy would keep
@@ -1215,8 +1229,13 @@ export class FleetManager {
1215
1229
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
1216
1230
  const row = outbox.get(event.deliveryId);
1217
1231
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
1218
- const notice = t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1219
- this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
1232
+ // #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
1233
+ const silent = isSilentScheduleDelivery(row);
1234
+ const notice = silent
1235
+ ? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
1236
+ : t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1237
+ this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
1238
+ cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
1220
1239
  this.notifyFleetError(notice);
1221
1240
  }
1222
1241
  }
@@ -1274,7 +1293,20 @@ export class FleetManager {
1274
1293
  }
1275
1294
  queryDurableDeliveryStatus(callerInstance, selector) {
1276
1295
  this.ensureDeliveryOutbox();
1277
- return this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1296
+ const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1297
+ // #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
1298
+ return {
1299
+ ...page,
1300
+ items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
1301
+ ? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
1302
+ : item),
1303
+ };
1304
+ }
1305
+ /** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
1306
+ deliveryWaitingReason(target) {
1307
+ if (this.lifecycle.isPaused(target))
1308
+ return t("delivery.hold_state", "paused");
1309
+ return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
1278
1310
  }
1279
1311
  getDaemonBootId(instanceName) {
1280
1312
  return this.daemons.get(instanceName)?.bootId;
@@ -3003,13 +3035,38 @@ export class FleetManager {
3003
3035
  }
3004
3036
  return posted;
3005
3037
  }
3006
- reserveWebChannelEcho(instanceName, sendEcho) {
3038
+ /**
3039
+ * #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
3040
+ * inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
3041
+ * id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
3042
+ */
3043
+ startWebChatProgress(instanceName) {
3044
+ if (this.shuttingDown)
3045
+ return Promise.resolve();
3046
+ return this.sendCancelButton(instanceName);
3047
+ }
3048
+ /**
3049
+ * `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
3050
+ * keeps its place in the order, but the lane's copy rules do not apply to it: its 5 s budget starts when its turn
3051
+ * starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
3052
+ * behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
3053
+ * any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
3054
+ * however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
3055
+ * deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
3056
+ */
3057
+ reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
3058
+ const copy = opts.copy !== false;
3007
3059
  const epoch = this.getDeliveryEpoch(instanceName);
3008
- const deadlineAt = performance.now() + 5_000;
3060
+ const queued = this.webChannelEchoTails.get(instanceName);
3061
+ // The last open non-copy ahead of this copy: its budget starts when that one finishes.
3062
+ const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
3063
+ let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
3009
3064
  let decide;
3010
3065
  const admission = new Promise(resolve => { decide = resolve; });
3011
- const queue = this.webChannelEchoTails.get(instanceName)
3066
+ const queue = queued
3012
3067
  ?? { tail: Promise.resolve(), pending: new Set() };
3068
+ const seq = queue.nextSeq ?? 0;
3069
+ queue.nextSeq = seq + 1;
3013
3070
  const previous = queue.tail;
3014
3071
  let resolveDone;
3015
3072
  const tail = new Promise(resolve => { resolveDone = resolve; });
@@ -3020,6 +3077,8 @@ export class FleetManager {
3020
3077
  if (done)
3021
3078
  return;
3022
3079
  done = true;
3080
+ if (!copy)
3081
+ queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
3023
3082
  clearTimeout(timer);
3024
3083
  queue.pending.delete(entry);
3025
3084
  if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
@@ -3027,7 +3086,7 @@ export class FleetManager {
3027
3086
  }
3028
3087
  resolveDone();
3029
3088
  };
3030
- const entry = { started: false, drop: () => {
3089
+ const entry = { seq, started: false, copy, drop: () => {
3031
3090
  this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
3032
3091
  finish();
3033
3092
  } };
@@ -3037,14 +3096,16 @@ export class FleetManager {
3037
3096
  expired = true;
3038
3097
  this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
3039
3098
  finish();
3040
- // Do not post copies that were queued behind an ambiguous platform send.
3041
- for (const pending of queue.pending)
3042
- if (!pending.started)
3043
- pending.drop();
3099
+ // Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
3100
+ // at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
3101
+ if (copy)
3102
+ for (const pending of queue.pending)
3103
+ if (pending.seq > seq && !pending.started && pending.copy)
3104
+ pending.drop();
3044
3105
  };
3045
3106
  const checkDeadline = () => {
3046
- if (done)
3047
- return;
3107
+ if (done || deadlineAt === Number.POSITIVE_INFINITY)
3108
+ return; // a non-copy's budget has not started yet
3048
3109
  const remaining = deadlineAt - performance.now();
3049
3110
  if (remaining <= 0)
3050
3111
  expire();
@@ -3053,8 +3114,19 @@ export class FleetManager {
3053
3114
  };
3054
3115
  queue.pending.add(entry);
3055
3116
  queue.tail = tail;
3117
+ if (!copy) {
3118
+ queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
3119
+ queue.lastNonCopyDone = tail;
3120
+ }
3056
3121
  this.webChannelEchoTails.set(instanceName, queue);
3057
3122
  checkDeadline();
3123
+ // Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
3124
+ // is set by the time that runs.
3125
+ if (nonCopyAhead)
3126
+ void nonCopyAhead.then(() => { if (!done) {
3127
+ deadlineAt = performance.now() + 5_000;
3128
+ checkDeadline();
3129
+ } });
3058
3130
  void Promise.all([previous, admission]).then(([, accepted]) => {
3059
3131
  if (done)
3060
3132
  return;
@@ -3063,11 +3135,15 @@ export class FleetManager {
3063
3135
  return;
3064
3136
  }
3065
3137
  // A delayed timer callback must not admit an already expired copy.
3066
- if (performance.now() >= deadlineAt) {
3138
+ if (copy && performance.now() >= deadlineAt) {
3067
3139
  expire();
3068
3140
  return;
3069
3141
  }
3070
3142
  entry.started = true;
3143
+ if (!copy) {
3144
+ deadlineAt = performance.now() + 5_000;
3145
+ checkDeadline();
3146
+ }
3071
3147
  guard = { epoch, deadlineAt };
3072
3148
  this.webChannelEchoGuards.set(instanceName, guard);
3073
3149
  let request;
@@ -4152,6 +4228,33 @@ export class FleetManager {
4152
4228
  names.add(channel.instanceName);
4153
4229
  return [...names];
4154
4230
  }
4231
+ /** #1624: migration cannot infer ownership from the running daemon roster.
4232
+ * Stopped/paused ClassicBot entries count too; their workspace is exactly
4233
+ * the one startClassicInstance builds, not this FleetManager's dataDir.
4234
+ */
4235
+ kiroPeerWorkingDirectories(name) {
4236
+ if (!this.fleetConfig || !this.classicChannels)
4237
+ throw new Error("Kiro peer configuration is not loaded");
4238
+ const peers = [];
4239
+ for (const [peer, cfg] of Object.entries(this.fleetConfig.instances)) {
4240
+ if (peer === name || (cfg.backend ?? this.fleetConfig.defaults?.backend ?? "claude-code") !== "kiro-cli")
4241
+ continue;
4242
+ if (typeof cfg.working_directory !== "string" || !cfg.working_directory)
4243
+ throw new Error("Kiro peer cwd is unknown");
4244
+ peers.push(cfg.working_directory);
4245
+ }
4246
+ for (const ch of this.classicChannels.getAll()) {
4247
+ if (ch.instanceName === name) {
4248
+ if (Object.hasOwn(this.fleetConfig.instances, name))
4249
+ throw new Error("Kiro instance name has two configurations");
4250
+ continue;
4251
+ }
4252
+ if (this.classicChannels.getBackendByInstance(ch.instanceName, this.fleetConfig.defaults?.backend) === "kiro-cli") {
4253
+ peers.push(join(getAgendHome(), "workspaces", ch.instanceName));
4254
+ }
4255
+ }
4256
+ return peers;
4257
+ }
4155
4258
  /**
4156
4259
  * Probe the same executable set exposed by the web backend catalog.
4157
4260
  *
@@ -4549,6 +4652,9 @@ export class FleetManager {
4549
4652
  this.webSessions = new WebSessionStore({
4550
4653
  dataDir: this.dataDir,
4551
4654
  onWarn: message => this.logger.warn(message),
4655
+ // #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
4656
+ // (shortening ends the sessions it now outlives). The public link's policy stays fixed.
4657
+ localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
4552
4658
  });
4553
4659
  }
4554
4660
  if (!this.returningDevices) {
@@ -7710,10 +7816,12 @@ export class FleetManager {
7710
7816
  respond(null, "No adapter available");
7711
7817
  return;
7712
7818
  }
7819
+ // Only channel-addressing schemas receive ClassicBot routing metadata.
7820
+ // Non-channel calls (notably strict delivery_status) retain their arguments.
7713
7821
  // For classic instances: force chat_id to channelId and clear thread_id
7714
7822
  // (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')
7715
7823
  const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
7716
- if (classicChannelId) {
7824
+ if (classicChannelId && usesChannelAddress(tool)) {
7717
7825
  args.chat_id = classicChannelId;
7718
7826
  // #1085: a Telegram forum ClassicBot replies in the topic it was asked
7719
7827
  // in. Any other thread_id (threadless Telegram, where it echoes the chat
@@ -9301,7 +9409,9 @@ export class FleetManager {
9301
9409
  else
9302
9410
  await this.lifecycle.remove(name, authorization);
9303
9411
  // A later same-name registration owns its files and caches.
9304
- if (this.fleetConfig?.instances[name] || this.daemons.has(name))
9412
+ // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
9413
+ // re-registration) — #1677 review.
9414
+ if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
9305
9415
  return;
9306
9416
  execution?.assert();
9307
9417
  // Clean up per-instance tracking maps so they don't grow unbounded
@@ -9313,6 +9423,13 @@ export class FleetManager {
9313
9423
  // must not be shown the old one's conversation, and deleted names must not pile up.
9314
9424
  this.webChatHistory.forget(name);
9315
9425
  this.webFiles.forget(name);
9426
+ // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
9427
+ try {
9428
+ this.webPrefs.forgetInstance(name);
9429
+ }
9430
+ catch (err) {
9431
+ this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
9432
+ }
9316
9433
  // Clean up statusline watcher + instance directory
9317
9434
  this.statuslineWatcher.unwatch(name);
9318
9435
  // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
@@ -9502,6 +9619,151 @@ export class FleetManager {
9502
9619
  /** Keyed `<connection>\0<server id>`. */
9503
9620
  guildEmojiCache = new Map();
9504
9621
  guildEmojiFetches = new Map();
9622
+ // ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
9623
+ webImageCaches = null;
9624
+ webImageCache(kind) {
9625
+ const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
9626
+ this.webImageCaches ??= {
9627
+ avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
9628
+ emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
9629
+ };
9630
+ return this.webImageCaches[kind];
9631
+ }
9632
+ /**
9633
+ * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
9634
+ * whatever an instance still names — #1635 review), not the primary, and answering for it alone.
9635
+ */
9636
+ ownBots() {
9637
+ const instances = this.fleetConfig?.instances ?? {};
9638
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
9639
+ const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
9640
+ const assigned = (name) => { const c = this.getInstanceAdapterId(name); return c && configured.has(c) ? c : undefined; };
9641
+ const assignments = Object.keys(instances).map(n => [n, assigned(n)]);
9642
+ for (const room of this.classicChannels?.getAll() ?? []) {
9643
+ if (!Object.hasOwn(instances, room.instanceName))
9644
+ assignments.push([room.instanceName, assigned(room.instanceName)]);
9645
+ }
9646
+ return ownBotConnections(this.getPrimaryAdapterId(), assignments);
9647
+ }
9648
+ /**
9649
+ * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
9650
+ * keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
9651
+ * face or colour, and the old one's late answers land under the old key (#1635 review). null while it has no id.
9652
+ */
9653
+ botOwner(connection) {
9654
+ const adapter = this.adapters.get(connection), id = adapter?.getBotUserId?.();
9655
+ return adapter && id ? { adapter, id } : null;
9656
+ }
9657
+ /** Still the same bot behind `connection` (after an await): the same adapter object and the same bot id. */
9658
+ botStillOwns(connection, owner) {
9659
+ return this.adapters.get(connection) === owner.adapter && owner.adapter.getBotUserId?.() === owner.id;
9660
+ }
9661
+ /** keyed `<connection>\0<bot id>\0<channel>`: the bot's colour there, refreshed at most daily (monotonic), one read at a time. */
9662
+ webAccents = new Map();
9663
+ webAccentReads = new Set();
9664
+ webAccent(connection, owner, channelId) {
9665
+ const adapter = owner.adapter;
9666
+ if (!adapter.botAccentColor)
9667
+ return null;
9668
+ const key = `${connection}\0${owner.id}\0${channelId ?? ""}`, now = performance.now(), cached = this.webAccents.get(key);
9669
+ if ((!cached || now >= cached.until) && !this.webAccentReads.has(key)) {
9670
+ this.webAccentReads.add(key);
9671
+ adapter.botAccentColor(channelId)
9672
+ // Keyed by the bot's id: a replaced bot's late answer lands under its own key, which the new bot never reads.
9673
+ .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 }); })
9674
+ .finally(() => this.webAccentReads.delete(key));
9675
+ }
9676
+ return cached?.value ?? null;
9677
+ }
9678
+ /** The cache key of an own bot's avatar, and its loader (which refuses an answer from a bot no longer there). */
9679
+ botAvatarSource(connection, owner) {
9680
+ return {
9681
+ key: `bot:${connection}:${owner.id}`,
9682
+ load: async () => {
9683
+ const got = await owner.adapter.fetchBotAvatar();
9684
+ if (!this.botStillOwns(connection, owner))
9685
+ throw new Error("the bot was replaced while its avatar was fetched");
9686
+ return got;
9687
+ },
9688
+ };
9689
+ }
9690
+ /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
9691
+ webIdentityImage(name, displayName, profileAvatar, own) {
9692
+ let profileVersion = null;
9693
+ if (profileAvatar && imageMimeForFile(profileAvatar)) {
9694
+ try {
9695
+ profileVersion = Math.floor(statSync(join(this.dataDir, "avatars", basename(profileAvatar))).mtimeMs).toString(36);
9696
+ }
9697
+ catch { /* removed */ }
9698
+ }
9699
+ const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
9700
+ const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
9701
+ const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
9702
+ const cfg = this.ownInstanceConfig(name);
9703
+ const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
9704
+ personaEmoji: cfg?.status_emojis?.delivered });
9705
+ // The ring belongs to the bot's own face: only when that is what is shown.
9706
+ const accent = !profileVersion && bot && connection && owner
9707
+ ? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
9708
+ : null;
9709
+ return accent ? { avatar, accent } : { avatar };
9710
+ }
9711
+ /** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
9712
+ webImage(kind, key) {
9713
+ if (kind === "bot") {
9714
+ if (![...this.ownBots().values()].includes(key))
9715
+ return null;
9716
+ const owner = this.botOwner(key);
9717
+ return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
9718
+ }
9719
+ const known = Object.hasOwn(this.fleetConfig?.instances ?? {}, key) || this.classicChannels?.getChannelIdByInstance(key) !== undefined;
9720
+ if (!known)
9721
+ return null;
9722
+ const file = profileIdentities(this.dataDir).get(key)?.avatar_path;
9723
+ const mime = file ? imageMimeForFile(file) : null;
9724
+ if (!file || !mime)
9725
+ return null;
9726
+ try {
9727
+ const path = join(this.dataDir, "avatars", basename(file));
9728
+ return { bytes: readFileSync(path), mime, version: Math.floor(statSync(path).mtimeMs).toString(36) };
9729
+ }
9730
+ catch {
9731
+ return null;
9732
+ }
9733
+ }
9734
+ /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
9735
+ async webEmojiList() {
9736
+ const adopted = [];
9737
+ for (const [id, world] of [...this.worlds]) {
9738
+ if (world.type !== "discord")
9739
+ continue;
9740
+ const listed = await this.listGuildEmojis(id);
9741
+ if (listed.ok)
9742
+ adopted.push({ id, world, emojis: listed.emojis });
9743
+ }
9744
+ // After the LAST await (#1635 review r2): only the lists whose world is still the one that was read — a connection
9745
+ // removed or replaced while a later list was awaited takes its servers along, whenever it went.
9746
+ const out = new Map();
9747
+ for (const a of adopted) {
9748
+ if (this.worlds.get(a.id) !== a.world)
9749
+ continue;
9750
+ for (const e of a.emojis)
9751
+ if (!out.has(e.id))
9752
+ out.set(e.id, { id: e.id, name: e.name, animated: e.animated });
9753
+ }
9754
+ return [...out.values()];
9755
+ }
9756
+ /** GET /ui/img/emoji/<id>: only an emoji of those servers; fetched once from Discord's CDN, then from the host cache. */
9757
+ async webEmoji(id) {
9758
+ if (!/^\d{15,25}$/.test(id))
9759
+ return null;
9760
+ const emoji = (await this.webEmojiList()).find(e => e.id === id);
9761
+ if (!emoji)
9762
+ return null;
9763
+ 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 }));
9764
+ // Still an emoji of the fleet's servers once it is here (a connection removed meanwhile takes its servers along).
9765
+ return image && (await this.webEmojiList()).some(e => e.id === id) ? image : null;
9766
+ }
9505
9767
  /**
9506
9768
  * What `instanceName` may pick as its persona emoji (the `list_emojis`
9507
9769
  * tool): its current status set with where each value comes from, the
@@ -10693,7 +10955,8 @@ export class FleetManager {
10693
10955
  // on the sign-in DM, armed by the first tap). A double tap, or a late second one from an old keyboard, lands
10694
10956
  // here with the spent nonce — and collapsing would wipe the message's text and the live buttons. The clicker is
10695
10957
  // told; the message is left alone.
10696
- if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.chatId === data.chatId && e.messageId === data.messageId)) {
10958
+ // #1617: and of the same bot — two bots' DM message ids can collide; another bot's live prompt says nothing here.
10959
+ if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.adapterId === callbackAdapterId && e.chatId === data.chatId && e.messageId === data.messageId)) {
10697
10960
  data.ack?.(t("buttons.stale_notice"));
10698
10961
  return "consumed";
10699
10962
  }
@@ -15444,6 +15707,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15444
15707
  const ch = this.classicChannels.unregister(channelId, adapterId);
15445
15708
  if (!ch)
15446
15709
  return t("classic.no_agent");
15710
+ // #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
15711
+ // instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
15712
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
15713
+ && !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
15714
+ try {
15715
+ this.webPrefs.forgetInstance(ch.instanceName);
15716
+ }
15717
+ catch (err) {
15718
+ this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
15719
+ }
15720
+ }
15447
15721
  this.instanceWorldBinding.delete(ch.instanceName);
15448
15722
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
15449
15723
  clearPausedMarker(this.getInstanceDir(ch.instanceName));
@@ -17940,7 +18214,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17940
18214
  // Never the token: fleet.log is readable by anything that can read the
17941
18215
  // data dir, is copied into bug reports, and is tailed in shared terminals.
17942
18216
  // `/dashboard` and `agend web` are the ways to get an authorized link.
17943
- this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
18217
+ this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /web or `agend web`)");
17944
18218
  this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
17945
18219
  // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
17946
18220
  const bound = this.healthServer?.address();
@@ -18082,6 +18356,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18082
18356
  // /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
18083
18357
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
18084
18358
  const profiles = profileIdentities(this.dataDir);
18359
+ // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
18360
+ let ownBots = new Map();
18361
+ try {
18362
+ ownBots = this.ownBots();
18363
+ }
18364
+ catch (err) {
18365
+ this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
18366
+ }
18085
18367
  const instances = names.map(name => {
18086
18368
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
18087
18369
  let cost = 0;
@@ -18119,9 +18401,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18119
18401
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
18120
18402
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
18121
18403
  const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
18404
+ let image;
18405
+ try {
18406
+ image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
18407
+ }
18408
+ catch (err) {
18409
+ this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
18410
+ image = { avatar: avatarFor({ name, displayName: identity.display_name }) };
18411
+ }
18122
18412
  return {
18123
18413
  name,
18124
18414
  display_name: identity.display_name || undefined,
18415
+ avatar: image.avatar,
18416
+ ...(image.accent ? { accent: image.accent } : {}),
18125
18417
  description: identity.description || undefined,
18126
18418
  role: identity.role || undefined,
18127
18419
  tags: identity.tags,
@@ -18144,6 +18436,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18144
18436
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
18145
18437
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
18146
18438
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
18439
+ // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
18440
+ prefs_rev: this.webPrefs.rev(),
18147
18441
  };
18148
18442
  }
18149
18443
  }