@songsid/agend 2.1.8-beta.1 → 2.1.8-beta.3

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 (46) hide show
  1. package/dist/agent-cli.js +9 -0
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +3 -1
  4. package/dist/agent-endpoint.js +7 -1
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/backend/cli-env-probe-worker.d.ts +1 -0
  7. package/dist/backend/cli-env-probe-worker.js +25 -0
  8. package/dist/backend/cli-env-probe-worker.js.map +1 -0
  9. package/dist/backend/codex.js +15 -1
  10. package/dist/backend/codex.js.map +1 -1
  11. package/dist/backend/muse.js +10 -1
  12. package/dist/backend/muse.js.map +1 -1
  13. package/dist/backend/types.d.ts +12 -1
  14. package/dist/backend/types.js +14 -3
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.d.ts +16 -4
  17. package/dist/channel/adapters/discord.js +20 -5
  18. package/dist/channel/adapters/discord.js.map +1 -1
  19. package/dist/channel/mcp-tools.js +4 -0
  20. package/dist/channel/mcp-tools.js.map +1 -1
  21. package/dist/daemon.d.ts +31 -0
  22. package/dist/daemon.js +107 -25
  23. package/dist/daemon.js.map +1 -1
  24. package/dist/fleet-context.d.ts +5 -0
  25. package/dist/fleet-context.js.map +1 -1
  26. package/dist/fleet-manager.d.ts +47 -11
  27. package/dist/fleet-manager.js +322 -61
  28. package/dist/fleet-manager.js.map +1 -1
  29. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +45 -0
  30. package/dist/locale.js +18 -0
  31. package/dist/locale.js.map +1 -1
  32. package/dist/outbound-schemas.d.ts +18 -4
  33. package/dist/outbound-schemas.js +8 -0
  34. package/dist/outbound-schemas.js.map +1 -1
  35. package/dist/settings-api.d.ts +6 -8
  36. package/dist/settings-api.js +9 -4
  37. package/dist/settings-api.js.map +1 -1
  38. package/dist/status-emojis.d.ts +19 -0
  39. package/dist/status-emojis.js.map +1 -1
  40. package/dist/tool-permissions.js +6 -2
  41. package/dist/tool-permissions.js.map +1 -1
  42. package/dist/topic-commands.d.ts +9 -0
  43. package/dist/topic-commands.js +41 -2
  44. package/dist/topic-commands.js.map +1 -1
  45. package/dist/ui/settings.html +23 -11
  46. package/package.json +1 -1
@@ -6,6 +6,7 @@ import type { Logger } from "./logger.js";
6
6
  import type { CostGuard } from "./cost-guard.js";
7
7
  import type { ClassicChannelManager } from "./classic-channel-manager.js";
8
8
  import type { ExplicitInstanceRemoval } from "./instance-removal.js";
