@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.4

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 (131) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/auth-api.js +2 -0
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +39 -2
  6. package/dist/backend/claude-code.js +115 -31
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/credential-profile.d.ts +33 -3
  9. package/dist/backend/credential-profile.js +107 -5
  10. package/dist/backend/credential-profile.js.map +1 -1
  11. package/dist/backend/types.d.ts +1 -1
  12. package/dist/cache-service.d.ts +2 -0
  13. package/dist/cache-service.js +2 -1
  14. package/dist/cache-service.js.map +1 -1
  15. package/dist/channel/adapters/discord.d.ts +14 -1
  16. package/dist/channel/adapters/discord.js +42 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/channel/adapters/telegram.d.ts +18 -3
  19. package/dist/channel/adapters/telegram.js +66 -10
  20. package/dist/channel/adapters/telegram.js.map +1 -1
  21. package/dist/channel/telegram-markdown.d.ts +6 -0
  22. package/dist/channel/telegram-markdown.js +104 -0
  23. package/dist/channel/telegram-markdown.js.map +1 -0
  24. package/dist/channel/tool-router.js +5 -4
  25. package/dist/channel/tool-router.js.map +1 -1
  26. package/dist/channel/types.d.ts +27 -4
  27. package/dist/config-validator.js +10 -5
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/daemon.d.ts +108 -0
  30. package/dist/daemon.js +432 -32
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/delivery-outbox.d.ts +29 -7
  33. package/dist/delivery-outbox.js +134 -16
  34. package/dist/delivery-outbox.js.map +1 -1
  35. package/dist/delivery-reconciliation.d.ts +18 -1
  36. package/dist/delivery-reconciliation.js +67 -2
  37. package/dist/delivery-reconciliation.js.map +1 -1
  38. package/dist/fleet-context.d.ts +1 -0
  39. package/dist/fleet-manager.d.ts +81 -4
  40. package/dist/fleet-manager.js +452 -85
  41. package/dist/fleet-manager.js.map +1 -1
  42. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  43. package/dist/host-memory.d.ts +10 -0
  44. package/dist/host-memory.js +33 -1
  45. package/dist/host-memory.js.map +1 -1
  46. package/dist/instance-lifecycle.d.ts +25 -6
  47. package/dist/instance-lifecycle.js +73 -26
  48. package/dist/instance-lifecycle.js.map +1 -1
  49. package/dist/locale.js +48 -18
  50. package/dist/locale.js.map +1 -1
  51. package/dist/login-controller.d.ts +20 -3
  52. package/dist/login-controller.js +71 -19
  53. package/dist/login-controller.js.map +1 -1
  54. package/dist/login-flows.d.ts +5 -2
  55. package/dist/login-flows.js +5 -4
  56. package/dist/login-flows.js.map +1 -1
  57. package/dist/memory-pressure.d.ts +13 -1
  58. package/dist/memory-pressure.js +50 -10
  59. package/dist/memory-pressure.js.map +1 -1
  60. package/dist/outbound-handlers.d.ts +63 -0
  61. package/dist/outbound-handlers.js +121 -46
  62. package/dist/outbound-handlers.js.map +1 -1
  63. package/dist/outbound-schemas.d.ts +3 -2
  64. package/dist/outbound-schemas.js +3 -1
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/public-web-gateway.js +8 -5
  67. package/dist/public-web-gateway.js.map +1 -1
  68. package/dist/settings-api.js +10 -4
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/settings-change.js +2 -2
  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/tips.js +1 -1
  77. package/dist/tips.js.map +1 -1
  78. package/dist/topic-commands.js +9 -2
  79. package/dist/topic-commands.js.map +1 -1
  80. package/dist/transcript-delta-reader.d.ts +3 -3
  81. package/dist/transcript-delta-reader.js +8 -8
  82. package/dist/transcript-delta-reader.js.map +1 -1
  83. package/dist/turn-resume.d.ts +2 -0
  84. package/dist/turn-resume.js +1 -1
  85. package/dist/turn-resume.js.map +1 -1
  86. package/dist/types.d.ts +2 -0
  87. package/dist/ui/chat-mentions.js +63 -0
  88. package/dist/ui/chat-render.js +25 -0
  89. package/dist/ui/chat-store.js +1 -1
  90. package/dist/ui/chat-strings.js +12 -12
  91. package/dist/ui/chat-thread.js +48 -11
  92. package/dist/ui/panel-chat.js +20 -9
  93. package/dist/ui/panel-details.js +2 -0
  94. package/dist/ui/panel-fleet.js +51 -22
  95. package/dist/ui/panel-settings.js +18 -9
  96. package/dist/ui/preview-panel.js +48 -14
  97. package/dist/ui/preview.js +5 -1
  98. package/dist/ui/settings-strings.js +6 -2
  99. package/dist/ui/settings-token.js +1 -1
  100. package/dist/ui/shared/app-i18n.js +35 -13
  101. package/dist/ui/shared/app-prefs.js +249 -0
  102. package/dist/ui/shared/app-shell.js +17 -4
  103. package/dist/ui/shared/app-store.js +5 -1
  104. package/dist/ui/shared/app.css +41 -7
  105. package/dist/ui/shared/app.js +6 -1
  106. package/dist/ui/shared/header-tools.js +55 -5
  107. package/dist/ui/shared/instance-nav.js +131 -38
  108. package/dist/ui/shared/panel-view.js +6 -0
  109. package/dist/ui/shared/theme.js +39 -7
  110. package/dist/ui/shared/ui-icons.js +1 -0
  111. package/dist/ui/shared/usage-dialog.js +13 -4
  112. package/dist/ui/shared/view-strings.js +26 -2
  113. package/dist/unrecognised-prompt.d.ts +23 -0
  114. package/dist/unrecognised-prompt.js +74 -0
  115. package/dist/unrecognised-prompt.js.map +1 -0
  116. package/dist/view-api.js +5 -0
  117. package/dist/view-api.js.map +1 -1
  118. package/dist/web-api.d.ts +13 -1
  119. package/dist/web-api.js +218 -18
  120. package/dist/web-api.js.map +1 -1
  121. package/dist/web-auth.js +3 -2
  122. package/dist/web-auth.js.map +1 -1
  123. package/dist/web-mentions.d.ts +44 -0
  124. package/dist/web-mentions.js +75 -0
  125. package/dist/web-mentions.js.map +1 -0
  126. package/dist/web-prefs.d.ts +63 -0
  127. package/dist/web-prefs.js +263 -0
  128. package/dist/web-prefs.js.map +1 -0
  129. package/dist/web-session.js +1 -1
  130. package/dist/web-session.js.map +1 -1
  131. package/package.json +1 -1
