@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.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 (239) 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-permissions.d.ts +33 -0
  50. package/dist/discord-permissions.js +42 -0
  51. package/dist/discord-permissions.js.map +1 -0
  52. package/dist/env-file.d.ts +11 -0
  53. package/dist/env-file.js +27 -0
  54. package/dist/env-file.js.map +1 -0
  55. package/dist/fleet-context.d.ts +5 -0
  56. package/dist/fleet-context.js.map +1 -1
  57. package/dist/fleet-level-config.d.ts +8 -1
  58. package/dist/fleet-level-config.js +31 -0
  59. package/dist/fleet-level-config.js.map +1 -1
  60. package/dist/fleet-manager.d.ts +76 -7
  61. package/dist/fleet-manager.js +599 -127
  62. package/dist/fleet-manager.js.map +1 -1
  63. package/dist/fleet-stop-budget.d.ts +22 -0
  64. package/dist/fleet-stop-budget.js +95 -0
  65. package/dist/fleet-stop-budget.js.map +1 -0
  66. package/dist/kiro-transcript-lane.d.ts +8 -4
  67. package/dist/kiro-transcript-lane.js +66 -44
  68. package/dist/kiro-transcript-lane.js.map +1 -1
  69. package/dist/locale.js +82 -0
  70. package/dist/locale.js.map +1 -1
  71. package/dist/next-steps.d.ts +5 -0
  72. package/dist/next-steps.js +26 -0
  73. package/dist/next-steps.js.map +1 -0
  74. package/dist/outbound-handlers.js +8 -0
  75. package/dist/outbound-handlers.js.map +1 -1
  76. package/dist/outbound-schemas.d.ts +4 -0
  77. package/dist/outbound-schemas.js +6 -1
  78. package/dist/outbound-schemas.js.map +1 -1
  79. package/dist/outbox-open-error.d.ts +7 -0
  80. package/dist/outbox-open-error.js +15 -0
  81. package/dist/outbox-open-error.js.map +1 -0
  82. package/dist/pane-context-cache.d.ts +25 -0
  83. package/dist/pane-context-cache.js +73 -0
  84. package/dist/pane-context-cache.js.map +1 -0
  85. package/dist/paths.d.ts +0 -7
  86. package/dist/paths.js +8 -2
  87. package/dist/paths.js.map +1 -1
  88. package/dist/provider-probe.d.ts +15 -0
  89. package/dist/provider-probe.js +20 -0
  90. package/dist/provider-probe.js.map +1 -1
  91. package/dist/public-link-progress.d.ts +73 -0
  92. package/dist/public-link-progress.js +190 -0
  93. package/dist/public-link-progress.js.map +1 -0
  94. package/dist/public-web-gateway.js +1 -1
  95. package/dist/public-web-gateway.js.map +1 -1
  96. package/dist/public-web-link.d.ts +6 -1
  97. package/dist/public-web-link.js +87 -15
  98. package/dist/public-web-link.js.map +1 -1
  99. package/dist/quickstart-api.d.ts +93 -20
  100. package/dist/quickstart-api.js +194 -62
  101. package/dist/quickstart-api.js.map +1 -1
  102. package/dist/quickstart.d.ts +15 -0
  103. package/dist/quickstart.js +100 -29
  104. package/dist/quickstart.js.map +1 -1
  105. package/dist/reply-buttons-controller.d.ts +98 -0
  106. package/dist/reply-buttons-controller.js +174 -0
  107. package/dist/reply-buttons-controller.js.map +1 -0
  108. package/dist/reply-buttons.d.ts +113 -0
  109. package/dist/reply-buttons.js +208 -0
  110. package/dist/reply-buttons.js.map +1 -0
  111. package/dist/reply-dedup.js +3 -1
  112. package/dist/reply-dedup.js.map +1 -1
  113. package/dist/restart-guard.d.ts +1 -0
  114. package/dist/restart-guard.js +8 -3
  115. package/dist/restart-guard.js.map +1 -1
  116. package/dist/service-activation.d.ts +15 -2
  117. package/dist/service-activation.js +81 -4
  118. package/dist/service-activation.js.map +1 -1
  119. package/dist/service-installer.js +19 -1
  120. package/dist/service-installer.js.map +1 -1
  121. package/dist/settings-api.js +53 -12
  122. package/dist/settings-api.js.map +1 -1
  123. package/dist/settings-authority.d.ts +11 -0
  124. package/dist/settings-authority.js +128 -0
  125. package/dist/settings-authority.js.map +1 -0
  126. package/dist/settings-change.d.ts +5 -0
  127. package/dist/settings-change.js +4 -3
  128. package/dist/settings-change.js.map +1 -1
  129. package/dist/settings-confirmation.d.ts +3 -0
  130. package/dist/settings-confirmation.js +7 -1
  131. package/dist/settings-confirmation.js.map +1 -1
  132. package/dist/settings-effect.js +19 -7
  133. package/dist/settings-effect.js.map +1 -1
  134. package/dist/settings-http-confirmation.js +1 -1
  135. package/dist/settings-http-confirmation.js.map +1 -1
  136. package/dist/settings-redaction.d.ts +18 -0
  137. package/dist/settings-redaction.js +58 -0
  138. package/dist/settings-redaction.js.map +1 -0
  139. package/dist/setup-form.d.ts +9 -1
  140. package/dist/setup-form.js +25 -1
  141. package/dist/setup-form.js.map +1 -1
  142. package/dist/setup-host.js +13 -3
  143. package/dist/setup-host.js.map +1 -1
  144. package/dist/setup-wizard.js +26 -19
  145. package/dist/setup-wizard.js.map +1 -1
  146. package/dist/systemd-runtime.d.ts +15 -0
  147. package/dist/systemd-runtime.js +41 -0
  148. package/dist/systemd-runtime.js.map +1 -0
  149. package/dist/tmux-control.d.ts +22 -0
  150. package/dist/tmux-control.js +82 -17
  151. package/dist/tmux-control.js.map +1 -1
  152. package/dist/token-env-name.d.ts +37 -0
  153. package/dist/token-env-name.js +93 -0
  154. package/dist/token-env-name.js.map +1 -0
  155. package/dist/tool-permissions.js +5 -3
  156. package/dist/tool-permissions.js.map +1 -1
  157. package/dist/topic-commands.d.ts +7 -16
  158. package/dist/topic-commands.js +45 -83
  159. package/dist/topic-commands.js.map +1 -1
  160. package/dist/transcript-monitor.js +3 -0
  161. package/dist/transcript-monitor.js.map +1 -1
  162. package/dist/transcript-sources.d.ts +68 -0
  163. package/dist/transcript-sources.js +336 -29
  164. package/dist/transcript-sources.js.map +1 -1
  165. package/dist/transcript-turns.d.ts +39 -0
  166. package/dist/transcript-turns.js +58 -0
  167. package/dist/transcript-turns.js.map +1 -0
  168. package/dist/tunnel/cloudflared-install.d.ts +18 -0
  169. package/dist/tunnel/cloudflared-install.js +15 -1
  170. package/dist/tunnel/cloudflared-install.js.map +1 -1
  171. package/dist/turn-reply-guard.d.ts +3 -0
  172. package/dist/turn-reply-guard.js.map +1 -1
  173. package/dist/ui/chat-render.js +32 -6
  174. package/dist/ui/chat-store.js +21 -1
  175. package/dist/ui/chat-strings.js +8 -0
  176. package/dist/ui/chat-thread.js +109 -52
  177. package/dist/ui/first-run.js +64 -0
  178. package/dist/ui/html-attachment.js +26 -0
  179. package/dist/ui/panel-chat.js +13 -5
  180. package/dist/ui/panel-fleet.js +5 -5
  181. package/dist/ui/panel-settings.js +18 -8
  182. package/dist/ui/preview-panel.js +38 -3
  183. package/dist/ui/settings-apply.js +3 -1
  184. package/dist/ui/settings-dialogs.js +95 -25
  185. package/dist/ui/settings-discord.js +130 -0
  186. package/dist/ui/settings-model.js +21 -0
  187. package/dist/ui/settings-strings.js +46 -6
  188. package/dist/ui/settings-token.js +54 -0
  189. package/dist/ui/settings-wizard.js +63 -29
  190. package/dist/ui/shared/app-shell.js +19 -33
  191. package/dist/ui/shared/app-store.js +5 -3
  192. package/dist/ui/shared/app-stream.js +2 -1
  193. package/dist/ui/shared/app.css +34 -2
  194. package/dist/ui/shared/app.js +2 -1
  195. package/dist/ui/shared/instance-nav.js +310 -0
  196. package/dist/ui/shared/panel-view.js +12 -190
  197. package/dist/ui/shared/view-roster-store.js +5 -0
  198. package/dist/ui/shared/view-strings.js +12 -0
  199. package/dist/update-dispatch.d.ts +1 -1
  200. package/dist/update-dispatch.js +1 -1
  201. package/dist/update-install.d.ts +15 -3
  202. package/dist/update-install.js +41 -13
  203. package/dist/update-install.js.map +1 -1
  204. package/dist/update-launch.d.ts +22 -0
  205. package/dist/update-launch.js +106 -0
  206. package/dist/update-launch.js.map +1 -0
  207. package/dist/update-marker.js +3 -8
  208. package/dist/update-marker.js.map +1 -1
  209. package/dist/usage/format-rich.js +7 -1
  210. package/dist/usage/format-rich.js.map +1 -1
  211. package/dist/usage/i18n-keys.d.ts +1 -1
  212. package/dist/usage/i18n-keys.js +1 -1
  213. package/dist/usage/i18n-keys.js.map +1 -1
  214. package/dist/usage/providers.d.ts +4 -0
  215. package/dist/usage/providers.js +2 -1
  216. package/dist/usage/providers.js.map +1 -1
  217. package/dist/usage/usage-api.js +48 -2
  218. package/dist/usage/usage-api.js.map +1 -1
  219. package/dist/view-api.d.ts +40 -0
  220. package/dist/view-api.js +28 -10
  221. package/dist/view-api.js.map +1 -1
  222. package/dist/web-api.d.ts +8 -0
  223. package/dist/web-api.js +50 -16
  224. package/dist/web-api.js.map +1 -1
  225. package/dist/web-auth.d.ts +8 -0
  226. package/dist/web-auth.js +34 -8
  227. package/dist/web-auth.js.map +1 -1
  228. package/dist/web-chat-history.d.ts +21 -0
  229. package/dist/web-chat-history.js +44 -0
  230. package/dist/web-chat-history.js.map +1 -1
  231. package/dist/web-host-guard.d.ts +18 -2
  232. package/dist/web-host-guard.js +30 -5
  233. package/dist/web-host-guard.js.map +1 -1
  234. package/dist/web-session.d.ts +7 -1
  235. package/dist/web-session.js +23 -2
  236. package/dist/web-session.js.map +1 -1
  237. package/launcher/postinstall.cjs +8 -4
  238. package/package.json +1 -1
  239. package/templates/systemd.service.ejs +3 -3
