@songsid/agend 2.2.0-beta.9 → 2.2.0

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 (226) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-endpoint.js +3 -0
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/agent-whats-new.js +7 -1
  6. package/dist/agent-whats-new.js.map +1 -1
  7. package/dist/auth-api.d.ts +3 -1
  8. package/dist/auth-api.js +15 -1
  9. package/dist/auth-api.js.map +1 -1
  10. package/dist/backend/antigravity.d.ts +33 -0
  11. package/dist/backend/antigravity.js +126 -18
  12. package/dist/backend/antigravity.js.map +1 -1
  13. package/dist/backend/claude-code.d.ts +3 -0
  14. package/dist/backend/claude-code.js +9 -0
  15. package/dist/backend/claude-code.js.map +1 -1
  16. package/dist/backend/codex-metadata.d.ts +33 -0
  17. package/dist/backend/codex-metadata.js +122 -18
  18. package/dist/backend/codex-metadata.js.map +1 -1
  19. package/dist/backend/codex.d.ts +3 -0
  20. package/dist/backend/codex.js +17 -1
  21. package/dist/backend/codex.js.map +1 -1
  22. package/dist/backend/effort-metadata.d.ts +37 -1
  23. package/dist/backend/effort-metadata.js +86 -15
  24. package/dist/backend/effort-metadata.js.map +1 -1
  25. package/dist/backend/grok.d.ts +3 -0
  26. package/dist/backend/grok.js +9 -0
  27. package/dist/backend/grok.js.map +1 -1
  28. package/dist/backend/kiro.d.ts +3 -1
  29. package/dist/backend/kiro.js +7 -3
  30. package/dist/backend/kiro.js.map +1 -1
  31. package/dist/backend/launch-effort.d.ts +61 -0
  32. package/dist/backend/launch-effort.js +170 -0
  33. package/dist/backend/launch-effort.js.map +1 -0
  34. package/dist/backend/types.d.ts +24 -0
  35. package/dist/backend/types.js +12 -1
  36. package/dist/backend/types.js.map +1 -1
  37. package/dist/backend-command.d.ts +91 -0
  38. package/dist/backend-command.js +122 -0
  39. package/dist/backend-command.js.map +1 -0
  40. package/dist/channel/adapters/discord.d.ts +12 -0
  41. package/dist/channel/adapters/discord.js +49 -2
  42. package/dist/channel/adapters/discord.js.map +1 -1
  43. package/dist/channel/adapters/telegram.d.ts +18 -0
  44. package/dist/channel/adapters/telegram.js +24 -3
  45. package/dist/channel/adapters/telegram.js.map +1 -1
  46. package/dist/channel/mcp-tools.js +2 -0
  47. package/dist/channel/mcp-tools.js.map +1 -1
  48. package/dist/channel/tool-router.d.ts +2 -0
  49. package/dist/channel/tool-router.js +1 -1
  50. package/dist/channel/tool-router.js.map +1 -1
  51. package/dist/channel/types.d.ts +16 -0
  52. package/dist/cli.js +124 -24
  53. package/dist/cli.js.map +1 -1
  54. package/dist/command-table.d.ts +2 -2
  55. package/dist/command-table.js +5 -2
  56. package/dist/command-table.js.map +1 -1
  57. package/dist/compact-watch.d.ts +86 -0
  58. package/dist/compact-watch.js +288 -0
  59. package/dist/compact-watch.js.map +1 -0
  60. package/dist/config-validator.js +39 -0
  61. package/dist/config-validator.js.map +1 -1
  62. package/dist/config.d.ts +1 -0
  63. package/dist/config.js +2 -1
  64. package/dist/config.js.map +1 -1
  65. package/dist/daemon.d.ts +9 -0
  66. package/dist/daemon.js +150 -9
  67. package/dist/daemon.js.map +1 -1
  68. package/dist/delivery-outbox.d.ts +8 -1
  69. package/dist/delivery-outbox.js +6 -2
  70. package/dist/delivery-outbox.js.map +1 -1
  71. package/dist/doctor.js +7 -0
  72. package/dist/doctor.js.map +1 -1
  73. package/dist/fleet-context.d.ts +7 -0
  74. package/dist/fleet-manager.d.ts +158 -9
  75. package/dist/fleet-manager.js +827 -118
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/forged-envelope-scan.d.ts +4 -3
  78. package/dist/forged-envelope-scan.js +4 -3
  79. package/dist/forged-envelope-scan.js.map +1 -1
  80. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  81. package/dist/install-lock.d.ts +18 -1
  82. package/dist/install-lock.js +30 -3
  83. package/dist/install-lock.js.map +1 -1
  84. package/dist/instance-avatar.d.ts +5 -4
  85. package/dist/instance-avatar.js +12 -8
  86. package/dist/instance-avatar.js.map +1 -1
  87. package/dist/instructions.js +2 -2
  88. package/dist/instructions.js.map +1 -1
  89. package/dist/kiro-compact-pane.d.ts +38 -0
  90. package/dist/kiro-compact-pane.js +57 -0
  91. package/dist/kiro-compact-pane.js.map +1 -0
  92. package/dist/launchd-handoff.d.ts +43 -0
  93. package/dist/launchd-handoff.js +56 -0
  94. package/dist/launchd-handoff.js.map +1 -0
  95. package/dist/locale.js +202 -30
  96. package/dist/locale.js.map +1 -1
  97. package/dist/log-service.js +10 -2
  98. package/dist/log-service.js.map +1 -1
  99. package/dist/login-shell.d.ts +21 -0
  100. package/dist/login-shell.js +98 -0
  101. package/dist/login-shell.js.map +1 -0
  102. package/dist/outbound-handlers.d.ts +27 -0
  103. package/dist/outbound-handlers.js +120 -36
  104. package/dist/outbound-handlers.js.map +1 -1
  105. package/dist/outbound-schemas.d.ts +12 -0
  106. package/dist/outbound-schemas.js +15 -4
  107. package/dist/outbound-schemas.js.map +1 -1
  108. package/dist/provider-probe.d.ts +29 -0
  109. package/dist/provider-probe.js +198 -2
  110. package/dist/provider-probe.js.map +1 -1
  111. package/dist/public-web-gateway.d.ts +2 -0
  112. package/dist/public-web-gateway.js +11 -1
  113. package/dist/public-web-gateway.js.map +1 -1
  114. package/dist/quickstart-api.d.ts +1 -1
  115. package/dist/quickstart-api.js +8 -6
  116. package/dist/quickstart-api.js.map +1 -1
  117. package/dist/quickstart.d.ts +12 -0
  118. package/dist/quickstart.js +36 -6
  119. package/dist/quickstart.js.map +1 -1
  120. package/dist/reply-buttons-controller.d.ts +86 -5
  121. package/dist/reply-buttons-controller.js +271 -7
  122. package/dist/reply-buttons-controller.js.map +1 -1
  123. package/dist/reply-buttons.d.ts +48 -3
  124. package/dist/reply-buttons.js +105 -10
  125. package/dist/reply-buttons.js.map +1 -1
  126. package/dist/reply-polls.d.ts +85 -0
  127. package/dist/reply-polls.js +154 -0
  128. package/dist/reply-polls.js.map +1 -0
  129. package/dist/restart-guard.d.ts +6 -3
  130. package/dist/restart-guard.js +6 -3
  131. package/dist/restart-guard.js.map +1 -1
  132. package/dist/restart-progress.d.ts +0 -2
  133. package/dist/restart-progress.js +0 -2
  134. package/dist/restart-progress.js.map +1 -1
  135. package/dist/sd-notify.d.ts +22 -1
  136. package/dist/sd-notify.js +63 -10
  137. package/dist/sd-notify.js.map +1 -1
  138. package/dist/service-activation.d.ts +15 -7
  139. package/dist/service-activation.js +42 -26
  140. package/dist/service-activation.js.map +1 -1
  141. package/dist/service-installer.d.ts +24 -0
  142. package/dist/service-installer.js +49 -1
  143. package/dist/service-installer.js.map +1 -1
  144. package/dist/service-plan.d.ts +11 -0
  145. package/dist/service-plan.js +23 -0
  146. package/dist/service-plan.js.map +1 -1
  147. package/dist/systemd-peer-bus.d.ts +95 -0
  148. package/dist/systemd-peer-bus.js +430 -0
  149. package/dist/systemd-peer-bus.js.map +1 -0
  150. package/dist/systemd-runtime.d.ts +27 -1
  151. package/dist/systemd-runtime.js +69 -7
  152. package/dist/systemd-runtime.js.map +1 -1
  153. package/dist/tool-permissions.js +3 -3
  154. package/dist/tool-permissions.js.map +1 -1
  155. package/dist/topic-commands.d.ts +68 -2
  156. package/dist/topic-commands.js +141 -27
  157. package/dist/topic-commands.js.map +1 -1
  158. package/dist/transcript-jsonl.d.ts +7 -2
  159. package/dist/transcript-jsonl.js +13 -7
  160. package/dist/transcript-jsonl.js.map +1 -1
  161. package/dist/transcript-sources.js +6 -1
  162. package/dist/transcript-sources.js.map +1 -1
  163. package/dist/types.d.ts +8 -0
  164. package/dist/ui/chat-commands.js +1 -0
  165. package/dist/ui/chat-render.js +55 -10
  166. package/dist/ui/chat-store.js +69 -5
  167. package/dist/ui/chat-strings.js +28 -16
  168. package/dist/ui/chat-thread.js +132 -14
  169. package/dist/ui/chat-tour.js +17 -14
  170. package/dist/ui/panel-chat.js +16 -10
  171. package/dist/ui/panel-fleet.js +15 -2
  172. package/dist/ui/panel-log.js +4 -4
  173. package/dist/ui/panel-needs.js +10 -7
  174. package/dist/ui/panel-settings.js +58 -7
  175. package/dist/ui/settings-dialogs.js +11 -6
  176. package/dist/ui/settings-model.js +3 -1
  177. package/dist/ui/settings-strings.js +24 -10
  178. package/dist/ui/settings-token.js +7 -0
  179. package/dist/ui/settings-wizard.js +5 -3
  180. package/dist/ui/shared/app-i18n.js +1 -1
  181. package/dist/ui/shared/app-stream.js +4 -1
  182. package/dist/ui/shared/app.css +52 -10
  183. package/dist/ui/shared/header-tools.js +34 -14
  184. package/dist/ui/shared/instance-switch.js +5 -2
  185. package/dist/ui/shared/panel-view.js +15 -4
  186. package/dist/ui/shared/signin.js +15 -9
  187. package/dist/ui/shared/ui-icons.js +1 -0
  188. package/dist/ui/shared/view-strings.js +10 -2
  189. package/dist/ui/signin.html +8 -3
  190. package/dist/update-check.d.ts +31 -0
  191. package/dist/update-check.js +47 -0
  192. package/dist/update-check.js.map +1 -1
  193. package/dist/update-launch.d.ts +48 -1
  194. package/dist/update-launch.js +159 -5
  195. package/dist/update-launch.js.map +1 -1
  196. package/dist/usage/providers.d.ts +28 -8
  197. package/dist/usage/providers.js +96 -41
  198. package/dist/usage/providers.js.map +1 -1
  199. package/dist/view-api.js +26 -9
  200. package/dist/view-api.js.map +1 -1
  201. package/dist/web-api.d.ts +44 -2
  202. package/dist/web-api.js +265 -57
  203. package/dist/web-api.js.map +1 -1
  204. package/dist/web-chat-history.d.ts +26 -1
  205. package/dist/web-chat-history.js +47 -8
  206. package/dist/web-chat-history.js.map +1 -1
  207. package/dist/web-commands.d.ts +39 -3
  208. package/dist/web-commands.js +22 -5
  209. package/dist/web-commands.js.map +1 -1
  210. package/dist/web-login.d.ts +14 -0
  211. package/dist/web-login.js +4 -2
  212. package/dist/web-login.js.map +1 -1
  213. package/dist/web-prefs.d.ts +18 -0
  214. package/dist/web-prefs.js +36 -4
  215. package/dist/web-prefs.js.map +1 -1
  216. package/dist/web-profile.d.ts +32 -0
  217. package/dist/web-profile.js +132 -0
  218. package/dist/web-profile.js.map +1 -0
  219. package/dist/web-session.d.ts +14 -0
  220. package/dist/web-session.js +10 -0
  221. package/dist/web-session.js.map +1 -1
  222. package/dist/web-upload.d.ts +119 -4
  223. package/dist/web-upload.js +252 -39
  224. package/dist/web-upload.js.map +1 -1
  225. package/launcher/install-admission.cjs +61 -6
  226. package/package.json +3 -3