@@ -4,6 +4,7 @@ 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";
@@ -75,7 +76,7 @@ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, che
75
76
  import { TopicArchiver } from "./topic-archiver.js";
76
77
  import { StatuslineWatcher } from "./statusline-watcher.js";
77
78
  import { outboundHandlers } from "./outbound-handlers.js";
78
- import { DeliveryOutbox } from "./delivery-outbox.js";
79
+ import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
79
80
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
80
81
  // deciding to defer before submission. This is an alert threshold only: the
81
82
  // active manager/target generation pair owns the lane until the daemon reports
@@ -129,6 +130,8 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
129
130
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
130
131
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
131
132
  import { WebChatDiskStore } from "./web-chat-store.js";
133
+ import { SeenUsers, resolveMentions } from "./web-mentions.js";
134
+ import { WebPrefsStore } from "./web-prefs.js";
132
135
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
133
136
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
134
137
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
@@ -150,7 +153,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
150
153
  import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
151
154
  import { resolveInstalledAgend } from "./update-dispatch.js";
152
155
  import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
153
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
156
+ import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
154
157
  import { runBeforeDeadline } from "./deadline.js";
155
158
  import { LoginWindowLock } from "./login-window-lock.js";
156
159
  import { handleSettingsRequest } from "./settings-api.js";
@@ -178,7 +181,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
178
181
  import { buildOrgChart } from "./web-org.js";
179
182
  import { CacheService, WINDOWS } from "./cache-service.js";
180
183
  import { runWebCommand } from "./web-commands.js";
181
- import { claudeProjectKey } from "./backend/claude-code.js";
184
+ import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
182
185
  import { sharedRolloutIndex } from "./rollout-index.js";
183
186
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
184
187
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
@@ -198,7 +201,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
198
201
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
199
202
  const SELF_RESTART_DEADLINE_MS = 300_000;
200
203
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
201
- import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
204
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
202
205
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
203
206
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
204
207
  import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
@@ -427,6 +430,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
427
430
  * and hides exactly the newly released model the user opened `/model` to find.
428
431
  * The wait is announced before it starts, so it reads as progress, not a stall.
429
432
  */
433
+ /** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
434
+ export const RUNTIME_SWITCH_RESULT_MS = 12_000;
430
435
  export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
431
436
  /**
432
437
  * How many CLIs may cold-start at once, from BOTH memory and cores.
@@ -749,8 +754,8 @@ export class FleetManager {
749
754
  memoryLogAt = null;
750
755
  memoryDebugAt = null;
751
756
  memoryLogLevel = null;
752
- memoryNoticeAt = null;
753
- memoryNoticeLevel = null;
757
+ /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
758
+ memoryIndicatorShown = null;
754
759
  // Mirror topic: buffer cross-instance messages, flush every 3s
755
760
  mirrorBuffer = [];
756
761
  mirrorTimer = null;
@@ -762,6 +767,17 @@ export class FleetManager {
762
767
  webChatStore = null;
763
768
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
764
769
  webFiles = new WebFileLedger();
770
+ /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
771
+ seenUsers = new SeenUsers();
772
+ webPrefsStore;
773
+ /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
774
+ get webPrefs() {
775
+ // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
776
+ return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
777
+ exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
778
+ || (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
779
+ }));
780
+ }
765
781
  /**
766
782
  * Read from disk on every access rather than cached at startup: `agend
767
783
  * web-token rotate` runs in a separate process, and a cached copy would keep
@@ -1219,8 +1235,13 @@ export class FleetManager {
1219
1235
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
1220
1236
  const row = outbox.get(event.deliveryId);
1221
1237
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
1222
- const notice = t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1223
- this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
1238
+ // #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
1239
+ const silent = isSilentScheduleDelivery(row);
1240
+ const notice = silent
1241
+ ? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
1242
+ : t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1243
+ this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
1244
+ cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
1224
1245
  this.notifyFleetError(notice);
1225
1246
  }
1226
1247
  }
@@ -1315,7 +1336,9 @@ export class FleetManager {
1315
1336
  // #926: a restart kills the turn that was working on a request, and a
1316
1337
  // resumed CLI does not pick it back up. Remind the owner once per restart.
1317
1338
  try {
1318
- outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, since: new Date() });
1339
+ const now = new Date();
1340
+ this.expireReplyObligations(now);
1341
+ outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
1319
1342
  }
1320
1343
  catch (err) {
1321
1344
  this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
@@ -1333,6 +1356,7 @@ export class FleetManager {
1333
1356
  return;
1334
1357
  const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
1335
1358
  try {
1359
+ this.expireReplyObligations(now);
1336
1360
  for (const name of this.daemons.keys()) {
1337
1361
  if (stateOf(name) === "idle")
1338
1362
  outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
@@ -1344,6 +1368,15 @@ export class FleetManager {
1344
1368
  this.logger.error({ err }, "Reply-obligation sweep failed");
1345
1369
  }
1346
1370
  }
1371
+ expireReplyObligations(now) {
1372
+ const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
1373
+ const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
1374
+ ? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
1375
+ for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
1376
+ this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
1377
+ correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
1378
+ }
1379
+ }
1347
1380
  scheduleDeliveryOutboxPump(delayMs = 0) {
1348
1381
  if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
1349
1382
  return;
@@ -1583,6 +1616,14 @@ export class FleetManager {
1583
1616
  return Math.max(1, Math.min(20, explicit));
1584
1617
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
1585
1618
  }
1619
+ /** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
1620
+ * unknown macOS reading — the advisory case — is unknown, so null too). */
1621
+ hostMemoryIndicator() {
1622
+ if (!this.memoryPressure)
1623
+ return null;
1624
+ const level = this.memoryPressure.snapshot().level;
1625
+ return level === "elevated" || level === "critical" ? level : null;
1626
+ }
1586
1627
  /** Separate cooldown: changing measurements must not create new text-throttle keys. */