9
+ import type { BackendCliVersionSnapshot } from "./backend/types.js";
9
10
  export type RouteTarget = {
10
11
  kind: "instance";
11
12
  name: string;
@@ -68,6 +69,10 @@ export interface FleetContext {
68
69
  /** Classic-bot channels (defined in classicBot.yaml, not fleet.yaml instances). */
69
70
  readonly classicChannels: ClassicChannelManager | null;
70
71
  getSysInfo(): SysInfo;
72
+ /** Read cached CLI versions without triggering a probe. */
73
+ getBackendCliVersionSnapshot?(): BackendCliVersionSnapshot;
74
+ /** Refresh stale/missing CLI versions after the sysinfo reply has been sent. */
75
+ refreshBackendCliVersions?(): void;
71
76
  getInstanceStatus(name: string): "running" | "paused" | "stopped" | "crashed";
72
77
  /** Current cancellation epoch stamped onto direct daemon IPC deliveries. */
73
78
  getDeliveryEpoch?(name: string): number;
@@ -1 +1 @@
1
- {"version":3,"file":"fleet-context.js","sourceRoot":"","sources":["../src/fleet-context.ts"],"names":[],"mappings":"AAiCA,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,OAAO,MAAM,CAAC,IAAI,KAAK,UAAU,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"fleet-context.js","sourceRoot":"","sources":["../src/fleet-context.ts"],"names":[],"mappings":"AAkCA,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,OAAO,MAAM,CAAC,IAAI,KAAK,UAAU,CAAC;AACpC,CAAC"}
@@ -1,13 +1,14 @@
1
1
  import { type FleetMemory } from "./process-memory.js";
2
2
  import { ReplyDeduper } from "./reply-dedup.js";
3
3
  import type { FleetConfig, RawFleetConfig, InstanceConfig } from "./types.js";
4
- import { type DeliveryStatus, type ResolvedStatusEmojis } from "./status-emojis.js";
4
+ import { type DeliveryStatus, type GuildEmoji, type GuildEmojiGroup, type ResolvedStatusEmojis } from "./status-emojis.js";
5
5
  import { type RouteTarget } from "./fleet-context.js";
6
6
  import { EventLog } from "./event-log.js";
7
7
  import { AdapterWorld } from "./adapter-world.js";
8
8
  import { CostGuard } from "./cost-guard.js";
9
9
  import { IpcClient } from "./channel/ipc-bridge.js";
10
10
  import type { AdapterHealthSnapshot, ChannelAdapter, InboundReaction } from "./channel/types.js";
11
+ import { type BackendCliVersionSnapshot } from "./backend/types.js";
11
12
  import { type Logger } from "./logger.js";
12
13
  import { Scheduler } from "./scheduler/index.js";
13
14
  import type { FleetContext } from "./fleet-context.js";
@@ -960,6 +961,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
960
961
  */
961
962
  private refuseTypedIpc;
962
963
  private dispatchTypedIpc;
964
+ /** `list_emojis` / `set_persona_emoji` over IPC; the agent endpoint calls the same two methods. */
965
+ private handlePersonaEmoji;
963
966
  private handleScheduleCrud;
964
967
  private handleDecisionCrud;
965
968
  /** Resolve display name for an instance, fallback to instance name. */
@@ -987,6 +990,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
987
990
  handleDecisionCrudHttp(instance: string, op: string, args: Record<string, unknown>): Promise<unknown>;
988
991
  handleTaskCrudHttp(instance: string, args: Record<string, unknown>): Promise<unknown>;
989
992
  handleSetDisplayNameHttp(instance: string, name: string): Promise<unknown>;
993
+ handleListEmojisHttp(instance: string, refresh: boolean): Promise<unknown>;
994
+ handleSetPersonaEmojiHttp(instance: string, args: Record<string, unknown>): Promise<unknown>;
990
995
  handleSetDescriptionHttp(instance: string, description: string): Promise<unknown>;
991
996
  private summarizeToolCall;
992
997
  private handleTaskCrud;
@@ -1077,6 +1082,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1077
1082
  private deliveryStatusChains;
1078
1083
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
1079
1084
  private pendingCliEnvProbes;
1085
+ /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
1086
+ private pendingVendorCliEnvProbes;
1080
1087
  /**
1081
1088
  * The status emojis for an instance (#1005): its own `status_emojis`, then
1082
1089
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -1088,27 +1095,49 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1088
1095
  };
1089
1096
  private warnedStatusEmojis;
1090
1097
  /**
1091
- * A Discord connection's server emojis for the Settings picker (#1005).
1092
- * Cached per connection — a server's emoji set changes rarely — and one
1093
- * fetch at a time; `refresh` forces a new one. Uses the running adapter, so
1094
- * the bot token never leaves the server.
1098
+ * A Discord connection's server emojis for the Settings picker (#1005), from
1099
+ * every server its bot can draw on (#1021): the primary `group_id` server,
1100
+ * then each other server the bot is a member of that ClassicBot's
1101
+ * `allowed_guilds` admits (unset = all, as for classic routing). A bot can
1102
+ * react with a custom emoji from any server it is in. Cached per server — a
1103
+ * server's emoji set changes rarely — and one fetch per server at a time;
1104
+ * `refresh` forces new ones. One server refusing (e.g. Missing Access) is
1105
+ * reported on that server and does not hide the others. Uses the running
1106
+ * adapter, so the bot token never leaves the server.
1095
1107
  */
1096
1108
  listGuildEmojis(channelId: string, refresh?: boolean): Promise<{
1097
1109
  ok: true;
1098
1110
  fetched_at: number;
1099
- emojis: Array<{
1100
- id: string;
1101
- name: string;
1102
- animated: boolean;
1103
- available: boolean;
1104
- }>;
1111
+ emojis: GuildEmoji[];
1112
+ guilds: GuildEmojiGroup[];
1105
1113
  } | {
1106
1114
  ok: false;
1107
1115
  error: string;
1108
1116
  }>;
1109
1117
  private static GUILD_EMOJI_TTL_MS;
1118
+ /** Keyed `<connection>\0<server id>`. */
1110
1119
  private guildEmojiCache;
1111
1120
  private guildEmojiFetches;
1121
+ /**
1122
+ * What `instanceName` may pick as its persona emoji (the `list_emojis`
1123
+ * tool): its current status set with where each value comes from, the
1124
+ * standard emojis its platform takes, and on Discord the server emojis its
1125
+ * bot can draw on — the same lists, and the same resolution, as the
1126
+ * Settings picker (#1005/#1021), so an agent and an operator see one truth.
1127
+ */
1128
+ listEmojisFor(instanceName: string, refresh?: boolean): Promise<Record<string, unknown>>;
1129
+ /**
1130
+ * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
1131
+ * `delivered` unless it names another — in its per-instance `status_emojis`
1132
+ * override (#1005 addendum 2), so its stamp is told apart from other bots'
1133
+ * on a shared message. The value is judged exactly as Settings and the
1134
+ * react path judge it (statusEmojiProblem on the instance's platform), and
1135
+ * a Discord server emoji must be one its bot can use: an emoji from a
1136
+ * server it is not in would be stored, preview fine, and fail every react.
1137
+ * An empty emoji removes that status's override.
1138
+ */
1139
+ setPersonaEmoji(instanceName: string, args: Record<string, unknown>): Promise<Record<string, unknown>>;
1140
+ private personaEmojiMissing;
1112
1141
  /** What the instructions tell `instanceName` not to react with (its own status set). */
1113
1142
  statusEmojiAvoidList(instanceName: string): string[];
1114
1143
  /** The progress bubble's leading emoji, in the form the channel renders. */
@@ -1711,6 +1740,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1711
1740
  private cliEnvPath;
1712
1741
  /** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
1713
1742
  private readCliEnv;
1743
+ /** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
1744
+ getBackendCliVersionSnapshot(): BackendCliVersionSnapshot;
1745
+ /** Called only after the user-visible sysinfo send/respond has settled. */
1746
+ refreshBackendCliVersions(): void;
1714
1747
  /** True when a cached CLI env is old enough that `/model` should re-probe. */
1715
1748
  private cliEnvNeedsRefresh;
1716
1749
  /**
@@ -1719,6 +1752,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1719
1752
  * previous list, never stall the command that asked for it.
1720
1753
  */
1721
1754
  private probeBackendBounded;
1755
+ private startCliEnvProbeWorker;
1722
1756
  /**
1723
1757
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
1724
1758
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -1745,6 +1779,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1745
1779
  * the old list off as fresh.
1746
1780
  */
1747
1781
  private probeBackend;
1782
+ /** Preserve the existing cache merge rules for both in-process and worker probes. */
1783
+ private persistCliEnvProbeResult;
1748
1784
  /** Background-probe every distinct backend in use at startup (non-blocking). */
1749
1785
  private probeCliEnvs;
1750
1786
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
@@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { freemem, totalmem, cpus } from "node:os";
5
5
  import { createServer } from "node:http";
6
+ import { Worker } from "node:worker_threads";
6
7
  import { join, dirname, basename } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
@@ -16,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = dirname(__filename);
17
18
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
18
19
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
19
- import { STATUS_EMOJI_KEYS, builtinStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
20
+ import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
20
21
  import { isProbeableRouteTarget } from "./fleet-context.js";
21
22
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
22
23
  import { EventLog } from "./event-log.js";
@@ -28,7 +29,7 @@ import { IpcClient } from "./channel/ipc-bridge.js";
28
29
  import { createAdapter } from "./channel/factory.js";
29
30
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
30
31
  import { createBackend } from "./backend/factory.js";
31
- import { isModelCompatible } from "./backend/types.js";
32
+ import { isModelCompatible, SYSINFO_BACKEND_IDS } from "./backend/types.js";
32
33
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
33
34
  import { processAttachments } from "./channel/attachment-handler.js";
34
35
  import { routeToolCall } from "./channel/tool-router.js";
@@ -3915,7 +3916,7 @@ export class FleetManager {
3915
3916
  }
3916
3917
  else if (data.command === "sysinfo") {
3917
3918
  // Slash commands are Discord-only; use plain lines (no markdown table)
3918
- await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
3919
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
3919
3920
  }
3920
3921
  else if (data.command === "dashboard") {
3921
3922
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -4275,7 +4276,7 @@ export class FleetManager {
4275
4276
  }
4276
4277
  else if (data.command === "sysinfo") {
4277
4278
  // Slash commands are Discord-only; use plain lines (no markdown table)
4278
- await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
4279
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
4279
4280
  }
4280
4281
  else if (data.command === "dashboard") {
4281
4282
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -5850,7 +5851,8 @@ export class FleetManager {
5850
5851
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
5851
5852
  : type === "fleet_task" ? "fleet_task_response"
5852
5853
  : type === "fleet_set_display_name" ? "fleet_display_name_response"
5853
- : "fleet_description_response";
5854
+ : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" ? "fleet_persona_emoji_response"
5855
+ : "fleet_description_response";
5854
5856
  ipc.send({ type: responseType, fleetRequestId, error: message });
5855
5857
  }
5856
5858
  dispatchTypedIpc(name, msg) {
@@ -5875,6 +5877,24 @@ export class FleetManager {
5875
5877
  this.handleSetDescription(name, msg);
5876
5878
  return;
5877
5879
  }
5880
+ if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji") {
5881
+ this.handlePersonaEmoji(name, msg);
5882
+ return;
5883
+ }
5884
+ }
5885
+ /** `list_emojis` / `set_persona_emoji` over IPC; the agent endpoint calls the same two methods. */
5886
+ handlePersonaEmoji(instanceName, msg) {
5887
+ const fleetRequestId = msg.fleetRequestId;
5888
+ const payload = (msg.payload ?? {});
5889
+ const ipc = this.instanceIpcClients.get(instanceName);
5890
+ if (!ipc)
5891
+ return;
5892
+ const op = msg.type === "fleet_list_emojis"
5893
+ ? this.listEmojisFor(instanceName, payload.refresh === true)
5894
+ : this.setPersonaEmoji(instanceName, payload);
5895
+ op.then((r) => typeof r.error === "string"
5896
+ ? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
5897
+ : ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
5878
5898
  }
5879
5899
  handleScheduleCrud(instanceName, msg) {
5880
5900
  const fleetRequestId = msg.fleetRequestId;
@@ -6217,6 +6237,12 @@ export class FleetManager {
6217
6237
  return { error: `Instance '${instance}' not found` };
6218
6238
  return { display_name: name };
6219
6239
  }
6240
+ async handleListEmojisHttp(instance, refresh) {
6241
+ return this.listEmojisFor(instance, refresh);
6242
+ }
6243
+ async handleSetPersonaEmojiHttp(instance, args) {
6244
+ return this.setPersonaEmoji(instance, args);
6245
+ }
6220
6246
  async handleSetDescriptionHttp(instance, description) {
6221
6247
  if (!this.fleetConfig)
6222
6248
  return { error: "Fleet config not available" };
@@ -6834,6 +6860,8 @@ export class FleetManager {
6834
6860
  deliveryStatusChains = new Map();
6835
6861
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6836
6862
  pendingCliEnvProbes = new Map();
6863
+ /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
6864
+ pendingVendorCliEnvProbes = new Map();
6837
6865
  /**
6838
6866
  * The status emojis for an instance (#1005): its own `status_emojis`, then
6839
6867
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -6864,10 +6892,15 @@ export class FleetManager {
6864
6892
  }
6865
6893
  warnedStatusEmojis = new Set();
6866
6894
  /**
6867
- * A Discord connection's server emojis for the Settings picker (#1005).
6868
- * Cached per connection — a server's emoji set changes rarely — and one
6869
- * fetch at a time; `refresh` forces a new one. Uses the running adapter, so
6870
- * the bot token never leaves the server.
6895
+ * A Discord connection's server emojis for the Settings picker (#1005), from
6896
+ * every server its bot can draw on (#1021): the primary `group_id` server,
6897
+ * then each other server the bot is a member of that ClassicBot's
6898
+ * `allowed_guilds` admits (unset = all, as for classic routing). A bot can
6899
+ * react with a custom emoji from any server it is in. Cached per server — a
6900
+ * server's emoji set changes rarely — and one fetch per server at a time;
6901
+ * `refresh` forces new ones. One server refusing (e.g. Missing Access) is
6902
+ * reported on that server and does not hide the others. Uses the running
6903
+ * adapter, so the bot token never leaves the server.
6871
6904
  */
6872
6905
  async listGuildEmojis(channelId, refresh = false) {
6873
6906
  const world = this.worlds.get(channelId);
@@ -6878,28 +6911,162 @@ export class FleetManager {
6878
6911
  const adapter = world.adapter;
6879
6912
  if (!adapter.listGuildEmojis)
6880
6913
  return { ok: false, error: "this adapter cannot list server emojis" };
6881
- const cached = this.guildEmojiCache.get(channelId);
6882
- if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
6883
- return { ok: true, ...cached };
6884
- let pending = this.guildEmojiFetches.get(channelId);
6885
- if (!pending) {
6886
- pending = adapter.listGuildEmojis().then(emojis => {
6887
- const entry = { fetched_at: Date.now(), emojis };
6888
- this.guildEmojiCache.set(channelId, entry);
6889
- return entry;
6890
- }).finally(() => this.guildEmojiFetches.delete(channelId));
6891
- this.guildEmojiFetches.set(channelId, pending);
6892
- }
6914
+ const listEmojis = adapter.listGuildEmojis.bind(adapter);
6915
+ let servers;
6893
6916
  try {
6894
- return { ok: true, ...(await pending) };
6917
+ servers = adapter.listMemberGuilds
6918
+ ? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
6919
+ : [{ id: "", name: "", primary: true }];
6895
6920
  }
6896
6921
  catch (e) {
6897
- return { ok: false, error: `Discord refused the emoji list: ${e.message}` };
6898
- }
6922
+ return { ok: false, error: `Discord refused the server list: ${e.message}` };
6923
+ }
6924
+ const guilds = await Promise.all(servers.map(async (g) => {
6925
+ const key = `${channelId}\0${g.id}`;
6926
+ const cached = this.guildEmojiCache.get(key);
6927
+ if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
6928
+ return { ...g, ...cached };
6929
+ let pending = this.guildEmojiFetches.get(key);
6930
+ if (!pending) {
6931
+ pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
6932
+ const entry = { fetched_at: Date.now(), emojis };
6933
+ this.guildEmojiCache.set(key, entry);
6934
+ return entry;
6935
+ }).finally(() => this.guildEmojiFetches.delete(key));
6936
+ this.guildEmojiFetches.set(key, pending);
6937
+ }
6938
+ try {
6939
+ return { ...g, ...(await pending) };
6940
+ }
6941
+ catch (e) {
6942
+ return { ...g, error: `Discord refused the emoji list: ${e.message}` };
6943
+ }
6944
+ }));
6945
+ const listed = guilds.filter((g) => !!g.emojis);
6946
+ if (!listed.length)
6947
+ return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
6948
+ return {
6949
+ ok: true,
6950
+ fetched_at: Math.min(...listed.map(g => g.fetched_at)),
6951
+ emojis: listed.flatMap(g => g.emojis),
6952
+ guilds,
6953
+ };
6899
6954
  }
6900
6955
  static GUILD_EMOJI_TTL_MS = 10 * 60_000;
6956
+ /** Keyed `<connection>\0<server id>`. */
6901
6957
  guildEmojiCache = new Map();
6902
6958
  guildEmojiFetches = new Map();
6959
+ /**
6960
+ * What `instanceName` may pick as its persona emoji (the `list_emojis`
6961
+ * tool): its current status set with where each value comes from, the
6962
+ * standard emojis its platform takes, and on Discord the server emojis its
6963
+ * bot can draw on — the same lists, and the same resolution, as the
6964
+ * Settings picker (#1005/#1021), so an agent and an operator see one truth.
6965
+ */
6966
+ async listEmojisFor(instanceName, refresh = false) {
6967
+ const self = this.fleetConfig?.instances[instanceName];
6968
+ if (!self)
6969
+ return { error: this.personaEmojiMissing(instanceName) };
6970
+ const worldId = this.getInstanceAdapterId(instanceName);
6971
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
6972
+ const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
6973
+ const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
6974
+ const out = {
6975
+ platform: platform ?? null,
6976
+ statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
6977
+ standard: platform === "telegram"
6978
+ ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
6979
+ : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
6980
+ };
6981
+ if (platform === "discord" && worldId) {
6982
+ const listed = await this.listGuildEmojis(worldId, refresh);
6983
+ out.server_emojis = listed.ok
6984
+ ? listed.guilds.map(g => g.emojis
6985
+ ? { server: g.name || g.id, primary: g.primary, emojis: g.emojis.filter(e => e.available).map(e => customEmojiValue(e)) }
6986
+ : { server: g.name || g.id, primary: g.primary, error: g.error })
6987
+ : { error: listed.error };
6988
+ }
6989
+ return out;
6990
+ }
6991
+ /**
6992
+ * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
6993
+ * `delivered` unless it names another — in its per-instance `status_emojis`
6994
+ * override (#1005 addendum 2), so its stamp is told apart from other bots'
6995
+ * on a shared message. The value is judged exactly as Settings and the
6996
+ * react path judge it (statusEmojiProblem on the instance's platform), and
6997
+ * a Discord server emoji must be one its bot can use: an emoji from a
6998
+ * server it is not in would be stored, preview fine, and fail every react.
6999
+ * An empty emoji removes that status's override.
7000
+ */
7001
+ async setPersonaEmoji(instanceName, args) {
7002
+ const self = this.fleetConfig?.instances[instanceName];
7003
+ if (!self)
7004
+ return { error: this.personaEmojiMissing(instanceName) };
7005
+ const status = (args.status ?? "delivered");
7006
+ if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
7007
+ return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
7008
+ }
7009
+ // Only an explicit "" removes the override. The agent endpoint, typed IPC
7010
+ // and agent-cli do not run the MCP schema, so a call missing `emoji` must
7011
+ // be refused here, never read as "clear my delivered stamp".
7012
+ if (typeof args.emoji !== "string") {
7013
+ return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
7014
+ }
7015
+ const raw = args.emoji.trim();
7016
+ // Whitespace is not an explicit "": a stray " " must not clear the stamp either.
7017
+ if (!raw && args.emoji !== "") {
7018
+ return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
7019
+ }
7020
+ let value;
7021
+ if (raw) {
7022
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7023
+ const problem = statusEmojiProblem(platform, status, raw);
7024
+ if (problem)
7025
+ return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
7026
+ const e = normalizeEmoji(raw);
7027
+ if (e.kind === "custom") {
7028
+ const worldId = this.getInstanceAdapterId(instanceName);
7029
+ if (!worldId)
7030
+ return { error: "no connection to check that server emoji against" };
7031
+ const listed = await this.listGuildEmojis(worldId);
7032
+ if (!listed.ok)
7033
+ return { error: `cannot check that server emoji: ${listed.error}` };
7034
+ const found = listed.emojis.find(g => g.id === e.id);
7035
+ if (!found?.available) {
7036
+ return { error: `${raw} is not a server emoji this bot can use${found ? " (Discord marks it unavailable)" : ""}. Call list_emojis for the ones it can.` };
7037
+ }
7038
+ value = customEmojiValue(found);
7039
+ }
7040
+ else {
7041
+ value = e.value;
7042
+ }
7043
+ }
7044
+ // Re-read after the validation above, which may have waited on Discord:
7045
+ // another set_persona_emoji or a Settings save can have changed another
7046
+ // status, or replaced this instance's config object, meanwhile. Change
7047
+ // only this one key on what is there now.
7048
+ const current = this.fleetConfig?.instances[instanceName];
7049
+ if (!current)
7050
+ return { error: this.personaEmojiMissing(instanceName) };
7051
+ const map = { ...(current.status_emojis ?? {}) };
7052
+ if (value)
7053
+ map[status] = value;
7054
+ else
7055
+ delete map[status];
7056
+ if (Object.keys(map).length)
7057
+ current.status_emojis = map;
7058
+ else
7059
+ delete current.status_emojis;
7060
+ this.saveFleetConfig();
7061
+ this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
7062
+ const resolved = this.resolveStatusEmojisFor(instanceName);
7063
+ return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
7064
+ }
7065
+ personaEmojiMissing(instanceName) {
7066
+ return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
7067
+ ? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
7068
+ : `Instance '${instanceName}' not found`;
7069
+ }
6903
7070
  /** What the instructions tell `instanceName` not to react with (its own status set). */
6904
7071
  statusEmojiAvoidList(instanceName) {
6905
7072
  return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
@@ -10325,6 +10492,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10325
10492
  catch { /* missing / stale / corrupt */ }
10326
10493
  return null;
10327
10494
  }
10495
+ /** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
10496
+ getBackendCliVersionSnapshot() {
10497
+ const snapshot = {};
10498
+ for (const backend of SYSINFO_BACKEND_IDS) {
10499
+ const cached = this.readCliEnv(backend);
10500
+ const needsRefresh = this.cliEnvNeedsRefresh(cached);
10501
+ const version = typeof cached?.version === "string" && cached.version.trim()
10502
+ ? cached.version.trim()
10503
+ : null;
10504
+ snapshot[backend] = {
10505
+ version,
10506
+ probing: needsRefresh && version === null,
10507
+ };
10508
+ }
10509
+ return snapshot;
10510
+ }
10511
+ /** Called only after the user-visible sysinfo send/respond has settled. */
10512
+ refreshBackendCliVersions() {
10513
+ for (const backend of SYSINFO_BACKEND_IDS) {
10514
+ if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
10515
+ continue;
10516
+ void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
10517
+ this.logger.debug({ err, backend }, "Background CLI version refresh failed");
10518
+ });
10519
+ }
10520
+ }
10328
10521
  /** True when a cached CLI env is old enough that `/model` should re-probe. */
10329
10522
  cliEnvNeedsRefresh(env) {
10330
10523
  return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
@@ -10335,33 +10528,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10335
10528
  * previous list, never stall the command that asked for it.
10336
10529
  */
10337
10530
  async probeBackendBounded(backend, opts = {}) {
10338
- // #725: single-flight per backend — if a probe for this backend is already
10339
- // in flight, return the same promise instead of starting a duplicate.
10340
- // Refresh probes (refreshVendorCatalog=true) are always started fresh since
10341
- // the caller explicitly asked for a new vendor fetch.
10342
- if (!opts.refreshVendorCatalog) {
10343
- const existing = this.pendingCliEnvProbes.get(backend);
10344
- if (existing)
10345
- return existing;
10346
- }
10347
- const work = this.probeBackend(backend, opts);
10531
+ // #725: single-flight per backend. Explicit vendor refreshes remain a
10532
+ // separate flight from an ordinary probe, while sysinfo can join either.
10533
+ const regularFlight = this.pendingCliEnvProbes.get(backend);
10534
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
10535
+ if (opts.refreshVendorCatalog && vendorFlight)
10536
+ return vendorFlight;
10537
+ if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
10538
+ return vendorFlight ?? regularFlight;
10539
+ const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
10540
+ const work = worker?.promise ?? this.probeBackend(backend, opts);
10348
10541
  work.catch(() => { });
10349
10542
  let timer;
10350
10543
  const deadline = new Promise(resolve => {
10351
10544
  timer = setTimeout(() => {
10545
+ worker?.terminate();
10352
10546
  this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
10353
10547
  resolve(null);
10354
10548
  }, CLI_ENV_PROBE_DEADLINE_MS);
10355
10549
  });
10356
10550
  const bounded = Promise.race([work, deadline]).finally(() => {
10357
10551
  clearTimeout(timer);
10358
- if (this.pendingCliEnvProbes.get(backend) === bounded)
10359
- this.pendingCliEnvProbes.delete(backend);
10552
+ const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
10553
+ if (flights.get(backend) === bounded)
10554
+ flights.delete(backend);
10360
10555
  });
10361
- if (!opts.refreshVendorCatalog)
10362
- this.pendingCliEnvProbes.set(backend, bounded);
10556
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
10363
10557
  return bounded;
10364
10558
  }
10559
+ startCliEnvProbeWorker(backend, opts) {
10560
+ const workerData = {
10561
+ backend,
10562
+ instanceDir: join(getAgendHome(), "cli-env"),
10563
+ config: {
10564
+ workingDirectory: "",
10565
+ instanceDir: join(getAgendHome(), "cli-env"),
10566
+ instanceName: `probe-${backend}`,
10567
+ mcpServers: {},
10568
+ },
10569
+ refreshVendorCatalog: opts.refreshVendorCatalog === true,
10570
+ };
10571
+ // `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
10572
+ // Published builds load the compiled worker and require no tsx dependency.
10573
+ const worker = import.meta.url.endsWith(".ts")
10574
+ ? new Worker(`const { workerData } = require("node:worker_threads"); import("tsx/esm/api").then(({ tsImport }) => tsImport(workerData.entry, workerData.entry));`, { eval: true, execArgv: [], workerData: { ...workerData, entry: new URL("./backend/cli-env-probe-worker.ts", import.meta.url).href } })
10575
+ : new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
10576
+ let settled = false;
10577
+ let resolveProbe;
10578
+ const promise = new Promise(resolve => { resolveProbe = resolve; });
10579
+ const finish = (value) => {
10580
+ if (settled)
10581
+ return;
10582
+ settled = true;
10583
+ resolveProbe(value);
10584
+ void worker.terminate().catch(err => {
10585
+ this.logger.debug({ backend, err }, "CLI env worker termination failed");
10586
+ });
10587
+ };
10588
+ worker.once("message", (message) => {
10589
+ if (settled)
10590
+ return;
10591
+ if (!message.ok) {
10592
+ this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
10593
+ finish(null);
10594
+ }
10595
+ else if (!message.result) {
10596
+ finish(null);
10597
+ }
10598
+ else {
10599
+ try {
10600
+ finish(this.persistCliEnvProbeResult(backend, message.result));
10601
+ }
10602
+ catch (err) {
10603
+ this.logger.warn({ backend, err }, "CLI env cache write failed");
10604
+ finish(null);
10605
+ }
10606
+ }
10607
+ });
10608
+ worker.once("error", err => {
10609
+ if (settled)
10610
+ return;
10611
+ this.logger.warn({ backend, err }, "CLI env worker crashed");
10612
+ finish(null);
10613
+ });
10614
+ worker.once("exit", code => {
10615
+ if (settled)
10616
+ return;
10617
+ this.logger.warn({ backend, code }, "CLI env worker exited without a result");
10618
+ finish(null);
10619
+ });
10620
+ return { promise, terminate: () => { finish(null); } };
10621
+ }
10365
10622
  /**
10366
10623
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
10367
10624
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -10428,35 +10685,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10428
10685
  if (opts.refreshVendorCatalog && be.refreshModelCatalog)
10429
10686
  await be.refreshModelCatalog();
10430
10687
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
10431
- const env = { backend, probedAt: Date.now(), ...probed };
10432
- // An empty result must never overwrite a catalog we already have. Some
10433
- // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
10434
- // returns [] — and writing that would blank the list for the whole 24h
10435
- // TTL, long after the CLI recovered. Observed live: a good 11-model
10436
- // antigravity cache replaced by an empty one. Keep the known models and
10437
- // let the fresher currentModel/version through.
10438
- if (!env.models?.length) {
10439
- const previous = this.readCliEnv(backend);
10440
- if (previous?.models?.length)
10441
- env.models = previous.models;
10442
- }
10443
- // Same protection for the extended catalog: one offline moment must not
10444
- // blank a good account list for the whole cache TTL.
10445
- if (!env.apiModels?.length) {
10446
- const previous = this.readCliEnv(backend);
10447
- if (previous?.apiModels?.length)
10448
- env.apiModels = previous.apiModels;
10449
- }
10450
- const path = this.cliEnvPath(backend);
10451
- mkdirSync(dirname(path), { recursive: true });
10452
- writeFileSync(path, JSON.stringify(env, null, 2));
10453
- return env;
10688
+ return this.persistCliEnvProbeResult(backend, probed);
10454
10689
  }
10455
10690
  catch (err) {
10456
10691
  this.logger.warn({ err, backend }, "CLI env probe failed");
10457
10692
  return null;
10458
10693
  }
10459
10694
  }
10695
+ /** Preserve the existing cache merge rules for both in-process and worker probes. */
10696
+ persistCliEnvProbeResult(backend, probed) {
10697
+ const env = { backend, probedAt: Date.now(), ...probed };
10698
+ // An empty result must never overwrite a catalog we already have. Some
10699
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
10700
+ // returns [] — and writing that would blank the list for the whole 24h
10701
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
10702
+ // antigravity cache replaced by an empty one. Keep the known models and
10703
+ // let the fresher currentModel/version through.
10704
+ if (!env.models?.length) {
10705
+ const previous = this.readCliEnv(backend);
10706
+ if (previous?.models?.length)
10707
+ env.models = previous.models;
10708
+ }
10709
+ // Same protection for the extended catalog: one offline moment must not
10710
+ // blank a good account list for the whole cache TTL.
10711
+ if (!env.apiModels?.length) {
10712
+ const previous = this.readCliEnv(backend);
10713
+ if (previous?.apiModels?.length)
10714
+ env.apiModels = previous.apiModels;
10715
+ }
10716
+ const path = this.cliEnvPath(backend);
10717
+ mkdirSync(dirname(path), { recursive: true });
10718
+ writeFileSync(path, JSON.stringify(env, null, 2));
10719
+ return env;
10720
+ }
10460
10721
  /** Background-probe every distinct backend in use at startup (non-blocking). */
10461
10722
  probeCliEnvs() {
10462
10723
  const backends = new Set();