@@ -7,7 +7,7 @@ import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileRes
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
9
9
  import { gatewayRequestContext } from "./web-request-context.js";
10
- import { createPublicWebGateway } from "./public-web-gateway.js";
10
+ import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } from "./public-web-gateway.js";
11
11
  import { handleWebIconRequest } from "./web-icons.js";
12
12
  import { trackWebServer } from "./web-server-drain.js";
13
13
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
@@ -20,10 +20,9 @@ import { withinBudget } from "./monotonic-budget.js";
20
20
  import { measureSyncWork } from "./sync-work-attribution.js";
21
21
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
22
22
  import { ProfileControlServer } from "./profile-control.js";
23
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
23
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
24
24
  import { atomicWriteFileSync } from "./atomic-write.js";
25
25
  import { createHash, randomBytes, randomUUID } from "node:crypto";
26
- import { spawnSync } from "node:child_process";
27
26
  import { freemem, totalmem, cpus, homedir } from "node:os";
28
27
  import { createServer } from "node:http";
29
28
  import { Worker } from "node:worker_threads";
@@ -34,7 +33,7 @@ import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
34
33
  import { fleetLabel } from "./fleet-label.js";
35
34
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
36
35
  import { formatUpdateProgress } from "./update-progress.js";
37
- import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
36
+ import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
38
37
  import { readFleetMemory } from "./process-memory.js";
39
38
  import { MemoryPressure } from "./memory-pressure.js";
40
39
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
@@ -46,7 +45,7 @@ const __dirname = dirname(__filename);
46
45
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
47
46
  import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
48
47
  import { isProbeableRouteTarget } from "./fleet-context.js";
49
- import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
48
+ import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
50
49
  import { EventLog } from "./event-log.js";
51
50
  import { binaryProbe } from "./binary-probe.js";
52
51
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
@@ -64,7 +63,9 @@ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
64
63
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
65
64
  import { createBackend } from "./backend/factory.js";
66
65
  import { readEffortMetadata } from "./backend/effort-metadata.js";
67
- import { CLI_ENV_TTL_MS, isModelCompatible, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
66
+ import { codexEffort, flagEffort } from "./backend/launch-effort.js";
67
+ import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
68
+ import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
68
69
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
69
70
  import { processAttachments } from "./channel/attachment-handler.js";
70
71
  import { routeToolCall } from "./channel/tool-router.js";
@@ -80,7 +81,8 @@ import { RoutingEngine } from "./routing-engine.js";
80
81
  import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
81
82
  import { TopicArchiver } from "./topic-archiver.js";
82
83
  import { StatuslineWatcher } from "./statusline-watcher.js";
83
- import { outboundHandlers } from "./outbound-handlers.js";
84
+ import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
85
+ import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
84
86
  import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
85
87
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
86
88
  // deciding to defer before submission. This is an alert threshold only: the
@@ -138,9 +140,11 @@ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } fr
138
140
  import { WebChatDiskStore } from "./web-chat-store.js";
139
141
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
140
142
  import { WebPrefsStore } from "./web-prefs.js";
143
+ import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
141
144
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
142
145
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
143
- import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
146
+ import { parsePoll } from "./reply-polls.js";
147
+ import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
144
148
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
145
149
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
146
150
  import { downloadStickerImage } from "./channel/sticker-download.js";
@@ -153,13 +157,14 @@ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permis
153
157
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
154
158
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
155
159
  import { LoginSession } from "./login-manager.js";
160
+ import { runOnLoginShell } from "./login-shell.js";
156
161
  import { tightenInstanceDirs } from "./private-dir.js";
157
162
  import { decideSlash } from "./slash-authz.js";
158
163
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
159
164
  import { runVisibilityCommand } from "./cross-instance-notice.js";
160
- import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
165
+ import { installedChannel, updateNoticeKey } from "./update-check.js";
161
166
  import { resolveInstalledAgend } from "./update-dispatch.js";
162
- import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
167
+ import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
163
168
  import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
164
169
  import { runBeforeDeadline } from "./deadline.js";
165
170
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -170,10 +175,10 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
170
175
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
171
176
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
172
177
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
173
- import { validateFleetConfig } from "./config-validator.js";
178
+ import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
174
179
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
175
180
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
176
- import { readLastInboundAt } from "./daemon.js";
181
+ import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
177
182
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
178
183
  import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
179
184
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
@@ -184,11 +189,13 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
184
189
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
185
190
  import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
186
191
  import { NeedsYouHub } from "./needs-you-hub.js";
187
- import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
192
+ import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
188
193
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
189
194
  import { buildOrgChart } from "./web-org.js";
190
195
  import { CacheService, WINDOWS } from "./cache-service.js";
191
196
  import { runWebCommand } from "./web-commands.js";
197
+ import { CompactWatch, launchIdentity } from "./compact-watch.js";
198
+ import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
192
199
  import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
193
200
  import { sharedRolloutIndex } from "./rollout-index.js";
194
201
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
@@ -353,6 +360,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
353
360
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
354
361
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
355
362
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
363
+ const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
356
364
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
357
365
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
358
366
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
@@ -376,6 +384,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
376
384
  const WEB_ONLY_REPLY_SINK = {
377
385
  type: "web",
378
386
  supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
387
+ supportsPolls: true, // #1823: …and a poll (its pages are told; there is nothing to edit)
379
388
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
380
389
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
381
390
  };