1587
1628
  reportMemoryPressure(snapshot) {
1588
1629
  if (this.shuttingDown)
@@ -1593,6 +1634,12 @@ export class FleetManager {
1593
1634
  this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
1594
1635
  }
1595
1636
  const changed = snapshot.level !== this.memoryLogLevel;
1637
+ // #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
1638
+ const indicator = this.hostMemoryIndicator();
1639
+ if (indicator !== this.memoryIndicatorShown) {
1640
+ this.memoryIndicatorShown = indicator;
1641
+ this.emitSseEvent("status", this.getUiStatus());
1642
+ }
1596
1643
  if (this.memoryPressure.advisoryOnly()) {
1597
1644
  // The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
1598
1645
  this.memoryLogLevel = snapshot.level;
@@ -1613,20 +1660,6 @@ export class FleetManager {
1613
1660
  : "Host memory sample unavailable — slowing new CLI spawns");
1614
1661
  }
1615
1662
  this.memoryLogLevel = snapshot.level;
1616
- if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
1617
- return;
1618
- const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
1619
- if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1620
- return;
1621
- const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1622
- const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
1623
- const text = this.memoryPressure.platform === "darwin"
1624
- ? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
1625
- : t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
1626
- if (this.notifyFleetError(text, { throttle: false })) {
1627
- this.memoryNoticeAt = now;
1628
- this.memoryNoticeLevel = snapshot.level;
1629
- }
1630
1663
  }
1631
1664
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1632
1665
  bindStormWindowEvents() {
@@ -3020,13 +3053,38 @@ export class FleetManager {
3020
3053
  }
3021
3054
  return posted;
3022
3055
  }
