@songsid/agend 2.2.0-alpha.1 → 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 (317) hide show
  1. package/README.md +6 -4
  2. package/README.zh-TW.md +6 -4
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +4 -1
  6. package/dist/agent-endpoint.js +82 -14
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-token-header.d.ts +13 -0
  9. package/dist/agent-token-header.js +16 -0
  10. package/dist/agent-token-header.js.map +1 -0
  11. package/dist/atomic-write.d.ts +21 -0
  12. package/dist/atomic-write.js +65 -0
  13. package/dist/atomic-write.js.map +1 -0
  14. package/dist/auth-api.js +2 -0
  15. package/dist/auth-api.js.map +1 -1
  16. package/dist/backend/antigravity.js +4 -3
  17. package/dist/backend/antigravity.js.map +1 -1
  18. package/dist/backend/codex.d.ts +7 -0
  19. package/dist/backend/codex.js +7 -0
  20. package/dist/backend/codex.js.map +1 -1
  21. package/dist/backend/grok.js +1 -1
  22. package/dist/backend/grok.js.map +1 -1
  23. package/dist/backend/kiro.d.ts +7 -0
  24. package/dist/backend/kiro.js +82 -33
  25. package/dist/backend/kiro.js.map +1 -1
  26. package/dist/backend/muse.d.ts +7 -0
  27. package/dist/backend/muse.js +8 -1
  28. package/dist/backend/muse.js.map +1 -1
  29. package/dist/backend/types.d.ts +12 -0
  30. package/dist/backend/types.js.map +1 -1
  31. package/dist/binary-probe.d.ts +80 -0
  32. package/dist/binary-probe.js +182 -0
  33. package/dist/binary-probe.js.map +1 -0
  34. package/dist/cache-analysis.d.ts +92 -0
  35. package/dist/cache-analysis.js +147 -0
  36. package/dist/cache-analysis.js.map +1 -0
  37. package/dist/cache-ledger.d.ts +108 -0
  38. package/dist/cache-ledger.js +230 -0
  39. package/dist/cache-ledger.js.map +1 -0
  40. package/dist/cache-prices.d.ts +32 -0
  41. package/dist/cache-prices.js +73 -0
  42. package/dist/cache-prices.js.map +1 -0
  43. package/dist/cache-scan.d.ts +39 -0
  44. package/dist/cache-scan.js +115 -0
  45. package/dist/cache-scan.js.map +1 -0
  46. package/dist/cache-service.d.ts +131 -0
  47. package/dist/cache-service.js +452 -0
  48. package/dist/cache-service.js.map +1 -0
  49. package/dist/channel/adapters/discord.d.ts +5 -1
  50. package/dist/channel/adapters/discord.js +56 -11
  51. package/dist/channel/adapters/discord.js.map +1 -1
  52. package/dist/channel/adapters/telegram.d.ts +38 -1
  53. package/dist/channel/adapters/telegram.js +82 -0
  54. package/dist/channel/adapters/telegram.js.map +1 -1
  55. package/dist/channel/mcp-tools.js +1 -1
  56. package/dist/channel/mcp-tools.js.map +1 -1
  57. package/dist/channel/tool-router.d.ts +8 -1
  58. package/dist/channel/tool-router.js +4 -2
  59. package/dist/channel/tool-router.js.map +1 -1
  60. package/dist/channel/types.d.ts +21 -0
  61. package/dist/cli-entry.d.ts +19 -0
  62. package/dist/cli-entry.js +36 -0
  63. package/dist/cli-entry.js.map +1 -0
  64. package/dist/cli.js +376 -54
  65. package/dist/cli.js.map +1 -1
  66. package/dist/connection-secrets.d.ts +5 -0
  67. package/dist/connection-secrets.js.map +1 -1
  68. package/dist/cpu-profile.js +50 -0
  69. package/dist/cpu-profile.js.map +1 -1
  70. package/dist/daemon.d.ts +18 -0
  71. package/dist/daemon.js +256 -63
  72. package/dist/daemon.js.map +1 -1
  73. package/dist/delivery-outbox.d.ts +1 -0
  74. package/dist/delivery-outbox.js +12 -0
  75. package/dist/delivery-outbox.js.map +1 -1
  76. package/dist/discord-permissions.d.ts +33 -0
  77. package/dist/discord-permissions.js +42 -0
  78. package/dist/discord-permissions.js.map +1 -0
  79. package/dist/env-file.d.ts +11 -0
  80. package/dist/env-file.js +27 -0
  81. package/dist/env-file.js.map +1 -0
  82. package/dist/event-log.d.ts +4 -1
  83. package/dist/event-log.js +6 -3
  84. package/dist/event-log.js.map +1 -1
  85. package/dist/fleet-context.d.ts +5 -0
  86. package/dist/fleet-context.js.map +1 -1
  87. package/dist/fleet-control-audit.d.ts +5 -0
  88. package/dist/fleet-control-audit.js +7 -0
  89. package/dist/fleet-control-audit.js.map +1 -1
  90. package/dist/fleet-level-config.d.ts +8 -1
  91. package/dist/fleet-level-config.js +53 -0
  92. package/dist/fleet-level-config.js.map +1 -1
  93. package/dist/fleet-manager.d.ts +122 -7
  94. package/dist/fleet-manager.js +871 -152
  95. package/dist/fleet-manager.js.map +1 -1
  96. package/dist/fleet-stop-budget.d.ts +22 -0
  97. package/dist/fleet-stop-budget.js +95 -0
  98. package/dist/fleet-stop-budget.js.map +1 -0
  99. package/dist/full-restart.js +2 -3
  100. package/dist/full-restart.js.map +1 -1
  101. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  102. package/dist/install-lock.d.ts +49 -0
  103. package/dist/install-lock.js +200 -0
  104. package/dist/install-lock.js.map +1 -0
  105. package/dist/instance-config-impact.js +3 -2
  106. package/dist/instance-config-impact.js.map +1 -1
  107. package/dist/kiro-transcript-lane.d.ts +8 -4
  108. package/dist/kiro-transcript-lane.js +66 -44
  109. package/dist/kiro-transcript-lane.js.map +1 -1
  110. package/dist/locale.js +96 -0
  111. package/dist/locale.js.map +1 -1
  112. package/dist/next-steps.d.ts +5 -0
  113. package/dist/next-steps.js +26 -0
  114. package/dist/next-steps.js.map +1 -0
  115. package/dist/outbound-handlers.js +8 -0
  116. package/dist/outbound-handlers.js.map +1 -1
  117. package/dist/outbound-schemas.d.ts +4 -0
  118. package/dist/outbound-schemas.js +7 -2
  119. package/dist/outbound-schemas.js.map +1 -1
  120. package/dist/outbox-open-error.d.ts +7 -0
  121. package/dist/outbox-open-error.js +15 -0
  122. package/dist/outbox-open-error.js.map +1 -0
  123. package/dist/package-preimage.d.ts +26 -0
  124. package/dist/package-preimage.js +96 -0
  125. package/dist/package-preimage.js.map +1 -0
  126. package/dist/pane-context-cache.d.ts +25 -0
  127. package/dist/pane-context-cache.js +73 -0
  128. package/dist/pane-context-cache.js.map +1 -0
  129. package/dist/paths.d.ts +0 -7
  130. package/dist/paths.js +8 -2
  131. package/dist/paths.js.map +1 -1
  132. package/dist/provider-probe.d.ts +15 -0
  133. package/dist/provider-probe.js +20 -0
  134. package/dist/provider-probe.js.map +1 -1
  135. package/dist/public-link-progress.d.ts +73 -0
  136. package/dist/public-link-progress.js +190 -0
  137. package/dist/public-link-progress.js.map +1 -0
  138. package/dist/public-web-gateway.js +2 -2
  139. package/dist/public-web-gateway.js.map +1 -1
  140. package/dist/public-web-link.d.ts +6 -1
  141. package/dist/public-web-link.js +87 -15
  142. package/dist/public-web-link.js.map +1 -1
  143. package/dist/quickstart-api.d.ts +93 -20
  144. package/dist/quickstart-api.js +194 -62
  145. package/dist/quickstart-api.js.map +1 -1
  146. package/dist/quickstart.d.ts +15 -0
  147. package/dist/quickstart.js +110 -33
  148. package/dist/quickstart.js.map +1 -1
  149. package/dist/reply-buttons-controller.d.ts +98 -0
  150. package/dist/reply-buttons-controller.js +174 -0
  151. package/dist/reply-buttons-controller.js.map +1 -0
  152. package/dist/reply-buttons.d.ts +113 -0
  153. package/dist/reply-buttons.js +208 -0
  154. package/dist/reply-buttons.js.map +1 -0
  155. package/dist/reply-dedup.js +3 -1
  156. package/dist/reply-dedup.js.map +1 -1
  157. package/dist/restart-guard.d.ts +53 -0
  158. package/dist/restart-guard.js +114 -0
  159. package/dist/restart-guard.js.map +1 -0
  160. package/dist/scheduler/db.js +6 -2
  161. package/dist/scheduler/db.js.map +1 -1
  162. package/dist/service-activation.d.ts +62 -7
  163. package/dist/service-activation.js +237 -55
  164. package/dist/service-activation.js.map +1 -1
  165. package/dist/service-installer.d.ts +56 -1
  166. package/dist/service-installer.js +152 -12
  167. package/dist/service-installer.js.map +1 -1
  168. package/dist/service-plan.d.ts +65 -0
  169. package/dist/service-plan.js +108 -0
  170. package/dist/service-plan.js.map +1 -0
  171. package/dist/settings-api.js +92 -10
  172. package/dist/settings-api.js.map +1 -1
  173. package/dist/settings-authority.d.ts +11 -0
  174. package/dist/settings-authority.js +128 -0
  175. package/dist/settings-authority.js.map +1 -0
  176. package/dist/settings-change.d.ts +5 -0
  177. package/dist/settings-change.js +37 -8
  178. package/dist/settings-change.js.map +1 -1
  179. package/dist/settings-confirmation.d.ts +3 -0
  180. package/dist/settings-confirmation.js +7 -1
  181. package/dist/settings-confirmation.js.map +1 -1
  182. package/dist/settings-effect.js +19 -7
  183. package/dist/settings-effect.js.map +1 -1
  184. package/dist/settings-http-confirmation.js +1 -1
  185. package/dist/settings-http-confirmation.js.map +1 -1
  186. package/dist/settings-redaction.d.ts +18 -0
  187. package/dist/settings-redaction.js +58 -0
  188. package/dist/settings-redaction.js.map +1 -0
  189. package/dist/setup-form.d.ts +9 -1
  190. package/dist/setup-form.js +25 -1
  191. package/dist/setup-form.js.map +1 -1
  192. package/dist/setup-host.js +15 -6
  193. package/dist/setup-host.js.map +1 -1
  194. package/dist/setup-wizard.js +27 -19
  195. package/dist/setup-wizard.js.map +1 -1
  196. package/dist/sqlite-open-errors.d.ts +12 -0
  197. package/dist/sqlite-open-errors.js +13 -0
  198. package/dist/sqlite-open-errors.js.map +1 -0
  199. package/dist/systemd-runtime.d.ts +15 -0
  200. package/dist/systemd-runtime.js +41 -0
  201. package/dist/systemd-runtime.js.map +1 -0
  202. package/dist/tmux-control.d.ts +44 -0
  203. package/dist/tmux-control.js +170 -21
  204. package/dist/tmux-control.js.map +1 -1
  205. package/dist/token-env-name.d.ts +37 -0
  206. package/dist/token-env-name.js +93 -0
  207. package/dist/token-env-name.js.map +1 -0
  208. package/dist/tool-permissions.js +5 -3
  209. package/dist/tool-permissions.js.map +1 -1
  210. package/dist/topic-commands.d.ts +7 -16
  211. package/dist/topic-commands.js +56 -85
  212. package/dist/topic-commands.js.map +1 -1
  213. package/dist/transcript-monitor.js +3 -0
  214. package/dist/transcript-monitor.js.map +1 -1
  215. package/dist/transcript-sources.d.ts +68 -0
  216. package/dist/transcript-sources.js +336 -29
  217. package/dist/transcript-sources.js.map +1 -1
  218. package/dist/transcript-turns.d.ts +39 -0
  219. package/dist/transcript-turns.js +58 -0
  220. package/dist/transcript-turns.js.map +1 -0
  221. package/dist/tunnel/cloudflared-install.d.ts +18 -0
  222. package/dist/tunnel/cloudflared-install.js +15 -1
  223. package/dist/tunnel/cloudflared-install.js.map +1 -1
  224. package/dist/turn-reply-guard.d.ts +3 -0
  225. package/dist/turn-reply-guard.js.map +1 -1
  226. package/dist/ui/chat-commands.js +43 -0
  227. package/dist/ui/chat-render.js +34 -8
  228. package/dist/ui/chat-store.js +51 -2
  229. package/dist/ui/chat-strings.js +30 -2
  230. package/dist/ui/chat-thread.js +141 -47
  231. package/dist/ui/first-run.js +64 -0
  232. package/dist/ui/fleet-cache.js +168 -0
  233. package/dist/ui/html-attachment.js +26 -0
  234. package/dist/ui/panel-chat.js +117 -23
  235. package/dist/ui/panel-fleet.js +31 -17
  236. package/dist/ui/panel-settings.js +29 -15
  237. package/dist/ui/preview-panel.js +237 -0
  238. package/dist/ui/preview.js +7 -3
  239. package/dist/ui/settings-apply.js +3 -1
  240. package/dist/ui/settings-dialogs.js +95 -25
  241. package/dist/ui/settings-discord.js +130 -0
  242. package/dist/ui/settings-model.js +21 -0
  243. package/dist/ui/settings-strings.js +50 -8
  244. package/dist/ui/settings-token.js +54 -0
  245. package/dist/ui/settings-wizard.js +63 -29
  246. package/dist/ui/shared/app-route.js +1 -1
  247. package/dist/ui/shared/app-shell.js +25 -37
  248. package/dist/ui/shared/app-store.js +5 -3
  249. package/dist/ui/shared/app-stream.js +2 -1
  250. package/dist/ui/shared/app.css +181 -5
  251. package/dist/ui/shared/app.js +2 -1
  252. package/dist/ui/shared/instance-nav.js +310 -0
  253. package/dist/ui/shared/panel-view.js +12 -190
  254. package/dist/ui/shared/signin.js +13 -1
  255. package/dist/ui/shared/ui-icons.js +2 -0
  256. package/dist/ui/shared/view-roster-store.js +5 -0
  257. package/dist/ui/shared/view-strings.js +12 -0
  258. package/dist/ui/signin.html +2 -1
  259. package/dist/update-dispatch.d.ts +28 -0
  260. package/dist/update-dispatch.js +73 -0
  261. package/dist/update-dispatch.js.map +1 -0
  262. package/dist/update-install.d.ts +78 -8
  263. package/dist/update-install.js +158 -30
  264. package/dist/update-install.js.map +1 -1
  265. package/dist/update-launch.d.ts +22 -0
  266. package/dist/update-launch.js +106 -0
  267. package/dist/update-launch.js.map +1 -0
  268. package/dist/update-marker.js +3 -8
  269. package/dist/update-marker.js.map +1 -1
  270. package/dist/usage/format-rich.js +7 -1
  271. package/dist/usage/format-rich.js.map +1 -1
  272. package/dist/usage/i18n-keys.d.ts +1 -1
  273. package/dist/usage/i18n-keys.js +1 -1
  274. package/dist/usage/i18n-keys.js.map +1 -1
  275. package/dist/usage/providers.d.ts +4 -0
  276. package/dist/usage/providers.js +2 -1
  277. package/dist/usage/providers.js.map +1 -1
  278. package/dist/usage/usage-api.js +48 -2
  279. package/dist/usage/usage-api.js.map +1 -1
  280. package/dist/view-api.d.ts +40 -0
  281. package/dist/view-api.js +28 -10
  282. package/dist/view-api.js.map +1 -1
  283. package/dist/web-api.d.ts +22 -0
  284. package/dist/web-api.js +115 -16
  285. package/dist/web-api.js.map +1 -1
  286. package/dist/web-auth.d.ts +8 -0
  287. package/dist/web-auth.js +34 -8
  288. package/dist/web-auth.js.map +1 -1
  289. package/dist/web-chat-history.d.ts +21 -0
  290. package/dist/web-chat-history.js +44 -0
  291. package/dist/web-chat-history.js.map +1 -1
  292. package/dist/web-commands.d.ts +77 -0
  293. package/dist/web-commands.js +81 -0
  294. package/dist/web-commands.js.map +1 -0
  295. package/dist/web-host-guard.d.ts +18 -2
  296. package/dist/web-host-guard.js +30 -5
  297. package/dist/web-host-guard.js.map +1 -1
  298. package/dist/web-session.d.ts +7 -1
  299. package/dist/web-session.js +23 -2
  300. package/dist/web-session.js.map +1 -1
  301. package/dist/web-shell-routes.d.ts +1 -1
  302. package/dist/web-shell-routes.js +1 -1
  303. package/dist/web-shell-routes.js.map +1 -1
  304. package/launcher/agend +110 -0
  305. package/launcher/agend-agent +110 -0
  306. package/launcher/agend-agent.cjs +4 -0
  307. package/launcher/agend.cjs +4 -0
  308. package/launcher/install-admission.cjs +105 -0
  309. package/launcher/launch.cjs +64 -0
  310. package/launcher/postinstall.cjs +118 -0
  311. package/launcher/run-in-process.cjs +13 -0
  312. package/launcher/runtime-platform.cjs +86 -0
  313. package/launcher/runtime-select.cjs +217 -0
  314. package/package.json +12 -4
  315. package/scripts/preinstall-guard.cjs +40 -21
  316. package/templates/launchd.plist.ejs +2 -2
  317. package/templates/systemd.service.ejs +4 -4
