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

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 (115) 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/claude-code.d.ts +35 -5
  7. package/dist/backend/claude-code.js +288 -17
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/cli-env-probe-worker.d.ts +1 -0
  10. package/dist/backend/cli-env-probe-worker.js +25 -0
  11. package/dist/backend/cli-env-probe-worker.js.map +1 -0
  12. package/dist/backend/codex-session-lookup.d.ts +16 -0
  13. package/dist/backend/codex-session-lookup.js +76 -2
  14. package/dist/backend/codex-session-lookup.js.map +1 -1
  15. package/dist/backend/codex.d.ts +3 -4
  16. package/dist/backend/codex.js +44 -6
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/credential-profile.d.ts +9 -0
  19. package/dist/backend/credential-profile.js +20 -0
  20. package/dist/backend/credential-profile.js.map +1 -1
  21. package/dist/backend/grok.js +19 -0
  22. package/dist/backend/grok.js.map +1 -1
  23. package/dist/backend/muse.d.ts +6 -0
  24. package/dist/backend/muse.js +37 -3
  25. package/dist/backend/muse.js.map +1 -1
  26. package/dist/backend/types.d.ts +13 -2
  27. package/dist/backend/types.js +14 -3
  28. package/dist/backend/types.js.map +1 -1
  29. package/dist/channel/adapters/discord.d.ts +16 -4
  30. package/dist/channel/adapters/discord.js +20 -5
  31. package/dist/channel/adapters/discord.js.map +1 -1
  32. package/dist/channel/mcp-tools.js +6 -0
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/cli.js +138 -48
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config-validator.js +11 -0
  37. package/dist/config-validator.js.map +1 -1
  38. package/dist/config.d.ts +1 -0
  39. package/dist/config.js +1 -1
  40. package/dist/config.js.map +1 -1
  41. package/dist/cross-instance-envelope.d.ts +7 -0
  42. package/dist/cross-instance-envelope.js +19 -1
  43. package/dist/cross-instance-envelope.js.map +1 -1
  44. package/dist/daemon.d.ts +130 -6
  45. package/dist/daemon.js +486 -69
  46. package/dist/daemon.js.map +1 -1
  47. package/dist/delivery-outbox.d.ts +9 -1
  48. package/dist/delivery-outbox.js +25 -4
  49. package/dist/delivery-outbox.js.map +1 -1
  50. package/dist/fleet-context.d.ts +5 -0
  51. package/dist/fleet-context.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +140 -18
  53. package/dist/fleet-manager.js +733 -106
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/forged-envelope-scan.d.ts +160 -0
  56. package/dist/forged-envelope-scan.js +312 -0
  57. package/dist/forged-envelope-scan.js.map +1 -0
  58. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
  59. package/dist/instance-lifecycle.d.ts +81 -6
  60. package/dist/instance-lifecycle.js +201 -17
  61. package/dist/instance-lifecycle.js.map +1 -1
  62. package/dist/locale.js +34 -4
  63. package/dist/locale.js.map +1 -1
  64. package/dist/login-controller.js +11 -4
  65. package/dist/login-controller.js.map +1 -1
  66. package/dist/login-flows.d.ts +18 -1
  67. package/dist/login-flows.js +9 -2
  68. package/dist/login-flows.js.map +1 -1
  69. package/dist/ls-rows.d.ts +17 -0
  70. package/dist/ls-rows.js +73 -0
  71. package/dist/ls-rows.js.map +1 -1
  72. package/dist/outbound-handlers.d.ts +3 -0
  73. package/dist/outbound-handlers.js +3 -3
  74. package/dist/outbound-handlers.js.map +1 -1
  75. package/dist/outbound-schemas.d.ts +21 -4
  76. package/dist/outbound-schemas.js +12 -0
  77. package/dist/outbound-schemas.js.map +1 -1
  78. package/dist/paths.js +4 -2
  79. package/dist/paths.js.map +1 -1
  80. package/dist/pause-marker.d.ts +10 -1
  81. package/dist/pause-marker.js +26 -2
  82. package/dist/pause-marker.js.map +1 -1
  83. package/dist/service-installer.d.ts +8 -0
  84. package/dist/service-installer.js +36 -1
  85. package/dist/service-installer.js.map +1 -1
  86. package/dist/settings-api.d.ts +8 -8
  87. package/dist/settings-api.js +12 -5
  88. package/dist/settings-api.js.map +1 -1
  89. package/dist/status-emojis.d.ts +19 -0
  90. package/dist/status-emojis.js.map +1 -1
  91. package/dist/tool-permissions.js +8 -2
  92. package/dist/tool-permissions.js.map +1 -1
  93. package/dist/topic-commands.d.ts +9 -0
  94. package/dist/topic-commands.js +64 -21
  95. package/dist/topic-commands.js.map +1 -1
  96. package/dist/transcript-monitor.js +3 -0
  97. package/dist/transcript-monitor.js.map +1 -1
  98. package/dist/transcript-sources.d.ts +63 -0
  99. package/dist/transcript-sources.js +171 -27
  100. package/dist/transcript-sources.js.map +1 -1
  101. package/dist/types.d.ts +25 -0
  102. package/dist/types.js +9 -1
  103. package/dist/types.js.map +1 -1
  104. package/dist/ui/settings.html +23 -11
  105. package/dist/wake-coordinator.d.ts +143 -0
  106. package/dist/wake-coordinator.js +321 -0
  107. package/dist/wake-coordinator.js.map +1 -0
  108. package/dist/web-api.d.ts +3 -1
  109. package/dist/web-api.js +1 -1
  110. package/dist/web-api.js.map +1 -1
  111. package/dist/web-terminal.d.ts +2 -0
  112. package/dist/web-terminal.js +3 -6
  113. package/dist/web-terminal.js.map +1 -1
  114. package/package.json +1 -1
  115. package/templates/systemd.service.ejs +7 -0
@@ -1,9 +1,10 @@
1
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
2
2
  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 { join, dirname, basename } from "node:path";
6
+ import { Worker } from "node:worker_threads";
7
+ import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
9
10
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.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";
@@ -40,7 +41,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
40
41
  import { TmuxControlClient } from "./tmux-control.js";
41
42
  import { safeHandler } from "./safe-async.js";
42
43
  import { RoutingEngine } from "./routing-engine.js";
43
- import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
44
+ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
44
45
  import { TopicArchiver } from "./topic-archiver.js";
45
46
  import { StatuslineWatcher } from "./statusline-watcher.js";
46
47
  import { outboundHandlers } from "./outbound-handlers.js";
@@ -65,7 +66,9 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
65
66
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
66
67
  import { validateFleetConfig } from "./config-validator.js";
67
68
  import { readLastInboundAt } from "./daemon.js";
68
- import { clearPausedMarker } from "./pause-marker.js";
69
+ import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
70
+ import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
71
+ import { resolveDeliveryWorkerMode } from "./types.js";
69
72
  import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
70
73
  import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
