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

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 (119) 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 +165 -18
  53. package/dist/fleet-manager.js +821 -121
  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 +55 -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 +23 -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 +28 -1
  90. package/dist/status-emojis.js +11 -1
  91. package/dist/status-emojis.js.map +1 -1
  92. package/dist/target-queue-worker.d.ts +67 -0
  93. package/dist/target-queue-worker.js +76 -0
  94. package/dist/target-queue-worker.js.map +1 -0
  95. package/dist/tool-permissions.js +15 -4
  96. package/dist/tool-permissions.js.map +1 -1
  97. package/dist/topic-commands.d.ts +9 -0
  98. package/dist/topic-commands.js +64 -21
  99. package/dist/topic-commands.js.map +1 -1
  100. package/dist/transcript-monitor.js +3 -0
  101. package/dist/transcript-monitor.js.map +1 -1
  102. package/dist/transcript-sources.d.ts +63 -0
  103. package/dist/transcript-sources.js +171 -27
  104. package/dist/transcript-sources.js.map +1 -1
  105. package/dist/types.d.ts +26 -1
  106. package/dist/types.js +9 -1
  107. package/dist/types.js.map +1 -1
  108. package/dist/ui/settings.html +25 -13
  109. package/dist/wake-coordinator.d.ts +143 -0
  110. package/dist/wake-coordinator.js +321 -0
  111. package/dist/wake-coordinator.js.map +1 -0
  112. package/dist/web-api.d.ts +3 -1
  113. package/dist/web-api.js +1 -1
  114. package/dist/web-api.js.map +1 -1
  115. package/dist/web-terminal.d.ts +2 -0
  116. package/dist/web-terminal.js +3 -6
  117. package/dist/web-terminal.js.map +1 -1
  118. package/package.json +1 -1
  119. 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,10 @@ 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 { TargetQueueWorker } from "./target-queue-worker.js";
72
+ import { resolveDeliveryWorkerMode } from "./types.js";
69
73
  import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
70
74
  import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