@@ -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,6 +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";
45
+ import { classifySqliteOpenError } from "./sqlite-open-errors.js";
46
+ import { OutboxOpenError } from "./outbox-open-error.js";
42
47
  import { AdapterWorld } from "./adapter-world.js";
43
48
  import { CostGuard, formatCents } from "./cost-guard.js";
44
49
  import { startEventLoopWatch } from "./event-loop-watch.js";
@@ -120,8 +125,13 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
120
125
  }
121
126
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
122
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";
123
130
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
124
- 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";
125
135
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
126
136
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
127
137
  import { LoginSession } from "./login-manager.js";
@@ -129,7 +139,9 @@ import { tightenInstanceDirs } from "./private-dir.js";
129
139
  import { decideSlash } from "./slash-authz.js";
130
140
  import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
131
141
  import { runVisibilityCommand } from "./cross-instance-notice.js";
132
- import { UPDATE_COMMAND, installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
142
+ import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
143
+ import { resolveInstalledAgend } from "./update-dispatch.js";
144
+ import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
133
145
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
134
146
  import { runBeforeDeadline } from "./deadline.js";
135
147
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -156,6 +168,10 @@ import { NeedsYouHub } from "./needs-you-hub.js";
156
168
  import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
157
169
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
158
170
  import { buildOrgChart } from "./web-org.js";
171
+ import { CacheService, WINDOWS } from "./cache-service.js";
172
+ import { runWebCommand } from "./web-commands.js";
173
+ import { claudeProjectKey } from "./backend/claude-code.js";
174
+ import { sharedRolloutIndex } from "./rollout-index.js";
159
175
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
160
176
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
161
177
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
@@ -164,7 +180,7 @@ import { tokenEpoch, WebSessionStore } from "./web-session.js";
164
180
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
165
181
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
166
182
  import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
167
- import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
183
+ import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
168
184
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
169
185
  import { SecretStore } from "./secret-store.js";
170
186
  import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
@@ -334,6 +350,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
334
350
  */
335
351
  const WEB_ONLY_REPLY_SINK = {
336
352
  type: "web",
353
+ supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
337
354
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
338
355
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
339
356
  };
@@ -437,6 +454,8 @@ export class FleetManager {
437
454
  dataDir;
438
455
  static signalTarget = null;
439
456
  static sighupHandlerInstalled = false;
457
+ /** Test seam: inject a spy to verify saveFleetConfig calls fsync. Default: fsyncSync. */
458
+ fsyncForTest = undefined;
440
459
  children = new Map();
441
460
  lifecycle;
442
461
  stormWindow;
@@ -833,21 +852,66 @@ export class FleetManager {
833
852
  return false;
834
853
  await settlement;
835
854
  const job = this.providerSecretJobs.get(id) ?? this.connectionSecretJobs.get(id) ?? this.connectionBindingJobs.get(id);
836
- 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);
837
858
  },
838
859
  });