71
74
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
@@ -285,6 +288,8 @@ export class FleetManager {
285
288
  managerBootId = randomUUID();
286
289
  deliveryOutbox = null;
287
290
  deliveryOutboxRecovered = false;
291
+ /** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
292
+ wakeCoordinator = null;
288
293
  deliveryPumpScheduled = false;
289
294
  deliveryPumpRunning = false;
290
295
  deliveryPumpTimer = null;
@@ -569,9 +574,15 @@ export class FleetManager {
569
574
  this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
570
575
  this.deliveryOutboxRecovered = true;
571
576
  }
572
- outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
577
+ outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
573
578
  outbox.on("state", (event) => {
574
579
  this.scheduleDeliveryOutboxPump();
580
+ this.wakeCoordinator?.kick();
581
+ if (event.deliveryId && event.state === "delivered") {
582
+ const target = outbox.get(event.deliveryId)?.targetInstance;
583
+ if (target)
584
+ this.wakeCoordinator?.noteDelivered(target);
585
+ }
575
586
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
576
587
  const row = outbox.get(event.deliveryId);
577
588
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
@@ -590,8 +601,11 @@ export class FleetManager {
590
601
  outbox.on("content_digest_mismatch", (event) => {
591
602
  this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
592
603
  });
604
+ this.wakeCoordinator = this.createWakeCoordinator();
605
+ this.wakeCoordinator.start();
593
606
  outbox.on("expired", (event) => {
594
607
  this.scheduleDeliveryOutboxPump();
608
+ this.wakeCoordinator?.kick();
595
609
  if (event.count)
596
610
  this.notifyFleetError(`${event.count} durable delivery row(s) reached the 24-hour outbox bound; ${event.uncertain ?? 0} unresolved submissions are uncertain and sender notices were queued.`);
597
611
  });
@@ -618,6 +632,9 @@ export class FleetManager {
618
632
  kind: input.kind,
619
633
  }, "Durable cross-instance delivery accepted");
620
634
  this.scheduleDeliveryOutboxPump();
635
+ // Phase 2b: a paused wake_only target is woken by the coordinator, never by
636
+ // the pump; admission must tell it, or the work waits for the watchdog.
637
+ this.wakeCoordinator?.kick();
621
638
  return {
622
639
  deliveryId: admitted.delivery.deliveryId,
623
640
  state: admitted.delivery.state,
@@ -680,7 +697,7 @@ export class FleetManager {
680
697
  }
681
698
  }
682
699
  scheduleDeliveryOutboxPump(delayMs = 0) {
683
- if (!this.deliveryOutbox || this.shuttingDown)
700
+ if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
684
701
  return;
685
702
  if (this.deliveryPumpTimer) {
686
703
  if (delayMs > 0)
@@ -700,14 +717,25 @@ export class FleetManager {
700
717
  }
701
718
  async runDeliveryOutboxPump() {
702
719
  const outbox = this.deliveryOutbox;
703
- if (!outbox || this.shuttingDown || this.deliveryPumpRunning)
720
+ // A closed database is never queried: this runs from timers, where a throw
721
+ // is an unhandled rejection. Checking here covers the finally block too:
722
+ // the body below never awaits, so the database cannot close in between.
723
+ if (!outbox || this.shuttingDown || this.deliveryPumpRunning || !outbox.isOpen)
704
724
  return;
705
725
  this.deliveryPumpRunning = true;
706
726
  this.deliveryPumpScheduled = false;
707
727
  try {
708
- outbox.expireStale();
728
+ outbox.expireStale(undefined, undefined, target => {
729
+ const failure = this.wakeCoordinator?.wakeFailure(target);
730
+ return failure ? `target could not be woken (${failure})` : undefined;
731
+ });
709
732
  while (this.activeDurableTargets.size < 8) {
710
- const claimed = outbox.claimNext(this.managerBootId, target => this.isInstanceRestarting(target) ? null : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
733
+ const claimed = outbox.claimNext(this.managerBootId,
734
+ // Phase 2b: a wake_only target is claimed only once it is awake; the
735
+ // wake coordinator, not this pump, wakes it (no claim, no attempt).
736
+ target => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
737
+ ? null
738
+ : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
711
739
  if (!claimed)
712
740
  break;
713
741
  this.activeDurableTargets.add(claimed.targetInstance);
@@ -761,7 +789,7 @@ export class FleetManager {
761
789
  content: payload.content,
762
790
  delivery_id: claimed.deliveryId,
763
791
  delivery_attempt: String(claimed.attemptNo),
764
- }, { waitForIdle: false });
792
+ }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
765
793
  }
766
794
  else {
767
795
  payload.meta = {
@@ -776,6 +804,7 @@ export class FleetManager {
776
804
  sent = await this.deliverToInstance(target, payload, {
777
805
  isCrossInstance: true,
778
806
  waitForIdle: claimed.kind !== "steer",
807
+ noInlineWake: this.deliveryWorkerMode(target) !== "off",
779
808
  });
780
809
  }
781
810
  if (!sent) {
@@ -1687,13 +1716,15 @@ export class FleetManager {
1687
1716
  exclude,
1688
1717
  isEvicting: name => this.warmCapEvicting.has(name),
1689
1718
  isGeneral: name => isGeneralInstance(this.fleetConfig, name),
1690
- isIdle: name => this.getInstanceExecutionState(name) === "idle",
1719
+ // A work lease (Phase 2a) keeps a just-woken or mid-submission instance
1720
+ // out of the eviction candidates; it is not idle in the sense that matters.
1721
+ isIdle: name => this.getInstanceExecutionState(name) === "idle" && !this.lifecycle.hasWorkLease(name),
1691
1722
  lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
1692
1723
  });
1693
1724
  for (const victim of victims) {
1694
1725
  this.warmCapEvicting.add(victim);
1695
1726
  this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
1696
- this.lifecycle.pause(victim)
1727
+ this.lifecycle.pause(victim, "warm_cap")
1697
1728
  .catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
1698
1729
  .finally(() => this.warmCapEvicting.delete(victim));
1699
1730
  }
@@ -1729,7 +1760,10 @@ export class FleetManager {
1729
1760
  const query = () => {
1730
1761
  const ipc = this.instanceIpcClients.get(instanceName);
1731
1762
  if (ipc?.connected) {
1732
- ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}` });
1763
+ // The daemon owns the backend/TTY proof and its 10s startup-input
1764
+ // timer. A cache-only query can never discover a Context-less idle
1765
+ // pane, and a second fleet timer would stack another 10s wait.
1766
+ ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}`, deliveryIdle: true });
1733
1767
  }
1734
1768
  check();
1735
1769
  };
@@ -1744,8 +1778,8 @@ export class FleetManager {
1744
1778
  /**
1745
1779
  * Ask the daemon to capture the pane before answering, then wait for any state
1746
1780
  * report produced after this request. The ordinary query is intentionally
1747
- * cache-only (idle gates call it frequently); this opt-in refresh is reserved
1748
- * for lifecycle decisions where a stale state would strand UI.
1781
+ * cache-only; delivery idle gates opt into their own pane/input proof, and
1782
+ * this refresh serves lifecycle decisions where a stale state would strand UI.
1749
1783
  */
1750
1784
  refreshInstanceExecutionState(instanceName, timeoutMs) {
1751
1785
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -1783,16 +1817,22 @@ export class FleetManager {
1783
1817
  finish(false);
1784
1818
  });
1785
1819
  }
1786
- async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1820
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
1787
1821
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1788
1822
  return false;
1789
1823
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
1790
1824
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1791
1825
  return false;
