@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.4

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 (257) hide show
  1. package/README.md +4 -4
  2. package/README.zh-TW.md +4 -4
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +14 -2
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/atomic-write.d.ts +21 -0
  7. package/dist/atomic-write.js +65 -0
  8. package/dist/atomic-write.js.map +1 -0
  9. package/dist/auth-api.js +2 -0
  10. package/dist/auth-api.js.map +1 -1
  11. package/dist/backend/codex.d.ts +7 -0
  12. package/dist/backend/codex.js +7 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/muse.d.ts +7 -0
  15. package/dist/backend/muse.js +7 -0
  16. package/dist/backend/muse.js.map +1 -1
  17. package/dist/backend/types.d.ts +6 -0
  18. package/dist/backend/types.js.map +1 -1
  19. package/dist/binary-probe.d.ts +80 -0
  20. package/dist/binary-probe.js +182 -0
  21. package/dist/binary-probe.js.map +1 -0
  22. package/dist/cache-service.d.ts +17 -1
  23. package/dist/cache-service.js +39 -4
  24. package/dist/cache-service.js.map +1 -1
  25. package/dist/channel/adapters/discord.d.ts +5 -1
  26. package/dist/channel/adapters/discord.js +56 -11
  27. package/dist/channel/adapters/discord.js.map +1 -1
  28. package/dist/channel/adapters/telegram.d.ts +38 -1
  29. package/dist/channel/adapters/telegram.js +82 -0
  30. package/dist/channel/adapters/telegram.js.map +1 -1
  31. package/dist/channel/mcp-tools.js +1 -1
  32. package/dist/channel/mcp-tools.js.map +1 -1
  33. package/dist/channel/tool-router.d.ts +8 -1
  34. package/dist/channel/tool-router.js +4 -2
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +21 -0
  37. package/dist/cli.js +133 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/connection-secrets.d.ts +5 -0
  40. package/dist/connection-secrets.js.map +1 -1
  41. package/dist/cpu-profile.js +50 -0
  42. package/dist/cpu-profile.js.map +1 -1
  43. package/dist/daemon.d.ts +12 -0
  44. package/dist/daemon.js +166 -26
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/delivery-outbox.d.ts +1 -0
  47. package/dist/delivery-outbox.js +12 -0
  48. package/dist/delivery-outbox.js.map +1 -1
  49. package/dist/discord-general.d.ts +15 -0
  50. package/dist/discord-general.js +24 -0
  51. package/dist/discord-general.js.map +1 -0
  52. package/dist/discord-permissions.d.ts +33 -0
  53. package/dist/discord-permissions.js +42 -0
  54. package/dist/discord-permissions.js.map +1 -0
  55. package/dist/env-file.d.ts +11 -0
  56. package/dist/env-file.js +27 -0
  57. package/dist/env-file.js.map +1 -0
  58. package/dist/fleet-context.d.ts +5 -0
  59. package/dist/fleet-context.js.map +1 -1
  60. package/dist/fleet-level-config.d.ts +8 -1
  61. package/dist/fleet-level-config.js +31 -0
  62. package/dist/fleet-level-config.js.map +1 -1
  63. package/dist/fleet-manager.d.ts +76 -7
  64. package/dist/fleet-manager.js +610 -142
  65. package/dist/fleet-manager.js.map +1 -1
  66. package/dist/fleet-stop-budget.d.ts +22 -0
  67. package/dist/fleet-stop-budget.js +95 -0
  68. package/dist/fleet-stop-budget.js.map +1 -0
  69. package/dist/kiro-transcript-lane.d.ts +8 -4
  70. package/dist/kiro-transcript-lane.js +66 -44
  71. package/dist/kiro-transcript-lane.js.map +1 -1
  72. package/dist/locale.js +88 -0
  73. package/dist/locale.js.map +1 -1
  74. package/dist/next-steps.d.ts +5 -0
  75. package/dist/next-steps.js +26 -0
  76. package/dist/next-steps.js.map +1 -0
  77. package/dist/outbound-handlers.js +8 -0
  78. package/dist/outbound-handlers.js.map +1 -1
  79. package/dist/outbound-schemas.d.ts +4 -0
  80. package/dist/outbound-schemas.js +6 -1
  81. package/dist/outbound-schemas.js.map +1 -1
  82. package/dist/outbox-open-error.d.ts +7 -0
  83. package/dist/outbox-open-error.js +15 -0
  84. package/dist/outbox-open-error.js.map +1 -0
  85. package/dist/pane-context-cache.d.ts +25 -0
  86. package/dist/pane-context-cache.js +73 -0
  87. package/dist/pane-context-cache.js.map +1 -0
  88. package/dist/paths.d.ts +0 -7
  89. package/dist/paths.js +8 -2
  90. package/dist/paths.js.map +1 -1
  91. package/dist/provider-probe.d.ts +15 -0
  92. package/dist/provider-probe.js +20 -0
  93. package/dist/provider-probe.js.map +1 -1
  94. package/dist/public-link-progress.d.ts +75 -0
  95. package/dist/public-link-progress.js +204 -0
  96. package/dist/public-link-progress.js.map +1 -0
  97. package/dist/public-web-gateway.js +4 -1
  98. package/dist/public-web-gateway.js.map +1 -1
  99. package/dist/public-web-link.d.ts +9 -2
  100. package/dist/public-web-link.js +89 -15
  101. package/dist/public-web-link.js.map +1 -1
  102. package/dist/quickstart-api.d.ts +93 -20
  103. package/dist/quickstart-api.js +198 -63
  104. package/dist/quickstart-api.js.map +1 -1
  105. package/dist/quickstart.d.ts +15 -0
  106. package/dist/quickstart.js +100 -29
  107. package/dist/quickstart.js.map +1 -1
  108. package/dist/reply-buttons-controller.d.ts +98 -0
  109. package/dist/reply-buttons-controller.js +174 -0
  110. package/dist/reply-buttons-controller.js.map +1 -0
  111. package/dist/reply-buttons.d.ts +113 -0
  112. package/dist/reply-buttons.js +208 -0
  113. package/dist/reply-buttons.js.map +1 -0
  114. package/dist/reply-dedup.js +3 -1
  115. package/dist/reply-dedup.js.map +1 -1
  116. package/dist/restart-guard.d.ts +1 -0
  117. package/dist/restart-guard.js +8 -3
  118. package/dist/restart-guard.js.map +1 -1
  119. package/dist/service-activation.d.ts +15 -2
  120. package/dist/service-activation.js +81 -4
  121. package/dist/service-activation.js.map +1 -1
  122. package/dist/service-installer.js +19 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +53 -12
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/settings-authority.d.ts +11 -0
  127. package/dist/settings-authority.js +128 -0
  128. package/dist/settings-authority.js.map +1 -0
  129. package/dist/settings-change.d.ts +5 -0
  130. package/dist/settings-change.js +4 -3
  131. package/dist/settings-change.js.map +1 -1
  132. package/dist/settings-confirmation.d.ts +3 -0
  133. package/dist/settings-confirmation.js +7 -1
  134. package/dist/settings-confirmation.js.map +1 -1
  135. package/dist/settings-effect.js +19 -7
  136. package/dist/settings-effect.js.map +1 -1
  137. package/dist/settings-http-confirmation.js +1 -1
  138. package/dist/settings-http-confirmation.js.map +1 -1
  139. package/dist/settings-redaction.d.ts +18 -0
  140. package/dist/settings-redaction.js +58 -0
  141. package/dist/settings-redaction.js.map +1 -0
  142. package/dist/setup-form.d.ts +9 -1
  143. package/dist/setup-form.js +25 -1
  144. package/dist/setup-form.js.map +1 -1
  145. package/dist/setup-host.js +13 -3
  146. package/dist/setup-host.js.map +1 -1
  147. package/dist/setup-wizard.js +26 -19
  148. package/dist/setup-wizard.js.map +1 -1
  149. package/dist/systemd-runtime.d.ts +15 -0
  150. package/dist/systemd-runtime.js +41 -0
  151. package/dist/systemd-runtime.js.map +1 -0
  152. package/dist/tmux-control.d.ts +22 -0
  153. package/dist/tmux-control.js +82 -17
  154. package/dist/tmux-control.js.map +1 -1
  155. package/dist/token-env-name.d.ts +37 -0
  156. package/dist/token-env-name.js +93 -0
  157. package/dist/token-env-name.js.map +1 -0
  158. package/dist/tool-permissions.js +5 -3
  159. package/dist/tool-permissions.js.map +1 -1
  160. package/dist/topic-commands.d.ts +7 -16
  161. package/dist/topic-commands.js +48 -84
  162. package/dist/topic-commands.js.map +1 -1
  163. package/dist/transcript-monitor.js +3 -0
  164. package/dist/transcript-monitor.js.map +1 -1
  165. package/dist/transcript-sources.d.ts +68 -0
  166. package/dist/transcript-sources.js +336 -29
  167. package/dist/transcript-sources.js.map +1 -1
  168. package/dist/transcript-turns.d.ts +39 -0
  169. package/dist/transcript-turns.js +58 -0
  170. package/dist/transcript-turns.js.map +1 -0
  171. package/dist/tunnel/cloudflared-install.d.ts +42 -0
  172. package/dist/tunnel/cloudflared-install.js +194 -9
  173. package/dist/tunnel/cloudflared-install.js.map +1 -1
  174. package/dist/tunnel/deb-extract.d.ts +9 -0
  175. package/dist/tunnel/deb-extract.js +94 -0
  176. package/dist/tunnel/deb-extract.js.map +1 -0
  177. package/dist/turn-reply-guard.d.ts +3 -0
  178. package/dist/turn-reply-guard.js.map +1 -1
  179. package/dist/ui/app.html +3 -0
  180. package/dist/ui/chat-render.js +32 -6
  181. package/dist/ui/chat-store.js +21 -1
  182. package/dist/ui/chat-strings.js +45 -0
  183. package/dist/ui/chat-thread.js +109 -52
  184. package/dist/ui/first-run.js +64 -0
  185. package/dist/ui/html-attachment.js +26 -0
  186. package/dist/ui/icons/apple-touch-icon.png +0 -0
  187. package/dist/ui/icons/favicon.ico +0 -0
  188. package/dist/ui/icons/favicon.svg +8 -0
  189. package/dist/ui/panel-chat.js +16 -7
  190. package/dist/ui/panel-fleet.js +5 -5
  191. package/dist/ui/panel-settings.js +18 -8
  192. package/dist/ui/preview-panel.js +47 -7
  193. package/dist/ui/preview.js +36 -13
  194. package/dist/ui/settings-apply.js +3 -1
  195. package/dist/ui/settings-dialogs.js +95 -25
  196. package/dist/ui/settings-discord.js +130 -0
  197. package/dist/ui/settings-model.js +21 -0
  198. package/dist/ui/settings-strings.js +46 -6
  199. package/dist/ui/settings-token.js +54 -0
  200. package/dist/ui/settings-wizard.js +63 -29
  201. package/dist/ui/shared/app-shell.js +19 -33
  202. package/dist/ui/shared/app-store.js +5 -3
  203. package/dist/ui/shared/app-stream.js +2 -1
  204. package/dist/ui/shared/app.css +34 -2
  205. package/dist/ui/shared/app.js +2 -1
  206. package/dist/ui/shared/instance-nav.js +310 -0
  207. package/dist/ui/shared/panel-view.js +12 -190
  208. package/dist/ui/shared/view-roster-store.js +5 -0
  209. package/dist/ui/shared/view-strings.js +12 -0
  210. package/dist/ui/signin.html +3 -0
  211. package/dist/update-dispatch.d.ts +1 -1
  212. package/dist/update-dispatch.js +1 -1
  213. package/dist/update-install.d.ts +15 -3
  214. package/dist/update-install.js +41 -13
  215. package/dist/update-install.js.map +1 -1
  216. package/dist/update-launch.d.ts +22 -0
  217. package/dist/update-launch.js +106 -0
  218. package/dist/update-launch.js.map +1 -0
  219. package/dist/update-marker.js +3 -8
  220. package/dist/update-marker.js.map +1 -1
  221. package/dist/usage/format-rich.js +7 -1
  222. package/dist/usage/format-rich.js.map +1 -1
  223. package/dist/usage/i18n-keys.d.ts +1 -1
  224. package/dist/usage/i18n-keys.js +1 -1
  225. package/dist/usage/i18n-keys.js.map +1 -1
  226. package/dist/usage/providers.d.ts +4 -0
  227. package/dist/usage/providers.js +2 -1
  228. package/dist/usage/providers.js.map +1 -1
  229. package/dist/usage/usage-api.js +48 -2
  230. package/dist/usage/usage-api.js.map +1 -1
  231. package/dist/view-api.d.ts +40 -0
  232. package/dist/view-api.js +28 -10
  233. package/dist/view-api.js.map +1 -1
  234. package/dist/web-api.d.ts +8 -0
  235. package/dist/web-api.js +53 -17
  236. package/dist/web-api.js.map +1 -1
  237. package/dist/web-auth.d.ts +8 -0
  238. package/dist/web-auth.js +34 -8
  239. package/dist/web-auth.js.map +1 -1
  240. package/dist/web-chat-history.d.ts +21 -0
  241. package/dist/web-chat-history.js +44 -0
  242. package/dist/web-chat-history.js.map +1 -1
  243. package/dist/web-host-guard.d.ts +18 -2
  244. package/dist/web-host-guard.js +30 -5
  245. package/dist/web-host-guard.js.map +1 -1
  246. package/dist/web-icons.d.ts +21 -0
  247. package/dist/web-icons.js +38 -0
  248. package/dist/web-icons.js.map +1 -0
  249. package/dist/web-preview.d.ts +9 -0
  250. package/dist/web-preview.js +15 -9
  251. package/dist/web-preview.js.map +1 -1
  252. package/dist/web-session.d.ts +7 -1
  253. package/dist/web-session.js +23 -2
  254. package/dist/web-session.js.map +1 -1
  255. package/launcher/postinstall.cjs +8 -4
  256. package/package.json +1 -1
  257. package/templates/systemd.service.ejs +3 -3