@@ -672,6 +681,8 @@ export class FleetManager {
672
681
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
673
682
  pendingEffortSelects = new Map();
674
683
  pendingModelSelects = new Map();
684
+ /** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
685
+ pendingBackendSelects = new Map();
675
686
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
676
687
  pendingNonceButtons = new Map();
677
688
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
@@ -753,7 +764,6 @@ export class FleetManager {
753
764
  updateProgressTimer = null;
754
765
  updateProgressEditRunning = false;
755
766
  lastUpdateProgressText = null;
756
- updateCompletionTipText = null;
757
767
  /**
758
768
  * #1660: an adopted update whose retire outcome is still owed. The
759
769
  * completion summary was (or will be) posted; this watch appends the
@@ -792,7 +802,8 @@ export class FleetManager {
792
802
  /** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
793
803
  webChatStore = null;
794
804
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
795
- webFiles = new WebFileLedger();
805
+ // #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
806
+ webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
796
807
  /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
797
808
  webReplyOrder = new Map();
798
809
  /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
@@ -801,6 +812,78 @@ export class FleetManager {
801
812
  seenUsers = new SeenUsers();
802
813
  webPrefsStore;
803
814
  /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
815
+ /**
816
+ * #1827: a web person as agents and chats see them. Signed in as an account (a sign-in code DMed to it): that
817
+ * account's user id — still `via web`, and only for recognition — and the name they chose, else their account's name
818
+ * at sign-in (made safe). Otherwise (an `agend web` session): "web-user", as before, under a name the operator may set.
819
+ * Names are checked again now: one an instance or bot has taken since reads as the next choice down.
820
+ */
821
+ webPerson(owner) {
822
+ const key = webPersonKey(owner);
823
+ let profile = null;
824
+ try {
825
+ profile = this.webPrefs.profile(key);
826
+ }
827
+ catch (err) {
828
+ this.logger.warn({ err: err.message }, "Web profile unavailable");
829
+ }
830
+ const reserved = (n) => this.webNameReserved(n);
831
+ const own = profile?.name !== undefined ? webNameInUse(profile.name, reserved) : WEB_NAME_FALLBACK;
832
+ const chosen = own !== WEB_NAME_FALLBACK;
833
+ const platformName = owner ? safeWebName(profile?.platformName, reserved) : undefined;
834
+ return {
835
+ key, bound: !!owner,
836
+ userId: owner ? owner.userId : WEB_NAME_FALLBACK,
837
+ // A poll ballot is the account's — the same key its votes on the platform have — or the operator's.
838
+ voterKey: owner ? `${owner.adapterId}:${owner.userId}` : "web:operator",
839
+ name: chosen ? own : platformName ?? WEB_NAME_FALLBACK,
840
+ set: chosen,
841
+ ...(platformName !== undefined ? { platformName } : {}),
842
+ };
843
+ }
844
+ /** #1827: set (null: clear — back to their account's name) a web person's own name; validated in full. */
845
+ setWebPersonName(owner, raw) {
846
+ let name = null;
847
+ if (raw !== null) {
848
+ const v = validateWebName(raw, (n) => this.webNameReserved(n));
849
+ if (!v.ok)
850
+ return { ok: false, status: 400, error: v.error };
851
+ name = v.name;
852
+ }
853
+ const w = this.webPrefs.setProfileName(webPersonKey(owner), name);
854
+ return w.ok ? { ok: true, person: this.webPerson(owner) } : { ok: false, status: w.status, error: w.error };
855
+ }
856
+ /** #1827 (auth-api): a sign-in as this account — its display name then is the web name it starts with. */
857
+ noteWebSignIn(account) {
858
+ if (account.username)
859
+ this.webPrefs.setPlatformName(webPersonKey(account), account.username);
860
+ }
861
+ /**
862
+ * #1827: a name that belongs to something here — an instance (or ClassicBot room), its display name, a bot. On the web
863
+ * send path: it never throws (a lookup that fails reserves nothing; the name was checked in full when it was set).
864
+ */
865
+ webNameReserved(lowered) {
866
+ try {
867
+ return this.webNameTaken(lowered);
868
+ }
869
+ catch {
870
+ return false;
871
+ }
872
+ }
873
+ webNameTaken(lowered) {
874
+ const low = (s) => (typeof s === "string" ? s.normalize("NFC").toLocaleLowerCase("en-US") : "");
875
+ for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
876
+ if (low(name) === lowered || low(cfg.display_name) === lowered)
877
+ return true;
878
+ }
879
+ for (const ch of this.classicChannels?.getAll() ?? [])
880
+ if (low(ch.instanceName) === lowered || low(ch.displayName) === lowered)
881
+ return true;
882
+ for (const w of this.worlds.values())
883
+ if (w.botUsername && low(w.botUsername) === lowered)
884
+ return true;
885
+ return false;
886
+ }
804
887
  get webPrefs() {
805
888
  // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
806
889
  return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
@@ -1149,6 +1232,8 @@ export class FleetManager {
1149
1232
  mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
1150
1233
  deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
1151
1234
  publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
1235
+ admitResults: (set, key, text) => this.admitPollResults(set, key, text),
1236
+ monotonic: () => performance.now(),
1152
1237
  logger: this.logger,
1153
1238
  setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
1154
1239
  clearTimer: (h) => clearTimeout(h),
@@ -1157,6 +1242,21 @@ export class FleetManager {
1157
1242
  return this.replyButtonsCtl;
1158
1243
  }
1159
1244
  replyButtonsStore = null;
1245
+ /**
1246
+ * #1823: a closed poll's results go to its instance through the delivery outbox, as a system notice keyed by the
1247
+ * poll: durable (held until the instance can take it) and admitted once, however often this is asked. Addressed to
1248
+ * the poll's chat, so the instance's answer (the right option, the scores) goes there.
1249
+ */
1250
+ admitPollResults(set, key, text) {
1251
+ this.ensureDeliveryOutbox();
1252
+ const outbox = this.deliveryOutbox;
1253
+ if (!outbox)
1254
+ return false;
1255
+ outbox.admitSystemNotice(set.instance, key, text, new Date(), {
1256
+ chatId: set.chatId, threadId: set.threadId || undefined, adapterId: set.adapterId === "web" ? undefined : set.adapterId,
1257
+ });
1258
+ return true;
1259
+ }
1160
1260
  /**
1161
1261
  * #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
1162
1262
  * room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
@@ -1220,8 +1320,10 @@ export class FleetManager {
1220
1320
  return false;
1221
1321
  reactions.consume();
1222
1322
  this.afterUserInboundDelivered(set.instance, inbound, deliveryEpoch);
1323
+ // The web chat shows the click as its button's label (a chip); `text` stays what the agent was given.
1223
1324
  this.emitSseEvent("message", {
1224
1325
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1326
+ click: { label: button.label, of: set.id },
1225
1327
  });
1226
1328
  return true;
1227
1329
  }
@@ -1564,6 +1666,7 @@ export class FleetManager {
1564
1666
  content: payload.content,
1565
1667
  delivery_id: claimed.deliveryId,
1566
1668
  delivery_attempt: String(claimed.attemptNo),
1669
+ ...(isSilentScheduleDelivery(claimed) ? { silent_schedule: true } : {}),
1567
1670
  }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
1568
1671
  }
1569
1672
  else {
@@ -2203,7 +2306,7 @@ export class FleetManager {
2203
2306
  async handleUpdateSlash(data, adapterId) {
2204
2307
  const gate = this.fleetAdminGate(data.userId, adapterId);
2205
2308
  if (gate !== "ok") {
2206
- await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
2309
+ await data.respond(gate === "disabled" ? t("update.disabled", this.adminListKey(adapterId)) : t("not_authorized"));
2207
2310
  return;
2208
2311
  }
2209
2312
  const messageId = await data.respond(t("update.progress.preparing", 0));
@@ -2218,24 +2321,29 @@ export class FleetManager {
2218
2321
  // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
2219
2322
  const installed = await resolveInstalledAgend();
2220
2323
  if (!installed.ok) {
2221
- this.failUpdateProgress(`/update cannot verify the installed AgEnD (${installed.reason}). Run \`agend update\` from a shell.`);
2324
+ this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.installed", installed.reason)}`);
2222
2325
  return;
2223
2326
  }
2224
2327
  const origin = `slash /update by ${adapterId}:${data.userId}`;
2225
2328
  recordInternalRequest(this.dataDir, "update", origin);
2226
2329
  const launch = await resolveUpdateLaunch(installed.agend);
2227
2330
  if (!launch.ok) {
2228
- this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
2331
+ this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.independent", launch.reason)}`);
2229
2332
  return;
2230
2333
  }
2231
2334
  if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
2232
2335
  this.failUpdateProgress(t("not_authorized"));
2233
2336
  return;
2234
2337
  }
2338
+ const logPath = updateLogPath(this.dataDir);
2339
+ const ts = new Date().toISOString();
2340
+ const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
2235
2341
  const child = spawn(launch.command, launch.args, {
2236
- detached: true, stdio: "ignore", env: withOrigin(origin),
2342
+ detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
2237
2343
  });
2238
- watchUpdateLaunch(child, message => this.failUpdateProgress(message));
2344
+ if (logFd)
2345
+ closeSync(logFd.fd); // parent closes its copy; the child's fd stays open
2346
+ watchUpdateLaunch(child, message => this.failUpdateProgress(message), logFd ? logPath : undefined, logFd?.offset, installed.agend);
2239
2347
  child.unref();
2240
2348
  }
2241
2349
  async handleProfileSlash(data, adapterId) {
@@ -2708,6 +2816,32 @@ export class FleetManager {
2708
2816
  emit: (event, data) => this.emitSseEvent(event, data),
2709
2817
  });
2710
2818
  }
2819
+ /** Board 003648ac: a web /compact followed to its end, from the transcript the Log reads (none: nothing to follow). */
2820
+ compactWatchService = null;
2821
+ get compactWatch() {
2822
+ return this.compactWatchService ??= new CompactWatch({
2823
+ source: (name) => { const src = this.logSource(name); return { path: src.path, format: src.format }; },
2824
+ // The launch that runs it now (the same value the daemon sends with raw_paste_written).
2825
+ owner: (name) => {
2826
+ const daemon = this.lifecycle.daemons.get(name);
2827
+ try {
2828
+ return daemon ? launchIdentity(daemon.getInteractionSnapshot().owner) : null;
2829
+ }
2830
+ catch {
2831
+ return null;
2832
+ }
2833
+ },
2834
+ paneFollow: (name) => {
2835
+ try {
2836
+ return this.lifecycle.daemons.get(name)?.compactPaneUi() != null;
2837
+ }
2838
+ catch {
2839
+ return false;
2840
+ }
2841
+ },
2842
+ changed: () => this.emitSseEvent("status", this.getUiStatus()),
2843
+ });
2844
+ }
2711
2845
  /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
2712
2846
  logSteps(name, q) { return this.log.steps(name, q); }
2713
2847
  /**
@@ -3117,7 +3251,8 @@ export class FleetManager {
3117
3251
  * or a rebound adapter is never posted through a stale route. A copy
3118
3252
  * already in flight may still land late — the contract permits that.
3119
3253
  */
3120
- async sendClassicWebEcho(instance, text) {
3254
+ /** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
3255
+ async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
3121
3256
  const guard = this.webChannelEchoGuards.get(instance);
3122
3257
  const handled = new Set();
3123
3258
  let posted = 0;
@@ -3148,7 +3283,7 @@ export class FleetManager {
3148
3283
  if (!adapter?.sendText)
3149
3284
  continue;
3150
3285
  await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
3151
- ClassicChannelManager.logMessage(instance, "web-user", text, new Date());
3286
+ ClassicChannelManager.logMessage(instance, sender, text, new Date());
3152
3287
  posted++;
3153
3288
  }
3154
3289
  catch (err) {
@@ -3475,6 +3610,20 @@ export class FleetManager {
3475
3610
  hasFleetAdmins(adapterId) {
3476
3611
  return (this.adminListOf(adapterId)?.length ?? 0) > 0;
3477
3612
  }
3613
+ /**
3614
+ * Where this adapter's fleet-admin list is in fleet.yaml, as the operator wrote it, for the "disabled" replies:
3615
+ * `channel.access.allowed_users` (the single-channel form), else the `channels:` entry — by its `id:`, or its
3616
+ * `type:` when it has none. Read from the raw file: loading turns `channel:` into `channels` and fills in ids.
3617
+ */
3618
+ adminListKey(adapterId) {
3619
+ const raw = this.rawFleetConfig;
3620
+ if (!Array.isArray(raw.channels) && raw.channel)
3621
+ return "channel.access.allowed_users";
3622
+ const channels = Array.isArray(raw.channels) ? raw.channels : this.fleetConfig?.channels ?? [];
3623
+ const id = adapterId ?? this.getPrimaryAdapterId();
3624
+ const entry = channels.find(ch => (ch.id ?? ch.type) === id);
3625
+ return `channels[${entry?.id ? `id: ${entry.id}` : `type: ${entry?.type ?? id}`}].access.allowed_users`;
3626
+ }
3478
3627
  /**
3479
3628
  * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
3480
3629
  * through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
@@ -3552,6 +3701,9 @@ export class FleetManager {
3552
3701
  if (!decision.allow) {
3553
3702
  this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
3554
3703
  const [key, ...args] = decision.reply;
3704
+ // A command off for want of fleet admins names where they go (`{0}` in its disabled reply).
3705
+ if (decision.reply === spec.disabled)
3706
+ args.push(this.adminListKey(adapterId));
3555
3707
  await data.respond(t(key, ...args)).catch(() => { });
3556
3708
  return;
3557
3709
  }
@@ -3611,6 +3763,9 @@ export class FleetManager {
3611
3763
  else if (data.command === "model") {
3612
3764
  await this.handleModelSlash(data, adapterId);
3613
3765
  }
3766
+ else if (data.command === "backend") {
3767
+ await this.handleBackendSlash(data, adapterId);
3768
+ }
3614
3769
  else if (data.command === "clear") {
3615
3770
  await this.handleClearSlash(data, adapterId);
3616
3771
  }
@@ -3704,7 +3859,7 @@ export class FleetManager {
3704
3859
  await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
3705
3860
  }
3706
3861
  else {
3707
- const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
3862
+ const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
3708
3863
  if (!owner) {
3709
3864
  await this.sendLocalDashboard(data, adapterId);
3710
3865
  return;
@@ -5026,7 +5181,7 @@ export class FleetManager {
5026
5181
  && cfg.topic_id?.toString() === owner.threadId);
5027
5182
  }
5028
5183
  /** Exact owning General. No primary-adapter or name fallback participates in authorization. */
5029
- dashboardOwner(userId, adapterId, address, threadId) {
5184
+ dashboardOwner(userId, adapterId, address, threadId, username) {
5030
5185
  const world = this.worlds.get(adapterId);
5031
5186
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5032
5187
  return null;
@@ -5041,7 +5196,7 @@ export class FleetManager {
5041
5196
  const matched = dc ? topic === address || (group === address && topic === threadId)
5042
5197
  : group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
5043
5198
  if (group && matched)
5044
- return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
5199
+ return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
5045
5200
  }
5046
5201
  return null;
5047
5202
  }
@@ -5050,7 +5205,7 @@ export class FleetManager {
5050
5205
  * — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
5051
5206
  * no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
5052
5207
  */
5053
- dmDashboardOwner(userId, adapterId) {
5208
+ dmDashboardOwner(userId, adapterId, username) {
5054
5209
  const world = this.worlds.get(adapterId);
5055
5210
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5056
5211
  return null;
@@ -5064,7 +5219,7 @@ export class FleetManager {
5064
5219
  .map(([, cfg]) => cfg.topic_id.toString());
5065
5220
  if (topics.length !== 1)
5066
5221
  return null;
5067
- const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
5222
+ const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter, ...(username ? { username } : {}) };
5068
5223
  return this.publicOwnerCurrent(owner) ? owner : null;
5069
5224
  }
5070
5225
  /**
@@ -5091,7 +5246,7 @@ export class FleetManager {
5091
5246
  async startWebInDm(msg, adapter) {
5092
5247
  if (!adapter)
5093
5248
  return;
5094
- const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
5249
+ const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "", msg.username) : null;
5095
5250
  if (!owner) {
5096
5251
  await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
5097
5252
  return;
@@ -5144,7 +5299,7 @@ export class FleetManager {
5144
5299
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
5145
5300
  async dashboardMenu(msg) {
5146
5301
  const id = msg.adapterId;
5147
- const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
5302
+ const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
5148
5303
  if (!owner) {
5149
5304
  const adapter = id ? this.adapters.get(id) : undefined;
5150
5305
  await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
@@ -5177,7 +5332,9 @@ export class FleetManager {
5177
5332
  return;
5178
5333
  }
5179
5334
  this.initializeWebSessions();
5180
- const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token) });
5335
+ // #1827: the code goes privately to this account (a DM, or else this slash command's ephemeral answer): whoever
5336
+ // redeems it is recognised as that account. Recognition only — no owner (this is not an owning General's code).
5337
+ const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token), recipient: { adapterId, userId: data.userId, ...(data.username ? { username: data.username } : {}) } });
5181
5338
  const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
5182
5339
  && this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
5183
5340
  const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
@@ -5289,13 +5446,12 @@ export class FleetManager {
5289
5446
  const adapter = owner.binding;
5290
5447
  const status = this.getPublicWebStatus();
5291
5448
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
5292
- const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
5293
- const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
5294
- + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
5295
- + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5296
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
5297
- + (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
5298
- const choices = [{ action: "local", label: t("dashboard.local") },
5449
+ // Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
5450
+ const menuText = this.topicCommands.getDashboardMenuText()
5451
+ + (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
5452
+ + (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5453
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "") : "");
5454
+ const choices = [{ action: "local", label: t("dashboard.local_signin") },
5299
5455
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
5300
5456
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
5301
5457
  const posted = await this.postNonceButtonPrompt({
@@ -5371,9 +5527,11 @@ export class FleetManager {
5371
5527
  return false;
5372
5528
  this.initializeWebSessions();
5373
5529
  const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
5374
- // #1586: the expiry in the fleet's zone with its offset, never a bare UTC ISO string.
5375
- const text = t("dashboard.private_link", url, issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), expiresAt ? formatFleetTime(expiresAt) : t("dashboard.local"))
5376
- + "\n" + t("dashboard.code_next");
5530
+ // Only a public link has a link expiry; the code's five-minute lifetime applies to either packet.
5531
+ const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
5532
+ const text = expiresAt !== undefined
5533
+ ? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
5534
+ : t("dashboard.private_local", url, issued.display, minutes);
5377
5535
  const deadline = performance.now() + 10_000;
5378
5536
  const direct = owner.binding;
5379
5537
  // The route the sign-in message took: the code-only follow-up takes the same one (#1586).
@@ -5562,7 +5720,6 @@ export class FleetManager {
5562
5720
  messageId,
5563
5721
  });
5564
5722
  this.lastUpdateProgressText = null;
5565
- this.updateCompletionTipText = null;
5566
5723
  this.startUpdateProgressMonitor(adapter);
5567
5724
  }
5568
5725
  /** Persist the public response, wait for idle, then start the canonical service restart. */
@@ -5593,7 +5750,6 @@ export class FleetManager {
5593
5750
  return false;
5594
5751
  }
5595
5752
  this.lastUpdateProgressText = null;
5596
- this.updateCompletionTipText = null;
5597
5753
  this.startUpdateProgressMonitor(adapter);
5598
5754
  await this.waitForFullRestartIdleGrace();
5599
5755
  // An update can begin while the idle wait yields. Never launch a second
@@ -5833,17 +5989,8 @@ export class FleetManager {
5833
5989
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
5834
5990
  if (!adapter)
5835
5991
  return;
5836
- let text = formatUpdateProgress(marker);
5837
- // No-restart updates complete in the old fleet process, so they never
5838
- // reach RestartProgress.finish(). Append the same optional tip here.
5839
- if (marker.progress.stage === "complete" && this.tipsEnabled()) {
5840
- if (this.updateCompletionTipText === null) {
5841
- const tip = this.pickAvailableTip();
5842
- this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
5843
- }
5844
- if (this.updateCompletionTipText)
5845
- text += `\n\n${this.updateCompletionTipText}`;
5846
- }
5992
+ // #1854: no tip on an update's completion (either path); tips come on their own schedule and from /tips.
5993
+ const text = formatUpdateProgress(marker);
5847
5994
  if (text === this.lastUpdateProgressText)
5848
5995
  return;
5849
5996
  this.updateProgressEditRunning = true;
@@ -5894,6 +6041,7 @@ export class FleetManager {
5894
6041
  this.rotateFleetLogs();
5895
6042
  const fleet = this.loadConfig(configPath);
5896
6043
  this.slimFleetConfigAtStartup();
6044
+ this.logConfigWarningsAtStartup();
5897
6045
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
5898
6046
  // #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
5899
6047
  const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
@@ -6229,6 +6377,7 @@ export class FleetManager {
6229
6377
  // into the same broken state, and a user who has legitimately stopped every
6230
6378
  // instance would get a restart loop. Those conditions surface through /health
6231
6379
  // (which now returns 503) and through the General-topic notifications instead.
6380
+ setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
6232
6381
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
6233
6382
  // #1231: a stalled loop is what turns a slash command into "did not respond";
6234
6383
  // put each long stall in the log instead of leaving it to be guessed at.
@@ -6347,12 +6496,6 @@ export class FleetManager {
6347
6496
  pausedNames,
6348
6497
  failedNames,
6349
6498
  backgroundWorkEndedNames,
6350
- tipText: pendingProgressOperation === "update" && this.tipsEnabled()
6351
- ? (() => {
6352
- const tip = this.pickAvailableTip();
6353
- return tip ? this.formatTip(tip) : undefined;
6354
- })()
6355
- : undefined,
6356
6499
  ...(cleanupOutcome && "render" in cleanupOutcome
6357
6500
  ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
6358
6501
  : {}),
@@ -6403,6 +6546,7 @@ export class FleetManager {
6403
6546
  // Health HTTP endpoint
6404
6547
  this.startHealthServer(fleet.health_port ?? 19280);
6405
6548
  // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
6549
+ // #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
6406
6550
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
6407
6551
  // Daily update check — first check after 1 hour, then every 24 hours
6408
6552
  this.updateCheckTimer = setTimeout(() => {
@@ -7144,6 +7288,16 @@ export class FleetManager {
7144
7288
  });
7145
7289
  }
7146
7290
  }
7291
+ else if (msg.type === "raw_paste_written") {
7292
+ // Board 003648ac: a web /compact's paste reached the pane (or never will): its watch starts, or ends as lost.
7293
+ if (typeof msg.id === "string")
7294
+ this.compactWatchService?.pasted(name, msg.id, msg.outcome === "written", { owner: msg.owner, checkpoint: msg.checkpoint, pane: msg.pane });
7295
+ }
7296
+ else if (msg.type === "compact_pane_result") {
7297
+ // Board 907c7899: kiro's pane said how that /compact ended.
7298
+ if (typeof msg.id === "string")
7299
+ this.compactWatchService?.paneResult(name, msg.id, msg.owner, msg.outcome);
7300
+ }
7147
7301
  else if (msg.type === "runtime_switch_result") {
7148
7302
  // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
7149
7303
  const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
@@ -7938,6 +8092,21 @@ export class FleetManager {
7938
8092
  }
7939
8093
  return;
7940
8094
  }
8095
+ // #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
8096
+ if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
8097
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
8098
+ await msgAdapter?.sendText(chatId, t("permission.denied"));
8099
+ return;
8100
+ }
8101
+ const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
8102
+ if (!backendInstance) {
8103
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
8104
+ return;
8105
+ }
8106
+ if (msgAdapter)
8107
+ await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
8108
+ return;
8109
+ }
7941
8110
  // Handle /compact command (admin only)
7942
8111
  const classicCompact = parseCompactCommand(text);
7943
8112
  if (classicCompact) {
@@ -8337,7 +8506,7 @@ export class FleetManager {
8337
8506
  if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
8338
8507
  continue;
8339
8508
  try {
8340
- await world.adapter.sendText(target.chatId, t("upgrade.web_chat", WEB_REMOTE_DOCS_URL), target.opts);
8509
+ await world.adapter.sendText(target.chatId, t("upgrade.web_chat"), target.opts);
8341
8510
  this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
8342
8511
  }
8343
8512
  catch (err) {
@@ -8500,6 +8669,17 @@ export class FleetManager {
8500
8669
  respond(null, `reply: ${parsedButtons.error}`);
8501
8670
  return;
8502
8671
  }
8672
+ // #1823: a poll is checked with its buttons. Where it cannot be shown it is refused, never sent as text: text
8673
+ // options would never come back as results.
8674
+ const parsedPoll = parsePoll(args.poll, parsedButtons && "buttons" in parsedButtons ? parsedButtons.buttons : null, args.text);
8675
+ if (parsedPoll && "error" in parsedPoll) {
8676
+ respond(null, `reply: ${parsedPoll.error}`);
8677
+ return;
8678
+ }
8679
+ if (parsedPoll && (!outAdapter.supportsPolls || !this.replyButtons())) {
8680
+ respond(null, `reply: a poll cannot be shown here${outAdapter.supportsPolls ? " (reply buttons are unavailable until AgEnD restarts)" : " — polls work in Discord and Telegram chats and the web chat"}; send the question without poll`);
8681
+ return;
8682
+ }
8503
8683
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
8504
8684
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
8505
8685
  if (stickerProblem) {
@@ -8519,7 +8699,7 @@ export class FleetManager {
8519
8699
  const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
8520
8700
  if (buttons) {
8521
8701
  const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
8522
- replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
8702
+ replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons, parsedPoll && "poll" in parsedPoll ? parsedPoll.poll : null);
8523
8703
  }
8524
8704
  else {
8525
8705
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
@@ -8539,6 +8719,9 @@ export class FleetManager {
8539
8719
  this.replyButtonsCtl?.discard(replySet.id);
8540
8720
  }
8541
8721
  }
8722
+ // #1823: the agent learns its poll's id (the results name it).
8723
+ if (replySet?.poll && !error && result && typeof result === "object")
8724
+ result = { ...result, poll_id: replySet.id };
8542
8725
  ticket.complete(result, error);
8543
8726
  // Return the platform outcome first. Bookkeeping below must never turn
8544
8727
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -8566,7 +8749,7 @@ export class FleetManager {
8566
8749
  });
8567
8750
  }
8568
8751
  };
8569
- if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
8752
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks, ...(replySet.poll ? { replyPoll: replySet.poll } : {}) } : {})) {
8570
8753
  return;
8571
8754
  }
8572
8755
  if (replySet)
@@ -9929,6 +10112,21 @@ export class FleetManager {
9929
10112
  ? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
9930
10113
  : null;
9931
10114
  }
10115
+ /**
10116
+ * #1874 follow-up: the validator's warnings, once per start, in daemon.log — a setting fleet.yaml has that AgEnD
10117
+ * ignores (e.g. `web.access`) is otherwise silent unless someone runs `agend validate`.
10118
+ */
10119
+ logConfigWarningsAtStartup() {
10120
+ let warnings = [];
10121
+ try {
10122
+ warnings = validateFleetConfig(this.rawFleetConfig).warnings;
10123
+ }
10124
+ catch (err) {
10125
+ this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
10126
+ }
10127
+ for (const w of warnings)
10128
+ this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
10129
+ }
9932
10130
  /** One-time upgrade migration; invalid YAML is never rewritten. */
9933
10131
  slimFleetConfigAtStartup() {
9934
10132
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
@@ -10102,6 +10300,7 @@ export class FleetManager {
10102
10300
  // must not be shown the old one's conversation, and deleted names must not pile up.
10103
10301
  this.webChatHistory.forget(name);
10104
10302
  this.webFiles.forget(name);
10303
+ this.compactWatchService?.forget(name);
10105
10304
  // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
10106
10305
  try {
10107
10306
  this.webPrefs.forgetInstance(name);
@@ -10309,10 +10508,12 @@ export class FleetManager {
10309
10508
  return this.webImageCaches[kind];
10310
10509
  }
10311
10510
  /**
10312
- * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
10313
- * whatever an instance still names — #1635 review), not the primary, and answering for it alone.
10511
+ * The bots instances and ClassicBot rooms speak through, by connections configured now (a connection removed from
10512
+ * fleet.yaml is nobody's, whatever an instance still names — #1635 review). `own`: instance → its own bot (answering
10513
+ * for it alone; the primary too, when it serves one instance only). `speaking` (#1867): instance → the bot it speaks through — its own, a shared one,
10514
+ * or, with none configured for it, the primary.
10314
10515
  */
10315
- ownBots() {
10516
+ webBots() {
10316
10517
  const instances = this.fleetConfig?.instances ?? {};
10317
10518
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
10318
10519
  const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
@@ -10322,8 +10523,17 @@ export class FleetManager {
10322
10523
  if (!Object.hasOwn(instances, room.instanceName))
10323
10524
  assignments.push([room.instanceName, assigned(room.instanceName)]);
10324
10525
  }
10325
- return ownBotConnections(this.getPrimaryAdapterId(), assignments);
10526
+ const primary = this.getPrimaryAdapterId(), fallback = primary && configured.has(primary) ? primary : undefined;
10527
+ const speaking = new Map();
10528
+ for (const [instance, connection] of assignments) {
10529
+ const c = connection ?? fallback;
10530
+ if (c)
10531
+ speaking.set(instance, c);
10532
+ }
10533
+ return { own: ownBotConnections(assignments), speaking };
10326
10534
  }
10535
+ /** instance → the connection that is its own bot (see webBots). */
10536
+ ownBots() { return this.webBots().own; }
10327
10537
  /**
10328
10538
  * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
10329
10539
  * keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
@@ -10366,8 +10576,11 @@ export class FleetManager {
10366
10576
  },
10367
10577
  };
10368
10578
  }
10369
- /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
10370
- webIdentityImage(name, displayName, profileAvatar, own) {
10579
+ /**
10580
+ * One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. The bot
10581
+ * is its own, else the one it speaks through (#1867) — one cache entry per bot, so a shared bot is fetched once.
10582
+ */
10583
+ webIdentityImage(name, displayName, profileAvatar, bots) {
10371
10584
  let profileVersion = null;
10372
10585
  if (profileAvatar && imageMimeForFile(profileAvatar)) {
10373
10586
  try {
@@ -10375,22 +10588,26 @@ export class FleetManager {
10375
10588
  }
10376
10589
  catch { /* removed */ }
10377
10590
  }
10378
- const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
10591
+ const ownConnection = bots.own.get(name), connection = ownConnection ?? bots.speaking.get(name);
10592
+ const owner = connection ? this.botOwner(connection) : null;
10379
10593
  const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
10380
10594
  const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
10381
10595
  const cfg = this.ownInstanceConfig(name);
10382
10596
  const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
10383
10597
  personaEmoji: cfg?.status_emojis?.delivered });
10384
- // The ring belongs to the bot's own face: only when that is what is shown.
10385
- const accent = !profileVersion && bot && connection && owner
10386
- ? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
10598
+ // The ring belongs to an own bot's face: only when that is what is shown (a shared bot's would ring everyone alike).
10599
+ const accent = !profileVersion && bot && ownConnection && owner
10600
+ ? this.webAccent(ownConnection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
10387
10601
  : null;
10388
10602
  return accent ? { avatar, accent } : { avatar };
10389
10603
  }
10390
- /** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
10604
+ /**
10605
+ * GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only a connection some instance or room speaks
10606
+ * through (#1867), or a known instance's upload.
10607
+ */
10391
10608
  webImage(kind, key) {
10392
10609
  if (kind === "bot") {
10393
- if (![...this.ownBots().values()].includes(key))
10610
+ if (![...this.webBots().speaking.values()].includes(key))
10394
10611
  return null;
10395
10612
  const owner = this.botOwner(key);
10396
10613
  return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
@@ -11827,6 +12044,8 @@ export class FleetManager {
11827
12044
  return true;
11828
12045
  if (await this.handleEffortSelection(data, adapterId))
11829
12046
  return true;
12047
+ if (await this.handleBackendSelection(data, adapterId))
12048
+ return true;
11830
12049
  if (await this.handleHangPrompt(data, adapterId, adapter))
11831
12050
  return true;
11832
12051
  if (data.callbackData.startsWith("cancel:")) {
@@ -12094,10 +12313,18 @@ export class FleetManager {
12094
12313
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
12095
12314
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
12096
12315
  */
12097
- /** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
12098
- clickWebReplyButton(instance, id, index) {
12316
+ /** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
12317
+ closePollEarly(instance, id) {
12099
12318
  const buttons = this.replyButtons();
12100
- return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12319
+ return buttons ? buttons.closeEarly(instance, id) : Promise.resolve({ ok: false, error: "polls are unavailable until AgEnD restarts" });
12320
+ }
12321
+ /**
12322
+ * #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). On a
12323
+ * poll (#1823) it is `voter`'s vote: the page's session.
12324
+ */
12325
+ clickWebReplyButton(instance, id, index, voter) {
12326
+ const buttons = this.replyButtons();
12327
+ return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12101
12328
  }
12102
12329
  async clickWebPrompt(instance, nonce, action) {
12103
12330
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
@@ -12450,6 +12677,8 @@ export class FleetManager {
12450
12677
  }
12451
12678
  };
12452
12679
  }
12680
+ /** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
12681
+ webBackendTokens = new Map();
12453
12682
  /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
12454
12683
  webClearTokens = new Map();
12455
12684
  /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
@@ -12457,7 +12686,9 @@ export class FleetManager {
12457
12686
  const webMeta = (instance) => {
12458
12687
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
12459
12688
  return {
12460
- chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
12689
+ // #1827: a /steer or /btw reaches the agent as the web person who wrote it.
12690
+ chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
12691
+ username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
12461
12692
  threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
12462
12693
  };
12463
12694
  };
@@ -12477,7 +12708,22 @@ export class FleetManager {
12477
12708
  return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
12478
12709
  },
12479
12710
  ctx: (instance) => this.topicCommands.getCtxText(instance),
12480
- compact: (instance, instructions) => this.topicCommands.sendCompact(instance, instructions),
12711
+ // Board 003648ac: a watch for the paste, armed when the daemon says it was written (raw_paste_written); one
12712
+ // for a command that was not handed over is dropped.
12713
+ compact: async (instance, instructions) => {
12714
+ let watch = null;
12715
+ try {
12716
+ const r = this.topicCommands.compactInstance(instance, instructions, () => (watch = this.compactWatch.begin(instance)));
12717
+ if (!r.sent && watch)
12718
+ this.compactWatch.cancel(instance, watch);
12719
+ return { ...r, watch: r.sent ? watch : null };
12720
+ }
12721
+ catch (err) {
12722
+ if (watch)
12723
+ this.compactWatch.cancel(instance, watch);
12724
+ throw err;
12725
+ }
12726
+ },
12481
12727
  applyModel: (instance, name) => this.applyModel(instance, name),
12482
12728
  modelChoices: choicesFromCache,
12483
12729
  applyEffort: (instance, level) => this.applyEffort(instance, level),
@@ -12488,6 +12734,41 @@ export class FleetManager {
12488
12734
  const current = this.resolveInstanceEffort(instance).effort;
12489
12735
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
12490
12736
  },
12737
+ backendStep: async (instance, args) => {
12738
+ const refusal = this.backendCommandRefusal(instance);
12739
+ if (refusal)
12740
+ return { error: refusal };
12741
+ // The cached model lists only, as the web's /model: never a live probe on this path.
12742
+ const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
12743
+ if (step.kind === "refuse")
12744
+ return step.noop ? { text: step.text } : { error: step.text };
12745
+ if (step.kind === "choose") {
12746
+ const id = (token) => joinBackendArgs([...step.args, token]);
12747
+ return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
12748
+ }
12749
+ const now = performance.now();
12750
+ for (const [k, v] of this.webBackendTokens)
12751
+ if (v.deadline < now)
12752
+ this.webBackendTokens.delete(k);
12753
+ const token = randomBytes(16).toString("hex");
12754
+ this.webBackendTokens.set(token, {
12755
+ instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
12756
+ target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
12757
+ });
12758
+ return { confirm: { token, message: step.text } };
12759
+ },
12760
+ backendConfirm: async (instance, args, token) => {
12761
+ const entry = this.webBackendTokens.get(token);
12762
+ this.webBackendTokens.delete(token); // used once, whatever happens next
12763
+ if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
12764
+ return { status: 409, text: t("backend.selection_expired") };
12765
+ }
12766
+ // The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
12767
+ const stillCurrent = () => this.backendTargetIs(instance, entry.target);
12768
+ if (!stillCurrent())
12769
+ return { status: 409, text: t("menu.click_stale") };
12770
+ return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
12771
+ },
12491
12772
  cancel: (instance) => this.cancelInstance(instance),
12492
12773
  steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12493
12774
  btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
@@ -12516,7 +12797,8 @@ export class FleetManager {
12516
12797
  // sendClear sends its first IPC synchronously: no await separates this check from the effect.
12517
12798
  if (this.shuttingDown || !entry.fence())
12518
12799
  return { status: 409, text: t("menu.click_stale") };
12519
- return { status: 200, text: await this.topicCommands.sendClear(instance) };
12800
+ const r = this.topicCommands.clearInstance(instance);
12801
+ return r.sent ? { status: 200, text: r.text, sent: true } : { status: 409, text: r.text };
12520
12802
  },
12521
12803
  t: (key, ...args) => t(key, ...args),
12522
12804
  }, input, opts);
@@ -14099,10 +14381,18 @@ export class FleetManager {
14099
14381
  }
14100
14382
  return;
14101
14383
  }
14384
+ // Between the install ending and the sign-in starting there is no
14385
+ // session to cancel; this hand-off is what `/login cancel` stops — from
14386
+ // here on, as the login-shell lookup below is an await.
14387
+ const handoff = { backend, cancelled: false };
14388
+ this.installHandoff = handoff;
14102
14389
  // The installer may only have added the binary to a profile PATH; a
14103
14390
  // fresh login shell sees that, the fleet process's PATH may not.
14104
- const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
14391
+ const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
14392
+ if (handoff.cancelled || this.installHandoff !== handoff)
14393
+ return;
14105
14394
  if (!installedAt) {
14395
+ this.installHandoff = null;
14106
14396
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
14107
14397
  return;
14108
14398
  }
@@ -14112,6 +14402,7 @@ export class FleetManager {
14112
14402
  // installed, reported as verified, and then offered nowhere.
14113
14403
  this.adoptBinaryDirectory(installedAt, backend);
14114
14404
  if (!LOGIN_FLOWS[backend]) {
14405
+ this.installHandoff = null;
14115
14406
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
14116
14407
  return;
14117
14408
  }
@@ -14120,10 +14411,6 @@ export class FleetManager {
14120
14411
  // publish a durable completion line first so a successful install can
14121
14412
  // never look like it silently disappeared. Include the binary that
14122
14413
  // passed the fresh-login-shell verification and the exact next step.
14123
- // Between the install ending and the sign-in starting there is no
14124
- // session to cancel; this hand-off is what `/login cancel` stops.
14125
- const handoff = { backend, cancelled: false };
14126
- this.installHandoff = handoff;
14127
14414
  await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
14128
14415
  if (handoff.cancelled || this.installHandoff !== handoff)
14129
14416
  return;
@@ -14182,16 +14469,14 @@ export class FleetManager {
14182
14469
  * The absolute path a fresh login shell resolves `binary` to, or null. Only
14183
14470
  * a real executable file counts: `command -v` also answers with an alias
14184
14471
  * definition or a function name, neither of which a spawn could run.
14472
+ * Async (runOnLoginShell): a login file that backgrounds a process holding the shell's output once froze the fleet loop here.
14185
14473
  */
14186
- locateBinaryOnLoginShell(binary) {
14187
- return measureSyncWork("fleet.installLookup", () => this.locateBinaryOnLoginShellSync(binary));
14188
- }
14189
- locateBinaryOnLoginShellSync(binary) {
14474
+ async locateBinaryOnLoginShell(binary) {
14190
14475
  try {
14191
- const result = measureSyncWork("fleet.installLoginShell", () => spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" }));
14476
+ const result = await runOnLoginShell(`command -v ${binary}`);
14192
14477
  if (result.status !== 0)
14193
14478
  return null;
14194
- const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
14479
+ const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
14195
14480
  if (!isAbsolute(path))
14196
14481
  return null;
14197
14482
  accessSync(path, fsConstants.X_OK);
@@ -14340,6 +14625,7 @@ export class FleetManager {
14340
14625
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
14341
14626
  role: typeof m.role === "string" ? m.role : undefined,
14342
14627
  buttons: m.buttons,
14628
+ click: m.click,
14343
14629
  });
14344
14630
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
14345
14631
  return;
@@ -14391,7 +14677,7 @@ export class FleetManager {
14391
14677
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
14392
14678
  const configuredMinutes = instanceHangConfig?.timeout_minutes
14393
14679
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
14394
- ?? 15;
14680
+ ?? DEFAULT_STUCK_TIMEOUT_MS / 60_000; // #1854: the detector's own default (10 min), not a stale 15
14395
14681
  const unchangedMinutes = unchangedForMs == null
14396
14682
  ? configuredMinutes
14397
14683
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
@@ -14449,12 +14735,12 @@ export class FleetManager {
14449
14735
  "codex": "AGENTS.md",
14450
14736
  "opencode": "AGENTS.md",
14451
14737
  "kiro-cli": ".kiro/steering/project.md",
14452
- // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
14738
+ // Grok reads AGENTS.md project docs; agy reads .agents/AGENTS.md (board 0340ec89: not the lowercase name) — the
14453
14739
  // same files their writeConfig() appends fleet instructions to.
14454
14740
  "grok": "AGENTS.md",
14455
14741
  // muse init scaffolds AGENTS.md and the binary reads it as project rules.
14456
14742
  "muse": "AGENTS.md",
14457
- "antigravity": ".agents/agents.md",
14743
+ "antigravity": ".agents/AGENTS.md",
14458
14744
  "mock": "CLAUDE.md",
14459
14745
  };
14460
14746
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -14494,10 +14780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14494
14780
  ensureGeneralInstructionsSync(workDir, backendName, instanceName) {
14495
14781
  const backend = backendName ?? "claude-code";
14496
14782
  workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
14783
+ // Board 0340ec89: General's file from before, under the lowercase name agy never read, becomes the one it reads.
14784
+ if (backend === "antigravity")
14785
+ migrateAgyAgentsFile(workDir);
14497
14786
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
14498
14787
  const filePath = join(workDir, filename);
14499
14788
  mkdirSync(dirname(filePath), { recursive: true });
14500
- if (!existsSync(filePath)) {
14789
+ if (backend === "antigravity" && danglingSymlink(filePath)) {
14790
+ // #1914 review: a person's dangling symlink there is kept and not followed (nothing created where it points).
14791
+ this.logger.warn({ filePath }, "General's agy instructions file is a dangling symlink; left as it is, not written");
14792
+ }
14793
+ else if (!existsSync(filePath)) {
14501
14794
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
14502
14795
  this.logger.info({ filePath }, "Created general instance instructions file");
14503
14796
  }
@@ -14594,6 +14887,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14594
14887
  }
14595
14888
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
14596
14889
  const instructionsPath = join(workDir, filename);
14890
+ if (backend === "antigravity" && danglingSymlink(instructionsPath))
14891
+ return; // kept, not followed (#1914 review; logged above)
14597
14892
  const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
14598
14893
  const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
14599
14894
  let existing = "";
@@ -14614,6 +14909,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14614
14909
  mkdirSync(dirname(instructionsPath), { recursive: true });
14615
14910
  writeFileSync(instructionsPath, next);
14616
14911
  }
14912
+ // Board 0340ec89: a legacy agy file that stays beside the new one (a person's own) loses only AgEnD's block.
14913
+ const legacy = backend === "antigravity" ? agyLegacyAgentsFile(workDir) : null;
14914
+ if (legacy) {
14915
+ try {
14916
+ const old = readFileSync(legacy, "utf-8");
14917
+ const b = old.indexOf(FleetManager.STEERING_BLOCK_BEGIN), e = old.indexOf(FleetManager.STEERING_BLOCK_END);
14918
+ if (b !== -1 && e > b) {
14919
+ const rest = (old.slice(0, b).trimEnd() + "\n" + old.slice(e + FleetManager.STEERING_BLOCK_END.length).trimStart()).trim();
14920
+ if (rest)
14921
+ writeFileSync(legacy, rest + "\n");
14922
+ else
14923
+ rmSync(legacy, { force: true });
14924
+ }
14925
+ }
14926
+ catch { /* best effort: the new file already has the block */ }
14927
+ }
14617
14928
  }
14618
14929
  static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
14619
14930
  static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
@@ -15149,10 +15460,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15149
15460
  * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
15150
15461
  * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
15151
15462
  */
15152
- claudeModelKnown(instanceName, model) {
15463
+ claudeModelKnown(instanceName, model,
15464
+ /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15465
+ configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15153
15466
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15154
15467
  const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
15155
- const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
15156
15468
  const lower = model.trim().toLowerCase();
15157
15469
  const bare = lower.replace(/\[1m\]$/, "");
15158
15470
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -16131,7 +16443,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16131
16443
  /** How this instance's backend applies an effort change. */
16132
16444
  effortStrategyFor(instanceName) {
16133
16445
  try {
16134
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16446
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
16135
16447
  return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
16136
16448
  }
16137
16449
  catch {
@@ -16141,13 +16453,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16141
16453
  /** Effort levels this instance's backend actually accepts (empty = unsupported). */
16142
16454
  effortLevelsFor(instanceName) {
16143
16455
  try {
16144
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16456
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
16145
16457
  return strategy === "unsupported" ? [] : levels;
16146
16458
  }
16147
16459
  catch {
16148
16460
  return [];
16149
16461
  }
16150
16462
  }
16463
+ /** The instance's backend's effort metadata; agy's levels are its model's (#1825), the one the instance runs. */
16464
+ instanceEffortMetadata(instanceName) {
16465
+ const backend = this.backendNameForInstance(instanceName);
16466
+ let model = null;
16467
+ if (backend === "antigravity") {
16468
+ const resolved = this.resolveInstanceModel(instanceName);
16469
+ model = resolved.source === "unresolved" ? null : resolved.model;
16470
+ }
16471
+ return readEffortMetadata(backend, this.getInstanceDir(instanceName), model);
16472
+ }
16151
16473
  /** Configured effort for an instance: per-instance, else fleet default, else none. */
16152
16474
  resolveInstanceEffort(instanceName) {
16153
16475
  const own = this.fleetConfig?.instances[instanceName]?.effort;
@@ -16299,6 +16621,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16299
16621
  ? `\n${t("effort.current_fleet", effort)}`
16300
16622
  : `\n${t("effort.current", effort)}`;
16301
16623
  }
16624
+ // ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
16625
+ /** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
16626
+ backendCommandDeps(instanceName, opts) {
16627
+ return {
16628
+ currentBackend: (name) => this.backendNameForInstance(name),
16629
+ currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
16630
+ installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
16631
+ models: (backend) => this.backendModelOptions(backend, opts),
16632
+ // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
16633
+ modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
16634
+ effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
16635
+ inherited: (key) => {
16636
+ const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
16637
+ return typeof value === "string" && value ? value : undefined;
16638
+ },
16639
+ modelCompatible: (backend, model) => isModelCompatible(backend, model),
16640
+ state: (name) => {
16641
+ const status = this.getInstanceStatus(name);
16642
+ return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
16643
+ },
16644
+ t: (key, ...args) => t(key, ...args),
16645
+ };
16646
+ }
16647
+ /**
16648
+ * `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
16649
+ * per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
16650
+ */
16651
+ backendEffortLevels(instanceName, backend, model = null) {
16652
+ // codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
16653
+ let runs = model;
16654
+ if (backend === "codex") {
16655
+ const next = this.prospectiveCodexModel(instanceName, model);
16656
+ if (!next.proven)
16657
+ return [];
16658
+ runs = next.model;
16659
+ }
16660
+ let levels;
16661
+ try {
16662
+ const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
16663
+ levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
16664
+ }
16665
+ catch {
16666
+ return [];
16667
+ }
16668
+ // #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
16669
+ // itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
16670
+ const takes = this.launchEffortGate(instanceName, backend, runs);
16671
+ return levels.filter(takes);
16672
+ }
16673
+ /**
16674
+ * #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
16675
+ * The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
16676
+ * claude-code's known names (without this instance's configured model, which belongs to the backend being left).
16677
+ * With no list known, the backend's usual name pattern decides.
16678
+ */
16679
+ backendModelVerdict(instanceName, backend, model,
16680
+ /** The provider the backend will run on (default: this instance's own, else the fleet's). */
16681
+ opts = { provider: this.customProviderFor(instanceName, backend) }) {
16682
+ // A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
16683
+ // review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
16684
+ if (opts.provider)
16685
+ return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
16686
+ const listed = this.readCliEnv(backend)?.models ?? [];
16687
+ const choices = listed.slice(0, 24).map(o => o.id);
16688
+ if (!modelNameCarried(backend, model))
16689
+ return { ok: false, choices };
16690
+ if (listed.some(o => o.id === model))
16691
+ return { ok: true };
16692
+ if (backend === "claude-code") {
16693
+ const check = this.claudeModelKnown(instanceName, model, null);
16694
+ return check.known ? { ok: true } : { ok: false, choices: check.names };
16695
+ }
16696
+ return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
16697
+ }
16698
+ /**
16699
+ * #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
16700
+ * config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
16701
+ * launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
16702
+ */
16703
+ prospectiveCodexModel(instanceName, model) {
16704
+ const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
16705
+ return resolveCodexLaunchModel({
16706
+ isolatedHome: sharedHome, sharedHome, model,
16707
+ workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
16708
+ });
16709
+ }
16710
+ /** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
16711
+ launchEffortGate(instanceName, backend, model) {
16712
+ if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
16713
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
16714
+ if (!binary)
16715
+ return () => false;
16716
+ const path = resolveBinary(binary);
16717
+ return level => flagEffort(backend, level, path).arg !== null;
16718
+ }
16719
+ if (backend === "codex") {
16720
+ // `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
16721
+ if (!model)
16722
+ return () => false;
16723
+ const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
16724
+ return level => codexEffort(level, catalog).arg !== null;
16725
+ }
16726
+ // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
16727
+ return () => true;
16728
+ }
16729
+ /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
16730
+ backendInstalledHere(backend) {
16731
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
16732
+ return !!binary && resolveBinary(binary) !== binary;
16733
+ }
16734
+ /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
16735
+ async backendModelOptions(backend, opts) {
16736
+ const cached = this.readCliEnv(backend);
16737
+ if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
16738
+ return cached?.models ?? [];
16739
+ opts.onLiveProbe?.();
16740
+ const env = await this.probeBackendBounded(backend).catch(() => null);
16741
+ return env?.models.length ? env.models : cached?.models ?? [];
16742
+ }
16743
+ /**
16744
+ * #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
16745
+ * again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
16746
+ */
16747
+ backendTargetIs(instanceName, target) {
16748
+ const instances = this.fleetConfig?.instances;
16749
+ return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
16750
+ }
16751
+ /** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
16752
+ backendCommandRefusal(instanceName) {
16753
+ return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
16754
+ ? null : t("backend.classic_unsupported", instanceName);
16755
+ }
16756
+ /**
16757
+ * Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
16758
+ * removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
16759
+ * (its credential-profile login gate included), then one fresh relaunch of a running instance.
16760
+ */
16761
+ async applyBackendSwitch(instanceName, args, from, stillCurrent) {
16762
+ if (this.backendNameForInstance(instanceName) !== from)
16763
+ return t("backend.changed", instanceName, from);
16764
+ if (!stillCurrent())
16765
+ return t("menu.click_stale");
16766
+ const deps = this.backendCommandDeps(instanceName, { live: false });
16767
+ const step = await backendStep(deps, instanceName, args);
16768
+ if (step.kind !== "confirm")
16769
+ return step.kind === "refuse" ? step.text : t("backend.usage");
16770
+ // Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
16771
+ // synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
16772
+ if (this.backendNameForInstance(instanceName) !== from)
16773
+ return t("backend.changed", instanceName, from);
16774
+ if (!stillCurrent())
16775
+ return t("menu.click_stale");
16776
+ const { plan } = step;
16777
+ const shown = planDisplay(deps, plan);
16778
+ const { value, error } = await new Promise(resolve => {
16779
+ applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
16780
+ });
16781
+ if (error)
16782
+ return t("backend.refused", instanceName, error);
16783
+ const result = (value ?? {});
16784
+ if (result.warning)
16785
+ return t("backend.restart_failed", instanceName, plan.backend, result.warning);
16786
+ if (result.restarted)
16787
+ return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
16788
+ return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
16789
+ }
16790
+ /** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
16791
+ backendStepButtons(instanceName, nonce, step) {
16792
+ if (step.kind === "choose")
16793
+ return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
16794
+ if (step.kind !== "confirm")
16795
+ return [];
16796
+ const running = this.getInstanceStatus(instanceName) === "running";
16797
+ return [
16798
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
16799
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
16800
+ ];
16801
+ }
16802
+ /**
16803
+ * Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
16804
+ * reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
16805
+ * nonce: a click on an older step finds nothing.
16806
+ */
16807
+ async presentBackendStep(place, step) {
16808
+ const nonce = randomBytes(6).toString("hex");
16809
+ const buttons = this.backendStepButtons(place.instanceName, nonce, step);
16810
+ const timer = setTimeout(() => {
16811
+ const p = this.pendingBackendSelects.get(nonce);
16812
+ if (!p)
16813
+ return;
16814
+ this.pendingBackendSelects.delete(nonce);
16815
+ void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
16816
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
16817
+ timer.unref?.();
16818
+ const pending = { ...place, step, timer };
16819
+ this.pendingBackendSelects.set(nonce, pending);
16820
+ try {
16821
+ if (step.kind === "confirm" && place.respondButtons) {
16822
+ await place.respondButtons(step.text, buttons);
16823
+ return;
16824
+ }
16825
+ if (place.respondChoices) {
16826
+ await place.respondChoices(step.text, buttons);
16827
+ return;
16828
+ }
16829
+ if (!place.adapter || !place.adapterChatId)
16830
+ throw new Error("no place to show the menu");
16831
+ if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
16832
+ const chosen = t("backend.chosen", joinBackendArgs(step.args));
16833
+ await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
16834
+ }
16835
+ const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
16836
+ const fresh = this.pendingBackendSelects.get(nonce);
16837
+ if (fresh)
16838
+ fresh.menuMessageId = menuMessageId;
16839
+ }
16840
+ catch (err) {
16841
+ this.pendingBackendSelects.delete(nonce);
16842
+ clearTimeout(timer);
16843
+ this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
16844
+ await place.respond(t("backend.usage")).catch(() => { });
16845
+ }
16846
+ }
16847
+ /** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
16848
+ async endBackendMenu(pending, text) {
16849
+ if (pending.adapter && pending.adapterChatId) {
16850
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
16851
+ try {
16852
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
16853
+ return pending.menuMessageId;
16854
+ }
16855
+ catch { /* say it in a new message */ }
16856
+ }
16857
+ return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
16858
+ }
16859
+ if (pending.retireButtons)
16860
+ await pending.retireButtons(text);
16861
+ else
16862
+ await pending.respond(text);
16863
+ return undefined;
16864
+ }
16865
+ /** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
16866
+ async beginBackendCommand(place, args) {
16867
+ const refusal = this.backendCommandRefusal(place.instanceName);
16868
+ if (refusal) {
16869
+ await place.respond(refusal);
16870
+ return;
16871
+ }
16872
+ if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
16873
+ await place.respond(t("backend.usage"));
16874
+ return;
16875
+ }
16876
+ place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
16877
+ const deps = this.backendCommandDeps(place.instanceName, {
16878
+ live: true,
16879
+ onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
16880
+ });
16881
+ const step = await backendStep(deps, place.instanceName, args);
16882
+ if (step.kind === "refuse") {
16883
+ await place.respond(step.text);
16884
+ return;
16885
+ }
16886
+ await this.presentBackendStep(place, step);
16887
+ }
16888
+ /** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
16889
+ async handleBackendSlash(data, adapterId) {
16890
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
16891
+ await data.respond(t("permission.denied"));
16892
+ return;
16893
+ }
16894
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
16895
+ if (!name) {
16896
+ await data.respond(t("classic.no_agent"));
16897
+ return;
16898
+ }
16899
+ const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
16900
+ const given = [option("backend"), option("model"), option("effort")];
16901
+ // A later option without the one before it says nothing that can be judged in order.
16902
+ if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
16903
+ await data.respond(t("backend.usage"));
16904
+ return;
16905
+ }
16906
+ const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
16907
+ await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
16908
+ respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
16909
+ }
16910
+ /** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
16911
+ async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
16912
+ const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
16913
+ await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
16914
+ }
16915
+ /** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
16916
+ async handleBackendSelection(data, adapterId) {
16917
+ if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
16918
+ return false;
16919
+ const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
16920
+ if (!match)
16921
+ return true;
16922
+ const pending = this.pendingBackendSelects.get(match[1]);
16923
+ if (!pending)
16924
+ return true;
16925
+ // As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
16926
+ const cbChannel = data.threadId ?? data.chatId;
16927
+ if (!data.userId || data.userId !== pending.userId
16928
+ || (pending.adapterId !== undefined && pending.adapterId !== adapterId)
16929
+ || (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
16930
+ || !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
16931
+ data.ack?.(t("buttons.admin_only"));
16932
+ return true;
16933
+ }
16934
+ this.pendingBackendSelects.delete(match[1]);
16935
+ clearTimeout(pending.timer);
16936
+ if (!pending.menuMessageId && data.messageId)
16937
+ pending.menuMessageId = data.messageId;
16938
+ const index = Number(match[2]);
16939
+ const { step } = pending;
16940
+ if (step.kind === "choose") {
16941
+ const choice = step.choices[index];
16942
+ if (!choice)
16943
+ return true;
16944
+ if (!this.backendTargetIs(pending.instanceName, pending.target)) {
16945
+ await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
16946
+ return true;
16947
+ }
16948
+ const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
16949
+ if (next.kind === "refuse") {
16950
+ await this.endBackendMenu(pending, next.text).catch(() => { });
16951
+ return true;
16952
+ }
16953
+ await this.presentBackendStep(pending, next);
16954
+ return true;
16955
+ }
16956
+ if (index === 1) {
16957
+ await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
16958
+ return true;
16959
+ }
16960
+ if (index !== 0)
16961
+ return true;
16962
+ // Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
16963
+ const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
16964
+ const say = async (text) => {
16965
+ if (pending.adapter && pending.adapterChatId) {
16966
+ if (progressId)
16967
+ await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
16968
+ else
16969
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
16970
+ }
16971
+ else
16972
+ await pending.respond(text);
16973
+ };
16974
+ void (async () => {
16975
+ let result;
16976
+ try {
16977
+ // Asked again after the awaits above: the instance may have moved, or the admin lost admin.
16978
+ const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
16979
+ && this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
16980
+ result = stillCurrent()
16981
+ ? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
16982
+ : t("menu.click_stale");
16983
+ }
16984
+ catch (err) {
16985
+ this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
16986
+ result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
16987
+ }
16988
+ await say(result).catch(() => {
16989
+ if (pending.adapter && pending.adapterChatId)
16990
+ pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
16991
+ });
16992
+ })();
16993
+ return true;
16994
+ }
16302
16995
  /** Read recent chat log for agent context */
16303
16996
  getRecentChatLog(instanceName, maxLines = 10) {
16304
16997
  const logDir = ClassicChannelManager.chatLogDir(instanceName);
@@ -16711,6 +17404,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16711
17404
  this.eventLogPruneTimer = null;
16712
17405
  }
16713
17406
  this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17407
+ this.compactWatchService?.stopAll();
16714
17408
  if (this.outboxPruneTimer) {
16715
17409
  clearInterval(this.outboxPruneTimer);
16716
17410
  this.outboxPruneTimer = null;
@@ -18694,37 +19388,41 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18694
19388
  const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
18695
19389
  const latest = await npmVersion("@songsid/agend");
18696
19390
  let target = latest;
19391
+ let fromChannel = false;
18697
19392
  // The same channel rule as `agend update` (#1259): an alpha follows @alpha, any other prerelease @beta.
18698
19393
  const channel = installedChannel(currentVersion);
18699
19394
  if (channel !== "latest") {
18700
- // Prerelease users track their own channel (never fall back to @latest,
18701
- // which is older, and an alpha is never offered @beta), but should also
18702
- // hear when a newer STABLE ships — pick whichever is the newest.
18703
19395
  let pre = "";
18704
19396
  try {
18705
19397
  pre = await npmVersion(`@songsid/agend@${channel}`);
18706
19398
  }
18707
19399
  catch { /* no such tag */ }
18708
- target = pre || latest;
18709
- if (latest && this.semverGt(latest, target))
19400
+ // #1926: a beta install hears about @beta only: beta users stay on the beta line after a stable ships, so
19401
+ // @latest is not their target. Taking the higher of the two made a same-core stable (2.2.0 over beta.16) the
19402
+ // target, which the same-core guard then dropped, so the newer beta.17 was never announced.
19403
+ target = pre;
19404
+ fromChannel = true;
19405
+ // An alpha is still told about a stable that passed its own minor (with --stable, #1259): only a stable of
19406
+ // another core that is newer than its channel's release. A same-core @latest (2.2.0 for 2.2.0-alpha.3) is not
19407
+ // taken from @latest and no longer hides a newer alpha; once the release points @alpha at it, it comes from
19408
+ // @alpha like any channel release.
19409
+ const core = (v) => v.replace(/^v/, "").split("-")[0];
19410
+ if (channel === "alpha" && latest && core(latest) !== core(currentVersion) && (!pre || this.semverGt(latest, pre))) {
18710
19411
  target = latest;
19412
+ fromChannel = false;
19413
+ }
18711
19414
  }
18712
- // A beta already at/ahead of its matching stable must NOT be told to
18713
- // "update" to that stable — e.g. 2.0.11-beta.41 already contains everything
18714
- // in stable 2.0.11, so semverGt(2.0.11, 2.0.11-beta.41) being true (a stable
18715
- // outranks a prerelease of the same core) is a false positive here. Suppress
18716
- // only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
18717
- // or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
18718
- const core = (v) => v.replace(/^v/, "").split("-")[0];
18719
- const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
18720
19415
  // Only notify when target is genuinely newer (semver), so a beta user on
18721
19416
  // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
18722
- if (target && !betaSupersedesStable && this.semverGt(target, currentVersion)) {
19417
+ if (target && this.semverGt(target, currentVersion)) {
18723
19418
  const generalId = this.findGeneralInstance();
18724
19419
  if (generalId) {
18725
19420
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
18726
19421
  // link preview looked bad. Version + /update instruction is enough.
18727
- this.notifyInstanceTopic(generalId, t(updateNoticeKey(currentVersion, target), `v${target}`, `v${currentVersion}`));
19422
+ // From the install's own channel tag, this is what /update installs — even a stable the release pointed the
19423
+ // tag at (2.2.0 on @alpha / @beta): the plain /update text, never "/update stays on … use --stable" (#1928).
19424
+ const key = fromChannel ? "update.available_current" : updateNoticeKey(currentVersion, target);
19425
+ this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
18728
19426
  }
18729
19427
  }
18730
19428
  }
@@ -18791,6 +19489,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18791
19489
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
18792
19490
  dispatchWebHttp(req, res, port, closing = false) {
18793
19491
  try {
19492
+ // #1937: the listeners allow an upload long enough for a slow link; every other request keeps its old bound on
19493
+ // arriving whole — 10 s through the public link's gateway, Node's 300 s here.
19494
+ if (!isUploadRequest(req))
19495
+ limitRequestReceive(req, gatewayRequestContext(req) ? PUBLIC_GATEWAY_RECEIVE_MS : 300_000);
18794
19496
  res.setHeader("Content-Type", "application/json");
18795
19497
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
18796
19498
  // the panel — the dashboard URL is itself a credential-bearing address.
@@ -19132,6 +19834,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19132
19834
  if (!this.webToken || !this.webSessions)
19133
19835
  this.initializeWebAuthTokens();
19134
19836
  const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
19837
+ // #1937: Node's default (300 s to receive a request) would cut a large upload on a slow link. The listener allows
19838
+ // more; the upload route keeps its own clocks (no byte for a minute, or 30 minutes in all), and every other request
19839
+ // keeps the old 300 s (limitRequestReceive in dispatchWebHttp).
19840
+ server.requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS;
19135
19841
  const drain = trackWebServer(server);
19136
19842
  this.healthServer = server;
19137
19843
  this.healthServerDrain = drain;
@@ -19319,12 +20025,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19319
20025
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
19320
20026
  const profiles = profileIdentities(this.dataDir);
19321
20027
  // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
19322
- let ownBots = new Map();
20028
+ let bots = { own: new Map(), speaking: new Map() };
19323
20029
  try {
19324
- ownBots = this.ownBots();
20030
+ bots = this.webBots();
19325
20031
  }
19326
20032
  catch (err) {
19327
- this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
20033
+ this.logger.debug({ err }, "bots unreadable (getUiStatus)");
19328
20034
  }
19329
20035
  const instances = names.map(name => {
19330
20036
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -19363,9 +20069,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19363
20069
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
19364
20070
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
19365
20071
  const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
20072
+ const compact = this.compactWatchService?.view(name) ?? null;
19366
20073
  let image;
19367
20074
  try {
19368
- image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
20075
+ image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, bots);
19369
20076
  }
19370
20077
  catch (err) {
19371
20078
  this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
@@ -19390,6 +20097,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19390
20097
  effort,
19391
20098
  effort_source,
19392
20099
  ...this.instancePresentation(name),
20100
+ // Board 003648ac: the last web /compact, while it runs and briefly after (the chat's card follows it).
20101
+ ...(compact ? { compact } : {}),
19393
20102
  };
19394
20103
  });
19395
20104
  return {