@songsid/agend 2.1.10-beta.4 → 2.1.10-beta.6

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 (45) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/channel/adapters/discord.d.ts +20 -1
  4. package/dist/channel/adapters/discord.js +110 -50
  5. package/dist/channel/adapters/discord.js.map +1 -1
  6. package/dist/channel/adapters/telegram.js +18 -1
  7. package/dist/channel/adapters/telegram.js.map +1 -1
  8. package/dist/channel/factory.d.ts +2 -0
  9. package/dist/channel/factory.js.map +1 -1
  10. package/dist/config-validator.js +3 -0
  11. package/dist/config-validator.js.map +1 -1
  12. package/dist/config.js +1 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/fleet-context.d.ts +2 -18
  15. package/dist/fleet-label.d.ts +5 -0
  16. package/dist/fleet-label.js +19 -0
  17. package/dist/fleet-label.js.map +1 -0
  18. package/dist/fleet-manager.d.ts +55 -31
  19. package/dist/fleet-manager.js +279 -246
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/instance-lifecycle.d.ts +1 -1
  22. package/dist/locale.js +54 -42
  23. package/dist/locale.js.map +1 -1
  24. package/dist/login-controller.d.ts +19 -0
  25. package/dist/login-controller.js +87 -12
  26. package/dist/login-controller.js.map +1 -1
  27. package/dist/login-flows.d.ts +8 -1
  28. package/dist/login-flows.js +16 -3
  29. package/dist/login-flows.js.map +1 -1
  30. package/dist/login-window-lock.js +1 -1
  31. package/dist/setup-wizard.js +1 -1
  32. package/dist/setup-wizard.js.map +1 -1
  33. package/dist/topic-commands.d.ts +0 -2
  34. package/dist/topic-commands.js +14 -53
  35. package/dist/topic-commands.js.map +1 -1
  36. package/dist/tunnel/cloudflared-install.d.ts +83 -0
  37. package/dist/tunnel/cloudflared-install.js +359 -0
  38. package/dist/tunnel/cloudflared-install.js.map +1 -0
  39. package/dist/tunnel/cloudflared.d.ts +1 -0
  40. package/dist/tunnel/cloudflared.js +2 -2
  41. package/dist/tunnel/cloudflared.js.map +1 -1
  42. package/dist/types.d.ts +11 -4
  43. package/dist/web-terminal.d.ts +1 -1
  44. package/dist/web-terminal.js +1 -1
  45. package/package.json +2 -1
@@ -7,6 +7,7 @@ import { Worker } from "node:worker_threads";
7
7
  import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
10
+ import { fleetLabel } from "./fleet-label.js";
10
11
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
11
12
  import { formatUpdateProgress } from "./update-progress.js";
12
13
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
@@ -206,6 +207,11 @@ const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
206
207
  */
207
208
  const IPC_RECONNECT_GRACE_MS = 30_000;
208
209
  const IPC_RECONNECT_POLL_MS = 250;
210
+ /** The prefix of a button's callback data, for logs (never the nonce). */
211
+ function callbackPrefix(callbackData) {
212
+ const colon = callbackData.indexOf(":");
213
+ return colon === -1 ? "(none)" : callbackData.slice(0, colon + 1);
214
+ }
209
215
  const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
210
216
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
211
217
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
@@ -217,11 +223,12 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
217
223
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
218
224
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
219
225
  export const LOGIN_CALLBACK_PREFIX = "login:";
220
- const INSTALL_CALLBACK_PREFIX = "install-select:";
226
+ /** Prompt kinds `/install-cli` used, before it became part of `/login` (#1131). */
227
+ const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:"];
228
+ const NEVER_MATCHES = /(?!)/;
221
229
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
222
230
  const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
223
231
  const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
