@songsid/agend 2.2.0-beta.3 → 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 (96) 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 +39 -2
  4. package/dist/backend/claude-code.js +115 -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/config-validator.js +4 -0
  21. package/dist/config-validator.js.map +1 -1
  22. package/dist/daemon.d.ts +96 -0
  23. package/dist/daemon.js +339 -16
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/delivery-outbox.d.ts +18 -5
  26. package/dist/delivery-outbox.js +121 -14
  27. package/dist/delivery-outbox.js.map +1 -1
  28. package/dist/fleet-context.d.ts +1 -0
  29. package/dist/fleet-manager.d.ts +59 -3
  30. package/dist/fleet-manager.js +357 -71
  31. package/dist/fleet-manager.js.map +1 -1
  32. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  33. package/dist/host-memory.d.ts +10 -0
  34. package/dist/host-memory.js +33 -1
  35. package/dist/host-memory.js.map +1 -1
  36. package/dist/instance-lifecycle.d.ts +25 -6
  37. package/dist/instance-lifecycle.js +73 -26
  38. package/dist/instance-lifecycle.js.map +1 -1
  39. package/dist/locale.js +44 -18
  40. package/dist/locale.js.map +1 -1
  41. package/dist/login-controller.d.ts +20 -3
  42. package/dist/login-controller.js +71 -19
  43. package/dist/login-controller.js.map +1 -1
  44. package/dist/login-flows.d.ts +5 -2
  45. package/dist/login-flows.js +5 -4
  46. package/dist/login-flows.js.map +1 -1
  47. package/dist/memory-pressure.d.ts +13 -1
  48. package/dist/memory-pressure.js +50 -10
  49. package/dist/memory-pressure.js.map +1 -1
  50. package/dist/outbound-handlers.d.ts +61 -0
  51. package/dist/outbound-handlers.js +100 -35
  52. package/dist/outbound-handlers.js.map +1 -1
  53. package/dist/outbound-schemas.d.ts +1 -0
  54. package/dist/outbound-schemas.js +2 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/public-web-gateway.js +4 -3
  57. package/dist/public-web-gateway.js.map +1 -1
  58. package/dist/settings-api.js +10 -4
  59. package/dist/settings-api.js.map +1 -1
  60. package/dist/spawn-gate.js +5 -1
  61. package/dist/spawn-gate.js.map +1 -1
  62. package/dist/spawn-hold-reply.d.ts +12 -0
  63. package/dist/spawn-hold-reply.js +39 -0
  64. package/dist/spawn-hold-reply.js.map +1 -0
  65. package/dist/topic-commands.js +9 -2
  66. package/dist/topic-commands.js.map +1 -1
  67. package/dist/turn-resume.d.ts +2 -0
  68. package/dist/turn-resume.js +1 -1
  69. package/dist/turn-resume.js.map +1 -1
  70. package/dist/types.d.ts +2 -0
  71. package/dist/ui/chat-mentions.js +63 -0
  72. package/dist/ui/chat-render.js +25 -0
  73. package/dist/ui/chat-thread.js +26 -8
  74. package/dist/ui/panel-chat.js +17 -9
  75. package/dist/ui/panel-details.js +2 -0
  76. package/dist/ui/panel-fleet.js +51 -22
  77. package/dist/ui/panel-settings.js +3 -1
  78. package/dist/ui/preview-panel.js +48 -14
  79. package/dist/ui/preview.js +5 -1
  80. package/dist/ui/settings-token.js +1 -1
  81. package/dist/ui/shared/app-store.js +3 -1
  82. package/dist/ui/shared/app.css +11 -7
  83. package/dist/ui/shared/instance-nav.js +32 -7
  84. package/dist/ui/shared/view-strings.js +6 -0
  85. package/dist/unrecognised-prompt.d.ts +23 -0
  86. package/dist/unrecognised-prompt.js +74 -0
  87. package/dist/unrecognised-prompt.js.map +1 -0
  88. package/dist/web-api.d.ts +3 -0
  89. package/dist/web-api.js +115 -17
  90. package/dist/web-api.js.map +1 -1
  91. package/dist/web-auth.js +1 -1
  92. package/dist/web-auth.js.map +1 -1
  93. package/dist/web-mentions.d.ts +44 -0
  94. package/dist/web-mentions.js +75 -0
  95. package/dist/web-mentions.js.map +1 -0
  96. 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, isSilentScheduleDelivery } 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,7 @@ 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";