@@ -6,6 +6,7 @@ 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 { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
9
10
  import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
10
11
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
11
12
  import { ManagedTunnel } from "./tunnel/manager.js";
@@ -14,6 +15,7 @@ import { measureSyncWork } from "./sync-work-attribution.js";
14
15
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
15
16
  import { ProfileControlServer } from "./profile-control.js";
16
17
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
18
+ import { atomicWriteFileSync } from "./atomic-write.js";
17
19
  import { createHash, randomBytes, randomUUID } from "node:crypto";
18
20
  import { spawnSync } from "node:child_process";
19
21
  import { freemem, totalmem, cpus, homedir } from "node:os";
@@ -39,7 +41,9 @@ import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS,
39
41
  import { isProbeableRouteTarget } from "./fleet-context.js";
40
42
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
41
43
  import { EventLog } from "./event-log.js";
44
+ import { binaryProbe } from "./binary-probe.js";
42
45
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
46
+ import { OutboxOpenError } from "./outbox-open-error.js";
43
47
  import { AdapterWorld } from "./adapter-world.js";
44
48
  import { CostGuard, formatCents } from "./cost-guard.js";
45
49
  import { startEventLoopWatch } from "./event-loop-watch.js";
@@ -121,8 +125,13 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
121
125
  }
122
126
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
123
127
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
128
+ import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
129
+ import { ReplyButtonsController } from "./reply-buttons-controller.js";
124
130
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
125
- import { handleViewRequest, isViewPath } from "./view-api.js";
131
+ import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
132
+ import { envFileKeys } from "./token-env-name.js";
133
+ import { parseEnvText } from "./env-file.js";
134
+ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
126
135
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
127
136
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
128
137
  import { LoginSession } from "./login-manager.js";
@@ -131,7 +140,8 @@ import { decideSlash } from "./slash-authz.js";
131
140
  import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
132
141
  import { runVisibilityCommand } from "./cross-instance-notice.js";
133
142
  import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
134
- import { resolveInstalledAgend, updateCommand } from "./update-dispatch.js";
143
+ import { resolveInstalledAgend } from "./update-dispatch.js";
144
+ import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
135
145
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
136
146
  import { runBeforeDeadline } from "./deadline.js";
137
147
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -170,7 +180,7 @@ import { tokenEpoch, WebSessionStore } from "./web-session.js";
170
180
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
171
181
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
172
182
  import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
173
- import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
183
+ import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
174
184
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
175
185
  import { SecretStore } from "./secret-store.js";
176
186
  import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
@@ -340,6 +350,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
340
350
  */
341
351
  const WEB_ONLY_REPLY_SINK = {
342
352
  type: "web",
353
+ supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
343
354
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
344
355
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
345
356
  };
@@ -443,6 +454,8 @@ export class FleetManager {
443
454
  dataDir;
444
455
  static signalTarget = null;
445
456
  static sighupHandlerInstalled = false;
457
+ /** Test seam: inject a spy to verify saveFleetConfig calls fsync. Default: fsyncSync. */
458
+ fsyncForTest = undefined;
446
459
  children = new Map();
447
460
  lifecycle;
448
461
  stormWindow;
@@ -839,21 +852,66 @@ export class FleetManager {
839
852
  return false;
840
853
  await settlement;
841
854
  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);
855
+ // restart_required: the value is stored and committed (the receipt is checked above), only no adapter was running
856
+ // to take it — the change is done, and the page says it starts with the next restart (#1519 P1), never "failed".
857
+ return !!job && ["applied", "applied_next_use", "reloaded", "restart_required"].includes(job.result);
843
858
  },
844
859
  });
845
860
  this.settingsConfirmation = gate;
846
861
  return gate;
847
862
  }
