@songsid/agend 2.2.0-beta.8 → 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 (239) 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 +4 -0
  26. package/dist/backend/grok.js +14 -0
  27. package/dist/backend/grok.js.map +1 -1
  28. package/dist/backend/kiro.d.ts +18 -1
  29. package/dist/backend/kiro.js +35 -8
  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/muse.d.ts +17 -0
  35. package/dist/backend/muse.js +86 -16
  36. package/dist/backend/muse.js.map +1 -1
  37. package/dist/backend/types.d.ts +40 -1
  38. package/dist/backend/types.js +12 -1
  39. package/dist/backend/types.js.map +1 -1
  40. package/dist/backend-command.d.ts +91 -0
  41. package/dist/backend-command.js +122 -0
  42. package/dist/backend-command.js.map +1 -0
  43. package/dist/channel/adapters/discord.d.ts +12 -0
  44. package/dist/channel/adapters/discord.js +49 -2
  45. package/dist/channel/adapters/discord.js.map +1 -1
  46. package/dist/channel/adapters/telegram.d.ts +18 -0
  47. package/dist/channel/adapters/telegram.js +24 -3
  48. package/dist/channel/adapters/telegram.js.map +1 -1
  49. package/dist/channel/mcp-tools.js +2 -0
  50. package/dist/channel/mcp-tools.js.map +1 -1
  51. package/dist/channel/tool-router.d.ts +2 -0
  52. package/dist/channel/tool-router.js +1 -1
  53. package/dist/channel/tool-router.js.map +1 -1
  54. package/dist/channel/types.d.ts +16 -0
  55. package/dist/cli.js +124 -24
  56. package/dist/cli.js.map +1 -1
  57. package/dist/command-table.d.ts +2 -2
  58. package/dist/command-table.js +5 -2
  59. package/dist/command-table.js.map +1 -1
  60. package/dist/compact-watch.d.ts +86 -0
  61. package/dist/compact-watch.js +288 -0
  62. package/dist/compact-watch.js.map +1 -0
  63. package/dist/config-validator.js +39 -0
  64. package/dist/config-validator.js.map +1 -1
  65. package/dist/config.d.ts +1 -0
  66. package/dist/config.js +2 -1
  67. package/dist/config.js.map +1 -1
  68. package/dist/daemon.d.ts +13 -1
  69. package/dist/daemon.js +160 -13
  70. package/dist/daemon.js.map +1 -1
  71. package/dist/delivery-outbox.d.ts +8 -1
  72. package/dist/delivery-outbox.js +6 -2
  73. package/dist/delivery-outbox.js.map +1 -1
  74. package/dist/doctor.js +7 -0
  75. package/dist/doctor.js.map +1 -1
  76. package/dist/fleet-context.d.ts +7 -0
  77. package/dist/fleet-manager.d.ts +160 -10
  78. package/dist/fleet-manager.js +837 -121
  79. package/dist/fleet-manager.js.map +1 -1
  80. package/dist/forged-envelope-scan.d.ts +4 -3
  81. package/dist/forged-envelope-scan.js +4 -3
  82. package/dist/forged-envelope-scan.js.map +1 -1
  83. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  84. package/dist/install-lock.d.ts +18 -1
  85. package/dist/install-lock.js +30 -3
  86. package/dist/install-lock.js.map +1 -1
  87. package/dist/instance-avatar.d.ts +5 -4
  88. package/dist/instance-avatar.js +12 -8
  89. package/dist/instance-avatar.js.map +1 -1
  90. package/dist/instance-lifecycle.js +3 -1
  91. package/dist/instance-lifecycle.js.map +1 -1
  92. package/dist/instructions.js +2 -2
  93. package/dist/instructions.js.map +1 -1
  94. package/dist/kiro-compact-pane.d.ts +38 -0
  95. package/dist/kiro-compact-pane.js +57 -0
  96. package/dist/kiro-compact-pane.js.map +1 -0
  97. package/dist/kiro-log-gate.d.ts +42 -0
  98. package/dist/kiro-log-gate.js +39 -0
  99. package/dist/kiro-log-gate.js.map +1 -0
  100. package/dist/launchd-handoff.d.ts +43 -0
  101. package/dist/launchd-handoff.js +56 -0
  102. package/dist/launchd-handoff.js.map +1 -0
  103. package/dist/locale.js +202 -30
  104. package/dist/locale.js.map +1 -1
  105. package/dist/log-service.js +10 -2
  106. package/dist/log-service.js.map +1 -1
  107. package/dist/log-steps.d.ts +26 -0
  108. package/dist/log-steps.js +215 -3
  109. package/dist/log-steps.js.map +1 -1
  110. package/dist/login-shell.d.ts +21 -0
  111. package/dist/login-shell.js +98 -0
  112. package/dist/login-shell.js.map +1 -0
  113. package/dist/outbound-handlers.d.ts +27 -0
  114. package/dist/outbound-handlers.js +120 -36
  115. package/dist/outbound-handlers.js.map +1 -1
  116. package/dist/outbound-schemas.d.ts +12 -0
  117. package/dist/outbound-schemas.js +15 -4
  118. package/dist/outbound-schemas.js.map +1 -1
  119. package/dist/provider-probe.d.ts +29 -0
  120. package/dist/provider-probe.js +198 -2
  121. package/dist/provider-probe.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +2 -0
  123. package/dist/public-web-gateway.js +11 -1
  124. package/dist/public-web-gateway.js.map +1 -1
  125. package/dist/quickstart-api.d.ts +1 -1
  126. package/dist/quickstart-api.js +8 -6
  127. package/dist/quickstart-api.js.map +1 -1
  128. package/dist/quickstart.d.ts +12 -0
  129. package/dist/quickstart.js +36 -6
  130. package/dist/quickstart.js.map +1 -1
  131. package/dist/reply-buttons-controller.d.ts +86 -5
  132. package/dist/reply-buttons-controller.js +271 -7
  133. package/dist/reply-buttons-controller.js.map +1 -1
  134. package/dist/reply-buttons.d.ts +48 -3
  135. package/dist/reply-buttons.js +105 -10
  136. package/dist/reply-buttons.js.map +1 -1
  137. package/dist/reply-polls.d.ts +85 -0
  138. package/dist/reply-polls.js +154 -0
  139. package/dist/reply-polls.js.map +1 -0
  140. package/dist/restart-guard.d.ts +6 -3
  141. package/dist/restart-guard.js +6 -3
  142. package/dist/restart-guard.js.map +1 -1
  143. package/dist/restart-progress.d.ts +0 -2
  144. package/dist/restart-progress.js +0 -2
  145. package/dist/restart-progress.js.map +1 -1
  146. package/dist/sd-notify.d.ts +22 -1
  147. package/dist/sd-notify.js +63 -10
  148. package/dist/sd-notify.js.map +1 -1
  149. package/dist/service-activation.d.ts +15 -7
  150. package/dist/service-activation.js +42 -26
  151. package/dist/service-activation.js.map +1 -1
  152. package/dist/service-installer.d.ts +24 -0
  153. package/dist/service-installer.js +49 -1
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-plan.d.ts +11 -0
  156. package/dist/service-plan.js +23 -0
  157. package/dist/service-plan.js.map +1 -1
  158. package/dist/systemd-peer-bus.d.ts +95 -0
  159. package/dist/systemd-peer-bus.js +430 -0
  160. package/dist/systemd-peer-bus.js.map +1 -0
  161. package/dist/systemd-runtime.d.ts +27 -1
  162. package/dist/systemd-runtime.js +69 -7
  163. package/dist/systemd-runtime.js.map +1 -1
  164. package/dist/tool-permissions.js +3 -3
  165. package/dist/tool-permissions.js.map +1 -1
  166. package/dist/topic-commands.d.ts +68 -2
  167. package/dist/topic-commands.js +141 -27
  168. package/dist/topic-commands.js.map +1 -1
  169. package/dist/transcript-jsonl.d.ts +7 -2
  170. package/dist/transcript-jsonl.js +13 -7
  171. package/dist/transcript-jsonl.js.map +1 -1
  172. package/dist/transcript-sources.js +6 -1
  173. package/dist/transcript-sources.js.map +1 -1
  174. package/dist/types.d.ts +8 -0
  175. package/dist/ui/chat-commands.js +1 -0
  176. package/dist/ui/chat-render.js +55 -10
  177. package/dist/ui/chat-store.js +69 -5
  178. package/dist/ui/chat-strings.js +28 -16
  179. package/dist/ui/chat-thread.js +132 -14
  180. package/dist/ui/chat-tour.js +17 -14
  181. package/dist/ui/panel-chat.js +16 -10
  182. package/dist/ui/panel-fleet.js +15 -2
  183. package/dist/ui/panel-log.js +55 -15
  184. package/dist/ui/panel-needs.js +10 -7
  185. package/dist/ui/panel-settings.js +58 -7
  186. package/dist/ui/settings-dialogs.js +11 -6
  187. package/dist/ui/settings-model.js +3 -1
  188. package/dist/ui/settings-strings.js +24 -10
  189. package/dist/ui/settings-token.js +7 -0
  190. package/dist/ui/settings-wizard.js +5 -3
  191. package/dist/ui/shared/app-i18n.js +1 -1
  192. package/dist/ui/shared/app-stream.js +4 -1
  193. package/dist/ui/shared/app.css +52 -10
  194. package/dist/ui/shared/header-tools.js +34 -14
  195. package/dist/ui/shared/instance-switch.js +5 -2
  196. package/dist/ui/shared/panel-view.js +15 -4
  197. package/dist/ui/shared/signin.js +15 -9
  198. package/dist/ui/shared/ui-icons.js +1 -0
  199. package/dist/ui/shared/view-strings.js +10 -2
  200. package/dist/ui/signin.html +8 -3
  201. package/dist/unrecognised-prompt.js +2 -2
  202. package/dist/unrecognised-prompt.js.map +1 -1
  203. package/dist/update-check.d.ts +31 -0
  204. package/dist/update-check.js +47 -0
  205. package/dist/update-check.js.map +1 -1
  206. package/dist/update-launch.d.ts +48 -1
  207. package/dist/update-launch.js +159 -5
  208. package/dist/update-launch.js.map +1 -1
  209. package/dist/usage/providers.d.ts +28 -8
  210. package/dist/usage/providers.js +96 -41
  211. package/dist/usage/providers.js.map +1 -1
  212. package/dist/view-api.js +26 -9
  213. package/dist/view-api.js.map +1 -1
  214. package/dist/web-api.d.ts +44 -2
  215. package/dist/web-api.js +265 -57
  216. package/dist/web-api.js.map +1 -1
  217. package/dist/web-chat-history.d.ts +26 -1
  218. package/dist/web-chat-history.js +47 -8
  219. package/dist/web-chat-history.js.map +1 -1
  220. package/dist/web-commands.d.ts +39 -3
  221. package/dist/web-commands.js +22 -5
  222. package/dist/web-commands.js.map +1 -1
  223. package/dist/web-login.d.ts +14 -0
  224. package/dist/web-login.js +4 -2
  225. package/dist/web-login.js.map +1 -1
  226. package/dist/web-prefs.d.ts +18 -0
  227. package/dist/web-prefs.js +36 -4
  228. package/dist/web-prefs.js.map +1 -1
  229. package/dist/web-profile.d.ts +32 -0
  230. package/dist/web-profile.js +132 -0
  231. package/dist/web-profile.js.map +1 -0
  232. package/dist/web-session.d.ts +14 -0
  233. package/dist/web-session.js +10 -0
  234. package/dist/web-session.js.map +1 -1
  235. package/dist/web-upload.d.ts +119 -4
  236. package/dist/web-upload.js +252 -39
  237. package/dist/web-upload.js.map +1 -1
  238. package/launcher/install-admission.cjs +61 -6
  239. 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,18 +33,19 @@ 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";
