@songsid/agend 2.1.6-beta.9 → 2.1.7-beta.1
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 +35 -0
- package/dist/backend/codex.js +588 -36
- 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 +15 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +1 -0
- package/dist/channel/adapters/discord.js +25 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +13 -0
- package/dist/channel/adapters/telegram.js +44 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +4 -0
- package/dist/daemon.d.ts +98 -10
- package/dist/daemon.js +868 -119
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +53 -9
- package/dist/fleet-manager.js +253 -75
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +19 -1
- package/dist/instance-lifecycle.js +128 -9
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +16 -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/turn-reply-guard.d.ts +5 -1
- package/dist/turn-reply-guard.js +6 -1
- package/dist/turn-reply-guard.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.d.ts
CHANGED
|
@@ -292,6 +292,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
292
292
|
private eventLogPruneTimer;
|
|
293
293
|
private logRotateTimer;
|
|
294
294
|
private discordPresenceTimer;
|
|
295
|
+
private discordPresenceEagerTimer;
|
|
296
|
+
private discordPresenceEagerPending;
|
|
295
297
|
private discordPresenceInFlight;
|
|
296
298
|
private static readonly DISCORD_PRESENCE_REFRESH_MS;
|
|
297
299
|
/** Days of event/activity history to keep. */
|
|
@@ -681,6 +683,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
681
683
|
startAll(configPath: string): Promise<void>;
|
|
682
684
|
/** Keep Discord profile activity aligned with the same cached usage source as /usage. */
|
|
683
685
|
private startDiscordUsagePresence;
|
|
686
|
+
/**
|
|
687
|
+
* Request one prompt presence refresh after an instance/adapter comes online.
|
|
688
|
+
* Startup can bring several instances up together; a short coalescing window
|
|
689
|
+
* keeps that herd on the existing shared refresh path and one usage fetch.
|
|
690
|
+
*/
|
|
691
|
+
private requestDiscordUsagePresenceRefresh;
|
|
684
692
|
private refreshDiscordUsagePresence;
|
|
685
693
|
/**
|
|
686
694
|
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
@@ -918,6 +926,19 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
918
926
|
private handleSetDisplayName;
|
|
919
927
|
private handleSetDescription;
|
|
920
928
|
handleScheduleCrudHttp(instance: string, op: string, args: Record<string, unknown>): Promise<unknown>;
|
|
929
|
+
/**
|
|
930
|
+
* The only place an agent's request creates, changes or removes a schedule
|
|
931
|
+
* (#895). The IPC handler (MCP calls, direct channel.sock writes) and the
|
|
932
|
+
* agent endpoint (agent-cli, HTTP agent mode) are adapters over this, so the
|
|
933
|
+
* target check cannot be present on one face and missing on the other — the
|
|
934
|
+
* shape #804 had to close twice.
|
|
935
|
+
*
|
|
936
|
+
* `caller` is the instance the server resolved for the request; any
|
|
937
|
+
* `source` in `args` is ignored, and a schedule's source is always its caller.
|
|
938
|
+
* A refusal is a ToolNotPermittedError: 403 on the agent endpoint, the error
|
|
939
|
+
* of the schedule response on IPC.
|
|
940
|
+
*/
|
|
941
|
+
private performScheduleOp;
|
|
921
942
|
handleDecisionCrudHttp(instance: string, op: string, args: Record<string, unknown>): Promise<unknown>;
|
|
922
943
|
handleTaskCrudHttp(instance: string, args: Record<string, unknown>): Promise<unknown>;
|
|
923
944
|
handleSetDisplayNameHttp(instance: string, name: string): Promise<unknown>;
|
|
@@ -994,7 +1015,29 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
994
1015
|
removeInstance(name: string, authorization: ExplicitInstanceRemoval): Promise<void>;
|
|
995
1016
|
startStatuslineWatcher(name: string): void;
|
|
996
1017
|
stopStatuslineWatcher(name: string): void;
|
|
997
|
-
|
|
1018
|
+
/**
|
|
1019
|
+
* Last delivery status applied per bot+message. Unbounded by design: any cap
|
|
1020
|
+
* reintroduces the hole where a forgotten ❌ can never be cleared, and an
|
|
1021
|
+
* entry is two short strings (id pair plus one emoji) — thousands of
|
|
1022
|
+
* status-reacted messages cost kilobytes. A bot restart loses it, degrading
|
|
1023
|
+
* to a plain add (never worse than the old behaviour).
|
|
1024
|
+
*/
|
|
1025
|
+
private lastStatusEmoji;
|
|
1026
|
+
/**
|
|
1027
|
+
* One in-flight status update per bot+message: updates run strictly in call
|
|
1028
|
+
* order, so a delayed ❌ add can never land after a newer ✅'s removal —
|
|
1029
|
+
* the final state is always the latest call. Entries delete themselves when
|
|
1030
|
+
* the chain drains, so idle messages hold no memory.
|
|
1031
|
+
*/
|
|
1032
|
+
private deliveryStatusChains;
|
|
1033
|
+
reactMessageStatus(instanceName: string, chatId: string, messageId: string, emoji: string, threadId?: string): void;
|
|
1034
|
+
/**
|
|
1035
|
+
* Apply a terminal delivery verdict. Telegram maps ✅ to 👀 and ❌ to 👎 in
|
|
1036
|
+
* reactMessageStatus; Discord continues to show the verdict emoji directly.
|
|
1037
|
+
*/
|
|
1038
|
+
finishDeliveryStatus(instanceName: string, chatId: string, messageId: string, emoji: string, threadId?: string): void;
|
|
1039
|
+
private queueDeliveryStatusReaction;
|
|
1040
|
+
private applyDeliveryStatusReaction;
|
|
998
1041
|
private static FAILOVER_TRIGGER_PCT;
|
|
999
1042
|
private static FAILOVER_RECOVER_PCT;
|
|
1000
1043
|
checkModelFailover(name: string, fiveHourPct: number): void;
|
|
@@ -1335,13 +1378,6 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
1335
1378
|
* Used by report_result, where the sender's self-derived name may not match
|
|
1336
1379
|
* the target-address name the button was registered under. */
|
|
1337
1380
|
clearCancelButtonByCorrelation(correlationId: string): void;
|
|
1338
|
-
/**
|
|
1339
|
-
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
1340
|
-
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
1341
|
-
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
1342
|
-
* the channel/thread id.
|
|
1343
|
-
*/
|
|
1344
|
-
private reactTarget;
|
|
1345
1381
|
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
1346
1382
|
private trackInboundMsg;
|
|
1347
1383
|
/** Clear the tracked last-inbound message after the agent replies. The ✅
|
|
@@ -1655,7 +1691,15 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
1655
1691
|
private customProviderFor;
|
|
1656
1692
|
/** Ask a backend for its catalog using ONE instance's real config. Never throws. */
|
|
1657
1693
|
private instanceScopedModels;
|
|
1658
|
-
/**
|
|
1694
|
+
/**
|
|
1695
|
+
* Account-wide catalog: probe cache first, live probe when the cache is
|
|
1696
|
+
* missing or older than the same ~1h window `/model` uses (#888).
|
|
1697
|
+
*
|
|
1698
|
+
* Previously this served anything inside the 24h hard TTL, so `list_models`
|
|
1699
|
+
* could answer with a day-old list `/model` had already re-probed past.
|
|
1700
|
+
* The probe runs under the same deadline `/model` uses: a vendor that stops
|
|
1701
|
+
* answering degrades to the previous list, never stalls the tool call.
|
|
1702
|
+
*/
|
|
1659
1703
|
private globalModelCatalog;
|
|
1660
1704
|
/** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
|
|
1661
1705
|
/** Label an effort choice, marking the one currently configured. */
|
package/dist/fleet-manager.js
CHANGED
|
@@ -16,6 +16,17 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
16
16
|
const __dirname = dirname(__filename);
|
|
17
17
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
18
18
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
19
|
+
/**
|
|
20
|
+
* Telegram bot reactions replace a single slot per message. Keep delivery state
|
|
21
|
+
* presentation here so supported emoji choices can change without altering the
|
|
22
|
+
* status reconciliation logic.
|
|
23
|
+
*/
|
|
24
|
+
const TELEGRAM_DELIVERY_STATUS_REACTIONS = new Map([
|
|
25
|
+
["👀", "👀"], // queued / processing
|
|
26
|
+
["⏳", "👀"],
|
|
27
|
+
["✅", "👀"], // confirmed
|
|
28
|
+
["❌", "👎"], // failed
|
|
29
|
+
]);
|
|
19
30
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
20
31
|
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
21
32
|
import { EventLog } from "./event-log.js";
|
|
@@ -25,6 +36,7 @@ import { TmuxManager } from "./tmux-manager.js";
|
|
|
25
36
|
import { AccessManager } from "./channel/access-manager.js";
|
|
26
37
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
27
38
|
import { createAdapter } from "./channel/factory.js";
|
|
39
|
+
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
28
40
|
import { createBackend } from "./backend/factory.js";
|
|
29
41
|
import { isModelCompatible } from "./backend/types.js";
|
|
30
42
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
@@ -52,7 +64,7 @@ import { runBeforeDeadline } from "./deadline.js";
|
|
|
52
64
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
53
65
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
54
66
|
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
55
|
-
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
67
|
+
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
56
68
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
57
69
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
58
70
|
import { validateFleetConfig } from "./config-validator.js";
|
|
@@ -63,7 +75,7 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
|
63
75
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
64
76
|
import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
|
|
65
77
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
66
|
-
import { mayUseTool, resolveToolSet, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
78
|
+
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
67
79
|
import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
68
80
|
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
69
81
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
@@ -488,6 +500,8 @@ export class FleetManager {
|
|
|
488
500
|
eventLogPruneTimer = null;
|
|
489
501
|
logRotateTimer = null;
|
|
490
502
|
discordPresenceTimer = null;
|
|
503
|
+
discordPresenceEagerTimer = null;
|
|
504
|
+
discordPresenceEagerPending = false;
|
|
491
505
|
discordPresenceInFlight = null;
|
|
492
506
|
static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
|
|
493
507
|
/** Days of event/activity history to keep. */
|
|
@@ -1839,6 +1853,7 @@ export class FleetManager {
|
|
|
1839
1853
|
catch { /* consumed or absent */ }
|
|
1840
1854
|
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
1841
1855
|
await this.connectIpcToInstance(name);
|
|
1856
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
1842
1857
|
}
|
|
1843
1858
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
1844
1859
|
async startPersistedPausedInstance(name) {
|
|
@@ -3058,12 +3073,37 @@ export class FleetManager {
|
|
|
3058
3073
|
startDiscordUsagePresence() {
|
|
3059
3074
|
if (this.discordPresenceTimer)
|
|
3060
3075
|
clearInterval(this.discordPresenceTimer);
|
|
3076
|
+
if (this.discordPresenceEagerTimer) {
|
|
3077
|
+
clearTimeout(this.discordPresenceEagerTimer);
|
|
3078
|
+
this.discordPresenceEagerTimer = null;
|
|
3079
|
+
}
|
|
3080
|
+
this.discordPresenceEagerPending = false;
|
|
3061
3081
|
void this.refreshDiscordUsagePresence();
|
|
3062
3082
|
this.discordPresenceTimer = setInterval(() => {
|
|
3063
3083
|
void this.refreshDiscordUsagePresence();
|
|
3064
3084
|
}, FleetManager.DISCORD_PRESENCE_REFRESH_MS);
|
|
3065
3085
|
this.discordPresenceTimer.unref?.();
|
|
3066
3086
|
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Request one prompt presence refresh after an instance/adapter comes online.
|
|
3089
|
+
* Startup can bring several instances up together; a short coalescing window
|
|
3090
|
+
* keeps that herd on the existing shared refresh path and one usage fetch.
|
|
3091
|
+
*/
|
|
3092
|
+
requestDiscordUsagePresenceRefresh() {
|
|
3093
|
+
this.discordPresenceEagerPending = true;
|
|
3094
|
+
// During early fleet startup the interval is not installed yet; its first
|
|
3095
|
+
// refresh in startDiscordUsagePresence already covers all pending starts.
|
|
3096
|
+
if (!this.discordPresenceTimer || this.discordPresenceEagerTimer)
|
|
3097
|
+
return;
|
|
3098
|
+
this.discordPresenceEagerTimer = setTimeout(() => {
|
|
3099
|
+
this.discordPresenceEagerTimer = null;
|
|
3100
|
+
if (!this.discordPresenceEagerPending || !this.discordPresenceTimer)
|
|
3101
|
+
return;
|
|
3102
|
+
this.discordPresenceEagerPending = false;
|
|
3103
|
+
void this.refreshDiscordUsagePresence();
|
|
3104
|
+
}, 50);
|
|
3105
|
+
this.discordPresenceEagerTimer.unref?.();
|
|
3106
|
+
}
|
|
3067
3107
|
refreshDiscordUsagePresence() {
|
|
3068
3108
|
if (this.discordPresenceInFlight)
|
|
3069
3109
|
return this.discordPresenceInFlight;
|
|
@@ -3706,7 +3746,7 @@ export class FleetManager {
|
|
|
3706
3746
|
this.adapter.setChatId(String(fleet.channel.group_id));
|
|
3707
3747
|
}
|
|
3708
3748
|
if (this.discordPresenceTimer && this.adapter.type === "discord") {
|
|
3709
|
-
|
|
3749
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
3710
3750
|
}
|
|
3711
3751
|
this.startTopicCleanupPoller();
|
|
3712
3752
|
// Prune stale external sessions every 5 minutes
|
|
@@ -4051,7 +4091,7 @@ export class FleetManager {
|
|
|
4051
4091
|
adapter.setChatId(String(channelConfig.group_id));
|
|
4052
4092
|
}
|
|
4053
4093
|
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4054
|
-
|
|
4094
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
4055
4095
|
}
|
|
4056
4096
|
this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
|
|
4057
4097
|
}
|
|
@@ -4296,7 +4336,7 @@ export class FleetManager {
|
|
|
4296
4336
|
await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
|
|
4297
4337
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
4298
4338
|
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4299
|
-
|
|
4339
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
4300
4340
|
}
|
|
4301
4341
|
this.logger.info({ id }, "Adapter gateway rebuilt successfully");
|
|
4302
4342
|
}
|
|
@@ -4324,7 +4364,7 @@ export class FleetManager {
|
|
|
4324
4364
|
this.logger.info({ id, attempt }, "Adapter restarted successfully");
|
|
4325
4365
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
4326
4366
|
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4327
|
-
|
|
4367
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
4328
4368
|
}
|
|
4329
4369
|
return;
|
|
4330
4370
|
}
|
|
@@ -4922,13 +4962,11 @@ export class FleetManager {
|
|
|
4922
4962
|
if (msg.adapterId)
|
|
4923
4963
|
this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
|
|
4924
4964
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
4925
|
-
// React immediately — before any other API calls
|
|
4926
|
-
//
|
|
4927
|
-
//
|
|
4965
|
+
// React immediately — before any other API calls — through the status
|
|
4966
|
+
// path, so a later delivery status replaces (not stacks onto) this
|
|
4967
|
+
// acknowledgement.
|
|
4928
4968
|
if (msg.chatId && msg.messageId) {
|
|
4929
|
-
|
|
4930
|
-
reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
|
|
4931
|
-
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
4969
|
+
this.reactMessageStatus(generalInstance, msg.chatId, msg.messageId, "👀", msg.threadId || undefined);
|
|
4932
4970
|
}
|
|
4933
4971
|
this.warnIfRateLimited(generalInstance, msg);
|
|
4934
4972
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
@@ -5016,13 +5054,11 @@ export class FleetManager {
|
|
|
5016
5054
|
if (msg.adapterId)
|
|
5017
5055
|
this.bindInstanceAdapter(instanceName, msg.adapterId, true);
|
|
5018
5056
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
5019
|
-
// React immediately — before any other Discord API calls
|
|
5020
|
-
//
|
|
5021
|
-
//
|
|
5057
|
+
// React immediately — before any other Discord API calls — through the
|
|
5058
|
+
// status path, so a later delivery status replaces (not stacks onto) this
|
|
5059
|
+
// acknowledgement. Same bound-adapter routing as the status path itself.
|
|
5022
5060
|
if (msg.chatId && msg.messageId) {
|
|
5023
|
-
|
|
5024
|
-
reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
5025
|
-
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
5061
|
+
this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "👀", msg.threadId || undefined);
|
|
5026
5062
|
}
|
|
5027
5063
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
5028
5064
|
if (this.topicArchiver.isArchived(threadId)) {
|
|
@@ -5061,8 +5097,10 @@ export class FleetManager {
|
|
|
5061
5097
|
catch (err) {
|
|
5062
5098
|
this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
|
|
5063
5099
|
if (msg.chatId && msg.messageId) {
|
|
5064
|
-
|
|
5065
|
-
|
|
5100
|
+
// Reconciled status path (not a bare add): a retry that later
|
|
5101
|
+
// succeeds replaces this ❌ with ✅ instead of leaving both. Chat and
|
|
5102
|
+
// thread travel separately so Telegram addresses the supergroup.
|
|
5103
|
+
this.finishDeliveryStatus(instanceName, msg.chatId, msg.messageId, "❌", msg.threadId || undefined);
|
|
5066
5104
|
}
|
|
5067
5105
|
return;
|
|
5068
5106
|
}
|
|
@@ -5550,36 +5588,18 @@ export class FleetManager {
|
|
|
5550
5588
|
return;
|
|
5551
5589
|
}
|
|
5552
5590
|
try {
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
label: payload.label,
|
|
5566
|
-
timezone: payload.timezone,
|
|
5567
|
-
silent: !!(payload.silent),
|
|
5568
|
-
};
|
|
5569
|
-
result = this.scheduler.create(params);
|
|
5570
|
-
break;
|
|
5571
|
-
}
|
|
5572
|
-
case "fleet_schedule_list":
|
|
5573
|
-
result = this.scheduler.list(payload.target);
|
|
5574
|
-
break;
|
|
5575
|
-
case "fleet_schedule_update":
|
|
5576
|
-
result = this.scheduler.update(payload.id, payload);
|
|
5577
|
-
break;
|
|
5578
|
-
case "fleet_schedule_delete":
|
|
5579
|
-
this.scheduler.delete(payload.id);
|
|
5580
|
-
result = "ok";
|
|
5581
|
-
break;
|
|
5582
|
-
}
|
|
5591
|
+
const op = String(msg.type).replace("fleet_schedule_", "");
|
|
5592
|
+
const result = this.performScheduleOp(instanceName, op, payload, {
|
|
5593
|
+
// The daemon sends its last chat id, which is unset until the instance
|
|
5594
|
+
// has had a chat message — and cross-instance traffic never sets it. So
|
|
5595
|
+
// a worker that only takes delegated tasks, the very instance #895 lets
|
|
5596
|
+
// self-schedule, hit "NOT NULL constraint failed: schedules.reply_chat_id".
|
|
5597
|
+
// No chat means no reply chat, exactly as on the agent endpoint.
|
|
5598
|
+
chatId: meta.chat_id ?? "",
|
|
5599
|
+
threadId: meta.thread_id || null,
|
|
5600
|
+
adapterId: meta.adapter_id || null,
|
|
5601
|
+
silent: !!(payload.silent),
|
|
5602
|
+
});
|
|
5583
5603
|
ipc.send({ type: "fleet_schedule_response", fleetRequestId, result });
|
|
5584
5604
|
}
|
|
5585
5605
|
catch (err) {
|
|
@@ -5715,23 +5735,76 @@ export class FleetManager {
|
|
|
5715
5735
|
async handleScheduleCrudHttp(instance, op, args) {
|
|
5716
5736
|
if (!this.scheduler)
|
|
5717
5737
|
return { error: "Scheduler not available" };
|
|
5738
|
+
if (op !== "create" && op !== "list" && op !== "update" && op !== "delete") {
|
|
5739
|
+
return { error: `Unknown schedule op: ${op}` };
|
|
5740
|
+
}
|
|
5741
|
+
// No bound chat on this path, as before: a schedule made through the agent
|
|
5742
|
+
// endpoint has nowhere of its own to reply.
|
|
5743
|
+
return this.performScheduleOp(instance, op, args, { chatId: "", threadId: null });
|
|
5744
|
+
}
|
|
5745
|
+
/**
|
|
5746
|
+
* The only place an agent's request creates, changes or removes a schedule
|
|
5747
|
+
* (#895). The IPC handler (MCP calls, direct channel.sock writes) and the
|
|
5748
|
+
* agent endpoint (agent-cli, HTTP agent mode) are adapters over this, so the
|
|
5749
|
+
* target check cannot be present on one face and missing on the other — the
|
|
5750
|
+
* shape #804 had to close twice.
|
|
5751
|
+
*
|
|
5752
|
+
* `caller` is the instance the server resolved for the request; any
|
|
5753
|
+
* `source` in `args` is ignored, and a schedule's source is always its caller.
|
|
5754
|
+
* A refusal is a ToolNotPermittedError: 403 on the agent endpoint, the error
|
|
5755
|
+
* of the schedule response on IPC.
|
|
5756
|
+
*/
|
|
5757
|
+
performScheduleOp(caller, op, args, reply) {
|
|
5758
|
+
const scheduler = this.scheduler;
|
|
5759
|
+
// Shape first, so the value the permission decision reads is the value the
|
|
5760
|
+
// scheduler gets. `target` used to be filtered through typeof for the
|
|
5761
|
+
// decision and passed raw to the scheduler, whose `target.startsWith`
|
|
5762
|
+
// then threw a TypeError for `target: 123` (#897). A non-string is refused
|
|
5763
|
+
// — never coerced: turning 123 into "123" would decide an identity
|
|
5764
|
+
// question on a value the caller did not send. `null` means absent, as
|
|
5765
|
+
// omitting it always did, and is removed before the scheduler sees it.
|
|
5766
|
+
if (args.target === null) {
|
|
5767
|
+
args = { ...args };
|
|
5768
|
+
delete args.target;
|
|
5769
|
+
}
|
|
5770
|
+
if (args.target !== undefined && typeof args.target !== "string") {
|
|
5771
|
+
throw new Error(`${op}_schedule: "target" must be an instance name (a string), not ${typeof args.target}.`);
|
|
5772
|
+
}
|
|
5773
|
+
if ((op === "update" || op === "delete") && typeof args.id !== "string") {
|
|
5774
|
+
throw new Error(`${op}_schedule: "id" must be a schedule id (a string) — get one from list_schedules.`);
|
|
5775
|
+
}
|
|
5776
|
+
const requestedTarget = args.target;
|
|
5777
|
+
if (op !== "list") {
|
|
5778
|
+
const profile = resolveToolSet(this.fleetConfig?.instances[caller], caller);
|
|
5779
|
+
const existing = op === "create" ? null : scheduler.get(args.id);
|
|
5780
|
+
const refusal = scheduleOpRefusal(profile, caller, op, { requestedTarget, existing });
|
|
5781
|
+
if (refusal) {
|
|
5782
|
+
this.logger.warn({ instance: caller, profile, op, target: requestedTarget ?? existing?.target, scheduleId: args.id }, "tool-permissions: schedule op refused");
|
|
5783
|
+
throw new ToolNotPermittedError(refusal);
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5718
5786
|
switch (op) {
|
|
5719
5787
|
case "create":
|
|
5720
|
-
return
|
|
5788
|
+
return scheduler.create({
|
|
5721
5789
|
cron: args.cron,
|
|
5722
5790
|
at: args.at,
|
|
5723
5791
|
message: args.message,
|
|
5724
|
-
source:
|
|
5725
|
-
|
|
5792
|
+
source: caller,
|
|
5793
|
+
target: requestedTarget || caller,
|
|
5794
|
+
reply_chat_id: reply.chatId,
|
|
5795
|
+
reply_thread_id: reply.threadId,
|
|
5796
|
+
...(reply.adapterId !== undefined ? { reply_adapter_id: reply.adapterId } : {}),
|
|
5726
5797
|
label: args.label,
|
|
5727
5798
|
timezone: args.timezone,
|
|
5799
|
+
...(reply.silent !== undefined ? { silent: reply.silent } : {}),
|
|
5728
5800
|
});
|
|
5729
|
-
case "list":
|
|
5730
|
-
|
|
5801
|
+
case "list":
|
|
5802
|
+
return scheduler.list(requestedTarget);
|
|
5803
|
+
case "update":
|
|
5804
|
+
return scheduler.update(args.id, args);
|
|
5731
5805
|
case "delete":
|
|
5732
|
-
|
|
5806
|
+
scheduler.delete(args.id);
|
|
5733
5807
|
return "ok";
|
|
5734
|
-
default: return { error: `Unknown schedule op: ${op}` };
|
|
5735
5808
|
}
|
|
5736
5809
|
}
|
|
5737
5810
|
async handleDecisionCrudHttp(instance, op, args) {
|
|
@@ -6396,6 +6469,9 @@ export class FleetManager {
|
|
|
6396
6469
|
}
|
|
6397
6470
|
}
|
|
6398
6471
|
}
|
|
6472
|
+
// Capture instance dir BEFORE lifecycle.remove, which deletes the instance
|
|
6473
|
+
// from fleetConfig. (The backend would be unresolvable afterwards.)
|
|
6474
|
+
const instanceDirToClean = this.getInstanceDir(name);
|
|
6399
6475
|
await this.lifecycle.remove(name, authorization);
|
|
6400
6476
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
6401
6477
|
// as instances are created and deleted over the lifetime of the fleet.
|
|
@@ -6404,12 +6480,29 @@ export class FleetManager {
|
|
|
6404
6480
|
this.rateLimitWarnedAt.delete(name);
|
|
6405
6481
|
// Clean up statusline watcher + instance directory
|
|
6406
6482
|
this.statuslineWatcher.unwatch(name);
|
|
6483
|
+
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
6484
|
+
// (which deletes the instance from fleetConfig).
|
|
6407
6485
|
try {
|
|
6408
|
-
rmSync(
|
|
6486
|
+
rmSync(instanceDirToClean, { recursive: true, force: true });
|
|
6409
6487
|
}
|
|
6410
6488
|
catch (err) {
|
|
6411
6489
|
this.logger.debug({ err, name }, "Instance dir cleanup failed");
|
|
6412
6490
|
}
|
|
6491
|
+
// Remove the codex short home (~/.agend/cx/<hash>/) unconditionally.
|
|
6492
|
+
// (#953). CodexBackend.shortHomeFor is a pure path computation; the existsSync
|
|
6493
|
+
// guard makes this a no-op for instances that never used a short home (non-codex,
|
|
6494
|
+
// or codex instances on a short-enough path). Unconditional also covers instances
|
|
6495
|
+
// whose backend was switched away from codex before deletion — they still have a
|
|
6496
|
+
// short home that would otherwise be inherited by a later same-name instance.
|
|
6497
|
+
try {
|
|
6498
|
+
const { CodexBackend } = await import("./backend/codex.js");
|
|
6499
|
+
const shortHome = CodexBackend.shortHomeFor(instanceDirToClean);
|
|
6500
|
+
if (existsSync(shortHome))
|
|
6501
|
+
rmSync(shortHome, { recursive: true, force: true });
|
|
6502
|
+
}
|
|
6503
|
+
catch (err) {
|
|
6504
|
+
this.logger.debug({ err, name }, "Codex short home cleanup failed");
|
|
6505
|
+
}
|
|
6413
6506
|
}
|
|
6414
6507
|
startStatuslineWatcher(name) {
|
|
6415
6508
|
if (this.lifecycle.isPaused(name))
|
|
@@ -6420,17 +6513,89 @@ export class FleetManager {
|
|
|
6420
6513
|
// Pausing stops I/O but retains the last observed limits for status views.
|
|
6421
6514
|
this.statuslineWatcher.unwatch(name, true);
|
|
6422
6515
|
}
|
|
6423
|
-
|
|
6516
|
+
/**
|
|
6517
|
+
* Last delivery status applied per bot+message. Unbounded by design: any cap
|
|
6518
|
+
* reintroduces the hole where a forgotten ❌ can never be cleared, and an
|
|
6519
|
+
* entry is two short strings (id pair plus one emoji) — thousands of
|
|
6520
|
+
* status-reacted messages cost kilobytes. A bot restart loses it, degrading
|
|
6521
|
+
* to a plain add (never worse than the old behaviour).
|
|
6522
|
+
*/
|
|
6523
|
+
lastStatusEmoji = new Map();
|
|
6524
|
+
/**
|
|
6525
|
+
* One in-flight status update per bot+message: updates run strictly in call
|
|
6526
|
+
* order, so a delayed ❌ add can never land after a newer ✅'s removal —
|
|
6527
|
+
* the final state is always the latest call. Entries delete themselves when
|
|
6528
|
+
* the chain drains, so idle messages hold no memory.
|
|
6529
|
+
*/
|
|
6530
|
+
deliveryStatusChains = new Map();
|
|
6531
|
+
reactMessageStatus(instanceName, chatId, messageId, emoji, threadId) {
|
|
6424
6532
|
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
6425
6533
|
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
6426
6534
|
// (bound bot) and the delivery/confirm reactions (some other bot) come from
|
|
6427
6535
|
// different bots, leaving a duplicate 👀 that never turns into ✅.
|
|
6428
6536
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
6429
|
-
|
|
6430
|
-
if (!adapter || adapter.type !== "discord")
|
|
6537
|
+
if (!adapter)
|
|
6431
6538
|
return;
|
|
6432
|
-
adapter
|
|
6433
|
-
|
|
6539
|
+
const statusEmoji = adapter instanceof TelegramAdapter
|
|
6540
|
+
? TELEGRAM_DELIVERY_STATUS_REACTIONS.get(emoji)
|
|
6541
|
+
: emoji;
|
|
6542
|
+
// Telegram has a deliberately smaller status vocabulary, and every mapped
|
|
6543
|
+
// value is accepted by ReactionTypeEmoji. Unknown statuses stay a no-op.
|
|
6544
|
+
if (!statusEmoji)
|
|
6545
|
+
return;
|
|
6546
|
+
// Bot-scoped: sibling bots reacting on the same message must not clear
|
|
6547
|
+
// each other's state — every removal below targets this adapter's own
|
|
6548
|
+
// reactions (@me on Discord, the bot's list on Telegram).
|
|
6549
|
+
const adapterId = typeof adapter.id === "string"
|
|
6550
|
+
? adapter.id : "?";
|
|
6551
|
+
const key = `${adapterId}:${chatId}:${threadId ?? ""}:${messageId}`;
|
|
6552
|
+
this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId);
|
|
6553
|
+
}
|
|
6554
|
+
/**
|
|
6555
|
+
* Apply a terminal delivery verdict. Telegram maps ✅ to 👀 and ❌ to 👎 in
|
|
6556
|
+
* reactMessageStatus; Discord continues to show the verdict emoji directly.
|
|
6557
|
+
*/
|
|
6558
|
+
finishDeliveryStatus(instanceName, chatId, messageId, emoji, threadId) {
|
|
6559
|
+
this.reactMessageStatus(instanceName, chatId, messageId, emoji, threadId);
|
|
6560
|
+
}
|
|
6561
|
+
queueDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
|
|
6562
|
+
const prev = this.deliveryStatusChains.get(key) ?? Promise.resolve();
|
|
6563
|
+
const run = prev.then(() => this.applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId));
|
|
6564
|
+
this.deliveryStatusChains.set(key, run);
|
|
6565
|
+
void run.then(() => { if (this.deliveryStatusChains.get(key) === run)
|
|
6566
|
+
this.deliveryStatusChains.delete(key); }, () => { if (this.deliveryStatusChains.get(key) === run)
|
|
6567
|
+
this.deliveryStatusChains.delete(key); });
|
|
6568
|
+
}
|
|
6569
|
+
async applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
|
|
6570
|
+
try {
|
|
6571
|
+
const target = adapter instanceof TelegramAdapter ? chatId : (threadId ?? chatId);
|
|
6572
|
+
const prev = this.lastStatusEmoji.get(key);
|
|
6573
|
+
if (emoji == null) {
|
|
6574
|
+
if (prev && adapter.unreact) {
|
|
6575
|
+
await adapter.unreact(target, messageId, prev, threadId);
|
|
6576
|
+
this.lastStatusEmoji.delete(key);
|
|
6577
|
+
}
|
|
6578
|
+
return;
|
|
6579
|
+
}
|
|
6580
|
+
// A Telegram status may map multiple lifecycle states to the same
|
|
6581
|
+
// reaction. Avoid resending it: an agent may have replaced the one
|
|
6582
|
+
// reaction slot with its own 👍 between delivered and confirmed.
|
|
6583
|
+
if (prev === emoji)
|
|
6584
|
+
return;
|
|
6585
|
+
// Thread-aware adapters (Discord) react where the thread is; Telegram
|
|
6586
|
+
// addresses the supergroup chat and ignores the thread part.
|
|
6587
|
+
// Discord needs the old reaction removed before the new one is added.
|
|
6588
|
+
// Telegram's setMessageReaction replaces the bot's one current reaction.
|
|
6589
|
+
if (prev && prev !== emoji && adapter.unreact && !(adapter instanceof TelegramAdapter)) {
|
|
6590
|
+
// Best effort: a failed removal must not block the new status.
|
|
6591
|
+
await adapter.unreact(target, messageId, prev, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
|
|
6592
|
+
}
|
|
6593
|
+
await adapter.react(target, messageId, emoji, threadId);
|
|
6594
|
+
this.lastStatusEmoji.set(key, emoji);
|
|
6595
|
+
}
|
|
6596
|
+
catch (e) {
|
|
6597
|
+
this.logger.debug({ err: e.message }, "Message status react failed");
|
|
6598
|
+
}
|
|
6434
6599
|
}
|
|
6435
6600
|
// ── Model failover ──────────────────────────────────────────────────────
|
|
6436
6601
|
static FAILOVER_TRIGGER_PCT = 90;
|
|
@@ -8103,15 +8268,6 @@ export class FleetManager {
|
|
|
8103
8268
|
this.retireButton(e);
|
|
8104
8269
|
}
|
|
8105
8270
|
}
|
|
8106
|
-
/**
|
|
8107
|
-
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
8108
|
-
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
8109
|
-
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
8110
|
-
* the channel/thread id.
|
|
8111
|
-
*/
|
|
8112
|
-
reactTarget(msg) {
|
|
8113
|
-
return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
|
|
8114
|
-
}
|
|
8115
8271
|
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
8116
8272
|
trackInboundMsg(instanceName, msg) {
|
|
8117
8273
|
if (!msg.chatId || !msg.messageId)
|
|
@@ -9993,17 +10149,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
9993
10149
|
return [];
|
|
9994
10150
|
}
|
|
9995
10151
|
}
|
|
9996
|
-
/**
|
|
10152
|
+
/**
|
|
10153
|
+
* Account-wide catalog: probe cache first, live probe when the cache is
|
|
10154
|
+
* missing or older than the same ~1h window `/model` uses (#888).
|
|
10155
|
+
*
|
|
10156
|
+
* Previously this served anything inside the 24h hard TTL, so `list_models`
|
|
10157
|
+
* could answer with a day-old list `/model` had already re-probed past.
|
|
10158
|
+
* The probe runs under the same deadline `/model` uses: a vendor that stops
|
|
10159
|
+
* answering degrades to the previous list, never stalls the tool call.
|
|
10160
|
+
*/
|
|
9997
10161
|
async globalModelCatalog(backend) {
|
|
9998
10162
|
const cached = this.readCliEnv(backend);
|
|
9999
|
-
if (cached?.models?.length) {
|
|
10163
|
+
if (cached?.models?.length && !this.cliEnvNeedsRefresh(cached)) {
|
|
10000
10164
|
return {
|
|
10001
10165
|
backend, scope: "global", current_model: cached.currentModel ?? null,
|
|
10002
10166
|
models: cached.models, source: "cache",
|
|
10003
10167
|
probed_at: new Date(cached.probedAt).toISOString(),
|
|
10004
10168
|
};
|
|
10005
10169
|
}
|
|
10006
|
-
const env = await this.
|
|
10170
|
+
const env = await this.probeBackendBounded(backend);
|
|
10007
10171
|
if (env?.models?.length) {
|
|
10008
10172
|
return {
|
|
10009
10173
|
backend, scope: "global", current_model: env.currentModel ?? null,
|
|
@@ -10011,6 +10175,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10011
10175
|
probed_at: new Date(env.probedAt).toISOString(),
|
|
10012
10176
|
};
|
|
10013
10177
|
}
|
|
10178
|
+
// Probe failed or timed out: like `/model`, the previous list is still the
|
|
10179
|
+
// best answer — a stale list beats an empty one.
|
|
10180
|
+
if (cached?.models?.length) {
|
|
10181
|
+
return {
|
|
10182
|
+
backend, scope: "global", current_model: cached.currentModel ?? null,
|
|
10183
|
+
models: cached.models, source: "cache",
|
|
10184
|
+
probed_at: new Date(cached.probedAt).toISOString(),
|
|
10185
|
+
};
|
|
10186
|
+
}
|
|
10014
10187
|
// Reported rather than thrown: "we could not enumerate" is a useful answer,
|
|
10015
10188
|
// and the caller can still set a model by name (AgEnD passes it through).
|
|
10016
10189
|
return {
|
|
@@ -10952,6 +11125,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10952
11125
|
clearInterval(this.discordPresenceTimer);
|
|
10953
11126
|
this.discordPresenceTimer = null;
|
|
10954
11127
|
}
|
|
11128
|
+
if (this.discordPresenceEagerTimer) {
|
|
11129
|
+
clearTimeout(this.discordPresenceEagerTimer);
|
|
11130
|
+
this.discordPresenceEagerTimer = null;
|
|
11131
|
+
}
|
|
11132
|
+
this.discordPresenceEagerPending = false;
|
|
10955
11133
|
// Cancel-button timers were never cleared here. The idle-check interval is not
|
|
10956
11134
|
// unref'd, so it held the event loop open past shutdown and kept retrying
|
|
10957
11135
|
// deletes against an adapter that was already gone.
|