3023
- reserveWebChannelEcho(instanceName, sendEcho) {
3056
+ /**
3057
+ * #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
3058
+ * inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
3059
+ * id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
3060
+ */
3061
+ startWebChatProgress(instanceName) {
3062
+ if (this.shuttingDown)
3063
+ return Promise.resolve();
3064
+ return this.sendCancelButton(instanceName);
3065
+ }
3066
+ /**
3067
+ * `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
3068
+ * keeps its place in the order, but the lane's copy rules do not apply to it: its 5 s budget starts when its turn
3069
+ * starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
3070
+ * behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
3071
+ * any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
3072
+ * however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
3073
+ * deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
3074
+ */
3075
+ reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
3076
+ const copy = opts.copy !== false;
3024
3077
  const epoch = this.getDeliveryEpoch(instanceName);
3025
- const deadlineAt = performance.now() + 5_000;
3078
+ const queued = this.webChannelEchoTails.get(instanceName);
3079
+ // The last open non-copy ahead of this copy: its budget starts when that one finishes.
3080
+ const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
3081
+ let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
3026
3082
  let decide;
3027
3083
  const admission = new Promise(resolve => { decide = resolve; });
3028
- const queue = this.webChannelEchoTails.get(instanceName)
3084
+ const queue = queued
3029
3085
  ?? { tail: Promise.resolve(), pending: new Set() };
3086
+ const seq = queue.nextSeq ?? 0;
3087
+ queue.nextSeq = seq + 1;
3030
3088
  const previous = queue.tail;
3031
3089
  let resolveDone;
3032
3090
  const tail = new Promise(resolve => { resolveDone = resolve; });
@@ -3037,6 +3095,8 @@ export class FleetManager {
3037
3095
  if (done)
3038
3096
  return;
3039
3097
  done = true;
3098
+ if (!copy)
3099
+ queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
3040
3100
  clearTimeout(timer);
3041
3101
  queue.pending.delete(entry);
3042
3102
  if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
@@ -3044,7 +3104,7 @@ export class FleetManager {
3044
3104
  }
3045
3105
  resolveDone();
3046
3106
  };
3047
- const entry = { started: false, drop: () => {
3107
+ const entry = { seq, started: false, copy, drop: () => {
3048
3108
  this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
3049
3109
  finish();
3050
3110
  } };
@@ -3054,14 +3114,16 @@ export class FleetManager {
3054
3114
  expired = true;
3055
3115
  this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
3056
3116
  finish();
3057
- // Do not post copies that were queued behind an ambiguous platform send.
3058
- for (const pending of queue.pending)
3059
- if (!pending.started)
3060
- pending.drop();
3117
+ // Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
3118
+ // at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
3119
+ if (copy)
3120
+ for (const pending of queue.pending)
3121
+ if (pending.seq > seq && !pending.started && pending.copy)
3122
+ pending.drop();
3061
3123
  };
3062
3124
  const checkDeadline = () => {
3063
- if (done)
3064
- return;
3125
+ if (done || deadlineAt === Number.POSITIVE_INFINITY)
3126
+ return; // a non-copy's budget has not started yet
3065
3127
  const remaining = deadlineAt - performance.now();
3066
3128
  if (remaining <= 0)
3067
3129
  expire();
@@ -3070,8 +3132,19 @@ export class FleetManager {
3070
3132
  };
3071
3133
  queue.pending.add(entry);
3072
3134
  queue.tail = tail;
3135
+ if (!copy) {
3136
+ queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
3137
+ queue.lastNonCopyDone = tail;
3138
+ }
3073
3139
  this.webChannelEchoTails.set(instanceName, queue);
3074
3140
  checkDeadline();
3141
+ // Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
3142
+ // is set by the time that runs.
3143
+ if (nonCopyAhead)
3144
+ void nonCopyAhead.then(() => { if (!done) {
3145
+ deadlineAt = performance.now() + 5_000;
3146
+ checkDeadline();
3147
+ } });
3075
3148
  void Promise.all([previous, admission]).then(([, accepted]) => {
3076
3149
  if (done)
3077
3150
  return;
@@ -3080,11 +3153,15 @@ export class FleetManager {
3080
3153
  return;
3081
3154
  }
3082
3155
  // A delayed timer callback must not admit an already expired copy.
3083
- if (performance.now() >= deadlineAt) {
3156
+ if (copy && performance.now() >= deadlineAt) {
3084
3157
  expire();
3085
3158
  return;
3086
3159
  }
3087
3160
  entry.started = true;
3161
+ if (!copy) {
3162
+ deadlineAt = performance.now() + 5_000;
3163
+ checkDeadline();
3164
+ }
3088
3165
  guard = { epoch, deadlineAt };
3089
3166
  this.webChannelEchoGuards.set(instanceName, guard);
3090
3167
  let request;
@@ -3251,6 +3328,9 @@ export class FleetManager {
3251
3328
  * the check and the act, so the channel's instance cannot be rebound to another bot in between.
3252
3329
  */
3253
3330
  async dispatchSlash(slash, adapterId, adapter) {
3331
+ return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
3332
+ }
3333
+ async dispatchSlashCore(slash, adapterId, adapter) {
3254
3334
  // #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
3255
3335
  const data = { ...slash, command: canonicalCommand(slash.command) };
3256
3336
  const door = this.slashDoor(data, adapterId);
@@ -4710,6 +4790,16 @@ export class FleetManager {
4710
4790
  await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4711
4791
  }
4712
4792
  }
4793
+ /**
4794
+ * #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
4795
+ * Returns null when no botUsername is known or the username fails Telegram's format validation.
4796
+ */
4797
+ tgStartWebUrl(adapterId) {
4798
+ const username = this.worlds.get(adapterId)?.botUsername;
4799
+ if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
4800
+ return null;
4801
+ return `https://t.me/${username}?start=web`;
4802
+ }
4713
4803
  async sendLocalDashboard(data, adapterId) {
4714
4804
  if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4715
4805
  await data.respond(t("not_authorized"));
@@ -4831,10 +4921,12 @@ export class FleetManager {
4831
4921
  const adapter = owner.binding;
4832
4922
  const status = this.getPublicWebStatus();
4833
4923
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
4924
+ const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
4834
4925
  const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4835
4926
  + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4836
4927
  + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
4837
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "");
4928
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
4929
+ + (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
4838
4930
  const choices = [{ action: "local", label: t("dashboard.local") },
4839
4931
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4840
4932
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
@@ -4977,7 +5069,11 @@ export class FleetManager {
4977
5069
  : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
4978
5070
  await progress;
4979
5071
  await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
4980
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
5072
+ // #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
5073
+ const failUrlButtons = (!ok && owner.binding.type === "telegram")
5074
+ ? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
5075
+ : undefined;
5076
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
4981
5077
  return true;
4982
5078
  }
4983
5079
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5322,7 +5418,11 @@ export class FleetManager {
5322
5418
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
5323
5419
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
5324
5420
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
5325
- this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
5421
+ this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
5422
+ const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
5423
+ // #1699 follow-up: each instance's own login — a profile is probed with its own environment.
5424
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
5425
+ }));
5326
5426
  // Start tmux control mode client for idle detection
5327
5427
  if (!this.controlClient) {
5328
5428
  this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
@@ -6510,6 +6610,14 @@ export class FleetManager {
6510
6610
  });
6511
6611
  }
6512
6612
  }
6613
+ else if (msg.type === "runtime_switch_result") {
6614
+ // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
6615
+ const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
6616
+ if (waiter) {
6617
+ this.runtimeSwitchWaiters.delete(msg.id);
6618
+ waiter(msg);
6619
+ }
6620
+ }
6513
6621
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
6514
6622
  this.cacheInstanceExecutionState(name, msg);
6515
6623
  if (msg.type === "instance_state_response") {
@@ -7022,6 +7130,16 @@ export class FleetManager {
7022
7130
  });
7023
7131
  }
7024
7132
  async handleInboundMessage(msg) {
7133
+ if (msg.isBotMessage)
7134
+ return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7135
+ const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapters.get(msg.adapterId ?? "") ?? this.adapter;
7136
+ return withSpawnHoldReply(text => adapter?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
7137
+ }
7138
+ async handleInboundMessageCore(msg) {
7139
+ // #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
7140
+ // the instance they belong to).
7141
+ if (!msg.isBotMessage && msg.userId && msg.username)
7142
+ this.seenUsers.record(msg.userId, msg.username);
7025
7143
  // Platform author identity is the authority. This runs before routing,
7026
7144
  // collab/commands/access/dedup and does not depend on any echo setting/ACK.
7027
7145
  const fleetBotIds = new Set();
@@ -7177,6 +7295,16 @@ export class FleetManager {
7177
7295
  }
7178
7296
  // Handle /start command
7179
7297
  if (text === "/start" || text.startsWith("/start ")) {
7298
+ // #1686: intercept /start web before the ClassicBot admission flow runs.
7299
+ // /start alone already opens the DM for future bot-to-user messages — no further action needed.
7300
+ const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7301
+ if (requestedBackend === "web" && isPrivateChat) {
7302
+ if (this.isFleetAdmin(msg.userId, msg.adapterId ?? "")) {
7303
+ await msgAdapter?.sendText(chatId, t("dashboard.start_web_ack"));
7304
+ }
7305
+ // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
7306
+ return;
7307
+ }
7180
7308
  // All new starts, including explicit backend and later chooser callbacks,
7181
7309
  // share validateClassicStart. Existing channels are recognized first.
7182
7310
  const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
@@ -7185,7 +7313,6 @@ export class FleetManager {
7185
7313
  return;
7186
7314
  }
7187
7315
  const channelName = msg.username || chatId;
7188
- const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7189
7316
  if (requestedBackend) {
7190
7317
  // handleClassicStart binds the instance to this adapter authoritatively.
7191
7318
  const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
@@ -9350,7 +9477,9 @@ export class FleetManager {
9350
9477
  else
9351
9478
  await this.lifecycle.remove(name, authorization);
9352
9479
  // A later same-name registration owns its files and caches.
9353
- if (this.fleetConfig?.instances[name] || this.daemons.has(name))
9480
+ // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
9481
+ // re-registration) — #1677 review.
9482
+ if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
9354
9483
  return;
9355
9484
  execution?.assert();
9356
9485
  // Clean up per-instance tracking maps so they don't grow unbounded
@@ -9362,6 +9491,13 @@ export class FleetManager {
9362
9491
  // must not be shown the old one's conversation, and deleted names must not pile up.
9363
9492
  this.webChatHistory.forget(name);
9364
9493
  this.webFiles.forget(name);
9494
+ // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
9495
+ try {
9496
+ this.webPrefs.forgetInstance(name);
9497
+ }
9498
+ catch (err) {
9499
+ this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
9500
+ }
9365
9501
  // Clean up statusline watcher + instance directory
9366
9502
  this.statuslineWatcher.unwatch(name);
9367
9503
  // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
@@ -9663,6 +9799,45 @@ export class FleetManager {
9663
9799
  return null;
9664
9800
  }
9665
9801
  }
9802
+ /**
9803
+ * #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
9804
+ * the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
9805
+ * profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
9806
+ * (members, users, roles, channels).
9807
+ */
9808
+ webMentionNames(query) {
9809
+ const bots = (q) => {
9810
+ const u = Object.create(null);
9811
+ if (!q.u.length)
9812
+ return { u };
9813
+ let own = new Map();
9814
+ try {
9815
+ own = this.ownBots();
9816
+ }
9817
+ catch { /* none known */ }
9818
+ const profiles = profileIdentities(this.dataDir);
9819
+ const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
9820
+ for (const [instance, connection] of own) {
9821
+ const id = this.adapters.get(connection)?.getBotUserId?.();
9822
+ if (!id || !q.u.includes(id))
9823
+ continue;
9824
+ const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
9825
+ u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
9826
+ }
9827
+ return { u };
9828
+ };
9829
+ const seen = (q) => {
9830
+ const u = Object.create(null);
9831
+ for (const id of q.u) {
9832
+ const name = this.seenUsers.get(id);
9833
+ if (name)
9834
+ u[id] = name;
9835
+ }
9836
+ return { u };
9837
+ };
9838
+ const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
9839
+ return resolveMentions(query, [bots, seen, ...caches]);
9840
+ }
9666
9841
  /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
9667
9842
  async webEmojiList() {
9668
9843
  const adopted = [];
@@ -11106,6 +11281,48 @@ export class FleetManager {
11106
11281
  });
11107
11282
  }
11108
11283
  cacheService = null;
11284
+ /**
11285
+ * #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
11286
+ * ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
11287
+ * (also for a backend that has no profiles, where the option is ignored).
11288
+ */
11289
+ credentialProfileOf(name, backend) {
11290
+ if (!credentialHomeSpec(backend))
11291
+ return null;
11292
+ const cfg = this.fleetConfig?.instances?.[name];
11293
+ if (cfg)
11294
+ return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
11295
+ const launched = this.daemons.get(name)?.getConfigSnapshot?.();
11296
+ if (launched)
11297
+ return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
11298
+ if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
11299
+ return null;
11300
+ return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
11301
+ }
11302
+ /**
11303
+ * #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
11304
+ * (CLAUDE_CONFIG_DIR or ~/.claude).
11305
+ */
11306
+ claudeConfigDirOf(name) {
11307
+ const profile = this.credentialProfileOf(name, "claude-code");
11308
+ return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
11309
+ }
11310
+ /** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
11311
+ cacheInstances() {
11312
+ const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
11313
+ name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
11314
+ claudeConfigDir: this.claudeConfigDirOf(name),
11315
+ }));
11316
+ const taken = new Set(fleet.map(i => i.name));
11317
+ const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
11318
+ name: ch.instanceName,
11319
+ backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
11320
+ workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
11321
+ ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
11322
+ claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
11323
+ }));
11324
+ return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
11325
+ }
11109
11326
  /**
11110
11327
  * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
11111
11328
  * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
@@ -11114,20 +11331,7 @@ export class FleetManager {
11114
11331
  cacheReport(window) {
11115
11332
  const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
11116
11333
  this.cacheService ??= new CacheService({
11117
- // fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
11118
- instances: () => {
11119
- const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
11120
- name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
11121
- }));
11122
- const taken = new Set(fleet.map(i => i.name));
11123
- const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
11124
- name: ch.instanceName,
11125
- backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
11126
- workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
11127
- ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
11128
- }));
11129
- return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
11130
- },
11334
+ instances: () => this.cacheInstances(),
11131
11335
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
11132
11336
  claudeKey: claudeProjectKey,
11133
11337
  codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
@@ -11314,7 +11518,9 @@ export class FleetManager {
11314
11518
  }
11315
11519
  async retireNonceButtons(pending, messageId, text,
11316
11520
  /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
11317
- editClicked) {
11521
+ editClicked,
11522
+ /** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
11523
+ urlButtons) {
11318
11524
  this.webPromptGone(pending, text);
11319
11525
  try {
11320
11526
  const retire = editClicked ?? pending.retire;
@@ -11324,7 +11530,12 @@ export class FleetManager {
11324
11530
  }
11325
11531
  if (!pending.adapter.editMessageRemoveButtons)
11326
11532
  throw new Error("adapter cannot remove prompt buttons");
11327
- await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
11533
+ if (urlButtons) {
11534
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
11535
+ }
11536
+ else {
11537
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
11538
+ }
11328
11539
  }
11329
11540
  catch (err) {
11330
11541
  // The action was already atomically consumed. An edit failure must not
@@ -11938,9 +12149,12 @@ export class FleetManager {
11938
12149
  * Backends with no remote login flow (opencode logs in from a terminal) get
11939
12150
  * no button — the alert's own wording already tells them what to run.
11940
12151
  */
11941
- async offerBackendLogin(targetInstance, backend) {
12152
+ async offerBackendLogin(targetInstance, backend, profile) {
11942
12153
  if (!LOGIN_FLOWS[backend])
11943
12154
  return;
12155
+ if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
12156
+ return; // that profile signs in on the host
12157
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
11944
12158
  const adapterId = this.getInstanceAdapterId(targetInstance);
11945
12159
  const adapter = this.getAdapterForInstance(targetInstance);
11946
12160
  const chatId = this.getGroupIdForInstance(targetInstance);
@@ -11958,9 +12172,10 @@ export class FleetManager {
11958
12172
  adapterId,
11959
12173
  chatId,
11960
12174
  threadId,
11961
- message: t("login.offer", backend),
11962
- choices: [{ action: backend, label: t("login.offer_action", backend) }],
12175
+ message: t("login.offer", shown),
12176
+ choices: [{ action: backend, label: t("login.offer_action", shown) }],
11963
12177
  expiredText: t("buttons.stale"),
12178
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
11964
12179
  });
11965
12180
  }
11966
12181
  /** Consume a General assist button exactly once. */
@@ -12773,7 +12988,7 @@ export class FleetManager {
12773
12988
  fleetConfig: () => this.fleetConfig,
12774
12989
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
12775
12990
  eventLog: () => this.eventLog,
12776
- recoverBackendInstances: backend => this.recoverBackendInstances(backend),
12991
+ recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
12777
12992
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
12778
12993
  releaseWindow: claim => { this.loginWindow.release(claim); },
12779
12994
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -12782,11 +12997,12 @@ export class FleetManager {
12782
12997
  reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
12783
12998
  // Throws when the prompt cannot be posted: the controller tells the
12784
12999
  // user so instead of leaving "Starting…" as the last word (#1133).
12785
- postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
13000
+ postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
12786
13001
  await this.postNonceButtonPromptOrThrow({
12787
13002
  prefix, alertType: "login", instanceName,
12788
13003
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
12789
13004
  message, choices, expiredText,
13005
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12790
13006
  });
12791
13007
  },
12792
13008
  });
@@ -13000,10 +13216,20 @@ export class FleetManager {
13000
13216
  // A removed backend (#1280) is neither installed nor signed into: say what replaces it.
13001
13217
  if (isRemovedBackend(wanted))
13002
13218
  return removedBackendMessage(wanted);
13219
+ // #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
13220
+ // whose profiles sign in on the host, is refused here, not after an install it did not need.
13221
+ if (opts.profile !== undefined) {
13222
+ const admitted = admitProfileLogin(wanted, opts.profile);
13223
+ if ("refusal" in admitted)
13224
+ return admitted.refusal;
13225
+ opts = { ...opts, profile: admitted.profile };
13226
+ }
13003
13227
  if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
13004
13228
  const flow = LOGIN_FLOWS[wanted];
13005
13229
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
13006
- return this.startInstallSession(wanted, chat);
13230
+ return opts.profile !== undefined
13231
+ ? this.startInstallSession(wanted, chat, { profile: opts.profile })
13232
+ : this.startInstallSession(wanted, chat);
13007
13233
  }
13008
13234
  this.recordLoginFlow(wanted, "login", chat.userId);
13009
13235
  return this.launchSignIn(backendArg, chat, opts);
@@ -13092,7 +13318,9 @@ export class FleetManager {
13092
13318
  * cancelled: they are still coming back, and are reported as pending so the
13093
13319
  * message can say so instead of implying failure.
13094
13320
  */
13095
- async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
13321
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
13322
+ /** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
13323
+ profile = null) {
13096
13324
  const woken = [];
13097
13325
  const restarted = [];
13098
13326
  const pending = [];
@@ -13100,6 +13328,9 @@ export class FleetManager {
13100
13328
  for (const name of this.configuredBackendInstanceNames()) {
13101
13329
  if (this.backendNameOf(name) !== backend)
13102
13330
  continue;
13331
+ // Only the instances on the login that changed: another profile's (or the default's) credentials did not.
13332
+ if (this.credentialProfileOf(name, backend) !== profile)
13333
+ continue;
13103
13334
  const status = this.getInstanceStatus(name);
13104
13335
  if (status !== "paused" && status !== "running")
13105
13336
  continue;
@@ -13137,14 +13368,16 @@ export class FleetManager {
13137
13368
  if (claimed === "consumed")
13138
13369
  return true;
13139
13370
  const { entry, action: backend } = claimed;
13140
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
13141
- const text = await this.startLoginSession(backend, {
13371
+ const profile = entry.loginProfile;
13372
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
13373
+ const chat = {
13142
13374
  adapter: entry.adapter,
13143
13375
  adapterId: entry.adapterId,
13144
13376
  chatId: entry.chatId,
13145
13377
  threadId: entry.threadId,
13146
13378
  userId: data.userId,
13147
- });
13379
+ };
13380
+ const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13148
13381
  if (text)
13149
13382
  await this.postPromptOutcome(entry, text);
13150
13383
  return true;
@@ -13158,11 +13391,13 @@ export class FleetManager {
13158
13391
  return true;
13159
13392
  const { entry, action } = claimed;
13160
13393
  const backend = entry.instanceName;
13394
+ const profile = entry.loginProfile;
13395
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
13161
13396
  if (action === "cancel") {
13162
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
13397
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
13163
13398
  return true;
13164
13399
  }
13165
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
13400
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
13166
13401
  const text = await this.startLoginSession(backend, {
13167
13402
  adapter: entry.adapter,
13168
13403
  adapterId: entry.adapterId,
@@ -13174,6 +13409,7 @@ export class FleetManager {
13174
13409
  tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
13175
13410
  // The consent is the button itself: only the explicit "Open public link" actions carry it.
13176
13411
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
13412
+ ...(profile ? { profile } : {}),
13177
13413
  });
13178
13414
  if (text)
13179
13415
  await this.postPromptOutcome(entry, text);
@@ -13201,7 +13437,7 @@ export class FleetManager {
13201
13437
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
13202
13438
  installHandoff = null;
13203
13439
  /** Start a CLI install session. Caller enforces admin. */
13204
- async startInstallSession(backendArg, chat) {
13440
+ async startInstallSession(backendArg, chat, signIn = {}) {
13205
13441
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
13206
13442
  const info = BACKEND_INSTALLATION_INFO[backend];
13207
13443
  if (!info)
@@ -13285,7 +13521,8 @@ export class FleetManager {
13285
13521
  // without a click.
13286
13522
  // launchSignIn, not startLoginSession: if the new binary were still
13287
13523
  // not visible, startLoginSession would route straight back to an install.
13288
- const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
13524
+ // A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
13525
+ const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
13289
13526
  if (next) {
13290
13527
  await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
13291
13528
  .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
@@ -13393,8 +13630,16 @@ export class FleetManager {
13393
13630
  return;
13394
13631
  }
13395
13632
  const backend = String(data.options?.backend ?? "").trim();
13633
+ // #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
13634
+ // and one without a backend is refused rather than becoming the default login's picker.
13635
+ const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
13636
+ const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
13637
+ if (profileSupplied && !backend) {
13638
+ await data.respond(t("login.profile_needs_backend"));
13639
+ return;
13640
+ }
13396
13641
  if (backend) {
13397
- const text = await this.startLoginSession(backend, chat);
13642
+ const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13398
13643
  await data.respond(text ?? t("login.confirm_posted"));
13399
13644
  return;
13400
13645
  }
@@ -14251,6 +14496,49 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14251
14496
  });
14252
14497
  }
14253
14498
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
14499
+ /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
14500
+ runtimeSwitchWaiters = new Map();
14501
+ /**
14502
+ * #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
14503
+ * when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
14504
+ * "not confirmed".
14505
+ */
14506
+ runRuntimeModelSwitch(instanceName, model) {
14507
+ const ipc = this.instanceIpcClients.get(instanceName);
14508
+ if (!ipc)
14509
+ return Promise.resolve({ outcome: "not_sent" });
14510
+ const id = `switch-${randomUUID()}`;
14511
+ return new Promise(resolve => {
14512
+ const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
14513
+ timer.unref?.();
14514
+ this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
14515
+ const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
14516
+ if (!sent) {
14517
+ clearTimeout(timer);
14518
+ this.runtimeSwitchWaiters.delete(id);
14519
+ resolve({ outcome: "not_sent" });
14520
+ return;
14521
+ }
14522
+ this.logger.info({ instanceName, model }, "Runtime model switch sent");
14523
+ });
14524
+ }
14525
+ /**
14526
+ * #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
14527
+ * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
14528
+ * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
14529
+ */
14530
+ claudeModelKnown(instanceName, model) {
14531
+ const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
14532
+ const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
14533
+ const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
14534
+ const lower = model.trim().toLowerCase();
14535
+ const bare = lower.replace(/\[1m\]$/, "");
14536
+ const known = aliases.some(a => a.toLowerCase() === bare)
14537
+ || api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
14538
+ || (typeof configured === "string" && configured.toLowerCase() === lower)
14539
+ || (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
14540
+ return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
14541
+ }
14254
14542
  pasteRawToClassicInstance(instanceName, text) {
14255
14543
  const ipc = this.instanceIpcClients.get(instanceName);
14256
14544
  if (!ipc) {
@@ -14850,7 +15138,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14850
15138
  try {
14851
15139
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14852
15140
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14853
- ? await this.applyEffort(pending.instanceName, level) : t("menu.click_stale");
15141
+ ? await withSpawnHoldReply(async (text) => {
15142
+ if (pending.adapter && pending.adapterChatId) {
15143
+ if (progressMsgId)
15144
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
15145
+ else
15146
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
15147
+ }
15148
+ else
15149
+ await pending.respond(text);
15150
+ }, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
14854
15151
  }
14855
15152
  catch (err) {
14856
15153
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -15175,7 +15472,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15175
15472
  try {
15176
15473
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
15177
15474
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
15178
- ? await this.applyModel(pending.instanceName, model) : t("menu.click_stale");
15475
+ ? await withSpawnHoldReply(async (text) => {
15476
+ if (pending.adapter && pending.adapterChatId) {
15477
+ if (progressMsgId)
15478
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
15479
+ else
15480
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
15481
+ }
15482
+ else
15483
+ await pending.respond(text);
15484
+ }, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
15179
15485
  }
15180
15486
  catch (err) {
15181
15487
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -15309,21 +15615,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15309
15615
  }
15310
15616
  catch { /* default restart */ }
15311
15617
  const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
15618
+ // #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
15619
+ // nothing is typed or saved for an unknown one.
15620
+ if (backendName === "claude-code") {
15621
+ const check = this.claudeModelKnown(instanceName, model);
15622
+ if (!check.known)
15623
+ return t("model.unknown_name", model, check.names.join(", "));
15624
+ }
15312
15625
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
15313
15626
  return `${warn}${t("effort.not_running", instanceName)}`;
15314
15627
  }
15628
+ // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
15629
+ if (strategy === "runtime" && backendName === "claude-code") {
15630
+ const result = await this.runRuntimeModelSwitch(instanceName, model);
15631
+ if (result.outcome === "mismatch")
15632
+ return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
15633
+ if (result.outcome === "not_sent")
15634
+ return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
15635
+ const persistedNow = this.persistInstanceModel(instanceName, model);
15636
+ if (!persistedNow)
15637
+ return `${warn}${t("model.persist_failed", instanceName)}`;
15638
+ const head = result.outcome === "switched"
15639
+ ? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
15640
+ : t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
15641
+ return `${warn}${head}${this.effortSuffix(instanceName)}`;
15642
+ }
15315
15643
  // Persist either way: a runtime switch must survive the next respawn too,
15316
15644
  // or the instance silently reverts to the CLI default after a fleet restart.
15317
- let persisted = false;
15318
- if (this.fleetConfig?.instances[instanceName]) {
15319
- this.fleetConfig.instances[instanceName].model = model;
15320
- this.saveFleetConfig();
15321
- persisted = true;
15322
- }
15323
- else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
15324
- persisted = true;
15325
- }
15326
- if (!persisted)
15645
+ if (!this.persistInstanceModel(instanceName, model))
15327
15646
  return `${warn}${t("model.persist_failed", instanceName)}`;
15328
15647
  if (strategy === "runtime") {
15329
15648
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
@@ -15332,6 +15651,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15332
15651
  await this.restartSingleInstance(instanceName);
15333
15652
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
15334
15653
  }
15654
+ /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
15655
+ persistInstanceModel(instanceName, model) {
15656
+ if (this.fleetConfig?.instances[instanceName]) {
15657
+ this.fleetConfig.instances[instanceName].model = model;
15658
+ this.saveFleetConfig();
15659
+ return true;
15660
+ }
15661
+ return this.classicChannels?.setModelByInstance(instanceName, model) === true;
15662
+ }
15335
15663
  /**
15336
15664
  * The trailing "Current effort: …" line for a /model reply.
15337
15665
  *
@@ -15442,6 +15770,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15442
15770
  }
15443
15771
  /** Handle Discord's required static slash choice, warning before a likely startup failure. */
15444
15772
  async handleClassicStartSlash(data, adapterId) {
15773
+ return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
15774
+ }
15775
+ async handleClassicStartSlashCore(data, adapterId) {
15445
15776
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
15446
15777
  if (!requestedBackend) {
15447
15778
  // beta.31 made this option required. Discord can briefly retain the old
@@ -15558,7 +15889,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15558
15889
  // failure. The selected backend is still attempted as requested.
15559
15890
  if (warning)
15560
15891
  await pending.complete(warning, pending.messageId);
15561
- const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
15892
+ const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
15562
15893
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
15563
15894
  }
15564
15895
  /** Start a classic channel instance with lightweight config */
@@ -15639,6 +15970,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15639
15970
  const ch = this.classicChannels.unregister(channelId, adapterId);
15640
15971
  if (!ch)
15641
15972
  return t("classic.no_agent");
15973
+ // #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
15974
+ // instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
15975
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
15976
+ && !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
15977
+ try {
15978
+ this.webPrefs.forgetInstance(ch.instanceName);
15979
+ }
15980
+ catch (err) {
15981
+ this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
15982
+ }
15983
+ }
15642
15984
  this.instanceWorldBinding.delete(ch.instanceName);
15643
15985
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
15644
15986
  clearPausedMarker(this.getInstanceDir(ch.instanceName));
@@ -18013,14 +18355,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18013
18355
  return;
18014
18356
  }
18015
18357
  (async () => {
18358
+ let held = false;
18016
18359
  try {
18017
18360
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
18018
- await this.startInstance(name, config, topicMode ?? false, "fleet-topic", true);
18361
+ await withSpawnHoldReply(async (message) => {
18362
+ held = true;
18363
+ res.writeHead(202, { "Content-Type": "application/json" });
18364
+ res.end(JSON.stringify({ held: true, message, instance: name }));
18365
+ }, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
18019
18366
  this.emitSseEvent("status", this.getUiStatus());
18367
+ if (held)
18368
+ return;
18020
18369
  res.writeHead(200);
18021
18370
  res.end(JSON.stringify({ ok: true }));
18022
18371
  }
18023
18372
  catch (err) {
18373
+ if (held) {
18374
+ this.logger.error({ err, name }, "Held start failed");
18375
+ return;
18376
+ }
18024
18377
  res.writeHead(500);
18025
18378
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
18026
18379
  }
@@ -18035,15 +18388,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18035
18388
  const name = decodeURIComponent(req.url.slice("/restart/".length));
18036
18389
  this.logger.info({ name }, "Instance restart requested via HTTP");
18037
18390
  (async () => {
18391
+ let held = false;
18038
18392
  try {
18039
- await this.restartSingleInstance(name, { explicit: true });
18393
+ await withSpawnHoldReply(async (message) => {
18394
+ held = true;
18395
+ res.writeHead(202, { "Content-Type": "application/json" });
18396
+ res.end(JSON.stringify({ held: true, message, instance: name }));
18397
+ }, () => this.restartSingleInstance(name, { explicit: true }));
18040
18398
  this.logger.info({ name }, "Instance restarted");
18041
18399
  this.emitSseEvent("status", this.getUiStatus());
18400
+ if (held)
18401
+ return;
18042
18402
  res.writeHead(200);
18043
18403
  res.end(JSON.stringify({ restarted: name }));
18044
18404
  }
18045
18405
  catch (err) {
18046
18406
  this.logger.error({ err, name }, "Instance restart failed");
18407
+ if (held)
18408
+ return;
18047
18409
  const status = err.message.includes("not found") ? 404 : 500;
18048
18410
  res.writeHead(status);
18049
18411
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
@@ -18357,6 +18719,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18357
18719
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
18358
18720
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
18359
18721
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
18722
+ // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
18723
+ prefs_rev: this.webPrefs.rev(),
18724
+ // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
18725
+ // shown here rather than in chat). null when normal, unknown or advisory.
18726
+ host_memory: this.hostMemoryIndicator(),
18360
18727
  };
18361
18728
  }
18362
18729
  }