41
40
  import { isMap, isScalar, parseDocument } from "yaml";
41
+ import { kiroLogGate } from "./kiro-log-gate.js";
42
42
  const __filename = fileURLToPath(import.meta.url);
43
43
  const __dirname = dirname(__filename);
44
44
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
45
45
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
46
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";
47
47
  import { isProbeableRouteTarget } from "./fleet-context.js";
48
- 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";
49
49
  import { EventLog } from "./event-log.js";
50
50
  import { binaryProbe } from "./binary-probe.js";
51
51
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
@@ -63,7 +63,9 @@ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
63
63
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
64
64
  import { createBackend } from "./backend/factory.js";
65
65
  import { readEffortMetadata } from "./backend/effort-metadata.js";
66
- 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";
67
69
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
68
70
  import { processAttachments } from "./channel/attachment-handler.js";
69
71
  import { routeToolCall } from "./channel/tool-router.js";
@@ -79,7 +81,8 @@ import { RoutingEngine } from "./routing-engine.js";
79
81
  import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
80
82
  import { TopicArchiver } from "./topic-archiver.js";
81
83
  import { StatuslineWatcher } from "./statusline-watcher.js";
82
- 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";
83
86
  import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
84
87
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
85
88
  // deciding to defer before submission. This is an alert threshold only: the