1792
1826
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
1827
+ if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
1828
+ // Paused after its row was claimed (an operator or auth pause): hand the
1829
+ // row back; the coordinator decides whether and when to wake it.
1830
+ this.wakeCoordinator?.kick();
1831
+ return false;
1832
+ }
1793
1833
  if (this.lifecycle.isPaused(instanceName)) {
1794
1834
  const wakeStartedAt = Date.now();
1795
- await this.lifecycle.wake(instanceName, 30_000);
1835
+ await this.explicitWake(instanceName, 30_000);
1796
1836
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1797
1837
  return false;
1798
1838
  // Waking added one to the warm count — make room by evicting a different
@@ -1915,7 +1955,11 @@ export class FleetManager {
1915
1955
  ?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
1916
1956
  if (!waitForIdle) {
1917
1957
  if (this.lifecycle.isPaused(instanceName)) {
1918
- await this.lifecycle.wake(instanceName, 30_000);
1958
+ if (options.noInlineWake) {
1959
+ this.wakeCoordinator?.kick();
1960
+ return false;
1961
+ }
1962
+ await this.explicitWake(instanceName, 30_000);
1919
1963
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1920
1964
  return false;
1921
1965
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
@@ -1931,7 +1975,7 @@ export class FleetManager {
1931
1975
  return true;
1932
1976
  }
1933
1977
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
1934
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
1978
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
1935
1979
  this.idleGatedDeliveryTails.set(instanceName, delivery);
1936
1980
  try {
1937
1981
  return await delivery;
@@ -1947,9 +1991,117 @@ export class FleetManager {
1947
1991
  const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
1948
1992
  return allowed.some(entry => String(entry) === String(userId));
1949
1993
  }
1994
+ /** Phase 2: delivery_worker for a target (instance override → fleet default → off). */
1995
+ deliveryWorkerMode(target) {
1996
+ return resolveDeliveryWorkerMode(this.fleetConfig, target);
1997
+ }
1998
+ /** Active instances mid-restart: they keep their warm slot through the stop → replacement gap. */
1999
+ restartsHoldingSlot = new Set();
2000
+ /**
2001
+ * Instances counted against the wake coordinator's hard cap: running
2002
+ * daemons, plus active instances whose restart is between stopping the old
2003
+ * daemon and publishing the replacement.
2004
+ */
2005
+ warmInstanceNames() {
2006
+ const names = new Set([...this.daemons.keys()].filter(name => this.getInstanceStatus(name) === "running"));
2007
+ for (const name of this.restartsHoldingSlot)
2008
+ names.add(name);
2009
+ return [...names];
2010
+ }
2011
+ createWakeCoordinator() {
2012
+ return new WakeCoordinator({
2013
+ mode: target => this.deliveryWorkerMode(target),
2014
+ available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
2015
+ listPending: () => this.deliveryOutbox?.listPending() ?? [],
2016
+ isPaused: target => this.lifecycle.isPaused(target),
2017
+ pauseReason: target => readPauseReason(this.getInstanceDir(target)),
2018
+ isRestarting: target => this.isInstanceRestarting(target),
2019
+ wake: async (target) => {
2020
+ await this.lifecycle.wake(target, 30_000, undefined, { source: "coordinator" });
2021
+ // The soft cap still applies: evict a different, unleased idle instance.
2022
+ this.enforceWarmCap(target);
2023
+ },
2024
+ residentNames: () => this.warmInstanceNames(),
2025
+ warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
2026
+ warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
2027
+ wokeAt: target => this.lifecycle.wokeAtFor(target),
2028
+ notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
2029
+ notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
2030
+ notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
2031
+ kickPump: () => this.scheduleDeliveryOutboxPump(),
2032
+ now: () => Date.now(),
2033
+ logger: this.logger,
2034
+ });
2035
+ }
2036
+ /** LifecycleContext: an operator or user wake clears the coordinator's park/backoff for it. */
2037
+ onExternalWake(name) {
2038
+ this.wakeCoordinator?.noteExternalWake(name);
2039
+ }
2040
+ /**
2041
+ * Phase 2b shared warm-slot admission (design §1.3, §1.6) for every wake
2042
+ * that is not the coordinator's own: an operator's wake, a user's message,
2043
+ * an explicit start or a restart of a paused instance. With delivery_worker
2044
+ * off, or for a target that is not paused (an active restart is already
2045
+ * counted), nothing is reserved. A target already being woken is joined —
2046
+ * its flight holds the slot — rather than reserved twice. Otherwise a slot
2047
+ * is taken under the hard cap, an unleased idle instance is paused to make
2048
+ * one, and if none can be, the wake is refused instead of exceeding the cap.
2049
+ */
2050
+ async acquireWakeSlot(name) {
2051
+ const coordinator = this.wakeCoordinator;
2052
+ if (!coordinator || this.deliveryWorkerMode(name) === "off" || !this.lifecycle.isPaused(name))
2053
+ return null;
2054
+ if (coordinator.isWaking(name))
2055
+ return null;
2056
+ let token = coordinator.tryReserve(name);
2057
+ if (!token) {
2058
+ const victim = this.lruUnleasedIdle(name);
2059
+ if (victim) {
2060
+ await this.lifecycle.pause(victim, "warm_cap");
2061
+ if (coordinator.isWaking(name))
2062
+ return null;
2063
+ token = coordinator.tryReserve(name);
2064
+ }
2065
+ }
2066
+ if (!token) {
2067
+ throw new Error(`No warm slot is free for '${name}' (warm_cap + overflow reached and no idle instance can be paused); try again when one goes idle`);
2068
+ }
2069
+ return token;
2070
+ }
2071
+ releaseWakeSlot(name, token) {
2072
+ if (!token || !this.wakeCoordinator)
2073
+ return;
2074
+ this.wakeCoordinator.release(name, token);
2075
+ this.wakeCoordinator.kick();
2076
+ }
2077
+ /**
2078
+ * An operator's or user's wake (/wake, wake_instance, start_instance on a
2079
+ * paused instance, Settings, a channel message). With delivery_worker off it
2080
+ * is exactly the old lifecycle wake; otherwise it goes through the shared
2081
+ * slot admission and joins any wake already in flight for the target.
2082
+ */
2083
+ async explicitWake(name, timeoutMs = 30_000) {
2084
+ const token = await this.acquireWakeSlot(name);
2085
+ try {
2086
+ await this.lifecycle.wake(name, timeoutMs);
2087
+ }
2088
+ finally {
2089
+ this.releaseWakeSlot(name, token);
2090
+ }
2091
+ }
2092
+ /** The least-recently-active idle, unleased, non-general running instance other than `exclude`. */
2093
+ lruUnleasedIdle(exclude) {
2094
+ return this.warmInstanceNames()
2095
+ .filter(name => name !== exclude
2096
+ && !this.warmCapEvicting.has(name)
2097
+ && !isGeneralInstance(this.fleetConfig, name)
2098
+ && this.getInstanceExecutionState(name) === "idle"
2099
+ && !this.lifecycle.hasWorkLease(name))
2100
+ .sort((a, b) => (readLastInboundAt(this.getInstanceDir(a)) ?? 0) - (readLastInboundAt(this.getInstanceDir(b)) ?? 0))[0];
2101
+ }
1950
2102
  async changeInstancePauseState(name, action) {
1951
2103
  if (action === "wake") {
1952
- await this.lifecycle.wake(name, 30_000);
2104
+ await this.explicitWake(name, 30_000);
1953
2105
  this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
1954
2106
  return "awake";
1955
2107
  }
@@ -2068,12 +2220,14 @@ export class FleetManager {
2068
2220
  * and reconcile calls leave this false so a persisted pause remains paused
2069
2221
  * across a fleet restart.
2070
2222
  */
2071
- resumePaused = false) {
2223
+ resumePaused = false,
2224
+ /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
2225
+ transition) {
2072
2226
  // Any start supersedes a pending automatic retry (it would otherwise fire
2073
2227
  // into a running instance — harmless, but noisy — or race this start).
2074
2228
  this.cancelStartupRetry(name);
2075
2229
  if (resumePaused && this.lifecycle.isPaused(name)) {
2076
- await this.lifecycle.wake(name, 30_000);
2230
+ await this.explicitWake(name, 30_000);
2077
2231
  // A successful wake clears the persisted pause marker and produces a
2078
2232
  // fresh instance_state snapshot. Drop any stale process error left by a
2079
2233
  // pre-pause crash so /api/fleet and `agend ls` converge on running.
@@ -2100,6 +2254,10 @@ export class FleetManager {
2100
2254
  this.logger.info({ name }, "Instance already running, skipping");
2101
2255
  return;
2102
2256
  }
2257
+ // An explicit start (CLI/API) begins a fresh idle window; boot and
2258
+ // reconcile starts keep the persisted last-inbound seed (Phase 2a).
2259
+ if (resumePaused)
2260
+ this.lifecycle.markActivitySeedNow(name);
2103
2261
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
2104
2262
  if (config.general_topic) {
2105
2263
  this.ensureGeneralInstructions(config.working_directory, backend, name);
@@ -2123,7 +2281,7 @@ export class FleetManager {
2123
2281
  kind,
2124
2282
  backend,
2125
2283
  model: this.resolveInstanceModel(name).display,
2126
- });
2284
+ }, transition);
2127
2285
  // Only clear a stale process status after a real start succeeded. Clearing
2128
2286
  // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
2129
2287
  // a falsely running instance when lifecycle.start() returns early.
@@ -2137,18 +2295,18 @@ export class FleetManager {
2137
2295
  this.requestDiscordUsagePresenceRefresh();
2138
2296
  }
2139
2297
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
2140
- async startPersistedPausedInstance(name) {
2298
+ async startPersistedPausedInstance(name, transition) {
2141
2299
  const topicMode = this.fleetConfig?.channel?.mode === "topic"
2142
2300
  || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
2143
2301
  const fleetConfig = this.fleetConfig?.instances[name];
2144
2302
  if (fleetConfig) {
2145
- await this.startInstance(name, fleetConfig, topicMode);
2303
+ await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
2146
2304
  return;
2147
2305
  }
2148
2306
  const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
2149
2307
  if (!channel || !this.classicChannels)
2150
2308
  throw new Error(`Paused instance '${name}' is no longer configured`);
2151
- await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
2309
+ await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition);
2152
2310
  }
2153
2311
  /**
2154
2312
  * Start instances with configurable concurrency and stagger delay.
@@ -2197,6 +2355,10 @@ export class FleetManager {
2197
2355
  return this.daemons.has(name);
2198
2356
  }
2199
2357
  catch (err) {
2358
+ // Superseded by a stop/restart (Phase 2a): that transition owns the
2359
+ // outcome. Retrying would start an instance the operator just stopped.
2360
+ if (err instanceof SupersededStartError)
2361
+ return false;
2200
2362
  this.logger.error({ err, name }, `Failed to start ${what}`);
2201
2363
  this.scheduleStartupRetry(name, 0);
2202
2364
  return false;
@@ -2318,6 +2480,9 @@ export class FleetManager {
2318
2480
  }
2319
2481
  }
2320
2482
  catch (err) {
2483
+ // Superseded by a stop/restart: that transition owns the outcome.
2484
+ if (err instanceof SupersededStartError)
2485
+ return;
2321
2486
  this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
2322
2487
  this.scheduleStartupRetry(name, attempt + 1);
2323
2488
  }
@@ -2358,6 +2523,8 @@ export class FleetManager {
2358
2523
  return this.daemons.has(ch.instanceName);
2359
2524
  }
2360
2525
  catch (err) {
2526
+ if (err instanceof SupersededStartError)
2527
+ return false;
2361
2528
  this.logger.warn({ err, instanceName: ch.instanceName }, `Failed to start ${what}`);
2362
2529
  this.scheduleStartupRetry(ch.instanceName, 0);
2363
2530
  return false;
@@ -2514,7 +2681,7 @@ export class FleetManager {
2514
2681
  }
2515
2682
  return false;
2516
2683
  }
2517
- async stopInstance(name) {
2684
+ async stopInstance(name, transition) {
2518
2685
  this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
2519
2686
  this.cancelStartupRetry(name);
2520
2687
  this.failoverActive.delete(name);
@@ -2533,7 +2700,7 @@ export class FleetManager {
2533
2700
  // the hand-off snapshotted it).
2534
2701
  const run = (async () => {
2535
2702
  try {
2536
- await this.lifecycle.stop(name);
2703
+ await this.lifecycle.stop(name, transition);
2537
2704
  }
2538
2705
  finally {
2539
2706
  this.clearNoncePromptsForInstance(name);
@@ -2555,11 +2722,16 @@ export class FleetManager {
2555
2722
  return inFlight;
2556
2723
  }
2557
2724
  const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
2558
- const run = this.spawnGate.run({
2725
+ // Phase 2a: invalidate synchronously so a start still spawning does not
2726
+ // publish, then queue behind it as this instance's next transition. The
2727
+ // spawn gate is taken inside the transition, never the other way round, so
2728
+ // the start being waited for can still get its own gate slot.
2729
+ this.lifecycle.invalidate(name);
2730
+ const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
2559
2731
  instanceName: name,
2560
2732
  workingDirectory,
2561
2733
  reason: "restart",
2562
- }, () => this.doRestartSingleInstance(name, opts))
2734
+ }, () => this.doRestartSingleInstance(name, opts, transition)))
2563
2735
  .finally(() => {
2564
2736
  this.restartsInFlight.delete(name);
2565
2737
  this.scheduleDeliveryOutboxPump();
@@ -2570,19 +2742,73 @@ export class FleetManager {
2570
2742
  isInstanceRestarting(name) {
2571
2743
  return this.restartsInFlight.has(name);
2572
2744
  }
2573
- async doRestartSingleInstance(name, opts) {
2745
+ /** InstanceLifecycleContext: an explicit wake joins the restart in flight. */
2746
+ restartInFlight(name) {
2747
+ return this.restartsInFlight.get(name);
2748
+ }
2749
+ /**
2750
+ * Phase 2a: restarting a paused instance is a wake. The stop leaves the
2751
+ * pause marker in place, and the start used to skip on it ("Persisted
2752
+ * paused instance"), turning a resident pause into a marker-only one that
2753
+ * nothing would wake. Clear it before the start; if the start fails, put it
2754
+ * back so the instance stays wakeable instead of becoming plain stopped.
2755
+ */
2756
+ async startAfterRestart(name, wasPaused, pausedAt, reason, start) {
2757
+ const instanceDir = this.getInstanceDir(name);
2758
+ if (wasPaused)
2759
+ clearPausedMarker(instanceDir);
2760
+ this.lifecycle.markActivitySeedNow(name);
2761
+ try {
2762
+ await start();
2763
+ }
2764
+ catch (err) {
2765
+ if (wasPaused && !this.daemons.has(name))
2766
+ writePausedMarker(instanceDir, pausedAt ?? Date.now(), reason);
2767
+ throw err;
2768
+ }
2769
+ finally {
2770
+ this.lifecycle.clearActivitySeedNow(name);
2771
+ }
2772
+ }
2773
+ async doRestartSingleInstance(name, opts, transition) {
2574
2774
  if (this.configPath) {
2575
2775
  this.loadConfig(this.configPath);
2576
2776
  this.routing.rebuild(this.fleetConfig);
2577
2777
  this.reregisterClassicChannels();
2578
2778
  }
2579
2779
  const config = this.fleetConfig?.instances[name];
2780
+ const wasPaused = this.lifecycle.isPaused(name);
2781
+ // A paused instance's restart is a wake: it takes a warm slot like any other
2782
+ // (refused before anything is stopped when none can be had). An active
2783
+ // restart is already counted and reserves nothing.
2784
+ const slot = await this.acquireWakeSlot(name);
2785
+ // An active instance keeps the slot it already occupies across its own
2786
+ // stop → replacement gap: counted as warm until the replacement is
2787
+ // published or the restart settles, so no other target can take it.
2788
+ if (!wasPaused)
2789
+ this.restartsHoldingSlot.add(name);
2790
+ try {
2791
+ await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
2792
+ // An explicit restart of a paused instance is an operator wake (§1.6):
2793
+ // its success lifts the coordinator's park/backoff (the level stays).
2794
+ // Automatic restarts never do.
2795
+ if (wasPaused && opts?.explicit && this.daemons.has(name))
2796
+ this.wakeCoordinator?.noteExternalWake(name);
2797
+ }
2798
+ finally {
2799
+ this.restartsHoldingSlot.delete(name);
2800
+ this.releaseWakeSlot(name, slot);
2801
+ }
2802
+ }
2803
+ async doRestartWithSlot(name, opts, transition, config, wasPaused) {
2804
+ const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
2805
+ const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
2580
2806
  if (config) {
2581
- await this.stopInstance(name);
2807
+ await this.stopInstance(name, transition);
2582
2808
  if (opts?.freshStart)
2583
2809
  this.writeFreshStartMarker(name);
2584
2810
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
2585
- await this.startInstance(name, config, topicMode ?? false);
2811
+ await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
2586
2812
  this.stormWindow.markRecovered(name);
2587
2813
  return;
2588
2814
  }
@@ -2591,11 +2817,11 @@ export class FleetManager {
2591
2817
  if (channelId) {
2592
2818
  const fleetBackend = this.fleetConfig?.defaults?.backend;
2593
2819
  const adapterId = this.classicChannels.getAdapterIdByInstance(name);
2594
- await this.stopInstance(name);
2820
+ await this.stopInstance(name, transition);
2595
2821
  await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
2596
2822
  if (opts?.freshStart)
2597
2823
  this.writeFreshStartMarker(name);
2598
- await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
2824
+ await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
2599
2825
  this.stormWindow.markRecovered(name);
2600
2826
  return;
2601
2827
  }
@@ -3915,7 +4141,7 @@ export class FleetManager {
3915
4141
  }
3916
4142
  else if (data.command === "sysinfo") {
3917
4143
  // Slash commands are Discord-only; use plain lines (no markdown table)
3918
- await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
4144
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
3919
4145
  }
3920
4146
  else if (data.command === "dashboard") {
3921
4147
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -4275,7 +4501,7 @@ export class FleetManager {
4275
4501
  }
4276
4502
  else if (data.command === "sysinfo") {
4277
4503
  // Slash commands are Discord-only; use plain lines (no markdown table)
4278
- await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
4504
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
4279
4505
  }
4280
4506
  else if (data.command === "dashboard") {
4281
4507
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -5850,7 +6076,8 @@ export class FleetManager {
5850
6076
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
5851
6077
  : type === "fleet_task" ? "fleet_task_response"
5852
6078
  : type === "fleet_set_display_name" ? "fleet_display_name_response"
5853
- : "fleet_description_response";
6079
+ : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
6080
+ : "fleet_description_response";
5854
6081
  ipc.send({ type: responseType, fleetRequestId, error: message });
5855
6082
  }
5856
6083
  dispatchTypedIpc(name, msg) {
@@ -5875,6 +6102,24 @@ export class FleetManager {
5875
6102
  this.handleSetDescription(name, msg);
5876
6103
  return;
5877
6104
  }
6105
+ if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
6106
+ this.handlePersonaEmoji(name, msg);
6107
+ return;
6108
+ }
6109
+ }
6110
+ /** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
6111
+ handlePersonaEmoji(instanceName, msg) {
6112
+ const fleetRequestId = msg.fleetRequestId;
6113
+ const payload = (msg.payload ?? {});
6114
+ const ipc = this.instanceIpcClients.get(instanceName);
6115
+ if (!ipc)
6116
+ return;
6117
+ const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
6118
+ : msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
6119
+ : this.setPersonaEmoji(instanceName, payload);
6120
+ op.then((r) => typeof r.error === "string"
6121
+ ? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
6122
+ : ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
5878
6123
  }
5879
6124
  handleScheduleCrud(instanceName, msg) {
5880
6125
  const fleetRequestId = msg.fleetRequestId;
@@ -5979,10 +6224,15 @@ export class FleetManager {
5979
6224
  ?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
5980
6225
  ?? instanceName;
5981
6226
  }
6227
+ /** Inherited keys such as __proto__ must never resolve to a mutable instance. */
6228
+ ownInstanceConfig(instanceName) {
6229
+ const instances = this.fleetConfig?.instances;
6230
+ return instances && Object.hasOwn(instances, instanceName) ? instances[instanceName] : undefined;
6231
+ }
5982
6232
  /** Persist identity to the instance's actual config store. Classic instances
5983
6233
  * are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
5984
6234
  setInstanceDisplayName(instanceName, displayName) {
5985
- const fleetInstance = this.fleetConfig?.instances[instanceName];
6235
+ const fleetInstance = this.ownInstanceConfig(instanceName);
5986
6236
  if (fleetInstance) {
5987
6237
  fleetInstance.display_name = displayName;
5988
6238
  this.saveFleetConfig();
@@ -5991,7 +6241,7 @@ export class FleetManager {
5991
6241
  return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
5992
6242
  }
5993
6243
  setInstanceDescription(instanceName, description) {
5994
- const fleetInstance = this.fleetConfig?.instances[instanceName];
6244
+ const fleetInstance = this.ownInstanceConfig(instanceName);
5995
6245
  if (fleetInstance) {
5996
6246
  fleetInstance.description = description;
5997
6247
  this.saveFleetConfig();
@@ -6217,6 +6467,15 @@ export class FleetManager {
6217
6467
  return { error: `Instance '${instance}' not found` };
6218
6468
  return { display_name: name };
6219
6469
  }
6470
+ async handleListEmojisHttp(instance, refresh) {
6471
+ return this.listEmojisFor(instance, refresh);
6472
+ }
6473
+ async handleSetPersonaEmojiHttp(instance, args) {
6474
+ return this.setPersonaEmoji(instance, args);
6475
+ }
6476
+ async handlePreviewEmojisHttp(instance, args) {
6477
+ return this.previewEmojis(instance, args);
6478
+ }
6220
6479
  async handleSetDescriptionHttp(instance, description) {
6221
6480
  if (!this.fleetConfig)
6222
6481
  return { error: "Fleet config not available" };
@@ -6834,6 +7093,8 @@ export class FleetManager {
6834
7093
  deliveryStatusChains = new Map();
6835
7094
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6836
7095
  pendingCliEnvProbes = new Map();
7096
+ /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
7097
+ pendingVendorCliEnvProbes = new Map();
6837
7098
  /**
6838
7099
  * The status emojis for an instance (#1005): its own `status_emojis`, then
6839
7100
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -6864,10 +7125,15 @@ export class FleetManager {
6864
7125
  }
6865
7126
  warnedStatusEmojis = new Set();
6866
7127
  /**
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.
7128
+ * A Discord connection's server emojis for the Settings picker (#1005), from
7129
+ * every server its bot can draw on (#1021): the primary `group_id` server,
7130
+ * then each other server the bot is a member of that ClassicBot's
7131
+ * `allowed_guilds` admits (unset = all, as for classic routing). A bot can
7132
+ * react with a custom emoji from any server it is in. Cached per server — a
7133
+ * server's emoji set changes rarely — and one fetch per server at a time;
7134
+ * `refresh` forces new ones. One server refusing (e.g. Missing Access) is
7135
+ * reported on that server and does not hide the others. Uses the running
7136
+ * adapter, so the bot token never leaves the server.
6871
7137
  */
6872
7138
  async listGuildEmojis(channelId, refresh = false) {
6873
7139
  const world = this.worlds.get(channelId);
@@ -6878,28 +7144,268 @@ export class FleetManager {
6878
7144
  const adapter = world.adapter;
6879
7145
  if (!adapter.listGuildEmojis)
6880
7146
  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
- }
7147
+ const listEmojis = adapter.listGuildEmojis.bind(adapter);
7148
+ let servers;
6893
7149
  try {
6894
- return { ok: true, ...(await pending) };
7150
+ servers = adapter.listMemberGuilds
7151
+ ? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
7152
+ : [{ id: "", name: "", primary: true }];
6895
7153
  }
6896
7154
  catch (e) {
6897
- return { ok: false, error: `Discord refused the emoji list: ${e.message}` };
6898
- }
7155
+ return { ok: false, error: `Discord refused the server list: ${e.message}` };
7156
+ }
7157
+ const guilds = await Promise.all(servers.map(async (g) => {
7158
+ const key = `${channelId}\0${g.id}`;
7159
+ const cached = this.guildEmojiCache.get(key);
7160
+ if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
7161
+ return { ...g, ...cached };
7162
+ let pending = this.guildEmojiFetches.get(key);
7163
+ if (!pending) {
7164
+ pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
7165
+ const entry = { fetched_at: Date.now(), emojis };
7166
+ this.guildEmojiCache.set(key, entry);
7167
+ return entry;
7168
+ }).finally(() => this.guildEmojiFetches.delete(key));
7169
+ this.guildEmojiFetches.set(key, pending);
7170
+ }
7171
+ try {
7172
+ return { ...g, ...(await pending) };
7173
+ }
7174
+ catch (e) {
7175
+ return { ...g, error: `Discord refused the emoji list: ${e.message}` };
7176
+ }
7177
+ }));
7178
+ const listed = guilds.filter((g) => !!g.emojis);
7179
+ if (!listed.length)
7180
+ return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
7181
+ return {
7182
+ ok: true,
7183
+ fetched_at: Math.min(...listed.map(g => g.fetched_at)),
7184
+ emojis: listed.flatMap(g => g.emojis),
7185
+ guilds,
7186
+ };
6899
7187
  }
6900
7188
  static GUILD_EMOJI_TTL_MS = 10 * 60_000;
7189
+ /** Keyed `<connection>\0<server id>`. */
6901
7190
  guildEmojiCache = new Map();
6902
7191
  guildEmojiFetches = new Map();
7192
+ /**
7193
+ * What `instanceName` may pick as its persona emoji (the `list_emojis`
7194
+ * tool): its current status set with where each value comes from, the
7195
+ * standard emojis its platform takes, and on Discord the server emojis its
7196
+ * bot can draw on — the same lists, and the same resolution, as the
7197
+ * Settings picker (#1005/#1021), so an agent and an operator see one truth.
7198
+ */
7199
+ async listEmojisFor(instanceName, refresh = false) {
7200
+ const self = this.fleetConfig?.instances[instanceName];
7201
+ if (!self)
7202
+ return { error: this.personaEmojiMissing(instanceName) };
7203
+ const worldId = this.getInstanceAdapterId(instanceName);
7204
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7205
+ const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
7206
+ const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
7207
+ const out = {
7208
+ platform: platform ?? null,
7209
+ statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
7210
+ standard: platform === "telegram"
7211
+ ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
7212
+ : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
7213
+ };
7214
+ if (platform === "discord" && worldId) {
7215
+ const listed = await this.listGuildEmojis(worldId, refresh);
7216
+ out.server_emojis = listed.ok
7217
+ ? listed.guilds.map(g => g.emojis
7218
+ ? {
7219
+ server: g.name || g.id, primary: g.primary,
7220
+ emojis: g.emojis.filter(e => e.available).map(e => {
7221
+ const value = customEmojiValue(e);
7222
+ return { value, image_url: emojiImageUrl(value) };
7223
+ }),
7224
+ }
7225
+ : { server: g.name || g.id, primary: g.primary, error: g.error })
7226
+ : { error: listed.error };
7227
+ }
7228
+ return out;
7229
+ }
7230
+ /**
7231
+ * `preview_emojis` (#1040): download a few Discord server emojis so an agent
7232
+ * can look at them before it picks one — a name and an id say nothing about
7233
+ * what an emoji looks like. The fleet fetches, not the agent (not every
7234
+ * backend can), and returns a local path per emoji to Read, as
7235
+ * download_attachment does for an attachment.
7236
+ *
7237
+ * SSRF-safe by construction: nothing from the caller reaches a URL. The
7238
+ * caller names emojis; each must be an available one in a server this bot
7239
+ * can draw on (the list set_persona_emoji checks), and the CDN URL is built
7240
+ * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
7241
+ */
7242
+ async previewEmojis(instanceName, args) {
7243
+ const self = this.fleetConfig?.instances[instanceName];
7244
+ if (!self)
7245
+ return { error: this.personaEmojiMissing(instanceName) };
7246
+ const wanted = args.emojis;
7247
+ if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
7248
+ return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
7249
+ }
7250
+ if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
7251
+ return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
7252
+ }
7253
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7254
+ const worldId = this.getInstanceAdapterId(instanceName);
7255
+ if (platform !== "discord" || !worldId) {
7256
+ return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
7257
+ }
7258
+ const listed = await this.listGuildEmojis(worldId);
7259
+ if (!listed.ok)
7260
+ return { error: `cannot list this bot's server emojis: ${listed.error}` };
7261
+ const previews = [];
7262
+ const errors = [];
7263
+ for (const raw of wanted) {
7264
+ const e = normalizeEmoji(raw);
7265
+ if (e?.kind !== "custom") {
7266
+ errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
7267
+ continue;
7268
+ }
7269
+ const found = listed.emojis.find(g => g.id === e.id);
7270
+ if (!found?.available) {
7271
+ errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
7272
+ continue;
7273
+ }
7274
+ try {
7275
+ previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
7276
+ }
7277
+ catch (err) {
7278
+ errors.push({ emoji: raw, error: `download failed: ${err.message}` });
7279
+ }
7280
+ }
7281
+ return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
7282
+ }
7283
+ static EMOJI_PREVIEW_MAX = 8;
7284
+ static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
7285
+ static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
7286
+ /** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
7287
+ async fetchEmojiPreview(id) {
7288
+ if (!/^\d{15,25}$/.test(id))
7289
+ throw new Error("not a Discord emoji id");
7290
+ const dir = join(this.dataDir, "inbox", "emoji-previews");
7291
+ mkdirSync(dir, { recursive: true });
7292
+ const now = Date.now();
7293
+ for (const name of readdirSync(dir)) {
7294
+ try {
7295
+ if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
7296
+ unlinkSync(join(dir, name));
7297
+ }
7298
+ catch { /* raced */ }
7299
+ }
7300
+ const path = join(dir, `${id}.png`);
7301
+ if (existsSync(path))
7302
+ return path;
7303
+ const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
7304
+ if (!response.ok)
7305
+ throw new Error(`HTTP ${response.status}`);
7306
+ if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
7307
+ throw new Error("not a PNG");
7308
+ const reader = response.body?.getReader();
7309
+ if (!reader)
7310
+ throw new Error("empty response");
7311
+ const chunks = [];
7312
+ let size = 0;
7313
+ for (;;) {
7314
+ const { done, value } = await reader.read();
7315
+ if (done)
7316
+ break;
7317
+ size += value.byteLength;
7318
+ if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
7319
+ await reader.cancel();
7320
+ throw new Error("image too large");
7321
+ }
7322
+ chunks.push(value);
7323
+ }
7324
+ const tmp = `${path}.${process.pid}.tmp`;
7325
+ writeFileSync(tmp, Buffer.concat(chunks));
7326
+ renameSync(tmp, path);
7327
+ return path;
7328
+ }
7329
+ /**
7330
+ * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
7331
+ * `delivered` unless it names another — in its per-instance `status_emojis`
7332
+ * override (#1005 addendum 2), so its stamp is told apart from other bots'
7333
+ * on a shared message. The value is judged exactly as Settings and the
7334
+ * react path judge it (statusEmojiProblem on the instance's platform), and
7335
+ * a Discord server emoji must be one its bot can use: an emoji from a
7336
+ * server it is not in would be stored, preview fine, and fail every react.
7337
+ * An empty emoji removes that status's override.
7338
+ */
7339
+ async setPersonaEmoji(instanceName, args) {
7340
+ const self = this.ownInstanceConfig(instanceName);
7341
+ if (!self)
7342
+ return { error: this.personaEmojiMissing(instanceName) };
7343
+ const status = (args.status ?? "delivered");
7344
+ if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
7345
+ return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
7346
+ }
7347
+ // Only an explicit "" removes the override. The agent endpoint, typed IPC
7348
+ // and agent-cli do not run the MCP schema, so a call missing `emoji` must
7349
+ // be refused here, never read as "clear my delivered stamp".
7350
+ if (typeof args.emoji !== "string") {
7351
+ return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
7352
+ }
7353
+ const raw = args.emoji.trim();
7354
+ // Whitespace is not an explicit "": a stray " " must not clear the stamp either.
7355
+ if (!raw && args.emoji !== "") {
7356
+ return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
7357
+ }
7358
+ let value;
7359
+ if (raw) {
7360
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7361
+ const problem = statusEmojiProblem(platform, status, raw);
7362
+ if (problem)
7363
+ return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
7364
+ const e = normalizeEmoji(raw);
7365
+ if (e.kind === "custom") {
7366
+ const worldId = this.getInstanceAdapterId(instanceName);
7367
+ if (!worldId)
7368
+ return { error: "no connection to check that server emoji against" };
7369
+ const listed = await this.listGuildEmojis(worldId);
7370
+ if (!listed.ok)
7371
+ return { error: `cannot check that server emoji: ${listed.error}` };
7372
+ const found = listed.emojis.find(g => g.id === e.id);
7373
+ if (!found?.available) {
7374
+ 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.` };
7375
+ }
7376
+ value = customEmojiValue(found);
7377
+ }
7378
+ else {
7379
+ value = e.value;
7380
+ }
7381
+ }
7382
+ // Re-read after the validation above, which may have waited on Discord:
7383
+ // another set_persona_emoji or a Settings save can have changed another
7384
+ // status, or replaced this instance's config object, meanwhile. Change
7385
+ // only this one key on what is there now. Ownership must still hold:
7386
+ // removal during the await must not fall through to an inherited entry.
7387
+ const current = this.ownInstanceConfig(instanceName);
7388
+ if (!current)
7389
+ return { error: this.personaEmojiMissing(instanceName) };
7390
+ const map = { ...(current.status_emojis ?? {}) };
7391
+ if (value)
7392
+ map[status] = value;
7393
+ else
7394
+ delete map[status];
7395
+ if (Object.keys(map).length)
7396
+ current.status_emojis = map;
7397
+ else
7398
+ delete current.status_emojis;
7399
+ this.saveFleetConfig();
7400
+ this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
7401
+ const resolved = this.resolveStatusEmojisFor(instanceName);
7402
+ return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
7403
+ }
7404
+ personaEmojiMissing(instanceName) {
7405
+ return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
7406
+ ? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
7407
+ : `Instance '${instanceName}' not found`;
7408
+ }
6903
7409
  /** What the instructions tell `instanceName` not to react with (its own status set). */
6904
7410
  statusEmojiAvoidList(instanceName) {
6905
7411
  return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
@@ -9405,10 +9911,16 @@ export class FleetManager {
9405
9911
  }
9406
9912
  // The installer may only have added the binary to a profile PATH; a
9407
9913
  // fresh login shell sees that, the fleet process's PATH may not.
9408
- if (!this.verifyBinaryOnLoginShell(info.binary)) {
9914
+ const installedAt = this.locateBinaryOnLoginShell(info.binary);
9915
+ if (!installedAt) {
9409
9916
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
9410
9917
  return;
9411
9918
  }
9919
+ // #1059: make it reachable from this process too. `/login` lists what
9920
+ // `which` finds on the fleet's own PATH, and instances resolve their
9921
+ // binary the same way, so a binary only a login shell could see was
9922
+ // installed, reported as verified, and then offered nowhere.
9923
+ this.adoptBinaryDirectory(installedAt, backend);
9412
9924
  if (!LOGIN_FLOWS[backend]) {
9413
9925
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
9414
9926
  return;
@@ -9468,15 +9980,35 @@ export class FleetManager {
9468
9980
  return t("install.cancelled", backend);
9469
9981
  }
9470
9982
  /** `command -v` on a login shell, so PATH additions from rc files count. */
9471
- verifyBinaryOnLoginShell(binary) {
9983
+ /**
9984
+ * The absolute path a fresh login shell resolves `binary` to, or null. Only
9985
+ * a real executable file counts: `command -v` also answers with an alias
9986
+ * definition or a function name, neither of which a spawn could run.
9987
+ */
9988
+ locateBinaryOnLoginShell(binary) {
9472
9989
  try {
9473
- const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
9474
- return result.status === 0;
9990
+ const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" });
9991
+ if (result.status !== 0)
9992
+ return null;
9993
+ const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
9994
+ if (!isAbsolute(path))
9995
+ return null;
9996
+ accessSync(path, fsConstants.X_OK);
9997
+ return statSync(path).isFile() ? path : null;
9475
9998
  }
9476
9999
  catch {
9477
- return false;
10000
+ return null;
9478
10001
  }
9479
10002
  }
10003
+ /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
10004
+ adoptBinaryDirectory(binaryPath, backend) {
10005
+ const dir = dirname(binaryPath);
10006
+ const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
10007
+ if (entries.includes(dir))
10008
+ return;
10009
+ process.env.PATH = [dir, ...entries].join(delimiter);
10010
+ this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
10011
+ }
9480
10012
  /** "Sign in now?" button after a successful install. */
9481
10013
  async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
9482
10014
  const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
@@ -10325,6 +10857,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10325
10857
  catch { /* missing / stale / corrupt */ }
10326
10858
  return null;
10327
10859
  }
10860
+ /** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
10861
+ getBackendCliVersionSnapshot() {
10862
+ const snapshot = {};
10863
+ for (const backend of SYSINFO_BACKEND_IDS) {
10864
+ const cached = this.readCliEnv(backend);
10865
+ const needsRefresh = this.cliEnvNeedsRefresh(cached);
10866
+ const version = typeof cached?.version === "string" && cached.version.trim()
10867
+ ? cached.version.trim()
10868
+ : null;
10869
+ snapshot[backend] = {
10870
+ version,
10871
+ probing: needsRefresh && version === null,
10872
+ };
10873
+ }
10874
+ return snapshot;
10875
+ }
10876
+ /** Called only after the user-visible sysinfo send/respond has settled. */
10877
+ refreshBackendCliVersions() {
10878
+ for (const backend of SYSINFO_BACKEND_IDS) {
10879
+ if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
10880
+ continue;
10881
+ void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
10882
+ this.logger.debug({ err, backend }, "Background CLI version refresh failed");
10883
+ });
10884
+ }
10885
+ }
10328
10886
  /** True when a cached CLI env is old enough that `/model` should re-probe. */
10329
10887
  cliEnvNeedsRefresh(env) {
10330
10888
  return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
@@ -10335,33 +10893,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10335
10893
  * previous list, never stall the command that asked for it.
10336
10894
  */
10337
10895
  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);
10896
+ // #725: single-flight per backend. Explicit vendor refreshes remain a
10897
+ // separate flight from an ordinary probe, while sysinfo can join either.
10898
+ const regularFlight = this.pendingCliEnvProbes.get(backend);
10899
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
10900
+ if (opts.refreshVendorCatalog && vendorFlight)
10901
+ return vendorFlight;
10902
+ if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
10903
+ return vendorFlight ?? regularFlight;
10904
+ const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
10905
+ const work = worker?.promise ?? this.probeBackend(backend, opts);
10348
10906
  work.catch(() => { });
10349
10907
  let timer;
10350
10908
  const deadline = new Promise(resolve => {
10351
10909
  timer = setTimeout(() => {
10910
+ worker?.terminate();
10352
10911
  this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
10353
10912
  resolve(null);
10354
10913
  }, CLI_ENV_PROBE_DEADLINE_MS);
10355
10914
  });
10356
10915
  const bounded = Promise.race([work, deadline]).finally(() => {
10357
10916
  clearTimeout(timer);
10358
- if (this.pendingCliEnvProbes.get(backend) === bounded)
10359
- this.pendingCliEnvProbes.delete(backend);
10917
+ const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
10918
+ if (flights.get(backend) === bounded)
10919
+ flights.delete(backend);
10360
10920
  });
10361
- if (!opts.refreshVendorCatalog)
10362
- this.pendingCliEnvProbes.set(backend, bounded);
10921
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
10363
10922
  return bounded;
10364
10923
  }
10924
+ startCliEnvProbeWorker(backend, opts) {
10925
+ const workerData = {
10926
+ backend,
10927
+ instanceDir: join(getAgendHome(), "cli-env"),
10928
+ config: {
10929
+ workingDirectory: "",
10930
+ instanceDir: join(getAgendHome(), "cli-env"),
10931
+ instanceName: `probe-${backend}`,
10932
+ mcpServers: {},
10933
+ },
10934
+ refreshVendorCatalog: opts.refreshVendorCatalog === true,
10935
+ };
10936
+ // `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
10937
+ // Published builds load the compiled worker and require no tsx dependency.
10938
+ const worker = import.meta.url.endsWith(".ts")
10939
+ ? 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 } })
10940
+ : new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
10941
+ let settled = false;
10942
+ let resolveProbe;
10943
+ const promise = new Promise(resolve => { resolveProbe = resolve; });
10944
+ const finish = (value) => {
10945
+ if (settled)
10946
+ return;
10947
+ settled = true;
10948
+ resolveProbe(value);
10949
+ void worker.terminate().catch(err => {
10950
+ this.logger.debug({ backend, err }, "CLI env worker termination failed");
10951
+ });
10952
+ };
10953
+ worker.once("message", (message) => {
10954
+ if (settled)
10955
+ return;
10956
+ if (!message.ok) {
10957
+ this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
10958
+ finish(null);
10959
+ }
10960
+ else if (!message.result) {
10961
+ finish(null);
10962
+ }
10963
+ else {
10964
+ try {
10965
+ finish(this.persistCliEnvProbeResult(backend, message.result));
10966
+ }
10967
+ catch (err) {
10968
+ this.logger.warn({ backend, err }, "CLI env cache write failed");
10969
+ finish(null);
10970
+ }
10971
+ }
10972
+ });
10973
+ worker.once("error", err => {
10974
+ if (settled)
10975
+ return;
10976
+ this.logger.warn({ backend, err }, "CLI env worker crashed");
10977
+ finish(null);
10978
+ });
10979
+ worker.once("exit", code => {
10980
+ if (settled)
10981
+ return;
10982
+ this.logger.warn({ backend, code }, "CLI env worker exited without a result");
10983
+ finish(null);
10984
+ });
10985
+ return { promise, terminate: () => { finish(null); } };
10986
+ }
10365
10987
  /**
10366
10988
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
10367
10989
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -10428,35 +11050,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10428
11050
  if (opts.refreshVendorCatalog && be.refreshModelCatalog)
10429
11051
  await be.refreshModelCatalog();
10430
11052
  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;
11053
+ return this.persistCliEnvProbeResult(backend, probed);
10454
11054
  }
10455
11055
  catch (err) {
10456
11056
  this.logger.warn({ err, backend }, "CLI env probe failed");
10457
11057
  return null;
10458
11058
  }
10459
11059
  }
11060
+ /** Preserve the existing cache merge rules for both in-process and worker probes. */
11061
+ persistCliEnvProbeResult(backend, probed) {
11062
+ const env = { backend, probedAt: Date.now(), ...probed };
11063
+ // An empty result must never overwrite a catalog we already have. Some
11064
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
11065
+ // returns [] — and writing that would blank the list for the whole 24h
11066
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
11067
+ // antigravity cache replaced by an empty one. Keep the known models and
11068
+ // let the fresher currentModel/version through.
11069
+ if (!env.models?.length) {
11070
+ const previous = this.readCliEnv(backend);
11071
+ if (previous?.models?.length)
11072
+ env.models = previous.models;
11073
+ }
11074
+ // Same protection for the extended catalog: one offline moment must not
11075
+ // blank a good account list for the whole cache TTL.
11076
+ if (!env.apiModels?.length) {
11077
+ const previous = this.readCliEnv(backend);
11078
+ if (previous?.apiModels?.length)
11079
+ env.apiModels = previous.apiModels;
11080
+ }
11081
+ const path = this.cliEnvPath(backend);
11082
+ mkdirSync(dirname(path), { recursive: true });
11083
+ writeFileSync(path, JSON.stringify(env, null, 2));
11084
+ return env;
11085
+ }
10460
11086
  /** Background-probe every distinct backend in use at startup (non-blocking). */
