@songsid/agend 2.1.6-beta.7 → 2.1.6-beta.9

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.
@@ -479,6 +479,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
479
479
  * Classic channels use their effective backend merge chain, not the raw row.
480
480
  */
481
481
  getActiveUsageProviderIds(): ReadonlySet<string>;
482
+ /** Subscription providers used by the running/paused instances owned by one adapter. */
483
+ getUsageProviderIdsForAdapter(adapterId: string): ReadonlySet<string>;
482
484
  /** `[instance, effective backend, credential profile]` for everything that is
483
485
  * running or paused — the one place both usage views agree on who is live. */
484
486
  private activeBackendBindings;
@@ -1598,12 +1600,35 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1598
1600
  /** Human-readable effective model, e.g. `auto (default)`. Used by /ctx. */
1599
1601
  modelDisplayForInstance(instanceName: string): string;
1600
1602
  private modelChoiceLabel;
1601
- /** Probe one backend's CLI env and cache it. Best-effort; never throws. */
1603
+ /**
1604
+ * Probe one backend's CLI env and cache it. Best-effort; never throws.
1605
+ *
1606
+ * `refreshVendorCatalog` is the "🔄 Refresh models" path only (#886): first
1607
+ * ask the CLI to refetch its own catalog, for backends whose probe merely
1608
+ * reads a file the CLI maintains. Every other caller leaves it off, so the
1609
+ * startup and /model probes behave exactly as before. A failed vendor
1610
+ * refresh fails the probe (null), which the menu reports instead of passing
1611
+ * the old list off as fresh.
1612
+ */
1602
1613
  private probeBackend;
1603
1614
  /** Background-probe every distinct backend in use at startup (non-blocking). */
1604
1615
  private probeCliEnvs;
1605
1616
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
1606
1617
  private getModelOptions;
1618
+ /**
1619
+ * The model list plus where it came from. `source` is "live" only when a probe
1620
+ * actually ran and answered; a refresh that failed or ran out of time reports
1621
+ * "cache" with the previous list, so a menu can say "could not refresh"
1622
+ * instead of presenting an old list as a fresh one.
1623
+ */
1624
+ private getModelOptionsWithSource;
1625
+ /**
1626
+ * The /model menu's choices, in the one order all three pickers share:
1627
+ * 🔄 Refresh first, then models, then (claude) "More models…". Refresh takes
1628
+ * a slot inside Discord's 25-option select cap, so it is paid for from the
1629
+ * model rows, never from "More models…".
1630
+ */
1631
+ private modelMenuChoices;
1607
1632
  /**
1608
1633
  * Model catalog behind the `list_models` tool.
1609
1634
  *
@@ -1653,6 +1678,18 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1653
1678
  private claudeApiModelOptions;
1654
1679
  /** Replace a consumed "/model" menu with the full account catalog tier. */
1655
1680
  private expandClaudeModelMenu;
1681
+ /**
1682
+ * "🔄 Refresh models" (#886): probe live past both caches and redraw the menu.
1683
+ *
1684
+ * Past AgEnD's cli-env cache (refresh=true) and, where the backend supports
1685
+ * it, past the CLI's own catalog cache too (codex: `codex debug models`).
1686
+ * Without the second half, a codex refresh re-read the same models_cache.json
1687
+ * and showed the old list as new.
1688
+ *
1689
+ * A failed refresh keeps the previous list and says so. It never blanks the
1690
+ * menu, because a picker with no rows cannot even offer another refresh.
1691
+ */
1692
+ private refreshModelMenu;
1656
1693
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
1657
1694
  private handleModelSelection;
1658
1695
  /** Apply a model to an instance: runtime paste (claude-code) or persist + restart (others). */
@@ -44,7 +44,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
44
44
  import { outboundHandlers } from "./outbound-handlers.js";
45
45
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
46
46
  import { handleViewRequest, isViewPath } from "./view-api.js";
47
- import { formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
47
+ import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
48
48
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
49
49
  import { LoginSession } from "./login-manager.js";
50
50
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
@@ -1251,6 +1251,19 @@ export class FleetManager {
1251
1251
  }
1252
1252
  return providers;
1253
1253
  }