132
134
  import { WebPrefsStore } from "./web-prefs.js";
133
135
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
134
136
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
@@ -151,7 +153,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
151
153
  import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
152
154
  import { resolveInstalledAgend } from "./update-dispatch.js";
153
155
  import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
154
- 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";
155
157
  import { runBeforeDeadline } from "./deadline.js";
156
158
  import { LoginWindowLock } from "./login-window-lock.js";
157
159
  import { handleSettingsRequest } from "./settings-api.js";
@@ -179,7 +181,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
179
181
  import { buildOrgChart } from "./web-org.js";
180
182
  import { CacheService, WINDOWS } from "./cache-service.js";
181
183
  import { runWebCommand } from "./web-commands.js";
182
- import { claudeProjectKey } from "./backend/claude-code.js";
184
+ import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
183
185
  import { sharedRolloutIndex } from "./rollout-index.js";
184
186
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
185
187
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
@@ -199,7 +201,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
199
201
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
200
202
  const SELF_RESTART_DEADLINE_MS = 300_000;
201
203
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
202
- import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
204
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
203
205
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
204
206
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
205
207
  import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
@@ -428,6 +430,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
428
430
  * and hides exactly the newly released model the user opened `/model` to find.
429
431
  * The wait is announced before it starts, so it reads as progress, not a stall.
430
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;
431
435
  export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
432
436
  /**
433
437
  * How many CLIs may cold-start at once, from BOTH memory and cores.
@@ -750,8 +754,8 @@ export class FleetManager {
750
754
  memoryLogAt = null;
751
755
  memoryDebugAt = null;
752
756
  memoryLogLevel = null;
753
- memoryNoticeAt = null;
754
- memoryNoticeLevel = null;
757
+ /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
758
+ memoryIndicatorShown = null;
755
759
  // Mirror topic: buffer cross-instance messages, flush every 3s
756
760
  mirrorBuffer = [];
757
761
  mirrorTimer = null;
@@ -763,6 +767,8 @@ export class FleetManager {
763
767
  webChatStore = null;
764
768
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
765
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();
766
772
  webPrefsStore;
767
773
  /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
768
774
  get webPrefs() {
@@ -1330,7 +1336,9 @@ export class FleetManager {
1330
1336
  // #926: a restart kills the turn that was working on a request, and a
1331
1337
  // resumed CLI does not pick it back up. Remind the owner once per restart.
1332
1338
  try {
1333
- 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 });
1334
1342
  }
1335
1343
  catch (err) {
1336
1344
  this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
@@ -1348,6 +1356,7 @@ export class FleetManager {
1348
1356
  return;
1349
1357
  const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
1350
1358
  try {
1359
+ this.expireReplyObligations(now);
1351
1360
  for (const name of this.daemons.keys()) {
1352
1361
  if (stateOf(name) === "idle")
1353
1362
  outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
@@ -1359,6 +1368,15 @@ export class FleetManager {
1359
1368
  this.logger.error({ err }, "Reply-obligation sweep failed");
1360
1369
  }
1361
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
+ }
1362
1380
  scheduleDeliveryOutboxPump(delayMs = 0) {
1363
1381
  if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
1364
1382
  return;
@@ -1598,6 +1616,14 @@ export class FleetManager {
1598
1616
  return Math.max(1, Math.min(20, explicit));
1599
1617
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
1600
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
+ }
1601
1627
  /** Separate cooldown: changing measurements must not create new text-throttle keys. */