839
860
  this.settingsConfirmation = gate;
840
861
  return gate;
841
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
+ }
842
901
  async promptSettingsChange(store, view) {
843
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
+ }
844
908
  const excluded = new Set(store.affectedConnections(view.id));
845
909
  for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
846
910
  if (!config.general_topic)
847
911
  continue;
848
912
  const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
849
913
  const general = this.daemons.get(name);
850
- if (!adapterId || !adapter || !general || excluded.has(adapterId) || !this.hasFleetAdmins(adapterId))
914
+ if (!adapterId || !adapter || !general || excluded.has(adapterId) || adapter.type !== authority.platform)
851
915
  continue;
852
916
  const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
853
917
  if (!chatId || !topic)
@@ -860,7 +924,7 @@ export class FleetManager {
860
924
  && this.adapterState.get(adapterId)?.status === "connected"
861
925
  && this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
862
926
  && this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
863
- && this.hasFleetAdmins(adapterId);
927
+ && authority.current();
864
928
  if (!current())
865
929
  continue;
866
930
  try {
@@ -869,7 +933,7 @@ export class FleetManager {
869
933
  message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
870
934
  choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
871
935
  expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
872
- extra: { pendingChangeId: view.id, confirmationCurrent: current },
936
+ extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
873
937
  });
874
938
  void posted.then(nonce => {
875
939
  if (!retired)
@@ -907,7 +971,7 @@ export class FleetManager {
907
971
  this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
908
972
  }
909
973
  async handleSettingsChangeCallback(data, adapterId, adapter) {
910
- 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);
911
975
  if (!result)
912
976
  return false;
913
977
  if (result === "consumed")
@@ -917,7 +981,7 @@ export class FleetManager {
917
981
  const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
918
982
  label: `fleet admin ${data.userId}`,
919
983
  current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
920
- && !!data.userId && this.isFleetAdmin(data.userId, entry.adapterId),
984
+ && !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
921
985
  });
922
986
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
923
987
  }
@@ -990,16 +1054,111 @@ export class FleetManager {
990
1054
  this.topicArchiver = new TopicArchiver(this);
991
1055
  this.statuslineWatcher = new StatuslineWatcher(this);
992
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
+ }
993
1137
  ensureDeliveryOutbox() {
994
1138
  if (this.deliveryOutbox)
995
1139
  return;
996
- const outbox = new DeliveryOutbox(join(this.dataDir, "delivery-outbox.db"), this.managerBootId);
997
- this.deliveryOutbox = outbox;
998
- if (!this.deliveryOutboxRecovered) {
999
- const recovered = outbox.recoverForBoot(this.managerBootId);
1000
- this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
1001
- 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
+ }
1002
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;
1159
+ }
1160
+ this.deliveryOutbox = outbox;
1161
+ this.deliveryOutboxRecovered = true;
1003
1162
  outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
1004
1163
  outbox.on("state", (event) => {
1005
1164
  this.scheduleDeliveryOutboxPump();
@@ -1514,6 +1673,10 @@ export class FleetManager {
1514
1673
  });
1515
1674
  return caller;
1516
1675
  }
1676
+ /** Pure cached admission for command continuations; no IO or update-progress lookup. */
1677
+ isFleetStopping() {
1678
+ return this.shuttingDown;
1679
+ }
1517
1680
  /**
1518
1681
  * Is the fleet going down (or coming back up) on purpose?
1519
1682
  *
@@ -1575,6 +1738,16 @@ export class FleetManager {
1575
1738
  lastActivityMs(name) {
1576
1739
  return this.lastActivity.get(name) ?? 0;
1577
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
+ }
1578
1751
  /**
1579
1752
  * Is the instance between turns?
1580
1753
  *
@@ -1850,7 +2023,7 @@ export class FleetManager {
1850
2023
  await data.respond(t(`${action}.usage`));
1851
2024
  return;
1852
2025
  }
1853
- if (!this.fleetConfig?.instances[requested]) {
2026
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, requested)) {
1854
2027
  await data.respond(t("instance.not_found", requested));
1855
2028
  return;
1856
2029
  }
@@ -1898,14 +2071,27 @@ export class FleetManager {
1898
2071
  const { spawn } = await import("node:child_process");
1899
2072
  // Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
1900
2073
  // stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
1901
- // "not a beta" and sent a beta install to @latest.
1902
- const command = UPDATE_COMMAND;
2074
+ // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
2075
+ const installed = await resolveInstalledAgend();
2076
+ if (!installed.ok) {
2077
+ this.failUpdateProgress(`/update cannot verify the installed AgEnD (${installed.reason}). Run \`agend update\` from a shell.`);
2078
+ return;
2079
+ }
1903
2080
  const origin = `slash /update by ${adapterId}:${data.userId}`;
1904
2081
  recordInternalRequest(this.dataDir, "update", origin);
1905
- const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
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, {
1906
2092
  detached: true, stdio: "ignore", env: withOrigin(origin),
1907
2093
  });
1908
- child.once("error", err => this.failUpdateProgress(err.message));
2094
+ watchUpdateLaunch(child, message => this.failUpdateProgress(message));
1909
2095
  child.unref();
1910
2096
  }
1911
2097
  async handleProfileSlash(data, adapterId) {
@@ -1927,7 +2113,7 @@ export class FleetManager {
1927
2113
  await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
1928
2114
  }
1929
2115
  async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
1930
- if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
2116
+ if (!general || !Object.hasOwn(this.fleetConfig?.instances ?? {}, general) || !this.fleetConfig?.instances[general]?.general_topic) {
1931
2117
  await respond(t("profile.general_only"));
1932
2118
  return;
1933
2119
  }
@@ -3173,7 +3359,9 @@ export class FleetManager {
3173
3359
  await this.sendLocalDashboard(data, adapterId);
3174
3360
  return;
3175
3361
  }
3176
- 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"));
3177
3365
  }
3178
3366
  }
3179
3367
  else if (data.command === "restart") {
@@ -3913,18 +4101,17 @@ export class FleetManager {
3913
4101
  /**
3914
4102
  * Probe the same executable set exposed by the web backend catalog.
3915
4103
  *
3916
- * 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
3917
4105
  * binary to PATH while the fleet process remains alive, and the next bare
3918
4106
  * `/login` must see it without requiring a restart or an explicit cache
3919
- * 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.
3920
4110
  */
3921
- probeInstalledBackends() {
3922
- const installed = new Set();
3923
- for (const [backend, info] of Object.entries(BACKEND_INSTALLATION_INFO)) {
3924
- if (checkBinaryInstalled(info.binary))
3925
- installed.add(backend);
3926
- }
3927
- 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));
3928
4115
  }
