@songsid/agend 2.2.0-beta.3 → 2.2.0-beta.5

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 (139) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +46 -2
  4. package/dist/backend/claude-code.js +125 -31
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/credential-profile.d.ts +33 -3
  7. package/dist/backend/credential-profile.js +107 -5
  8. package/dist/backend/credential-profile.js.map +1 -1
  9. package/dist/backend/types.d.ts +1 -1
  10. package/dist/cache-service.d.ts +2 -0
  11. package/dist/cache-service.js +2 -1
  12. package/dist/cache-service.js.map +1 -1
  13. package/dist/channel/adapters/discord.d.ts +13 -0
  14. package/dist/channel/adapters/discord.js +42 -0
  15. package/dist/channel/adapters/discord.js.map +1 -1
  16. package/dist/channel/adapters/telegram.d.ts +6 -1
  17. package/dist/channel/adapters/telegram.js +27 -2
  18. package/dist/channel/adapters/telegram.js.map +1 -1
  19. package/dist/channel/types.d.ts +22 -1
  20. package/dist/cli.js +35 -0
  21. package/dist/cli.js.map +1 -1
  22. package/dist/config-validator.js +7 -0
  23. package/dist/config-validator.js.map +1 -1
  24. package/dist/daemon.d.ts +96 -0
  25. package/dist/daemon.js +339 -16
  26. package/dist/daemon.js.map +1 -1
  27. package/dist/delivery-outbox.d.ts +18 -5
  28. package/dist/delivery-outbox.js +121 -14
  29. package/dist/delivery-outbox.js.map +1 -1
  30. package/dist/fleet-context.d.ts +1 -0
  31. package/dist/fleet-manager.d.ts +89 -3
  32. package/dist/fleet-manager.js +541 -86
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  35. package/dist/host-memory.d.ts +10 -0
  36. package/dist/host-memory.js +33 -1
  37. package/dist/host-memory.js.map +1 -1
  38. package/dist/instance-lifecycle.d.ts +25 -6
  39. package/dist/instance-lifecycle.js +73 -26
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/locale.js +48 -18
  42. package/dist/locale.js.map +1 -1
  43. package/dist/login-controller.d.ts +20 -3
  44. package/dist/login-controller.js +71 -19
  45. package/dist/login-controller.js.map +1 -1
  46. package/dist/login-flows.d.ts +5 -2
  47. package/dist/login-flows.js +5 -4
  48. package/dist/login-flows.js.map +1 -1
  49. package/dist/memory-pressure.d.ts +13 -1
  50. package/dist/memory-pressure.js +50 -10
  51. package/dist/memory-pressure.js.map +1 -1
  52. package/dist/outbound-handlers.d.ts +61 -0
  53. package/dist/outbound-handlers.js +100 -35
  54. package/dist/outbound-handlers.js.map +1 -1
  55. package/dist/outbound-schemas.d.ts +1 -0
  56. package/dist/outbound-schemas.js +2 -0
  57. package/dist/outbound-schemas.js.map +1 -1
  58. package/dist/public-preview.d.ts +100 -0
  59. package/dist/public-preview.js +233 -0
  60. package/dist/public-preview.js.map +1 -0
  61. package/dist/public-web-gateway.js +6 -4
  62. package/dist/public-web-gateway.js.map +1 -1
  63. package/dist/public-web-link.d.ts +20 -0
  64. package/dist/public-web-link.js +26 -1
  65. package/dist/public-web-link.js.map +1 -1
  66. package/dist/restart-progress.d.ts +29 -0
  67. package/dist/restart-progress.js +23 -0
  68. package/dist/restart-progress.js.map +1 -1
  69. package/dist/settings-api.js +10 -4
  70. package/dist/settings-api.js.map +1 -1
  71. package/dist/spawn-gate.js +5 -1
  72. package/dist/spawn-gate.js.map +1 -1
  73. package/dist/spawn-hold-reply.d.ts +12 -0
  74. package/dist/spawn-hold-reply.js +39 -0
  75. package/dist/spawn-hold-reply.js.map +1 -0
  76. package/dist/topic-commands.js +9 -2
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/tunnel/purpose-lane.d.ts +5 -1
  79. package/dist/tunnel/purpose-lane.js +14 -7
  80. package/dist/tunnel/purpose-lane.js.map +1 -1
  81. package/dist/turn-resume.d.ts +2 -0
  82. package/dist/turn-resume.js +1 -1
  83. package/dist/turn-resume.js.map +1 -1
  84. package/dist/types.d.ts +8 -0
  85. package/dist/ui/chat-mentions.js +63 -0
  86. package/dist/ui/chat-render.js +25 -0
  87. package/dist/ui/chat-strings.js +14 -2
  88. package/dist/ui/chat-thread.js +28 -10
  89. package/dist/ui/panel-chat.js +26 -11
  90. package/dist/ui/panel-details.js +2 -0
  91. package/dist/ui/panel-fleet.js +199 -57
  92. package/dist/ui/panel-needs.js +4 -3
  93. package/dist/ui/panel-settings.js +4 -2
  94. package/dist/ui/preview-panel.js +48 -14
  95. package/dist/ui/preview-status.js +28 -0
  96. package/dist/ui/preview.js +40 -3
  97. package/dist/ui/settings-token.js +1 -1
  98. package/dist/ui/shared/app-i18n.js +4 -0
  99. package/dist/ui/shared/app-prefs.js +1 -0
  100. package/dist/ui/shared/app-shell.js +146 -2
  101. package/dist/ui/shared/app-store.js +3 -1
  102. package/dist/ui/shared/app.css +47 -20
  103. package/dist/ui/shared/instance-nav.js +79 -23
  104. package/dist/ui/shared/view-strings.js +10 -0
  105. package/dist/unrecognised-prompt.d.ts +23 -0
  106. package/dist/unrecognised-prompt.js +74 -0
  107. package/dist/unrecognised-prompt.js.map +1 -0
  108. package/dist/update-install.d.ts +8 -0
  109. package/dist/update-install.js +9 -3
  110. package/dist/update-install.js.map +1 -1
  111. package/dist/update-marker.d.ts +40 -0
  112. package/dist/update-marker.js +49 -0
  113. package/dist/update-marker.js.map +1 -1
  114. package/dist/usage/i18n-keys.d.ts +1 -1
  115. package/dist/usage/i18n-keys.js +1 -1
  116. package/dist/usage/i18n-keys.js.map +1 -1
  117. package/dist/usage/providers.d.ts +34 -5
  118. package/dist/usage/providers.js +160 -27
  119. package/dist/usage/providers.js.map +1 -1
  120. package/dist/usage/statusline-usage.d.ts +6 -2
  121. package/dist/usage/statusline-usage.js +10 -4
  122. package/dist/usage/statusline-usage.js.map +1 -1
  123. package/dist/web-api.d.ts +20 -0
  124. package/dist/web-api.js +216 -30
  125. package/dist/web-api.js.map +1 -1
  126. package/dist/web-auth.js +3 -2
  127. package/dist/web-auth.js.map +1 -1
  128. package/dist/web-mentions.d.ts +44 -0
  129. package/dist/web-mentions.js +75 -0
  130. package/dist/web-mentions.js.map +1 -0
  131. package/dist/web-prefs.js +2 -0
  132. package/dist/web-prefs.js.map +1 -1
  133. package/dist/web-preview.d.ts +14 -1
  134. package/dist/web-preview.js +35 -13
  135. package/dist/web-preview.js.map +1 -1
  136. package/dist/web-schedules.d.ts +12 -0
  137. package/dist/web-schedules.js +34 -0
  138. package/dist/web-schedules.js.map +1 -0
  139. package/package.json +1 -1