1602
1628
  reportMemoryPressure(snapshot) {
1603
1629
  if (this.shuttingDown)
@@ -1608,6 +1634,12 @@ export class FleetManager {
1608
1634
  this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
1609
1635
  }
1610
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
+ }
1611
1643
  if (this.memoryPressure.advisoryOnly()) {
1612
1644
  // The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
1613
1645
  this.memoryLogLevel = snapshot.level;
@@ -1628,20 +1660,6 @@ export class FleetManager {
1628
1660
  : "Host memory sample unavailable — slowing new CLI spawns");
1629
1661
  }
1630
1662
  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
1663
  }
1646
1664
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1647
1665
  bindStormWindowEvents() {
@@ -3310,6 +3328,9 @@ export class FleetManager {
3310
3328
  * the check and the act, so the channel's instance cannot be rebound to another bot in between.
3311
3329
  */
3312
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) {
3313
3334
  // #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
3314
3335
  const data = { ...slash, command: canonicalCommand(slash.command) };
3315
3336
  const door = this.slashDoor(data, adapterId);
@@ -4769,6 +4790,16 @@ export class FleetManager {
4769
4790
  await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4770
4791
  }
4771
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
+ }
4772
4803
  async sendLocalDashboard(data, adapterId) {
4773
4804
  if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4774
4805
  await data.respond(t("not_authorized"));
@@ -4890,10 +4921,12 @@ export class FleetManager {
4890
4921
  const adapter = owner.binding;
4891
4922
  const status = this.getPublicWebStatus();
4892
4923
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
4924
+ const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
4893
4925
  const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4894
4926
  + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4895
4927
  + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
4896
- + (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) : "");
4897
4930
  const choices = [{ action: "local", label: t("dashboard.local") },
4898
4931
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4899
4932
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
@@ -5036,7 +5069,11 @@ export class FleetManager {
5036
5069
  : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
5037
5070
  await progress;
5038
5071
  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);
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);
5040
5077
  return true;
5041
5078
  }
5042
5079
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5381,7 +5418,11 @@ export class FleetManager {
5381
5418
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
5382
5419
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
5383
5420
  // 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"));
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
+ }));
5385
5426
  // Start tmux control mode client for idle detection
5386
5427
  if (!this.controlClient) {
5387
5428
  this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
@@ -6569,6 +6610,14 @@ export class FleetManager {
6569
6610
  });
6570
6611
  }
6571
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
+ }
6572
6621
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
6573
6622
  this.cacheInstanceExecutionState(name, msg);
6574
6623
  if (msg.type === "instance_state_response") {
@@ -7081,6 +7130,16 @@ export class FleetManager {
7081
7130
  });
7082
7131
  }
7083
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);
7084
7143
  // Platform author identity is the authority. This runs before routing,
7085
7144
  // collab/commands/access/dedup and does not depend on any echo setting/ACK.
7086
7145
  const fleetBotIds = new Set();
@@ -7236,6 +7295,16 @@ export class FleetManager {
7236
7295
  }
7237
7296
  // Handle /start command
7238
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
+ }
7239
7308
  // All new starts, including explicit backend and later chooser callbacks,
7240
7309
  // share validateClassicStart. Existing channels are recognized first.
7241
7310
  const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
@@ -7244,7 +7313,6 @@ export class FleetManager {
7244
7313
  return;
7245
7314
  }
7246
7315
  const channelName = msg.username || chatId;
7247
- const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7248
7316
  if (requestedBackend) {
7249
7317
  // handleClassicStart binds the instance to this adapter authoritatively.
7250
7318
  const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
@@ -9731,6 +9799,45 @@ export class FleetManager {
9731
9799
  return null;
9732
9800
  }
9733
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
+ }
9734
9841
  /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
9735
9842
  async webEmojiList() {
9736
9843
  const adopted = [];
@@ -11174,6 +11281,48 @@ export class FleetManager {
11174
11281
  });