71
75
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
@@ -285,6 +289,14 @@ export class FleetManager {
285
289
  managerBootId = randomUUID();
286
290
  deliveryOutbox = null;
287
291
  deliveryOutboxRecovered = false;
292
+ /** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
293
+ wakeCoordinator = null;
294
+ /**
295
+ * Phase 2c: targets whose durable lane a TargetQueueWorker owns
296
+ * (delivery_worker: on). Granted and released only in the pump's
297
+ * synchronous section and only while the lane is empty; the pump skips them.
298
+ */
299
+ queueWorkers = new Map();
288
300
  deliveryPumpScheduled = false;
289
301
  deliveryPumpRunning = false;
290
302
  deliveryPumpTimer = null;
@@ -569,9 +581,15 @@ export class FleetManager {
569
581
  this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
570
582
  this.deliveryOutboxRecovered = true;
571
583
  }
572
- outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
584
+ outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
573
585
  outbox.on("state", (event) => {
574
586
  this.scheduleDeliveryOutboxPump();
587
+ this.wakeCoordinator?.kick();
588
+ if (event.deliveryId && event.state === "delivered") {
589
+ const target = outbox.get(event.deliveryId)?.targetInstance;
590
+ if (target)
591
+ this.wakeCoordinator?.noteDelivered(target);
592
+ }
575
593
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
576
594
  const row = outbox.get(event.deliveryId);
577
595
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
@@ -590,8 +608,11 @@ export class FleetManager {
590
608
  outbox.on("content_digest_mismatch", (event) => {
591
609
  this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
592
610
  });
611
+ this.wakeCoordinator = this.createWakeCoordinator();
612
+ this.wakeCoordinator.start();
593
613
  outbox.on("expired", (event) => {
594
614
  this.scheduleDeliveryOutboxPump();
615
+ this.wakeCoordinator?.kick();
595
616
  if (event.count)
596
617
  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
618
  });
@@ -618,6 +639,9 @@ export class FleetManager {
618
639
  kind: input.kind,
619
640
  }, "Durable cross-instance delivery accepted");
620
641
  this.scheduleDeliveryOutboxPump();
642
+ // Phase 2b: a paused wake_only target is woken by the coordinator, never by
643
+ // the pump; admission must tell it, or the work waits for the watchdog.
644
+ this.wakeCoordinator?.kick();
621
645
  return {
622
646
  deliveryId: admitted.delivery.deliveryId,
623
647
  state: admitted.delivery.state,
@@ -680,7 +704,7 @@ export class FleetManager {
680
704
  }
681
705
  }
682
706
  scheduleDeliveryOutboxPump(delayMs = 0) {
683
- if (!this.deliveryOutbox || this.shuttingDown)
707
+ if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
684
708
  return;
685
709
  if (this.deliveryPumpTimer) {
686
710
  if (delayMs > 0)
@@ -700,14 +724,27 @@ export class FleetManager {
700
724
  }
701
725
  async runDeliveryOutboxPump() {
702
726
  const outbox = this.deliveryOutbox;
703
- if (!outbox || this.shuttingDown || this.deliveryPumpRunning)
727
+ // A closed database is never queried: this runs from timers, where a throw
728
+ // is an unhandled rejection. Checking here covers the finally block too:
729
+ // the body below never awaits, so the database cannot close in between.
730
+ if (!outbox || this.shuttingDown || this.deliveryPumpRunning || !outbox.isOpen)
704
731
  return;
705
732
  this.deliveryPumpRunning = true;
706
733
  this.deliveryPumpScheduled = false;
707
734
  try {
708
- outbox.expireStale();
735
+ outbox.expireStale(undefined, undefined, target => {
736
+ const failure = this.wakeCoordinator?.wakeFailure(target);
737
+ return failure ? `target could not be woken (${failure})` : undefined;
738
+ });
739
+ this.reconcileQueueWorkers(outbox);
709
740
  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);
741
+ const claimed = outbox.claimNext(this.managerBootId,
742
+ // Phase 2b: a wake_only target is claimed only once it is awake; the
743
+ // wake coordinator, not this pump, wakes it (no claim, no attempt).
744
+ // Phase 2c: a lane a queue worker owns is never claimed here.
745
+ target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
746
+ ? null
747
+ : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
711
748
  if (!claimed)
712
749
  break;
713
750
  this.activeDurableTargets.add(claimed.targetInstance);
@@ -735,7 +772,67 @@ export class FleetManager {
735
772
  }
736
773
  }
737
774
  }
738
- async dispatchDurableDelivery(claimed) {
775
+ /**
776
+ * Phase 2c ownership (design §1.5-A), called synchronously from the pump
777
+ * before its claim loop, so a grant and the pump's own claims cannot
778
+ * interleave. A worker is granted a target's lane only when the target is
779
+ * `on` and its lane is empty: no row in flight in the outbox and no pump
780
+ * dispatch holding it. It gives the lane back only when it is idle and the
781
+ * target is no longer `on` (or has nothing pending). A flag change never
782
+ * takes a lane mid-drain.
783
+ */
784
+ reconcileQueueWorkers(outbox) {
785
+ const pendingTargets = new Set(outbox.listPending().map(row => row.targetInstance));
786
+ for (const [target, worker] of this.queueWorkers) {
787
+ const keep = this.deliveryWorkerMode(target) === "on" && pendingTargets.has(target);
788
+ if (!keep && !worker.busy && !worker.inFlight)
789
+ this.queueWorkers.delete(target);
790
+ }
791
+ for (const target of pendingTargets) {
792
+ if (this.queueWorkers.has(target) || this.deliveryWorkerMode(target) !== "on")
793
+ continue;
794
+ const laneEmpty = !this.activeDurableTargets.has(target)
795
+ && outbox.countForTarget(target, ["delivering", "submission_started"]) === 0;
796
+ if (laneEmpty)
797
+ this.queueWorkers.set(target, this.createQueueWorker(target));
798
+ }
799
+ for (const worker of this.queueWorkers.values())
800
+ void worker.drain();
801
+ }
802
+ createQueueWorker(target) {
803
+ const worker = new TargetQueueWorker(target, {
804
+ owns: () => this.queueWorkers.get(target) === worker,
805
+ wanted: () => this.deliveryWorkerMode(target) === "on",
806
+ available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
807
+ blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
808
+ daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
809
+ claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
810
+ tryAcquireBudget: () => {
811
+ if (this.activeDurableTargets.size >= 8)
812
+ return false;
813
+ this.activeDurableTargets.add(target);
814
+ return true;
815
+ },
816
+ releaseBudget: () => { this.activeDurableTargets.delete(target); },
817
+ dispatch: async (claimed) => {
818
+ try {
819
+ await this.dispatchDurableDelivery(claimed, { holdLaneOnTransportError: true });
820
+ }
821
+ finally {
822
+ // The row's state event can fire before the hand-off settles; the
823
+ // pump pass it starts then still sees this worker busy and keeps it
824
+ // as owner. One kick after the dispatch has really settled lets the
825
+ // pump release an owner that is no longer wanted (or claim the next
826
+ // row). The no-claim path never kicks, so no retry hot loop.
827
+ this.scheduleDeliveryOutboxPump();
828
+ }
829
+ },
830
+ kickCoordinator: () => this.wakeCoordinator?.kick(),
831
+ logger: this.logger,
832
+ });
833
+ return worker;
834
+ }
835
+ async dispatchDurableDelivery(claimed, opts = {}) {
739
836
  const outbox = this.deliveryOutbox;
740
837
  if (!outbox)
741
838
  return;
@@ -761,7 +858,7 @@ export class FleetManager {
761
858
  content: payload.content,
762
859
  delivery_id: claimed.deliveryId,
763
860
  delivery_attempt: String(claimed.attemptNo),
764
- }, { waitForIdle: false });
861
+ }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
765
862
  }
766
863
  else {
767
864
  payload.meta = {
@@ -776,6 +873,7 @@ export class FleetManager {
776
873
  sent = await this.deliverToInstance(target, payload, {
777
874
  isCrossInstance: true,
778
875
  waitForIdle: claimed.kind !== "steer",
876
+ noInlineWake: this.deliveryWorkerMode(target) !== "off",
779
877
  });
780
878
  }
781
879
  if (!sent) {
@@ -788,7 +886,10 @@ export class FleetManager {
788
886
  catch (err) {
789
887
  const latest = outbox.get(claimed.deliveryId);
790
888
  const reason = err instanceof Error ? err.message : String(err);
791
- if (latest?.state === "submission_started") {
889
+ if (opts.holdLaneOnTransportError && (latest?.state === "submission_started" || latest?.state === "reconciliation_pending")) {
890
+ this.logger.warn({ deliveryId: claimed.deliveryId, target, attempt, err: reason }, "Transport failed after the submission began; holding the lane for the daemon's verdict or its generation's reconciliation");
891
+ }
892
+ else if (latest?.state === "submission_started") {
792
893
  outbox.complete(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, "uncertain", reason);
793
894
  }
794
895
  else {
@@ -1687,13 +1788,15 @@ export class FleetManager {
1687
1788
  exclude,
1688
1789
  isEvicting: name => this.warmCapEvicting.has(name),
1689
1790
  isGeneral: name => isGeneralInstance(this.fleetConfig, name),
1690
- isIdle: name => this.getInstanceExecutionState(name) === "idle",
1791
+ // A work lease (Phase 2a) keeps a just-woken or mid-submission instance
1792
+ // out of the eviction candidates; it is not idle in the sense that matters.
1793
+ isIdle: name => this.getInstanceExecutionState(name) === "idle" && !this.lifecycle.hasWorkLease(name),
1691
1794
  lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
1692
1795
  });
1693
1796
  for (const victim of victims) {
1694
1797
  this.warmCapEvicting.add(victim);
1695
1798
  this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
1696
- this.lifecycle.pause(victim)
1799
+ this.lifecycle.pause(victim, "warm_cap")
1697
1800
  .catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
1698
1801
  .finally(() => this.warmCapEvicting.delete(victim));
1699
1802
  }
@@ -1729,7 +1832,10 @@ export class FleetManager {
1729
1832
  const query = () => {
1730
1833
  const ipc = this.instanceIpcClients.get(instanceName);
1731
1834
  if (ipc?.connected) {
1732
- ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}` });
1835
+ // The daemon owns the backend/TTY proof and its 10s startup-input
1836
+ // timer. A cache-only query can never discover a Context-less idle
1837
+ // pane, and a second fleet timer would stack another 10s wait.
1838
+ ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}`, deliveryIdle: true });
1733
1839
  }
1734
1840
  check();
1735
1841
  };
@@ -1744,8 +1850,8 @@ export class FleetManager {
1744
1850
  /**
1745
1851
  * Ask the daemon to capture the pane before answering, then wait for any state
1746
1852
  * 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.
1853
+ * cache-only; delivery idle gates opt into their own pane/input proof, and
1854
+ * this refresh serves lifecycle decisions where a stale state would strand UI.
1749
1855
  */
1750
1856
  refreshInstanceExecutionState(instanceName, timeoutMs) {
1751
1857
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -1783,16 +1889,22 @@ export class FleetManager {
1783
1889
  finish(false);
1784
1890
  });
1785
1891
  }
1786
- async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1892
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
1787
1893
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1788
1894
  return false;
1789
1895
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
1790
1896
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1791
1897
  return false;
1792
1898
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
1899
+ if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
1900
+ // Paused after its row was claimed (an operator or auth pause): hand the
1901
+ // row back; the coordinator decides whether and when to wake it.
1902
+ this.wakeCoordinator?.kick();
1903
+ return false;
1904
+ }
1793
1905
  if (this.lifecycle.isPaused(instanceName)) {
1794
1906
  const wakeStartedAt = Date.now();
1795
- await this.lifecycle.wake(instanceName, 30_000);
1907
+ await this.explicitWake(instanceName, 30_000);
1796
1908
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1797
1909
  return false;
1798
1910
  // Waking added one to the warm count — make room by evicting a different
@@ -1915,7 +2027,11 @@ export class FleetManager {
1915
2027
  ?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
1916
2028
  if (!waitForIdle) {
1917
2029
  if (this.lifecycle.isPaused(instanceName)) {
1918
- await this.lifecycle.wake(instanceName, 30_000);
2030
+ if (options.noInlineWake) {
2031
+ this.wakeCoordinator?.kick();
2032
+ return false;
2033
+ }
2034
+ await this.explicitWake(instanceName, 30_000);
1919
2035
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1920
2036
  return false;
1921
2037
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
@@ -1931,7 +2047,7 @@ export class FleetManager {
1931
2047
  return true;
1932
2048
  }
1933
2049
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
1934
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
2050
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
1935
2051
  this.idleGatedDeliveryTails.set(instanceName, delivery);
1936
2052
  try {
1937
2053
  return await delivery;
@@ -1947,9 +2063,117 @@ export class FleetManager {
1947
2063
  const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
1948
2064
  return allowed.some(entry => String(entry) === String(userId));
1949
2065
  }
2066
+ /** Phase 2: delivery_worker for a target (instance override → fleet default → off). */
2067
+ deliveryWorkerMode(target) {
2068
+ return resolveDeliveryWorkerMode(this.fleetConfig, target);
2069
+ }
2070
+ /** Active instances mid-restart: they keep their warm slot through the stop → replacement gap. */
2071
+ restartsHoldingSlot = new Set();
2072
+ /**
2073
+ * Instances counted against the wake coordinator's hard cap: running
2074
+ * daemons, plus active instances whose restart is between stopping the old
2075
+ * daemon and publishing the replacement.
2076
+ */
2077
+ warmInstanceNames() {
2078
+ const names = new Set([...this.daemons.keys()].filter(name => this.getInstanceStatus(name) === "running"));
2079
+ for (const name of this.restartsHoldingSlot)
2080
+ names.add(name);
2081
+ return [...names];
2082
+ }
2083
+ createWakeCoordinator() {
2084
+ return new WakeCoordinator({
2085
+ mode: target => this.deliveryWorkerMode(target),
2086
+ available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
2087
+ listPending: () => this.deliveryOutbox?.listPending() ?? [],
2088
+ isPaused: target => this.lifecycle.isPaused(target),
2089
+ pauseReason: target => readPauseReason(this.getInstanceDir(target)),
2090
+ isRestarting: target => this.isInstanceRestarting(target),
2091
+ wake: async (target) => {
2092
+ await this.lifecycle.wake(target, 30_000, undefined, { source: "coordinator" });
2093
+ // The soft cap still applies: evict a different, unleased idle instance.
2094
+ this.enforceWarmCap(target);
2095
+ },
2096
+ residentNames: () => this.warmInstanceNames(),
2097
+ warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
2098
+ warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
2099
+ wokeAt: target => this.lifecycle.wokeAtFor(target),
2100
+ notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
2101
+ notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
2102
+ notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
2103
+ kickPump: () => this.scheduleDeliveryOutboxPump(),
2104
+ now: () => Date.now(),
2105
+ logger: this.logger,
2106
+ });
2107
+ }
2108
+ /** LifecycleContext: an operator or user wake clears the coordinator's park/backoff for it. */
2109
+ onExternalWake(name) {
2110
+ this.wakeCoordinator?.noteExternalWake(name);
2111
+ }
2112
+ /**
2113
+ * Phase 2b shared warm-slot admission (design §1.3, §1.6) for every wake
2114
+ * that is not the coordinator's own: an operator's wake, a user's message,
2115
+ * an explicit start or a restart of a paused instance. With delivery_worker
2116
+ * off, or for a target that is not paused (an active restart is already
2117
+ * counted), nothing is reserved. A target already being woken is joined —
2118
+ * its flight holds the slot — rather than reserved twice. Otherwise a slot
2119
+ * is taken under the hard cap, an unleased idle instance is paused to make
2120
+ * one, and if none can be, the wake is refused instead of exceeding the cap.
2121
+ */
2122
+ async acquireWakeSlot(name) {
2123
+ const coordinator = this.wakeCoordinator;
2124
+ if (!coordinator || this.deliveryWorkerMode(name) === "off" || !this.lifecycle.isPaused(name))
2125
+ return null;
2126
+ if (coordinator.isWaking(name))
2127
+ return null;
2128
+ let token = coordinator.tryReserve(name);
2129
+ if (!token) {
2130
+ const victim = this.lruUnleasedIdle(name);
2131
+ if (victim) {
2132
+ await this.lifecycle.pause(victim, "warm_cap");
2133
+ if (coordinator.isWaking(name))
2134
+ return null;
2135
+ token = coordinator.tryReserve(name);
2136
+ }
2137
+ }
2138
+ if (!token) {
2139
+ 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`);
2140
+ }
2141
+ return token;
2142
+ }
2143
+ releaseWakeSlot(name, token) {
2144
+ if (!token || !this.wakeCoordinator)
2145
+ return;
2146
+ this.wakeCoordinator.release(name, token);
2147
+ this.wakeCoordinator.kick();
2148
+ }
2149
+ /**
2150
+ * An operator's or user's wake (/wake, wake_instance, start_instance on a
2151
+ * paused instance, Settings, a channel message). With delivery_worker off it
2152
+ * is exactly the old lifecycle wake; otherwise it goes through the shared
2153
+ * slot admission and joins any wake already in flight for the target.
2154
+ */
2155
+ async explicitWake(name, timeoutMs = 30_000) {
2156
+ const token = await this.acquireWakeSlot(name);
2157
+ try {
2158
+ await this.lifecycle.wake(name, timeoutMs);
2159
+ }
2160
+ finally {
2161
+ this.releaseWakeSlot(name, token);
2162
+ }
2163
+ }
2164
+ /** The least-recently-active idle, unleased, non-general running instance other than `exclude`. */
2165
+ lruUnleasedIdle(exclude) {
2166
+ return this.warmInstanceNames()
2167
+ .filter(name => name !== exclude
2168
+ && !this.warmCapEvicting.has(name)
2169
+ && !isGeneralInstance(this.fleetConfig, name)
2170
+ && this.getInstanceExecutionState(name) === "idle"
2171
+ && !this.lifecycle.hasWorkLease(name))
2172
+ .sort((a, b) => (readLastInboundAt(this.getInstanceDir(a)) ?? 0) - (readLastInboundAt(this.getInstanceDir(b)) ?? 0))[0];
2173
+ }
1950
2174
  async changeInstancePauseState(name, action) {
1951
2175
  if (action === "wake") {
1952
- await this.lifecycle.wake(name, 30_000);
2176
+ await this.explicitWake(name, 30_000);
1953
2177
  this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
1954
2178
  return "awake";
1955
2179
  }
@@ -2068,12 +2292,14 @@ export class FleetManager {
2068
2292
  * and reconcile calls leave this false so a persisted pause remains paused
2069
2293
  * across a fleet restart.
2070
2294
  */
2071
- resumePaused = false) {
2295
+ resumePaused = false,
2296
+ /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
2297
+ transition) {
2072
2298
  // Any start supersedes a pending automatic retry (it would otherwise fire
2073
2299
  // into a running instance — harmless, but noisy — or race this start).
2074
2300
  this.cancelStartupRetry(name);
2075
2301
  if (resumePaused && this.lifecycle.isPaused(name)) {
2076
- await this.lifecycle.wake(name, 30_000);
2302
+ await this.explicitWake(name, 30_000);
2077
2303
  // A successful wake clears the persisted pause marker and produces a
2078
2304
  // fresh instance_state snapshot. Drop any stale process error left by a
2079
2305
  // pre-pause crash so /api/fleet and `agend ls` converge on running.
@@ -2100,6 +2326,10 @@ export class FleetManager {
2100
2326
  this.logger.info({ name }, "Instance already running, skipping");
2101
2327
  return;
2102
2328
  }
2329
+ // An explicit start (CLI/API) begins a fresh idle window; boot and
2330
+ // reconcile starts keep the persisted last-inbound seed (Phase 2a).
2331
+ if (resumePaused)
2332
+ this.lifecycle.markActivitySeedNow(name);
2103
2333
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
2104
2334
  if (config.general_topic) {
2105
2335
  this.ensureGeneralInstructions(config.working_directory, backend, name);
@@ -2123,7 +2353,7 @@ export class FleetManager {
2123
2353
  kind,
2124
2354
  backend,
2125
2355
  model: this.resolveInstanceModel(name).display,
2126
- });
2356
+ }, transition);
2127
2357
  // Only clear a stale process status after a real start succeeded. Clearing
2128
2358
  // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
2129
2359
  // a falsely running instance when lifecycle.start() returns early.
@@ -2137,18 +2367,18 @@ export class FleetManager {
2137
2367
  this.requestDiscordUsagePresenceRefresh();
2138
2368
  }
2139
2369
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
2140
- async startPersistedPausedInstance(name) {
2370
+ async startPersistedPausedInstance(name, transition) {
2141
2371
  const topicMode = this.fleetConfig?.channel?.mode === "topic"
2142
2372
  || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
2143
2373
  const fleetConfig = this.fleetConfig?.instances[name];
2144
2374
  if (fleetConfig) {
2145
- await this.startInstance(name, fleetConfig, topicMode);
2375
+ await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
2146
2376
  return;
2147
2377
  }
2148
2378
  const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
2149
2379
  if (!channel || !this.classicChannels)
2150
2380
  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));
2381
+ 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
2382
  }
2153
2383
  /**
2154
2384
  * Start instances with configurable concurrency and stagger delay.
@@ -2197,6 +2427,10 @@ export class FleetManager {
2197
2427
  return this.daemons.has(name);
2198
2428
  }
2199
2429
  catch (err) {
2430
+ // Superseded by a stop/restart (Phase 2a): that transition owns the
2431
+ // outcome. Retrying would start an instance the operator just stopped.
2432
+ if (err instanceof SupersededStartError)
2433
+ return false;
2200
2434
  this.logger.error({ err, name }, `Failed to start ${what}`);
2201
2435
  this.scheduleStartupRetry(name, 0);
2202
2436
  return false;
@@ -2318,6 +2552,9 @@ export class FleetManager {
2318
2552
  }
2319
2553
  }
2320
2554
  catch (err) {
2555
+ // Superseded by a stop/restart: that transition owns the outcome.
2556
+ if (err instanceof SupersededStartError)
2557
+ return;
2321
2558
  this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
2322
2559
  this.scheduleStartupRetry(name, attempt + 1);
2323
2560
  }
@@ -2358,6 +2595,8 @@ export class FleetManager {
2358
2595
  return this.daemons.has(ch.instanceName);
2359
2596
  }
2360
2597
  catch (err) {
2598
+ if (err instanceof SupersededStartError)
2599
+ return false;
2361
2600
  this.logger.warn({ err, instanceName: ch.instanceName }, `Failed to start ${what}`);
2362
2601
  this.scheduleStartupRetry(ch.instanceName, 0);
2363
2602
  return false;
@@ -2514,7 +2753,7 @@ export class FleetManager {
2514
2753
  }
2515
2754
  return false;
2516
2755
  }
2517
- async stopInstance(name) {
2756
+ async stopInstance(name, transition) {
2518
2757
  this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
2519
2758
  this.cancelStartupRetry(name);
2520
2759
  this.failoverActive.delete(name);
@@ -2533,7 +2772,7 @@ export class FleetManager {
2533
2772
  // the hand-off snapshotted it).
2534
2773
  const run = (async () => {
2535
2774
  try {
2536
- await this.lifecycle.stop(name);
2775
+ await this.lifecycle.stop(name, transition);
2537
2776
  }
2538
2777
  finally {
2539
2778
  this.clearNoncePromptsForInstance(name);
@@ -2555,11 +2794,16 @@ export class FleetManager {
2555
2794
  return inFlight;
2556
2795
  }
2557
2796
  const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
2558
- const run = this.spawnGate.run({
2797
+ // Phase 2a: invalidate synchronously so a start still spawning does not
2798
+ // publish, then queue behind it as this instance's next transition. The
2799
+ // spawn gate is taken inside the transition, never the other way round, so
2800
+ // the start being waited for can still get its own gate slot.
2801
+ this.lifecycle.invalidate(name);
2802
+ const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
2559
2803
  instanceName: name,
2560
2804
  workingDirectory,
2561
2805
  reason: "restart",
2562
- }, () => this.doRestartSingleInstance(name, opts))
2806
+ }, () => this.doRestartSingleInstance(name, opts, transition)))
2563
2807
  .finally(() => {
2564
2808
  this.restartsInFlight.delete(name);
2565
2809
  this.scheduleDeliveryOutboxPump();
@@ -2570,19 +2814,73 @@ export class FleetManager {
2570
2814
  isInstanceRestarting(name) {
2571
2815
  return this.restartsInFlight.has(name);
2572
2816
  }
2573
- async doRestartSingleInstance(name, opts) {
2817
+ /** InstanceLifecycleContext: an explicit wake joins the restart in flight. */
2818
+ restartInFlight(name) {
2819
+ return this.restartsInFlight.get(name);
2820
+ }
2821
+ /**
2822
+ * Phase 2a: restarting a paused instance is a wake. The stop leaves the
2823
+ * pause marker in place, and the start used to skip on it ("Persisted
2824
+ * paused instance"), turning a resident pause into a marker-only one that
2825
+ * nothing would wake. Clear it before the start; if the start fails, put it
2826
+ * back so the instance stays wakeable instead of becoming plain stopped.
2827
+ */
2828
+ async startAfterRestart(name, wasPaused, pausedAt, reason, start) {
2829
+ const instanceDir = this.getInstanceDir(name);
2830
+ if (wasPaused)
2831
+ clearPausedMarker(instanceDir);
2832
+ this.lifecycle.markActivitySeedNow(name);
2833
+ try {
2834
+ await start();
2835
+ }
2836
+ catch (err) {
2837
+ if (wasPaused && !this.daemons.has(name))
2838
+ writePausedMarker(instanceDir, pausedAt ?? Date.now(), reason);
2839
+ throw err;
2840
+ }
2841
+ finally {
2842
+ this.lifecycle.clearActivitySeedNow(name);
2843
+ }
2844
+ }
2845
+ async doRestartSingleInstance(name, opts, transition) {
2574
2846
  if (this.configPath) {
2575
2847
  this.loadConfig(this.configPath);
2576
2848
  this.routing.rebuild(this.fleetConfig);
2577
2849
  this.reregisterClassicChannels();
2578
2850
  }
2579
2851
  const config = this.fleetConfig?.instances[name];
2852
+ const wasPaused = this.lifecycle.isPaused(name);
2853
+ // A paused instance's restart is a wake: it takes a warm slot like any other
2854
+ // (refused before anything is stopped when none can be had). An active
2855
+ // restart is already counted and reserves nothing.
2856
+ const slot = await this.acquireWakeSlot(name);
2857
+ // An active instance keeps the slot it already occupies across its own
2858
+ // stop → replacement gap: counted as warm until the replacement is
2859
+ // published or the restart settles, so no other target can take it.
2860
+ if (!wasPaused)
2861
+ this.restartsHoldingSlot.add(name);
2862
+ try {
2863
+ await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
2864
+ // An explicit restart of a paused instance is an operator wake (§1.6):
2865
+ // its success lifts the coordinator's park/backoff (the level stays).
2866
+ // Automatic restarts never do.
2867
+ if (wasPaused && opts?.explicit && this.daemons.has(name))
2868
+ this.wakeCoordinator?.noteExternalWake(name);
2869
+ }
2870
+ finally {
2871
+ this.restartsHoldingSlot.delete(name);
2872
+ this.releaseWakeSlot(name, slot);
2873
+ }
2874
+ }
2875
+ async doRestartWithSlot(name, opts, transition, config, wasPaused) {
2876
+ const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
2877
+ const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
2580
2878
  if (config) {
2581
- await this.stopInstance(name);
2879
+ await this.stopInstance(name, transition);
2582
2880
  if (opts?.freshStart)
2583
2881
  this.writeFreshStartMarker(name);
2584
2882
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
2585
- await this.startInstance(name, config, topicMode ?? false);
2883
+ await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
2586
2884
  this.stormWindow.markRecovered(name);
2587
2885
  return;
2588
2886
  }
@@ -2591,11 +2889,11 @@ export class FleetManager {
2591
2889
  if (channelId) {
2592
2890
  const fleetBackend = this.fleetConfig?.defaults?.backend;
2593
2891
  const adapterId = this.classicChannels.getAdapterIdByInstance(name);
2594
- await this.stopInstance(name);
2892
+ await this.stopInstance(name, transition);
2595
2893
  await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
2596
2894
  if (opts?.freshStart)
2597
2895
  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));
2896
+ 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
2897
  this.stormWindow.markRecovered(name);
2600
2898
  return;
2601
2899
  }
@@ -3915,7 +4213,7 @@ export class FleetManager {
3915
4213
  }
3916
4214
  else if (data.command === "sysinfo") {
3917
4215
  // Slash commands are Discord-only; use plain lines (no markdown table)
3918
- await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
4216
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
3919
4217
  }
3920
4218
  else if (data.command === "dashboard") {
3921
4219
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -4275,7 +4573,7 @@ export class FleetManager {
4275
4573
  }
4276
4574
  else if (data.command === "sysinfo") {
4277
4575
  // Slash commands are Discord-only; use plain lines (no markdown table)
4278
- await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
4576
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
4279
4577
  }
4280
4578
  else if (data.command === "dashboard") {
4281
4579
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -5850,7 +6148,8 @@ export class FleetManager {
5850
6148
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
5851
6149
  : type === "fleet_task" ? "fleet_task_response"
5852
6150
  : type === "fleet_set_display_name" ? "fleet_display_name_response"
5853
- : "fleet_description_response";
6151
+ : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
6152
+ : "fleet_description_response";
5854
6153
  ipc.send({ type: responseType, fleetRequestId, error: message });
5855
6154
  }
5856
6155
  dispatchTypedIpc(name, msg) {
@@ -5875,6 +6174,24 @@ export class FleetManager {
5875
6174
  this.handleSetDescription(name, msg);
5876
6175
  return;
5877
6176
  }
6177
+ if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
6178
+ this.handlePersonaEmoji(name, msg);
6179
+ return;
6180
+ }
6181
+ }
6182
+ /** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
6183
+ handlePersonaEmoji(instanceName, msg) {
6184
+ const fleetRequestId = msg.fleetRequestId;
6185
+ const payload = (msg.payload ?? {});
6186
+ const ipc = this.instanceIpcClients.get(instanceName);
6187
+ if (!ipc)
6188
+ return;
6189
+ const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
6190
+ : msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
6191
+ : this.setPersonaEmoji(instanceName, payload);
6192
+ op.then((r) => typeof r.error === "string"
6193
+ ? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
6194
+ : ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
5878
6195
  }
5879
6196
  handleScheduleCrud(instanceName, msg) {
5880
6197
  const fleetRequestId = msg.fleetRequestId;
@@ -5979,10 +6296,15 @@ export class FleetManager {
5979
6296
  ?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
5980
6297
  ?? instanceName;
5981
6298
  }
6299
+ /** Inherited keys such as __proto__ must never resolve to a mutable instance. */
6300
+ ownInstanceConfig(instanceName) {
6301
+ const instances = this.fleetConfig?.instances;
6302
+ return instances && Object.hasOwn(instances, instanceName) ? instances[instanceName] : undefined;
6303
+ }
5982
6304
  /** Persist identity to the instance's actual config store. Classic instances
5983
6305
  * are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
5984
6306
  setInstanceDisplayName(instanceName, displayName) {
5985
- const fleetInstance = this.fleetConfig?.instances[instanceName];
6307
+ const fleetInstance = this.ownInstanceConfig(instanceName);
5986
6308
  if (fleetInstance) {
5987
6309
  fleetInstance.display_name = displayName;
5988
6310
  this.saveFleetConfig();
@@ -5991,7 +6313,7 @@ export class FleetManager {
5991
6313
  return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
5992
6314
  }
5993
6315
  setInstanceDescription(instanceName, description) {
5994
- const fleetInstance = this.fleetConfig?.instances[instanceName];
6316
+ const fleetInstance = this.ownInstanceConfig(instanceName);
5995
6317
  if (fleetInstance) {
5996
6318
  fleetInstance.description = description;
5997
6319
  this.saveFleetConfig();
@@ -6217,6 +6539,15 @@ export class FleetManager {
6217
6539
  return { error: `Instance '${instance}' not found` };
6218
6540
  return { display_name: name };
6219
6541
  }
6542
+ async handleListEmojisHttp(instance, refresh) {
6543
+ return this.listEmojisFor(instance, refresh);
6544
+ }
6545
+ async handleSetPersonaEmojiHttp(instance, args) {
6546
+ return this.setPersonaEmoji(instance, args);
6547
+ }
6548
+ async handlePreviewEmojisHttp(instance, args) {
6549
+ return this.previewEmojis(instance, args);
6550
+ }
6220
6551
  async handleSetDescriptionHttp(instance, description) {
6221
6552
  if (!this.fleetConfig)
6222
6553
  return { error: "Fleet config not available" };
@@ -6834,6 +7165,8 @@ export class FleetManager {
6834
7165
  deliveryStatusChains = new Map();
6835
7166
  /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6836
7167
  pendingCliEnvProbes = new Map();
7168
+ /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
7169
+ pendingVendorCliEnvProbes = new Map();
6837
7170
  /**
6838
7171
  * The status emojis for an instance (#1005): its own `status_emojis`, then
6839
7172
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -6864,10 +7197,15 @@ export class FleetManager {
6864
7197
  }
6865
7198
  warnedStatusEmojis = new Set();
6866
7199
  /**
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.
7200
+ * A Discord connection's server emojis for the Settings picker (#1005), from
7201
+ * every server its bot can draw on (#1021): the primary `group_id` server,
7202
+ * then each other server the bot is a member of that ClassicBot's
7203
+ * `allowed_guilds` admits (unset = all, as for classic routing). A bot can
7204
+ * react with a custom emoji from any server it is in. Cached per server — a
7205
+ * server's emoji set changes rarely — and one fetch per server at a time;
7206
+ * `refresh` forces new ones. One server refusing (e.g. Missing Access) is
7207
+ * reported on that server and does not hide the others. Uses the running
7208
+ * adapter, so the bot token never leaves the server.
6871
7209
  */
6872
7210
  async listGuildEmojis(channelId, refresh = false) {
6873
7211
  const world = this.worlds.get(channelId);
@@ -6878,28 +7216,268 @@ export class FleetManager {
6878
7216
  const adapter = world.adapter;
6879
7217
  if (!adapter.listGuildEmojis)
6880
7218
  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
- }
7219
+ const listEmojis = adapter.listGuildEmojis.bind(adapter);
7220
+ let servers;
6893
7221
  try {
6894
- return { ok: true, ...(await pending) };
7222
+ servers = adapter.listMemberGuilds
7223
+ ? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
7224
+ : [{ id: "", name: "", primary: true }];
6895
7225
  }
6896
7226
  catch (e) {
6897
- return { ok: false, error: `Discord refused the emoji list: ${e.message}` };
6898
- }
7227
+ return { ok: false, error: `Discord refused the server list: ${e.message}` };
7228
+ }
7229
+ const guilds = await Promise.all(servers.map(async (g) => {
7230
+ const key = `${channelId}\0${g.id}`;
7231
+ const cached = this.guildEmojiCache.get(key);
7232
+ if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
7233
+ return { ...g, ...cached };
7234
+ let pending = this.guildEmojiFetches.get(key);
7235
+ if (!pending) {
7236
+ pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
7237
+ const entry = { fetched_at: Date.now(), emojis };
7238
+ this.guildEmojiCache.set(key, entry);
7239
+ return entry;
7240
+ }).finally(() => this.guildEmojiFetches.delete(key));
7241
+ this.guildEmojiFetches.set(key, pending);
7242
+ }
7243
+ try {
7244
+ return { ...g, ...(await pending) };
7245
+ }
7246
+ catch (e) {
7247
+ return { ...g, error: `Discord refused the emoji list: ${e.message}` };
7248
+ }
7249
+ }));
7250
+ const listed = guilds.filter((g) => !!g.emojis);
7251
+ if (!listed.length)
7252
+ return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
7253
+ return {
7254
+ ok: true,
7255
+ fetched_at: Math.min(...listed.map(g => g.fetched_at)),
7256
+ emojis: listed.flatMap(g => g.emojis),
7257
+ guilds,
7258
+ };
6899
7259
  }
6900
7260
  static GUILD_EMOJI_TTL_MS = 10 * 60_000;
7261
+ /** Keyed `<connection>\0<server id>`. */
6901
7262
  guildEmojiCache = new Map();
6902
7263
  guildEmojiFetches = new Map();
7264
+ /**
7265
+ * What `instanceName` may pick as its persona emoji (the `list_emojis`
7266
+ * tool): its current status set with where each value comes from, the
7267
+ * standard emojis its platform takes, and on Discord the server emojis its
7268
+ * bot can draw on — the same lists, and the same resolution, as the
7269
+ * Settings picker (#1005/#1021), so an agent and an operator see one truth.
7270
+ */
7271
+ async listEmojisFor(instanceName, refresh = false) {
7272
+ const self = this.fleetConfig?.instances[instanceName];
7273
+ if (!self)
7274
+ return { error: this.personaEmojiMissing(instanceName) };
7275
+ const worldId = this.getInstanceAdapterId(instanceName);
7276
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7277
+ const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
7278
+ const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
7279
+ const out = {
7280
+ platform: platform ?? null,
7281
+ statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
7282
+ standard: platform === "telegram"
7283
+ ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
7284
+ : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
7285
+ };
7286
+ if (platform === "discord" && worldId) {
7287
+ const listed = await this.listGuildEmojis(worldId, refresh);
7288
+ out.server_emojis = listed.ok
7289
+ ? listed.guilds.map(g => g.emojis
7290
+ ? {
7291
+ server: g.name || g.id, primary: g.primary,
7292
+ emojis: g.emojis.filter(e => e.available).map(e => {
7293
+ const value = customEmojiValue(e);
7294
+ return { value, image_url: emojiImageUrl(value) };
7295
+ }),
7296
+ }
7297
+ : { server: g.name || g.id, primary: g.primary, error: g.error })
7298
+ : { error: listed.error };
7299
+ }
7300
+ return out;
7301
+ }
7302
+ /**
7303
+ * `preview_emojis` (#1040): download a few Discord server emojis so an agent
7304
+ * can look at them before it picks one — a name and an id say nothing about
7305
+ * what an emoji looks like. The fleet fetches, not the agent (not every
7306
+ * backend can), and returns a local path per emoji to Read, as
7307
+ * download_attachment does for an attachment.
7308
+ *
7309
+ * SSRF-safe by construction: nothing from the caller reaches a URL. The
7310
+ * caller names emojis; each must be an available one in a server this bot
7311
+ * can draw on (the list set_persona_emoji checks), and the CDN URL is built
7312
+ * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
7313
+ */
7314
+ async previewEmojis(instanceName, args) {
7315
+ const self = this.fleetConfig?.instances[instanceName];
7316
+ if (!self)
7317
+ return { error: this.personaEmojiMissing(instanceName) };
7318
+ const wanted = args.emojis;
7319
+ if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
7320
+ return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
7321
+ }
7322
+ if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
7323
+ return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
7324
+ }
7325
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7326
+ const worldId = this.getInstanceAdapterId(instanceName);
7327
+ if (platform !== "discord" || !worldId) {
7328
+ return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
7329
+ }
7330
+ const listed = await this.listGuildEmojis(worldId);
7331
+ if (!listed.ok)
7332
+ return { error: `cannot list this bot's server emojis: ${listed.error}` };
7333
+ const previews = [];
7334
+ const errors = [];
7335
+ for (const raw of wanted) {
7336
+ const e = normalizeEmoji(raw);
7337
+ if (e?.kind !== "custom") {
7338
+ errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
7339
+ continue;
7340
+ }
7341
+ const found = listed.emojis.find(g => g.id === e.id);
7342
+ if (!found?.available) {
7343
+ errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
7344
+ continue;
7345
+ }
7346
+ try {
7347
+ previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
7348
+ }
7349
+ catch (err) {
7350
+ errors.push({ emoji: raw, error: `download failed: ${err.message}` });
7351
+ }
7352
+ }
7353
+ return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
7354
+ }
7355
+ static EMOJI_PREVIEW_MAX = 8;
7356
+ static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
7357
+ static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
7358
+ /** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
7359
+ async fetchEmojiPreview(id) {
7360
+ if (!/^\d{15,25}$/.test(id))
7361
+ throw new Error("not a Discord emoji id");
7362
+ const dir = join(this.dataDir, "inbox", "emoji-previews");
7363
+ mkdirSync(dir, { recursive: true });
7364
+ const now = Date.now();
7365
+ for (const name of readdirSync(dir)) {
7366
+ try {
7367
+ if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
7368
+ unlinkSync(join(dir, name));
7369
+ }
7370
+ catch { /* raced */ }
7371
+ }
7372
+ const path = join(dir, `${id}.png`);
7373
+ if (existsSync(path))
7374
+ return path;
7375
+ const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
7376
+ if (!response.ok)
7377
+ throw new Error(`HTTP ${response.status}`);
7378
+ if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
7379
+ throw new Error("not a PNG");
7380
+ const reader = response.body?.getReader();
7381
+ if (!reader)
7382
+ throw new Error("empty response");
7383
+ const chunks = [];
7384
+ let size = 0;
7385
+ for (;;) {
7386
+ const { done, value } = await reader.read();
7387
+ if (done)
7388
+ break;
7389
+ size += value.byteLength;
7390
+ if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
7391
+ await reader.cancel();
7392
+ throw new Error("image too large");
7393
+ }
7394
+ chunks.push(value);
7395
+ }
7396
+ const tmp = `${path}.${process.pid}.tmp`;
7397
+ writeFileSync(tmp, Buffer.concat(chunks));
7398
+ renameSync(tmp, path);
7399
+ return path;
7400
+ }
7401
+ /**
7402
+ * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
7403
+ * `delivered` unless it names another — in its per-instance `status_emojis`
7404
+ * override (#1005 addendum 2), so its stamp is told apart from other bots'
7405
+ * on a shared message. The value is judged exactly as Settings and the
7406
+ * react path judge it (statusEmojiProblem on the instance's platform), and
7407
+ * a Discord server emoji must be one its bot can use: an emoji from a
7408
+ * server it is not in would be stored, preview fine, and fail every react.
7409
+ * An empty emoji removes that status's override.
7410
+ */
7411
+ async setPersonaEmoji(instanceName, args) {
7412
+ const self = this.ownInstanceConfig(instanceName);
7413
+ if (!self)
7414
+ return { error: this.personaEmojiMissing(instanceName) };
7415
+ const status = (args.status ?? "delivered");
7416
+ if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
7417
+ return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
7418
+ }
7419
+ // Only an explicit "" removes the override. The agent endpoint, typed IPC
7420
+ // and agent-cli do not run the MCP schema, so a call missing `emoji` must
7421
+ // be refused here, never read as "clear my delivered stamp".
7422
+ if (typeof args.emoji !== "string") {
7423
+ return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
7424
+ }
7425
+ const raw = args.emoji.trim();
7426
+ // Whitespace is not an explicit "": a stray " " must not clear the stamp either.
7427
+ if (!raw && args.emoji !== "") {
7428
+ return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
7429
+ }
7430
+ let value;
7431
+ if (raw) {
7432
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7433
+ const problem = statusEmojiProblem(platform, status, raw);
7434
+ if (problem)
7435
+ return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
7436
+ const e = normalizeEmoji(raw);
7437
+ if (e.kind === "custom") {
7438
+ const worldId = this.getInstanceAdapterId(instanceName);
7439
+ if (!worldId)
7440
+ return { error: "no connection to check that server emoji against" };
7441
+ const listed = await this.listGuildEmojis(worldId);
7442
+ if (!listed.ok)
7443
+ return { error: `cannot check that server emoji: ${listed.error}` };
7444
+ const found = listed.emojis.find(g => g.id === e.id);
7445
+ if (!found?.available) {
7446
+ 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.` };
7447
+ }
7448
+ value = customEmojiValue(found);
7449
+ }
7450
+ else {
7451
+ value = e.value;
7452
+ }
7453
+ }
7454
+ // Re-read after the validation above, which may have waited on Discord:
7455
+ // another set_persona_emoji or a Settings save can have changed another
7456
+ // status, or replaced this instance's config object, meanwhile. Change
7457
+ // only this one key on what is there now. Ownership must still hold:
7458
+ // removal during the await must not fall through to an inherited entry.
7459
+ const current = this.ownInstanceConfig(instanceName);
7460
+ if (!current)
7461
+ return { error: this.personaEmojiMissing(instanceName) };
7462
+ const map = { ...(current.status_emojis ?? {}) };
7463
+ if (value)
7464
+ map[status] = value;
7465
+ else
7466
+ delete map[status];
7467
+ if (Object.keys(map).length)
7468
+ current.status_emojis = map;
7469
+ else
7470
+ delete current.status_emojis;
7471
+ this.saveFleetConfig();
7472
+ this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
7473
+ const resolved = this.resolveStatusEmojisFor(instanceName);
7474
+ return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
7475
+ }
7476
+ personaEmojiMissing(instanceName) {
7477
+ return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
7478
+ ? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
7479
+ : `Instance '${instanceName}' not found`;
7480
+ }
6903
7481
  /** What the instructions tell `instanceName` not to react with (its own status set). */
6904
7482
  statusEmojiAvoidList(instanceName) {
6905
7483
  return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
@@ -6956,6 +7534,16 @@ export class FleetManager {
6956
7534
  const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
6957
7535
  return reactionForm(resolved.platform, resolved.received);
6958
7536
  }
7537
+ /**
7538
+ * The stamp for an inbound photo / file a classic bot saved (#1080): the
7539
+ * instance's `status_emojis.photo|attachment`, then its connection's, then
7540
+ * the built-in (📸/📎, or 👌/👍 on Telegram — what these paths always stamped).
7541
+ * Anything else a saved attachment can be counts as a file.
7542
+ */
7543
+ savedAttachmentReactionFor(instanceName, adapter, adapterId, kind) {
7544
+ const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
7545
+ return reactionForm(resolved.platform, kind === "photo" ? resolved.photo : resolved.attachment);
7546
+ }
6959
7547
  reactMessageStatus(instanceName, chatId, messageId, status, threadId) {
6960
7548
  // React via the adapter BOUND to this instance — NOT the first discord world.
6961
7549
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -9405,10 +9993,16 @@ export class FleetManager {
9405
9993
  }
9406
9994
  // The installer may only have added the binary to a profile PATH; a
9407
9995
  // fresh login shell sees that, the fleet process's PATH may not.
9408
- if (!this.verifyBinaryOnLoginShell(info.binary)) {
9996
+ const installedAt = this.locateBinaryOnLoginShell(info.binary);
9997
+ if (!installedAt) {
9409
9998
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
9410
9999
  return;
9411
10000
  }
10001
+ // #1059: make it reachable from this process too. `/login` lists what
10002
+ // `which` finds on the fleet's own PATH, and instances resolve their
10003
+ // binary the same way, so a binary only a login shell could see was
10004
+ // installed, reported as verified, and then offered nowhere.
10005
+ this.adoptBinaryDirectory(installedAt, backend);
9412
10006
  if (!LOGIN_FLOWS[backend]) {
9413
10007
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
9414
10008
  return;
@@ -9468,15 +10062,35 @@ export class FleetManager {
9468
10062
  return t("install.cancelled", backend);
9469
10063
  }
9470
10064
  /** `command -v` on a login shell, so PATH additions from rc files count. */
9471
- verifyBinaryOnLoginShell(binary) {
10065
+ /**
10066
+ * The absolute path a fresh login shell resolves `binary` to, or null. Only
10067
+ * a real executable file counts: `command -v` also answers with an alias
10068
+ * definition or a function name, neither of which a spawn could run.
10069
+ */
10070
+ locateBinaryOnLoginShell(binary) {
9472
10071
  try {
9473
- const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
9474
- return result.status === 0;
10072
+ const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" });
10073
+ if (result.status !== 0)
10074
+ return null;
10075
+ const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
10076
+ if (!isAbsolute(path))
10077
+ return null;
10078
+ accessSync(path, fsConstants.X_OK);
10079
+ return statSync(path).isFile() ? path : null;
9475
10080
  }
9476
10081
  catch {
9477
- return false;
10082
+ return null;
9478
10083
  }
9479
10084
  }
10085
+ /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
10086
+ adoptBinaryDirectory(binaryPath, backend) {
10087
+ const dir = dirname(binaryPath);
10088
+ const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
10089
+ if (entries.includes(dir))
10090
+ return;
10091
+ process.env.PATH = [dir, ...entries].join(delimiter);
10092
+ this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
10093
+ }
9480
10094
  /** "Sign in now?" button after a successful install. */
9481
10095
  async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
9482
10096
  const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
@@ -10047,9 +10661,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10047
10661
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10048
10662
  const noMentionReactChatId = msg.threadId ?? msg.chatId;
10049
10663
  if (reactAdapter && noMentionReactChatId && msg.messageId) {
10050
- const emoji = msg.source === "telegram"
10051
- ? (saved.kind === "photo" ? "👌" : "👍")
10052
- : (saved.kind === "photo" ? "📸" : "📎");
10664
+ const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
10053
10665
  reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
10054
10666
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10055
10667
  }
@@ -10080,9 +10692,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10080
10692
  return;
10081
10693
  // Attachments already saved at the top of the collab block.
10082
10694
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
10083
- const emoji = msg.source === "telegram"
10084
- ? (saved.kind === "photo" ? "👌" : "👍")
10085
- : (saved.kind === "photo" ? "📸" : "📎");
10695
+ const emoji = this.savedAttachmentReactionFor(instanceName, classicAdapter, msg.adapterId, saved.kind);
10086
10696
  classicAdapter.react(collabReactChatId, msg.messageId, emoji)
10087
10697
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10088
10698
  }
@@ -10130,10 +10740,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10130
10740
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10131
10741
  const reactChatId = msg.threadId ?? msg.chatId;
10132
10742
  if (saved && reactAdapter && reactChatId && msg.messageId) {
10133
- // Telegram only supports limited emoji for reactions; use 👌 for photo, 👍 for file
10134
- const emoji = msg.source === "telegram"
10135
- ? (saved.kind === "photo" ? "👌" : "👍")
10136
- : (saved.kind === "photo" ? "📸" : "📎");
10743
+ const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
10137
10744
  reactAdapter.react(reactChatId, msg.messageId, emoji)
10138
10745
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10139
10746
  }
@@ -10179,9 +10786,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10179
10786
  classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
10180
10787
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10181
10788
  if (saved) {
10182
- const savedEmoji = msg.source === "telegram"
10183
- ? (saved.kind === "photo" ? "👌" : "👍")
10184
- : (saved.kind === "photo" ? "📸" : "📎");
10789
+ const savedEmoji = this.savedAttachmentReactionFor(instanceName, classicMsgAdapter, msg.adapterId, saved.kind);
10185
10790
  classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji)
10186
10791
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10187
10792
  }
@@ -10325,6 +10930,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10325
10930
  catch { /* missing / stale / corrupt */ }
10326
10931
  return null;
10327
10932
  }
10933
+ /** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
10934
+ getBackendCliVersionSnapshot() {
10935
+ const snapshot = {};
10936
+ for (const backend of SYSINFO_BACKEND_IDS) {
10937
+ const cached = this.readCliEnv(backend);
10938
+ const needsRefresh = this.cliEnvNeedsRefresh(cached);
10939
+ const version = typeof cached?.version === "string" && cached.version.trim()
10940
+ ? cached.version.trim()
10941
+ : null;
10942
+ snapshot[backend] = {
10943
+ version,
10944
+ probing: needsRefresh && version === null,
10945
+ };
10946
+ }
10947
+ return snapshot;
10948
+ }
10949
+ /** Called only after the user-visible sysinfo send/respond has settled. */
10950
+ refreshBackendCliVersions() {
10951
+ for (const backend of SYSINFO_BACKEND_IDS) {
10952
+ if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
10953
+ continue;
10954
+ void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
10955
+ this.logger.debug({ err, backend }, "Background CLI version refresh failed");
10956
+ });
10957
+ }
10958
+ }
10328
10959
  /** True when a cached CLI env is old enough that `/model` should re-probe. */
10329
10960
  cliEnvNeedsRefresh(env) {
10330
10961
  return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
@@ -10335,33 +10966,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10335
10966
  * previous list, never stall the command that asked for it.
10336
10967
  */
10337
10968
  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);
10969
+ // #725: single-flight per backend. Explicit vendor refreshes remain a
10970
+ // separate flight from an ordinary probe, while sysinfo can join either.
10971
+ const regularFlight = this.pendingCliEnvProbes.get(backend);
10972
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
10973
+ if (opts.refreshVendorCatalog && vendorFlight)
10974
+ return vendorFlight;
10975
+ if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
10976
+ return vendorFlight ?? regularFlight;
10977
+ const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
10978
+ const work = worker?.promise ?? this.probeBackend(backend, opts);
10348
10979
  work.catch(() => { });
10349
10980
  let timer;
10350
10981
  const deadline = new Promise(resolve => {
10351
10982
  timer = setTimeout(() => {
10983
+ worker?.terminate();
10352
10984
  this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
10353
10985
  resolve(null);
10354
10986
  }, CLI_ENV_PROBE_DEADLINE_MS);
10355
10987
  });
10356
10988
  const bounded = Promise.race([work, deadline]).finally(() => {
10357
10989
  clearTimeout(timer);
10358
- if (this.pendingCliEnvProbes.get(backend) === bounded)
10359
- this.pendingCliEnvProbes.delete(backend);
10990
+ const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
10991
+ if (flights.get(backend) === bounded)
10992
+ flights.delete(backend);
10360
10993
  });
10361
- if (!opts.refreshVendorCatalog)
10362
- this.pendingCliEnvProbes.set(backend, bounded);
10994
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
10363
10995
  return bounded;
10364
10996
  }
10997
+ startCliEnvProbeWorker(backend, opts) {
10998
+ const workerData = {
10999
+ backend,
11000
+ instanceDir: join(getAgendHome(), "cli-env"),
11001
+ config: {
11002
+ workingDirectory: "",
11003
+ instanceDir: join(getAgendHome(), "cli-env"),
11004
+ instanceName: `probe-${backend}`,
11005
+ mcpServers: {},
11006
+ },
11007
+ refreshVendorCatalog: opts.refreshVendorCatalog === true,
11008
+ };
11009
+ // `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
11010
+ // Published builds load the compiled worker and require no tsx dependency.
11011
+ const worker = import.meta.url.endsWith(".ts")
11012
+ ? 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 } })
11013
+ : new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
11014
+ let settled = false;
11015
+ let resolveProbe;
11016
+ const promise = new Promise(resolve => { resolveProbe = resolve; });
11017
+ const finish = (value) => {
11018
+ if (settled)
11019
+ return;
11020
+ settled = true;
11021
+ resolveProbe(value);
11022
+ void worker.terminate().catch(err => {
11023
+ this.logger.debug({ backend, err }, "CLI env worker termination failed");
11024
+ });
11025
+ };
11026
+ worker.once("message", (message) => {
11027
+ if (settled)
11028
+ return;
11029
+ if (!message.ok) {
11030
+ this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
11031
+ finish(null);
11032
+ }
11033
+ else if (!message.result) {
11034
+ finish(null);
11035
+ }
11036
+ else {
11037
+ try {
11038
+ finish(this.persistCliEnvProbeResult(backend, message.result));
11039
+ }
11040
+ catch (err) {
11041
+ this.logger.warn({ backend, err }, "CLI env cache write failed");
11042
+ finish(null);
11043
+ }
11044
+ }
11045
+ });
11046
+ worker.once("error", err => {
11047
+ if (settled)
11048
+ return;
11049
+ this.logger.warn({ backend, err }, "CLI env worker crashed");
11050
+ finish(null);
11051
+ });
11052
+ worker.once("exit", code => {
11053
+ if (settled)
11054
+ return;
11055
+ this.logger.warn({ backend, code }, "CLI env worker exited without a result");
11056
+ finish(null);
11057
+ });
11058
+ return { promise, terminate: () => { finish(null); } };
11059
+ }
10365
11060
  /**
10366
11061
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
10367
11062
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -10428,35 +11123,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10428
11123
  if (opts.refreshVendorCatalog && be.refreshModelCatalog)
10429
11124
  await be.refreshModelCatalog();
10430
11125
  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;
11126
+ return this.persistCliEnvProbeResult(backend, probed);
10454
11127
  }
10455
11128
  catch (err) {
10456
11129
  this.logger.warn({ err, backend }, "CLI env probe failed");
10457
11130
  return null;
10458
11131
  }
10459
11132
  }
11133
+ /** Preserve the existing cache merge rules for both in-process and worker probes. */
11134
+ persistCliEnvProbeResult(backend, probed) {
11135
+ const env = { backend, probedAt: Date.now(), ...probed };
11136
+ // An empty result must never overwrite a catalog we already have. Some
11137
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
11138
+ // returns [] — and writing that would blank the list for the whole 24h
11139
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
11140
+ // antigravity cache replaced by an empty one. Keep the known models and
11141
+ // let the fresher currentModel/version through.
11142
+ if (!env.models?.length) {
11143
+ const previous = this.readCliEnv(backend);
11144
+ if (previous?.models?.length)
11145
+ env.models = previous.models;
11146
+ }
11147
+ // Same protection for the extended catalog: one offline moment must not
11148
+ // blank a good account list for the whole cache TTL.
11149
+ if (!env.apiModels?.length) {
11150
+ const previous = this.readCliEnv(backend);
11151
+ if (previous?.apiModels?.length)
11152
+ env.apiModels = previous.apiModels;
11153
+ }
11154
+ const path = this.cliEnvPath(backend);
11155
+ mkdirSync(dirname(path), { recursive: true });
11156
+ writeFileSync(path, JSON.stringify(env, null, 2));
11157
+ return env;
11158
+ }
10460
11159
  /** Background-probe every distinct backend in use at startup (non-blocking). */
10461
11160
  probeCliEnvs() {
10462
11161
  const backends = new Set();
@@ -11429,7 +12128,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11429
12128
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
11430
12129
  }
11431
12130
  /** Start a classic channel instance with lightweight config */
11432
- async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
12131
+ async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
11433
12132
  if (this.daemons.has(instanceName))
11434
12133
  return;
11435
12134
  const workDir = join(getAgendHome(), "workspaces", instanceName);
@@ -11456,7 +12155,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11456
12155
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
11457
12156
  };
11458
12157
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
11459
- await this.startInstance(instanceName, config, topicMode, "classic");
12158
+ await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
11460
12159
  }
11461
12160
  /** Handle /start slash command — register classic channel */
11462
12161
  async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
@@ -11568,6 +12267,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11568
12267
  clearInterval(this.replyObligationTimer);
11569
12268
  this.replyObligationTimer = null;
11570
12269
  }
12270
+ this.wakeCoordinator?.stop();
11571
12271
  if (this.logRotateTimer) {
11572
12272
  clearInterval(this.logRotateTimer);
11573
12273
  this.logRotateTimer = null;
@@ -13629,7 +14329,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13629
14329
  this.logger.info({ name }, "Instance restart requested via HTTP");
13630
14330
  (async () => {
13631
14331
  try {
13632
- await this.restartSingleInstance(name);
14332
+ await this.restartSingleInstance(name, { explicit: true });
13633
14333
  this.logger.info({ name }, "Instance restarted");
13634
14334
  this.emitSseEvent("status", this.getUiStatus());
13635
14335
  res.writeHead(200);