@songsid/agend 2.1.10-beta.4 → 2.1.10-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/channel/adapters/discord.d.ts +20 -1
- package/dist/channel/adapters/discord.js +111 -49
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.js +18 -1
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/factory.d.ts +2 -0
- package/dist/channel/factory.js.map +1 -1
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/fleet-context.d.ts +3 -18
- package/dist/fleet-label.d.ts +5 -0
- package/dist/fleet-label.js +19 -0
- package/dist/fleet-label.js.map +1 -0
- package/dist/fleet-manager.d.ts +58 -31
- package/dist/fleet-manager.js +295 -243
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -1
- package/dist/locale.js +36 -30
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +9 -2
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.js +1 -1
- package/dist/login-flows.js.map +1 -1
- package/dist/login-window-lock.js +1 -1
- package/dist/setup-wizard.js +1 -1
- package/dist/setup-wizard.js.map +1 -1
- package/dist/topic-commands.d.ts +0 -2
- package/dist/topic-commands.js +22 -47
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/web-terminal.d.ts +1 -1
- package/dist/web-terminal.js +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
-
|
|
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 `/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(" · ")} — ${
|
|
9536
|
-
? t("login.
|
|
9537
|
-
:
|
|
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
|
-
|
|
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,47 @@ 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
|
+
// `reinstall` runs the installer even over an installed CLI.
|
|
9747
|
+
// A confirmation click (skipAuthCheck) continues a sign-in already decided:
|
|
9748
|
+
// straight to it, never back through the install routing — a binary that
|
|
9749
|
+
// went missing meanwhile must not start an installer from a "go" button.
|
|
9750
|
+
if (opts.skipAuthCheck)
|
|
9751
|
+
return this.launchSignIn(backendArg, chat, opts);
|
|
9752
|
+
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
9753
|
+
const installer = BACKEND_INSTALLATION_INFO[wanted];
|
|
9754
|
+
if (opts.reinstall) {
|
|
9755
|
+
if (!installer)
|
|
9756
|
+
return t("install.unsupported", backendArg);
|
|
9757
|
+
this.recordLoginFlow(wanted, "reinstall", chat.userId);
|
|
9758
|
+
return this.startInstallSession(wanted, chat, { reinstall: true });
|
|
9759
|
+
}
|
|
9760
|
+
if (installer && !this.isCliInstalled(wanted)) {
|
|
9761
|
+
const flow = LOGIN_FLOWS[wanted];
|
|
9762
|
+
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
9763
|
+
return this.startInstallSession(wanted, chat);
|
|
9764
|
+
}
|
|
9765
|
+
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
9766
|
+
return this.launchSignIn(backendArg, chat, opts);
|
|
9767
|
+
}
|
|
9768
|
+
/** Which way a `/login` went (#1131) — so the event log shows what users actually need. */
|
|
9769
|
+
recordLoginFlow(backend, flow, requester) {
|
|
9770
|
+
try {
|
|
9771
|
+
this.eventLog?.insert("login", "login_entry", { backend, flow, requester: requester ?? null });
|
|
9772
|
+
}
|
|
9773
|
+
catch (err) {
|
|
9774
|
+
this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
|
|
9775
|
+
}
|
|
9776
|
+
}
|
|
9777
|
+
/** The sign-in itself (web, or the legacy relay) for a CLI that is installed. */
|
|
9778
|
+
async launchSignIn(backendArg, chat, opts = {}) {
|
|
9710
9779
|
if (this.webLogin.mode() === "web")
|
|
9711
9780
|
return this.webLogin.start(backendArg, chat, opts);
|
|
9712
9781
|
// ── legacy relay mode (login.mode: relay) — removed in 2.1.6 ──
|
|
@@ -9742,7 +9811,7 @@ export class FleetManager {
|
|
|
9742
9811
|
if (!this.loginWindow.isCurrent(claim))
|
|
9743
9812
|
return t("login.web_shutting_down"); // fleet shut down while we probed
|
|
9744
9813
|
if (status === "valid") {
|
|
9745
|
-
await this.
|
|
9814
|
+
const failure = await this.postChooser({
|
|
9746
9815
|
prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
|
|
9747
9816
|
alertType: "login",
|
|
9748
9817
|
instanceName: backend,
|
|
@@ -9757,7 +9826,7 @@ export class FleetManager {
|
|
|
9757
9826
|
],
|
|
9758
9827
|
expiredText: t("buttons.stale"),
|
|
9759
9828
|
});
|
|
9760
|
-
return null;
|
|
9829
|
+
return failure ?? null;
|
|
9761
9830
|
}
|
|
9762
9831
|
}
|
|
9763
9832
|
markTransferred(); // launchLoginSession owns the claim from here
|
|
@@ -9783,7 +9852,7 @@ export class FleetManager {
|
|
|
9783
9852
|
}
|
|
9784
9853
|
const session = new LoginSession(flow, tmux, {
|
|
9785
9854
|
onMenu: async (options) => {
|
|
9786
|
-
await this.
|
|
9855
|
+
const failure = await this.postChooser({
|
|
9787
9856
|
prefix: LOGIN_MENU_CALLBACK_PREFIX,
|
|
9788
9857
|
alertType: "login",
|
|
9789
9858
|
instanceName: backend,
|
|
@@ -9795,6 +9864,10 @@ export class FleetManager {
|
|
|
9795
9864
|
choices: options.map((label, index) => ({ action: String(index), label })),
|
|
9796
9865
|
expiredText: t("buttons.stale"),
|
|
9797
9866
|
});
|
|
9867
|
+
if (failure) {
|
|
9868
|
+
await chat.adapter.sendText(chat.chatId, failure, { threadId: chat.threadId })
|
|
9869
|
+
.catch(err => this.logger.warn({ err, backend }, "Could not report a login menu that failed to post"));
|
|
9870
|
+
}
|
|
9798
9871
|
},
|
|
9799
9872
|
onAuthHint: async (url, code) => {
|
|
9800
9873
|
await this.sendLoginSecret(chat, backend, url, code);
|
|
@@ -9906,6 +9979,16 @@ export class FleetManager {
|
|
|
9906
9979
|
}
|
|
9907
9980
|
/** `/login cancel` — abort the active session and remove its window. */
|
|
9908
9981
|
async cancelLoginSession() {
|
|
9982
|
+
// `/login cancel` also stops an install that `/login` started — and the
|
|
9983
|
+
// sign-in an install that just succeeded is about to hand over to.
|
|
9984
|
+
if (this.activeInstall)
|
|
9985
|
+
return this.cancelInstallSession();
|
|
9986
|
+
if (this.installHandoff) {
|
|
9987
|
+
const { backend } = this.installHandoff;
|
|
9988
|
+
this.installHandoff.cancelled = true;
|
|
9989
|
+
this.installHandoff = null;
|
|
9990
|
+
return t("login.cancelled", backend);
|
|
9991
|
+
}
|
|
9909
9992
|
if (this.loginController?.isActive())
|
|
9910
9993
|
return this.loginController.cancel();
|
|
9911
9994
|
if (!this.activeLogin)
|
|
@@ -10005,7 +10088,7 @@ export class FleetManager {
|
|
|
10005
10088
|
userId: data.userId,
|
|
10006
10089
|
});
|
|
10007
10090
|
if (text)
|
|
10008
|
-
await
|
|
10091
|
+
await this.postPromptOutcome(entry, text);
|
|
10009
10092
|
return true;
|
|
10010
10093
|
}
|
|
10011
10094
|
/** Re-login confirmation (auth pre-check said credentials still work). */
|
|
@@ -10035,7 +10118,7 @@ export class FleetManager {
|
|
|
10035
10118
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
10036
10119
|
});
|
|
10037
10120
|
if (text)
|
|
10038
|
-
await
|
|
10121
|
+
await this.postPromptOutcome(entry, text);
|
|
10039
10122
|
return true;
|
|
10040
10123
|
}
|
|
10041
10124
|
/** "Resend token" button (web login): only the requester may press it; the token never enters the channel. */
|
|
@@ -10064,20 +10147,22 @@ export class FleetManager {
|
|
|
10064
10147
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
|
|
10065
10148
|
return true;
|
|
10066
10149
|
}
|
|
10067
|
-
// ── Remote CLI install (`/
|
|
10150
|
+
// ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
|
|
10068
10151
|
//
|
|
10069
10152
|
// Same dedicated-window model as /login (and the same LoginSession state
|
|
10070
10153
|
// machine — an install is a login flow with no auth hints): run the
|
|
10071
10154
|
// installer, judge by exit code, verify the binary on a fresh login shell,
|
|
10072
|
-
// then
|
|
10155
|
+
// then sign in (the hand-off below).
|
|
10073
10156
|
activeInstall = null;
|
|
10157
|
+
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
10158
|
+
installHandoff = null;
|
|
10074
10159
|
/** Start a CLI install session. Caller enforces admin. */
|
|
10075
|
-
async startInstallSession(backendArg, chat) {
|
|
10160
|
+
async startInstallSession(backendArg, chat, opts = {}) {
|
|
10076
10161
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
10077
10162
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
10078
10163
|
if (!info)
|
|
10079
10164
|
return t("install.unsupported", backendArg);
|
|
10080
|
-
if (checkBinaryInstalled(info.binary))
|
|
10165
|
+
if (!opts.reinstall && checkBinaryInstalled(info.binary))
|
|
10081
10166
|
return t("install.already", backend, info.binary);
|
|
10082
10167
|
// Reserve the fleet-wide window before the first await (shared with web/relay
|
|
10083
10168
|
// login). Owned by this method until the session is published.
|
|
@@ -10147,22 +10232,24 @@ export class FleetManager {
|
|
|
10147
10232
|
// publish a durable completion line first so a successful install can
|
|
10148
10233
|
// never look like it silently disappeared. Include the binary that
|
|
10149
10234
|
// passed the fresh-login-shell verification and the exact next step.
|
|
10235
|
+
// Between the install ending and the sign-in starting there is no
|
|
10236
|
+
// session to cancel; this hand-off is what `/login cancel` stops.
|
|
10237
|
+
const handoff = { backend, cancelled: false };
|
|
10238
|
+
this.installHandoff = handoff;
|
|
10150
10239
|
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
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
{
|
|
10163
|
-
|
|
10164
|
-
expiredText: t("install.later_ack", backend),
|
|
10165
|
-
});
|
|
10240
|
+
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
10241
|
+
return;
|
|
10242
|
+
this.installHandoff = null;
|
|
10243
|
+
// The user asked `/login` for a working CLI: sign in straight away
|
|
10244
|
+
// (#1131). The login has its own confirmation, so nothing starts
|
|
10245
|
+
// without a click.
|
|
10246
|
+
// launchSignIn, not startLoginSession: if the new binary were still
|
|
10247
|
+
// not visible, startLoginSession would route straight back to an install.
|
|
10248
|
+
const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
|
|
10249
|
+
if (next) {
|
|
10250
|
+
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
10251
|
+
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
10252
|
+
}
|
|
10166
10253
|
},
|
|
10167
10254
|
}, this.logger);
|
|
10168
10255
|
this.activeInstall = { session, backend, chat };
|
|
@@ -10188,7 +10275,12 @@ export class FleetManager {
|
|
|
10188
10275
|
}
|
|
10189
10276
|
return t("install.started", backend);
|
|
10190
10277
|
}
|
|
10191
|
-
/**
|
|
10278
|
+
/** Whether this backend's CLI is on the fleet's PATH (what `/login` installs when it is not). */
|
|
10279
|
+
isCliInstalled(backend) {
|
|
10280
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
10281
|
+
return !!info && checkBinaryInstalled(info.binary);
|
|
10282
|
+
}
|
|
10283
|
+
/** Abort the active install (`/login cancel`) and remove its window. */
|
|
10192
10284
|
async cancelInstallSession() {
|
|
10193
10285
|
if (!this.activeInstall)
|
|
10194
10286
|
return t("install.no_session");
|
|
@@ -10246,31 +10338,6 @@ export class FleetManager {
|
|
|
10246
10338
|
process.env.PATH = [dir, ...entries].join(delimiter);
|
|
10247
10339
|
this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
|
|
10248
10340
|
}
|
|
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
10341
|
/** Discord native `/login` slash — shared by every adapter dispatch block. */
|
|
10275
10342
|
async handleLoginSlash(data, adapterId, adapter) {
|
|
10276
10343
|
if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
@@ -10288,34 +10355,19 @@ export class FleetManager {
|
|
|
10288
10355
|
return;
|
|
10289
10356
|
}
|
|
10290
10357
|
const backend = String(data.options?.backend ?? "").trim();
|
|
10358
|
+
if (data.options?.reinstall === true) {
|
|
10359
|
+
await data.respond(backend
|
|
10360
|
+
? await this.startLoginSession(backend, chat, { reinstall: true }) ?? t("login.confirm_posted")
|
|
10361
|
+
: t("login.usage"));
|
|
10362
|
+
return;
|
|
10363
|
+
}
|
|
10291
10364
|
if (backend) {
|
|
10292
10365
|
const text = await this.startLoginSession(backend, chat);
|
|
10293
10366
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
10294
10367
|
return;
|
|
10295
10368
|
}
|
|
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 }));
|
|
10369
|
+
const failure = await this.promptLoginBackends(chat);
|
|
10370
|
+
await data.respond(failure ?? t("login.chooser_posted"));
|
|
10319
10371
|
}
|
|
10320
10372
|
queueMirrorMessage(text) {
|
|
10321
10373
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|