@@ -6,6 +6,8 @@ import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileRes
6
6
  import { performance } from "node:perf_hooks";
7
7
  import { gatewayRequestContext } from "./web-request-context.js";
8
8
  import { createPublicWebGateway } from "./public-web-gateway.js";
9
+ import { handleWebIconRequest } from "./web-icons.js";
10
+ import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
9
11
  import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
10
12
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
11
13
  import { ManagedTunnel } from "./tunnel/manager.js";
@@ -14,6 +16,7 @@ import { measureSyncWork } from "./sync-work-attribution.js";
14
16
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
15
17
  import { ProfileControlServer } from "./profile-control.js";
16
18
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
19
+ import { atomicWriteFileSync } from "./atomic-write.js";
17
20
  import { createHash, randomBytes, randomUUID } from "node:crypto";
18
21
  import { spawnSync } from "node:child_process";
19
22
  import { freemem, totalmem, cpus, homedir } from "node:os";
@@ -39,7 +42,9 @@ import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS,
39
42
  import { isProbeableRouteTarget } from "./fleet-context.js";
40
43
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
41
44
  import { EventLog } from "./event-log.js";
45
+ import { binaryProbe } from "./binary-probe.js";
42
46
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
47
+ import { OutboxOpenError } from "./outbox-open-error.js";
43
48
  import { AdapterWorld } from "./adapter-world.js";
44
49
  import { CostGuard, formatCents } from "./cost-guard.js";
45
50
  import { startEventLoopWatch } from "./event-loop-watch.js";
@@ -121,8 +126,14 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
121
126
  }
122
127
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
123
128
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
129
+ import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
130
+ import { ReplyButtonsController } from "./reply-buttons-controller.js";
124
131
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
125
- import { handleViewRequest, isViewPath } from "./view-api.js";
132
+ import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
133
+ import { envFileKeys } from "./token-env-name.js";
134
+ import { discordGeneralChannelId } from "./discord-general.js";
135
+ import { parseEnvText } from "./env-file.js";
136
+ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
126
137
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
127
138
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
128
139
  import { LoginSession } from "./login-manager.js";
@@ -131,7 +142,8 @@ import { decideSlash } from "./slash-authz.js";
131
142
  import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
132
143
  import { runVisibilityCommand } from "./cross-instance-notice.js";
133
144
  import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
134
- import { resolveInstalledAgend, updateCommand } from "./update-dispatch.js";
145
+ import { resolveInstalledAgend } from "./update-dispatch.js";
146
+ import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
135
147
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
136
148
  import { runBeforeDeadline } from "./deadline.js";
137
149
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -170,7 +182,7 @@ import { tokenEpoch, WebSessionStore } from "./web-session.js";
170
182
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
171
183
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
172
184
  import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
173
- import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
185
+ import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
174
186
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
175
187
  import { SecretStore } from "./secret-store.js";
176
188
  import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
@@ -340,6 +352,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
340
352
  */
341
353
  const WEB_ONLY_REPLY_SINK = {
342
354
  type: "web",
355
+ supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
343
356
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
344
357
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
345
358
  };
@@ -443,6 +456,8 @@ export class FleetManager {
443
456
  dataDir;
444
457
  static signalTarget = null;
445
458
  static sighupHandlerInstalled = false;
459
+ /** Test seam: inject a spy to verify saveFleetConfig calls fsync. Default: fsyncSync. */
460
+ fsyncForTest = undefined;
446
461
  children = new Map();
447
462
  lifecycle;
448
463
  stormWindow;
@@ -839,21 +854,66 @@ export class FleetManager {
839
854
  return false;
840
855
  await settlement;
841
856
  const job = this.providerSecretJobs.get(id) ?? this.connectionSecretJobs.get(id) ?? this.connectionBindingJobs.get(id);
842
- return !!job && ["applied", "applied_next_use", "reloaded"].includes(job.result);
857
+ // restart_required: the value is stored and committed (the receipt is checked above), only no adapter was running
858
+ // to take it — the change is done, and the page says it starts with the next restart (#1519 P1), never "failed".
859
+ return !!job && ["applied", "applied_next_use", "reloaded", "restart_required"].includes(job.result);
843
860
  },
844
861
  });
845
862
  this.settingsConfirmation = gate;
846
863
  return gate;
847
864
  }