1254
+ /** Subscription providers used by the running/paused instances owned by one adapter. */
1255
+ getUsageProviderIdsForAdapter(adapterId) {
1256
+ const providers = new Set();
1257
+ for (const [name, backend, profile] of this.activeBackendBindings()) {
1258
+ if (this.getInstanceAdapterId(name) !== adapterId)
1259
+ continue;
1260
+ const provider = usageProviderIdForBackend(backend);
1261
+ if (!provider)
1262
+ continue;
1263
+ providers.add(profile ? `${provider}:${profile}` : provider);
1264
+ }
1265
+ return providers;
1266
+ }
1254
1267
  /** `[instance, effective backend, credential profile]` for everything that is
1255
1268
  * running or paused — the one place both usage views agree on who is live. */
1256
1269
  activeBackendBindings() {
@@ -3060,11 +3073,15 @@ export class FleetManager {
3060
3073
  if (targets.length === 0)
3061
3074
  return;
3062
3075
  try {
3063
- const payload = await getUsageSnapshot(false, this.getActiveUsageProviderIds());
3064
- const text = formatDiscordUsageActivity(payload);
3076
+ // Fetch the shared snapshot once, then scope the projection to each
3077
+ // adapter's own fleet/Classic instances. Passing the fleet-wide active
3078
+ // set here would make every bot advertise providers owned by a sibling
3079
+ // bot (notably ClassicBot's Grok/Antigravity rows).
3080
+ const payload = await getUsageSnapshot(false);
3065
3081
  for (const adapter of targets) {
3066
3082
  try {
3067
- adapter.setActivity?.(text);
3083
+ const scoped = filterUsageProviders(payload, this.getUsageProviderIdsForAdapter(adapter.id));
3084
+ adapter.setActivity?.(formatDiscordUsageActivity(scoped));
3068
3085
  }
3069
3086
  catch {
3070
3087
  // Presence is cosmetic; a failed update must not affect delivery.
@@ -3077,11 +3094,12 @@ export class FleetManager {
3077
3094
  this.logger.debug("Discord usage presence refresh skipped");
3078
3095
  }
3079
3096
  })();
3080
- this.discordPresenceInFlight = run.finally(() => {
3081
- if (this.discordPresenceInFlight === run)
3097
+ const done = run.finally(() => {
3098
+ if (this.discordPresenceInFlight === done)
3082
3099
  this.discordPresenceInFlight = null;
3083
3100
  });
3084
- return this.discordPresenceInFlight;
3101
+ this.discordPresenceInFlight = done;
3102
+ return done;
3085
3103
  }
3086
3104
  /**
3087
3105
  * Delete inbox files older than retentionDays (by mtime). Cleans the shared
@@ -9726,8 +9744,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9726
9744
  * A model list is an aid: a vendor that stops answering must degrade to the
9727
9745
  * previous list, never stall the command that asked for it.
9728
9746
  */
9729
- async probeBackendBounded(backend) {
9730
- const work = this.probeBackend(backend);
9747
+ async probeBackendBounded(backend, opts = {}) {
9748
+ const work = this.probeBackend(backend, opts);
9731
9749
  work.catch(() => { });
9732
9750
  let timer;
9733
9751
  const deadline = new Promise(resolve => {
@@ -9791,12 +9809,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9791
9809
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
9792
9810
  return option.id === currentModel ? `✓ ${label}` : label;
9793
9811
  }
9794
- /** Probe one backend's CLI env and cache it. Best-effort; never throws. */
9795
- async probeBackend(backend) {
9812
+ /**
9813
+ * Probe one backend's CLI env and cache it. Best-effort; never throws.
9814
+ *
9815
+ * `refreshVendorCatalog` is the "🔄 Refresh models" path only (#886): first
9816
+ * ask the CLI to refetch its own catalog, for backends whose probe merely
9817
+ * reads a file the CLI maintains. Every other caller leaves it off, so the
9818
+ * startup and /model probes behave exactly as before. A failed vendor
9819
+ * refresh fails the probe (null), which the menu reports instead of passing
9820
+ * the old list off as fresh.
9821
+ */
9822
+ async probeBackend(backend, opts = {}) {
9796
9823
  try {
9797
9824
  const be = createBackend(backend, join(getAgendHome(), "cli-env"));
9798
9825
  if (!be.probeCLIEnv)
9799
9826
  return null;
9827
+ if (opts.refreshVendorCatalog && be.refreshModelCatalog)
9828
+ await be.refreshModelCatalog();
9800
9829
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
9801
9830
  const env = { backend, probedAt: Date.now(), ...probed };
9802
9831
  // An empty result must never overwrite a catalog we already have. Some
@@ -9845,22 +9874,48 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9845
9874
  }
9846
9875
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
9847
9876
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
9877
+ return (await this.getModelOptionsWithSource(instanceName, { refresh, onLiveProbe })).models;
9878
+ }
9879
+ /**
9880
+ * The model list plus where it came from. `source` is "live" only when a probe
9881
+ * actually ran and answered; a refresh that failed or ran out of time reports
9882
+ * "cache" with the previous list, so a menu can say "could not refresh"
9883
+ * instead of presenting an old list as a fresh one.
9884
+ */
9885
+ async getModelOptionsWithSource(instanceName, opts = {}) {
9848
9886
  const backendName = this.backendNameForInstance(instanceName);
9849
9887
  const cached = this.readCliEnv(backendName);
9850
- if (!refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached))
9851
- return cached.models;
9888
+ if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
9889
+ return { models: cached.models, source: "cache" };
9890
+ }
9852
9891
  // About to go to the vendor: let the caller say so. A silent 1–10s pause on
9853
9892
  // an interactive command reads as another hang, which is the wrong lesson to
9854
9893
  // teach a user who has just been bitten by one.
9855
- onLiveProbe?.();
9894
+ opts.onLiveProbe?.();
9856
9895
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
9857
9896
  // A newly released model is invisible until this runs, which is why staleness
9858
9897
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
9859
- const env = await this.probeBackendBounded(backendName);
9898
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
9860
9899
  if (env?.models.length)
9861
- return env.models;
9900
+ return { models: env.models, source: "live" };
9862
9901
  // Probe failed or timed out: the previous list is still the best answer.
9863
- return cached?.models ?? [];
9902
+ return { models: cached?.models ?? [], source: "cache" };
9903
+ }
9904
+ /**
9905
+ * The /model menu's choices, in the one order all three pickers share:
9906
+ * 🔄 Refresh first, then models, then (claude) "More models…". Refresh takes
9907
+ * a slot inside Discord's 25-option select cap, so it is paid for from the
9908
+ * model rows, never from "More models…".
9909
+ */
9910
+ modelMenuChoices(instanceName, nonce, options, currentModel) {
9911
+ const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
9912
+ const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
9913
+ for (const o of options.slice(0, isClaude ? 23 : 24)) {
9914
+ choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
9915
+ }
9916
+ if (isClaude)
9917
+ choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
9918
+ return choices;
9864
9919
  }
9865
9920
  /**
9866
9921
  * Model catalog behind the `list_models` tool.
@@ -10161,14 +10216,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10161
10216
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
10162
10217
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(name);
10163
10218
  const nonce = randomBytes(6).toString("hex");
10164
- const isClaude = this.backendNameForInstance(name) === "claude-code";
10165
- const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
10166
- id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
10167
- label: this.modelChoiceLabel(o, currentModel),
10168
- }));
10169
- if (isClaude) {
10170
- choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
10171
- }
10219
+ const choices = this.modelMenuChoices(name, nonce, options, currentModel);
10172
10220
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
10173
10221
  timer.unref?.();
10174
10222
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
@@ -10194,15 +10242,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10194
10242
  }
10195
10243
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
10196
10244
  const nonce = randomBytes(6).toString("hex");
10197
- const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
10198
- // Keep the more-models entry inside Discord's 25-option select cap.
10199
- const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
10200
- id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
10201
- label: this.modelChoiceLabel(o, currentModel),
10202
- }));
10203
- if (isClaude) {
10204
- choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
10205
- }
10245
+ const choices = this.modelMenuChoices(instanceName, nonce, options, currentModel);
10206
10246
  const respond = async (text) => {
10207
10247
  await adapter.sendText(chatId, text, { threadId });
10208
10248
  return undefined;
@@ -10286,6 +10326,64 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10286
10326
  await pending.respond(t("model.more_unavailable")).catch(() => { });
10287
10327
  }
10288
10328
  }
10329
+ /**
10330
+ * "🔄 Refresh models" (#886): probe live past both caches and redraw the menu.
10331
+ *
10332
+ * Past AgEnD's cli-env cache (refresh=true) and, where the backend supports
10333
+ * it, past the CLI's own catalog cache too (codex: `codex debug models`).
10334
+ * Without the second half, a codex refresh re-read the same models_cache.json
10335
+ * and showed the old list as new.
10336
+ *
10337
+ * A failed refresh keeps the previous list and says so. It never blanks the
10338
+ * menu, because a picker with no rows cannot even offer another refresh.
10339
+ */
10340
+ async refreshModelMenu(pending) {
10341
+ const { models, source } = await this.getModelOptionsWithSource(pending.instanceName, {
10342
+ refresh: true, refreshVendorCatalog: true,
10343
+ });
10344
+ if (models.length === 0) {
10345
+ await pending.respond(t("model.list_unavailable", pending.instanceName)).catch(() => { });
10346
+ return;
10347
+ }
10348
+ const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(pending.instanceName);
10349
+ const nonce = randomBytes(6).toString("hex");
10350
+ const choices = this.modelMenuChoices(pending.instanceName, nonce, models, currentModel);
10351
+ const status = source === "live" ? t("model.refreshed") : t("model.refresh_failed");
10352
+ const timer = setTimeout(() => {
10353
+ const p = this.pendingModelSelects.get(nonce);
10354
+ if (p) {
10355
+ this.pendingModelSelects.delete(nonce);
10356
+ p.respond(t("model.selection_expired")).catch(() => { });
10357
+ }
10358
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
10359
+ timer.unref?.();
10360
+ this.pendingModelSelects.set(nonce, { ...pending, model: "", timer });
10361
+ try {
10362
+ if (pending.respondChoices) {
10363
+ // Discord select menu: edit the same interaction reply in place.
10364
+ await pending.respondChoices(`${status}\n${t("model.menu", `**${currentDisplay}**`)}`, choices);
10365
+ return;
10366
+ }
10367
+ if (pending.adapter && pending.adapterChatId) {
10368
+ // Telegram: retire the consumed keyboard, then post the redrawn menu.
10369
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
10370
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, t("model.refresh"), pending.adapterThreadId).catch(() => { });
10371
+ }
10372
+ const menuMessageId = await pending.adapter.promptUser(pending.adapterChatId, `${status}\n${t("model.menu", currentDisplay)}`, choices, { threadId: pending.adapterThreadId });
10373
+ const fresh = this.pendingModelSelects.get(nonce);
10374
+ if (fresh)
10375
+ fresh.menuMessageId = menuMessageId;
10376
+ return;
10377
+ }
10378
+ await pending.respond(t("model.usage")).catch(() => { });
10379
+ }
10380
+ catch (err) {
10381
+ this.pendingModelSelects.delete(nonce);
10382
+ clearTimeout(timer);
10383
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Refreshed model menu failed");
10384
+ await pending.respond(t("model.usage")).catch(() => { });
10385
+ }
10386
+ }
10289
10387
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
10290
10388
  async handleModelSelection(data) {
10291
10389
  if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
@@ -10311,6 +10409,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10311
10409
  await this.expandClaudeModelMenu(pending);
10312
10410
  return true;
10313
10411
  }
10412
+ // "🔄 Refresh models" is navigation too: re-probe past every cache and
10413
+ // redraw the same menu with what came back.
10414
+ if (model === "__refresh__") {
10415
+ await this.refreshModelMenu(pending);
10416
+ return true;
10417
+ }
10314
10418
  // Send immediate "⏳ Switching..." feedback, then apply in background.
10315
10419
  const progressText = t("model.switching", pending.instanceName, model);
10316
10420
  let progressMsgId;