3929
4116
  /**
3930
4117
  * One fleet-level notice per burst, not one per instance: a post-update herd
@@ -4283,24 +4470,10 @@ export class FleetManager {
4283
4470
  if (!existsSync(envPath))
4284
4471
  return;
4285
4472
  assertSettingsLease(settingsFileResource(envPath), owner);
4286
- const content = readFileSync(envPath, "utf-8");
4287
- for (const line of content.split("\n")) {
4288
- const trimmed = line.trim();
4289
- if (!trimmed || trimmed.startsWith("#"))
4290
- continue;
4291
- const eqIdx = trimmed.indexOf("=");
4292
- if (eqIdx < 0)
4293
- continue;
4294
- // Accept `export KEY=value` — the shell-style form people paste from their
4295
- // .bashrc. Without this the variable landed in process.env under the key
4296
- // "export KEY" and silently did nothing.
4297
- const key = trimmed.slice(0, eqIdx).replace(/^export\s+/, "").trim();
4298
- const raw = trimmed.slice(eqIdx + 1);
4299
- const value = raw.replace(/^["'](.*)["']$/, '$1');
4300
- // .env file always wins over inherited shell env vars, so that
4301
- // 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")))
4302
4476
  process.env[key] = value;
4303
- }
4304
4477
  }
4305
4478
  /** Initialize auth before any adapter can answer /dashboard. */
4306
4479
  initializeWebAuthTokens() {
@@ -4429,11 +4602,15 @@ export class FleetManager {
4429
4602
  await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
4430
4603
  return;
4431
4604
  }
4432
- 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
+ }
4433
4608
  }
4434
4609
  async sendLocalDashboard(data, adapterId) {
4435
- 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"));
4436
4612
  return;
4613
+ }
4437
4614
  const text = this.topicCommands.getDashboardText();
4438
4615
  const adapter = this.adapters.get(adapterId);
4439
4616
  const token = this.webToken;
@@ -4455,7 +4632,8 @@ export class FleetManager {
4455
4632
  await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
4456
4633
  deliveredByDm = true;
4457
4634
  }
4458
- catch {
4635
+ catch (err) {
4636
+ this.logDashboardDmFailure(err);
4459
4637
  if (!current())
4460
4638
  throw new Error("owner changed");
4461
4639
  const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
@@ -4465,15 +4643,25 @@ export class FleetManager {
4465
4643
  if (!current())
4466
4644
  throw new Error("owner changed");
4467
4645
  if (deliveredByDm)
4468
- await withinBudget(data.respond(t("dashboard.private_sent")), deadline);
4646
+ await withinBudget(data.respond(t("dashboard.private_sent_dm")), deadline);
4469
4647
  }
4470
- catch {
4648
+ catch (err) {
4471
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);
4472
4652
  }
4473
4653
  }
4474
- async showDashboardMenu(owner, respondButtons) {
4475
- if (!this.publicOwnerCurrent(owner))
4476
- 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
+ }
4477
4665
  const adapter = owner.binding;
4478
4666
  const status = this.getPublicWebStatus();
4479
4667
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
@@ -4483,7 +4671,7 @@ export class FleetManager {
4483
4671
  const choices = [{ action: "local", label: t("dashboard.local") },
4484
4672
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4485
4673
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4486
- await this.postNonceButtonPromptOrThrow({
4674
+ const posted = await this.postNonceButtonPrompt({
4487
4675
  prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4488
4676
  chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
4489
4677
  message: menuText,
@@ -4492,9 +4680,11 @@ export class FleetManager {
4492
4680
  const messageId = await respondButtons(menuText, c);
4493
4681
  if (!messageId)
4494
4682
  throw new Error("menu refused");
4495
- 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 } : {}) };
4496
4685
  } } : {}),
4497
4686
  });
4687
+ return posted !== null;
4498
4688
  }
4499
4689
  async handleDashboardCallback(data, adapterId, adapter) {
4500
4690
  const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
@@ -4514,9 +4704,26 @@ export class FleetManager {
4514
4704
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
4515
4705
  if (result?.confirmed === false)
4516
4706
  data.ack?.(t("dashboard.public_cleanup"));
4517
- 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);
4518
4708
  return true;
4519
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;
4520
4727
  const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
4521
4728
  const token = this.webToken;
4522
4729
  if (!token || !current())
@@ -4531,9 +4738,12 @@ export class FleetManager {
4531
4738
  try {
4532
4739
  if (!direct.sendDirect)
4533
4740
  throw new Error("DM unavailable");
4534
- 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;
4535
4744
  }
4536
- catch {
4745
+ catch (err) {
4746
+ this.logDashboardDmFailure(err);
4537
4747
  if (direct.type !== "discord" || !data.respondPrivate || !current())
4538
4748
  throw new Error("private delivery unavailable");
4539
4749
  const sent = await withinBudget(data.respondPrivate(text, c), deadline);
@@ -4552,17 +4762,31 @@ export class FleetManager {
4552
4762
  throw new Error("owner changed");
4553
4763
  return true;
4554
4764
  }
4555
- catch {
4765
+ catch (err) {
4556
4766
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
4557
- this.logger.info("Dashboard private delivery was not confirmed");
4767
+ this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
4558
4768
  return false;
4559
4769
  }
4560
4770
  };
4561
- const ok = action === "public"
4562
- ? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent))
4563
- : 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
+ }
4564
4784
  // Only safe, static words enter General; never link, code or platform error text.
4565
- 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);
4566
4790
  return true;
4567
4791
  }
4568
4792
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5274,6 +5498,8 @@ export class FleetManager {
5274
5498
  }
5275
5499
  // Health HTTP endpoint
5276
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"));
5277
5503
  // Daily update check — first check after 1 hour, then every 24 hours
5278
5504
  this.updateCheckTimer = setTimeout(() => {
5279
5505
  this.checkForUpdates();
@@ -6427,26 +6653,64 @@ export class FleetManager {
6427
6653
  return undefined;
6428
6654
  }
6429
6655
  /**
6430
- * #1085, defense in depth: a routed Telegram target must belong to the group
6431
- * the message came from — its own world's group_id. Two Telegram fleet
6432
- * 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.
6433
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
+ }
6434
6695
  /**
6435
6696
  * #1346: whether this copy may claim the shared dedup key for
6436
6697
  * command-like text. Fleet topics resolve an owning adapter and only its
6437
6698
  * copy proceeds. Classic targets and unknown routing keep their existing
6438
- * handling. At the receiving Telegram world's own forum root, an explicit
6439
- * 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
6440
6701
  * copies retain their permissive handling when that identity is unknown.
6441
6702
  */
6442
- 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
+ }
6443
6707
  if (!msg.adapterId)
6444
6708
  return true;
6445
6709
  const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
6446
6710
  if (threadId === undefined) {
6447
- // General at the forum root belongs to the receiving world. Prove an
6448
- // explicit suffix before shared dedup, even when there is no message id.
6449
- // 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.
6450
6714
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6451
6715
  const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
6452
6716
  ?? this.worlds.get(msg.adapterId)?.channelConfig;
@@ -6476,6 +6740,9 @@ export class FleetManager {
6476
6740
  }
6477
6741
  return true;
6478
6742
  }
6743
+ /**
6744
+ * #1085: coincident topic numbers must not route across Telegram groups.
6745
+ */
6479
6746
  resolveInboundTarget(msg, threadId) {
6480
6747
  if (msg.source !== "telegram")
6481
6748
  return this.routing.resolve(threadId);
@@ -6512,6 +6779,7 @@ export class FleetManager {
6512
6779
  return;
6513
6780
  }
6514
6781
  const threadId = this.inboundRouteThreadId(msg);
6782
+ const rootCommand = this.telegramRootCommandTarget(msg, threadId);
6515
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");
6516
6784
  // Ownership and per-adapter filtering both run before the dedup claim below.
6517
6785
  // When several bots share a guild they all receive the same message; a copy
@@ -6572,7 +6840,7 @@ export class FleetManager {
6572
6840
  // to flow — so only fleet targets are judged here; the in-handler gates
6573
6841
  // (owner entry check, Discord ignore) cover classic instead.
6574
6842
  if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
6575
- && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
6843
+ && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId, rootCommand)) {
6576
6844
  this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
6577
6845
  return;
6578
6846
  }
@@ -6879,7 +7147,7 @@ export class FleetManager {
6879
7147
  }
6880
7148
  // General topic: Discord /xxx gets the system note first (never a
6881
7149
  // command); other sources fall through to the handlers below.
6882
- const generalInstance = this.findGeneralInstance(msg.adapterId);
7150
+ const generalInstance = rootCommand?.name ?? this.findGeneralInstance(msg.adapterId);
6883
7151
  if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
6884
7152
  return;
6885
7153
  if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
@@ -7268,6 +7536,12 @@ export class FleetManager {
7268
7536
  return;
7269
7537
  }
7270
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
+ }
7271
7545
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
7272
7546
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
7273
7547
  if (stickerProblem) {
@@ -7280,8 +7554,33 @@ export class FleetManager {
7280
7554
  ticket.subscribe(respond);
7281
7555
  return;
7282
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
+ }
7283
7570
  const original = respond;
7284
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
+ }
7285
7584
  ticket.complete(result, error);
7286
7585
  // Return the platform outcome first. Bookkeeping below must never turn