11175
11282
  }
11176
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
+ }
11177
11326
  /**
11178
11327
  * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
11179
11328
  * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
@@ -11182,20 +11331,7 @@ export class FleetManager {
11182
11331
  cacheReport(window) {
11183
11332
  const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
11184
11333
  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
- },
11334
+ instances: () => this.cacheInstances(),
11199
11335
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
11200
11336
  claudeKey: claudeProjectKey,
11201
11337
  codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
@@ -11382,7 +11518,9 @@ export class FleetManager {
11382
11518
  }
11383
11519
  async retireNonceButtons(pending, messageId, text,
11384
11520
  /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
11385
- editClicked) {
11521
+ editClicked,
11522
+ /** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
11523
+ urlButtons) {
11386
11524
  this.webPromptGone(pending, text);
11387
11525
  try {
11388
11526
  const retire = editClicked ?? pending.retire;
@@ -11392,7 +11530,12 @@ export class FleetManager {
11392
11530
  }
11393
11531
  if (!pending.adapter.editMessageRemoveButtons)
11394
11532
  throw new Error("adapter cannot remove prompt buttons");
11395
- 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
+ }
11396
11539
  }
11397
11540
  catch (err) {
11398
11541
  // The action was already atomically consumed. An edit failure must not
@@ -12006,9 +12149,12 @@ export class FleetManager {
12006
12149
  * Backends with no remote login flow (opencode logs in from a terminal) get
12007
12150
  * no button — the alert's own wording already tells them what to run.
12008
12151
  */
12009
- async offerBackendLogin(targetInstance, backend) {
12152
+ async offerBackendLogin(targetInstance, backend, profile) {
12010
12153
  if (!LOGIN_FLOWS[backend])
12011
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;
12012
12158
  const adapterId = this.getInstanceAdapterId(targetInstance);
12013
12159
  const adapter = this.getAdapterForInstance(targetInstance);
12014
12160
  const chatId = this.getGroupIdForInstance(targetInstance);
@@ -12026,9 +12172,10 @@ export class FleetManager {
12026
12172
  adapterId,
12027
12173
  chatId,
12028
12174
  threadId,
12029
- message: t("login.offer", backend),
12030
- 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) }],
12031
12177
  expiredText: t("buttons.stale"),
12178
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12032
12179
  });
12033
12180
  }
12034
12181
  /** Consume a General assist button exactly once. */
@@ -12841,7 +12988,7 @@ export class FleetManager {
12841
12988
  fleetConfig: () => this.fleetConfig,
12842
12989
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
12843
12990
  eventLog: () => this.eventLog,
12844
- recoverBackendInstances: backend => this.recoverBackendInstances(backend),
12991
+ recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
12845
12992
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
12846
12993
  releaseWindow: claim => { this.loginWindow.release(claim); },
12847
12994
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -12850,11 +12997,12 @@ export class FleetManager {
12850
12997
  reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
12851
12998
  // Throws when the prompt cannot be posted: the controller tells the
12852
12999
  // user so instead of leaving "Starting…" as the last word (#1133).
12853
- postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
13000
+ postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
12854
13001
  await this.postNonceButtonPromptOrThrow({
12855
13002
  prefix, alertType: "login", instanceName,
12856
13003
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
12857
13004
  message, choices, expiredText,
13005
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12858
13006
  });
12859
13007
  },
12860
13008
  });
@@ -13068,10 +13216,20 @@ export class FleetManager {
13068
13216
  // A removed backend (#1280) is neither installed nor signed into: say what replaces it.
13069
13217
  if (isRemovedBackend(wanted))
13070
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
+ }
13071
13227
  if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
13072
13228
  const flow = LOGIN_FLOWS[wanted];