@@ -137,9 +140,11 @@ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } fr
137
140
  import { WebChatDiskStore } from "./web-chat-store.js";
138
141
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
139
142
  import { WebPrefsStore } from "./web-prefs.js";
143
+ import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
140
144
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
141
145
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
142
- 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";
143
148
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
144
149
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
145
150
  import { downloadStickerImage } from "./channel/sticker-download.js";
@@ -152,13 +157,14 @@ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permis
152
157
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
153
158
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
154
159
  import { LoginSession } from "./login-manager.js";
160
+ import { runOnLoginShell } from "./login-shell.js";
155
161
  import { tightenInstanceDirs } from "./private-dir.js";
156
162
  import { decideSlash } from "./slash-authz.js";
157
163
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
158
164
  import { runVisibilityCommand } from "./cross-instance-notice.js";
159
- import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
165
+ import { installedChannel, updateNoticeKey } from "./update-check.js";
160
166
  import { resolveInstalledAgend } from "./update-dispatch.js";
161
- import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
167
+ import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
162
168
  import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
163
169
  import { runBeforeDeadline } from "./deadline.js";
164
170
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -169,10 +175,10 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
169
175
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
170
176
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
171
177
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
172
- import { validateFleetConfig } from "./config-validator.js";
178
+ import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
173
179
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
174
180
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
175
- import { readLastInboundAt } from "./daemon.js";
181
+ import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
176
182
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
177
183
  import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
178
184
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
@@ -183,11 +189,13 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
183
189
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
184
190
  import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
185
191
  import { NeedsYouHub } from "./needs-you-hub.js";
186
- 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";
187
193
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
188
194
  import { buildOrgChart } from "./web-org.js";
189
195
  import { CacheService, WINDOWS } from "./cache-service.js";
190
196
  import { runWebCommand } from "./web-commands.js";
197
+ import { CompactWatch, launchIdentity } from "./compact-watch.js";
198
+ import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
191
199
  import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
192
200
  import { sharedRolloutIndex } from "./rollout-index.js";
193
201
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
@@ -352,6 +360,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
352
360
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
353
361
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
354
362
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
363
+ const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
355
364
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
356
365
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
357
366
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
@@ -375,6 +384,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
375
384
  const WEB_ONLY_REPLY_SINK = {
376
385
  type: "web",
377
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)
378
388
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
379
389
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
380
390
  };
