@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.
Files changed (55) hide show
  1. package/dist/backend/codex.d.ts +35 -0
  2. package/dist/backend/codex.js +588 -36
  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 +15 -1
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/channel/adapters/discord.d.ts +1 -0
  13. package/dist/channel/adapters/discord.js +25 -0
  14. package/dist/channel/adapters/discord.js.map +1 -1
  15. package/dist/channel/adapters/telegram.d.ts +13 -0
  16. package/dist/channel/adapters/telegram.js +44 -3
  17. package/dist/channel/adapters/telegram.js.map +1 -1
  18. package/dist/channel/types.d.ts +4 -0
  19. package/dist/daemon.d.ts +98 -10
  20. package/dist/daemon.js +868 -119
  21. package/dist/daemon.js.map +1 -1
  22. package/dist/fleet-manager.d.ts +53 -9
  23. package/dist/fleet-manager.js +253 -75
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/instance-lifecycle.d.ts +19 -1
  26. package/dist/instance-lifecycle.js +128 -9
  27. package/dist/instance-lifecycle.js.map +1 -1
  28. package/dist/locale.js +16 -0
  29. package/dist/locale.js.map +1 -1
  30. package/dist/muse-usage-relay.d.ts +72 -0
  31. package/dist/muse-usage-relay.js +431 -0
  32. package/dist/muse-usage-relay.js.map +1 -0
  33. package/dist/tmux-manager.d.ts +2 -0
  34. package/dist/tmux-manager.js +20 -0
  35. package/dist/tmux-manager.js.map +1 -1
  36. package/dist/tool-permissions.d.ts +27 -0
  37. package/dist/tool-permissions.js +64 -3
  38. package/dist/tool-permissions.js.map +1 -1
  39. package/dist/topic-commands.d.ts +9 -0
  40. package/dist/topic-commands.js +36 -2
  41. package/dist/topic-commands.js.map +1 -1
  42. package/dist/turn-reply-guard.d.ts +5 -1
  43. package/dist/turn-reply-guard.js +6 -1
  44. package/dist/turn-reply-guard.js.map +1 -1
  45. package/dist/ui/view.html +2 -2
  46. package/dist/usage/i18n-keys.d.ts +1 -1
  47. package/dist/usage/i18n-keys.js +1 -1
  48. package/dist/usage/i18n-keys.js.map +1 -1
  49. package/dist/usage/providers.d.ts +4 -0
  50. package/dist/usage/providers.js +86 -9
  51. package/dist/usage/providers.js.map +1 -1
  52. package/dist/usage/usage-api.d.ts +0 -11
  53. package/dist/usage/usage-api.js +50 -8
  54. package/dist/usage/usage-api.js.map +1 -1
  55. package/package.json +2 -1
@@ -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
- reactMessageStatus(instanceName: string, chatId: string, messageId: string, emoji: string): void;
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
- /** Account-wide catalog: probe cache first, live probe on miss. */
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. */
@@ -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
- void this.refreshDiscordUsagePresence();
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
- void this.refreshDiscordUsagePresence();
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
- void this.refreshDiscordUsagePresence();
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
- void this.refreshDiscordUsagePresence();
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. Use the adapter BOUND to
4926
- // the instance (not whichever same-guild bot received the event first) so
4927
- // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
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
- const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
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. Use the adapter
5020
- // BOUND to the instance (not whichever same-guild bot received the event
5021
- // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
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
- const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
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
- const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
5065
- reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
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
- let result;
5554
- switch (msg.type) {
5555
- case "fleet_schedule_create": {
5556
- const params = {
5557
- cron: payload.cron,
5558
- at: payload.at,
5559
- message: payload.message,
5560
- source: instanceName,
5561
- target: payload.target || instanceName,
5562
- reply_chat_id: meta.chat_id,
5563
- reply_thread_id: meta.thread_id || null,
5564
- reply_adapter_id: meta.adapter_id || null,
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 this.scheduler.create({
5788
+ return scheduler.create({
5721
5789
  cron: args.cron,
5722
5790
  at: args.at,
5723
5791
  message: args.message,
5724
- source: instance, target: args.target || instance,
5725
- reply_chat_id: "", reply_thread_id: null,
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": return this.scheduler.list(args.target);
5730
- case "update": return this.scheduler.update(args.id, args);
5801
+ case "list":
5802
+ return scheduler.list(requestedTarget);
5803
+ case "update":
5804
+ return scheduler.update(args.id, args);
5731
5805
  case "delete":
5732
- this.scheduler.delete(args.id);
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(this.getInstanceDir(name), { recursive: true, force: true });
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
- reactMessageStatus(instanceName, chatId, messageId, emoji) {
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
- // Status reactions are Discord-only (TG/others use the inbound react path).
6430
- if (!adapter || adapter.type !== "discord")
6537
+ if (!adapter)
6431
6538
  return;
6432
- adapter.react(chatId, messageId, emoji)
6433
- .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
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
- /** Account-wide catalog: probe cache first, live probe on miss. */
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.probeBackend(backend);
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.