@songsid/agend 2.1.8-beta.2 → 2.1.8-beta.4

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 (44) hide show
  1. package/dist/agent-cli.js +14 -0
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +10 -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/muse.js +30 -2
  10. package/dist/backend/muse.js.map +1 -1
  11. package/dist/backend/types.d.ts +12 -1
  12. package/dist/backend/types.js +14 -3
  13. package/dist/backend/types.js.map +1 -1
  14. package/dist/channel/adapters/discord.d.ts +16 -4
  15. package/dist/channel/adapters/discord.js +20 -5
  16. package/dist/channel/adapters/discord.js.map +1 -1
  17. package/dist/channel/mcp-tools.js +6 -0
  18. package/dist/channel/mcp-tools.js.map +1 -1
  19. package/dist/daemon.d.ts +2 -0
  20. package/dist/daemon.js +24 -18
  21. package/dist/daemon.js.map +1 -1
  22. package/dist/fleet-context.d.ts +5 -0
  23. package/dist/fleet-context.js.map +1 -1
  24. package/dist/fleet-manager.d.ts +66 -11
  25. package/dist/fleet-manager.js +430 -61
  26. package/dist/fleet-manager.js.map +1 -1
  27. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
  28. package/dist/locale.js +18 -0
  29. package/dist/locale.js.map +1 -1
  30. package/dist/outbound-schemas.d.ts +21 -4
  31. package/dist/outbound-schemas.js +12 -0
  32. package/dist/outbound-schemas.js.map +1 -1
  33. package/dist/settings-api.d.ts +6 -8
  34. package/dist/settings-api.js +9 -4
  35. package/dist/settings-api.js.map +1 -1
  36. package/dist/status-emojis.d.ts +19 -0
  37. package/dist/status-emojis.js.map +1 -1
  38. package/dist/tool-permissions.js +8 -2
  39. package/dist/tool-permissions.js.map +1 -1
  40. package/dist/topic-commands.d.ts +9 -0
  41. package/dist/topic-commands.js +41 -2
  42. package/dist/topic-commands.js.map +1 -1
  43. package/dist/ui/settings.html +23 -11
  44. package/package.json +1 -1
@@ -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, emojiImageUrl, 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" || type === "fleet_preview_emojis" ? "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" || type === "fleet_preview_emojis") {
5881
+ this.handlePersonaEmoji(name, msg);
5882
+ return;
5883
+ }
5884
+ }
5885
+ /** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same 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" ? this.listEmojisFor(instanceName, payload.refresh === true)
5893
+ : msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
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,15 @@ 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
+ }
6246
+ async handlePreviewEmojisHttp(instance, args) {
6247
+ return this.previewEmojis(instance, args);
6248
+ }
6220
6249
  async handleSetDescriptionHttp(instance, description) {
6221
6250
  if (!this.fleetConfig)
6222
6251
  return { error: "Fleet config not available" };
@@ -6834,6 +6863,8 @@ export class FleetManager {
6834
6863
  deliveryStatusChains = new Map();
6835
6864
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6836
6865
  pendingCliEnvProbes = new Map();
6866
+ /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
6867
+ pendingVendorCliEnvProbes = new Map();
6837
6868
  /**
6838
6869
  * The status emojis for an instance (#1005): its own `status_emojis`, then
6839
6870
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -6864,10 +6895,15 @@ export class FleetManager {
6864
6895
  }
6865
6896
  warnedStatusEmojis = new Set();
6866
6897
  /**
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.
6898
+ * A Discord connection's server emojis for the Settings picker (#1005), from
6899
+ * every server its bot can draw on (#1021): the primary `group_id` server,
6900
+ * then each other server the bot is a member of that ClassicBot's
6901
+ * `allowed_guilds` admits (unset = all, as for classic routing). A bot can
6902
+ * react with a custom emoji from any server it is in. Cached per server — a
6903
+ * server's emoji set changes rarely — and one fetch per server at a time;
6904
+ * `refresh` forces new ones. One server refusing (e.g. Missing Access) is
6905
+ * reported on that server and does not hide the others. Uses the running
6906
+ * adapter, so the bot token never leaves the server.
6871
6907
  */