@@ -671,6 +681,8 @@ export class FleetManager {
671
681
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
672
682
  pendingEffortSelects = new Map();
673
683
  pendingModelSelects = new Map();
684
+ /** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
685
+ pendingBackendSelects = new Map();
674
686
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
675
687
  pendingNonceButtons = new Map();
676
688
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
@@ -752,7 +764,6 @@ export class FleetManager {
752
764
  updateProgressTimer = null;
753
765
  updateProgressEditRunning = false;
754
766
  lastUpdateProgressText = null;
755
- updateCompletionTipText = null;
756
767
  /**
757
768
  * #1660: an adopted update whose retire outcome is still owed. The
758
769
  * completion summary was (or will be) posted; this watch appends the
@@ -791,7 +802,8 @@ export class FleetManager {
791
802
  /** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
792
803
  webChatStore = null;
793
804
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
794
- 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) });
795
807
  /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
796
808
  webReplyOrder = new Map();
797
809
  /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
@@ -800,6 +812,78 @@ export class FleetManager {
800
812
  seenUsers = new SeenUsers();
801
813
  webPrefsStore;
802
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
+ }
803
887
  get webPrefs() {
804
888
  // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
805
889
  return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
@@ -1148,6 +1232,8 @@ export class FleetManager {
1148
1232
  mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
1149
1233
  deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
1150
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(),
1151
1237
  logger: this.logger,
1152
1238
  setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
1153
1239
  clearTimer: (h) => clearTimeout(h),
@@ -1156,6 +1242,21 @@ export class FleetManager {
1156
1242
  return this.replyButtonsCtl;
1157
1243
  }
1158
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
+ }
1159
1260
  /**
1160
1261
  * #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
1161
1262
  * room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
@@ -1219,8 +1320,10 @@ export class FleetManager {
1219
1320
  return false;
1220
1321
  reactions.consume();
1221
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.
1222
1324
  this.emitSseEvent("message", {
1223
1325
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1326
+ click: { label: button.label, of: set.id },
1224
1327
  });
1225
1328
  return true;
1226
1329
  }
@@ -1563,6 +1666,7 @@ export class FleetManager {
1563
1666
  content: payload.content,
1564
1667
  delivery_id: claimed.deliveryId,
1565
1668
  delivery_attempt: String(claimed.attemptNo),
1669
+ ...(isSilentScheduleDelivery(claimed) ? { silent_schedule: true } : {}),
1566
1670
  }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
1567
1671
  }
1568
1672
  else {
@@ -2202,7 +2306,7 @@ export class FleetManager {
2202
2306
  async handleUpdateSlash(data, adapterId) {
2203
2307
  const gate = this.fleetAdminGate(data.userId, adapterId);
2204
2308
  if (gate !== "ok") {
2205
- 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"));
2206
2310
  return;
2207
2311
  }
2208
2312
  const messageId = await data.respond(t("update.progress.preparing", 0));
@@ -2217,24 +2321,29 @@ export class FleetManager {
2217
2321
  // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
2218
2322
  const installed = await resolveInstalledAgend();
2219
2323
  if (!installed.ok) {
2220
- 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)}`);
2221
2325
  return;
2222
2326
  }
2223
2327
  const origin = `slash /update by ${adapterId}:${data.userId}`;
2224
2328
  recordInternalRequest(this.dataDir, "update", origin);
2225
2329
  const launch = await resolveUpdateLaunch(installed.agend);
2226
2330
  if (!launch.ok) {
2227
- 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)}`);
2228
2332
  return;
2229
2333
  }
2230
2334
  if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
2231
2335
  this.failUpdateProgress(t("not_authorized"));
2232
2336
  return;
2233
2337
  }
2338
+ const logPath = updateLogPath(this.dataDir);
2339
+ const ts = new Date().toISOString();
2340
+ const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
2234
2341
  const child = spawn(launch.command, launch.args, {
2235
- detached: true, stdio: "ignore", env: withOrigin(origin),
2342
+ detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
2236
2343
  });
2237
- 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);
2238
2347
  child.unref();
2239
2348
  }
2240
2349
  async handleProfileSlash(data, adapterId) {
@@ -2707,12 +2816,39 @@ export class FleetManager {
2707
2816
  emit: (event, data) => this.emitSseEvent(event, data),
2708
2817
  });
2709
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
+ }
2710
2845
  /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
2711
2846
  logSteps(name, q) { return this.log.steps(name, q); }
2712
2847
  /**
2713
2848
  * #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
2714
2849
  * now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
2715
- * work). kiro comes with its own reader later in phase 1; other backends have no transcript AgEnD can read.
2850
+ * work). kiro: fleet events with the reason its history is not read (kiro-log-gate.ts); other backends have no
2851
+ * transcript AgEnD can read.
2716
2852
  */
2717
2853
  logSource(name) {
2718
2854
  const instances = this.fleetConfig?.instances ?? {};
@@ -2735,8 +2871,13 @@ export class FleetManager {
2735
2871
  }
2736
2872
  if (backend === "codex")
2737
2873
  return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
2738
- if (backend === "kiro-cli")
2739
- return { backend, path: null, format: null, reason: "coming" };
2874
+ if (backend === "kiro-cli") {
2875
+ // #1218 PR4: events only until the launch's root and the current conversation are both proven (kiro-log-gate.ts);
2876
+ // the reason names which proof is missing. With today's proofs the gate never opens, and nothing is read for kiro:
2877
+ // the reader is bound to an admitted launch when the proofs arrive (#849 root, #1789 current conversation).
2878
+ const gate = kiroLogGate(this.lifecycle.daemons.get(name)?.logLaunch() ?? null);
2879
+ return { backend, path: null, format: null, reason: gate.open ? "kiro_session_unobservable" : gate.reason };
2880
+ }
2740
2881
  return { backend, path: null, format: null, reason: "unsupported" };
2741
2882
  }
2742
2883
  classicRoomFor(name) {
@@ -3110,7 +3251,8 @@ export class FleetManager {
3110
3251
  * or a rebound adapter is never posted through a stale route. A copy
3111
3252
  * already in flight may still land late — the contract permits that.
3112
3253
  */
3113
- 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) {
3114
3256
  const guard = this.webChannelEchoGuards.get(instance);
3115
3257
  const handled = new Set();
3116
3258
  let posted = 0;
@@ -3141,7 +3283,7 @@ export class FleetManager {
3141
3283
  if (!adapter?.sendText)
3142
3284
  continue;
3143
3285
  await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
3144
- ClassicChannelManager.logMessage(instance, "web-user", text, new Date());
3286
+ ClassicChannelManager.logMessage(instance, sender, text, new Date());
3145
3287
  posted++;
3146
3288
  }
3147
3289
  catch (err) {
@@ -3468,6 +3610,20 @@ export class FleetManager {
3468
3610
  hasFleetAdmins(adapterId) {
3469
3611
  return (this.adminListOf(adapterId)?.length ?? 0) > 0;
3470
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
+ }
3471
3627
  /**
3472
3628
  * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
3473
3629
  * through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
@@ -3545,6 +3701,9 @@ export class FleetManager {
3545
3701
  if (!decision.allow) {
3546
3702
  this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
3547
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));
3548
3707
  await data.respond(t(key, ...args)).catch(() => { });
3549
3708
  return;
3550
3709
  }
@@ -3604,6 +3763,9 @@ export class FleetManager {
3604
3763
  else if (data.command === "model") {
3605
3764
  await this.handleModelSlash(data, adapterId);
3606
3765
  }
3766
+ else if (data.command === "backend") {
3767
+ await this.handleBackendSlash(data, adapterId);
3768
+ }
3607
3769
  else if (data.command === "clear") {
3608
3770
  await this.handleClearSlash(data, adapterId);
3609
3771
  }
@@ -3697,7 +3859,7 @@ export class FleetManager {
3697
3859
  await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
3698
3860
  }
3699
3861
  else {
3700
- const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
3862
+ const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
3701
3863
  if (!owner) {
3702
3864
  await this.sendLocalDashboard(data, adapterId);
3703
3865
  return;
@@ -5019,7 +5181,7 @@ export class FleetManager {
5019
5181
  && cfg.topic_id?.toString() === owner.threadId);
5020
5182
  }
5021
5183
  /** Exact owning General. No primary-adapter or name fallback participates in authorization. */
5022
- dashboardOwner(userId, adapterId, address, threadId) {
5184
+ dashboardOwner(userId, adapterId, address, threadId, username) {
5023
5185
  const world = this.worlds.get(adapterId);
5024
5186
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5025
5187
  return null;
@@ -5034,7 +5196,7 @@ export class FleetManager {
5034
5196
  const matched = dc ? topic === address || (group === address && topic === threadId)
5035
5197
  : group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
5036
5198
  if (group && matched)
5037
- return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
5199
+ return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
5038
5200
  }
5039
5201
  return null;
5040
5202
  }
@@ -5043,7 +5205,7 @@ export class FleetManager {
5043
5205
  * — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
5044
5206
  * no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
5045
5207
  */
5046
- dmDashboardOwner(userId, adapterId) {
5208
+ dmDashboardOwner(userId, adapterId, username) {
5047
5209
  const world = this.worlds.get(adapterId);
5048
5210
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5049
5211
  return null;
@@ -5057,7 +5219,7 @@ export class FleetManager {
5057
5219
  .map(([, cfg]) => cfg.topic_id.toString());
5058
5220
  if (topics.length !== 1)
5059
5221
  return null;
5060
- 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 } : {}) };
5061
5223
  return this.publicOwnerCurrent(owner) ? owner : null;
5062
5224
  }
5063
5225
  /**
@@ -5084,7 +5246,7 @@ export class FleetManager {
5084
5246
  async startWebInDm(msg, adapter) {
5085
5247
  if (!adapter)
5086
5248
  return;
5087
- 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;
5088
5250
  if (!owner) {
5089
5251
  await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
5090
5252
  return;
@@ -5137,7 +5299,7 @@ export class FleetManager {
5137
5299
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
5138
5300
  async dashboardMenu(msg) {
5139
5301
  const id = msg.adapterId;
5140
- 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;
5141
5303
  if (!owner) {
5142
5304
  const adapter = id ? this.adapters.get(id) : undefined;
5143
5305
  await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
@@ -5170,7 +5332,9 @@ export class FleetManager {
5170
5332
  return;
5171
5333
  }
5172
5334
  this.initializeWebSessions();
5173
- 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 } : {}) } });
5174
5338
  const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
5175
5339
  && this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
5176
5340
  const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
@@ -5282,13 +5446,12 @@ export class FleetManager {
5282
5446
  const adapter = owner.binding;
5283
5447
  const status = this.getPublicWebStatus();
5284
5448
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
5285
- const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
5286
- const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
5287
- + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
5288
- + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5289
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
5290
- + (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
5291
- 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") },
5292
5455
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
5293
5456
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
5294
5457
  const posted = await this.postNonceButtonPrompt({
@@ -5364,9 +5527,11 @@ export class FleetManager {
5364
5527
  return false;
5365
5528
  this.initializeWebSessions();
5366
5529
  const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
5367
- // #1586: the expiry in the fleet's zone with its offset, never a bare UTC ISO string.
5368
- const text = t("dashboard.private_link", url, issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), expiresAt ? formatFleetTime(expiresAt) : t("dashboard.local"))
5369
- + "\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);
5370
5535
  const deadline = performance.now() + 10_000;
5371
5536
  const direct = owner.binding;
5372
5537
  // The route the sign-in message took: the code-only follow-up takes the same one (#1586).
@@ -5555,7 +5720,6 @@ export class FleetManager {
5555
5720
  messageId,
5556
5721
  });
5557
5722
  this.lastUpdateProgressText = null;
5558
- this.updateCompletionTipText = null;
5559
5723
  this.startUpdateProgressMonitor(adapter);
5560
5724
  }
5561
5725
  /** Persist the public response, wait for idle, then start the canonical service restart. */
@@ -5586,7 +5750,6 @@ export class FleetManager {
5586
5750
  return false;
5587
5751
  }
5588
5752
  this.lastUpdateProgressText = null;
5589
- this.updateCompletionTipText = null;
5590
5753
  this.startUpdateProgressMonitor(adapter);
5591
5754
  await this.waitForFullRestartIdleGrace();
5592
5755
  // An update can begin while the idle wait yields. Never launch a second
@@ -5826,17 +5989,8 @@ export class FleetManager {
5826
5989
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
5827
5990
  if (!adapter)
5828
5991
  return;
5829
- let text = formatUpdateProgress(marker);
5830
- // No-restart updates complete in the old fleet process, so they never
5831
- // reach RestartProgress.finish(). Append the same optional tip here.
5832
- if (marker.progress.stage === "complete" && this.tipsEnabled()) {
5833
- if (this.updateCompletionTipText === null) {
5834
- const tip = this.pickAvailableTip();
5835
- this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
5836
- }
5837
- if (this.updateCompletionTipText)
5838
- text += `\n\n${this.updateCompletionTipText}`;
5839
- }
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);
5840
5994
  if (text === this.lastUpdateProgressText)
5841
5995
  return;
5842
5996
  this.updateProgressEditRunning = true;
@@ -5887,6 +6041,7 @@ export class FleetManager {
5887
6041
  this.rotateFleetLogs();
5888
6042
  const fleet = this.loadConfig(configPath);
5889
6043
  this.slimFleetConfigAtStartup();
6044
+ this.logConfigWarningsAtStartup();
5890
6045
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
5891
6046
  // #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
5892
6047
  const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
@@ -6222,6 +6377,7 @@ export class FleetManager {
6222
6377
  // into the same broken state, and a user who has legitimately stopped every
6223
6378
  // instance would get a restart loop. Those conditions surface through /health
6224
6379
  // (which now returns 503) and through the General-topic notifications instead.
6380
+ setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
6225
6381
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
6226
6382
  // #1231: a stalled loop is what turns a slash command into "did not respond";
6227
6383
  // put each long stall in the log instead of leaving it to be guessed at.
@@ -6340,12 +6496,6 @@ export class FleetManager {
6340
6496
  pausedNames,
6341
6497
  failedNames,
6342
6498
  backgroundWorkEndedNames,
6343
- tipText: pendingProgressOperation === "update" && this.tipsEnabled()
6344
- ? (() => {
6345
- const tip = this.pickAvailableTip();
6346
- return tip ? this.formatTip(tip) : undefined;
6347
- })()
6348
- : undefined,
6349
6499
  ...(cleanupOutcome && "render" in cleanupOutcome
6350
6500
  ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
6351
6501
  : {}),
@@ -6396,6 +6546,7 @@ export class FleetManager {
6396
6546
  // Health HTTP endpoint
6397
6547
  this.startHealthServer(fleet.health_port ?? 19280);
6398
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.
6399
6550
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
6400
6551
  // Daily update check — first check after 1 hour, then every 24 hours
6401
6552
  this.updateCheckTimer = setTimeout(() => {
@@ -7137,6 +7288,16 @@ export class FleetManager {
7137
7288
  });
7138
7289
  }
7139
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
+ }
7140
7301
  else if (msg.type === "runtime_switch_result") {
7141
7302
  // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
7142
7303
  const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
@@ -7931,6 +8092,21 @@ export class FleetManager {
7931
8092
  }
7932
8093
  return;
7933
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
+ }
7934
8110
  // Handle /compact command (admin only)
7935
8111
  const classicCompact = parseCompactCommand(text);
7936
8112
  if (classicCompact) {
@@ -8330,7 +8506,7 @@ export class FleetManager {
8330
8506
  if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
8331
8507
  continue;
8332
8508
  try {
8333
- 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);
8334
8510
  this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
8335
8511
  }
8336
8512
  catch (err) {
@@ -8493,6 +8669,17 @@ export class FleetManager {
8493
8669
  respond(null, `reply: ${parsedButtons.error}`);
8494
8670
  return;
8495
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
+ }
8496
8683
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
8497
8684
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
8498
8685
  if (stickerProblem) {
@@ -8512,7 +8699,7 @@ export class FleetManager {
8512
8699
  const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
8513
8700
  if (buttons) {
8514
8701
  const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
8515
- 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);
8516
8703
  }
8517
8704
  else {
8518
8705
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
@@ -8532,6 +8719,9 @@ export class FleetManager {
8532
8719
  this.replyButtonsCtl?.discard(replySet.id);
8533
8720
  }
8534
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 };
8535
8725
  ticket.complete(result, error);
8536
8726
  // Return the platform outcome first. Bookkeeping below must never turn
8537
8727
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -8559,7 +8749,7 @@ export class FleetManager {
8559
8749
  });
8560
8750
  }
8561
8751
  };
8562
- 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 } : {}) } : {})) {
8563
8753
  return;
8564
8754
  }
8565
8755
  if (replySet)
@@ -9922,6 +10112,21 @@ export class FleetManager {
9922
10112
  ? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
9923
10113
  : null;
9924
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
+ }
9925
10130
  /** One-time upgrade migration; invalid YAML is never rewritten. */
9926
10131
  slimFleetConfigAtStartup() {
9927
10132
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
@@ -10095,6 +10300,7 @@ export class FleetManager {
10095
10300
  // must not be shown the old one's conversation, and deleted names must not pile up.
10096
10301
  this.webChatHistory.forget(name);
10097
10302
  this.webFiles.forget(name);
10303
+ this.compactWatchService?.forget(name);
10098
10304
  // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
10099
10305
  try {
10100
10306
  this.webPrefs.forgetInstance(name);
@@ -10302,10 +10508,12 @@ export class FleetManager {
10302
10508
  return this.webImageCaches[kind];
10303
10509
  }
10304
10510
  /**
10305
- * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
10306
- * 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.
10307
10515
  */
10308
- ownBots() {
10516
+ webBots() {
10309
10517
  const instances = this.fleetConfig?.instances ?? {};
10310
10518
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
10311
10519
  const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
@@ -10315,8 +10523,17 @@ export class FleetManager {
10315
10523
  if (!Object.hasOwn(instances, room.instanceName))
10316
10524
  assignments.push([room.instanceName, assigned(room.instanceName)]);
10317
10525
  }
10318
- 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 };
10319
10534
  }
10535
+ /** instance → the connection that is its own bot (see webBots). */
10536
+ ownBots() { return this.webBots().own; }
10320
10537
  /**
10321
10538
  * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
10322
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
@@ -10359,8 +10576,11 @@ export class FleetManager {
10359
10576
  },
10360
10577
  };
10361
10578
  }
10362
- /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
10363
- 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) {
10364
10584
  let profileVersion = null;
10365
10585
  if (profileAvatar && imageMimeForFile(profileAvatar)) {
10366
10586
  try {
@@ -10368,22 +10588,26 @@ export class FleetManager {
10368
10588
  }
10369
10589
  catch { /* removed */ }
10370
10590
  }
10371
- 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;
10372
10593
  const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
10373
10594
  const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
10374
10595
  const cfg = this.ownInstanceConfig(name);
10375
10596
  const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
10376
10597
  personaEmoji: cfg?.status_emojis?.delivered });
10377
- // The ring belongs to the bot's own face: only when that is what is shown.
10378
- const accent = !profileVersion && bot && connection && owner
10379
- ? 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))
10380
10601
  : null;
10381
10602
  return accent ? { avatar, accent } : { avatar };
10382
10603
  }