863
+ /** Exact configured effect owners; this does not depend on prompt location. */
864
+ settingsChatAuthority(store, id) {
865
+ const scope = store.authorityOf(id);
866
+ if (!scope || scope.unknown)
867
+ return null;
868
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
869
+ const targets = new Set(scope.connections);
870
+ let primaryGeneral;
871
+ const selectPrimary = () => {
872
+ const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true);
873
+ const selected = generals.find(([name]) => this.getInstanceAdapterId(name) === this.getPrimaryAdapterId()) ?? generals[0];
874
+ const owner = selected && this.getInstanceAdapterId(selected[0]);
875
+ return selected && owner ? { name: selected[0], owner } : undefined;
876
+ };
877
+ if (scope.primaryGeneral) {
878
+ primaryGeneral = selectPrimary();
879
+ if (!primaryGeneral)
880
+ return null;
881
+ targets.add(primaryGeneral.owner);
882
+ }
883
+ const owners = [...targets].map(id => channels.filter(ch => (ch.id ?? ch.type) === id));
884
+ if (!owners.length || owners.some(matches => matches.length !== 1))
885
+ return null;
886
+ const platform = owners[0][0].type;
887
+ if ((platform !== "telegram" && platform !== "discord") || owners.some(matches => matches[0].type !== platform))
888
+ return null;
889
+ const identities = owners.map(matches => ({ id: matches[0].id ?? matches[0].type, type: matches[0].type }));
890
+ const generation = this.settingsGeneration;
891
+ const current = () => !this.shuttingDown && this.settingsGeneration === generation
892
+ && identities.every(owner => {
893
+ const configs = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
894
+ const matches = configs.filter(ch => (ch.id ?? ch.type) === owner.id);
895
+ return matches.length === 1 && matches[0].type === owner.type;
896
+ })
897
+ && (!primaryGeneral || selectPrimary()?.name === primaryGeneral.name && selectPrimary()?.owner === primaryGeneral.owner);
898
+ return { platform, current, admin: userId => current() && !!userId
899
+ && identities.every(owner => this.isFleetAdmin(userId, owner.id)) };
900
+ }
848
901
  async promptSettingsChange(store, view) {
849
902
  const generation = this.settingsGeneration;
903
+ const authority = this.settingsChatAuthority(store, view.id);
904
+ if (!authority) {
905
+ this.logger.info({ id: view.id }, "Settings authority is unknown or spans platforms; host CLI confirmation required");
906
+ return;
907
+ }
850
908
  const excluded = new Set(store.affectedConnections(view.id));
851
909
  for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
852
910
  if (!config.general_topic)
853
911
  continue;
854
912
  const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
855
913
  const general = this.daemons.get(name);
856
- if (!adapterId || !adapter || !general || excluded.has(adapterId) || !this.hasFleetAdmins(adapterId))
914
+ if (!adapterId || !adapter || !general || excluded.has(adapterId) || adapter.type !== authority.platform)
857
915
  continue;
858
916
  const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
859
917
  if (!chatId || !topic)
@@ -866,7 +924,7 @@ export class FleetManager {
866
924
  && this.adapterState.get(adapterId)?.status === "connected"
867
925
  && this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
868
926
  && this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
869
- && this.hasFleetAdmins(adapterId);
927
+ && authority.current();
870
928
  if (!current())
871
929
  continue;
872
930
  try {
@@ -875,7 +933,7 @@ export class FleetManager {
875
933
  message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
876
934
  choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
877
935
  expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
878
- extra: { pendingChangeId: view.id, confirmationCurrent: current },
936
+ extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
879
937
  });
880
938
  void posted.then(nonce => {
881
939
  if (!retired)
@@ -913,7 +971,7 @@ export class FleetManager {
913
971
  this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
914
972
  }
915
973
  async handleSettingsChangeCallback(data, adapterId, adapter) {
916
- const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter);
974
+ 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
975
  if (!result)
918
976
  return false;
919
977
  if (result === "consumed")
@@ -923,7 +981,7 @@ export class FleetManager {
923
981
  const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
924
982
  label: `fleet admin ${data.userId}`,
925
983
  current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
926
- && !!data.userId && this.isFleetAdmin(data.userId, entry.adapterId),
984
+ && !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
927
985
  });
928
986
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
929
987
  }
@@ -996,16 +1054,111 @@ export class FleetManager {
996
1054
  this.topicArchiver = new TopicArchiver(this);
997
1055
  this.statuslineWatcher = new StatuslineWatcher(this);
998
1056
  }
1057
+ /** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
1058
+ replyButtonsCtl = null;
1059
+ /** The store could not be opened in this process: buttons are offered as text until the next start. */
1060
+ replyButtonsUnavailable = false;
1061
+ /**
1062
+ * The controller, or null when reply-buttons.db cannot be opened (#1500 review). That file holds only open choices,
1063
+ * so it never stops AgEnD: the failure is logged and posted once, replies offer their choices as text, and a click on
1064
+ * an older button is answered "closed". The file is left where it is — an open error is not proof of corruption.
1065
+ */
1066
+ replyButtons() {
1067
+ if (this.replyButtonsCtl)
1068
+ return this.replyButtonsCtl;
1069
+ if (this.replyButtonsUnavailable)
1070
+ return null;
1071
+ const path = join(this.dataDir, "reply-buttons.db");
1072
+ let store;
1073
+ try {
1074
+ store = new ReplyButtonStore(path);
1075
+ }
1076
+ catch (err) {
1077
+ this.replyButtonsUnavailable = true;
1078
+ this.logger.error({ err: err.message, path }, "Reply buttons unavailable: reply-buttons.db could not be opened — replies offer their choices as text");
1079
+ try {
1080
+ this.notifyFleetError(`⚠️ Reply buttons are off until AgEnD restarts: ${path} could not be opened (${err.message}). Replies offer their choices as text.`);
1081
+ }
1082
+ catch { /* the notice is best effort */ }
1083
+ return null;
1084
+ }
1085
+ this.replyButtonsCtl = new ReplyButtonsController({
1086
+ store,
1087
+ now: () => Date.now(),
1088
+ adapterFor: (adapterId) => this.worlds.get(adapterId)?.adapter ?? (adapterId === this.getPrimaryAdapterId() ? this.adapter ?? undefined : undefined),
1089
+ mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
1090
+ deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
1091
+ publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
1092
+ logger: this.logger,
1093
+ setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
1094
+ clearTimer: (h) => clearTimeout(h),
1095
+ });
1096
+ this.replyButtonsStore = store;
1097
+ return this.replyButtonsCtl;
1098
+ }
1099
+ replyButtonsStore = null;
1100
+ /**
1101
+ * #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
1102
+ * room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
1103
+ * fleet bot.
1104
+ */
1105
+ mayAnswerReplyButtons(instance, adapterId, userId) {
1106
+ if ([...this.worlds.values()].some(w => w.botUserId && w.botUserId === userId))
1107
+ return false;
1108
+ if (this.classicChannels?.getChannelIdByInstance(instance) !== undefined)
1109
+ return true;
1110
+ const access = this.worlds.get(adapterId)?.accessManager ?? (adapterId === this.getPrimaryAdapterId() ? this.accessManager : null);
1111
+ return this.isFleetAdmin(userId, adapterId) || !!access?.isAllowed(userId);
1112
+ }
1113
+ /** #1266: the choice, delivered as an ordinary inbound message from whoever made it, and shown in the web chat. */
1114
+ async deliverReplyButtonChoice(set, button, by) {
1115
+ const content = replyButtonClickText(button);
1116
+ const web = by.source === "web";
1117
+ const ts = new Date().toISOString();
1118
+ const messageId = web ? newWebMessageId() : (set.messageId ?? "");
1119
+ const sent = await this.deliverToInstance(set.instance, {
1120
+ type: "fleet_inbound",
1121
+ content,
1122
+ targetSession: set.instance,
1123
+ meta: {
1124
+ chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
1125
+ thread_id: set.threadId, adapter_id: set.adapterId === "web" ? undefined : set.adapterId,
1126
+ source: web ? "web" : (this.worlds.get(set.adapterId)?.adapter.type ?? "web"),
1127
+ },
1128
+ });
1129
+ if (sent === false)
1130
+ return false;
1131
+ this.lastInboundUser.set(set.instance, by.username);
1132
+ this.emitSseEvent("message", {
1133
+ instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1134
+ });
1135
+ return true;
1136
+ }
999
1137
  ensureDeliveryOutbox() {
1000
1138
  if (this.deliveryOutbox)
1001
1139
  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;
1140
+ const dbPath = join(this.dataDir, "delivery-outbox.db");
1141
+ let outbox;
1142
+ try {
1143
+ outbox = new DeliveryOutbox(dbPath, this.managerBootId);
1144
+ if (!this.deliveryOutboxRecovered) {
1145
+ const recovered = outbox.recoverForBoot(this.managerBootId);
1146
+ this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
1147
+ }
1148
+ }
1149
+ catch (cause) {
1150
+ // Unlike events.db, these rows cannot be discarded: queued messages and uncertain submissions are authoritative.
1151
+ // Publish only a fully recovered store. A partial open/recovery must not authorize admission on a later call.
1152
+ try {
1153
+ outbox?.close();
1154
+ }
1155
+ catch { /* preserve the original failure */ }
1156
+ const error = new OutboxOpenError(dbPath, cause);
1157
+ this.logger.error({ err: cause, dbPath, kind: error.kind }, error.message);
1158
+ throw error;
1008
1159
  }
1160
+ this.deliveryOutbox = outbox;
1161
+ this.deliveryOutboxRecovered = true;
1009
1162
  outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
1010
1163
  outbox.on("state", (event) => {
1011
1164
  this.scheduleDeliveryOutboxPump();
@@ -1520,6 +1673,10 @@ export class FleetManager {
1520
1673
  });
1521
1674
  return caller;
1522
1675
  }
1676
+ /** Pure cached admission for command continuations; no IO or update-progress lookup. */
1677
+ isFleetStopping() {
1678
+ return this.shuttingDown;
1679
+ }
1523
1680
  /**
1524
1681
  * Is the fleet going down (or coming back up) on purpose?
1525
1682
  *
@@ -1581,6 +1738,16 @@ export class FleetManager {
1581
1738
  lastActivityMs(name) {
1582
1739
  return this.lastActivity.get(name) ?? 0;
1583
1740
  }
1741
+ /** Capture the current daemon and delivery owner for a background context read. */
1742
+ getPaneContextSource(name) {
1743
+ const daemon = this.daemons.get(name);
1744
+ const source = daemon?.getPaneContextSource?.();
1745
+ if (!daemon || !source || this.shuttingDown)
1746
+ return null;
1747
+ const epoch = this.getDeliveryEpoch(name);
1748
+ return { ...source, generation: `${source.generation}:${epoch}`, isCurrent: () => !this.shuttingDown
1749
+ && this.daemons.get(name) === daemon && this.getDeliveryEpoch(name) === epoch && source.isCurrent() };
1750
+ }
1584
1751
  /**
1585
1752
  * Is the instance between turns?
1586
1753
  *
@@ -1856,7 +2023,7 @@ export class FleetManager {
1856
2023
  await data.respond(t(`${action}.usage`));
1857
2024
  return;
1858
2025
  }
1859
- if (!this.fleetConfig?.instances[requested]) {
2026
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, requested)) {
1860
2027
  await data.respond(t("instance.not_found", requested));
1861
2028
  return;
1862
2029
  }
@@ -1912,11 +2079,19 @@ export class FleetManager {
1912
2079
  }
1913
2080
  const origin = `slash /update by ${adapterId}:${data.userId}`;
1914
2081
  recordInternalRequest(this.dataDir, "update", origin);
1915
- const { command, args } = updateCommand(installed.agend);
1916
- const child = spawn(command, args, {
2082
+ const launch = await resolveUpdateLaunch(installed.agend);
2083
+ if (!launch.ok) {
2084
+ this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
2085
+ return;
2086
+ }
2087
+ if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
2088
+ this.failUpdateProgress(t("not_authorized"));
2089
+ return;
2090
+ }
2091
+ const child = spawn(launch.command, launch.args, {
1917
2092
  detached: true, stdio: "ignore", env: withOrigin(origin),
1918
2093
  });
1919
- child.once("error", err => this.failUpdateProgress(err.message));
2094
+ watchUpdateLaunch(child, message => this.failUpdateProgress(message));
1920
2095
  child.unref();
1921
2096
  }
1922
2097
  async handleProfileSlash(data, adapterId) {
@@ -1938,7 +2113,7 @@ export class FleetManager {
1938
2113
  await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
1939
2114
  }
1940
2115
  async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
1941
- if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
2116
+ if (!general || !Object.hasOwn(this.fleetConfig?.instances ?? {}, general) || !this.fleetConfig?.instances[general]?.general_topic) {
1942
2117
  await respond(t("profile.general_only"));
1943
2118
  return;
1944
2119
  }
@@ -3184,7 +3359,9 @@ export class FleetManager {
3184
3359
  await this.sendLocalDashboard(data, adapterId);
3185
3360
  return;
3186
3361
  }
3187
- await this.showDashboardMenu(owner, data.respondButtons);
3362
+ // Discord shows a deferred reply that is never answered as "the application did not respond".
3363
+ if (!await this.showDashboardMenu(owner, data.respondButtons, data.retireButtons))
3364
+ await data.respond(t("dashboard.menu_failed"));
3188
3365
  }
3189
3366
  }
3190
3367
  else if (data.command === "restart") {
@@ -3924,18 +4101,17 @@ export class FleetManager {
3924
4101
  /**
3925
4102
  * Probe the same executable set exposed by the web backend catalog.
3926
4103
  *
3927
- * This deliberately has no cache: an install `/login` ran can add a
4104
+ * This deliberately skips the cache: an install `/login` ran can add a
3928
4105
  * binary to PATH while the fleet process remains alive, and the next bare
3929
4106
  * `/login` must see it without requiring a restart or an explicit cache
3930
- * invalidation call.
4107
+ * invalidation call. The fresh answers also refresh the shared cache that
4108
+ * `/ui/backends` reads. #1490: async and bounded, never `which` on the event loop; a probe with no answer in time
4109
+ * counts as not installed, as the old timeout did.
3931
4110
  */
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;
4111
+ async probeInstalledBackends() {
4112
+ const entries = Object.entries(BACKEND_INSTALLATION_INFO);
4113
+ const found = await Promise.all(entries.map(([, info]) => binaryProbe.probe(info.binary, { fresh: true })));
4114
+ return new Set(entries.filter((_, i) => { const r = found[i]; return r.known && r.path !== null; }).map(([backend]) => backend));
3939
4115
  }
3940
4116
  /**
3941
4117
  * One fleet-level notice per burst, not one per instance: a post-update herd
@@ -4294,24 +4470,10 @@ export class FleetManager {
4294
4470
  if (!existsSync(envPath))
4295
4471
  return;
4296
4472
  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.
4473
+ // .env file always wins over inherited shell env vars, so that quickstart's newly written token overrides any stale
4474
+ // value. One parser for every reader of .env (env-file.ts): `export KEY=value` included.
4475
+ for (const [key, value] of parseEnvText(readFileSync(envPath, "utf-8")))
4313
4476
  process.env[key] = value;
4314
- }
4315
4477
  }
4316
4478
  /** Initialize auth before any adapter can answer /dashboard. */
4317
4479
  initializeWebAuthTokens() {
@@ -4440,11 +4602,15 @@ export class FleetManager {
4440
4602
  await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
4441
4603
  return;
4442
4604
  }
4443
- await this.showDashboardMenu(owner);
4605
+ if (!await this.showDashboardMenu(owner)) {
4606
+ await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4607
+ }
4444
4608
  }
4445
4609
  async sendLocalDashboard(data, adapterId) {
4446
- if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId))
4610
+ if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4611
+ await data.respond(t("not_authorized"));
4447
4612
  return;
4613
+ }
4448
4614
  const text = this.topicCommands.getDashboardText();
4449
4615
  const adapter = this.adapters.get(adapterId);
4450
4616
  const token = this.webToken;
@@ -4466,7 +4632,8 @@ export class FleetManager {
4466
4632
  await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
4467
4633
  deliveredByDm = true;
4468
4634
  }
4469
- catch {
4635
+ catch (err) {
4636
+ this.logDashboardDmFailure(err);
4470
4637
  if (!current())
4471
4638
  throw new Error("owner changed");
4472
4639
  const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
@@ -4476,15 +4643,25 @@ export class FleetManager {
4476
4643
  if (!current())
4477
4644
  throw new Error("owner changed");
4478
4645
  if (deliveredByDm)
4479
- await withinBudget(data.respond(t("dashboard.private_sent")), deadline);
4646
+ await withinBudget(data.respond(t("dashboard.private_sent_dm")), deadline);
4480
4647
  }
4481
- catch {
4648
+ catch (err) {
4482
4649
  this.webLoginCodes.revokeIfCurrent(login.issuanceId);
4650
+ this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
4651
+ await data.respond(t("dashboard.private_failed")).catch(() => undefined);
4483
4652
  }
4484
4653
  }
4485
- async showDashboardMenu(owner, respondButtons) {
4486
- if (!this.publicOwnerCurrent(owner))
4487
- return;
4654
+ /** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
4655
+ logDashboardDmFailure(err) {
4656
+ const code = err?.code;
4657
+ 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");
4658
+ }
4659
+ /** False when the menu was not shown (the caller still owes the user an answer). */
4660
+ async showDashboardMenu(owner, respondButtons, retireButtons) {
4661
+ if (!this.publicOwnerCurrent(owner)) {
4662
+ this.logger.info("Dashboard menu not shown: the General owner changed");
4663
+ return false;
4664
+ }
4488
4665
  const adapter = owner.binding;
4489
4666
  const status = this.getPublicWebStatus();
4490
4667
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
@@ -4494,7 +4671,7 @@ export class FleetManager {
4494
4671
  const choices = [{ action: "local", label: t("dashboard.local") },
4495
4672
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4496
4673
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4497
- await this.postNonceButtonPromptOrThrow({
4674
+ const posted = await this.postNonceButtonPrompt({
4498
4675
  prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4499
4676
  chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
4500
4677
  message: menuText,
@@ -4503,9 +4680,11 @@ export class FleetManager {
4503
4680
  const messageId = await respondButtons(menuText, c);
4504
4681
  if (!messageId)
4505
4682
  throw new Error("menu refused");
4506
- return { chatId: owner.chatId, threadId: owner.threadId, messageId };
4683
+ // The menu is the command's ephemeral reply: only that interaction can collapse it.
4684
+ return { chatId: owner.chatId, threadId: owner.threadId, messageId, ...(retireButtons ? { retire: retireButtons } : {}) };
4507
4685
  } } : {}),
4508
4686
  });
4687
+ return posted !== null;
4509
4688
  }
4510
4689
  async handleDashboardCallback(data, adapterId, adapter) {
4511
4690
  const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
@@ -4525,9 +4704,26 @@ export class FleetManager {
4525
4704
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
4526
4705
  if (result?.confirmed === false)
4527
4706
  data.ack?.(t("dashboard.public_cleanup"));
4528
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"));
4707
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
4529
4708
  return true;
4530
4709
  }
4710
+ // The click is claimed and its buttons are spent: say so on the menu now. A public link follows each of its steps
4711
+ // there (the first one installs cloudflared, which can take a minute): on Discord through the interaction, on
4712
+ // Telegram by editing the menu message itself. Only static words and numbers — the menu may be in General.
4713
+ const progressEdit = data.editClicked ?? entry.retire;
4714
+ const progress = progressEdit && action !== "public"
4715
+ ? progressEdit(t("dashboard.private_sending")).catch(err => this.logger.debug({ err }, "Could not show the dashboard menu's progress"))
4716
+ : Promise.resolve();
4717
+ const stepsEdit = progressEdit ?? (entry.messageId && entry.adapter.editMessageRemoveButtons
4718
+ ? (text) => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, text, entry.threadId) : undefined);
4719
+ const steps = action === "public" && stepsEdit ? new ThrottledMessageEditor({
4720
+ edit: stepsEdit, now: () => performance.now(), heartbeatMs: 5_000,
4721
+ // Telegram counts edits against a group's ~20 messages a minute.
4722
+ minIntervalMs: owner.binding.type === "telegram" ? 3_000 : 2_000,
4723
+ onError: err => this.logger.debug({ err }, "Could not show the public link's progress"),
4724
+ }) : null;
4725
+ let lastSteps;
4726
+ let deliveredByDm = false;
4531
4727
  const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
4532
4728
  const token = this.webToken;
4533
4729
  if (!token || !current())
@@ -4542,9 +4738,12 @@ export class FleetManager {
4542
4738
  try {
4543
4739
  if (!direct.sendDirect)
4544
4740
  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));
4741
+ const sent = await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
4742
+ deliveredByDm = true;
4743
+ return sent;
4546
4744
  }
4547
- catch {
4745
+ catch (err) {
4746
+ this.logDashboardDmFailure(err);
4548
4747
  if (direct.type !== "discord" || !data.respondPrivate || !current())
4549
4748
  throw new Error("private delivery unavailable");
4550
4749
  const sent = await withinBudget(data.respondPrivate(text, c), deadline);
@@ -4563,17 +4762,31 @@ export class FleetManager {
4563
4762
  throw new Error("owner changed");
4564
4763
  return true;
4565
4764
  }
4566
- catch {
4765
+ catch (err) {
4567
4766
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
4568
- this.logger.info("Dashboard private delivery was not confirmed");
4767
+ this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
4569
4768
  return false;
4570
4769
  }
4571
4770
  };
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`);
4771
+ let ok = false;
4772
+ try {
4773
+ ok = action === "public"
4774
+ ? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent), p => {
4775
+ lastSteps = p;
4776
+ steps?.update(() => renderPublicLinkProgress(p, performance.now()));
4777
+ })
4778
+ : await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
4779
+ }
4780
+ catch (err) {
4781
+ // Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
4782
+ this.logger.warn({ err }, "Dashboard sign-in delivery failed");
4783
+ }
4575
4784
  // 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"));
4785
+ const outcome = t(!ok ? "dashboard.private_failed"
4786
+ : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
4787
+ await progress;
4788
+ await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
4789
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
4577
4790
  return true;
4578
4791
  }
4579
4792
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5285,6 +5498,8 @@ export class FleetManager {
5285
5498
  }
5286
5499
  // Health HTTP endpoint
5287
5500
  this.startHealthServer(fleet.health_port ?? 19280);
5501
+ // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
5502
+ void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
5288
5503
  // Daily update check — first check after 1 hour, then every 24 hours
5289
5504
  this.updateCheckTimer = setTimeout(() => {
5290
5505
  this.checkForUpdates();
@@ -6438,26 +6653,64 @@ export class FleetManager {
6438
6653
  return undefined;
6439
6654
  }
6440
6655
  /**
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.
6656
+ * A forum-root command has one configured General before any adapter claims
6657
+ * dedup. Bare commands prefer the primary adapter's General in this group;
6658
+ * explicit suffixes select their own bot. Runtime availability must not pick
6659
+ * another administrator, so stopped owners remain selected and every copy
6660
+ * silently refuses instead of falling back.
6661
+ * undefined = another ingress scope; null = root command with no proven target.
6444
6662
  */
6663
+ telegramRootCommandTarget(msg, threadId) {
6664
+ if (msg.source !== "telegram" || threadId !== undefined || !/^\/\w/.test(msg.text?.trim() ?? ""))
6665
+ return undefined;
6666
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6667
+ const receiving = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId);
6668
+ if (receiving?.type !== "telegram" || receiving.group_id == null || String(receiving.group_id) !== msg.chatId)
6669
+ return undefined;
6670
+ const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true)
6671
+ .map(([name]) => {
6672
+ const owner = this.getInstanceAdapterId(name);
6673
+ return { name, owner, channel: channels.find(ch => (ch.id ?? ch.type) === owner) };
6674
+ });
6675
+ const candidates = generals.filter(entry => entry.owner && entry.channel?.type === "telegram"
6676
+ && entry.channel.group_id != null && String(entry.channel.group_id) === msg.chatId);
6677
+ const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
6678
+ let selected;
6679
+ if (suffix) {
6680
+ const matches = candidates.filter(entry => this.worlds.get(entry.owner)?.botUsername?.toLowerCase() === suffix.toLowerCase());
6681
+ const owners = new Set(matches.map(entry => entry.owner));
6682
+ if (owners.size !== 1)
6683
+ return null;
6684
+ selected = matches[0];
6685
+ }
6686
+ else {
6687
+ // An unresolvable General may be the intended primary; do not silently
6688
+ // widen authority to the next readable binding.
6689
+ if (generals.some(entry => !entry.channel || (entry.channel.type === "telegram" && entry.channel.group_id == null)))
6690
+ return null;
6691
+ selected = candidates.find(entry => entry.owner === this.getPrimaryAdapterId()) ?? candidates[0];
6692
+ }
6693
+ return selected?.owner ? { name: selected.name, owner: selected.owner } : null;
6694
+ }
6445
6695
  /**
6446
6696
  * #1346: whether this copy may claim the shared dedup key for
6447
6697
  * command-like text. Fleet topics resolve an owning adapter and only its
6448
6698
  * 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
6699
+ * handling. Telegram forum-root commands select a configured General before
6700
+ * dedup. An explicit suffix requires a known matching username; present-thread
6451
6701
  * copies retain their permissive handling when that identity is unknown.
6452
6702
  */
6453
- isOwnerCommandCopy(msg, threadId) {
6703
+ isOwnerCommandCopy(msg, threadId, rootCommand = this.telegramRootCommandTarget(msg, threadId)) {
6704
+ if (rootCommand !== undefined) {
6705
+ return rootCommand !== null && msg.adapterId === rootCommand.owner && this.daemons.has(rootCommand.name);
6706
+ }
6454
6707
  if (!msg.adapterId)
6455
6708
  return true;
6456
6709
  const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
6457
6710
  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.
6711
+ // Compatibility for a runtime world without configuration: preserve
6712
+ // explicit receiver proof; Classic/private/foreign-forum copies retain
6713
+ // their own dispatch rules.
6461
6714
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6462
6715
  const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
6463
6716
  ?? this.worlds.get(msg.adapterId)?.channelConfig;
@@ -6487,6 +6740,9 @@ export class FleetManager {
6487
6740
  }
6488
6741
  return true;
6489
6742
  }
6743
+ /**
6744
+ * #1085: coincident topic numbers must not route across Telegram groups.
6745
+ */
6490
6746
  resolveInboundTarget(msg, threadId) {
6491
6747
  if (msg.source !== "telegram")
6492
6748
  return this.routing.resolve(threadId);
@@ -6523,6 +6779,7 @@ export class FleetManager {
6523
6779
  return;
6524
6780
  }
6525
6781
  const threadId = this.inboundRouteThreadId(msg);
6782
+ const rootCommand = this.telegramRootCommandTarget(msg, threadId);
6526
6783
  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
6784
  // Ownership and per-adapter filtering both run before the dedup claim below.
6528
6785
  // When several bots share a guild they all receive the same message; a copy
@@ -6583,7 +6840,7 @@ export class FleetManager {
6583
6840
  // to flow — so only fleet targets are judged here; the in-handler gates
6584
6841
  // (owner entry check, Discord ignore) cover classic instead.
6585
6842
  if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
6586
- && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
6843
+ && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId, rootCommand)) {
6587
6844
  this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
6588
6845
  return;
6589
6846
  }
@@ -6890,7 +7147,7 @@ export class FleetManager {
6890
7147
  }
6891
7148
  // General topic: Discord /xxx gets the system note first (never a
6892
7149
  // command); other sources fall through to the handlers below.
6893
- const generalInstance = this.findGeneralInstance(msg.adapterId);
7150
+ const generalInstance = rootCommand?.name ?? this.findGeneralInstance(msg.adapterId);
6894
7151
  if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
6895
7152
  return;
6896
7153
  if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
@@ -7279,6 +7536,12 @@ export class FleetManager {
7279
7536
  return;
7280
7537
  }
7281
7538
  }
7539
+ // #1266: buttons are checked before anything is sent, like stickers.
7540
+ const parsedButtons = parseReplyButtons(args.buttons, args);
7541
+ if (parsedButtons && "error" in parsedButtons) {
7542
+ respond(null, `reply: ${parsedButtons.error}`);
7543
+ return;
7544
+ }
7282
7545
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
7283
7546
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
7284
7547
  if (stickerProblem) {
@@ -7291,8 +7554,33 @@ export class FleetManager {
7291
7554
  ticket.subscribe(respond);
7292
7555
  return;
7293
7556
  }
7557
+ // #1266: the buttons' set, before the send (a click cannot match it until the message is known). Where buttons
7558
+ // cannot be shown, the choices go into the text instead.
7559
+ let replySet = null;
7560
+ if (parsedButtons && "buttons" in parsedButtons) {
7561
+ const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
7562
+ if (buttons) {
7563
+ const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
7564
+ replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
7565
+ }
7566
+ else {
7567
+ args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
7568
+ }
7569
+ }
7294
7570
  const original = respond;
7295
7571
  const respondAndRecord = (result, error) => {
7572
+ let buttonsView = null;
7573
+ if (replySet) {
7574
+ const sent = result;
7575
+ const carrying = sent?.buttonsMessageId ?? sent?.messageId;
7576
+ if (!error && carrying) {
7577
+ this.replyButtonsCtl?.bind(replySet.id, carrying);
7578
+ buttonsView = this.replyButtonsCtl?.viewOf(replySet.id) ?? null;
7579
+ }
7580
+ else {
7581
+ this.replyButtonsCtl?.discard(replySet.id);
7582
+ }
7583
+ }
7296
7584
  ticket.complete(result, error);
7297
7585
  // Return the platform outcome first. Bookkeeping below must never turn
7298
7586
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -7305,7 +7593,7 @@ export class FleetManager {
7305
7593
  // an agent cannot invent it to suppress the normal completion marker.
7306
7594
  if (!error && result != null && msg.statusOnly !== true) {
7307
7595
  try {
7308
- this.afterReplyRouted(instanceName, args, senderSessionName);
7596
+ this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
7309
7597
  }
7310
7598
  catch (err) {
7311
7599
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -7320,9 +7608,11 @@ export class FleetManager {
7320
7608
  });
7321
7609
  }
7322
7610
  };
7323
- if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
7611
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
7324
7612
  return;
7325
7613
  }
7614
+ if (replySet)
7615
+ this.replyButtonsCtl?.discard(replySet.id);
7326
7616
  // routeToolCall knows "reply"; not handling it means the world changed.
7327
7617
  ticket.complete(null, "reply not handled");
7328
7618
  original(null, "reply not handled");
@@ -7356,7 +7646,7 @@ export class FleetManager {
7356
7646
  webOnlyPromptPlace() {
7357
7647
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
7358
7648
  }
7359
- afterReplyRouted(instanceName, args, senderSessionName) {
7649
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
7360
7650
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
7361
7651
  // more work either. Split the difference: an instance that is clearly
7362
7652
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -7387,6 +7677,7 @@ export class FleetManager {
7387
7677
  text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
7388
7678
  ts: new Date().toISOString(),
7389
7679
  ...(attachments.length ? { attachments } : {}),
7680
+ ...(buttons ? { buttons } : {}), // #1266
7390
7681
  });
7391
7682
  // Log bot reply to classic instance chat-log
7392
7683
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
@@ -8590,22 +8881,23 @@ export class FleetManager {
8590
8881
  const output = String(this.rawFleetDocument);
8591
8882
  if (redundantPaths.length > 0)
8592
8883
  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);
8884
+ // Atomic write with fsync. The beforeRename hook runs after fsync and
8885
+ // before rename so validation failures are reported before the file is
8886
+ // replaced (#1056), and the temp file is cleaned up on any throw.
8887
+ atomicWriteFileSync(this.configPath, output, {
8888
+ mode: existsSync(this.configPath) ? statSync(this.configPath).mode : 0o644,
8889
+ fsync: this.fsyncForTest,
8890
+ beforeRename: (tempPath) => {
8891
+ // #1056 (Fable's 2.2 audit): the patcher writes leaves, not configs, so
8892
+ // a wrong patch can produce a file the validator — or the next start —
8893
+ // refuses. Load and validate what is about to replace fleet.yaml.
8894
+ const refusal = this.savedFleetConfigProblem(tempPath);
8895
+ if (refusal)
8896
+ throw new Error(refusal);
8897
+ if (existsSync(this.configPath))
8898
+ chmodSync(tempPath, statSync(this.configPath).mode);
8899
+ },
8900
+ });
8609
8901
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
8610
8902
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
8611
8903
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
@@ -10110,9 +10402,7 @@ export class FleetManager {
10110
10402
  return;
10111
10403
  this.pendingNonceButtons.delete(nonce);
10112
10404
  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
- }
10405
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
10116
10406
  }, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
10117
10407
  entry.timer.unref?.();
10118
10408
  this.pendingNonceButtons.set(nonce, entry);
@@ -10136,6 +10426,8 @@ export class FleetManager {
10136
10426
  entry.threadId = sent.threadId;
10137
10427
  }
10138
10428
  entry.messageId = sent.messageId;
10429
+ if (sent.retire)
10430
+ entry.retire = sent.retire;
10139
10431
  // Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
10140
10432
  // and only if nothing claimed or expired it meanwhile.
10141
10433
  if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
@@ -10167,7 +10459,7 @@ export class FleetManager {
10167
10459
  * longer parses) collapses the clicked message so the dead button stops
10168
10460
  * inviting clicks — the same courtesy the cancel button extends.
10169
10461
  */
10170
- consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling) {
10462
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling, authorize) {
10171
10463
  if (!data.callbackData.startsWith(prefix))
10172
10464
  return null;
10173
10465
  const match = data.callbackData.match(actionRe);
@@ -10186,6 +10478,9 @@ export class FleetManager {
10186
10478
  if (staleHandling?.keepText && adapter?.removeMessageButtons) {
10187
10479
  adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
10188
10480
  }
10481
+ else if (data.editClicked) {
10482
+ data.editClicked(t("buttons.stale")).catch(collapseFailed);
10483
+ }
10189
10484
  else {
10190
10485
  adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
10191
10486
  }
@@ -10204,11 +10499,13 @@ export class FleetManager {
10204
10499
  const isAuthorized = fromWeb
10205
10500
  ? pending.web !== undefined
10206
10501
  : data.userId
10207
- ? pending.allowAnyUser
10208
- ? true
10209
- : pending.authChannelId
10210
- ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10211
- : this.isFleetAdmin(data.userId, callbackAdapterId)
10502
+ ? authorize
10503
+ ? authorize(data.userId, pending)
10504
+ : pending.allowAnyUser
10505
+ ? true
10506
+ : pending.authChannelId
10507
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10508
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
10212
10509
  : false;
10213
10510
  const mismatchedFields = [];
10214
10511
  if (pending.adapterId !== callbackAdapterId)
@@ -10282,6 +10579,14 @@ export class FleetManager {
10282
10579
  }
10283
10580
  /** Routes a click to the handler that owns its prefix; false when none does. */
10284
10581
  async dispatchAdapterCallback(data, adapterId, adapter) {
10582
+ if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
10583
+ const buttons = this.replyButtons();
10584
+ if (!buttons) {
10585
+ data.ack?.(t("reply_buttons.closed"));
10586
+ return true;
10587
+ }
10588
+ return buttons.handleCallback(data, adapterId);
10589
+ }
10285
10590
  if (this.needsYou?.handleCallback(data, adapterId))
10286
10591
  return true;
10287
10592
  if (await this.handleTipDismiss(data, adapterId, adapter))
@@ -10552,6 +10857,11 @@ export class FleetManager {
10552
10857
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
10553
10858
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
10554
10859
  */
10860
+ /** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
10861
+ clickWebReplyButton(instance, id, index) {
10862
+ const buttons = this.replyButtons();
10863
+ return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
10864
+ }
10555
10865
  async clickWebPrompt(instance, nonce, action) {
10556
10866
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
10557
10867
  return { status: 400, error: "Malformed prompt answer" };
@@ -10580,9 +10890,16 @@ export class FleetManager {
10580
10890
  return { status: 200 };
10581
10891
  return { status: 409, error: notice ?? "This prompt is no longer open" };
10582
10892
  }
10583
- async retireNonceButtons(pending, messageId, text) {
10893
+ async retireNonceButtons(pending, messageId, text,
10894
+ /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
10895
+ editClicked) {
10584
10896
  this.webPromptGone(pending, text);
10585
10897
  try {
10898
+ const retire = editClicked ?? pending.retire;
10899
+ if (retire) {
10900
+ await retire(text);
10901
+ return;
10902
+ }
10586
10903
  if (!pending.adapter.editMessageRemoveButtons)
10587
10904
  throw new Error("adapter cannot remove prompt buttons");
10588
10905
  await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
@@ -11040,8 +11357,8 @@ export class FleetManager {
11040
11357
  this.webPromptGone(entry, entry.expiredText);
11041
11358
  }
11042
11359
  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")));
11360
+ .map(entry => this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")))
11361
+ .filter(collapse => collapse !== undefined);
11045
11362
  if (!collapses.length)
11046
11363
  return;
11047
11364
  await Promise.race([
@@ -11060,12 +11377,17 @@ export class FleetManager {
11060
11377
  if (entry.timer)
11061
11378
  clearTimeout(entry.timer);
11062
11379
  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
- }
11380
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
11067
11381
  }
11068
11382
  }
11383
+ /** A lapsed prompt's buttons collapsed to its expired text — through its own interaction when it is private. */
11384
+ collapseNoncePrompt(entry) {
11385
+ if (entry.retire)
11386
+ return entry.retire(entry.expiredText);
11387
+ if (!entry.messageId || !entry.adapter.editMessageRemoveButtons)
11388
+ return undefined;
11389
+ return entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId);
11390
+ }
11069
11391
  /** A clean exit is intentional from the CLI's perspective, but often not from
11070
11392
  * the operator's. Keep the instance notice passive and put the action in the
11071
11393
  * same-world General topic where an administrator can make the choice. */
@@ -12048,7 +12370,7 @@ export class FleetManager {
12048
12370
  for (const name of this.configuredBackendInstanceNames()) {
12049
12371
  configured.add(this.backendNameOf(name));
12050
12372
  }
12051
- const installed = this.probeInstalledBackends();
12373
+ const installed = await this.probeInstalledBackends();
12052
12374
  // One entry point for "get this CLI working" (#1131): a backend that is
12053
12375
  // not installed is offered too, and the click installs it first, then
12054
12376
  // signs in (startLoginSession routes it).
@@ -12680,10 +13002,16 @@ export class FleetManager {
12680
13002
  attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
12681
13003
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
12682
13004
  role: typeof m.role === "string" ? m.role : undefined,
13005
+ buttons: m.buttons,
12683
13006
  });
12684
13007
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
12685
13008
  return;
12686
13009
  }
13010
+ // #1266: a reply's buttons ended — the history shows it too (a later load, the public link's poll).
13011
+ if (event === "reply_buttons" && data && typeof data === "object") {
13012
+ const u = data;
13013
+ this.webChatHistory.updateButtons(String(u.instance ?? ""), u.buttons);
13014
+ }
12687
13015
  broadcastSseEvent(this.sseClients, event, data, onError);
12688
13016
  }
12689
13017
  listClaimedTasks(assignee) {
@@ -14495,6 +14823,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14495
14823
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
14496
14824
  }
14497
14825
  async applyModel(instanceName, model) {
14826
+ // Reject model names with newlines or control characters: they would be
14827
+ // persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
14828
+ if (/[\x00-\x1f\x7f]/.test(model)) {
14829
+ return t("model.invalid_chars");
14830
+ }
14498
14831
  const backendName = this.backendNameForInstance(instanceName);
14499
14832
  let strategy = "restart";
14500
14833
  try {
@@ -14869,7 +15202,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14869
15202
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
14870
15203
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
14871
15204
  const needsStopped = this.stopNeedsYou();
14872
- this.cacheService?.stop();
15205
+ // #1490 P3: stop() now returns a Promise; await it so the final
15206
+ // ledger flush completes before process.exit(0) in doStopAll's caller.
15207
+ const cacheStopped = this.cacheService?.stop() ?? Promise.resolve();
14873
15208
  const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
14874
15209
  this.ipcStoppingInstances.add("__fleet_stopping__");
14875
15210
  // Release held delivery promises before awaiting daemon shutdown, then
@@ -14903,6 +15238,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14903
15238
  await profileStopped;
14904
15239
  await needsStopped;
14905
15240
  await settingsControlStopped;
15241
+ await cacheStopped;
14906
15242
  await this.cpuProfileControl?.close();
14907
15243
  this.cpuProfileControl = null;
14908
15244
  await this.shutdownLoginWindows();
@@ -15035,6 +15371,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15035
15371
  // nor revives a session that was revoked while the disk was refusing writes.
15036
15372
  this.webSessions?.flush();
15037
15373
  this.eventLog?.close();
15374
+ this.replyButtonsCtl?.stop();
15375
+ this.replyButtonsStore?.close();
15376
+ this.replyButtonsCtl = null;
15377
+ this.replyButtonsStore = null;
15038
15378
  const pidPath = join(this.dataDir, "fleet.pid");
15039
15379
  try {
15040
15380
  unlinkSync(pidPath);
@@ -15262,7 +15602,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15262
15602
  // this process came up on: a Settings edit mutates this.fleetConfig in place
15263
15603
  // before the reload, so comparing the pre-load copy sees nothing at all.
15264
15604
  const newFleetLevel = this.fleetLevelSignature();
15265
- if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
15605
+ // #1519 P6: connections appended to a running fleet start now — no restart. Only once every one is running does
15606
+ // this process count the configuration as applied; otherwise the row says a restart is still needed.
15607
+ if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel && this.canHotAddConnections(this.fleetConfig)) {
15608
+ observe?.(APPLY_FLEET_TARGET, "hot", "running");
15609
+ if (await this.hotAddConnections(appendedConnections(this.startupFleetConfig, this.fleetConfig))) {
15610
+ this.startupFleetConfig = structuredClone(this.fleetConfig);
15611
+ this.appliedFleetLevel = newFleetLevel;
15612
+ observe?.(APPLY_FLEET_TARGET, "hot", "done");
15613
+ }
15614
+ else {
15615
+ observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
15616
+ }
15617
+ }
15618
+ else if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
15266
15619
  this.logger.warn({
15267
15620
  keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
15268
15621
  }, "Fleet-level config changed — restart AgEnD for it to take effect");
@@ -15596,6 +15949,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15596
15949
  listSecureConnections() {
15597
15950
  const channels = this.fleetConfig?.channels
15598
15951
  ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
15952
+ // A token written from Settings is in .env before this process has it (#1519 P1: a new connection's token waits for
15953
+ // the restart that starts it) — stored is "set", not "missing". Names only; no value is read out.
15954
+ let stored;
15955
+ try {
15956
+ stored = envFileKeys(this.dataDir);
15957
+ }
15958
+ catch {
15959
+ stored = new Set();
15960
+ } // unreadable: only what this process has
15599
15961
  return channels.map((channel, index) => {
15600
15962
  const id = channel.id ?? channel.type ?? `channel-${index}`;
15601
15963
  const world = this.worlds.get(id);
@@ -15604,16 +15966,44 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15604
15966
  id,
15605
15967
  type: channel.type,
15606
15968
  token_env: channel.bot_token_env,
15607
- token_present: !!process.env[channel.bot_token_env],
15969
+ token_present: !!process.env[channel.bot_token_env] || stored.has(channel.bot_token_env),
15608
15970
  group_id: channel.group_id != null ? String(channel.group_id) : null,
15609
15971
  general_channel_id: channel.options?.general_channel_id != null
15610
15972
  ? String(channel.options.general_channel_id)
15611
15973
  : null,
15612
- status: state?.status ?? (world ? "starting" : "stopped"),
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
  }
@@ -16947,7 +17413,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16947
17413
  const backend = this.fleetConfig?.instances[name]?.backend
16948
17414
  ?? this.fleetConfig?.defaults?.backend
16949
17415
  ?? "claude-code";
16950
- const { context } = resolveInstanceContext(this.dataDir, name, backend);
17416
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
16951
17417
  return {
16952
17418
  name,
16953
17419
  status: this.getInstanceStatus(name),
@@ -17336,6 +17802,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17336
17802
  .map(ch => ch.instanceName)
17337
17803
  .filter(name => !fleetNames.includes(name));
17338
17804
  const names = [...fleetNames, ...classicOnly];
17805
+ // The identity every page shows (alpha.2, N1): alias, description, role and tags resolved by the same rule as
17806
+ // /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
17807
+ const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
17808
+ const profiles = profileIdentities(this.dataDir);
17339
17809
  const instances = names.map(name => {
17340
17810
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
17341
17811
  let cost = 0;
@@ -17353,7 +17823,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17353
17823
  : (this.fleetConfig?.instances[name]?.backend
17354
17824
  ?? this.fleetConfig?.defaults?.backend
17355
17825
  ?? "claude-code");
17356
- const { context } = resolveInstanceContext(this.dataDir, name, backend);
17826
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
17357
17827
  // context_pct: null when unavailable, not 0
17358
17828
  const context_pct = context ?? null;
17359
17829
  // Model: Only Claude Code has live statusline; others use the effective resolver.
@@ -17372,13 +17842,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17372
17842
  // Only show effort if backend supports it and it's not antigravity
17373
17843
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
17374
17844
  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;
17845
+ const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
17379
17846
  return {
17380
17847
  name,
17381
- display_name: display_name || undefined,
17848
+ display_name: identity.display_name || undefined,
17849
+ description: identity.description || undefined,
17850
+ role: identity.role || undefined,
17851
+ tags: identity.tags,
17382
17852
  status: this.getInstanceStatus(name),
17383
17853
  // `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
17384
17854
  // what the dashboard's activity events carry) come from instancePresentation (#1212).
@@ -17396,6 +17866,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17396
17866
  instances,
17397
17867
  publicLink: this.getPublicWebStatus(),
17398
17868
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
17869
+ // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
17870
+ connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
17399
17871
  };
17400
17872
  }
17401
17873
  }