6872
6908
  async listGuildEmojis(channelId, refresh = false) {
6873
6909
  const world = this.worlds.get(channelId);
@@ -6878,28 +6914,267 @@ export class FleetManager {
6878
6914
  const adapter = world.adapter;
6879
6915
  if (!adapter.listGuildEmojis)
6880
6916
  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
- }
6917
+ const listEmojis = adapter.listGuildEmojis.bind(adapter);
6918
+ let servers;
6893
6919
  try {
6894
- return { ok: true, ...(await pending) };
6920
+ servers = adapter.listMemberGuilds
6921
+ ? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
6922
+ : [{ id: "", name: "", primary: true }];
6895
6923
  }
6896
6924
  catch (e) {
6897
- return { ok: false, error: `Discord refused the emoji list: ${e.message}` };
6898
- }
6925
+ return { ok: false, error: `Discord refused the server list: ${e.message}` };
6926
+ }
6927
+ const guilds = await Promise.all(servers.map(async (g) => {
6928
+ const key = `${channelId}\0${g.id}`;
6929
+ const cached = this.guildEmojiCache.get(key);
6930
+ if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
6931
+ return { ...g, ...cached };
6932
+ let pending = this.guildEmojiFetches.get(key);
6933
+ if (!pending) {
6934
+ pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
6935
+ const entry = { fetched_at: Date.now(), emojis };
6936
+ this.guildEmojiCache.set(key, entry);
6937
+ return entry;
6938
+ }).finally(() => this.guildEmojiFetches.delete(key));
6939
+ this.guildEmojiFetches.set(key, pending);
6940
+ }
6941
+ try {
6942
+ return { ...g, ...(await pending) };
6943
+ }
6944
+ catch (e) {
6945
+ return { ...g, error: `Discord refused the emoji list: ${e.message}` };
6946
+ }
6947
+ }));
6948
+ const listed = guilds.filter((g) => !!g.emojis);
6949
+ if (!listed.length)
6950
+ return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
6951
+ return {
6952
+ ok: true,
6953
+ fetched_at: Math.min(...listed.map(g => g.fetched_at)),
6954
+ emojis: listed.flatMap(g => g.emojis),
6955
+ guilds,
6956
+ };
6899
6957
  }
6900
6958
  static GUILD_EMOJI_TTL_MS = 10 * 60_000;
6959
+ /** Keyed `<connection>\0<server id>`. */
6901
6960
  guildEmojiCache = new Map();
6902
6961
  guildEmojiFetches = new Map();
