@songsid/agend 2.1.6-beta.8 → 2.1.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/dist/backend/codex.d.ts +52 -0
  2. package/dist/backend/codex.js +618 -35
  3. package/dist/backend/codex.js.map +1 -1
  4. package/dist/backend/kiro.d.ts +9 -0
  5. package/dist/backend/kiro.js +129 -0
  6. package/dist/backend/kiro.js.map +1 -1
  7. package/dist/backend/muse.d.ts +37 -4
  8. package/dist/backend/muse.js +150 -61
  9. package/dist/backend/muse.js.map +1 -1
  10. package/dist/backend/types.d.ts +25 -1
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/daemon.d.ts +98 -10
  13. package/dist/daemon.js +836 -94
  14. package/dist/daemon.js.map +1 -1
  15. package/dist/fleet-manager.d.ts +68 -2
  16. package/dist/fleet-manager.js +290 -81
  17. package/dist/fleet-manager.js.map +1 -1
  18. package/dist/instance-lifecycle.d.ts +17 -0
  19. package/dist/instance-lifecycle.js +118 -5
  20. package/dist/instance-lifecycle.js.map +1 -1
  21. package/dist/locale.js +22 -0
  22. package/dist/locale.js.map +1 -1
  23. package/dist/muse-usage-relay.d.ts +72 -0
  24. package/dist/muse-usage-relay.js +431 -0
  25. package/dist/muse-usage-relay.js.map +1 -0
  26. package/dist/tmux-manager.d.ts +2 -0
  27. package/dist/tmux-manager.js +20 -0
  28. package/dist/tmux-manager.js.map +1 -1
  29. package/dist/tool-permissions.d.ts +27 -0
  30. package/dist/tool-permissions.js +64 -3
  31. package/dist/tool-permissions.js.map +1 -1
  32. package/dist/topic-commands.d.ts +9 -0
  33. package/dist/topic-commands.js +36 -2
  34. package/dist/topic-commands.js.map +1 -1
  35. package/dist/ui/view.html +2 -2
  36. package/dist/usage/i18n-keys.d.ts +1 -1
  37. package/dist/usage/i18n-keys.js +1 -1
  38. package/dist/usage/i18n-keys.js.map +1 -1
  39. package/dist/usage/providers.d.ts +4 -0
  40. package/dist/usage/providers.js +86 -9
  41. package/dist/usage/providers.js.map +1 -1
  42. package/dist/usage/usage-api.d.ts +0 -11
  43. package/dist/usage/usage-api.js +50 -8
  44. package/dist/usage/usage-api.js.map +1 -1
  45. package/package.json +2 -1
@@ -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";
@@ -52,7 +52,7 @@ import { runBeforeDeadline } from "./deadline.js";
52
52
  import { LoginWindowLock } from "./login-window-lock.js";
53
53
  import { handleSettingsRequest } from "./settings-api.js";
54
54
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
55
- import { handleAgentRequest } from "./agent-endpoint.js";
55
+ import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
56
56
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
57
57
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
58
58
  import { validateFleetConfig } from "./config-validator.js";
@@ -63,7 +63,7 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
63
63
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
64
64
  import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