224
- const INSTALL_LOGIN_CALLBACK_PREFIX = "install-login:";
225
232
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
226
233
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
227
234
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
@@ -382,6 +389,8 @@ export class FleetManager {
382
389
  instanceStateCache = new Map();
383
390
  /** CLI pane status overrides; daemon.pid alone only proves FleetManager lives. */
384
391
  instanceProcessStatus = new Map();
392
+ /** Adapters whose last slash command registration failed and General was told (#1131). */
393
+ slashRegistrationFailed = new Set();
385
394
  /** Instances currently being auto-paused by warm_cap, so concurrent checks don't double-evict. */
386
395
  warmCapEvicting = new Set();
387
396
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
@@ -2667,7 +2676,7 @@ export class FleetManager {
2667
2676
  /**
2668
2677
  * Probe the same executable set exposed by the web backend catalog.
2669
2678
  *
2670
- * This deliberately has no cache: an `/install-cli` completion can add a
2679
+ * This deliberately has no cache: an install `/login` ran can add a
2671
2680
  * binary to PATH while the fleet process remains alive, and the next bare
2672
2681
  * `/login` must see it without requiring a restart or an explicit cache
2673
2682
  * invalidation call.
@@ -3910,6 +3919,29 @@ export class FleetManager {
3910
3919
  return this.adapterState;
3911
3920
  }
3912
3921
  bindAdapterHealth(adapter, adapterId) {
3922
+ // Discord slash command registration, every gateway ready (#1131). A
3923
+ // rejected registration leaves the previous command list in place, so a
3924
+ // new command silently never appears: log every outcome, and tell General
3925
+ // once per failure streak (a reconnect loop must not repeat it).
3926
+ adapter.on("slash_registration", (outcome) => {
3927
+ if (this.adapters.get(adapterId) !== adapter)
3928
+ return;
3929
+ if (outcome.ok) {
3930
+ this.slashRegistrationFailed.delete(adapterId);
3931
+ this.logger.info({ adapterId, count: outcome.count }, "Registered Discord slash commands");
3932
+ return;
3933
+ }
3934
+ this.logger.warn({ adapterId, code: outcome.code, status: outcome.status, error: outcome.message }, "Discord rejected the slash command registration; the previous command list stays");
3935
+ if (this.slashRegistrationFailed.has(adapterId))
3936
+ return;
3937
+ const detail = [outcome.code, outcome.message].filter(v => v !== undefined && v !== "").join(" ");
3938
+ // The streak is the de-duplication (not the shared text throttle), and
3939
+ // it counts as told only once General actually got it: with nowhere to
3940
+ // post yet, the next failure tries again.
3941
+ if (this.notifyFleetError(t("discord.slash_registration_failed", adapterId, detail), { throttle: false })) {
3942
+ this.slashRegistrationFailed.add(adapterId);
3943
+ }
3944
+ });
3913
3945
  adapter.on("gateway_health", (snapshot) => {
3914
3946
  // A token rotation tears down the old EventEmitter before constructing
3915
3947
  // the replacement. A late health frame from that old client must never
@@ -4016,6 +4048,7 @@ export class FleetManager {
4016
4048
  botToken,
4017
4049
  accessManager,
4018
4050
  inboxDir,
4051
+ fleetLabel: fleetLabel(this.fleetConfig),
4019
4052
  });
4020
4053
  const adapter = this.adapter;
4021
4054
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
@@ -4034,44 +4067,7 @@ export class FleetManager {
4034
4067
  await this.handleInboundReaction(r);
4035
4068
  }, this.logger, "adapter.reaction"));
4036
4069
  this.adapter.on("callback_query", safeHandler(async (data) => {
4037
- if (!isCurrentAdapter())
4038
- return;
4039
- if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
4040
- return;
4041
- if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
4042
- return;
4043
- if (await this.handleLoginBackendSelect(data, adapterId, this.adapter ?? undefined))
4044
- return;
4045
- if (await this.handleInstallBackendSelect(data, adapterId, this.adapter ?? undefined))
4046
- return;
4047
- if (await this.handleClassicApproval(data, adapterId, this.adapter ?? undefined))
4048
- return;
4049
- if (await this.handleLoginMenuSelect(data, adapterId, this.adapter ?? undefined))
4050
- return;
4051
- if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
4052
- return;
4053
- if (await this.handleLoginTokenResend(data, adapterId, this.adapter ?? undefined))
4054
- return;
4055
- if (await this.handleInstallLoginConfirm(data, adapterId, this.adapter ?? undefined))
4056
- return;
4057
- if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
4058
- return;
4059
- if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
4060
- return;
4061
- if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
4062
- return;
4063
- if (await this.handleClassicBackendSelection(data))
4064
- return;
4065
- if (await this.handleModelSelection(data))
4066
- return;
4067
- if (await this.handleEffortSelection(data))
4068
- return;
4069
- if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
4070
- return;
4071
- if (data.callbackData.startsWith("cancel:")) {
4072
- this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
4073
- return;
4074
- }
4070
+ await this.receiveAdapterCallback(data, adapterId, this.adapter ?? undefined, isCurrentAdapter);
4075
4071
  }, this.logger, "adapter.callback_query"));
4076
4072
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
4077
4073
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
@@ -4183,9 +4179,6 @@ export class FleetManager {
4183
4179
  else if (data.command === "login") {
4184
4180
  await this.handleLoginSlash(data, adapterId, this.adapter);
4185
4181
  }
4186
- else if (data.command === "install-cli") {
4187
- await this.handleInstallCliSlash(data, adapterId, this.adapter);
4188
- }
4189
4182
  else if (data.command === "clear") {
4190
4183
  await this.handleClearSlash(data, adapterId);
4191
4184
  }
@@ -4348,9 +4341,6 @@ export class FleetManager {
4348
4341
  else if (data.command === "login") {
4349
4342
  await this.handleLoginSlash(data, adapterId, this.adapter);
4350
4343
  }
4351
- else if (data.command === "install-cli") {
4352
- await this.handleInstallCliSlash(data, adapterId, this.adapter);
4353
- }
4354
4344
  }, this.logger, "adapter.slash_command"));
4355
4345
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
4356
4346
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -4428,6 +4418,7 @@ export class FleetManager {
4428
4418
  accessManager,
4429
4419
  inboxDir,
4430
4420
  registerCommands,
4421
+ fleetLabel: fleetLabel(this.fleetConfig),
4431
4422
  });
4432
4423
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
4433
4424
  this.worlds.set(adapterId, world);
@@ -4446,44 +4437,7 @@ export class FleetManager {
4446
4437
  await this.handleInboundReaction(r);
4447
4438
  }, this.logger, `adapter[${adapterId}].reaction`));
4448
4439
  adapter.on("callback_query", safeHandler(async (data) => {
4449
- if (!isCurrentAdapter())
4450
- return;
4451
- if (await this.handleTipDismiss(data, adapterId, adapter))
4452
- return;
4453
- if (await this.handleTipUnlock(data, adapterId, adapter))
4454
- return;
4455
- if (await this.handleLoginBackendSelect(data, adapterId, adapter))
4456
- return;
4457
- if (await this.handleInstallBackendSelect(data, adapterId, adapter))
4458
- return;
4459
- if (await this.handleClassicApproval(data, adapterId, adapter))
4460
- return;
4461
- if (await this.handleLoginMenuSelect(data, adapterId, adapter))
4462
- return;
4463
- if (await this.handleLoginConfirm(data, adapterId, adapter))
4464
- return;
4465
- if (await this.handleLoginTokenResend(data, adapterId, adapter))
4466
- return;
4467
- if (await this.handleInstallLoginConfirm(data, adapterId, adapter))
4468
- return;
4469
- if (await this.handleClearConfirmation(data, adapterId, adapter))
4470
- return;
4471
- if (await this.handleExitRestartPrompt(data, adapterId, adapter))
4472
- return;
4473
- if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
4474
- return;
4475
- if (await this.handleClassicBackendSelection(data))
4476
- return;
4477
- if (await this.handleModelSelection(data))
4478
- return;
4479
- if (await this.handleEffortSelection(data))
4480
- return;
4481
- if (await this.handleHangPrompt(data, adapterId, adapter))
4482
- return;
4483
- if (data.callbackData.startsWith("cancel:")) {
4484
- this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
4485
- return;
4486
- }
4440
+ await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
4487
4441
  }, this.logger, `adapter[${adapterId}].callback_query`));
4488
4442
  this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
4489
4443
  // Slash commands: classic bot + admin commands
@@ -4708,9 +4662,6 @@ export class FleetManager {
4708
4662
  else if (data.command === "login") {
4709
4663
  await this.handleLoginSlash(data, adapterId, adapter);
4710
4664
  }
4711
- else if (data.command === "install-cli") {
4712
- await this.handleInstallCliSlash(data, adapterId, adapter);
4713
- }
4714
4665
  }, this.logger, `adapter[${adapterId}].slash_command`));
4715
4666
  adapter.on("started", safeHandler((username, userId) => {
4716
4667
  if (!isCurrentAdapter())
@@ -8005,13 +7956,20 @@ export class FleetManager {
8005
7956
  this.adapter.sendText(target.chatId, text, target.opts)
8006
7957
  .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
8007
7958
  }
8008
- notifyFleetError(text) {
7959
+ /**
7960
+ * Returns whether the notice was dispatched. `throttle: false` is for a
7961
+ * caller that does its own de-duplication (one notice per failure streak):
7962
+ * the shared 10-minute text throttle would otherwise swallow the next
7963
+ * streak's notice, and it is neither consulted nor recorded.
7964
+ */
7965
+ notifyFleetError(text, opts = {}) {
7966
+ const throttled = opts.throttle !== false;
8009
7967
  const now = Date.now();
8010
7968
  const key = text.slice(0, 200);
8011
- const seen = this.fleetErrorNotices.get(key);
7969
+ const seen = throttled ? this.fleetErrorNotices.get(key) : undefined;
8012
7970
  if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
8013
7971
  seen.suppressed++;
8014
- return;
7972
+ return false;
8015
7973
  }
8016
7974
  const suppressed = seen?.suppressed ?? 0;
8017
7975
  const body = suppressed > 0
@@ -8049,8 +8007,10 @@ export class FleetManager {
8049
8007
  // unspent lets the next caller, once an adapter is up, actually get
8050
8008
  // through. The counter is not reset either: nothing was shown.
8051
8009
  this.logger.warn({ text: body }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
8052
- return;
8010
+ return false;
8053
8011
  }
8012
+ if (!throttled)
8013
+ return true;
8054
8014
  this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
8055
8015
  // Bound the map: it is keyed by message text, and a message with a varying
8056
8016
  // suffix (a path, an id) would otherwise grow it without limit.
@@ -8059,6 +8019,7 @@ export class FleetManager {
8059
8019
  if (oldest !== undefined)
8060
8020
  this.fleetErrorNotices.delete(oldest);
8061
8021
  }
8022
+ return true;
8062
8023
  }
8063
8024
  static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
8064
8025
  fleetErrorNotices = new Map();
@@ -8121,7 +8082,22 @@ export class FleetManager {
8121
8082
  * fails, so a nonce in the map always refers to a message that exists (or
8122
8083
  * is about to). Returns the nonce, or null when the alert could not be sent.
8123
8084
  */
8085
+ /** postNonceButtonPromptOrThrow, logging a failure and returning null instead. */
8124
8086
  async postNonceButtonPrompt(opts) {
8087
+ try {
8088
+ return await this.postNonceButtonPromptOrThrow(opts);
8089
+ }
8090
+ catch (err) {
8091
+ this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
8092
+ return null;
8093
+ }
8094
+ }
8095
+ /**
8096
+ * Post a nonce-armed button prompt; throws (with the nonce disarmed) when it
8097
+ * cannot be posted, so a caller that told the user "buttons posted" can
8098
+ * tell them the truth instead (#1133).
8099
+ */
8100
+ async postNonceButtonPromptOrThrow(opts) {
8125
8101
  // 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
8126
8102
  // callback_data cap still holds, with two prefixes tied at the longest:
8127
8103
  // "interactive-assist:" (19) + 32 hex + ":confirm" (8) = 59, and
@@ -8178,8 +8154,7 @@ export class FleetManager {
8178
8154
  this.pendingNonceButtons.delete(nonce);
8179
8155
  if (entry.timer)
8180
8156
  clearTimeout(entry.timer);
8181
- this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
8182
- return null;
8157
+ throw err;
8183
8158
  }
8184
8159
  }
8185
8160
  /**
@@ -8206,12 +8181,15 @@ export class FleetManager {
8206
8181
  pending = undefined;
8207
8182
  if (!match || !pending) {
8208
8183
  const adapter = receivingAdapter ?? this.adapter;
8184
+ // The clicker always hears why nothing happens (#1133); collapsing the
8185
+ // dead buttons is a courtesy that can fail (>48h on Telegram, message gone).
8186
+ data.ack?.(t("buttons.stale_notice"));
8187
+ const collapseFailed = (err) => this.logger.info({ err: err?.message, prefix }, "Could not collapse an expired prompt's buttons");
8209
8188
  if (staleHandling?.keepText && adapter?.removeMessageButtons) {
8210
- adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId)
8211
- .catch(() => { });
8189
+ adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
8212
8190
  }
8213
8191
  else {
8214
- adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
8192
+ adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
8215
8193
  }
8216
8194
  if (staleHandling?.notice) {
8217
8195
  adapter?.sendText(data.chatId, staleHandling.notice, { threadId: data.threadId })
@@ -8248,8 +8226,10 @@ export class FleetManager {
8248
8226
  userId: data.userId,
8249
8227
  mismatchedFields: mismatchedFields.join(","),
8250
8228
  }, "Rejected unauthorized or mismatched button callback");
8229
+ data.ack?.(t(isAuthorized ? "buttons.wrong_place" : "buttons.admin_only"));
8251
8230
  return "consumed";
8252
8231
  }
8232
+ data.ack?.();
8253
8233
  // Claim before any await: double clicks and duplicate callback delivery
8254
8234
  // can never act twice for state-changing actions.
8255
8235
  this.pendingNonceButtons.delete(match[1]);
@@ -8258,18 +8238,91 @@ export class FleetManager {
8258
8238
  return { entry: pending, action: match[2] };
8259
8239
  }
8260
8240
  /** Collapse a consumed prompt's buttons into a final status line. */
8261
- async retireNonceButtons(pending, messageId, text) {
8262
- if (!pending.adapter.editMessageRemoveButtons) {
8263
- this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
8264
- return;
8241
+ /**
8242
+ * One button click from an adapter (#1133). Every click is acknowledged
8243
+ * once the fleet has decided — Telegram keeps a spinner until it is, and a
8244
+ * refused click acknowledges with a notice of its own (consumeNonceCallback) —
8245
+ * and a click nothing acts on is logged rather than vanishing.
8246
+ */
8247
+ async receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter) {
8248
+ try {
8249
+ if (!isCurrentAdapter()) {
8250
+ this.logger.info({ adapterId, prefix: callbackPrefix(data.callbackData) }, "Button click from a replaced adapter ignored");
8251
+ return;
8252
+ }
8253
+ if (!await this.dispatchAdapterCallback(data, adapterId, adapter)) {
8254
+ this.logger.info({ adapterId, prefix: callbackPrefix(data.callbackData) }, "Button click not handled by the fleet (adapter-level prompt or unknown button)");
8255
+ }
8256
+ }
8257
+ finally {
8258
+ data.ack?.();
8265
8259
  }
8260
+ }
8261
+ /**
8262
+ * A button from a prompt kind that no longer exists (#1131: `/install-cli`'s
8263
+ * picker and its "sign in now?" prompt). Still on screen after an upgrade;
8264
+ * a click gets the expired-prompt treatment — a private notice, the buttons
8265
+ * collapsed — instead of nothing.
8266
+ */
8267
+ handleRetiredPromptButton(data, adapterId, adapter) {
8268
+ for (const prefix of RETIRED_PROMPT_PREFIXES) {
8269
+ if (this.consumeNonceCallback(prefix, NEVER_MATCHES, data, adapterId, adapter) !== null)
8270
+ return true;
8271
+ }
8272
+ return false;
8273
+ }
8274
+ /** Routes a click to the handler that owns its prefix; false when none does. */
8275
+ async dispatchAdapterCallback(data, adapterId, adapter) {
8276
+ if (await this.handleTipDismiss(data, adapterId, adapter))
8277
+ return true;
8278
+ if (await this.handleTipUnlock(data, adapterId, adapter))
8279
+ return true;
8280
+ if (await this.handleLoginBackendSelect(data, adapterId, adapter))
8281
+ return true;
8282
+ if (await this.handleClassicApproval(data, adapterId, adapter))
8283
+ return true;
8284
+ if (await this.handleLoginMenuSelect(data, adapterId, adapter))
8285
+ return true;
8286
+ if (this.handleRetiredPromptButton(data, adapterId, adapter))
8287
+ return true;
8288
+ if (await this.handleLoginConfirm(data, adapterId, adapter))
8289
+ return true;
8290
+ if (await this.handleLoginTokenResend(data, adapterId, adapter))
8291
+ return true;
8292
+ if (await this.handleClearConfirmation(data, adapterId, adapter))
8293
+ return true;
8294
+ if (await this.handleExitRestartPrompt(data, adapterId, adapter))
8295
+ return true;
8296
+ if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
8297
+ return true;
8298
+ if (await this.handleClassicBackendSelection(data))
8299
+ return true;
8300
+ if (await this.handleModelSelection(data))
8301
+ return true;
8302
+ if (await this.handleEffortSelection(data))
8303
+ return true;
8304
+ if (await this.handleHangPrompt(data, adapterId, adapter))
8305
+ return true;
8306
+ if (data.callbackData.startsWith("cancel:")) {
8307
+ this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter ?? null, data);
8308
+ return true;
8309
+ }
8310
+ return false;
8311
+ }
8312
+ async retireNonceButtons(pending, messageId, text) {
8266
8313
  try {
8314
+ if (!pending.adapter.editMessageRemoveButtons)
8315
+ throw new Error("adapter cannot remove prompt buttons");
8267
8316
  await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
8268
8317
  }
8269
8318
  catch (err) {
8270
8319
  // The action was already atomically consumed. An edit failure must not
8271
- // undo that or make the button actionable again.
8272
- this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
8320
+ // undo that or make the button actionable again — but the edit is the
8321
+ // only place some outcomes are told (cancel, later, a resend's result),
8322
+ // so the text goes out as a message instead (#1133).
8323
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
8324
+ await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId })
8325
+ .catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
8273
8326
  }
8274
8327
  }
8275
8328
  tipsEnabled() {
@@ -9493,8 +9546,10 @@ export class FleetManager {
9493
9546
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
9494
9547
  windowBusyMessage: () => this.loginWindow.busyMessage(),
9495
9548
  tunnelDataDir: () => this.dataDir,
9549
+ // Throws when the prompt cannot be posted: the controller tells the
9550
+ // user so instead of leaving "Starting…" as the last word (#1133).
9496
9551
  postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
9497
- await this.postNonceButtonPrompt({
9552
+ await this.postNonceButtonPromptOrThrow({
9498
9553
  prefix, alertType: "login", instanceName,
9499
9554
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
9500
9555
  message, choices, expiredText,
@@ -9504,37 +9559,50 @@ export class FleetManager {
9504
9559
  }
9505
9560
  return this.loginController;
9506
9561
  }
9507
- /** Post the backend chooser for a bare `/login`. Caller enforces admin. */
9562
+ /**
9563
+ * Post the backend chooser for a bare `/login`. Caller enforces admin.
9564
+ * Returns why the chooser could not be posted, for the caller to tell the
9565
+ * user (#1133); undefined when it was posted or there was nothing to choose.
9566
+ */
9508
9567
  async promptLoginBackends(chat) {
9509
9568
  const configured = new Set();
9510
9569
  for (const name of this.configuredBackendInstanceNames()) {
9511
9570
  configured.add(this.backendNameOf(name));
9512
9571
  }
9513
9572
  const installed = this.probeInstalledBackends();
9514
- const candidates = new Set([...installed, ...configured]);
9573
+ // One entry point for "get this CLI working" (#1131): a backend that is
9574
+ // not installed is offered too, and the click installs it first, then
9575
+ // signs in (startLoginSession routes it). gemini-cli is not recommended,
9576
+ // as before; `/login gemini-cli` still installs it.
9577
+ const installable = new Set(Object.keys(BACKEND_INSTALLATION_INFO).filter(backend => backend !== "gemini-cli"));
9578
+ const candidates = new Set([...installed, ...configured, ...installable]);
9515
9579
  const unsupported = [];
9516
9580
  const choices = [...candidates].sort().flatMap(backend => {
9517
9581
  const flow = LOGIN_FLOWS[backend];
9518
9582
  const remoteLogin = !!flow && flow.remoteLogin !== "unsupported";
9583
+ const isInstalled = installed.has(backend);
9519
9584
  const status = [];
9520
- if (installed.has(backend))
9521
- status.push(t("login.status_installed"));
9585
+ status.push(t(isInstalled ? "login.status_installed" : "login.status_not_installed"));
9522
9586
  if (configured.has(backend))
9523
9587
  status.push(t("login.status_configured"));
9524
- if (remoteLogin)
9588
+ if (!isInstalled && installable.has(backend)) {
9589
+ status.push(t(remoteLogin ? "login.status_install_then_auth" : "login.status_install"));
9590
+ return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
9591
+ }
9592
+ if (remoteLogin && isInstalled) {
9525
9593
  status.push(t("login.status_auth"));
9526
- else
9527
- status.push(t("login.status_unsupported"));
9528
- if (!remoteLogin) {
9529
- unsupported.push({ backend, flow, status });
9530
- return [];
9594
+ return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
9531
9595
  }
9532
- return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
9596
+ status.push(t("login.status_unsupported"));
9597
+ unsupported.push({ backend, flow, status });
9598
+ return [];
9533
9599
  });
9534
9600
  if (unsupported.length) {
9535
- const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${flow?.remoteLogin === "unsupported"
9536
- ? t("login.remote_unsupported_agent_cli", backend, flow.command)
9537
- : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
9601
+ const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${!installed.has(backend)
9602
+ ? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
9603
+ : flow?.remoteLogin === "unsupported"
9604
+ ? t("login.remote_unsupported_agent_cli", backend, flow.command)
9605
+ : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
9538
9606
  await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
9539
9607
  .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
9540
9608
  }
@@ -9545,7 +9613,7 @@ export class FleetManager {
9545
9613
  }
9546
9614
  return;
9547
9615
  }
9548
- await this.postNonceButtonPrompt({
9616
+ return this.postChooser({
9549
9617
  prefix: LOGIN_CALLBACK_PREFIX,
9550
9618
  alertType: "login",
9551
9619
  instanceName: "login",
@@ -9553,11 +9621,27 @@ export class FleetManager {
9553
9621
  adapterId: chat.adapterId,
9554
9622
  chatId: chat.chatId,
9555
9623
  threadId: chat.threadId,
9556
- message: t("login.choose_backend"),
9624
+ message: `${t("login.choose_backend")}\n${t("fleet.label_line", fleetLabel(this.fleetConfig))}`,
9557
9625
  choices,
9558
9626
  expiredText: t("buttons.stale"),
9559
9627
  });
9560
9628
  }
9629
+ /** A clicked prompt's follow-up line; a failure to post it is logged, not swallowed (#1133). */
9630
+ async postPromptOutcome(entry, text) {
9631
+ await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId })
9632
+ .catch(err => this.logger.warn({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Could not post a prompt's follow-up"));
9633
+ }
9634
+ /** A chooser prompt: undefined when posted, else the user-facing reason it was not. */
9635
+ async postChooser(opts) {
9636
+ try {
9637
+ await this.postNonceButtonPromptOrThrow(opts);
9638
+ return undefined;
9639
+ }
9640
+ catch (err) {
9641
+ this.logger.warn({ err, prefix: opts.prefix }, "Could not post a backend chooser");
9642
+ return t("buttons.post_failed", err?.message ?? String(err));
9643
+ }
9644
+ }
9561
9645
  /**
9562
9646
  * Ask the General topic to approve a ClassicBot access request.
9563
9647
  *
@@ -9651,62 +9735,39 @@ export class FleetManager {
9651
9735
  await this.applyClassicApproval(entry, groupId, grantAdmin ? userId : undefined);
9652
9736
  return true;
9653
9737
  }
9654
- /**
9655
- * Backend chooser for a bare `/install-cli`, mirroring promptLoginBackends so
9656
- * both commands feel the same. Built on postNonceButtonPrompt rather than the
9657
- * `/model` selection coordinator: that is the mechanism `/login` already uses,
9658
- * and the one whose canonical-address binding (#682) makes the buttons answer
9659
- * in a Telegram General topic.
9660
- *
9661
- * Unlike the login chooser this does NOT filter to backends the fleet already
9662
- * runs. Installing is how you get a backend you do not have yet, so filtering
9663
- * by configured backends would hide the only entry the admin came for.
9664
- *
9665
- * gemini-cli is omitted: it is deprecated (see backend/factory.ts). Typing
9666
- * `/install-cli gemini-cli` still works — this only stops recommending it.
9667
- */
9668
- async promptInstallBackends(chat) {
9669
- const choices = Object.keys(BACKEND_INSTALLATION_INFO)
9670
- .filter(backend => backend !== "gemini-cli")
9671
- .map(backend => ({ action: backend, label: backend }));
9672
- await this.postNonceButtonPrompt({
9673
- prefix: INSTALL_CALLBACK_PREFIX,
9674
- alertType: "install",
9675
- instanceName: "install",
9676
- adapter: chat.adapter,
9677
- adapterId: chat.adapterId,
9678
- chatId: chat.chatId,
9679
- threadId: chat.threadId,
9680
- message: t("install.choose_backend"),
9681
- choices,
9682
- expiredText: t("buttons.stale"),
9683
- });
9684
- }
9685
- /** Backend chooser button → start that backend's install session. */
9686
- async handleInstallBackendSelect(data, callbackAdapterId, receivingAdapter) {
9687
- const claimed = this.consumeNonceCallback(INSTALL_CALLBACK_PREFIX, /^install-select:([0-9a-f]+):([a-z][a-z-]*)$/, data, callbackAdapterId, receivingAdapter);
9688
- if (claimed === null)
9689
- return false;
9690
- if (claimed === "consumed")
9691
- return true;
9692
- const { entry, action: backend } = claimed;
9693
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.starting_backend", backend));
9694
- const text = await this.startInstallSession(backend, {
9695
- adapter: entry.adapter,
9696
- adapterId: entry.adapterId,
9697
- chatId: entry.chatId,
9698
- threadId: entry.threadId,
9699
- });
9700
- if (text)
9701
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
9702
- return true;
9703
- }
9704
9738
  /**
9705
9739
  * Start a login session for one backend. Caller enforces admin.
9706
9740
  * Returns a status line to post, or null when a confirmation prompt was
9707
9741
  * posted instead (auth still valid — see the pre-check below).
9708
9742
  */
9709
9743
  async startLoginSession(backendArg, chat, opts = {}) {
9744
+ // `/login` is the one entry point (#1131): a CLI that is not installed yet
9745
+ // is installed first, and the install's success signs in (startInstallSession).
9746
+ // A confirmation click (skipAuthCheck) continues a sign-in already decided:
9747
+ // straight to it, never back through the install routing — a binary that
9748
+ // went missing meanwhile must not start an installer from a "go" button.
9749
+ if (opts.skipAuthCheck)
9750
+ return this.launchSignIn(backendArg, chat, opts);
9751
+ const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
9752
+ if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
9753
+ const flow = LOGIN_FLOWS[wanted];
9754
+ this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
9755
+ return this.startInstallSession(wanted, chat);
9756
+ }
9757
+ this.recordLoginFlow(wanted, "login", chat.userId);
9758
+ return this.launchSignIn(backendArg, chat, opts);
9759
+ }
9760
+ /** Which way a `/login` went (#1131) — so the event log shows what users actually need. */
9761
+ recordLoginFlow(backend, flow, requester) {
9762
+ try {
9763
+ this.eventLog?.insert("login", "login_entry", { backend, flow, requester: requester ?? null });
9764
+ }
9765
+ catch (err) {
9766
+ this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
9767
+ }
9768
+ }
9769
+ /** The sign-in itself (web, or the legacy relay) for a CLI that is installed. */
9770
+ async launchSignIn(backendArg, chat, opts = {}) {
9710
9771
  if (this.webLogin.mode() === "web")
9711
9772
  return this.webLogin.start(backendArg, chat, opts);
9712
9773
  // ── legacy relay mode (login.mode: relay) — removed in 2.1.6 ──
@@ -9742,7 +9803,7 @@ export class FleetManager {
9742
9803
  if (!this.loginWindow.isCurrent(claim))
9743
9804
  return t("login.web_shutting_down"); // fleet shut down while we probed
9744
9805
  if (status === "valid") {
9745
- await this.postNonceButtonPrompt({
9806
+ const failure = await this.postChooser({
9746
9807
  prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
9747
9808
  alertType: "login",
9748
9809
  instanceName: backend,
@@ -9757,7 +9818,7 @@ export class FleetManager {
9757
9818
  ],
9758
9819
  expiredText: t("buttons.stale"),
9759
9820
  });
9760
- return null;
9821
+ return failure ?? null;
9761
9822
  }
9762
9823
  }
9763
9824
  markTransferred(); // launchLoginSession owns the claim from here
@@ -9783,7 +9844,7 @@ export class FleetManager {
9783
9844
  }
9784
9845
  const session = new LoginSession(flow, tmux, {
9785
9846
  onMenu: async (options) => {
9786
- await this.postNonceButtonPrompt({
9847
+ const failure = await this.postChooser({
9787
9848
  prefix: LOGIN_MENU_CALLBACK_PREFIX,
9788
9849
  alertType: "login",
9789
9850
  instanceName: backend,
@@ -9795,6 +9856,10 @@ export class FleetManager {
9795
9856
  choices: options.map((label, index) => ({ action: String(index), label })),
9796
9857
  expiredText: t("buttons.stale"),
9797
9858
  });
9859
+ if (failure) {
9860
+ await chat.adapter.sendText(chat.chatId, failure, { threadId: chat.threadId })
9861
+ .catch(err => this.logger.warn({ err, backend }, "Could not report a login menu that failed to post"));
9862
+ }
9798
9863
  },
9799
9864
  onAuthHint: async (url, code) => {
9800
9865
  await this.sendLoginSecret(chat, backend, url, code);
@@ -9906,6 +9971,16 @@ export class FleetManager {
9906
9971
  }
9907
9972
  /** `/login cancel` — abort the active session and remove its window. */
9908
9973
  async cancelLoginSession() {
9974
+ // `/login cancel` also stops an install that `/login` started — and the
9975
+ // sign-in an install that just succeeded is about to hand over to.
9976
+ if (this.activeInstall)
9977
+ return this.cancelInstallSession();
9978
+ if (this.installHandoff) {
9979
+ const { backend } = this.installHandoff;
9980
+ this.installHandoff.cancelled = true;
9981
+ this.installHandoff = null;
9982
+ return t("login.cancelled", backend);
9983
+ }
9909
9984
  if (this.loginController?.isActive())
9910
9985
  return this.loginController.cancel();
9911
9986
  if (!this.activeLogin)
@@ -10005,7 +10080,7 @@ export class FleetManager {
10005
10080
  userId: data.userId,
10006
10081
  });
10007
10082
  if (text)
10008
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
10083
+ await this.postPromptOutcome(entry, text);
10009
10084
  return true;
10010
10085
  }
10011
10086
  /** Re-login confirmation (auth pre-check said credentials still work). */
@@ -10035,7 +10110,7 @@ export class FleetManager {
10035
10110
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
10036
10111
  });
10037
10112
  if (text)
10038
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
10113
+ await this.postPromptOutcome(entry, text);
10039
10114
  return true;
10040
10115
  }
10041
10116
  /** "Resend token" button (web login): only the requester may press it; the token never enters the channel. */
@@ -10064,13 +10139,15 @@ export class FleetManager {
10064
10139
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
10065
10140
  return true;
10066
10141
  }
10067
- // ── Remote CLI install (`/install-cli`) ──────────────────────────────────
10142
+ // ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
10068
10143
  //
10069
10144
  // Same dedicated-window model as /login (and the same LoginSession state
10070
10145
  // machine — an install is a login flow with no auth hints): run the
10071
10146
  // installer, judge by exit code, verify the binary on a fresh login shell,
10072
- // then offer to chain straight into /login.
10147
+ // then sign in (the hand-off below).
10073
10148
  activeInstall = null;
10149
+ /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
10150
+ installHandoff = null;
10074
10151
  /** Start a CLI install session. Caller enforces admin. */
10075
10152
  async startInstallSession(backendArg, chat) {
10076
10153
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
@@ -10147,22 +10224,24 @@ export class FleetManager {
10147
10224
  // publish a durable completion line first so a successful install can
10148
10225
  // never look like it silently disappeared. Include the binary that
10149
10226
  // passed the fresh-login-shell verification and the exact next step.
10227
+ // Between the install ending and the sign-in starting there is no
10228
+ // session to cancel; this hand-off is what `/login cancel` stops.
10229
+ const handoff = { backend, cancelled: false };
10230
+ this.installHandoff = handoff;
10150
10231
  await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
10151
- await this.postNonceButtonPrompt({
10152
- prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
10153
- alertType: "login",
10154
- instanceName: backend,
10155
- adapter: chat.adapter,
10156
- adapterId: chat.adapterId,
10157
- chatId: chat.chatId,
10158
- threadId: chat.threadId,
10159
- message: t("install.login_prompt", backend),
10160
- choices: [
10161
- { action: "go", label: t("install.login_now") },
10162
- { action: "later", label: t("install.later") },
10163
- ],
10164
- expiredText: t("install.later_ack", backend),
10165
- });
10232
+ if (handoff.cancelled || this.installHandoff !== handoff)
10233
+ return;
10234
+ this.installHandoff = null;
10235
+ // The user asked `/login` for a working CLI: sign in straight away
10236
+ // (#1131). The login has its own confirmation, so nothing starts
10237
+ // without a click.
10238
+ // launchSignIn, not startLoginSession: if the new binary were still
10239
+ // not visible, startLoginSession would route straight back to an install.
10240
+ const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
10241
+ if (next) {
10242
+ await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
10243
+ .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
10244
+ }
10166
10245
  },
10167
10246
  }, this.logger);
10168
10247
  this.activeInstall = { session, backend, chat };
@@ -10188,7 +10267,12 @@ export class FleetManager {
10188
10267
  }
10189
10268
  return t("install.started", backend);
10190
10269
  }
10191
- /** `/install-cli cancel` — abort the active install and remove its window. */
10270
+ /** Whether this backend's CLI is on the fleet's PATH (what `/login` installs when it is not). */
10271
+ isCliInstalled(backend) {
10272
+ const info = BACKEND_INSTALLATION_INFO[backend];
10273
+ return !!info && checkBinaryInstalled(info.binary);
10274
+ }
10275
+ /** Abort the active install (`/login cancel`) and remove its window. */
10192
10276
  async cancelInstallSession() {
10193
10277
  if (!this.activeInstall)
10194
10278
  return t("install.no_session");
@@ -10246,31 +10330,6 @@ export class FleetManager {
10246
10330
  process.env.PATH = [dir, ...entries].join(delimiter);
10247
10331
  this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
10248
10332
  }
10249
- /** "Sign in now?" button after a successful install. */
10250
- async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
10251
- const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
10252
- if (claimed === null)
10253
- return false;
10254
- if (claimed === "consumed")
10255
- return true;
10256
- const { entry, action } = claimed;
10257
- const backend = entry.instanceName;
10258
- if (action === "later") {
10259
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.later_ack", backend));
10260
- return true;
10261
- }
10262
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
10263
- const text = await this.startLoginSession(backend, {
10264
- adapter: entry.adapter,
10265
- adapterId: entry.adapterId,
10266
- chatId: entry.chatId,
10267
- threadId: entry.threadId,
10268
- userId: data.userId,
10269
- });
10270
- if (text)
10271
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
10272
- return true;
10273
- }
10274
10333
  /** Discord native `/login` slash — shared by every adapter dispatch block. */
10275
10334
  async handleLoginSlash(data, adapterId, adapter) {
10276
10335
  if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
@@ -10282,40 +10341,14 @@ export class FleetManager {
10282
10341
  await data.respond(await this.cancelLoginSession());
10283
10342
  return;
10284
10343
  }
10285
- const code = String(data.options?.code ?? "").trim();
10286
- if (code) {
10287
- await data.respond(await this.loginSubmitInput(code));
10288
- return;
10289
- }
10290
10344
  const backend = String(data.options?.backend ?? "").trim();
10291
10345
  if (backend) {
10292
10346
  const text = await this.startLoginSession(backend, chat);
10293
10347
  await data.respond(text ?? t("login.confirm_posted"));
10294
10348
  return;
10295
10349
  }
10296
- await this.promptLoginBackends(chat);
10297
- await data.respond(t("login.chooser_posted"));
10298
- }
10299
- /** Discord native `/install-cli` slash — shared by every dispatch block. */
10300
- async handleInstallCliSlash(data, adapterId, adapter) {
10301
- if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
10302
- await data.respond(t("permission.denied"));
10303
- return;
10304
- }
10305
- if (data.options?.cancel === true) {
10306
- await data.respond(await this.cancelInstallSession());
10307
- return;
10308
- }
10309
- const backend = String(data.options?.backend ?? "").trim();
10310
- if (!backend) {
10311
- // Bare call: offer the backends instead of printing a usage line the user
10312
- // then has to retype. A Discord slash that DID pick the native `backend`
10313
- // choice never lands here, so the two paths cannot both fire.
10314
- await this.promptInstallBackends({ adapter, adapterId, chatId: data.channelId });
10315
- await data.respond(t("install.chooser_posted"));
10316
- return;
10317
- }
10318
- await data.respond(await this.startInstallSession(backend, { adapter, adapterId, chatId: data.channelId }));
10350
+ const failure = await this.promptLoginBackends(chat);
10351
+ await data.respond(failure ?? t("login.chooser_posted"));
10319
10352
  }
10320
10353
  queueMirrorMessage(text) {
10321
10354
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;