6962
+ /**
6963
+ * What `instanceName` may pick as its persona emoji (the `list_emojis`
6964
+ * tool): its current status set with where each value comes from, the
6965
+ * standard emojis its platform takes, and on Discord the server emojis its
6966
+ * bot can draw on — the same lists, and the same resolution, as the
6967
+ * Settings picker (#1005/#1021), so an agent and an operator see one truth.
6968
+ */
6969
+ async listEmojisFor(instanceName, refresh = false) {
6970
+ const self = this.fleetConfig?.instances[instanceName];
6971
+ if (!self)
6972
+ return { error: this.personaEmojiMissing(instanceName) };
6973
+ const worldId = this.getInstanceAdapterId(instanceName);
6974
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
6975
+ const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
6976
+ const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
6977
+ const out = {
6978
+ platform: platform ?? null,
6979
+ statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
6980
+ standard: platform === "telegram"
6981
+ ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
6982
+ : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
6983
+ };
6984
+ if (platform === "discord" && worldId) {
6985
+ const listed = await this.listGuildEmojis(worldId, refresh);
6986
+ out.server_emojis = listed.ok
6987
+ ? listed.guilds.map(g => g.emojis
6988
+ ? {
6989
+ server: g.name || g.id, primary: g.primary,
6990
+ emojis: g.emojis.filter(e => e.available).map(e => {
6991
+ const value = customEmojiValue(e);
6992
+ return { value, image_url: emojiImageUrl(value) };
6993
+ }),
6994
+ }
6995
+ : { server: g.name || g.id, primary: g.primary, error: g.error })
6996
+ : { error: listed.error };
6997
+ }
6998
+ return out;
6999
+ }
7000
+ /**
7001
+ * `preview_emojis` (#1040): download a few Discord server emojis so an agent
7002
+ * can look at them before it picks one — a name and an id say nothing about
7003
+ * what an emoji looks like. The fleet fetches, not the agent (not every
7004
+ * backend can), and returns a local path per emoji to Read, as
7005
+ * download_attachment does for an attachment.
7006
+ *
7007
+ * SSRF-safe by construction: nothing from the caller reaches a URL. The
7008
+ * caller names emojis; each must be an available one in a server this bot
7009
+ * can draw on (the list set_persona_emoji checks), and the CDN URL is built
7010
+ * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
7011
+ */
7012
+ async previewEmojis(instanceName, args) {
7013
+ const self = this.fleetConfig?.instances[instanceName];
7014
+ if (!self)
7015
+ return { error: this.personaEmojiMissing(instanceName) };
7016
+ const wanted = args.emojis;
7017
+ if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
7018
+ return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
7019
+ }
7020
+ if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
7021
+ return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
7022
+ }
7023
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7024
+ const worldId = this.getInstanceAdapterId(instanceName);
7025
+ if (platform !== "discord" || !worldId) {
7026
+ return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
7027
+ }
7028
+ const listed = await this.listGuildEmojis(worldId);
7029
+ if (!listed.ok)
7030
+ return { error: `cannot list this bot's server emojis: ${listed.error}` };
7031
+ const previews = [];
7032
+ const errors = [];
7033
+ for (const raw of wanted) {
7034
+ const e = normalizeEmoji(raw);
7035
+ if (e?.kind !== "custom") {
7036
+ errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
7037
+ continue;
7038
+ }
7039
+ const found = listed.emojis.find(g => g.id === e.id);
7040
+ if (!found?.available) {
7041
+ errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
7042
+ continue;
7043
+ }
7044
+ try {
7045
+ previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
7046
+ }
7047
+ catch (err) {
7048
+ errors.push({ emoji: raw, error: `download failed: ${err.message}` });
7049
+ }
7050
+ }
7051
+ return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
7052
+ }
7053
+ static EMOJI_PREVIEW_MAX = 8;
7054
+ static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
7055
+ static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
7056
+ /** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
7057
+ async fetchEmojiPreview(id) {
7058
+ if (!/^\d{15,25}$/.test(id))
7059
+ throw new Error("not a Discord emoji id");
7060
+ const dir = join(this.dataDir, "inbox", "emoji-previews");
7061
+ mkdirSync(dir, { recursive: true });
7062
+ const now = Date.now();
7063
+ for (const name of readdirSync(dir)) {
7064
+ try {
7065
+ if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
7066
+ unlinkSync(join(dir, name));
7067
+ }
7068
+ catch { /* raced */ }
7069
+ }
7070
+ const path = join(dir, `${id}.png`);
7071
+ if (existsSync(path))
7072
+ return path;
7073
+ const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
7074
+ if (!response.ok)
7075
+ throw new Error(`HTTP ${response.status}`);
7076
+ if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
7077
+ throw new Error("not a PNG");
7078
+ const reader = response.body?.getReader();
7079
+ if (!reader)
7080
+ throw new Error("empty response");
7081
+ const chunks = [];
7082
+ let size = 0;
7083
+ for (;;) {
7084
+ const { done, value } = await reader.read();
7085
+ if (done)
7086
+ break;
7087
+ size += value.byteLength;
7088
+ if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
7089
+ await reader.cancel();
7090
+ throw new Error("image too large");
7091
+ }
7092
+ chunks.push(value);
7093
+ }
7094
+ const tmp = `${path}.${process.pid}.tmp`;
7095
+ writeFileSync(tmp, Buffer.concat(chunks));
7096
+ renameSync(tmp, path);
7097
+ return path;
7098
+ }
7099
+ /**
7100
+ * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
7101
+ * `delivered` unless it names another — in its per-instance `status_emojis`
7102
+ * override (#1005 addendum 2), so its stamp is told apart from other bots'
7103
+ * on a shared message. The value is judged exactly as Settings and the
7104
+ * react path judge it (statusEmojiProblem on the instance's platform), and
7105
+ * a Discord server emoji must be one its bot can use: an emoji from a
7106
+ * server it is not in would be stored, preview fine, and fail every react.
7107
+ * An empty emoji removes that status's override.
7108
+ */
7109
+ async setPersonaEmoji(instanceName, args) {
7110
+ const self = this.fleetConfig?.instances[instanceName];
7111
+ if (!self)
7112
+ return { error: this.personaEmojiMissing(instanceName) };
7113
+ const status = (args.status ?? "delivered");
7114
+ if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
7115
+ return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
7116
+ }
7117
+ // Only an explicit "" removes the override. The agent endpoint, typed IPC
7118
+ // and agent-cli do not run the MCP schema, so a call missing `emoji` must
7119
+ // be refused here, never read as "clear my delivered stamp".
7120
+ if (typeof args.emoji !== "string") {
7121
+ return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
7122
+ }
7123
+ const raw = args.emoji.trim();
7124
+ // Whitespace is not an explicit "": a stray " " must not clear the stamp either.
7125
+ if (!raw && args.emoji !== "") {
7126
+ return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
7127
+ }
7128
+ let value;
7129
+ if (raw) {
7130
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7131
+ const problem = statusEmojiProblem(platform, status, raw);
7132
+ if (problem)
7133
+ return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
7134
+ const e = normalizeEmoji(raw);
7135
+ if (e.kind === "custom") {
7136
+ const worldId = this.getInstanceAdapterId(instanceName);
7137
+ if (!worldId)
7138
+ return { error: "no connection to check that server emoji against" };
7139
+ const listed = await this.listGuildEmojis(worldId);
7140
+ if (!listed.ok)
7141
+ return { error: `cannot check that server emoji: ${listed.error}` };
7142
+ const found = listed.emojis.find(g => g.id === e.id);
7143
+ if (!found?.available) {
7144
+ 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.` };
7145
+ }
7146
+ value = customEmojiValue(found);
7147
+ }
7148
+ else {
7149
+ value = e.value;
7150
+ }
7151
+ }
7152
+ // Re-read after the validation above, which may have waited on Discord:
7153
+ // another set_persona_emoji or a Settings save can have changed another
7154
+ // status, or replaced this instance's config object, meanwhile. Change
7155
+ // only this one key on what is there now.
7156
+ const current = this.fleetConfig?.instances[instanceName];
7157
+ if (!current)
7158
+ return { error: this.personaEmojiMissing(instanceName) };
7159
+ const map = { ...(current.status_emojis ?? {}) };
7160
+ if (value)
7161
+ map[status] = value;
7162
+ else
7163
+ delete map[status];
7164
+ if (Object.keys(map).length)
7165
+ current.status_emojis = map;
7166
+ else
7167
+ delete current.status_emojis;
7168
+ this.saveFleetConfig();
7169
+ this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
7170
+ const resolved = this.resolveStatusEmojisFor(instanceName);
7171
+ return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
7172
+ }
7173
+ personaEmojiMissing(instanceName) {
7174
+ return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
7175
+ ? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
7176
+ : `Instance '${instanceName}' not found`;
7177
+ }
6903
7178
  /** What the instructions tell `instanceName` not to react with (its own status set). */
6904
7179
  statusEmojiAvoidList(instanceName) {
6905
7180
  return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
@@ -10325,6 +10600,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10325
10600
  catch { /* missing / stale / corrupt */ }
10326
10601
  return null;
10327
10602
  }
10603
+ /** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
10604
+ getBackendCliVersionSnapshot() {
10605
+ const snapshot = {};
10606
+ for (const backend of SYSINFO_BACKEND_IDS) {
10607
+ const cached = this.readCliEnv(backend);
10608
+ const needsRefresh = this.cliEnvNeedsRefresh(cached);
10609
+ const version = typeof cached?.version === "string" && cached.version.trim()
10610
+ ? cached.version.trim()
10611
+ : null;
10612
+ snapshot[backend] = {
10613
+ version,
10614
+ probing: needsRefresh && version === null,
10615
+ };
10616
+ }
10617
+ return snapshot;
10618
+ }
10619
+ /** Called only after the user-visible sysinfo send/respond has settled. */
10620
+ refreshBackendCliVersions() {
10621
+ for (const backend of SYSINFO_BACKEND_IDS) {
10622
+ if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
10623
+ continue;
10624
+ void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
10625
+ this.logger.debug({ err, backend }, "Background CLI version refresh failed");
10626
+ });
10627
+ }
10628
+ }
10328
10629
  /** True when a cached CLI env is old enough that `/model` should re-probe. */
10329
10630
  cliEnvNeedsRefresh(env) {
10330
10631
  return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
@@ -10335,33 +10636,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10335
10636
  * previous list, never stall the command that asked for it.
10336
10637
  */
10337
10638
  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);
10639
+ // #725: single-flight per backend. Explicit vendor refreshes remain a
10640
+ // separate flight from an ordinary probe, while sysinfo can join either.
10641
+ const regularFlight = this.pendingCliEnvProbes.get(backend);
10642
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
10643
+ if (opts.refreshVendorCatalog && vendorFlight)
10644
+ return vendorFlight;
10645
+ if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
10646
+ return vendorFlight ?? regularFlight;
10647
+ const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
10648
+ const work = worker?.promise ?? this.probeBackend(backend, opts);
10348
10649
  work.catch(() => { });
10349
10650
  let timer;
10350
10651
  const deadline = new Promise(resolve => {
10351
10652
  timer = setTimeout(() => {
10653
+ worker?.terminate();
10352
10654
  this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
10353
10655
  resolve(null);
10354
10656
  }, CLI_ENV_PROBE_DEADLINE_MS);
10355
10657
  });
10356
10658
  const bounded = Promise.race([work, deadline]).finally(() => {
10357
10659
  clearTimeout(timer);
10358
- if (this.pendingCliEnvProbes.get(backend) === bounded)
10359
- this.pendingCliEnvProbes.delete(backend);
10660
+ const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
10661
+ if (flights.get(backend) === bounded)
10662
+ flights.delete(backend);
10360
10663
  });
10361
- if (!opts.refreshVendorCatalog)
10362
- this.pendingCliEnvProbes.set(backend, bounded);
10664
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
10363
10665
  return bounded;
10364
10666
  }
10667
+ startCliEnvProbeWorker(backend, opts) {
10668
+ const workerData = {
10669
+ backend,
10670
+ instanceDir: join(getAgendHome(), "cli-env"),
10671
+ config: {
10672
+ workingDirectory: "",
10673
+ instanceDir: join(getAgendHome(), "cli-env"),
10674
+ instanceName: `probe-${backend}`,
10675
+ mcpServers: {},
10676
+ },
10677
+ refreshVendorCatalog: opts.refreshVendorCatalog === true,
10678
+ };
10679
+ // `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
10680
+ // Published builds load the compiled worker and require no tsx dependency.
10681
+ const worker = import.meta.url.endsWith(".ts")
10682
+ ? 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 } })
10683
+ : new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
10684
+ let settled = false;
10685
+ let resolveProbe;
10686
+ const promise = new Promise(resolve => { resolveProbe = resolve; });
10687
+ const finish = (value) => {
10688
+ if (settled)
10689
+ return;
10690
+ settled = true;
10691
+ resolveProbe(value);
10692
+ void worker.terminate().catch(err => {
10693
+ this.logger.debug({ backend, err }, "CLI env worker termination failed");
10694
+ });
10695
+ };
10696
+ worker.once("message", (message) => {
10697
+ if (settled)
10698
+ return;
10699
+ if (!message.ok) {
10700
+ this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
10701
+ finish(null);
10702
+ }
10703
+ else if (!message.result) {
10704
+ finish(null);
10705
+ }
10706
+ else {
10707
+ try {
10708
+ finish(this.persistCliEnvProbeResult(backend, message.result));
10709
+ }
10710
+ catch (err) {
10711
+ this.logger.warn({ backend, err }, "CLI env cache write failed");
10712
+ finish(null);
10713
+ }
10714
+ }
10715
+ });
10716
+ worker.once("error", err => {
10717
+ if (settled)
10718
+ return;
10719
+ this.logger.warn({ backend, err }, "CLI env worker crashed");
10720
+ finish(null);
10721
+ });
10722
+ worker.once("exit", code => {
10723
+ if (settled)
10724
+ return;
10725
+ this.logger.warn({ backend, code }, "CLI env worker exited without a result");
10726
+ finish(null);
10727
+ });
10728
+ return { promise, terminate: () => { finish(null); } };
10729
+ }
10365
10730
  /**
10366
10731
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
10367
10732
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -10428,35 +10793,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10428
10793
  if (opts.refreshVendorCatalog && be.refreshModelCatalog)
10429
10794
  await be.refreshModelCatalog();
10430
10795
  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;
10796
+ return this.persistCliEnvProbeResult(backend, probed);
10454
10797
  }
10455
10798
  catch (err) {
10456
10799
  this.logger.warn({ err, backend }, "CLI env probe failed");
10457
10800
  return null;
10458
10801
  }
10459
10802
  }
10803
+ /** Preserve the existing cache merge rules for both in-process and worker probes. */
10804
+ persistCliEnvProbeResult(backend, probed) {
10805
+ const env = { backend, probedAt: Date.now(), ...probed };
10806
+ // An empty result must never overwrite a catalog we already have. Some
10807
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
10808
+ // returns [] — and writing that would blank the list for the whole 24h
10809
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
10810
+ // antigravity cache replaced by an empty one. Keep the known models and
10811
+ // let the fresher currentModel/version through.
10812
+ if (!env.models?.length) {
10813
+ const previous = this.readCliEnv(backend);
10814
+ if (previous?.models?.length)
10815
+ env.models = previous.models;
10816
+ }
10817
+ // Same protection for the extended catalog: one offline moment must not
10818
+ // blank a good account list for the whole cache TTL.
10819
+ if (!env.apiModels?.length) {
10820
+ const previous = this.readCliEnv(backend);
10821
+ if (previous?.apiModels?.length)
10822
+ env.apiModels = previous.apiModels;
10823
+ }
10824
+ const path = this.cliEnvPath(backend);
10825
+ mkdirSync(dirname(path), { recursive: true });
10826
+ writeFileSync(path, JSON.stringify(env, null, 2));
10827
+ return env;
10828
+ }
10460
10829
  /** Background-probe every distinct backend in use at startup (non-blocking). */
10461
10830
  probeCliEnvs() {
10462
10831
  const backends = new Set();