7287
7586
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -7294,7 +7593,7 @@ export class FleetManager {
7294
7593
  // an agent cannot invent it to suppress the normal completion marker.
7295
7594
  if (!error && result != null && msg.statusOnly !== true) {
7296
7595
  try {
7297
- this.afterReplyRouted(instanceName, args, senderSessionName);
7596
+ this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
7298
7597
  }
7299
7598
  catch (err) {
7300
7599
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -7309,9 +7608,11 @@ export class FleetManager {
7309
7608
  });
7310
7609
  }
7311
7610
  };
7312
- if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
7611
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
7313
7612
  return;
7314
7613
  }
7614
+ if (replySet)
7615
+ this.replyButtonsCtl?.discard(replySet.id);
7315
7616
  // routeToolCall knows "reply"; not handling it means the world changed.
7316
7617
  ticket.complete(null, "reply not handled");
7317
7618
  original(null, "reply not handled");
@@ -7345,7 +7646,7 @@ export class FleetManager {
7345
7646
  webOnlyPromptPlace() {
7346
7647
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
7347
7648
  }
7348
- afterReplyRouted(instanceName, args, senderSessionName) {
7649
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
7349
7650
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
7350
7651
  // more work either. Split the difference: an instance that is clearly
7351
7652
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -7376,6 +7677,7 @@ export class FleetManager {
7376
7677
  text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
7377
7678
  ts: new Date().toISOString(),
7378
7679
  ...(attachments.length ? { attachments } : {}),
7680
+ ...(buttons ? { buttons } : {}), // #1266
7379
7681
  });
7380
7682
  // Log bot reply to classic instance chat-log
7381
7683
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
@@ -8579,15 +8881,48 @@ export class FleetManager {
8579
8881
  const output = String(this.rawFleetDocument);
8580
8882
  if (redundantPaths.length > 0)
8581
8883
  this.writeFleetConfigBackup(source);
8582
- const tempPath = `${this.configPath}.tmp-${process.pid}`;
8583
- writeFileSync(tempPath, output, "utf-8");
8584
- if (existsSync(this.configPath))
8585
- chmodSync(tempPath, statSync(this.configPath).mode);
8586
- 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
+ });
8587
8901
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
8588
8902
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
8589
8903
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
8590
8904
  }
8905
+ /** Why the config written to `candidate` must not replace fleet.yaml, or null: it does not load, or it adds errors. */
8906
+ savedFleetConfigProblem(candidate) {
8907
+ let after;
8908
+ try {
8909
+ after = validateFleetConfig(loadFleetConfig(candidate));
8910
+ }
8911
+ catch (err) {
8912
+ return `Refusing to save fleet.yaml: the result would not load (${err.message})`;
8913
+ }
8914
+ let had = new Set();
8915
+ try {
8916
+ if (this.configPath && existsSync(this.configPath)) {
8917
+ had = new Set(validateFleetConfig(loadFleetConfig(this.configPath)).errors.map(e => `${e.path}\u0000${e.message}`));
8918
+ }
8919
+ }
8920
+ catch { /* the current file does not load: every error of the result counts as new */ }
8921
+ const introduced = after.errors.filter(e => !had.has(`${e.path}\u0000${e.message}`));
8922
+ return introduced.length
8923
+ ? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
8924
+ : null;
8925
+ }
8591
8926
  /** One-time upgrade migration; invalid YAML is never rewritten. */
