@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.
- package/dist/backend/codex.d.ts +52 -0
- package/dist/backend/codex.js +618 -35
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -0
- package/dist/backend/kiro.js +129 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +37 -4
- package/dist/backend/muse.js +150 -61
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +25 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +98 -10
- package/dist/daemon.js +836 -94
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +68 -2
- package/dist/fleet-manager.js +290 -81
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +17 -0
- package/dist/instance-lifecycle.js +118 -5
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +22 -0
- package/dist/locale.js.map +1 -1
- package/dist/muse-usage-relay.d.ts +72 -0
- package/dist/muse-usage-relay.js +431 -0
- package/dist/muse-usage-relay.js.map +1 -0
- package/dist/tmux-manager.d.ts +2 -0
- package/dist/tmux-manager.js +20 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +27 -0
- package/dist/tool-permissions.js +64 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +36 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/view.html +2 -2
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +4 -0
- package/dist/usage/providers.js +86 -9
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +0 -11
- package/dist/usage/usage-api.js +50 -8
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
-
|
|
3064
|
-
|
|
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.
|
|
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
|
-
|
|
3081
|
-
if (this.discordPresenceInFlight ===
|
|
3125
|
+
const done = run.finally(() => {
|
|
3126
|
+
if (this.discordPresenceInFlight === done)
|
|
3082
3127
|
this.discordPresenceInFlight = null;
|
|
3083
3128
|
});
|
|
3084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
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
|
|
5778
|
+
return scheduler.create({
|
|
5703
5779
|
cron: args.cron,
|
|
5704
5780
|
at: args.at,
|
|
5705
5781
|
message: args.message,
|
|
5706
|
-
source:
|
|
5707
|
-
|
|
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":
|
|
5712
|
-
|
|
5791
|
+
case "list":
|
|
5792
|
+
return scheduler.list(requestedTarget);
|
|
5793
|
+
case "update":
|
|
5794
|
+
return scheduler.update(args.id, args);
|
|
5713
5795
|
case "delete":
|
|
5714
|
-
|
|
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(
|
|
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
|
-
/**
|
|
9795
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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
|
|
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.
|