@@ -4,11 +4,14 @@ import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
4
4
  import { SettingsControlServer } from "./settings-control.js";
5
5
  import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
6
6
  import { performance } from "node:perf_hooks";
7
+ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
7
8
  import { gatewayRequestContext } from "./web-request-context.js";
8
9
  import { createPublicWebGateway } from "./public-web-gateway.js";
9
10
  import { handleWebIconRequest } from "./web-icons.js";
10
11
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
11
12
  import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
13
+ import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
14
+ import { CloudflaredProvider } from "./tunnel/cloudflared.js";
12
15
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
13
16
  import { ManagedTunnel } from "./tunnel/manager.js";
14
17
  import { withinBudget } from "./monotonic-budget.js";
@@ -27,7 +30,7 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
27
30
  import { fileURLToPath } from "node:url";
28
31
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
29
32
  import { fleetLabel } from "./fleet-label.js";
30
- import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
33
+ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
31
34
  import { formatUpdateProgress } from "./update-progress.js";
32
35
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
33
36
  import { readFleetMemory } from "./process-memory.js";
@@ -75,7 +78,7 @@ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, che
75
78
  import { TopicArchiver } from "./topic-archiver.js";
76
79
  import { StatuslineWatcher } from "./statusline-watcher.js";
77
80
  import { outboundHandlers } from "./outbound-handlers.js";
78
- import { DeliveryOutbox, isSilentScheduleDelivery } from "./delivery-outbox.js";
81
+ import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
79
82
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
80
83
  // deciding to defer before submission. This is an alert threshold only: the
81
84
  // active manager/target generation pair owns the lane until the daemon reports
@@ -129,6 +132,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
129
132
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
130
133
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
131
134
  import { WebChatDiskStore } from "./web-chat-store.js";
135
+ import { SeenUsers, resolveMentions } from "./web-mentions.js";
132
136
  import { WebPrefsStore } from "./web-prefs.js";
133
137
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
134
138
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
@@ -151,7 +155,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
151
155
  import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
152
156
  import { resolveInstalledAgend } from "./update-dispatch.js";
153
157
  import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
154
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
158
+ import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
155
159
  import { runBeforeDeadline } from "./deadline.js";
156
160
  import { LoginWindowLock } from "./login-window-lock.js";
157
161
  import { handleSettingsRequest } from "./settings-api.js";
@@ -179,7 +183,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
179
183
  import { buildOrgChart } from "./web-org.js";
180
184
  import { CacheService, WINDOWS } from "./cache-service.js";
181
185
  import { runWebCommand } from "./web-commands.js";
182
- import { claudeProjectKey } from "./backend/claude-code.js";
186
+ import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
183
187
  import { sharedRolloutIndex } from "./rollout-index.js";
184
188
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
185
189
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
@@ -189,7 +193,7 @@ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.j
189
193
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
190
194
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
191
195
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
192
- import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
196
+ import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
193
197
  import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
194
198
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
195
199
  import { SecretStore } from "./secret-store.js";
@@ -199,10 +203,10 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
199
203
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
200
204
  const SELF_RESTART_DEADLINE_MS = 300_000;
201
205
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
202
- import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
206
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
203
207
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
204
208
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
205
- import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
209
+ import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
206
210
  import { launchFullRestartHelper } from "./full-restart.js";
207
211
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
208
212
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
@@ -428,6 +432,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
428
432
  * and hides exactly the newly released model the user opened `/model` to find.
429
433
  * The wait is announced before it starts, so it reads as progress, not a stall.
430
434
  */
435
+ /** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
436
+ export const RUNTIME_SWITCH_RESULT_MS = 12_000;
431
437
  export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
432
438
  /**
433
439
  * How many CLIs may cold-start at once, from BOTH memory and cores.
@@ -731,6 +737,13 @@ export class FleetManager {
731
737
  updateProgressEditRunning = false;
732
738
  lastUpdateProgressText = null;
733
739
  updateCompletionTipText = null;
740
+ /**
741
+ * #1660: an adopted update whose retire outcome is still owed. The
742
+ * completion summary was (or will be) posted; this watch appends the
743
+ * cleanup outcome to the same receipt instead of letting stage edits
744
+ * clobber it — and refuses a receipt that no longer matches (checklist 3/5).
745
+ */
746
+ updateCleanupWatch = null;
734
747
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
735
748
  fullRestartLauncher = launchFullRestartHelper;
736
749
  eventLogPruneTimer = null;