8592
8927
  slimFleetConfigAtStartup() {
8593
8928
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
@@ -8646,6 +8981,20 @@ export class FleetManager {
8646
8981
  }
8647
8982
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8648
8983
  if (isRecord(before) && isRecord(after)) {
8984
+ // #1056: a legacy `channel` is being migrated (the caller removed it and kept `channels`). The file has no
8985
+ // `channels` for the leaf patch below to land on — the snapshot's list is only the normalized alias — so a bare
8986
+ // channels[0] holding just the changed field would be written while `channel` is deleted. Move the connection
8987
+ // as it is in the file NOW (a concurrent or manual edit, an option AgEnD does not know) into channels[0]
8988
+ // first; the patch then applies only this save's own changes on top, with the usual identity/order check.
8989
+ if (path.length === 0 && this.rawFleetConfig.channel && !this.rawFleetConfig.channels
8990
+ && after.channel === undefined && Array.isArray(after.channels) && !document.hasIn(["channels"])) {
8991
+ const fresh = document.getIn(["channel"], true);
8992
+ if (isMap(fresh)) {
8993
+ const list = document.createNode([]);
8994
+ list.items.push(fresh.clone());
8995
+ document.setIn(["channels"], list);
8996
+ }
8997
+ }
8649
8998
  const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
8650
8999
  for (const key of keys) {
8651
9000
  // `channel` is a derived alias when the raw file uses `channels`.
@@ -8790,6 +9139,21 @@ export class FleetManager {
8790
9139
  * to a plain add (never worse than the old behaviour).
8791
9140
  */
8792
9141
  lastStatusEmoji = new Map();
9142
+ /**
9143
+ * #1056: every status emoji each bot really stamped, per `${adapterId}:${messageId}` (match keys). A connection's
9144
+ * status set can change while AgEnD runs, so "is this a stamp?" cannot be answered from the current set alone: a
9145
+ * bot's earlier stamp in the old emoji must still be plumbing, not an agent's reaction. Unbounded for the same
9146
+ * reason as lastStatusEmoji (a few short strings per stamped message).
9147
+ */
9148
+ stampedStatus = new Map();
9149
+ noteStamped(adapter, messageId, emoji) {
9150
+ const adapterId = typeof adapter.id === "string" ? adapter.id : "?";
9151
+ const k = `${adapterId}:${messageId}`;
9152
+ let set = this.stampedStatus.get(k);
9153
+ if (!set)
9154
+ this.stampedStatus.set(k, set = new Set());
9155
+ set.add(statusMatchKey(emoji));
9156
+ }
8793
9157
  /**
8794
9158
  * One in-flight status update per bot+message: updates run strictly in call
8795
9159
  * order, so a delayed ❌ add can never land after a newer ✅'s removal —
@@ -8812,7 +9176,7 @@ export class FleetManager {
8812
9176
  */
8813
9177
  resolveStatusEmojisFor(instanceName, adapterId, adapter) {
8814
9178
  const worldId = adapterId ?? this.getInstanceAdapterId(instanceName);
8815
- const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
9179
+ const channel = this.statusEmojiChannel(worldId);
8816
9180
  // The adapter that will react decides the vocabulary: a Telegram bot gets
8817
9181
  // Telegram's reaction set even if the channel lookup fell back elsewhere.
8818
9182
  const reacting = adapter ?? (worldId ? this.worlds.get(worldId)?.adapter : undefined) ?? this.adapter;
@@ -8833,6 +9197,22 @@ export class FleetManager {
8833
9197
  return { ...resolved, platform };
8834
9198
  }
8835
9199
  warnedStatusEmojis = new Set();
9200
+ /**
9201
+ * The connection whose `options.status_emojis` an instance on `worldId` stamps with. #1056: read from the live config
9202
+ * first — a Settings save replaces `fleetConfig.channels`, and the running world keeps the object it started with —
9203
+ * so a change to the connection's emojis applies at the next stamp, as an instance's own override does. Only the
9204
+ * emoji options are taken from it: the platform is still the running adapter's (resolveStatusEmojisFor).
9205
+ */
9206
+ statusEmojiChannel(worldId) {
9207
+ const started = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
9208
+ if (!worldId)
9209
+ return started;
9210
+ const list = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
9211
+ const live = list.find(ch => (ch.id ?? ch.type) === worldId);
9212
+ if (!live || !started)
9213
+ return live ?? started;
9214
+ return { ...started, options: { ...(started.options ?? {}), status_emojis: live.options?.status_emojis } };
9215
+ }
8836
9216
  /**
8837
9217
  * A Discord connection's server emojis for the Settings picker (#1005), from
8838
9218
  * every server its bot can draw on (#1021): the primary `group_id` server,
@@ -8922,11 +9302,18 @@ export class FleetManager {
8922
9302
  return { error: this.personaEmojiMissing(instanceName) };
8923
9303
  const worldId = this.getInstanceAdapterId(instanceName);
8924
9304
  const { platform } = this.resolveStatusEmojisFor(instanceName);
8925
- const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
9305
+ const channel = this.statusEmojiChannel(worldId);
8926
9306
  const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self?.status_emojis });
8927
9307
  const out = {
8928
9308
  platform: platform ?? null,
8929
- statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
9309
+ // #1056: progress_prefix is the emoji at the start of the progress message, not a reaction stamp.
9310
+ statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source, kind: e.key === "progress_prefix" ? "text_prefix" : "reaction" })),
9311
+ // #1056: say inline why the lists differ by platform, so an agent need not know it already.
9312
+ platform_note: platform === "telegram"
9313
+ ? "Telegram has no server custom emoji, so there is no server_emojis list: standard.reactions is the complete set a status reaction can use. progress_prefix is message text, not a reaction, so any single emoji works there."
9314
+ : platform === "discord"
9315
+ ? "A status reaction can be any single emoji, or a server emoji from server_emojis as <:name:id> (one of the servers this bot is in). progress_prefix is message text, not a reaction."
9316
+ : "This instance has no chat connection, so no platform rules apply yet.",
8930
9317
  standard: platform === "telegram"
8931
9318
  ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
8932
9319
  : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
@@ -9365,6 +9752,9 @@ export class FleetManager {
9365
9752
  const reactorWorlds = new Set(worlds.filter(w => w.botUserId === r.userId).map(w => w.id));
9366
9753
  if (reactorWorlds.size === 0)
9367
9754
  return false; // a human, or a bot outside this fleet
9755
+ // #1056: what this bot really stamped on this message, whatever its status set says now.
9756
+ if ([...reactorWorlds].some(id => this.stampedStatus.get(`${id}:${r.messageId}`)?.has(key)))
9757
+ return true;
9368
9758
  return [...names]
9369
9759
  .filter(n => reactorWorlds.has(this.getInstanceAdapterId(n) ?? ""))
9370
9760
  .some(n => statusMatchKeys(this.resolveStatusEmojisFor(n)).includes(key));
@@ -9372,6 +9762,8 @@ export class FleetManager {
9372
9762
  // Fallback: the built-in ladder (as before #1005) plus every value an
9373
9763
  // operator configured. Not the Telegram built-ins — 👎 is AgEnD's failed
9374
9764
  // stamp there, but also far too common a human reaction to swallow blind.
9765
+ if (worlds.some(w => this.stampedStatus.get(`${w.id}:${r.messageId}`)?.has(key)))
9766
+ return true; // #1056, as above
9375
9767
  const keys = new Set(statusMatchKeys(builtinStatusEmojis(undefined)));
9376
9768
  for (const name of names) {
9377
9769
  const resolved = this.resolveStatusEmojisFor(name);
@@ -9394,9 +9786,15 @@ export class FleetManager {
9394
9786
  const emoji = this.receivedReactionFor(instanceName, adapter, msg.adapterId);
9395
9787
  // Classic's system receipt is status-owned too. An ordinary react call
9396
9788
  // would label it as an agent/other reaction and suppress later statuses.
9789
+ // #1056: recorded as this bot's stamp like the delivery statuses (provenance survives a status-set change) —
9790
+ // on Telegram once the adapter took the slot, on Discord before the add (its gateway can echo it first).
9397
9791
  if (adapter instanceof TelegramAdapter) {
9398
- return adapter.reactDeliveryStatus(msg.chatId, msg.messageId, emoji, msg.timestamp.getTime()).then(() => { });
9792
+ return adapter.reactDeliveryStatus(msg.chatId, msg.messageId, emoji, msg.timestamp.getTime()).then((took) => {
9793
+ if (took)
9794
+ this.noteStamped(adapter, msg.messageId, emoji);
9795
+ });
9399
9796
  }
9797
+ this.noteStamped(adapter, msg.messageId, emoji);
9400
9798
  return adapter.react(msg.threadId ?? msg.chatId, msg.messageId, emoji);
9401
9799
  }
9402
9800
  reactClassicForwardedAttachment(instanceName, adapter, msg, kind) {
@@ -9482,8 +9880,10 @@ export class FleetManager {
9482
9880
  if (await adapter.reactDeliveryStatus(chatId, messageId, emoji, receivedAt)) {
9483
9881
  if (emoji == null)
9484
9882
  this.lastStatusEmoji.delete(key);
9485
- else
9883
+ else {
9486
9884
  this.lastStatusEmoji.set(key, { emoji, status });
9885
+ this.noteStamped(adapter, messageId, emoji);
9886
+ }
9487
9887
  }
9488
9888
  return;
9489
9889
  }
@@ -9507,6 +9907,8 @@ export class FleetManager {
9507
9907
  // Best effort: a failed removal must not block the new status.
9508
9908
  await adapter.unreact(target, messageId, last.emoji, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
9509
9909
  }
9910
+ // Recorded before the add: Discord can report the reaction back before react() resolves.
9911
+ this.noteStamped(adapter, messageId, emoji);
9510
9912
  await adapter.react(target, messageId, emoji, threadId);
9511
9913
  this.lastStatusEmoji.set(key, { emoji, status });
9512
9914
  }
@@ -9694,13 +10096,28 @@ export class FleetManager {
9694
10096
  wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) {
9695
10097
  return this.deliveryOutbox?.wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) ?? false;
9696
10098
  }
10099
+ /** How long opening events.db waits for another process's lock (a field so tests need not wait 5 s). */
10100
+ eventLogBusyTimeoutMs = 5000;
9697
10101
  openEventLog() {
9698
10102
  const dbPath = join(this.dataDir, "events.db");
9699
10103
  try {
9700
- return new EventLog(dbPath);
10104
+ return new EventLog(dbPath, { busyTimeoutMs: this.eventLogBusyTimeoutMs });
9701
10105
  }
9702
10106
  catch (err) {
9703
- this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
10107
+ // #1490: only a file SQLite proved corrupt is moved aside. A lock (it outlasted the busy timeout), a driver that
10108
+ // cannot load (ABI), or a permission/I/O problem says nothing against the file: it stays where it is, history
10109
+ // intact, and the fleet runs without event logging until it restarts.
10110
+ const kind = classifySqliteOpenError(err);
10111
+ if (kind !== "corrupt") {
10112
+ const key = kind === "busy" ? "eventlog.locked" : kind === "abi" ? "eventlog.abi" : "eventlog.unopenable";
10113
+ this.logger.error({ err, dbPath, kind }, `events.db not opened (${kind}) — left in place; continuing without event logging`);
10114
+ try {
10115
+ this.notifyFleetError(t(key));
10116
+ }
10117
+ catch { /* best effort: adapters may not be up yet; the log line stands */ }
10118
+ return null;
10119
+ }
10120
+ this.logger.error({ err, dbPath }, "events.db is corrupt — moving it aside and starting a fresh one");
9704
10121
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
9705
10122
  for (const suffix of ["", "-wal", "-shm"]) {
9706
10123
  try {
@@ -9709,7 +10126,7 @@ export class FleetManager {
9709
10126
  catch { /* may not exist */ }
9710
10127
  }
9711
10128
  try {
9712
- return new EventLog(dbPath);
10129
+ return new EventLog(dbPath, { busyTimeoutMs: this.eventLogBusyTimeoutMs });
9713
10130
  }
9714
10131
  catch (retryErr) {
9715
10132
  // History is worth losing; a fleet that won't start is not.
@@ -9985,9 +10402,7 @@ export class FleetManager {
9985
10402
  return;
9986
10403
  this.pendingNonceButtons.delete(nonce);
9987
10404
  this.webPromptGone(entry, entry.expiredText);
9988
- if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
9989
- 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"));
9990
- }
10405
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
9991
10406
  }, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
9992
10407
  entry.timer.unref?.();
9993
10408
  this.pendingNonceButtons.set(nonce, entry);
@@ -10011,6 +10426,8 @@ export class FleetManager {
10011
10426
  entry.threadId = sent.threadId;
10012
10427
  }
10013
10428
  entry.messageId = sent.messageId;
10429
+ if (sent.retire)
10430
+ entry.retire = sent.retire;
10014
10431
  // Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
10015
10432
  // and only if nothing claimed or expired it meanwhile.
10016
10433
  if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
@@ -10042,7 +10459,7 @@ export class FleetManager {
10042
10459
  * longer parses) collapses the clicked message so the dead button stops
10043
10460
  * inviting clicks — the same courtesy the cancel button extends.
10044
10461
  */
10045
- consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling) {
10462
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling, authorize) {
10046
10463
  if (!data.callbackData.startsWith(prefix))
10047
10464
  return null;
10048
10465
  const match = data.callbackData.match(actionRe);
@@ -10061,6 +10478,9 @@ export class FleetManager {
10061
10478
  if (staleHandling?.keepText && adapter?.removeMessageButtons) {
10062
10479
  adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
10063
10480
  }
10481
+ else if (data.editClicked) {
10482
+ data.editClicked(t("buttons.stale")).catch(collapseFailed);
10483
+ }
10064
10484
  else {
10065
10485
  adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
10066
10486
  }
@@ -10079,11 +10499,13 @@ export class FleetManager {
10079
10499
  const isAuthorized = fromWeb
10080
10500
  ? pending.web !== undefined
10081
10501
  : data.userId
10082
- ? pending.allowAnyUser
10083
- ? true
10084
- : pending.authChannelId
10085
- ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10086
- : 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)
10087
10509
  : false;
10088
10510
  const mismatchedFields = [];
10089
10511
  if (pending.adapterId !== callbackAdapterId)
@@ -10157,6 +10579,14 @@ export class FleetManager {
10157
10579
  }
10158
10580
  /** Routes a click to the handler that owns its prefix; false when none does. */
10159
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
+ }
10160
10590
  if (this.needsYou?.handleCallback(data, adapterId))
10161
10591
  return true;
10162
10592
  if (await this.handleTipDismiss(data, adapterId, adapter))
@@ -10253,6 +10683,38 @@ export class FleetManager {
10253
10683
  },
10254
10684
  });
10255
10685
  }
10686
+ cacheService = null;
10687
+ /**
10688
+ * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
10689
+ * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
10690
+ * (cache-service.ts) — never a vendor call, never a synchronous transcript read.
10691
+ */
10692
+ cacheReport(window) {
10693
+ const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
10694
+ this.cacheService ??= new CacheService({
10695
+ // fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
10696
+ instances: () => {
10697
+ const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
10698
+ name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
10699
+ }));
10700
+ const taken = new Set(fleet.map(i => i.name));
10701
+ const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
10702
+ name: ch.instanceName,
10703
+ backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
10704
+ workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
10705
+ ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
10706
+ }));
10707
+ return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
10708
+ },
10709
+ claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
10710
+ claudeKey: claudeProjectKey,
10711
+ codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
10712
+ listRollouts: root => sharedRolloutIndex(root).list(),
10713
+ metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
10714
+ log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
10715
+ });
10716
+ return this.cacheService.report(w);
10717
+ }
10256
10718
  /** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
10257
10719
  acknowledgeNeedsItem(id, principal) {
10258
10720
  if (!this.needsYou)
@@ -10395,6 +10857,11 @@ export class FleetManager {
10395
10857
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
10396
10858
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
10397
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
+ }
10398
10865
  async clickWebPrompt(instance, nonce, action) {
10399
10866
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
10400
10867
  return { status: 400, error: "Malformed prompt answer" };
@@ -10423,9 +10890,16 @@ export class FleetManager {
10423
10890
  return { status: 200 };
10424
10891
  return { status: 409, error: notice ?? "This prompt is no longer open" };
10425
10892
  }
10426
- 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) {
10427
10896
  this.webPromptGone(pending, text);
10428
10897
  try {
10898
+ const retire = editClicked ?? pending.retire;
10899
+ if (retire) {
10900
+ await retire(text);
10901
+ return;
10902
+ }
10429
10903
  if (!pending.adapter.editMessageRemoveButtons)
10430
10904
  throw new Error("adapter cannot remove prompt buttons");
10431
10905
  await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
@@ -10680,29 +11154,18 @@ export class FleetManager {
10680
11154
  });
10681
11155
  return nonce ? null : t("clear.prompt_unavailable");
10682
11156
  }
10683
- /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
10684
- async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
10685
- const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
10686
- if (claimed === null)
10687
- return false;
10688
- if (claimed === "consumed")
10689
- return true;
10690
- const { entry: pending, action } = claimed;
10691
- this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
10692
- if (action === "cancel") {
10693
- await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
10694
- return true;
10695
- }
10696
- // The nonce was claimed synchronously. Keep the exact owner across platform
10697
- // retirement awaits; a replacement with the same name is not this clear's
10698
- // target, and a role granted at claim time may be revoked while editing.
10699
- const ipc = this.instanceIpcClients.get(pending.instanceName);
10700
- const daemon = this.daemons.get(pending.instanceName);
10701
- const epoch = this.getDeliveryEpoch(pending.instanceName);
10702
- const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
11157
+ /**
11158
+ * What a confirmed /clear must still find when it acts — shared by the platform's Confirm button and the web
11159
+ * (#1269): the same daemon and IPC client, the same interaction owner, lifecycle epoch and delivery epoch as when the
11160
+ * fence was taken. Null when there is no owner to pin (then nothing may be cleared). The reads are cached and
11161
+ * synchronous; they never probe the pane.
11162
+ */
11163
+ clearTargetFence(instanceName) {
11164
+ const ipc = this.instanceIpcClients.get(instanceName);
11165
+ const daemon = this.daemons.get(instanceName);
11166
+ const epoch = this.getDeliveryEpoch(instanceName);
10703
11167
  // Object identity survives a resident daemon's respawn/freeze, and stop
10704
11168
  // invalidates its lifecycle epoch before the queued work replaces objects.
10705
- // These reads are cached and synchronous; they never probe the pane.
10706
11169
  const readOwner = () => {
10707
11170
  try {
10708
11171
  const owner = daemon?.getInteractionSnapshot?.()?.owner;
@@ -10718,7 +11181,7 @@ export class FleetManager {
10718
11181
  };
10719
11182
  const readLifecycleEpoch = () => {
10720
11183
  try {
10721
- const value = this.lifecycle?.epochOf(pending.instanceName);
11184
+ const value = this.lifecycle?.epochOf(instanceName);
10722
11185
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
10723
11186
  }
10724
11187
  catch {
@@ -10726,8 +11189,112 @@ export class FleetManager {
10726
11189
  }
10727
11190
  };
10728
11191
  const owner = readOwner(), lifecycleEpoch = readLifecycleEpoch();
11192
+ if (owner === null || lifecycleEpoch === null)
11193
+ return null;
11194
+ return () => {
11195
+ try {
11196
+ const currentOwner = readOwner();
11197
+ return this.daemons.get(instanceName) === daemon
11198
+ && this.instanceIpcClients.get(instanceName) === ipc
11199
+ && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
11200
+ && readLifecycleEpoch() === lifecycleEpoch
11201
+ && this.isDeliveryEpochCurrent(instanceName, epoch);
11202
+ }
11203
+ catch {
11204
+ return false;
11205
+ }
11206
+ };
11207
+ }
11208
+ /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
11209
+ webClearTokens = new Map();
11210
+ /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
11211
+ webCommand(input, opts) {
11212
+ const webMeta = (instance) => {
11213
+ const topicId = this.fleetConfig?.instances[instance]?.topic_id;
11214
+ return {
11215
+ chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
11216
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
11217
+ };
11218
+ };
11219
+ const choicesFromCache = (instance) => {
11220
+ const cached = this.readCliEnv(this.backendNameForInstance(instance)); // the cached catalog only: never a probe here
11221
+ if (!cached?.models.length)
11222
+ return null;
11223
+ const current = this.resolveInstanceModel(instance).model;
11224
+ return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
11225
+ };
11226
+ return runWebCommand({
11227
+ // Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
11228
+ scope: (instance) => {
11229
+ const instances = this.fleetConfig?.instances;
11230
+ if (instances && Object.prototype.hasOwnProperty.call(instances, instance))
11231
+ return isGeneralInstance(this.fleetConfig, instance) ? "general" : "fleet";
11232
+ return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
11233
+ },
11234
+ ctx: (instance) => this.topicCommands.getCtxText(instance),
11235
+ compact: (instance, instructions) => this.topicCommands.sendCompact(instance, instructions),
11236
+ applyModel: (instance, name) => this.applyModel(instance, name),
11237
+ modelChoices: choicesFromCache,
11238
+ applyEffort: (instance, level) => this.applyEffort(instance, level),
11239
+ effortChoices: (instance) => {
11240
+ const levels = this.effortLevelsFor(instance);
11241
+ if (!levels.length)
11242
+ return null;
11243
+ const current = this.resolveInstanceEffort(instance).effort;
11244
+ return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
11245
+ },
11246
+ cancel: (instance) => this.cancelInstance(instance),
11247
+ steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
11248
+ btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
11249
+ pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
11250
+ save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
11251
+ clearAsk: (instance) => {
11252
+ if (!this.topicCommands.supportsClear(instance))
11253
+ return { refused: t("clear.unsupported") };
11254
+ const fence = this.clearTargetFence(instance);
11255
+ if (!fence)
11256
+ return { refused: t("clear.not_connected") };
11257
+ const now = performance.now();
11258
+ for (const [k, v] of this.webClearTokens)
11259
+ if (v.deadline < now)
11260
+ this.webClearTokens.delete(k);
11261
+ const token = randomBytes(16).toString("hex");
11262
+ this.webClearTokens.set(token, { instance, fence, deadline: now + CLEAR_CONFIRM_TIMEOUT_MS });
11263
+ return { token, message: t("clear.confirm_message", instance) };
11264
+ },
11265
+ clearConfirm: async (instance, token) => {
11266
+ const entry = this.webClearTokens.get(token);
11267
+ this.webClearTokens.delete(token); // used once, whatever happens next
11268
+ if (!entry || entry.instance !== instance || performance.now() > entry.deadline)
11269
+ return { status: 409, text: t("clear.expired", instance) };
11270
+ this.eventLog?.insert(instance, "clear_action", { action: "confirm", userId: "web" });
11271
+ // sendClear sends its first IPC synchronously: no await separates this check from the effect.
11272
+ if (this.shuttingDown || !entry.fence())
11273
+ return { status: 409, text: t("menu.click_stale") };
11274
+ return { status: 200, text: await this.topicCommands.sendClear(instance) };
11275
+ },
11276
+ t: (key, ...args) => t(key, ...args),
11277
+ }, input, opts);
11278
+ }
11279
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
11280
+ async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
11281
+ const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
11282
+ if (claimed === null)
11283
+ return false;
11284
+ if (claimed === "consumed")
11285
+ return true;
11286
+ const { entry: pending, action } = claimed;
11287
+ this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
11288
+ if (action === "cancel") {
11289
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
11290
+ return true;
11291
+ }
11292
+ // The nonce was claimed synchronously. Keep the exact owner across platform
11293
+ // retirement awaits; a replacement with the same name is not this clear's
11294
+ // target, and a role granted at claim time may be revoked while editing.
11295
+ const target = this.clearTargetFence(pending.instanceName);
11296
+ const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
10729
11297
  const current = () => {
10730
- const currentOwner = readOwner();
10731
11298
  try {
10732
11299
  return !this.shuttingDown
10733
11300
  && !!data.userId && !!pending.authChannelId
@@ -10739,11 +11306,7 @@ export class FleetManager {
10739
11306
  ? pending.adapter.type !== "telegram" || pending.chatId === pending.authChannelId
10740
11307
  : String(this.getChannelConfig(callbackAdapterId)?.group_id ?? "") === pending.chatId)
10741
11308
  && this.getChannelConfig(callbackAdapterId)?.group_id === groupId
10742
- && this.daemons.get(pending.instanceName) === daemon
10743
- && this.instanceIpcClients.get(pending.instanceName) === ipc
10744
- && owner !== null && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
10745
- && lifecycleEpoch !== null && readLifecycleEpoch() === lifecycleEpoch
10746
- && this.isDeliveryEpochCurrent(pending.instanceName, epoch);
11309
+ && target !== null && target();
10747
11310
  }
10748
11311
  catch {
10749
11312
  return false;
@@ -10794,8 +11357,8 @@ export class FleetManager {
10794
11357
  this.webPromptGone(entry, entry.expiredText);
10795
11358
  }
10796
11359
  const collapses = entries
10797
- .filter(entry => entry.messageId && entry.adapter.editMessageRemoveButtons)
10798
- .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);
10799
11362
  if (!collapses.length)
10800
11363
  return;
10801
11364
  await Promise.race([
@@ -10814,12 +11377,17 @@ export class FleetManager {
10814
11377
  if (entry.timer)
10815
11378
  clearTimeout(entry.timer);
10816
11379
  this.webPromptGone(entry, entry.expiredText);
10817
- if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
10818
- entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
10819
- .catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
10820
- }
11380
+ this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
10821
11381
  }
10822
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
+ }
10823
11391
  /** A clean exit is intentional from the CLI's perspective, but often not from
10824
11392
  * the operator's. Keep the instance notice passive and put the action in the
10825
11393
  * same-world General topic where an administrator can make the choice. */
@@ -11802,7 +12370,7 @@ export class FleetManager {
11802
12370
  for (const name of this.configuredBackendInstanceNames()) {
11803
12371
  configured.add(this.backendNameOf(name));
11804
12372
  }
11805
- const installed = this.probeInstalledBackends();
12373
+ const installed = await this.probeInstalledBackends();
11806
12374
  // One entry point for "get this CLI working" (#1131): a backend that is
11807
12375
  // not installed is offered too, and the click installs it first, then
11808
12376
  // signs in (startLoginSession routes it).
@@ -12434,10 +13002,16 @@ export class FleetManager {
12434
13002
  attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
12435
13003
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
12436
13004
  role: typeof m.role === "string" ? m.role : undefined,
13005
+ buttons: m.buttons,
12437
13006
  });
12438
13007
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
12439
13008
  return;
12440
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
+ }
12441
13015
  broadcastSseEvent(this.sseClients, event, data, onError);
12442
13016
  }
12443
13017
  listClaimedTasks(assignee) {
@@ -14249,6 +14823,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14249
14823
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
14250
14824
  }
14251
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
+ }
14252
14831
  const backendName = this.backendNameForInstance(instanceName);
14253
14832
  let strategy = "restart";
14254
14833
  try {
@@ -14623,6 +15202,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14623
15202
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
14624
15203
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
14625
15204
  const needsStopped = this.stopNeedsYou();
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();
14626
15208
  const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
14627
15209
  this.ipcStoppingInstances.add("__fleet_stopping__");
14628
15210
  // Release held delivery promises before awaiting daemon shutdown, then
@@ -14656,6 +15238,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14656
15238
  await profileStopped;
14657
15239
  await needsStopped;
14658
15240
  await settingsControlStopped;
15241
+ await cacheStopped;
14659
15242
  await this.cpuProfileControl?.close();
14660
15243
  this.cpuProfileControl = null;
14661
15244
  await this.shutdownLoginWindows();
@@ -14788,6 +15371,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14788
15371
  // nor revives a session that was revoked while the disk was refusing writes.
14789
15372
  this.webSessions?.flush();
14790
15373
  this.eventLog?.close();
15374
+ this.replyButtonsCtl?.stop();
15375
+ this.replyButtonsStore?.close();
15376
+ this.replyButtonsCtl = null;
15377
+ this.replyButtonsStore = null;
14791
15378
  const pidPath = join(this.dataDir, "fleet.pid");
14792
15379
  try {
14793
15380
  unlinkSync(pidPath);
@@ -15015,7 +15602,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15015
15602
  // this process came up on: a Settings edit mutates this.fleetConfig in place
15016
15603
  // before the reload, so comparing the pre-load copy sees nothing at all.
15017
15604
  const newFleetLevel = this.fleetLevelSignature();
15018
- 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) {
15019
15619
  this.logger.warn({
15020
15620
  keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
15021
15621
  }, "Fleet-level config changed — restart AgEnD for it to take effect");
@@ -15349,6 +15949,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15349
15949
  listSecureConnections() {
15350
15950
  const channels = this.fleetConfig?.channels
15351
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
15352
15961
  return channels.map((channel, index) => {
15353
15962
  const id = channel.id ?? channel.type ?? `channel-${index}`;
15354
15963
  const world = this.worlds.get(id);
@@ -15357,16 +15966,44 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15357
15966
  id,
15358
15967
  type: channel.type,
15359
15968
  token_env: channel.bot_token_env,
15360
- token_present: !!process.env[channel.bot_token_env],
15969
+ token_present: !!process.env[channel.bot_token_env] || stored.has(channel.bot_token_env),
15361
15970
  group_id: channel.group_id != null ? String(channel.group_id) : null,
15362
15971
  general_channel_id: channel.options?.general_channel_id != null
15363
15972
  ? String(channel.options.general_channel_id)
15364
15973
  : null,
15365
- status: state?.status ?? (world ? "starting" : "stopped"),
15974
+ ...this.connectionStatus(id, state, world),
15366
15975
  ...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
15367
15976
  };
15368
15977
  });
15369
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
+ }
15370
16007
  secureConnectionChannel(connectionId) {
15371
16008
  const channels = this.fleetConfig?.channels
15372
16009
  ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
@@ -16155,8 +16792,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16155
16792
  throw new Error("adapter operation superseded"); };
16156
16793
  check();
16157
16794
  const old = this.adapters.get(connectionId);
16158
- if (!old && !force)
16159
- 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
+ }
16160
16804
  const primary = this.getPrimaryAdapterId() === connectionId;
16161
16805
  const previousState = this.adapterState.get(connectionId);
16162
16806
  this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
@@ -16225,6 +16869,74 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16225
16869
  * the reconcile reports as it works. A target the forecast missed (a Classic
16226
16870
  * instance inheriting a changed default) is added when it is first touched.
16227
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
+ }
16228
16940
  planConfigApply() {
16229
16941
  const rows = [];
16230
16942
  const nextConfig = this.nextFleetConfig();
@@ -16248,7 +16960,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16248
16960
  rows.push({ target: name, kind: "restart" });
16249
16961
  }