10383
- /** 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
+ */
10384
10608
  webImage(kind, key) {
10385
10609
  if (kind === "bot") {
10386
- if (![...this.ownBots().values()].includes(key))
10610
+ if (![...this.webBots().speaking.values()].includes(key))
10387
10611
  return null;
10388
10612
  const owner = this.botOwner(key);
10389
10613
  return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
@@ -11820,6 +12044,8 @@ export class FleetManager {
11820
12044
  return true;
11821
12045
  if (await this.handleEffortSelection(data, adapterId))
11822
12046
  return true;
12047
+ if (await this.handleBackendSelection(data, adapterId))
12048
+ return true;
11823
12049
  if (await this.handleHangPrompt(data, adapterId, adapter))
11824
12050
  return true;
11825
12051
  if (data.callbackData.startsWith("cancel:")) {
@@ -12087,10 +12313,18 @@ export class FleetManager {
12087
12313
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
12088
12314
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
12089
12315
  */
12090
- /** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
12091
- clickWebReplyButton(instance, id, index) {
12316
+ /** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
12317
+ closePollEarly(instance, id) {
12318
+ const buttons = this.replyButtons();
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) {
12092
12326
  const buttons = this.replyButtons();
12093
- return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12327
+ return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12094
12328
  }
12095
12329
  async clickWebPrompt(instance, nonce, action) {
12096
12330
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
@@ -12443,6 +12677,8 @@ export class FleetManager {
12443
12677
  }
12444
12678
  };
12445
12679
  }
12680
+ /** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
12681
+ webBackendTokens = new Map();
12446
12682
  /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
12447
12683
  webClearTokens = new Map();
12448
12684
  /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
@@ -12450,7 +12686,9 @@ export class FleetManager {
12450
12686
  const webMeta = (instance) => {
12451
12687
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
12452
12688
  return {
12453
- 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,
12454
12692
  threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
12455
12693
  };
12456
12694
  };
@@ -12470,7 +12708,22 @@ export class FleetManager {
12470
12708
  return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
12471
12709
  },
12472
12710
  ctx: (instance) => this.topicCommands.getCtxText(instance),
12473
- 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
+ },
12474
12727
  applyModel: (instance, name) => this.applyModel(instance, name),
12475
12728
  modelChoices: choicesFromCache,
12476
12729
  applyEffort: (instance, level) => this.applyEffort(instance, level),
@@ -12481,6 +12734,41 @@ export class FleetManager {
12481
12734
  const current = this.resolveInstanceEffort(instance).effort;
12482
12735
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
12483
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
+ },
12484
12772
  cancel: (instance) => this.cancelInstance(instance),
12485
12773
  steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12486
12774
  btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
@@ -12509,7 +12797,8 @@ export class FleetManager {
12509
12797
  // sendClear sends its first IPC synchronously: no await separates this check from the effect.
12510
12798
  if (this.shuttingDown || !entry.fence())
12511
12799
  return { status: 409, text: t("menu.click_stale") };
12512
- 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 };
12513
12802
  },
12514
12803
  t: (key, ...args) => t(key, ...args),
12515
12804
  }, input, opts);
@@ -14092,10 +14381,18 @@ export class FleetManager {
14092
14381
  }
14093
14382
  return;
14094
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;
14095
14389
  // The installer may only have added the binary to a profile PATH; a
14096
14390
  // fresh login shell sees that, the fleet process's PATH may not.
14097
- 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;
14098
14394
  if (!installedAt) {
14395
+ this.installHandoff = null;
14099
14396
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
14100
14397
  return;
14101
14398
  }
@@ -14105,6 +14402,7 @@ export class FleetManager {
14105
14402
  // installed, reported as verified, and then offered nowhere.
14106
14403
  this.adoptBinaryDirectory(installedAt, backend);
14107
14404
  if (!LOGIN_FLOWS[backend]) {
14405
+ this.installHandoff = null;
14108
14406
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
14109
14407
  return;
14110
14408
  }
@@ -14113,10 +14411,6 @@ export class FleetManager {
14113
14411
  // publish a durable completion line first so a successful install can
14114
14412
  // never look like it silently disappeared. Include the binary that
14115
14413
  // passed the fresh-login-shell verification and the exact next step.
14116
- // Between the install ending and the sign-in starting there is no
14117
- // session to cancel; this hand-off is what `/login cancel` stops.
14118
- const handoff = { backend, cancelled: false };
14119
- this.installHandoff = handoff;
14120
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"));
14121
14415
  if (handoff.cancelled || this.installHandoff !== handoff)
14122
14416
  return;
@@ -14175,16 +14469,14 @@ export class FleetManager {
14175
14469
  * The absolute path a fresh login shell resolves `binary` to, or null. Only
14176
14470
  * a real executable file counts: `command -v` also answers with an alias
14177
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.
14178
14473
  */
14179
- locateBinaryOnLoginShell(binary) {
14180
- return measureSyncWork("fleet.installLookup", () => this.locateBinaryOnLoginShellSync(binary));
14181
- }
14182
- locateBinaryOnLoginShellSync(binary) {
14474
+ async locateBinaryOnLoginShell(binary) {
14183
14475
  try {
14184
- 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}`);
14185
14477
  if (result.status !== 0)
14186
14478
  return null;
14187
- const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
14479
+ const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
14188
14480
  if (!isAbsolute(path))
14189
14481
  return null;
14190
14482
  accessSync(path, fsConstants.X_OK);
@@ -14333,6 +14625,7 @@ export class FleetManager {
14333
14625
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
14334
14626
  role: typeof m.role === "string" ? m.role : undefined,
14335
14627
  buttons: m.buttons,
14628
+ click: m.click,
14336
14629
  });
14337
14630
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
14338
14631
  return;
@@ -14384,7 +14677,7 @@ export class FleetManager {
14384
14677
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
14385
14678
  const configuredMinutes = instanceHangConfig?.timeout_minutes
14386
14679
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
14387
- ?? 15;
14680
+ ?? DEFAULT_STUCK_TIMEOUT_MS / 60_000; // #1854: the detector's own default (10 min), not a stale 15
14388
14681
  const unchangedMinutes = unchangedForMs == null
14389
14682
  ? configuredMinutes
14390
14683
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
@@ -14442,12 +14735,12 @@ export class FleetManager {
14442
14735
  "codex": "AGENTS.md",
14443
14736
  "opencode": "AGENTS.md",
14444
14737
  "kiro-cli": ".kiro/steering/project.md",
14445
- // 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
14446
14739
  // same files their writeConfig() appends fleet instructions to.
14447
14740
  "grok": "AGENTS.md",
14448
14741
  // muse init scaffolds AGENTS.md and the binary reads it as project rules.
14449
14742
  "muse": "AGENTS.md",
14450
- "antigravity": ".agents/agents.md",
14743
+ "antigravity": ".agents/AGENTS.md",
14451
14744
  "mock": "CLAUDE.md",
14452
14745
  };
14453
14746
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -14487,10 +14780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14487
14780
  ensureGeneralInstructionsSync(workDir, backendName, instanceName) {
14488
14781
  const backend = backendName ?? "claude-code";
14489
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);
14490
14786
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
14491
14787
  const filePath = join(workDir, filename);
14492
14788
  mkdirSync(dirname(filePath), { recursive: true });
14493
- 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)) {
14494
14794
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
14495
14795
  this.logger.info({ filePath }, "Created general instance instructions file");
14496
14796
  }
@@ -14587,6 +14887,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14587
14887
  }
14588
14888
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
14589
14889
  const instructionsPath = join(workDir, filename);
14890
+ if (backend === "antigravity" && danglingSymlink(instructionsPath))
14891
+ return; // kept, not followed (#1914 review; logged above)
14590
14892
  const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
14591
14893
  const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
14592
14894
  let existing = "";
@@ -14607,6 +14909,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14607
14909
  mkdirSync(dirname(instructionsPath), { recursive: true });
14608
14910
  writeFileSync(instructionsPath, next);
14609
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
+ }
14610
14928
  }
14611
14929
  static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
14612
14930
  static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
@@ -15142,10 +15460,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15142
15460
  * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
15143
15461
  * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
15144
15462
  */
15145
- 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) {
15146
15466
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15147
15467
  const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
15148
- const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
15149
15468
  const lower = model.trim().toLowerCase();
15150
15469
  const bare = lower.replace(/\[1m\]$/, "");
15151
15470
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -16124,7 +16443,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16124
16443
  /** How this instance's backend applies an effort change. */
16125
16444
  effortStrategyFor(instanceName) {
16126
16445
  try {
16127
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16446
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
16128
16447
  return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
16129
16448
  }
16130
16449
  catch {
@@ -16134,13 +16453,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16134
16453
  /** Effort levels this instance's backend actually accepts (empty = unsupported). */
16135
16454
  effortLevelsFor(instanceName) {
16136
16455
  try {
16137
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16456
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
16138
16457
  return strategy === "unsupported" ? [] : levels;
16139
16458
  }
16140
16459
  catch {
16141
16460
  return [];
16142
16461
  }
16143
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
+ }
16144
16473
  /** Configured effort for an instance: per-instance, else fleet default, else none. */
16145
16474
  resolveInstanceEffort(instanceName) {
16146
16475
  const own = this.fleetConfig?.instances[instanceName]?.effort;
@@ -16292,6 +16621,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16292
16621
  ? `\n${t("effort.current_fleet", effort)}`
16293
16622
  : `\n${t("effort.current", effort)}`;
16294
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
+ }
16295
16995
  /** Read recent chat log for agent context */
16296
16996
  getRecentChatLog(instanceName, maxLines = 10) {
16297
16997
  const logDir = ClassicChannelManager.chatLogDir(instanceName);
@@ -16704,6 +17404,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16704
17404
  this.eventLogPruneTimer = null;
16705
17405
  }
16706
17406
  this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17407
+ this.compactWatchService?.stopAll();
16707
17408
  if (this.outboxPruneTimer) {
16708
17409
  clearInterval(this.outboxPruneTimer);
16709
17410
  this.outboxPruneTimer = null;
@@ -18687,37 +19388,41 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18687
19388
  const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
18688
19389
  const latest = await npmVersion("@songsid/agend");
18689
19390
  let target = latest;
19391
+ let fromChannel = false;
18690
19392
  // The same channel rule as `agend update` (#1259): an alpha follows @alpha, any other prerelease @beta.
18691
19393
  const channel = installedChannel(currentVersion);
18692
19394
  if (channel !== "latest") {
18693
- // Prerelease users track their own channel (never fall back to @latest,
18694
- // which is older, and an alpha is never offered @beta), but should also
18695
- // hear when a newer STABLE ships — pick whichever is the newest.
18696
19395
  let pre = "";
18697
19396
  try {
18698
19397
  pre = await npmVersion(`@songsid/agend@${channel}`);
18699
19398
  }
18700
19399
  catch { /* no such tag */ }
18701
- target = pre || latest;
18702
- 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))) {
18703
19411
  target = latest;
19412
+ fromChannel = false;
19413
+ }
18704
19414
  }
18705
- // A beta already at/ahead of its matching stable must NOT be told to
18706
- // "update" to that stable — e.g. 2.0.11-beta.41 already contains everything
18707
- // in stable 2.0.11, so semverGt(2.0.11, 2.0.11-beta.41) being true (a stable
18708
- // outranks a prerelease of the same core) is a false positive here. Suppress
18709
- // only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
18710
- // or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
18711
- const core = (v) => v.replace(/^v/, "").split("-")[0];
18712
- const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
18713
19415
  // Only notify when target is genuinely newer (semver), so a beta user on
18714
19416
  // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
18715
- if (target && !betaSupersedesStable && this.semverGt(target, currentVersion)) {
19417
+ if (target && this.semverGt(target, currentVersion)) {
18716
19418
  const generalId = this.findGeneralInstance();
18717
19419
  if (generalId) {
18718
19420
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
18719
19421
  // link preview looked bad. Version + /update instruction is enough.
18720
- 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}`));
18721
19426
  }
18722
19427
  }
18723
19428
  }
@@ -18784,6 +19489,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18784
19489
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
18785
19490
  dispatchWebHttp(req, res, port, closing = false) {
18786
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);
18787
19496
  res.setHeader("Content-Type", "application/json");
18788
19497
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
18789
19498
  // the panel — the dashboard URL is itself a credential-bearing address.
@@ -19125,6 +19834,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19125
19834
  if (!this.webToken || !this.webSessions)
19126
19835
  this.initializeWebAuthTokens();
19127
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;
19128
19841
  const drain = trackWebServer(server);
19129
19842
  this.healthServer = server;
19130
19843
  this.healthServerDrain = drain;
@@ -19312,12 +20025,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19312
20025
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
19313
20026
  const profiles = profileIdentities(this.dataDir);
19314
20027
  // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
19315
- let ownBots = new Map();
20028
+ let bots = { own: new Map(), speaking: new Map() };
19316
20029
  try {
19317
- ownBots = this.ownBots();
20030
+ bots = this.webBots();
19318
20031
  }
19319
20032
  catch (err) {
19320
- this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
20033
+ this.logger.debug({ err }, "bots unreadable (getUiStatus)");
19321
20034
  }
19322
20035
  const instances = names.map(name => {
19323
20036
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -19356,9 +20069,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19356
20069
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
19357
20070
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
19358
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;
19359
20073
  let image;
19360
20074
  try {
19361
- 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);
19362
20076
  }
19363
20077
  catch (err) {
19364
20078
  this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
@@ -19383,6 +20097,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19383
20097
  effort,
19384
20098
  effort_source,
19385
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 } : {}),
19386
20102
  };
19387
20103
  });
19388
20104
  return {