65
65
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
66
- import { mayUseTool, resolveToolSet, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
66
+ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
67
67
  import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
68
68
  import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
69
69
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
@@ -488,6 +488,8 @@ export class FleetManager {
488
488
  eventLogPruneTimer = null;
489
489
  logRotateTimer = null;
490
490
  discordPresenceTimer = null;
491
+ discordPresenceEagerTimer = null;
492
+ discordPresenceEagerPending = false;
491
493
  discordPresenceInFlight = null;
492
494
  static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
493
495
  /** Days of event/activity history to keep. */
@@ -1251,6 +1253,19 @@ export class FleetManager {
1251
1253
  }
1252
1254
  return providers;
1253
1255
  }
1256
+ /** Subscription providers used by the running/paused instances owned by one adapter. */
1257
+ getUsageProviderIdsForAdapter(adapterId) {
1258
+ const providers = new Set();
1259
+ for (const [name, backend, profile] of this.activeBackendBindings()) {
1260
+ if (this.getInstanceAdapterId(name) !== adapterId)
1261
+ continue;
1262
+ const provider = usageProviderIdForBackend(backend);
1263
+ if (!provider)
1264
+ continue;
1265
+ providers.add(profile ? `${provider}:${profile}` : provider);
1266
+ }
1267
+ return providers;
1268
+ }
1254
1269
  /** `[instance, effective backend, credential profile]` for everything that is
1255
1270
  * running or paused — the one place both usage views agree on who is live. */
1256
1271
  activeBackendBindings() {
@@ -1826,6 +1841,7 @@ export class FleetManager {
1826
1841
  catch { /* consumed or absent */ }
1827
1842
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
1828
1843
  await this.connectIpcToInstance(name);
1844
+ this.requestDiscordUsagePresenceRefresh();
1829
1845
  }
1830
1846
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
1831
1847
  async startPersistedPausedInstance(name) {
@@ -3045,12 +3061,37 @@ export class FleetManager {
3045
3061
  startDiscordUsagePresence() {
3046
3062
  if (this.discordPresenceTimer)
3047
3063
  clearInterval(this.discordPresenceTimer);
3064
+ if (this.discordPresenceEagerTimer) {
3065
+ clearTimeout(this.discordPresenceEagerTimer);
3066
+ this.discordPresenceEagerTimer = null;
3067
+ }
3068
+ this.discordPresenceEagerPending = false;
3048
3069
  void this.refreshDiscordUsagePresence();
3049
3070
  this.discordPresenceTimer = setInterval(() => {
3050
3071
  void this.refreshDiscordUsagePresence();
3051
3072
  }, FleetManager.DISCORD_PRESENCE_REFRESH_MS);
3052
3073
  this.discordPresenceTimer.unref?.();
3053
3074
  }
3075
+ /**
3076
+ * Request one prompt presence refresh after an instance/adapter comes online.
3077
+ * Startup can bring several instances up together; a short coalescing window
3078
+ * keeps that herd on the existing shared refresh path and one usage fetch.
3079
+ */
3080
+ requestDiscordUsagePresenceRefresh() {
3081
+ this.discordPresenceEagerPending = true;
3082
+ // During early fleet startup the interval is not installed yet; its first
3083
+ // refresh in startDiscordUsagePresence already covers all pending starts.
3084
+ if (!this.discordPresenceTimer || this.discordPresenceEagerTimer)
3085
+ return;
3086
+ this.discordPresenceEagerTimer = setTimeout(() => {
3087
+ this.discordPresenceEagerTimer = null;
3088
+ if (!this.discordPresenceEagerPending || !this.discordPresenceTimer)
3089
+ return;
3090
+ this.discordPresenceEagerPending = false;
3091
+ void this.refreshDiscordUsagePresence();
3092
+ }, 50);
3093
+ this.discordPresenceEagerTimer.unref?.();
3094
+ }
3054
3095
  refreshDiscordUsagePresence() {
3055
3096
  if (this.discordPresenceInFlight)
3056
3097
  return this.discordPresenceInFlight;
@@ -3060,11 +3101,15 @@ export class FleetManager {
3060
3101
  if (targets.length === 0)
3061
3102
  return;
3062
3103
  try {
3063
- const payload = await getUsageSnapshot(false, this.getActiveUsageProviderIds());
3064
- const text = formatDiscordUsageActivity(payload);
3104
+ // Fetch the shared snapshot once, then scope the projection to each
3105
+ // adapter's own fleet/Classic instances. Passing the fleet-wide active
3106
+ // set here would make every bot advertise providers owned by a sibling
3107
+ // bot (notably ClassicBot's Grok/Antigravity rows).
3108
+ const payload = await getUsageSnapshot(false);
3065
3109
  for (const adapter of targets) {
3066
3110
  try {
3067
- adapter.setActivity?.(text);
3111
+ const scoped = filterUsageProviders(payload, this.getUsageProviderIdsForAdapter(adapter.id));
3112
+ adapter.setActivity?.(formatDiscordUsageActivity(scoped));
3068
3113
  }
3069
3114
  catch {
3070
3115
  // Presence is cosmetic; a failed update must not affect delivery.
@@ -3077,11 +3122,12 @@ export class FleetManager {
3077
3122
  this.logger.debug("Discord usage presence refresh skipped");
3078
3123
  }
3079
3124
  })();
3080
- this.discordPresenceInFlight = run.finally(() => {
3081
- if (this.discordPresenceInFlight === run)
3125
+ const done = run.finally(() => {
3126
+ if (this.discordPresenceInFlight === done)
3082
3127
  this.discordPresenceInFlight = null;
3083
3128
  });
3084
- return this.discordPresenceInFlight;
3129
+ this.discordPresenceInFlight = done;
3130
+ return done;
3085
3131
  }
3086
3132
  /**
3087
3133
  * Delete inbox files older than retentionDays (by mtime). Cleans the shared
@@ -3688,7 +3734,7 @@ export class FleetManager {
3688
3734
  this.adapter.setChatId(String(fleet.channel.group_id));
3689
3735
  }
3690
3736
  if (this.discordPresenceTimer && this.adapter.type === "discord") {
3691
- void this.refreshDiscordUsagePresence();
3737
+ this.requestDiscordUsagePresenceRefresh();
3692
3738
  }
3693
3739
  this.startTopicCleanupPoller();
3694
3740
  // Prune stale external sessions every 5 minutes
@@ -4033,7 +4079,7 @@ export class FleetManager {
4033
4079
  adapter.setChatId(String(channelConfig.group_id));
4034
4080
  }
4035
4081
  if (this.discordPresenceTimer && adapter.type === "discord") {
4036
- void this.refreshDiscordUsagePresence();
4082
+ this.requestDiscordUsagePresenceRefresh();
4037
4083
  }
4038
4084
  this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
4039
4085
  }
@@ -4278,7 +4324,7 @@ export class FleetManager {
4278
4324
  await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
4279
4325
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
4280
4326
  if (this.discordPresenceTimer && adapter.type === "discord") {
4281
- void this.refreshDiscordUsagePresence();
4327
+ this.requestDiscordUsagePresenceRefresh();
4282
4328
  }
4283
4329
  this.logger.info({ id }, "Adapter gateway rebuilt successfully");
4284
4330
  }
@@ -4306,7 +4352,7 @@ export class FleetManager {
4306
4352
  this.logger.info({ id, attempt }, "Adapter restarted successfully");
4307
4353
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
4308
4354
  if (this.discordPresenceTimer && adapter.type === "discord") {
4309
- void this.refreshDiscordUsagePresence();
4355
+ this.requestDiscordUsagePresenceRefresh();
4310
4356
  }
4311
4357
  return;
4312
4358
  }
@@ -5532,36 +5578,18 @@ export class FleetManager {
5532
5578
  return;
5533
5579
  }
5534
5580
  try {
5535
- let result;
5536
- switch (msg.type) {
5537
- case "fleet_schedule_create": {
5538
- const params = {
5539
- cron: payload.cron,
5540
- at: payload.at,
5541
- message: payload.message,
5542
- source: instanceName,
5543
- target: payload.target || instanceName,
5544
- reply_chat_id: meta.chat_id,
5545
- reply_thread_id: meta.thread_id || null,
5546
- reply_adapter_id: meta.adapter_id || null,
5547
- label: payload.label,
5548
- timezone: payload.timezone,
5549
- silent: !!(payload.silent),
5550
- };
5551
- result = this.scheduler.create(params);
5552
- break;
5553
- }
5554
- case "fleet_schedule_list":
5555
- result = this.scheduler.list(payload.target);
5556
- break;
5557
- case "fleet_schedule_update":
5558
- result = this.scheduler.update(payload.id, payload);
5559
- break;
5560
- case "fleet_schedule_delete":
5561
- this.scheduler.delete(payload.id);
5562
- result = "ok";
5563
- break;
5564
- }
5581
+ const op = String(msg.type).replace("fleet_schedule_", "");
5582
+ const result = this.performScheduleOp(instanceName, op, payload, {
5583
+ // The daemon sends its last chat id, which is unset until the instance
5584
+ // has had a chat message — and cross-instance traffic never sets it. So
5585
+ // a worker that only takes delegated tasks, the very instance #895 lets
5586
+ // self-schedule, hit "NOT NULL constraint failed: schedules.reply_chat_id".
5587
+ // No chat means no reply chat, exactly as on the agent endpoint.
5588
+ chatId: meta.chat_id ?? "",
5589
+ threadId: meta.thread_id || null,
5590
+ adapterId: meta.adapter_id || null,
5591
+ silent: !!(payload.silent),
5592
+ });
5565
5593
  ipc.send({ type: "fleet_schedule_response", fleetRequestId, result });
5566
5594
  }
5567
5595
  catch (err) {
@@ -5697,23 +5725,76 @@ export class FleetManager {
5697
5725
  async handleScheduleCrudHttp(instance, op, args) {
5698
5726
  if (!this.scheduler)
5699
5727
  return { error: "Scheduler not available" };
5728
+ if (op !== "create" && op !== "list" && op !== "update" && op !== "delete") {
5729
+ return { error: `Unknown schedule op: ${op}` };
5730
+ }
5731
+ // No bound chat on this path, as before: a schedule made through the agent
5732
+ // endpoint has nowhere of its own to reply.
5733
+ return this.performScheduleOp(instance, op, args, { chatId: "", threadId: null });
5734
+ }
5735
+ /**
5736
+ * The only place an agent's request creates, changes or removes a schedule
5737
+ * (#895). The IPC handler (MCP calls, direct channel.sock writes) and the
5738
+ * agent endpoint (agent-cli, HTTP agent mode) are adapters over this, so the
5739
+ * target check cannot be present on one face and missing on the other — the
5740
+ * shape #804 had to close twice.
5741
+ *
5742
+ * `caller` is the instance the server resolved for the request; any
5743
+ * `source` in `args` is ignored, and a schedule's source is always its caller.
5744
+ * A refusal is a ToolNotPermittedError: 403 on the agent endpoint, the error
5745
+ * of the schedule response on IPC.
5746
+ */
5747
+ performScheduleOp(caller, op, args, reply) {
5748
+ const scheduler = this.scheduler;
5749
+ // Shape first, so the value the permission decision reads is the value the
5750
+ // scheduler gets. `target` used to be filtered through typeof for the
5751
+ // decision and passed raw to the scheduler, whose `target.startsWith`
5752
+ // then threw a TypeError for `target: 123` (#897). A non-string is refused
5753
+ // — never coerced: turning 123 into "123" would decide an identity
5754
+ // question on a value the caller did not send. `null` means absent, as
5755
+ // omitting it always did, and is removed before the scheduler sees it.
5756
+ if (args.target === null) {
5757
+ args = { ...args };
5758
+ delete args.target;
5759
+ }
5760
+ if (args.target !== undefined && typeof args.target !== "string") {
5761
+ throw new Error(`${op}_schedule: "target" must be an instance name (a string), not ${typeof args.target}.`);
5762
+ }
5763
+ if ((op === "update" || op === "delete") && typeof args.id !== "string") {
5764
+ throw new Error(`${op}_schedule: "id" must be a schedule id (a string) — get one from list_schedules.`);
5765
+ }
5766
+ const requestedTarget = args.target;
5767
+ if (op !== "list") {
5768
+ const profile = resolveToolSet(this.fleetConfig?.instances[caller], caller);
5769
+ const existing = op === "create" ? null : scheduler.get(args.id);
5770
+ const refusal = scheduleOpRefusal(profile, caller, op, { requestedTarget, existing });
5771
+ if (refusal) {
5772
+ this.logger.warn({ instance: caller, profile, op, target: requestedTarget ?? existing?.target, scheduleId: args.id }, "tool-permissions: schedule op refused");
5773
+ throw new ToolNotPermittedError(refusal);
5774
+ }
5775
+ }
5700
5776
  switch (op) {
5701
5777
  case "create":
5702
- return this.scheduler.create({
5778
+ return scheduler.create({
5703
5779
  cron: args.cron,
5704
5780
  at: args.at,
5705
5781
  message: args.message,
5706
- source: instance, target: args.target || instance,
5707
- reply_chat_id: "", reply_thread_id: null,
5782
+ source: caller,
5783
+ target: requestedTarget || caller,
5784
+ reply_chat_id: reply.chatId,
5785
+ reply_thread_id: reply.threadId,
5786
+ ...(reply.adapterId !== undefined ? { reply_adapter_id: reply.adapterId } : {}),
5708
5787
  label: args.label,
5709
5788
  timezone: args.timezone,
5789
+ ...(reply.silent !== undefined ? { silent: reply.silent } : {}),
5710
5790
  });
5711
- case "list": return this.scheduler.list(args.target);
5712
- case "update": return this.scheduler.update(args.id, args);
5791
+ case "list":
5792
+ return scheduler.list(requestedTarget);
5793
+ case "update":
5794
+ return scheduler.update(args.id, args);
5713
5795
  case "delete":
5714
- this.scheduler.delete(args.id);
5796
+ scheduler.delete(args.id);
5715
5797
  return "ok";
5716
- default: return { error: `Unknown schedule op: ${op}` };
5717
5798
  }
5718
5799
  }
5719
5800
  async handleDecisionCrudHttp(instance, op, args) {
@@ -6378,6 +6459,9 @@ export class FleetManager {
6378
6459
  }
6379
6460
  }
6380
6461
  }
6462
+ // Capture instance dir BEFORE lifecycle.remove, which deletes the instance
6463
+ // from fleetConfig. (The backend would be unresolvable afterwards.)
6464
+ const instanceDirToClean = this.getInstanceDir(name);
6381
6465
  await this.lifecycle.remove(name, authorization);
6382
6466
  // Clean up per-instance tracking maps so they don't grow unbounded
6383
6467
  // as instances are created and deleted over the lifetime of the fleet.
@@ -6386,12 +6470,29 @@ export class FleetManager {
6386
6470
  this.rateLimitWarnedAt.delete(name);
6387
6471
  // Clean up statusline watcher + instance directory
6388
6472
  this.statuslineWatcher.unwatch(name);
6473
+ // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
6474
+ // (which deletes the instance from fleetConfig).
6389
6475
  try {
6390
- rmSync(this.getInstanceDir(name), { recursive: true, force: true });
6476
+ rmSync(instanceDirToClean, { recursive: true, force: true });
6391
6477
  }
6392
6478
  catch (err) {
6393
6479
  this.logger.debug({ err, name }, "Instance dir cleanup failed");
6394
6480
  }
6481
+ // Remove the codex short home (~/.agend/cx/<hash>/) unconditionally.
6482
+ // (#953). CodexBackend.shortHomeFor is a pure path computation; the existsSync
6483
+ // guard makes this a no-op for instances that never used a short home (non-codex,
6484
+ // or codex instances on a short-enough path). Unconditional also covers instances
6485
+ // whose backend was switched away from codex before deletion — they still have a
6486
+ // short home that would otherwise be inherited by a later same-name instance.
6487
+ try {
6488
+ const { CodexBackend } = await import("./backend/codex.js");
6489
+ const shortHome = CodexBackend.shortHomeFor(instanceDirToClean);
6490
+ if (existsSync(shortHome))
6491
+ rmSync(shortHome, { recursive: true, force: true });
6492
+ }
6493
+ catch (err) {
6494
+ this.logger.debug({ err, name }, "Codex short home cleanup failed");
6495
+ }
6395
6496
  }
6396
6497
  startStatuslineWatcher(name) {
6397
6498
  if (this.lifecycle.isPaused(name))
@@ -9726,8 +9827,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9726
9827
  * A model list is an aid: a vendor that stops answering must degrade to the
9727
9828
  * previous list, never stall the command that asked for it.
9728
9829
  */
9729
- async probeBackendBounded(backend) {
9730
- const work = this.probeBackend(backend);
9830
+ async probeBackendBounded(backend, opts = {}) {
9831
+ const work = this.probeBackend(backend, opts);
9731
9832
  work.catch(() => { });
9732
9833
  let timer;
9733
9834
  const deadline = new Promise(resolve => {
@@ -9791,12 +9892,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9791
9892
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
9792
9893
  return option.id === currentModel ? `✓ ${label}` : label;
9793
9894
  }
9794
- /** Probe one backend's CLI env and cache it. Best-effort; never throws. */
9795
- async probeBackend(backend) {
9895
+ /**
9896
+ * Probe one backend's CLI env and cache it. Best-effort; never throws.
9897
+ *
9898
+ * `refreshVendorCatalog` is the "🔄 Refresh models" path only (#886): first
9899
+ * ask the CLI to refetch its own catalog, for backends whose probe merely
9900
+ * reads a file the CLI maintains. Every other caller leaves it off, so the
9901
+ * startup and /model probes behave exactly as before. A failed vendor
9902
+ * refresh fails the probe (null), which the menu reports instead of passing
9903
+ * the old list off as fresh.
9904
+ */
9905
+ async probeBackend(backend, opts = {}) {
9796
9906
  try {
9797
9907
  const be = createBackend(backend, join(getAgendHome(), "cli-env"));
9798
9908
  if (!be.probeCLIEnv)
9799
9909
  return null;
9910
+ if (opts.refreshVendorCatalog && be.refreshModelCatalog)
9911
+ await be.refreshModelCatalog();
9800
9912
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
9801
9913
  const env = { backend, probedAt: Date.now(), ...probed };
9802
9914
  // An empty result must never overwrite a catalog we already have. Some
@@ -9845,22 +9957,48 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9845
9957
  }
9846
9958
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
9847
9959
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
9960
+ return (await this.getModelOptionsWithSource(instanceName, { refresh, onLiveProbe })).models;
9961
+ }
9962
+ /**
9963
+ * The model list plus where it came from. `source` is "live" only when a probe
9964
+ * actually ran and answered; a refresh that failed or ran out of time reports
9965
+ * "cache" with the previous list, so a menu can say "could not refresh"
9966
+ * instead of presenting an old list as a fresh one.
9967
+ */
9968
+ async getModelOptionsWithSource(instanceName, opts = {}) {
9848
9969
  const backendName = this.backendNameForInstance(instanceName);
9849
9970
  const cached = this.readCliEnv(backendName);
9850
- if (!refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached))
9851
- return cached.models;
9971
+ if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
9972
+ return { models: cached.models, source: "cache" };
9973
+ }
9852
9974
  // About to go to the vendor: let the caller say so. A silent 1–10s pause on
9853
9975
  // an interactive command reads as another hang, which is the wrong lesson to
9854
9976
  // teach a user who has just been bitten by one.
9855
- onLiveProbe?.();
9977
+ opts.onLiveProbe?.();
9856
9978
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
9857
9979
  // A newly released model is invisible until this runs, which is why staleness
9858
9980
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
9859
- const env = await this.probeBackendBounded(backendName);
9981
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
9860
9982
  if (env?.models.length)
9861
- return env.models;
9983
+ return { models: env.models, source: "live" };
9862
9984
  // Probe failed or timed out: the previous list is still the best answer.
9863
- return cached?.models ?? [];
9985
+ return { models: cached?.models ?? [], source: "cache" };
9986
+ }
9987
+ /**
9988
+ * The /model menu's choices, in the one order all three pickers share:
9989
+ * 🔄 Refresh first, then models, then (claude) "More models…". Refresh takes
9990
+ * a slot inside Discord's 25-option select cap, so it is paid for from the
9991
+ * model rows, never from "More models…".
9992
+ */
9993
+ modelMenuChoices(instanceName, nonce, options, currentModel) {
9994
+ const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
9995
+ const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
9996
+ for (const o of options.slice(0, isClaude ? 23 : 24)) {
9997
+ choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
9998
+ }
9999
+ if (isClaude)
10000
+ choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
10001
+ return choices;
9864
10002
  }
9865
10003
  /**
9866
10004
  * Model catalog behind the `list_models` tool.
@@ -9938,17 +10076,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9938
10076
  return [];
9939
10077
  }
9940
10078
  }
9941
- /** Account-wide catalog: probe cache first, live probe on miss. */
10079
+ /**
10080
+ * Account-wide catalog: probe cache first, live probe when the cache is
10081
+ * missing or older than the same ~1h window `/model` uses (#888).
10082
+ *
10083
+ * Previously this served anything inside the 24h hard TTL, so `list_models`
10084
+ * could answer with a day-old list `/model` had already re-probed past.
10085
+ * The probe runs under the same deadline `/model` uses: a vendor that stops
10086
+ * answering degrades to the previous list, never stalls the tool call.
10087
+ */
9942
10088
  async globalModelCatalog(backend) {
9943
10089
  const cached = this.readCliEnv(backend);
9944
- if (cached?.models?.length) {
10090
+ if (cached?.models?.length && !this.cliEnvNeedsRefresh(cached)) {
9945
10091
  return {
9946
10092
  backend, scope: "global", current_model: cached.currentModel ?? null,
9947
10093
  models: cached.models, source: "cache",
9948
10094
  probed_at: new Date(cached.probedAt).toISOString(),
9949
10095
  };
9950
10096
  }
9951
- const env = await this.probeBackend(backend);
10097
+ const env = await this.probeBackendBounded(backend);
9952
10098
  if (env?.models?.length) {
9953
10099
  return {
9954
10100
  backend, scope: "global", current_model: env.currentModel ?? null,
@@ -9956,6 +10102,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9956
10102
  probed_at: new Date(env.probedAt).toISOString(),
9957
10103
  };
9958
10104
  }
10105
+ // Probe failed or timed out: like `/model`, the previous list is still the
10106
+ // best answer — a stale list beats an empty one.
10107
+ if (cached?.models?.length) {
10108
+ return {
10109
+ backend, scope: "global", current_model: cached.currentModel ?? null,
10110
+ models: cached.models, source: "cache",
10111
+ probed_at: new Date(cached.probedAt).toISOString(),
10112
+ };
10113
+ }
9959
10114
  // Reported rather than thrown: "we could not enumerate" is a useful answer,
9960
10115
  // and the caller can still set a model by name (AgEnD passes it through).
9961
10116
  return {
@@ -10161,14 +10316,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10161
10316
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
10162
10317
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(name);
10163
10318
  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
- }
10319
+ const choices = this.modelMenuChoices(name, nonce, options, currentModel);
10172
10320
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
10173
10321
  timer.unref?.();
10174
10322
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
@@ -10194,15 +10342,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10194
10342
  }
10195
10343
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
10196
10344
  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
- }
10345
+ const choices = this.modelMenuChoices(instanceName, nonce, options, currentModel);
10206
10346
  const respond = async (text) => {
10207
10347
  await adapter.sendText(chatId, text, { threadId });
10208
10348
  return undefined;
@@ -10286,6 +10426,64 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10286
10426
  await pending.respond(t("model.more_unavailable")).catch(() => { });
10287
10427
  }
10288
10428
  }
10429
+ /**
10430
+ * "🔄 Refresh models" (#886): probe live past both caches and redraw the menu.
10431
+ *
10432
+ * Past AgEnD's cli-env cache (refresh=true) and, where the backend supports
10433
+ * it, past the CLI's own catalog cache too (codex: `codex debug models`).
10434
+ * Without the second half, a codex refresh re-read the same models_cache.json
10435
+ * and showed the old list as new.
10436
+ *
10437
+ * A failed refresh keeps the previous list and says so. It never blanks the
10438
+ * menu, because a picker with no rows cannot even offer another refresh.
10439
+ */
10440
+ async refreshModelMenu(pending) {
10441
+ const { models, source } = await this.getModelOptionsWithSource(pending.instanceName, {
10442
+ refresh: true, refreshVendorCatalog: true,
10443
+ });
10444
+ if (models.length === 0) {
10445
+ await pending.respond(t("model.list_unavailable", pending.instanceName)).catch(() => { });
10446
+ return;
10447
+ }
10448
+ const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(pending.instanceName);
10449
+ const nonce = randomBytes(6).toString("hex");
10450
+ const choices = this.modelMenuChoices(pending.instanceName, nonce, models, currentModel);
10451
+ const status = source === "live" ? t("model.refreshed") : t("model.refresh_failed");
10452
+ const timer = setTimeout(() => {
10453
+ const p = this.pendingModelSelects.get(nonce);
10454
+ if (p) {
10455
+ this.pendingModelSelects.delete(nonce);
10456
+ p.respond(t("model.selection_expired")).catch(() => { });
10457
+ }
10458
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
10459
+ timer.unref?.();
10460
+ this.pendingModelSelects.set(nonce, { ...pending, model: "", timer });
10461
+ try {
10462
+ if (pending.respondChoices) {
10463
+ // Discord select menu: edit the same interaction reply in place.
10464
+ await pending.respondChoices(`${status}\n${t("model.menu", `**${currentDisplay}**`)}`, choices);
10465
+ return;
10466
+ }
10467
+ if (pending.adapter && pending.adapterChatId) {
10468
+ // Telegram: retire the consumed keyboard, then post the redrawn menu.
10469
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
10470
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, t("model.refresh"), pending.adapterThreadId).catch(() => { });
10471
+ }
10472
+ const menuMessageId = await pending.adapter.promptUser(pending.adapterChatId, `${status}\n${t("model.menu", currentDisplay)}`, choices, { threadId: pending.adapterThreadId });
10473
+ const fresh = this.pendingModelSelects.get(nonce);
10474
+ if (fresh)
10475
+ fresh.menuMessageId = menuMessageId;
10476
+ return;
10477
+ }
10478
+ await pending.respond(t("model.usage")).catch(() => { });
10479
+ }
10480
+ catch (err) {
10481
+ this.pendingModelSelects.delete(nonce);
10482
+ clearTimeout(timer);
10483
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Refreshed model menu failed");
10484
+ await pending.respond(t("model.usage")).catch(() => { });
10485
+ }
10486
+ }
10289
10487
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
10290
10488
  async handleModelSelection(data) {
10291
10489
  if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
@@ -10311,6 +10509,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10311
10509
  await this.expandClaudeModelMenu(pending);
10312
10510
  return true;
10313
10511
  }
10512
+ // "🔄 Refresh models" is navigation too: re-probe past every cache and
10513
+ // redraw the same menu with what came back.
10514
+ if (model === "__refresh__") {
10515
+ await this.refreshModelMenu(pending);
10516
+ return true;
10517
+ }
10314
10518
  // Send immediate "⏳ Switching..." feedback, then apply in background.
10315
10519
  const progressText = t("model.switching", pending.instanceName, model);
10316
10520
  let progressMsgId;
@@ -10848,6 +11052,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10848
11052
  clearInterval(this.discordPresenceTimer);
10849
11053
  this.discordPresenceTimer = null;
10850
11054
  }
11055
+ if (this.discordPresenceEagerTimer) {
11056
+ clearTimeout(this.discordPresenceEagerTimer);
11057
+ this.discordPresenceEagerTimer = null;
11058
+ }
11059
+ this.discordPresenceEagerPending = false;
10851
11060
  // Cancel-button timers were never cleared here. The idle-check interval is not
10852
11061
  // unref'd, so it held the event loop open past shutdown and kept retrying
10853
11062
  // deletes against an adapter that was already gone.