865
+ /** Exact configured effect owners; this does not depend on prompt location. */
866
+ settingsChatAuthority(store, id) {
867
+ const scope = store.authorityOf(id);
868
+ if (!scope || scope.unknown)
869
+ return null;
870
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
871
+ const targets = new Set(scope.connections);
872
+ let primaryGeneral;
873
+ const selectPrimary = () => {
874
+ const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true);
875
+ const selected = generals.find(([name]) => this.getInstanceAdapterId(name) === this.getPrimaryAdapterId()) ?? generals[0];
876
+ const owner = selected && this.getInstanceAdapterId(selected[0]);
877
+ return selected && owner ? { name: selected[0], owner } : undefined;
878
+ };
879
+ if (scope.primaryGeneral) {
880
+ primaryGeneral = selectPrimary();
881
+ if (!primaryGeneral)
882
+ return null;
883
+ targets.add(primaryGeneral.owner);
884
+ }
885
+ const owners = [...targets].map(id => channels.filter(ch => (ch.id ?? ch.type) === id));
886
+ if (!owners.length || owners.some(matches => matches.length !== 1))
887
+ return null;
888
+ const platform = owners[0][0].type;
889
+ if ((platform !== "telegram" && platform !== "discord") || owners.some(matches => matches[0].type !== platform))
890
+ return null;
891
+ const identities = owners.map(matches => ({ id: matches[0].id ?? matches[0].type, type: matches[0].type }));
892
+ const generation = this.settingsGeneration;
893
+ const current = () => !this.shuttingDown && this.settingsGeneration === generation
894
+ && identities.every(owner => {
895
+ const configs = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
896
+ const matches = configs.filter(ch => (ch.id ?? ch.type) === owner.id);
897
+ return matches.length === 1 && matches[0].type === owner.type;
898
+ })
899
+ && (!primaryGeneral || selectPrimary()?.name === primaryGeneral.name && selectPrimary()?.owner === primaryGeneral.owner);
900
+ return { platform, current, admin: userId => current() && !!userId
901
+ && identities.every(owner => this.isFleetAdmin(userId, owner.id)) };
902
+ }
848
903
  async promptSettingsChange(store, view) {
849
904
  const generation = this.settingsGeneration;
905
+ const authority = this.settingsChatAuthority(store, view.id);
906
+ if (!authority) {
907
+ this.logger.info({ id: view.id }, "Settings authority is unknown or spans platforms; host CLI confirmation required");
908
+ return;
909
+ }
850
910
  const excluded = new Set(store.affectedConnections(view.id));
851
911
  for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
852
912
  if (!config.general_topic)
853
913
  continue;
854
914
  const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
855
915
  const general = this.daemons.get(name);
856
- if (!adapterId || !adapter || !general || excluded.has(adapterId) || !this.hasFleetAdmins(adapterId))
916
+ if (!adapterId || !adapter || !general || excluded.has(adapterId) || adapter.type !== authority.platform)
857
917
  continue;
858
918
  const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
859
919
  if (!chatId || !topic)
@@ -866,7 +926,7 @@ export class FleetManager {
866
926
  && this.adapterState.get(adapterId)?.status === "connected"
867
927
  && this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
868
928
  && this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
869
- && this.hasFleetAdmins(adapterId);
929
+ && authority.current();
870
930
  if (!current())
871
931
  continue;
872
932
  try {
@@ -875,7 +935,7 @@ export class FleetManager {
875
935
  message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
876
936
  choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
877
937
  expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
878
- extra: { pendingChangeId: view.id, confirmationCurrent: current },
938
+ extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
879
939
  });
880
940
  void posted.then(nonce => {
881
941
  if (!retired)
@@ -913,7 +973,7 @@ export class FleetManager {
913
973
  this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
914
974
  }
915
975
  async handleSettingsChangeCallback(data, adapterId, adapter) {
916
- const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter);
976
+ const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter, undefined, (userId, entry) => entry.confirmationAdmin?.(userId) === true);
917
977
  if (!result)
918
978
  return false;
919
979
  if (result === "consumed")
@@ -923,7 +983,7 @@ export class FleetManager {
923
983
  const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
924
984
  label: `fleet admin ${data.userId}`,
925
985
  current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
926
- && !!data.userId && this.isFleetAdmin(data.userId, entry.adapterId),
986
+ && !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
927
987
  });
928
988
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
929
989
  }
@@ -996,16 +1056,111 @@ export class FleetManager {
996
1056
  this.topicArchiver = new TopicArchiver(this);
997
1057
  this.statuslineWatcher = new StatuslineWatcher(this);
998
1058
  }
1059
+ /** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
1060
+ replyButtonsCtl = null;
1061
+ /** The store could not be opened in this process: buttons are offered as text until the next start. */
1062
+ replyButtonsUnavailable = false;
1063
+ /**
1064
+ * The controller, or null when reply-buttons.db cannot be opened (#1500 review). That file holds only open choices,
1065
+ * so it never stops AgEnD: the failure is logged and posted once, replies offer their choices as text, and a click on
1066
+ * an older button is answered "closed". The file is left where it is — an open error is not proof of corruption.
1067
+ */
1068
+ replyButtons() {
1069
+ if (this.replyButtonsCtl)
1070
+ return this.replyButtonsCtl;
1071
+ if (this.replyButtonsUnavailable)
1072
+ return null;
1073
+ const path = join(this.dataDir, "reply-buttons.db");
1074
+ let store;
1075
+ try {
1076
+ store = new ReplyButtonStore(path);
1077
+ }
1078
+ catch (err) {
1079
+ this.replyButtonsUnavailable = true;
1080
+ this.logger.error({ err: err.message, path }, "Reply buttons unavailable: reply-buttons.db could not be opened — replies offer their choices as text");
1081
+ try {
1082
+ this.notifyFleetError(`⚠️ Reply buttons are off until AgEnD restarts: ${path} could not be opened (${err.message}). Replies offer their choices as text.`);
1083
+ }
1084
+ catch { /* the notice is best effort */ }
1085
+ return null;
1086
+ }
1087
+ this.replyButtonsCtl = new ReplyButtonsController({
1088
+ store,
1089
+ now: () => Date.now(),
1090
+ adapterFor: (adapterId) => this.worlds.get(adapterId)?.adapter ?? (adapterId === this.getPrimaryAdapterId() ? this.adapter ?? undefined : undefined),
1091
+ mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
1092
+ deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
1093
+ publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
1094
+ logger: this.logger,
1095
+ setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
1096
+ clearTimer: (h) => clearTimeout(h),
1097
+ });
1098
+ this.replyButtonsStore = store;
1099
+ return this.replyButtonsCtl;
1100
+ }
1101
+ replyButtonsStore = null;
1102
+ /**
1103
+ * #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
1104
+ * room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
1105
+ * fleet bot.
1106
+ */
1107
+ mayAnswerReplyButtons(instance, adapterId, userId) {
1108
+ if ([...this.worlds.values()].some(w => w.botUserId && w.botUserId === userId))
1109
+ return false;
1110
+ if (this.classicChannels?.getChannelIdByInstance(instance) !== undefined)
1111
+ return true;
1112
+ const access = this.worlds.get(adapterId)?.accessManager ?? (adapterId === this.getPrimaryAdapterId() ? this.accessManager : null);
1113
+ return this.isFleetAdmin(userId, adapterId) || !!access?.isAllowed(userId);
1114
+ }
1115
+ /** #1266: the choice, delivered as an ordinary inbound message from whoever made it, and shown in the web chat. */
1116
+ async deliverReplyButtonChoice(set, button, by) {
1117
+ const content = replyButtonClickText(button);
1118
+ const web = by.source === "web";
1119
+ const ts = new Date().toISOString();
1120
+ const messageId = web ? newWebMessageId() : (set.messageId ?? "");
1121
+ const sent = await this.deliverToInstance(set.instance, {
1122
+ type: "fleet_inbound",
1123
+ content,
1124
+ targetSession: set.instance,
1125
+ meta: {
1126
+ chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
1127
+ thread_id: set.threadId, adapter_id: set.adapterId === "web" ? undefined : set.adapterId,
1128
+ source: web ? "web" : (this.worlds.get(set.adapterId)?.adapter.type ?? "web"),
1129
+ },
1130
+ });
1131
+ if (sent === false)
1132
+ return false;
1133
+ this.lastInboundUser.set(set.instance, by.username);
1134
+ this.emitSseEvent("message", {
1135
+ instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1136
+ });
1137
+ return true;
1138
+ }
999
1139
  ensureDeliveryOutbox() {
1000
1140
  if (this.deliveryOutbox)
1001
1141
  return;
1002
- const outbox = new DeliveryOutbox(join(this.dataDir, "delivery-outbox.db"), this.managerBootId);
1003
- this.deliveryOutbox = outbox;
1004
- if (!this.deliveryOutboxRecovered) {
1005
- const recovered = outbox.recoverForBoot(this.managerBootId);
1006
- this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
1007
- this.deliveryOutboxRecovered = true;
1142
+ const dbPath = join(this.dataDir, "delivery-outbox.db");
1143
+ let outbox;
1144
+ try {
1145
+ outbox = new DeliveryOutbox(dbPath, this.managerBootId);
1146
+ if (!this.deliveryOutboxRecovered) {
1147
+ const recovered = outbox.recoverForBoot(this.managerBootId);
1148
+ this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
1149
+ }
1150
+ }
1151
+ catch (cause) {
1152
+ // Unlike events.db, these rows cannot be discarded: queued messages and uncertain submissions are authoritative.
1153
+ // Publish only a fully recovered store. A partial open/recovery must not authorize admission on a later call.
1154
+ try {
1155
+ outbox?.close();
1156
+ }
1157
+ catch { /* preserve the original failure */ }
1158
+ const error = new OutboxOpenError(dbPath, cause);
1159
+ this.logger.error({ err: cause, dbPath, kind: error.kind }, error.message);
1160
+ throw error;
1008
1161
  }
1162
+ this.deliveryOutbox = outbox;
1163
+ this.deliveryOutboxRecovered = true;
1009
1164
  outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
1010
1165
  outbox.on("state", (event) => {
1011
1166
  this.scheduleDeliveryOutboxPump();
@@ -1520,6 +1675,10 @@ export class FleetManager {
1520
1675
  });
1521
1676
  return caller;
1522
1677
  }
1678
+ /** Pure cached admission for command continuations; no IO or update-progress lookup. */
1679
+ isFleetStopping() {
1680
+ return this.shuttingDown;
1681
+ }
1523
1682
  /**
1524
1683
  * Is the fleet going down (or coming back up) on purpose?
1525
1684
  *
@@ -1581,6 +1740,16 @@ export class FleetManager {
1581
1740
  lastActivityMs(name) {
1582
1741
  return this.lastActivity.get(name) ?? 0;
1583
1742
  }
1743
+ /** Capture the current daemon and delivery owner for a background context read. */
1744
+ getPaneContextSource(name) {
1745
+ const daemon = this.daemons.get(name);
1746
+ const source = daemon?.getPaneContextSource?.();
1747
+ if (!daemon || !source || this.shuttingDown)
1748
+ return null;
1749
+ const epoch = this.getDeliveryEpoch(name);
1750
+ return { ...source, generation: `${source.generation}:${epoch}`, isCurrent: () => !this.shuttingDown
1751
+ && this.daemons.get(name) === daemon && this.getDeliveryEpoch(name) === epoch && source.isCurrent() };
1752
+ }
1584
1753
  /**
1585
1754
  * Is the instance between turns?
1586
1755
  *
@@ -1856,7 +2025,7 @@ export class FleetManager {
1856
2025
  await data.respond(t(`${action}.usage`));
1857
2026
  return;
1858
2027
  }
1859
- if (!this.fleetConfig?.instances[requested]) {
2028
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, requested)) {
1860
2029
  await data.respond(t("instance.not_found", requested));
1861
2030
  return;
1862
2031
  }
@@ -1912,11 +2081,19 @@ export class FleetManager {
1912
2081
  }
1913
2082
  const origin = `slash /update by ${adapterId}:${data.userId}`;
1914
2083
  recordInternalRequest(this.dataDir, "update", origin);
1915
- const { command, args } = updateCommand(installed.agend);
1916
- const child = spawn(command, args, {
2084
+ const launch = await resolveUpdateLaunch(installed.agend);
2085
+ if (!launch.ok) {
2086
+ this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
2087
+ return;
2088
+ }
2089
+ if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
2090
+ this.failUpdateProgress(t("not_authorized"));
2091
+ return;
2092
+ }
2093
+ const child = spawn(launch.command, launch.args, {
1917
2094
  detached: true, stdio: "ignore", env: withOrigin(origin),
1918
2095
  });
1919
- child.once("error", err => this.failUpdateProgress(err.message));
2096
+ watchUpdateLaunch(child, message => this.failUpdateProgress(message));
1920
2097
  child.unref();
1921
2098
  }
1922
2099
  async handleProfileSlash(data, adapterId) {
@@ -1938,7 +2115,7 @@ export class FleetManager {
1938
2115
  await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
1939
2116
  }
1940
2117
  async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
1941
- if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
2118
+ if (!general || !Object.hasOwn(this.fleetConfig?.instances ?? {}, general) || !this.fleetConfig?.instances[general]?.general_topic) {
1942
2119
  await respond(t("profile.general_only"));
1943
2120
  return;
1944
2121
  }
@@ -3184,7 +3361,9 @@ export class FleetManager {
3184
3361
  await this.sendLocalDashboard(data, adapterId);
3185
3362
  return;
3186
3363
  }
3187
- await this.showDashboardMenu(owner, data.respondButtons);
3364
+ // Discord shows a deferred reply that is never answered as "the application did not respond".
3365
+ if (!await this.showDashboardMenu(owner, data.respondButtons, data.retireButtons))
3366
+ await data.respond(t("dashboard.menu_failed"));
3188
3367
  }
3189
3368
  }
3190
3369
  else if (data.command === "restart") {
@@ -3924,18 +4103,17 @@ export class FleetManager {
3924
4103
  /**
3925
4104
  * Probe the same executable set exposed by the web backend catalog.
3926
4105
  *
3927
- * This deliberately has no cache: an install `/login` ran can add a
4106
+ * This deliberately skips the cache: an install `/login` ran can add a
3928
4107
  * binary to PATH while the fleet process remains alive, and the next bare
3929
4108
  * `/login` must see it without requiring a restart or an explicit cache
3930
- * invalidation call.
4109
+ * invalidation call. The fresh answers also refresh the shared cache that
4110
+ * `/ui/backends` reads. #1490: async and bounded, never `which` on the event loop; a probe with no answer in time
4111
+ * counts as not installed, as the old timeout did.
3931
4112
  */
3932
- probeInstalledBackends() {
3933
- const installed = new Set();
3934
- for (const [backend, info] of Object.entries(BACKEND_INSTALLATION_INFO)) {
3935
- if (checkBinaryInstalled(info.binary))
3936
- installed.add(backend);
3937
- }
3938
- return installed;
4113
+ async probeInstalledBackends() {
4114
+ const entries = Object.entries(BACKEND_INSTALLATION_INFO);
4115
+ const found = await Promise.all(entries.map(([, info]) => binaryProbe.probe(info.binary, { fresh: true })));
4116
+ return new Set(entries.filter((_, i) => { const r = found[i]; return r.known && r.path !== null; }).map(([backend]) => backend));
3939
4117
  }
3940
4118
  /**
3941
4119
  * One fleet-level notice per burst, not one per instance: a post-update herd
@@ -4294,24 +4472,10 @@ export class FleetManager {
4294
4472
  if (!existsSync(envPath))
4295
4473
  return;
4296
4474
  assertSettingsLease(settingsFileResource(envPath), owner);
4297
- const content = readFileSync(envPath, "utf-8");
4298
- for (const line of content.split("\n")) {
4299
- const trimmed = line.trim();
4300
- if (!trimmed || trimmed.startsWith("#"))
4301
- continue;
4302
- const eqIdx = trimmed.indexOf("=");
4303
- if (eqIdx < 0)
4304
- continue;
4305
- // Accept `export KEY=value` — the shell-style form people paste from their
4306
- // .bashrc. Without this the variable landed in process.env under the key
4307
- // "export KEY" and silently did nothing.
4308
- const key = trimmed.slice(0, eqIdx).replace(/^export\s+/, "").trim();
4309
- const raw = trimmed.slice(eqIdx + 1);
4310
- const value = raw.replace(/^["'](.*)["']$/, '$1');
4311
- // .env file always wins over inherited shell env vars, so that
4312
- // quickstart's newly written token overrides any stale value.
4475
+ // .env file always wins over inherited shell env vars, so that quickstart's newly written token overrides any stale
4476
+ // value. One parser for every reader of .env (env-file.ts): `export KEY=value` included.
4477
+ for (const [key, value] of parseEnvText(readFileSync(envPath, "utf-8")))
4313
4478
  process.env[key] = value;
4314
- }
4315
4479
  }
4316
4480
  /** Initialize auth before any adapter can answer /dashboard. */
4317
4481
  initializeWebAuthTokens() {
@@ -4440,11 +4604,15 @@ export class FleetManager {
4440
4604
  await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
4441
4605
  return;
4442
4606
  }
4443
- await this.showDashboardMenu(owner);
4607
+ if (!await this.showDashboardMenu(owner)) {
4608
+ await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4609
+ }
4444
4610
  }
4445
4611
  async sendLocalDashboard(data, adapterId) {
4446
- if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId))
4612
+ if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4613
+ await data.respond(t("not_authorized"));
4447
4614
  return;
4615
+ }
4448
4616
  const text = this.topicCommands.getDashboardText();
4449
4617
  const adapter = this.adapters.get(adapterId);
4450
4618
  const token = this.webToken;
@@ -4466,7 +4634,8 @@ export class FleetManager {
4466
4634
  await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
4467
4635
  deliveredByDm = true;
4468
4636
  }
4469
- catch {
4637
+ catch (err) {
4638
+ this.logDashboardDmFailure(err);
4470
4639
  if (!current())
4471
4640
  throw new Error("owner changed");
4472
4641
  const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
@@ -4476,15 +4645,25 @@ export class FleetManager {
4476
4645
  if (!current())
4477
4646
  throw new Error("owner changed");
4478
4647
  if (deliveredByDm)
4479
- await withinBudget(data.respond(t("dashboard.private_sent")), deadline);
4648
+ await withinBudget(data.respond(t("dashboard.private_sent_dm")), deadline);
4480
4649
  }
4481
- catch {
4650
+ catch (err) {
4482
4651
  this.webLoginCodes.revokeIfCurrent(login.issuanceId);
4652
+ this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
4653
+ await data.respond(t("dashboard.private_failed")).catch(() => undefined);
4483
4654
  }
4484
4655
  }
4485
- async showDashboardMenu(owner, respondButtons) {
4486
- if (!this.publicOwnerCurrent(owner))
4487
- return;
4656
+ /** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
4657
+ logDashboardDmFailure(err) {
4658
+ const code = err?.code;
4659
+ this.logger.info({ code: typeof code === "string" || typeof code === "number" ? code : undefined, err: err?.message }, "Dashboard DM not delivered; answering privately where the command was used");
4660
+ }
4661
+ /** False when the menu was not shown (the caller still owes the user an answer). */
4662
+ async showDashboardMenu(owner, respondButtons, retireButtons) {
4663
+ if (!this.publicOwnerCurrent(owner)) {
4664
+ this.logger.info("Dashboard menu not shown: the General owner changed");
4665
+ return false;
4666
+ }
4488
4667
  const adapter = owner.binding;
4489
4668
  const status = this.getPublicWebStatus();
4490
4669
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
@@ -4494,7 +4673,7 @@ export class FleetManager {
4494
4673
  const choices = [{ action: "local", label: t("dashboard.local") },
4495
4674
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4496
4675
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4497
- await this.postNonceButtonPromptOrThrow({
4676
+ const posted = await this.postNonceButtonPrompt({
4498
4677
  prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4499
4678
  chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
4500
4679
  message: menuText,
@@ -4503,9 +4682,11 @@ export class FleetManager {
4503
4682
  const messageId = await respondButtons(menuText, c);
4504
4683
  if (!messageId)
4505
4684
  throw new Error("menu refused");
4506
- return { chatId: owner.chatId, threadId: owner.threadId, messageId };
4685
+ // The menu is the command's ephemeral reply: only that interaction can collapse it.
4686
+ return { chatId: owner.chatId, threadId: owner.threadId, messageId, ...(retireButtons ? { retire: retireButtons } : {}) };
4507
4687
  } } : {}),
4508
4688
  });
4689
+ return posted !== null;
4509
4690
  }
4510
4691
  async handleDashboardCallback(data, adapterId, adapter) {
4511
4692
  const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
@@ -4525,9 +4706,26 @@ export class FleetManager {
4525
4706
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
4526
4707
  if (result?.confirmed === false)
4527
4708
  data.ack?.(t("dashboard.public_cleanup"));
4528
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"));
4709
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
4529
4710
  return true;
4530
4711
  }
4712
+ // The click is claimed and its buttons are spent: say so on the menu now. A public link follows each of its steps
4713
+ // there (the first one installs cloudflared, which can take a minute): on Discord through the interaction, on
4714
+ // Telegram by editing the menu message itself. Only static words and numbers — the menu may be in General.
4715
+ const progressEdit = data.editClicked ?? entry.retire;
4716
+ const progress = progressEdit && action !== "public"
4717
+ ? progressEdit(t("dashboard.private_sending")).catch(err => this.logger.debug({ err }, "Could not show the dashboard menu's progress"))
4718
+ : Promise.resolve();
4719
+ const stepsEdit = progressEdit ?? (entry.messageId && entry.adapter.editMessageRemoveButtons
4720
+ ? (text) => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, text, entry.threadId) : undefined);
4721
+ const steps = action === "public" && stepsEdit ? new ThrottledMessageEditor({
4722
+ edit: stepsEdit, now: () => performance.now(), heartbeatMs: 5_000,
4723
+ // Telegram counts edits against a group's ~20 messages a minute.
4724
+ minIntervalMs: owner.binding.type === "telegram" ? 3_000 : 2_000,
4725
+ onError: err => this.logger.debug({ err }, "Could not show the public link's progress"),
4726
+ }) : null;
4727
+ let lastSteps;
4728
+ let deliveredByDm = false;
4531
4729
  const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
4532
4730
  const token = this.webToken;
4533
4731
  if (!token || !current())
@@ -4542,9 +4740,12 @@ export class FleetManager {
4542
4740
  try {
4543
4741
  if (!direct.sendDirect)
4544
4742
  throw new Error("DM unavailable");
4545
- return await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
4743
+ const sent = await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
4744
+ deliveredByDm = true;
4745
+ return sent;
4546
4746
  }
4547
- catch {
4747
+ catch (err) {
4748
+ this.logDashboardDmFailure(err);
4548
4749
  if (direct.type !== "discord" || !data.respondPrivate || !current())
4549
4750
  throw new Error("private delivery unavailable");
4550
4751
  const sent = await withinBudget(data.respondPrivate(text, c), deadline);
@@ -4563,17 +4764,31 @@ export class FleetManager {
4563
4764
  throw new Error("owner changed");
4564
4765
  return true;
4565
4766
  }
4566
- catch {
4767
+ catch (err) {
4567
4768
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
4568
- this.logger.info("Dashboard private delivery was not confirmed");
4769
+ this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
4569
4770
  return false;
4570
4771
  }
4571
4772
  };
4572
- const ok = action === "public"
4573
- ? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent))
4574
- : await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
4773
+ let ok = false;
4774
+ try {
4775
+ ok = action === "public"
4776
+ ? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent), p => {
4777
+ lastSteps = p;
4778
+ steps?.update(() => renderPublicLinkProgress(p, performance.now()));
4779
+ })
4780
+ : await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
4781
+ }
4782
+ catch (err) {
4783
+ // Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
4784
+ this.logger.warn({ err }, "Dashboard sign-in delivery failed");
4785
+ }
4575
4786
  // Only safe, static words enter General; never link, code or platform error text.
4576
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t(ok ? "dashboard.private_sent" : "dashboard.private_failed"));
4787
+ const outcome = t(!ok ? "dashboard.private_failed"
4788
+ : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
4789
+ await progress;
4790
+ await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
4791
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
4577
4792
  return true;
4578
4793
  }
4579
4794
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -4986,8 +5201,8 @@ export class FleetManager {
4986
5201
  for (const need of [...needsGeneral]) {
4987
5202
  const matchIdx = unboundGenerals.findIndex(([, inst]) => {
4988
5203
  const topicId = String(inst.topic_id ?? "");
4989
- if (need.ch.type === "discord" && need.ch.options?.general_channel_id) {
4990
- return topicId === String(need.ch.options.general_channel_id);
5204
+ if (need.ch.type === "discord" && discordGeneralChannelId(need.ch)) {
5205
+ return topicId === discordGeneralChannelId(need.ch);
4991
5206
  }
4992
5207
  if (need.ch.type === "telegram") {
4993
5208
  return topicId === "1" || topicId === "";
@@ -5285,6 +5500,8 @@ export class FleetManager {
5285
5500
  }
5286
5501
  // Health HTTP endpoint
5287
5502
  this.startHealthServer(fleet.health_port ?? 19280);
5503
+ // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
5504
+ void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
5288
5505
  // Daily update check — first check after 1 hour, then every 24 hours
5289
5506
  this.updateCheckTimer = setTimeout(() => {
5290
5507
  this.checkForUpdates();
@@ -6438,26 +6655,64 @@ export class FleetManager {
6438
6655
  return undefined;
6439
6656
  }
6440
6657
  /**
6441
- * #1085, defense in depth: a routed Telegram target must belong to the group
6442
- * the message came from — its own world's group_id. Two Telegram fleet
6443
- * worlds can number topics alike; a coincidence is dropped, never delivered.
6658
+ * A forum-root command has one configured General before any adapter claims
6659
+ * dedup. Bare commands prefer the primary adapter's General in this group;
6660
+ * explicit suffixes select their own bot. Runtime availability must not pick
6661
+ * another administrator, so stopped owners remain selected and every copy
6662
+ * silently refuses instead of falling back.
6663
+ * undefined = another ingress scope; null = root command with no proven target.
6444
6664
  */
6665
+ telegramRootCommandTarget(msg, threadId) {
6666
+ if (msg.source !== "telegram" || threadId !== undefined || !/^\/\w/.test(msg.text?.trim() ?? ""))
6667
+ return undefined;
6668
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6669
+ const receiving = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId);
6670
+ if (receiving?.type !== "telegram" || receiving.group_id == null || String(receiving.group_id) !== msg.chatId)
6671
+ return undefined;
6672
+ const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true)
6673
+ .map(([name]) => {
6674
+ const owner = this.getInstanceAdapterId(name);
6675
+ return { name, owner, channel: channels.find(ch => (ch.id ?? ch.type) === owner) };
6676
+ });
6677
+ const candidates = generals.filter(entry => entry.owner && entry.channel?.type === "telegram"
6678
+ && entry.channel.group_id != null && String(entry.channel.group_id) === msg.chatId);
6679
+ const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
6680
+ let selected;
6681
+ if (suffix) {
6682
+ const matches = candidates.filter(entry => this.worlds.get(entry.owner)?.botUsername?.toLowerCase() === suffix.toLowerCase());
6683
+ const owners = new Set(matches.map(entry => entry.owner));
6684
+ if (owners.size !== 1)
6685
+ return null;
6686
+ selected = matches[0];
6687
+ }
6688
+ else {
6689
+ // An unresolvable General may be the intended primary; do not silently
6690
+ // widen authority to the next readable binding.
6691
+ if (generals.some(entry => !entry.channel || (entry.channel.type === "telegram" && entry.channel.group_id == null)))
6692
+ return null;
6693
+ selected = candidates.find(entry => entry.owner === this.getPrimaryAdapterId()) ?? candidates[0];
6694
+ }
6695
+ return selected?.owner ? { name: selected.name, owner: selected.owner } : null;
6696
+ }
6445
6697
  /**
6446
6698
  * #1346: whether this copy may claim the shared dedup key for
6447
6699
  * command-like text. Fleet topics resolve an owning adapter and only its
6448
6700
  * copy proceeds. Classic targets and unknown routing keep their existing
6449
- * handling. At the receiving Telegram world's own forum root, an explicit
6450
- * suffix requires a known matching username before dedup; present-thread
6701
+ * handling. Telegram forum-root commands select a configured General before
6702
+ * dedup. An explicit suffix requires a known matching username; present-thread
6451
6703
  * copies retain their permissive handling when that identity is unknown.
6452
6704
  */
6453
- isOwnerCommandCopy(msg, threadId) {
6705
+ isOwnerCommandCopy(msg, threadId, rootCommand = this.telegramRootCommandTarget(msg, threadId)) {
6706
+ if (rootCommand !== undefined) {
6707
+ return rootCommand !== null && msg.adapterId === rootCommand.owner && this.daemons.has(rootCommand.name);
6708
+ }
6454
6709
  if (!msg.adapterId)
6455
6710
  return true;
6456
6711
  const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
6457
6712
  if (threadId === undefined) {
6458
- // General at the forum root belongs to the receiving world. Prove an
6459
- // explicit suffix before shared dedup, even when there is no message id.
6460
- // Classic/private/foreign-forum copies retain their own dispatch rules.
6713
+ // Compatibility for a runtime world without configuration: preserve
6714
+ // explicit receiver proof; Classic/private/foreign-forum copies retain
6715
+ // their own dispatch rules.
6461
6716
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6462
6717
  const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
6463
6718
  ?? this.worlds.get(msg.adapterId)?.channelConfig;
@@ -6487,6 +6742,9 @@ export class FleetManager {
6487
6742
  }
6488
6743
  return true;
6489
6744
  }
6745
+ /**
6746
+ * #1085: coincident topic numbers must not route across Telegram groups.
6747
+ */
6490
6748
  resolveInboundTarget(msg, threadId) {
6491
6749
  if (msg.source !== "telegram")
6492
6750
  return this.routing.resolve(threadId);
@@ -6523,6 +6781,7 @@ export class FleetManager {
6523
6781
  return;
6524
6782
  }
6525
6783
  const threadId = this.inboundRouteThreadId(msg);
6784
+ const rootCommand = this.telegramRootCommandTarget(msg, threadId);
6526
6785
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
6527
6786
  // Ownership and per-adapter filtering both run before the dedup claim below.
6528
6787
  // When several bots share a guild they all receive the same message; a copy
@@ -6583,7 +6842,7 @@ export class FleetManager {
6583
6842
  // to flow — so only fleet targets are judged here; the in-handler gates
6584
6843
  // (owner entry check, Discord ignore) cover classic instead.
6585
6844
  if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
6586
- && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
6845
+ && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId, rootCommand)) {
6587
6846
  this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
6588
6847
  return;
6589
6848
  }
@@ -6890,7 +7149,7 @@ export class FleetManager {
6890
7149
  }
6891
7150
  // General topic: Discord /xxx gets the system note first (never a
6892
7151
  // command); other sources fall through to the handlers below.
6893
- const generalInstance = this.findGeneralInstance(msg.adapterId);
7152
+ const generalInstance = rootCommand?.name ?? this.findGeneralInstance(msg.adapterId);
6894
7153
  if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
6895
7154
  return;
6896
7155
  if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
@@ -7279,6 +7538,12 @@ export class FleetManager {
7279
7538
  return;
7280
7539
  }
7281
7540
  }
7541
+ // #1266: buttons are checked before anything is sent, like stickers.
7542
+ const parsedButtons = parseReplyButtons(args.buttons, args);
7543
+ if (parsedButtons && "error" in parsedButtons) {
7544
+ respond(null, `reply: ${parsedButtons.error}`);
7545
+ return;
7546
+ }
7282
7547
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
7283
7548
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
7284
7549
  if (stickerProblem) {
@@ -7291,8 +7556,33 @@ export class FleetManager {
7291
7556
  ticket.subscribe(respond);
7292
7557
  return;
7293
7558
  }
7559
+ // #1266: the buttons' set, before the send (a click cannot match it until the message is known). Where buttons
7560
+ // cannot be shown, the choices go into the text instead.
7561
+ let replySet = null;
7562
+ if (parsedButtons && "buttons" in parsedButtons) {
7563
+ const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
7564
+ if (buttons) {
7565
+ const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
7566
+ replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
7567
+ }
7568
+ else {
7569
+ args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
7570
+ }
7571
+ }
7294
7572
  const original = respond;
7295
7573
  const respondAndRecord = (result, error) => {
7574
+ let buttonsView = null;
7575
+ if (replySet) {
7576
+ const sent = result;
7577
+ const carrying = sent?.buttonsMessageId ?? sent?.messageId;
7578
+ if (!error && carrying) {
7579
+ this.replyButtonsCtl?.bind(replySet.id, carrying);
7580
+ buttonsView = this.replyButtonsCtl?.viewOf(replySet.id) ?? null;
7581
+ }
7582
+ else {
7583
+ this.replyButtonsCtl?.discard(replySet.id);
7584
+ }
7585
+ }
7296
7586
  ticket.complete(result, error);
7297
7587
  // Return the platform outcome first. Bookkeeping below must never turn
7298
7588
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -7305,7 +7595,7 @@ export class FleetManager {
7305
7595
  // an agent cannot invent it to suppress the normal completion marker.
7306
7596
  if (!error && result != null && msg.statusOnly !== true) {
7307
7597
  try {
7308
- this.afterReplyRouted(instanceName, args, senderSessionName);
7598
+ this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
7309
7599
  }
7310
7600
  catch (err) {
7311
7601
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -7320,9 +7610,11 @@ export class FleetManager {
7320
7610
  });
7321
7611
  }
7322
7612
  };
7323
- if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
7613
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
7324
7614
  return;
7325
7615
  }
7616
+ if (replySet)
7617
+ this.replyButtonsCtl?.discard(replySet.id);
7326
7618
  // routeToolCall knows "reply"; not handling it means the world changed.
7327
7619
  ticket.complete(null, "reply not handled");
7328
7620
  original(null, "reply not handled");
@@ -7356,7 +7648,7 @@ export class FleetManager {
7356
7648
  webOnlyPromptPlace() {
7357
7649
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
7358
7650
  }
7359
- afterReplyRouted(instanceName, args, senderSessionName) {
7651
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
7360
7652
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
7361
7653
  // more work either. Split the difference: an instance that is clearly
7362
7654
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -7387,6 +7679,7 @@ export class FleetManager {
7387
7679
  text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
7388
7680
  ts: new Date().toISOString(),
7389
7681
  ...(attachments.length ? { attachments } : {}),
7682
+ ...(buttons ? { buttons } : {}), // #1266
7390
7683
  });
7391
7684
  // Log bot reply to classic instance chat-log
7392
7685
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
@@ -8590,22 +8883,23 @@ export class FleetManager {
8590
8883
  const output = String(this.rawFleetDocument);
8591
8884
  if (redundantPaths.length > 0)
8592
8885
  this.writeFleetConfigBackup(source);
8593
- const tempPath = `${this.configPath}.tmp-${process.pid}`;
8594
- writeFileSync(tempPath, output, "utf-8");
8595
- // #1056 (Fable's 2.2 audit): the patcher writes leaves, not configs, so a wrong patch can produce a file the
8596
- // validator — or the next start — refuses (a bare channels[0], a channels list with a null). Load and validate
8597
- // what is about to replace fleet.yaml; a result that adds an error is not written, and the save fails instead.
8598
- const refusal = this.savedFleetConfigProblem(tempPath);
8599
- if (refusal) {
8600
- try {
8601
- unlinkSync(tempPath);
8602
- }
8603
- catch { /* already gone */ }
8604
- throw new Error(refusal);
8605
- }
8606
- if (existsSync(this.configPath))
8607
- chmodSync(tempPath, statSync(this.configPath).mode);
8608
- renameSync(tempPath, this.configPath);
8886
+ // Atomic write with fsync. The beforeRename hook runs after fsync and
8887
+ // before rename so validation failures are reported before the file is
8888
+ // replaced (#1056), and the temp file is cleaned up on any throw.
8889
+ atomicWriteFileSync(this.configPath, output, {
8890
+ mode: existsSync(this.configPath) ? statSync(this.configPath).mode : 0o644,
8891
+ fsync: this.fsyncForTest,
8892
+ beforeRename: (tempPath) => {
8893
+ // #1056 (Fable's 2.2 audit): the patcher writes leaves, not configs, so
8894
+ // a wrong patch can produce a file the validator — or the next start —
8895
+ // refuses. Load and validate what is about to replace fleet.yaml.
8896
+ const refusal = this.savedFleetConfigProblem(tempPath);
8897
+ if (refusal)
8898
+ throw new Error(refusal);
8899
+ if (existsSync(this.configPath))
8900
+ chmodSync(tempPath, statSync(this.configPath).mode);
8901
+ },
8902
+ });
8609
8903
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
8610
8904
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
8611
8905
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
@@ -9916,9 +10210,9 @@ export class FleetManager {
9916
10210
  if (generalTopic != null)
9917
10211
  return { chatId, opts: { threadId: String(generalTopic) } };
9918
10212
  if (cfg?.type === "discord") {
9919
- const configured = cfg.options?.general_channel_id;
9920
- if (configured != null && String(configured)) {
9921
- return { chatId, opts: { threadId: String(configured) } };
10213
+ const configured = discordGeneralChannelId(cfg);
10214
+ if (configured) {
10215
+ return { chatId, opts: { threadId: configured } };
9922
10216
  }
9923
10217
  return null; // a guild id is not a channel; sending would always fail
9924
10218
  }
@@ -10110,9 +10404,7 @@ export class FleetManager {
10110
10404
  return;
10111
10405
  this.pendingNonceButtons.delete(nonce);
10112
10406
  this.webPromptGone(entry, entry.expiredText);
10113
- if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
10114
- entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
10115
- }
10407
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
10116
10408
  }, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
10117
10409
  entry.timer.unref?.();
10118
10410
  this.pendingNonceButtons.set(nonce, entry);
@@ -10136,6 +10428,8 @@ export class FleetManager {
10136
10428
  entry.threadId = sent.threadId;
10137
10429
  }
10138
10430
  entry.messageId = sent.messageId;
10431
+ if (sent.retire)
10432
+ entry.retire = sent.retire;
10139
10433
  // Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
10140
10434
  // and only if nothing claimed or expired it meanwhile.
10141
10435
  if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
@@ -10167,7 +10461,7 @@ export class FleetManager {
10167
10461
  * longer parses) collapses the clicked message so the dead button stops
10168
10462
  * inviting clicks — the same courtesy the cancel button extends.
10169
10463
  */
10170
- consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling) {
10464
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling, authorize) {
10171
10465
  if (!data.callbackData.startsWith(prefix))
10172
10466
  return null;
10173
10467
  const match = data.callbackData.match(actionRe);
@@ -10186,6 +10480,9 @@ export class FleetManager {
10186
10480
  if (staleHandling?.keepText && adapter?.removeMessageButtons) {
10187
10481
  adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
10188
10482
  }
10483
+ else if (data.editClicked) {
10484
+ data.editClicked(t("buttons.stale")).catch(collapseFailed);
10485
+ }
10189
10486
  else {
10190
10487
  adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
10191
10488
  }
@@ -10204,11 +10501,13 @@ export class FleetManager {
10204
10501
  const isAuthorized = fromWeb
10205
10502
  ? pending.web !== undefined
10206
10503
  : data.userId
10207
- ? pending.allowAnyUser
10208
- ? true
10209
- : pending.authChannelId
10210
- ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10211
- : this.isFleetAdmin(data.userId, callbackAdapterId)
10504
+ ? authorize
10505
+ ? authorize(data.userId, pending)
10506
+ : pending.allowAnyUser
10507
+ ? true
10508
+ : pending.authChannelId
10509
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10510
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
10212
10511
  : false;
10213
10512
  const mismatchedFields = [];
10214
10513
  if (pending.adapterId !== callbackAdapterId)
@@ -10282,6 +10581,14 @@ export class FleetManager {
10282
10581
  }
10283
10582
  /** Routes a click to the handler that owns its prefix; false when none does. */
10284
10583
  async dispatchAdapterCallback(data, adapterId, adapter) {
10584
+ if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
10585
+ const buttons = this.replyButtons();
10586
+ if (!buttons) {
10587
+ data.ack?.(t("reply_buttons.closed"));
10588
+ return true;
10589
+ }
10590
+ return buttons.handleCallback(data, adapterId);
10591
+ }
10285
10592
  if (this.needsYou?.handleCallback(data, adapterId))
10286
10593
  return true;
10287
10594
  if (await this.handleTipDismiss(data, adapterId, adapter))
@@ -10552,6 +10859,11 @@ export class FleetManager {
10552
10859
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
10553
10860
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
10554
10861
  */
10862
+ /** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
10863
+ clickWebReplyButton(instance, id, index) {
10864
+ const buttons = this.replyButtons();
10865
+ return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
10866
+ }
10555
10867
  async clickWebPrompt(instance, nonce, action) {
10556
10868
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
10557
10869
  return { status: 400, error: "Malformed prompt answer" };
@@ -10580,9 +10892,16 @@ export class FleetManager {
10580
10892
  return { status: 200 };
10581
10893
  return { status: 409, error: notice ?? "This prompt is no longer open" };
10582
10894
  }
10583
- async retireNonceButtons(pending, messageId, text) {
10895
+ async retireNonceButtons(pending, messageId, text,
10896
+ /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
10897
+ editClicked) {
10584
10898
  this.webPromptGone(pending, text);
10585
10899
  try {
10900
+ const retire = editClicked ?? pending.retire;
10901
+ if (retire) {
10902
+ await retire(text);
10903
+ return;
10904
+ }
10586
10905
  if (!pending.adapter.editMessageRemoveButtons)
10587
10906
  throw new Error("adapter cannot remove prompt buttons");
10588
10907
  await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
@@ -11040,8 +11359,8 @@ export class FleetManager {
11040
11359
  this.webPromptGone(entry, entry.expiredText);
11041
11360
  }
11042
11361
  const collapses = entries
11043
- .filter(entry => entry.messageId && entry.adapter.editMessageRemoveButtons)
11044
- .map(entry => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")));
11362
+ .map(entry => this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")))
11363
+ .filter(collapse => collapse !== undefined);
11045
11364
  if (!collapses.length)
11046
11365
  return;
11047
11366
  await Promise.race([
@@ -11060,12 +11379,17 @@ export class FleetManager {
11060
11379
  if (entry.timer)
11061
11380
  clearTimeout(entry.timer);
11062
11381
  this.webPromptGone(entry, entry.expiredText);
11063
- if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
11064
- entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
11065
- .catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
11066
- }
11382
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
11067
11383
  }
11068
11384
  }
11385
+ /** A lapsed prompt's buttons collapsed to its expired text — through its own interaction when it is private. */
11386
+ collapseNoncePrompt(entry) {
11387
+ if (entry.retire)
11388
+ return entry.retire(entry.expiredText);
11389
+ if (!entry.messageId || !entry.adapter.editMessageRemoveButtons)
11390
+ return undefined;
11391
+ return entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId);
11392
+ }
11069
11393
  /** A clean exit is intentional from the CLI's perspective, but often not from
11070
11394
  * the operator's. Keep the instance notice passive and put the action in the
11071
11395
  * same-world General topic where an administrator can make the choice. */
@@ -12048,7 +12372,7 @@ export class FleetManager {
12048
12372
  for (const name of this.configuredBackendInstanceNames()) {
12049
12373
  configured.add(this.backendNameOf(name));
12050
12374
  }
12051
- const installed = this.probeInstalledBackends();
12375
+ const installed = await this.probeInstalledBackends();
12052
12376
  // One entry point for "get this CLI working" (#1131): a backend that is
12053
12377
  // not installed is offered too, and the click installs it first, then
12054
12378
  // signs in (startLoginSession routes it).
@@ -12680,10 +13004,16 @@ export class FleetManager {
12680
13004
  attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
12681
13005
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
12682
13006
  role: typeof m.role === "string" ? m.role : undefined,
13007
+ buttons: m.buttons,
12683
13008
  });
12684
13009
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
12685
13010
  return;
12686
13011
  }
13012
+ // #1266: a reply's buttons ended — the history shows it too (a later load, the public link's poll).
13013
+ if (event === "reply_buttons" && data && typeof data === "object") {
13014
+ const u = data;
13015
+ this.webChatHistory.updateButtons(String(u.instance ?? ""), u.buttons);
13016
+ }
12687
13017
  broadcastSseEvent(this.sseClients, event, data, onError);
12688
13018
  }
12689
13019
  listClaimedTasks(assignee) {
@@ -14495,6 +14825,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14495
14825
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
14496
14826
  }
14497
14827
  async applyModel(instanceName, model) {
14828
+ // Reject model names with newlines or control characters: they would be
14829
+ // persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
14830
+ if (/[\x00-\x1f\x7f]/.test(model)) {
14831
+ return t("model.invalid_chars");
14832
+ }
14498
14833
  const backendName = this.backendNameForInstance(instanceName);
14499
14834
  let strategy = "restart";
14500
14835
  try {
@@ -14869,7 +15204,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14869
15204
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
14870
15205
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
14871
15206
  const needsStopped = this.stopNeedsYou();
14872
- this.cacheService?.stop();
15207
+ // #1490 P3: stop() now returns a Promise; await it so the final
15208
+ // ledger flush completes before process.exit(0) in doStopAll's caller.
15209
+ const cacheStopped = this.cacheService?.stop() ?? Promise.resolve();
14873
15210
  const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
14874
15211
  this.ipcStoppingInstances.add("__fleet_stopping__");
14875
15212
  // Release held delivery promises before awaiting daemon shutdown, then
@@ -14903,6 +15240,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14903
15240
  await profileStopped;
14904
15241
  await needsStopped;
14905
15242
  await settingsControlStopped;
15243
+ await cacheStopped;
14906
15244
  await this.cpuProfileControl?.close();
14907
15245
  this.cpuProfileControl = null;
14908
15246
  await this.shutdownLoginWindows();
@@ -15035,6 +15373,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15035
15373
  // nor revives a session that was revoked while the disk was refusing writes.
15036
15374
  this.webSessions?.flush();
15037
15375
  this.eventLog?.close();
15376
+ this.replyButtonsCtl?.stop();
15377
+ this.replyButtonsStore?.close();
15378
+ this.replyButtonsCtl = null;
15379
+ this.replyButtonsStore = null;
15038
15380
  const pidPath = join(this.dataDir, "fleet.pid");
15039
15381
  try {
15040
15382
  unlinkSync(pidPath);
@@ -15262,7 +15604,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15262
15604
  // this process came up on: a Settings edit mutates this.fleetConfig in place
15263
15605
  // before the reload, so comparing the pre-load copy sees nothing at all.
15264
15606
  const newFleetLevel = this.fleetLevelSignature();
15265
- if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
15607
+ // #1519 P6: connections appended to a running fleet start now — no restart. Only once every one is running does
15608
+ // this process count the configuration as applied; otherwise the row says a restart is still needed.
15609
+ if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel && this.canHotAddConnections(this.fleetConfig)) {
15610
+ observe?.(APPLY_FLEET_TARGET, "hot", "running");
15611
+ if (await this.hotAddConnections(appendedConnections(this.startupFleetConfig, this.fleetConfig))) {
15612
+ this.startupFleetConfig = structuredClone(this.fleetConfig);
15613
+ this.appliedFleetLevel = newFleetLevel;
15614
+ observe?.(APPLY_FLEET_TARGET, "hot", "done");
15615
+ }
15616
+ else {
15617
+ observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
15618
+ }
15619
+ }
15620
+ else if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
15266
15621
  this.logger.warn({
15267
15622
  keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
15268
15623
  }, "Fleet-level config changed — restart AgEnD for it to take effect");
@@ -15596,6 +15951,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15596
15951
  listSecureConnections() {
15597
15952
  const channels = this.fleetConfig?.channels
15598
15953
  ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
15954
+ // A token written from Settings is in .env before this process has it (#1519 P1: a new connection's token waits for
15955
+ // the restart that starts it) — stored is "set", not "missing". Names only; no value is read out.
15956
+ let stored;
15957
+ try {
15958
+ stored = envFileKeys(this.dataDir);
15959
+ }
15960
+ catch {
15961
+ stored = new Set();
15962
+ } // unreadable: only what this process has
15599
15963
  return channels.map((channel, index) => {
15600
15964
  const id = channel.id ?? channel.type ?? `channel-${index}`;
15601
15965
  const world = this.worlds.get(id);
@@ -15604,16 +15968,42 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15604
15968
  id,
15605
15969
  type: channel.type,
15606
15970
  token_env: channel.bot_token_env,
15607
- token_present: !!process.env[channel.bot_token_env],
15971
+ token_present: !!process.env[channel.bot_token_env] || stored.has(channel.bot_token_env),
15608
15972
  group_id: channel.group_id != null ? String(channel.group_id) : null,
15609
- general_channel_id: channel.options?.general_channel_id != null
15610
- ? String(channel.options.general_channel_id)
15611
- : null,
15612
- status: state?.status ?? (world ? "starting" : "stopped"),
15973
+ general_channel_id: discordGeneralChannelId(channel),
15974
+ ...this.connectionStatus(id, state, world),
15613
15975
  ...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
15614
15976
  };
15615
15977
  });
15616
15978
  }
15979
+ /**
15980
+ * #1519 P6 (#1537 review): a connection's status from evidence, never from a start call that merely resolved. No
15981
+ * adapter object: not running (or retrying / failed, as recorded) — never "connected". An adapter that has not logged
15982
+ * in yet (no `started`: no bot id; for Telegram, polling not ready) is "starting". Named problems: the Message Content
15983
+ * intent refused (4014, P3), the token refused (an explicit auth error, or Telegram's own 401 flag) — the error text
15984
+ * itself is never passed on.
15985
+ */
15986
+ connectionStatus(id, state, world) {
15987
+ const adapter = this.adapters.get(id);
15988
+ const evidence = adapter?.connectionEvidence?.();
15989
+ const recorded = state?.status;
15990
+ const problem = evidence?.authRejected || (recorded !== "connected" && isRejectedTokenError(state?.lastError)) ? "rejected"
15991
+ : recorded !== "connected" && isDisallowedIntentsError(state?.lastError) ? "missing_intent" : undefined;
15992
+ let status;
15993
+ if (!adapter || evidence?.stopped)
15994
+ status = recorded === "retrying" || recorded === "failed" ? recorded : "stopped"; // gone, or told to stop
15995
+ else if (recorded === "retrying" || recorded === "failed")
15996
+ status = recorded;
15997
+ else if (evidence?.authRejected)
15998
+ status = "failed";
15999
+ else if (!world?.botUserId)
16000
+ status = "starting"; // not logged in yet
16001
+ else if (evidence && !evidence.ready)
16002
+ status = "retrying"; // logged in once; its polling has failed since
16003
+ else
16004
+ status = "connected";
16005
+ return { status, ...(problem ? { problem } : {}) };
16006
+ }
15617
16007
  secureConnectionChannel(connectionId) {
15618
16008
  const channels = this.fleetConfig?.channels
15619
16009
  ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
@@ -16402,8 +16792,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16402
16792
  throw new Error("adapter operation superseded"); };
16403
16793
  check();
16404
16794
  const old = this.adapters.get(connectionId);
16405
- if (!old && !force)
16406
- return false; // The secret is valid on disk; the next start adopts it.
16795
+ // No adapter: a connection that is not the primary, in a fleet already running connections, starts now (#1519 P6);
16796
+ // otherwise the secret is valid on disk and the next start adopts it.
16797
+ if (!old && !force && (this.getPrimaryAdapterId() === connectionId || this.adapters.size === 0))
16798
+ return false;
16799
+ if (!old) {
16800
+ const pending = this.adapterState.get(connectionId)?.retryTimer;
16801
+ if (pending)
16802
+ clearTimeout(pending);
16803
+ }
16407
16804
  const primary = this.getPrimaryAdapterId() === connectionId;
16408
16805
  const previousState = this.adapterState.get(connectionId);
16409
16806
  this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
@@ -16472,6 +16869,74 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16472
16869
  * the reconcile reports as it works. A target the forecast missed (a Classic
16473
16870
  * instance inheriting a changed default) is added when it is first touched.
16474
16871
  */
16872
+ /**
16873
+ * #1519 P6: whether `next` differs from what this process started with only by connections appended to it, while it
16874
+ * runs connections already (a fleet's first connection becomes its primary: a fresh process sets that up).
16875
+ */
16876
+ canHotAddConnections(next) {
16877
+ return this.adapters.size > 0 && appendedConnections(this.startupFleetConfig, next) !== null;
16878
+ }
16879
+ /**
16880
+ * #1519 P6: start connections appended to a running fleet. Each token is read from .env (that key only, under the
16881
+ * file's lease — Settings wrote it there, and this process read .env once at start); a connection already running (a
16882
+ * token Replace started it) is left alone. True only when every one is running; one that failed to start is retried in
16883
+ * the background like any other, and the Apply row says a restart is still needed.
16884
+ */
16885
+ async hotAddConnections(added) {
16886
+ const envPath = join(this.dataDir, ".env");
16887
+ const lease = trySettingsLease([settingsFileResource(envPath)]);
16888
+ if (!lease)
16889
+ return false;
16890
+ try {
16891
+ this.loadEnvKeys(new Set(added.map(c => c.bot_token_env)), lease.owner);
16892
+ }
16893
+ finally {
16894
+ lease.release();
16895
+ }
16896
+ let all = true;
16897
+ for (const channel of added) {
16898
+ const id = channel.id ?? channel.type;
16899
+ if (this.adapters.has(id))
16900
+ continue;
16901
+ if (!process.env[channel.bot_token_env]) {
16902
+ all = false;
16903
+ continue;
16904
+ }
16905
+ const pending = this.adapterState.get(id)?.retryTimer;
16906
+ if (pending)
16907
+ clearTimeout(pending);
16908
+ try {
16909
+ await this.startAdditionalAdapter(channel);
16910
+ if (!this.adapters.has(id)) {
16911
+ all = false;
16912
+ continue;
16913
+ }
16914
+ this.adapterState.set(id, { status: "connected", retryCount: 0 });
16915
+ this.logger.info({ adapterId: id }, "Connection started without a restart");
16916
+ }
16917
+ catch (err) {
16918
+ all = false;
16919
+ const message = err?.message ?? String(err);
16920
+ this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
16921
+ this.scheduleAdapterRetry(id, channel);
16922
+ }
16923
+ }
16924
+ this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
16925
+ this.routing.rebuild(this.fleetConfig);
16926
+ this.reregisterClassicChannels();
16927
+ return all;
16928
+ }
16929
+ /** Set the named keys of the data dir's .env into this process's environment (the file's lease held by `owner`). */
16930
+ loadEnvKeys(keys, owner) {
16931
+ const envPath = join(this.dataDir, ".env");
16932
+ if (!existsSync(envPath))
16933
+ return;
16934
+ assertSettingsLease(settingsFileResource(envPath), owner);
16935
+ // The same parser as the start's (env-file.ts): a token is read the same way at start and when hot-added.
16936
+ for (const [key, value] of parseEnvText(readFileSync(envPath, "utf-8")))
16937
+ if (keys.has(key))
16938
+ process.env[key] = value;
16939
+ }
16475
16940
  planConfigApply() {
16476
16941
  const rows = [];
16477
16942
  const nextConfig = this.nextFleetConfig();
@@ -16495,7 +16960,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16495
16960
  rows.push({ target: name, kind: "restart" });
16496
16961
  }
16497
16962
  if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
16498
- rows.push({ target: APPLY_FLEET_TARGET, kind: "restart" });
16963
+ // #1519 P6: connections appended to a running fleet start now; anything else at fleet level waits for a restart.
16964
+ rows.push({ target: APPLY_FLEET_TARGET, kind: this.canHotAddConnections(nextConfig) ? "hot" : "restart" });
16499
16965
  }
16500
16966
  return rows;
16501
16967
  }
@@ -16888,14 +17354,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16888
17354
  return;
16889
17355
  }
16890
17356
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
16891
- // Browsers request this automatically and AgEnD does not ship an icon.
16892
- // It is neither user data nor an API route, so do not turn the harmless
16893
- // probe into a noisy web-token 401 in the browser console.
16894
- if (req.method === "GET" && requestPath === "/favicon.ico") {
16895
- res.writeHead(204);
16896
- res.end();
17357
+ // Branding is public, after Host admission: automatic favicon requests
17358
+ // must not become noisy web-token 401s in the browser console.
17359
+ if (handleWebIconRequest(req, res, requestPath))
16897
17360
  return;
16898
- }
16899
17361
  // Public: the health probe, /agent (instance-token auth of its own), the
16900
17362
  // sign-in surface, and /view's reads unless web.view_access says otherwise.
16901
17363
  if (bypassesWebGate(req, requestPath, this.fleetConfig, p => !gatewayRequestContext(req) && (isViewPath(p) || isUsagePath(p)))) {
@@ -16947,7 +17409,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16947
17409
  const backend = this.fleetConfig?.instances[name]?.backend
16948
17410
  ?? this.fleetConfig?.defaults?.backend
16949
17411
  ?? "claude-code";
16950
- const { context } = resolveInstanceContext(this.dataDir, name, backend);
17412
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
16951
17413
  return {
16952
17414
  name,
16953
17415
  status: this.getInstanceStatus(name),
@@ -17336,6 +17798,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17336
17798
  .map(ch => ch.instanceName)
17337
17799
  .filter(name => !fleetNames.includes(name));
17338
17800
  const names = [...fleetNames, ...classicOnly];
17801
+ // The identity every page shows (alpha.2, N1): alias, description, role and tags resolved by the same rule as
17802
+ // /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
17803
+ const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
17804
+ const profiles = profileIdentities(this.dataDir);
17339
17805
  const instances = names.map(name => {
17340
17806
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
17341
17807
  let cost = 0;
@@ -17353,7 +17819,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17353
17819
  : (this.fleetConfig?.instances[name]?.backend
17354
17820
  ?? this.fleetConfig?.defaults?.backend
17355
17821
  ?? "claude-code");
17356
- const { context } = resolveInstanceContext(this.dataDir, name, backend);
17822
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
17357
17823
  // context_pct: null when unavailable, not 0
17358
17824
  const context_pct = context ?? null;
17359
17825
  // Model: Only Claude Code has live statusline; others use the effective resolver.
@@ -17372,13 +17838,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17372
17838
  // Only show effort if backend supports it and it's not antigravity
17373
17839
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
17374
17840
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
17375
- // Display name: fleet config → classic channel → undefined
17376
- const display_name = classic
17377
- ? this.classicChannels?.getAll().find(ch => ch.instanceName === name)?.displayName
17378
- : this.fleetConfig?.instances[name]?.display_name;
17841
+ const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
17379
17842
  return {
17380
17843
  name,
17381
- display_name: display_name || undefined,
17844
+ display_name: identity.display_name || undefined,
17845
+ description: identity.description || undefined,
17846
+ role: identity.role || undefined,
17847
+ tags: identity.tags,
17382
17848
  status: this.getInstanceStatus(name),
17383
17849
  // `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
17384
17850
  // what the dashboard's activity events carry) come from instancePresentation (#1212).
@@ -17396,6 +17862,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17396
17862
  instances,
17397
17863
  publicLink: this.getPublicWebStatus(),
17398
17864
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
17865
+ // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
17866
+ connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
17399
17867
  };
17400
17868
  }
17401
17869
  }