13073
13229
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
13074
- return this.startInstallSession(wanted, chat);
13230
+ return opts.profile !== undefined
13231
+ ? this.startInstallSession(wanted, chat, { profile: opts.profile })
13232
+ : this.startInstallSession(wanted, chat);
13075
13233
  }
13076
13234
  this.recordLoginFlow(wanted, "login", chat.userId);
13077
13235
  return this.launchSignIn(backendArg, chat, opts);
@@ -13160,7 +13318,9 @@ export class FleetManager {
13160
13318
  * cancelled: they are still coming back, and are reported as pending so the
13161
13319
  * message can say so instead of implying failure.
13162
13320
  */
13163
- 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) {
13164
13324
  const woken = [];
13165
13325
  const restarted = [];
13166
13326
  const pending = [];
@@ -13168,6 +13328,9 @@ export class FleetManager {
13168
13328
  for (const name of this.configuredBackendInstanceNames()) {
13169
13329
  if (this.backendNameOf(name) !== backend)
13170
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;
13171
13334
  const status = this.getInstanceStatus(name);
13172
13335
  if (status !== "paused" && status !== "running")
13173
13336
  continue;
@@ -13205,14 +13368,16 @@ export class FleetManager {
13205
13368
  if (claimed === "consumed")
13206
13369
  return true;
13207
13370
  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, {
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 = {
13210
13374
  adapter: entry.adapter,
13211
13375
  adapterId: entry.adapterId,
13212
13376
  chatId: entry.chatId,
13213
13377
  threadId: entry.threadId,
13214
13378
  userId: data.userId,
13215
- });
13379
+ };
13380
+ const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13216
13381
  if (text)
13217
13382
  await this.postPromptOutcome(entry, text);
13218
13383
  return true;
@@ -13226,11 +13391,13 @@ export class FleetManager {
13226
13391
  return true;
13227
13392
  const { entry, action } = claimed;
13228
13393
  const backend = entry.instanceName;
13394
+ const profile = entry.loginProfile;
13395
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
13229
13396
  if (action === "cancel") {
13230
- 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));
13231
13398
  return true;
13232
13399
  }
13233
- 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));
13234
13401
  const text = await this.startLoginSession(backend, {
13235
13402
  adapter: entry.adapter,
13236
13403
  adapterId: entry.adapterId,
@@ -13242,6 +13409,7 @@ export class FleetManager {
13242
13409
  tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
13243
13410
  // The consent is the button itself: only the explicit "Open public link" actions carry it.
13244
13411
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
13412
+ ...(profile ? { profile } : {}),
13245
13413
  });
13246
13414
  if (text)
13247
13415
  await this.postPromptOutcome(entry, text);
@@ -13269,7 +13437,7 @@ export class FleetManager {
13269
13437
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
13270
13438
  installHandoff = null;
13271
13439
  /** Start a CLI install session. Caller enforces admin. */
13272
- async startInstallSession(backendArg, chat) {
13440
+ async startInstallSession(backendArg, chat, signIn = {}) {
13273
13441
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
13274
13442
  const info = BACKEND_INSTALLATION_INFO[backend];
13275
13443
  if (!info)
@@ -13353,7 +13521,8 @@ export class FleetManager {
13353
13521
  // without a click.
13354
13522
  // launchSignIn, not startLoginSession: if the new binary were still
13355
13523
  // 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)));
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)));
13357
13526
  if (next) {
13358
13527
  await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
13359
13528
  .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
@@ -13461,8 +13630,16 @@ export class FleetManager {
13461
13630
  return;
13462
13631
  }
13463
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
+ }
13464
13641
  if (backend) {
13465
- const text = await this.startLoginSession(backend, chat);
13642
+ const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13466
13643
  await data.respond(text ?? t("login.confirm_posted"));
13467
13644
  return;
13468
13645
  }
@@ -14319,6 +14496,49 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14319
14496
  });
14320
14497
  }