16250
16962
  if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
16251
- 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" });
16252
16965
  }
16253
16966
  return rows;
16254
16967
  }
@@ -16700,7 +17413,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16700
17413
  const backend = this.fleetConfig?.instances[name]?.backend
16701
17414
  ?? this.fleetConfig?.defaults?.backend
16702
17415
  ?? "claude-code";
16703
- const { context } = resolveInstanceContext(this.dataDir, name, backend);
17416
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
16704
17417
  return {
16705
17418
  name,
16706
17419
  status: this.getInstanceStatus(name),
@@ -17089,6 +17802,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17089
17802
  .map(ch => ch.instanceName)
17090
17803
  .filter(name => !fleetNames.includes(name));
17091
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);
17092
17809
  const instances = names.map(name => {
17093
17810
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
17094
17811
  let cost = 0;
@@ -17106,7 +17823,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17106
17823
  : (this.fleetConfig?.instances[name]?.backend
17107
17824
  ?? this.fleetConfig?.defaults?.backend
17108
17825
  ?? "claude-code");
17109
- const { context } = resolveInstanceContext(this.dataDir, name, backend);
17826
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
17110
17827
  // context_pct: null when unavailable, not 0
17111
17828
  const context_pct = context ?? null;
17112
17829
  // Model: Only Claude Code has live statusline; others use the effective resolver.
@@ -17125,13 +17842,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17125
17842
  // Only show effort if backend supports it and it's not antigravity
17126
17843
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
17127
17844
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
17128
- // Display name: fleet config → classic channel → undefined
17129
- const display_name = classic
17130
- ? this.classicChannels?.getAll().find(ch => ch.instanceName === name)?.displayName
17131
- : this.fleetConfig?.instances[name]?.display_name;
17845
+ const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
17132
17846
  return {
17133
17847
  name,
17134
- 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,
17135
17852
  status: this.getInstanceStatus(name),
17136
17853
  // `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
17137
17854
  // what the dashboard's activity events carry) come from instancePresentation (#1212).
@@ -17149,6 +17866,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17149
17866
  instances,
17150
17867
  publicLink: this.getPublicWebStatus(),
17151
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,
17152
17871
  };
17153
17872
  }
17154
17873
  }