@@ -750,8 +763,8 @@ export class FleetManager {
750
763
  memoryLogAt = null;
751
764
  memoryDebugAt = null;
752
765
  memoryLogLevel = null;
753
- memoryNoticeAt = null;
754
- memoryNoticeLevel = null;
766
+ /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
767
+ memoryIndicatorShown = null;
755
768
  // Mirror topic: buffer cross-instance messages, flush every 3s
756
769
  mirrorBuffer = [];
757
770
  mirrorTimer = null;
@@ -763,6 +776,8 @@ export class FleetManager {
763
776
  webChatStore = null;
764
777
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
765
778
  webFiles = new WebFileLedger();
779
+ /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
780
+ seenUsers = new SeenUsers();
766
781
  webPrefsStore;
767
782
  /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
768
783
  get webPrefs() {
@@ -1330,7 +1345,9 @@ export class FleetManager {
1330
1345
  // #926: a restart kills the turn that was working on a request, and a
1331
1346
  // resumed CLI does not pick it back up. Remind the owner once per restart.
1332
1347
  try {
1333
- outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, since: new Date() });
1348
+ const now = new Date();
1349
+ this.expireReplyObligations(now);
1350
+ outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
1334
1351
  }
1335
1352
  catch (err) {
1336
1353
  this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
@@ -1348,6 +1365,7 @@ export class FleetManager {
1348
1365
  return;
1349
1366
  const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
1350
1367
  try {
1368
+ this.expireReplyObligations(now);
1351
1369
  for (const name of this.daemons.keys()) {
1352
1370
  if (stateOf(name) === "idle")
1353
1371
  outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
@@ -1359,6 +1377,15 @@ export class FleetManager {
1359
1377
  this.logger.error({ err }, "Reply-obligation sweep failed");
1360
1378
  }
1361
1379
  }
1380
+ expireReplyObligations(now) {
1381
+ const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
1382
+ const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
1383
+ ? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
1384
+ for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
1385
+ this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
1386
+ correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
1387
+ }
1388
+ }
1362
1389
  scheduleDeliveryOutboxPump(delayMs = 0) {
1363
1390
  if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
1364
1391
  return;
@@ -1598,6 +1625,14 @@ export class FleetManager {
1598
1625
  return Math.max(1, Math.min(20, explicit));
1599
1626
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
1600
1627
  }
1628
+ /** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
1629
+ * unknown macOS reading — the advisory case — is unknown, so null too). */
1630
+ hostMemoryIndicator() {
1631
+ if (!this.memoryPressure)
1632
+ return null;
1633
+ const level = this.memoryPressure.snapshot().level;
1634
+ return level === "elevated" || level === "critical" ? level : null;
1635
+ }
1601
1636
  /** Separate cooldown: changing measurements must not create new text-throttle keys. */
1602
1637
  reportMemoryPressure(snapshot) {
1603
1638
  if (this.shuttingDown)
@@ -1608,6 +1643,12 @@ export class FleetManager {
1608
1643
  this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
1609
1644
  }
1610
1645
  const changed = snapshot.level !== this.memoryLogLevel;
1646
+ // #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
1647
+ const indicator = this.hostMemoryIndicator();
1648
+ if (indicator !== this.memoryIndicatorShown) {
1649
+ this.memoryIndicatorShown = indicator;
1650
+ this.emitSseEvent("status", this.getUiStatus());
1651
+ }
1611
1652
  if (this.memoryPressure.advisoryOnly()) {
1612
1653
  // The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
1613
1654
  this.memoryLogLevel = snapshot.level;
@@ -1628,20 +1669,6 @@ export class FleetManager {
1628
1669
  : "Host memory sample unavailable — slowing new CLI spawns");
1629
1670
  }
1630
1671
  this.memoryLogLevel = snapshot.level;
1631
- if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
1632
- return;
1633
- const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
1634
- if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1635
- return;
1636
- const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1637
- const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
1638
- const text = this.memoryPressure.platform === "darwin"
1639
- ? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
1640
- : t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
1641
- if (this.notifyFleetError(text, { throttle: false })) {
1642
- this.memoryNoticeAt = now;
1643
- this.memoryNoticeLevel = snapshot.level;
1644
- }
1645
1672
  }
1646
1673
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1647
1674
  bindStormWindowEvents() {
@@ -1772,14 +1799,17 @@ export class FleetManager {
1772
1799
  // the next tunnel and says so, it does not block the fleet.
1773
1800
  this.announceToolPermissionsChange();
1774
1801
  this.tightenInstanceDirectories();
1775
- void reapStaleTunnel(this.dataDir)
1776
- .then(outcome => {
1777
- if (outcome.kind === "manual")
1778
- this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
1779
- else if (outcome.kind === "reaped")
1780
- this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
1781
- })
1782
- .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
1802
+ // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
1803
+ for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
1804
+ void reapStaleTunnel(leaseDir)
1805
+ .then(outcome => {
1806
+ if (outcome.kind === "manual")
1807
+ this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
1808
+ else if (outcome.kind === "reaped")
1809
+ this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
1810
+ })
1811
+ .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
1812
+ }
1783
1813
  // An existing installation has never written the setup marker — it predates
1784
1814
  // it — so `agend setup` would open a pre-fleet form for a fleet that plainly
1785
1815
  // exists. A fleet that just came up on a config with agents in it is proof
@@ -1802,7 +1832,13 @@ export class FleetManager {
1802
1832
  // We are the post-update fleet: the update is over by definition. Clearing
1803
1833
  // it here (rather than in the update command, which exits before the new
1804
1834
  // fleet is up) is what keeps the quiet window from outliving the restart.
1805
- clearUpdateMarker(this.dataDir);
1835
+ // #1660 exception: an update still owing its retire outcome keeps its
1836
+ // receipt until the cleanup watch resolves it — deleting it here is what
1837
+ // lost the warning. Terminal stages are still cleared unconditionally.
1838
+ const leftover = readUpdateProgress(this.dataDir)?.progress;
1839
+ if (!(leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")) {
1840
+ clearUpdateMarker(this.dataDir);
1841
+ }
1806
1842
  if (this.reloadPending)
1807
1843
  this.scheduleReconcile();
1808
1844
  void this.sweepOrphanedCancelButtons();
@@ -3310,6 +3346,9 @@ export class FleetManager {
3310
3346
  * the check and the act, so the channel's instance cannot be rebound to another bot in between.
3311
3347
  */
3312
3348
  async dispatchSlash(slash, adapterId, adapter) {
3349
+ return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
3350
+ }
3351
+ async dispatchSlashCore(slash, adapterId, adapter) {
3313
3352
  // #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
3314
3353
  const data = { ...slash, command: canonicalCommand(slash.command) };
3315
3354
  const door = this.slashDoor(data, adapterId);
@@ -4684,7 +4723,13 @@ export class FleetManager {
4684
4723
  return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
4685
4724
  }
4686
4725
  tunnelPurposeLane = null;
4726
+ previewTunnel = null;
4687
4727
  publicWebLink = null;
4728
+ /** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
4729
+ getPreviewTunnel() {
4730
+ return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
4731
+ log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
4732
+ }
4688
4733
  getTunnelLane() {
4689
4734
  return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
4690
4735
  log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
@@ -4737,6 +4782,13 @@ export class FleetManager {
4737
4782
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
4738
4783
  },
4739
4784
  log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
4785
+ createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
4786
+ if (!publicPreviewWanted(this.fleetConfig?.web))
4787
+ return null;
4788
+ return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
4789
+ provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
4790
+ log: event => this.logger.info({ event, exposureId: id }, "Public web link") });
4791
+ },
4740
4792
  onCleanupUnconfirmed: (result, owner) => {
4741
4793
  this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
4742
4794
  const world = this.worlds.get(owner.adapterId);
@@ -4769,6 +4821,16 @@ export class FleetManager {
4769
4821
  await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4770
4822
  }
4771
4823
  }
4824
+ /**
4825
+ * #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
4826
+ * Returns null when no botUsername is known or the username fails Telegram's format validation.
4827
+ */
4828
+ tgStartWebUrl(adapterId) {
4829
+ const username = this.worlds.get(adapterId)?.botUsername;
4830
+ if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
4831
+ return null;
4832
+ return `https://t.me/${username}?start=web`;
4833
+ }
4772
4834
  async sendLocalDashboard(data, adapterId) {
4773
4835
  if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4774
4836
  await data.respond(t("not_authorized"));
@@ -4890,10 +4952,12 @@ export class FleetManager {
4890
4952
  const adapter = owner.binding;
4891
4953
  const status = this.getPublicWebStatus();
4892
4954
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
4955
+ const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
4893
4956
  const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4894
4957
  + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4895
4958
  + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
4896
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "");
4959
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
4960
+ + (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
4897
4961
  const choices = [{ action: "local", label: t("dashboard.local") },
4898
4962
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4899
4963
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
@@ -5036,7 +5100,11 @@ export class FleetManager {
5036
5100
  : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
5037
5101
  await progress;
5038
5102
  await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
5039
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
5103
+ // #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
5104
+ const failUrlButtons = (!ok && owner.binding.type === "telegram")
5105
+ ? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
5106
+ : undefined;
5107
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
5040
5108
  return true;
5041
5109
  }
5042
5110
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5278,6 +5346,53 @@ export class FleetManager {
5278
5346
  setUpdateProgressStage(this.dataDir, "failed", { error: message });
5279
5347
  }
5280
5348
  /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
5349
+ /**
5350
+ * #1660: arm the post-completion cleanup watch and make sure its monitor
5351
+ * actually runs. The new fleet adopts the receipt at boot but nothing else
5352
+ * starts this timer on that path (Prism a) — without this, the owed
5353
+ * outcome would sit in the marker unwatched. READY never waits for it.
5354
+ */
5355
+ armCleanupWatch(receipt, summary) {
5356
+ this.updateCleanupWatch = { receipt, summary };
5357
+ if (!this.updateProgressTimer)
5358
+ this.startUpdateProgressMonitor();
5359
+ }
5360
+ /**
5361
+ * #1660: append a recorded cleanup outcome to the adopted update message,
5362
+ * then release the receipt — but only if this receipt still owns the
5363
+ * marker. The await yields, so a newer receipt (e.g. a full-restart that
5364
+ * landed mid-edit) must not be cleared or disturbed (Prism b).
5365
+ */
5366
+ async appendCleanupWarning(target, watch, warning, cleanupCommand) {
5367
+ const text = formatRestartProgressCompletion("update", {
5368
+ ...watch.summary,
5369
+ warningText: warning,
5370
+ warningCommand: cleanupCommand,
5371
+ }, watch.receipt.startedAt);
5372
+ const stopWatching = () => {
5373
+ this.updateCleanupWatch = null;
5374
+ if (this.updateProgressTimer)
5375
+ clearInterval(this.updateProgressTimer);
5376
+ this.updateProgressTimer = null;
5377
+ };
5378
+ if (text !== this.lastUpdateProgressText) {
5379
+ const adapter = this.adapters.get(target.adapterId);
5380
+ if (!adapter)
5381
+ return; // retry on a later tick
5382
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
5383
+ this.lastUpdateProgressText = text;
5384
+ }
5385
+ const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
5386
+ if (verdict.action === "drop") {
5387
+ // Someone else owns the marker now — leave it entirely alone.
5388
+ this.updateCleanupWatch = null;
5389
+ return;
5390
+ }
5391
+ if (verdict.action === "wait")
5392
+ return; // not terminal; keep watching
5393
+ clearUpdateMarker(this.dataDir);
5394
+ stopWatching();
5395
+ }
5281
5396
  startUpdateProgressMonitor(initialAdapter) {
5282
5397
  if (this.updateProgressTimer)
5283
5398
  clearInterval(this.updateProgressTimer);
@@ -5291,6 +5406,48 @@ export class FleetManager {
5291
5406
  this.updateProgressTimer = null;
5292
5407
  return;
5293
5408
  }
5409
+ // #1660: the completion summary was already posted; only watch for
5410
+ // the retire outcome owed to this exact receipt. Never stage-edit
5411
+ // here (that would clobber the posted completion), and never touch a
5412
+ // receipt that no longer matches (checklist 3/5).
5413
+ const watch = this.updateCleanupWatch;
5414
+ if (watch) {
5415
+ const stopWatching = () => {
5416
+ this.updateCleanupWatch = null;
5417
+ if (this.updateProgressTimer)
5418
+ clearInterval(this.updateProgressTimer);
5419
+ this.updateProgressTimer = null;
5420
+ };
5421
+ const verdict = cleanupWatchVerdict(marker, watch.receipt);
5422
+ if (verdict.action === "drop") {
5423
+ // Receipt gone or replaced (e.g. a later full-restart adopted the
5424
+ // marker): not ours — leave it to its own lifecycle, drop the watch.
5425
+ this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
5426
+ this.updateCleanupWatch = null;
5427
+ }
5428
+ else if (verdict.action === "release") {
5429
+ // Done silent, or the updater died past the max age: release it.
5430
+ clearUpdateMarker(this.dataDir);
5431
+ stopWatching();
5432
+ return;
5433
+ }
5434
+ else if (verdict.action === "wait") {
5435
+ return;
5436
+ }
5437
+ else {
5438
+ this.updateProgressEditRunning = true;
5439
+ try {
5440
+ await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
5441
+ }
5442
+ catch (err) {
5443
+ this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
5444
+ }
5445
+ finally {
5446
+ this.updateProgressEditRunning = false;
5447
+ }
5448
+ return;
5449
+ }
5450
+ }
5294
5451
  const target = marker.progress.target;
5295
5452
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
5296
5453
  if (!adapter)
@@ -5381,7 +5538,11 @@ export class FleetManager {
5381
5538
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
5382
5539
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
5383
5540
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
5384
- this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
5541
+ this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
5542
+ const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
5543
+ // #1699 follow-up: each instance's own login — a profile is probed with its own environment.
5544
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
5545
+ }));
5385
5546
  // Start tmux control mode client for idle detection
5386
5547
  if (!this.controlClient) {
5387
5548
  this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
@@ -5786,7 +5947,13 @@ export class FleetManager {
5786
5947
  const _require = createRequire(import.meta.url);
5787
5948
  const agendVersion = _require("../package.json").version ?? "unknown";
5788
5949
  await progressStart;
5789
- const progressCompleted = await startupProgress.finish({
5950
+ // #1660: when the adopted update still owes its retire outcome, the
5951
+ // completion posts without the warning and this same summary object is
5952
+ // kept for the watch to append it to the same receipt later. Waiting
5953
+ // for cleanup before READY would deadlock with the updater (it retires
5954
+ // only after the restart returns), so READY and cleanup notification
5955
+ // have separate lifecycles (review checklist 3/5).
5956
+ const completionSummary = {
5790
5957
  running: started,
5791
5958
  total,
5792
5959
  version: agendVersion,
@@ -5798,7 +5965,14 @@ export class FleetManager {
5798
5965
  return tip ? this.formatTip(tip) : undefined;
5799
5966
  })()
5800
5967
  : undefined,
5801
- });
5968
+ };
5969
+ const progressCompleted = await startupProgress.finish(completionSummary);
5970
+ if (pendingUpdateProgress && pendingProgressOperation === "update"
5971
+ && pendingUpdateProgress.progress.cleanup === "pending") {
5972
+ const receipt = updateReceiptOf(pendingUpdateProgress);
5973
+ if (receipt)
5974
+ this.armCleanupWatch(receipt, completionSummary);
5975
+ }
5802
5976
  if (!progressCompleted && fleet.channel?.group_id) {
5803
5977
  let text;
5804
5978
  if (pendingProgressOperation === "full-restart") {
@@ -5820,6 +5994,9 @@ export class FleetManager {
5820
5994
  text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
5821
5995
  + (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
5822
5996
  }
5997
+ // #1660: a pending cleanup warning cannot be present yet (the updater
5998
+ // retires only after the restart returns); the watch appends it to
5999
+ // the adopted message when the outcome lands. Nothing to add here.
5823
6000
  await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
5824
6001
  }
5825
6002
  // After "fleet ready", so the notice is not the first thing people see of a restart.
@@ -6569,6 +6746,14 @@ export class FleetManager {
6569
6746
  });
6570
6747
  }
6571
6748
  }
6749
+ else if (msg.type === "runtime_switch_result") {
6750
+ // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
6751
+ const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
6752
+ if (waiter) {
6753
+ this.runtimeSwitchWaiters.delete(msg.id);
6754
+ waiter(msg);
6755
+ }
6756
+ }
6572
6757
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
6573
6758
  this.cacheInstanceExecutionState(name, msg);
6574
6759
  if (msg.type === "instance_state_response") {
@@ -7081,6 +7266,16 @@ export class FleetManager {
7081
7266
  });
7082
7267
  }
7083
7268
  async handleInboundMessage(msg) {
7269
+ if (msg.isBotMessage)
7270
+ return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7271
+ const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapters.get(msg.adapterId ?? "") ?? this.adapter;
7272
+ return withSpawnHoldReply(text => adapter?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
7273
+ }
7274
+ async handleInboundMessageCore(msg) {
7275
+ // #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
7276
+ // the instance they belong to).
7277
+ if (!msg.isBotMessage && msg.userId && msg.username)
7278
+ this.seenUsers.record(msg.userId, msg.username);
7084
7279
  // Platform author identity is the authority. This runs before routing,
7085
7280
  // collab/commands/access/dedup and does not depend on any echo setting/ACK.
7086
7281
  const fleetBotIds = new Set();
@@ -7236,6 +7431,16 @@ export class FleetManager {
7236
7431
  }
7237
7432
  // Handle /start command
7238
7433
  if (text === "/start" || text.startsWith("/start ")) {
7434
+ // #1686: intercept /start web before the ClassicBot admission flow runs.
7435
+ // /start alone already opens the DM for future bot-to-user messages — no further action needed.
7436
+ const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7437
+ if (requestedBackend === "web" && isPrivateChat) {
7438
+ if (this.isFleetAdmin(msg.userId, msg.adapterId ?? "")) {
7439
+ await msgAdapter?.sendText(chatId, t("dashboard.start_web_ack"));
7440
+ }
7441
+ // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
7442
+ return;
7443
+ }
7239
7444
  // All new starts, including explicit backend and later chooser callbacks,
7240
7445
  // share validateClassicStart. Existing channels are recognized first.
7241
7446
  const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
@@ -7244,7 +7449,6 @@ export class FleetManager {
7244
7449
  return;
7245
7450
  }
7246
7451
  const channelName = msg.username || chatId;
7247
- const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7248
7452
  if (requestedBackend) {
7249
7453
  // handleClassicStart binds the instance to this adapter authoritatively.
7250
7454
  const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
@@ -9731,6 +9935,45 @@ export class FleetManager {
9731
9935
  return null;
9732
9936
  }
9733
9937
  }
9938
+ /**
9939
+ * #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
9940
+ * the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
9941
+ * profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
9942
+ * (members, users, roles, channels).
9943
+ */
9944
+ webMentionNames(query) {
9945
+ const bots = (q) => {
9946
+ const u = Object.create(null);
9947
+ if (!q.u.length)
9948
+ return { u };
9949
+ let own = new Map();
9950
+ try {
9951
+ own = this.ownBots();
9952
+ }
9953
+ catch { /* none known */ }
9954
+ const profiles = profileIdentities(this.dataDir);
9955
+ const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
9956
+ for (const [instance, connection] of own) {
9957
+ const id = this.adapters.get(connection)?.getBotUserId?.();
9958
+ if (!id || !q.u.includes(id))
9959
+ continue;
9960
+ const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
9961
+ u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
9962
+ }
9963
+ return { u };
9964
+ };
9965
+ const seen = (q) => {
9966
+ const u = Object.create(null);
9967
+ for (const id of q.u) {
9968
+ const name = this.seenUsers.get(id);
9969
+ if (name)
9970
+ u[id] = name;
9971
+ }
9972
+ return { u };
9973
+ };
9974
+ const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
9975
+ return resolveMentions(query, [bots, seen, ...caches]);
9976
+ }
9734
9977
  /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
9735
9978
  async webEmojiList() {
9736
9979
  const adopted = [];
@@ -11174,6 +11417,48 @@ export class FleetManager {
11174
11417
  });
11175
11418
  }
11176
11419
  cacheService = null;
11420
+ /**
11421
+ * #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
11422
+ * ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
11423
+ * (also for a backend that has no profiles, where the option is ignored).
11424
+ */
11425
+ credentialProfileOf(name, backend) {
11426
+ if (!credentialHomeSpec(backend))
11427
+ return null;
11428
+ const cfg = this.fleetConfig?.instances?.[name];
11429
+ if (cfg)
11430
+ return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
11431
+ const launched = this.daemons.get(name)?.getConfigSnapshot?.();
11432
+ if (launched)
11433
+ return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
11434
+ if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
11435
+ return null;
11436
+ return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
11437
+ }
11438
+ /**
11439
+ * #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
11440
+ * (CLAUDE_CONFIG_DIR or ~/.claude).
11441
+ */
11442
+ claudeConfigDirOf(name) {
11443
+ const profile = this.credentialProfileOf(name, "claude-code");
11444
+ return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
11445
+ }
11446
+ /** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
11447
+ cacheInstances() {
11448
+ const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
11449
+ name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
11450
+ claudeConfigDir: this.claudeConfigDirOf(name),
11451
+ }));
11452
+ const taken = new Set(fleet.map(i => i.name));
11453
+ const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
11454
+ name: ch.instanceName,
11455
+ backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
11456
+ workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
11457
+ ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
11458
+ claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
11459
+ }));
11460
+ return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
11461
+ }
11177
11462
  /**
11178
11463
  * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
11179
11464
  * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
@@ -11182,20 +11467,7 @@ export class FleetManager {
11182
11467
  cacheReport(window) {
11183
11468
  const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
11184
11469
  this.cacheService ??= new CacheService({
11185
- // fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
11186
- instances: () => {
11187
- const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
11188
- name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
11189
- }));
11190
- const taken = new Set(fleet.map(i => i.name));
11191
- const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
11192
- name: ch.instanceName,
11193
- backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
11194
- workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
11195
- ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
11196
- }));
11197
- return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
11198
- },
11470
+ instances: () => this.cacheInstances(),
11199
11471
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
11200
11472
  claudeKey: claudeProjectKey,
11201
11473
  codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
@@ -11382,7 +11654,9 @@ export class FleetManager {
11382
11654
  }
11383
11655
  async retireNonceButtons(pending, messageId, text,
11384
11656
  /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
11385
- editClicked) {
11657
+ editClicked,
11658
+ /** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
11659
+ urlButtons) {
11386
11660
  this.webPromptGone(pending, text);
11387
11661
  try {
11388
11662
  const retire = editClicked ?? pending.retire;
@@ -11392,7 +11666,12 @@ export class FleetManager {
11392
11666
  }
11393
11667
  if (!pending.adapter.editMessageRemoveButtons)
11394
11668
  throw new Error("adapter cannot remove prompt buttons");
11395
- await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
11669
+ if (urlButtons) {
11670
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
11671
+ }
11672
+ else {
11673
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
11674
+ }
11396
11675
  }
11397
11676
  catch (err) {
11398
11677
  // The action was already atomically consumed. An edit failure must not
@@ -12006,9 +12285,12 @@ export class FleetManager {
12006
12285
  * Backends with no remote login flow (opencode logs in from a terminal) get
12007
12286
  * no button — the alert's own wording already tells them what to run.
12008
12287
  */
12009
- async offerBackendLogin(targetInstance, backend) {
12288
+ async offerBackendLogin(targetInstance, backend, profile) {
12010
12289
  if (!LOGIN_FLOWS[backend])
12011
12290
  return;
12291
+ if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
12292
+ return; // that profile signs in on the host
12293
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
12012
12294
  const adapterId = this.getInstanceAdapterId(targetInstance);
12013
12295
  const adapter = this.getAdapterForInstance(targetInstance);
12014
12296
  const chatId = this.getGroupIdForInstance(targetInstance);
@@ -12026,9 +12308,10 @@ export class FleetManager {
12026
12308
  adapterId,
12027
12309
  chatId,
12028
12310
  threadId,
12029
- message: t("login.offer", backend),
12030
- choices: [{ action: backend, label: t("login.offer_action", backend) }],
12311
+ message: t("login.offer", shown),
12312
+ choices: [{ action: backend, label: t("login.offer_action", shown) }],
12031
12313
  expiredText: t("buttons.stale"),
12314
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12032
12315
  });
12033
12316
  }
12034
12317
  /** Consume a General assist button exactly once. */
@@ -12841,7 +13124,7 @@ export class FleetManager {
12841
13124
  fleetConfig: () => this.fleetConfig,
12842
13125
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
12843
13126
  eventLog: () => this.eventLog,
12844
- recoverBackendInstances: backend => this.recoverBackendInstances(backend),
13127
+ recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
12845
13128
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
12846
13129
  releaseWindow: claim => { this.loginWindow.release(claim); },
12847
13130
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -12850,11 +13133,12 @@ export class FleetManager {
12850
13133
  reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
12851
13134
  // Throws when the prompt cannot be posted: the controller tells the
12852
13135
  // user so instead of leaving "Starting…" as the last word (#1133).
12853
- postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
13136
+ postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
12854
13137
  await this.postNonceButtonPromptOrThrow({
12855
13138
  prefix, alertType: "login", instanceName,
12856
13139
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
12857
13140
  message, choices, expiredText,
13141
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12858
13142
  });
12859
13143
  },
12860
13144
  });
@@ -13068,10 +13352,20 @@ export class FleetManager {
13068
13352
  // A removed backend (#1280) is neither installed nor signed into: say what replaces it.
13069
13353
  if (isRemovedBackend(wanted))
13070
13354
  return removedBackendMessage(wanted);
13355
+ // #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
13356
+ // whose profiles sign in on the host, is refused here, not after an install it did not need.
13357
+ if (opts.profile !== undefined) {
13358
+ const admitted = admitProfileLogin(wanted, opts.profile);
13359
+ if ("refusal" in admitted)
13360
+ return admitted.refusal;
13361
+ opts = { ...opts, profile: admitted.profile };
13362
+ }
13071
13363
  if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
13072
13364
  const flow = LOGIN_FLOWS[wanted];
13073
13365
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
13074
- return this.startInstallSession(wanted, chat);
13366
+ return opts.profile !== undefined
13367
+ ? this.startInstallSession(wanted, chat, { profile: opts.profile })
13368
+ : this.startInstallSession(wanted, chat);
13075
13369
  }
13076
13370
  this.recordLoginFlow(wanted, "login", chat.userId);
13077
13371
  return this.launchSignIn(backendArg, chat, opts);
@@ -13160,7 +13454,9 @@ export class FleetManager {
13160
13454
  * cancelled: they are still coming back, and are reported as pending so the
13161
13455
  * message can say so instead of implying failure.
13162
13456
  */
13163
- async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
13457
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
13458
+ /** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
13459
+ profile = null) {
13164
13460
  const woken = [];
13165
13461
  const restarted = [];
13166
13462
  const pending = [];
@@ -13168,6 +13464,9 @@ export class FleetManager {
13168
13464
  for (const name of this.configuredBackendInstanceNames()) {
13169
13465
  if (this.backendNameOf(name) !== backend)
13170
13466
  continue;
13467
+ // Only the instances on the login that changed: another profile's (or the default's) credentials did not.
13468
+ if (this.credentialProfileOf(name, backend) !== profile)
13469
+ continue;
13171
13470
  const status = this.getInstanceStatus(name);
13172
13471
  if (status !== "paused" && status !== "running")
13173
13472
  continue;
@@ -13205,14 +13504,16 @@ export class FleetManager {
13205
13504
  if (claimed === "consumed")
13206
13505
  return true;
13207
13506
  const { entry, action: backend } = claimed;
13208
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
13209
- const text = await this.startLoginSession(backend, {
13507
+ const profile = entry.loginProfile;
13508
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
13509
+ const chat = {
13210
13510
  adapter: entry.adapter,
13211
13511
  adapterId: entry.adapterId,
13212
13512
  chatId: entry.chatId,
13213
13513
  threadId: entry.threadId,
13214
13514
  userId: data.userId,
13215
- });
13515
+ };
13516
+ const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13216
13517
  if (text)
13217
13518
  await this.postPromptOutcome(entry, text);
13218
13519
  return true;
@@ -13226,11 +13527,13 @@ export class FleetManager {
13226
13527
  return true;
13227
13528
  const { entry, action } = claimed;
13228
13529
  const backend = entry.instanceName;
13530
+ const profile = entry.loginProfile;
13531
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
13229
13532
  if (action === "cancel") {
13230
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
13533
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
13231
13534
  return true;
13232
13535
  }
13233
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
13536
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
13234
13537
  const text = await this.startLoginSession(backend, {
13235
13538
  adapter: entry.adapter,
13236
13539
  adapterId: entry.adapterId,
@@ -13242,6 +13545,7 @@ export class FleetManager {
13242
13545
  tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
13243
13546
  // The consent is the button itself: only the explicit "Open public link" actions carry it.
13244
13547
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
13548
+ ...(profile ? { profile } : {}),
13245
13549
  });
13246
13550
  if (text)
13247
13551
  await this.postPromptOutcome(entry, text);
@@ -13269,7 +13573,7 @@ export class FleetManager {
13269
13573
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
13270
13574
  installHandoff = null;
13271
13575
  /** Start a CLI install session. Caller enforces admin. */
13272
- async startInstallSession(backendArg, chat) {
13576
+ async startInstallSession(backendArg, chat, signIn = {}) {
13273
13577
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
13274
13578
  const info = BACKEND_INSTALLATION_INFO[backend];
13275
13579
  if (!info)
@@ -13353,7 +13657,8 @@ export class FleetManager {
13353
13657
  // without a click.
13354
13658
  // launchSignIn, not startLoginSession: if the new binary were still
13355
13659
  // not visible, startLoginSession would route straight back to an install.
13356
- const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
13660
+ // A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
13661
+ const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
13357
13662
  if (next) {
13358
13663
  await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
13359
13664
  .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
@@ -13461,8 +13766,16 @@ export class FleetManager {
13461
13766
  return;
13462
13767
  }
13463
13768
  const backend = String(data.options?.backend ?? "").trim();
13769
+ // #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
13770
+ // and one without a backend is refused rather than becoming the default login's picker.
13771
+ const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
13772
+ const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
13773
+ if (profileSupplied && !backend) {
13774
+ await data.respond(t("login.profile_needs_backend"));
13775
+ return;
13776
+ }
13464
13777
  if (backend) {
13465
- const text = await this.startLoginSession(backend, chat);
13778
+ const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13466
13779
  await data.respond(text ?? t("login.confirm_posted"));
13467
13780
  return;
13468
13781
  }
@@ -14319,6 +14632,49 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14319
14632
  });
14320
14633
  }
14321
14634
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
14635
+ /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
14636
+ runtimeSwitchWaiters = new Map();
14637
+ /**
14638
+ * #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
14639
+ * when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
14640
+ * "not confirmed".
14641
+ */
14642
+ runRuntimeModelSwitch(instanceName, model) {
14643
+ const ipc = this.instanceIpcClients.get(instanceName);
14644
+ if (!ipc)
14645
+ return Promise.resolve({ outcome: "not_sent" });
14646
+ const id = `switch-${randomUUID()}`;
14647
+ return new Promise(resolve => {
14648
+ const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
14649
+ timer.unref?.();
14650
+ this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
14651
+ const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
14652
+ if (!sent) {
14653
+ clearTimeout(timer);
14654
+ this.runtimeSwitchWaiters.delete(id);
14655
+ resolve({ outcome: "not_sent" });
14656
+ return;
14657
+ }
14658
+ this.logger.info({ instanceName, model }, "Runtime model switch sent");
14659
+ });
14660
+ }
14661
+ /**
14662
+ * #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
14663
+ * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
14664
+ * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
14665
+ */
14666
+ claudeModelKnown(instanceName, model) {
14667
+ const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
14668
+ const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
14669
+ const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
14670
+ const lower = model.trim().toLowerCase();
14671
+ const bare = lower.replace(/\[1m\]$/, "");
14672
+ const known = aliases.some(a => a.toLowerCase() === bare)
14673
+ || api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
14674
+ || (typeof configured === "string" && configured.toLowerCase() === lower)
14675
+ || (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
14676
+ return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
14677
+ }
14322
14678
  pasteRawToClassicInstance(instanceName, text) {
14323
14679
  const ipc = this.instanceIpcClients.get(instanceName);
14324
14680
  if (!ipc) {
@@ -14918,7 +15274,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14918
15274
  try {
14919
15275
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14920
15276
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14921
- ? await this.applyEffort(pending.instanceName, level) : t("menu.click_stale");
15277
+ ? await withSpawnHoldReply(async (text) => {
15278
+ if (pending.adapter && pending.adapterChatId) {
15279
+ if (progressMsgId)
15280
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
15281
+ else
15282
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
15283
+ }
15284
+ else
15285
+ await pending.respond(text);
15286
+ }, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
14922
15287
  }
14923
15288
  catch (err) {
14924
15289
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -15243,7 +15608,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15243
15608
  try {
15244
15609
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
15245
15610
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
15246
- ? await this.applyModel(pending.instanceName, model) : t("menu.click_stale");
15611
+ ? await withSpawnHoldReply(async (text) => {
15612
+ if (pending.adapter && pending.adapterChatId) {
15613
+ if (progressMsgId)
15614
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
15615
+ else
15616
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
15617
+ }
15618
+ else
15619
+ await pending.respond(text);
15620
+ }, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
15247
15621
  }
15248
15622
  catch (err) {
15249
15623
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -15377,21 +15751,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15377
15751
  }
15378
15752
  catch { /* default restart */ }
15379
15753
  const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
15754
+ // #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
15755
+ // nothing is typed or saved for an unknown one.
15756
+ if (backendName === "claude-code") {
15757
+ const check = this.claudeModelKnown(instanceName, model);
15758
+ if (!check.known)
15759
+ return t("model.unknown_name", model, check.names.join(", "));
15760
+ }
15380
15761
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
15381
15762
  return `${warn}${t("effort.not_running", instanceName)}`;
15382
15763
  }
15764
+ // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
15765
+ if (strategy === "runtime" && backendName === "claude-code") {
15766
+ const result = await this.runRuntimeModelSwitch(instanceName, model);
15767
+ if (result.outcome === "mismatch")
15768
+ return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
15769
+ if (result.outcome === "not_sent")
15770
+ return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
15771
+ const persistedNow = this.persistInstanceModel(instanceName, model);
15772
+ if (!persistedNow)
15773
+ return `${warn}${t("model.persist_failed", instanceName)}`;
15774
+ const head = result.outcome === "switched"
15775
+ ? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
15776
+ : t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
15777
+ return `${warn}${head}${this.effortSuffix(instanceName)}`;
15778
+ }
15383
15779
  // Persist either way: a runtime switch must survive the next respawn too,
15384
15780
  // or the instance silently reverts to the CLI default after a fleet restart.
15385
- let persisted = false;
15386
- if (this.fleetConfig?.instances[instanceName]) {
15387
- this.fleetConfig.instances[instanceName].model = model;
15388
- this.saveFleetConfig();
15389
- persisted = true;
15390
- }
15391
- else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
15392
- persisted = true;
15393
- }
15394
- if (!persisted)
15781
+ if (!this.persistInstanceModel(instanceName, model))
15395
15782
  return `${warn}${t("model.persist_failed", instanceName)}`;
15396
15783
  if (strategy === "runtime") {
15397
15784
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
@@ -15400,6 +15787,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15400
15787
  await this.restartSingleInstance(instanceName);
15401
15788
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
15402
15789
  }
15790
+ /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
15791
+ persistInstanceModel(instanceName, model) {
15792
+ if (this.fleetConfig?.instances[instanceName]) {
15793
+ this.fleetConfig.instances[instanceName].model = model;
15794
+ this.saveFleetConfig();
15795
+ return true;
15796
+ }
15797
+ return this.classicChannels?.setModelByInstance(instanceName, model) === true;
15798
+ }
15403
15799
  /**
15404
15800
  * The trailing "Current effort: …" line for a /model reply.
15405
15801
  *
@@ -15510,6 +15906,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15510
15906
  }
15511
15907
  /** Handle Discord's required static slash choice, warning before a likely startup failure. */
15512
15908
  async handleClassicStartSlash(data, adapterId) {
15909
+ return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
15910
+ }
15911
+ async handleClassicStartSlashCore(data, adapterId) {
15513
15912
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
15514
15913
  if (!requestedBackend) {
15515
15914
  // beta.31 made this option required. Discord can briefly retain the old
@@ -15626,7 +16025,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15626
16025
  // failure. The selected backend is still attempted as requested.
15627
16026
  if (warning)
15628
16027
  await pending.complete(warning, pending.messageId);
15629
- const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
16028
+ const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
15630
16029
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
15631
16030
  }
15632
16031
  /** Start a classic channel instance with lightweight config */
@@ -18092,14 +18491,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18092
18491
  return;
18093
18492
  }
18094
18493
  (async () => {
18494
+ let held = false;
18095
18495
  try {
18096
18496
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
18097
- await this.startInstance(name, config, topicMode ?? false, "fleet-topic", true);
18497
+ await withSpawnHoldReply(async (message) => {
18498
+ held = true;
18499
+ res.writeHead(202, { "Content-Type": "application/json" });
18500
+ res.end(JSON.stringify({ held: true, message, instance: name }));
18501
+ }, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
18098
18502
  this.emitSseEvent("status", this.getUiStatus());
18503
+ if (held)
18504
+ return;
18099
18505
  res.writeHead(200);
18100
18506
  res.end(JSON.stringify({ ok: true }));
18101
18507
  }
18102
18508
  catch (err) {
18509
+ if (held) {
18510
+ this.logger.error({ err, name }, "Held start failed");
18511
+ return;
18512
+ }
18103
18513
  res.writeHead(500);
18104
18514
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
18105
18515
  }
@@ -18114,15 +18524,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18114
18524
  const name = decodeURIComponent(req.url.slice("/restart/".length));
18115
18525
  this.logger.info({ name }, "Instance restart requested via HTTP");
18116
18526
  (async () => {
18527
+ let held = false;
18117
18528
  try {
18118
- await this.restartSingleInstance(name, { explicit: true });
18529
+ await withSpawnHoldReply(async (message) => {
18530
+ held = true;
18531
+ res.writeHead(202, { "Content-Type": "application/json" });
18532
+ res.end(JSON.stringify({ held: true, message, instance: name }));
18533
+ }, () => this.restartSingleInstance(name, { explicit: true }));
18119
18534
  this.logger.info({ name }, "Instance restarted");
18120
18535
  this.emitSseEvent("status", this.getUiStatus());
18536
+ if (held)
18537
+ return;
18121
18538
  res.writeHead(200);
18122
18539
  res.end(JSON.stringify({ restarted: name }));
18123
18540
  }
18124
18541
  catch (err) {
18125
18542
  this.logger.error({ err, name }, "Instance restart failed");
18543
+ if (held)
18544
+ return;
18126
18545
  const status = err.message.includes("not found") ? 404 : 500;
18127
18546
  res.writeHead(status);
18128
18547
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
@@ -18291,7 +18710,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18291
18710
  this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
18292
18711
  return;
18293
18712
  }
18294
- const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig });
18713
+ const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
18714
+ // #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
18715
+ extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
18295
18716
  this.previewListener = listener;
18296
18717
  listener.server.on("error", (err) => {
18297
18718
  if (this.previewListener !== listener)
@@ -18340,6 +18761,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18340
18761
  decided.reason = "Previews are not available on this fleet right now.";
18341
18762
  return { ...decided, boot: listener ? listener.bootId : null };
18342
18763
  }
18764
+ /**
18765
+ * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
18766
+ * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
18767
+ */
18768
+ publicPreviewForUi(exposureId, dashboardOrigin) {
18769
+ const web = this.fleetConfig?.web;
18770
+ const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
18771
+ if (web?.preview === false)
18772
+ return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
18773
+ if (web?.preview_on_public_link === false)
18774
+ return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
18775
+ const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
18776
+ if (configured) {
18777
+ const listener = this.previewListening ? this.previewListener : null;
18778
+ if (!listener)
18779
+ return off("publicLink", "Previews are not available on this fleet right now.");
18780
+ if (!configured.startsWith("https:"))
18781
+ return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
18782
+ return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
18783
+ }
18784
+ const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
18785
+ if (!status)
18786
+ return off("publicLink", "Previews are not offered on this public link.");
18787
+ // The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
18788
+ // origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
18789
+ if (status.state === "starting")
18790
+ return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
18791
+ if (status.state === "failed")
18792
+ return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
18793
+ return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
18794
+ }
18343
18795
  getUiStatus() {
18344
18796
  return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
18345
18797
  }
@@ -18438,6 +18890,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18438
18890
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
18439
18891
  // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
18440
18892
  prefs_rev: this.webPrefs.rev(),
18893
+ // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
18894
+ // shown here rather than in chat). null when normal, unknown or advisory.
18895
+ host_memory: this.hostMemoryIndicator(),
18441
18896
  };
18442
18897
  }
18443
18898
  }