10461
11087
  probeCliEnvs() {
10462
11088
  const backends = new Set();
@@ -11429,7 +12055,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11429
12055
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
11430
12056
  }
11431
12057
  /** Start a classic channel instance with lightweight config */
11432
- async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
12058
+ async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
11433
12059
  if (this.daemons.has(instanceName))
11434
12060
  return;
11435
12061
  const workDir = join(getAgendHome(), "workspaces", instanceName);
@@ -11456,7 +12082,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11456
12082
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
11457
12083
  };
11458
12084
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
11459
- await this.startInstance(instanceName, config, topicMode, "classic");
12085
+ await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
11460
12086
  }
11461
12087
  /** Handle /start slash command — register classic channel */
11462
12088
  async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
@@ -11568,6 +12194,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11568
12194
  clearInterval(this.replyObligationTimer);
11569
12195
  this.replyObligationTimer = null;
11570
12196
  }
12197
+ this.wakeCoordinator?.stop();
11571
12198
  if (this.logRotateTimer) {
11572
12199
  clearInterval(this.logRotateTimer);
11573
12200
  this.logRotateTimer = null;
@@ -13629,7 +14256,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13629
14256
  this.logger.info({ name }, "Instance restart requested via HTTP");
13630
14257
  (async () => {
13631
14258
  try {
13632
- await this.restartSingleInstance(name);
14259
+ await this.restartSingleInstance(name, { explicit: true });
13633
14260
  this.logger.info({ name }, "Instance restarted");
13634
14261
  this.emitSseEvent("status", this.getUiStatus());
13635
14262
  res.writeHead(200);