14321
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
+ }
14322
14542
  pasteRawToClassicInstance(instanceName, text) {
14323
14543
  const ipc = this.instanceIpcClients.get(instanceName);
14324
14544
  if (!ipc) {
@@ -14918,7 +15138,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14918
15138
  try {
14919
15139
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14920
15140
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14921
- ? 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");
14922
15151
  }
14923
15152
  catch (err) {
14924
15153
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -15243,7 +15472,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15243
15472
  try {
15244
15473
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
15245
15474
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
15246
- ? 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");
15247
15485
  }
15248
15486
  catch (err) {
15249
15487
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -15377,21 +15615,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15377
15615
  }
15378
15616
  catch { /* default restart */ }
15379
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
+ }
15380
15625
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
15381
15626
  return `${warn}${t("effort.not_running", instanceName)}`;
15382
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
+ }
15383
15643
  // Persist either way: a runtime switch must survive the next respawn too,
15384
15644
  // 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)
15645
+ if (!this.persistInstanceModel(instanceName, model))
15395
15646
  return `${warn}${t("model.persist_failed", instanceName)}`;
15396
15647
  if (strategy === "runtime") {
15397
15648
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
@@ -15400,6 +15651,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15400
15651
  await this.restartSingleInstance(instanceName);
15401
15652
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
15402
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
+ }
15403
15663
  /**
15404
15664
  * The trailing "Current effort: …" line for a /model reply.
15405
15665
  *
@@ -15510,6 +15770,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15510
15770
  }
15511
15771
  /** Handle Discord's required static slash choice, warning before a likely startup failure. */
15512
15772
  async handleClassicStartSlash(data, adapterId) {
15773
+ return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
15774
+ }
15775
+ async handleClassicStartSlashCore(data, adapterId) {
15513
15776
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
15514
15777
  if (!requestedBackend) {
15515
15778
  // beta.31 made this option required. Discord can briefly retain the old
@@ -15626,7 +15889,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15626
15889
  // failure. The selected backend is still attempted as requested.
15627
15890
  if (warning)
15628
15891
  await pending.complete(warning, pending.messageId);
15629
- 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));
15630
15893
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
15631
15894
  }
15632
15895
  /** Start a classic channel instance with lightweight config */
@@ -18092,14 +18355,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18092
18355
  return;
18093
18356
  }
18094
18357
  (async () => {
18358
+ let held = false;
18095
18359
  try {
18096
18360
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
18097
- 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));
18098
18366
  this.emitSseEvent("status", this.getUiStatus());
18367
+ if (held)
18368
+ return;
18099
18369
  res.writeHead(200);
18100
18370
  res.end(JSON.stringify({ ok: true }));
18101
18371
  }
18102
18372
  catch (err) {
18373
+ if (held) {
18374
+ this.logger.error({ err, name }, "Held start failed");
18375
+ return;
18376
+ }
18103
18377
  res.writeHead(500);
18104
18378
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
18105
18379
  }
@@ -18114,15 +18388,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18114
18388
  const name = decodeURIComponent(req.url.slice("/restart/".length));
18115
18389
  this.logger.info({ name }, "Instance restart requested via HTTP");
18116
18390
  (async () => {
18391
+ let held = false;
18117
18392
  try {
18118
- 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 }));
18119
18398
  this.logger.info({ name }, "Instance restarted");
18120
18399
  this.emitSseEvent("status", this.getUiStatus());
18400
+ if (held)
18401
+ return;
18121
18402
  res.writeHead(200);
18122
18403
  res.end(JSON.stringify({ restarted: name }));
18123
18404
  }
18124
18405
  catch (err) {
18125
18406
  this.logger.error({ err, name }, "Instance restart failed");
18407
+ if (held)
18408
+ return;
18126
18409
  const status = err.message.includes("not found") ? 404 : 500;
18127
18410
  res.writeHead(status);
18128
18411
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
@@ -18438,6 +18721,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18438
18721
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
18439
18722
  // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
18440
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(),
18441
18727
  };
18442
18728
  }
18443
18729
  }