@songsid/agend 2.1.8 → 2.1.9

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 (95) hide show
  1. package/dist/backend/claude-code.d.ts +35 -5
  2. package/dist/backend/claude-code.js +288 -17
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/credential-profile.d.ts +9 -0
  5. package/dist/backend/credential-profile.js +20 -0
  6. package/dist/backend/credential-profile.js.map +1 -1
  7. package/dist/backend/grok.js +19 -0
  8. package/dist/backend/grok.js.map +1 -1
  9. package/dist/backend/muse.d.ts +6 -0
  10. package/dist/backend/muse.js +7 -1
  11. package/dist/backend/muse.js.map +1 -1
  12. package/dist/backend/types.d.ts +1 -1
  13. package/dist/channel/mcp-tools.js +1 -1
  14. package/dist/channel/mcp-tools.js.map +1 -1
  15. package/dist/cli.js +138 -48
  16. package/dist/cli.js.map +1 -1
  17. package/dist/config-validator.js +11 -0
  18. package/dist/config-validator.js.map +1 -1
  19. package/dist/config.d.ts +1 -0
  20. package/dist/config.js +1 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/cross-instance-envelope.d.ts +7 -0
  23. package/dist/cross-instance-envelope.js +19 -1
  24. package/dist/cross-instance-envelope.js.map +1 -1
  25. package/dist/daemon.d.ts +89 -3
  26. package/dist/daemon.js +301 -39
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/delivery-outbox.d.ts +9 -1
  29. package/dist/delivery-outbox.js +25 -4
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +99 -5
  32. package/dist/fleet-manager.js +405 -65
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/forged-envelope-scan.d.ts +160 -0
  35. package/dist/forged-envelope-scan.js +312 -0
  36. package/dist/forged-envelope-scan.js.map +1 -0
  37. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
  38. package/dist/instance-lifecycle.d.ts +81 -6
  39. package/dist/instance-lifecycle.js +201 -17
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/locale.js +14 -4
  42. package/dist/locale.js.map +1 -1
  43. package/dist/login-controller.js +11 -4
  44. package/dist/login-controller.js.map +1 -1
  45. package/dist/login-flows.d.ts +18 -1
  46. package/dist/login-flows.js +9 -2
  47. package/dist/login-flows.js.map +1 -1
  48. package/dist/ls-rows.d.ts +17 -0
  49. package/dist/ls-rows.js +73 -0
  50. package/dist/ls-rows.js.map +1 -1
  51. package/dist/outbound-handlers.d.ts +3 -0
  52. package/dist/outbound-handlers.js +3 -3
  53. package/dist/outbound-handlers.js.map +1 -1
  54. package/dist/outbound-schemas.d.ts +2 -0
  55. package/dist/outbound-schemas.js +1 -1
  56. package/dist/outbound-schemas.js.map +1 -1
  57. package/dist/paths.js +4 -2
  58. package/dist/paths.js.map +1 -1
  59. package/dist/pause-marker.d.ts +10 -1
  60. package/dist/pause-marker.js +26 -2
  61. package/dist/pause-marker.js.map +1 -1
  62. package/dist/service-installer.d.ts +8 -0
  63. package/dist/service-installer.js +36 -1
  64. package/dist/service-installer.js.map +1 -1
  65. package/dist/settings-api.d.ts +2 -0
  66. package/dist/settings-api.js +3 -1
  67. package/dist/settings-api.js.map +1 -1
  68. package/dist/status-emojis.d.ts +9 -1
  69. package/dist/status-emojis.js +11 -1
  70. package/dist/status-emojis.js.map +1 -1
  71. package/dist/target-queue-worker.d.ts +67 -0
  72. package/dist/target-queue-worker.js +76 -0
  73. package/dist/target-queue-worker.js.map +1 -0
  74. package/dist/tool-permissions.js +7 -2
  75. package/dist/tool-permissions.js.map +1 -1
  76. package/dist/topic-commands.js +23 -19
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/transcript-sources.d.ts +30 -0
  79. package/dist/transcript-sources.js +78 -0
  80. package/dist/transcript-sources.js.map +1 -1
  81. package/dist/types.d.ts +26 -1
  82. package/dist/types.js +9 -1
  83. package/dist/types.js.map +1 -1
  84. package/dist/ui/settings.html +2 -2
  85. package/dist/wake-coordinator.d.ts +143 -0
  86. package/dist/wake-coordinator.js +321 -0
  87. package/dist/wake-coordinator.js.map +1 -0
  88. package/dist/web-api.d.ts +3 -1
  89. package/dist/web-api.js +1 -1
  90. package/dist/web-api.js.map +1 -1
  91. package/dist/web-terminal.d.ts +2 -0
  92. package/dist/web-terminal.js +3 -6
  93. package/dist/web-terminal.js.map +1 -1
  94. package/package.json +1 -1
  95. package/templates/systemd.service.ejs +7 -0
@@ -1,10 +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
6
  import { Worker } from "node:worker_threads";
7
- import { join, dirname, basename } from "node:path";
7
+ import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
10
10
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
@@ -41,7 +41,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
41
41
  import { TmuxControlClient } from "./tmux-control.js";
42
42
  import { safeHandler } from "./safe-async.js";
43
43
  import { RoutingEngine } from "./routing-engine.js";
44
- import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
44
+ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
45
45
  import { TopicArchiver } from "./topic-archiver.js";
46
46
  import { StatuslineWatcher } from "./statusline-watcher.js";
47
47
  import { outboundHandlers } from "./outbound-handlers.js";
@@ -66,7 +66,10 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
66
66
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
67
67
  import { validateFleetConfig } from "./config-validator.js";
68
68
  import { readLastInboundAt } from "./daemon.js";
69
- 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";
70
73
  import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
71
74
  import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
72
75
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
@@ -286,6 +289,14 @@ export class FleetManager {
286
289
  managerBootId = randomUUID();
287
290
  deliveryOutbox = null;
288
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();
289
300
  deliveryPumpScheduled = false;
290
301
  deliveryPumpRunning = false;
291
302
  deliveryPumpTimer = null;
@@ -570,9 +581,15 @@ export class FleetManager {
570
581
  this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
571
582
  this.deliveryOutboxRecovered = true;
572
583
  }
573
- outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
584
+ outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
574
585
  outbox.on("state", (event) => {
575
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
+ }
576
593
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
577
594
  const row = outbox.get(event.deliveryId);
578
595
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
@@ -591,8 +608,11 @@ export class FleetManager {
591
608
  outbox.on("content_digest_mismatch", (event) => {
592
609
  this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
593
610
  });
611
+ this.wakeCoordinator = this.createWakeCoordinator();
612
+ this.wakeCoordinator.start();
594
613
  outbox.on("expired", (event) => {
595
614
  this.scheduleDeliveryOutboxPump();
615
+ this.wakeCoordinator?.kick();
596
616
  if (event.count)
597
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.`);
598
618
  });
@@ -619,6 +639,9 @@ export class FleetManager {
619
639
  kind: input.kind,
620
640
  }, "Durable cross-instance delivery accepted");
621
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();
622
645
  return {
623
646
  deliveryId: admitted.delivery.deliveryId,
624
647
  state: admitted.delivery.state,
@@ -681,7 +704,7 @@ export class FleetManager {
681
704
  }
682
705
  }
683
706
  scheduleDeliveryOutboxPump(delayMs = 0) {
684
- if (!this.deliveryOutbox || this.shuttingDown)
707
+ if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
685
708
  return;
686
709
  if (this.deliveryPumpTimer) {
687
710
  if (delayMs > 0)
@@ -701,14 +724,27 @@ export class FleetManager {
701
724
  }
702
725
  async runDeliveryOutboxPump() {
703
726
  const outbox = this.deliveryOutbox;
704
- 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)
705
731
  return;
706
732
  this.deliveryPumpRunning = true;
707
733
  this.deliveryPumpScheduled = false;
708
734
  try {
709
- 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);
710
740
  while (this.activeDurableTargets.size < 8) {
711
- 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);
712
748
  if (!claimed)
713
749
  break;
714
750
  this.activeDurableTargets.add(claimed.targetInstance);
@@ -736,7 +772,67 @@ export class FleetManager {
736
772
  }
737
773
  }
738
774
  }
739
- 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 = {}) {
740
836
  const outbox = this.deliveryOutbox;
741
837
  if (!outbox)
742
838
  return;
@@ -762,7 +858,7 @@ export class FleetManager {
762
858
  content: payload.content,
763
859
  delivery_id: claimed.deliveryId,
764
860
  delivery_attempt: String(claimed.attemptNo),
765
- }, { waitForIdle: false });
861
+ }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
766
862
  }
767
863
  else {
768
864
  payload.meta = {
@@ -777,6 +873,7 @@ export class FleetManager {
777
873
  sent = await this.deliverToInstance(target, payload, {
778
874
  isCrossInstance: true,
779
875
  waitForIdle: claimed.kind !== "steer",
876
+ noInlineWake: this.deliveryWorkerMode(target) !== "off",
780
877
  });
781
878
  }
782
879
  if (!sent) {
@@ -789,7 +886,10 @@ export class FleetManager {
789
886
  catch (err) {
790
887
  const latest = outbox.get(claimed.deliveryId);
791
888
  const reason = err instanceof Error ? err.message : String(err);
792
- 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") {
793
893
  outbox.complete(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, "uncertain", reason);
794
894
  }
795
895
  else {
@@ -1688,13 +1788,15 @@ export class FleetManager {
1688
1788
  exclude,
1689
1789
  isEvicting: name => this.warmCapEvicting.has(name),
1690
1790
  isGeneral: name => isGeneralInstance(this.fleetConfig, name),
1691
- 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),
1692
1794
  lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
1693
1795
  });
1694
1796
  for (const victim of victims) {
1695
1797
  this.warmCapEvicting.add(victim);
1696
1798
  this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
1697
- this.lifecycle.pause(victim)
1799
+ this.lifecycle.pause(victim, "warm_cap")
1698
1800
  .catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
1699
1801
  .finally(() => this.warmCapEvicting.delete(victim));
1700
1802
  }
@@ -1787,16 +1889,22 @@ export class FleetManager {
1787
1889
  finish(false);
1788
1890
  });
1789
1891
  }
1790
- async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1892
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
1791
1893
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1792
1894
  return false;
1793
1895
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
1794
1896
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1795
1897
  return false;
1796
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
+ }
1797
1905
  if (this.lifecycle.isPaused(instanceName)) {
1798
1906
  const wakeStartedAt = Date.now();
1799
- await this.lifecycle.wake(instanceName, 30_000);
1907
+ await this.explicitWake(instanceName, 30_000);
1800
1908
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1801
1909
  return false;
1802
1910
  // Waking added one to the warm count — make room by evicting a different
@@ -1919,7 +2027,11 @@ export class FleetManager {
1919
2027
  ?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
1920
2028
  if (!waitForIdle) {
1921
2029
  if (this.lifecycle.isPaused(instanceName)) {
1922
- 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);
1923
2035
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1924
2036
  return false;
1925
2037
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
@@ -1935,7 +2047,7 @@ export class FleetManager {
1935
2047
  return true;
1936
2048
  }
1937
2049
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
1938
- 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));
1939
2051
  this.idleGatedDeliveryTails.set(instanceName, delivery);
1940
2052
  try {
1941
2053
  return await delivery;
@@ -1951,9 +2063,117 @@ export class FleetManager {
1951
2063
  const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
1952
2064
  return allowed.some(entry => String(entry) === String(userId));
1953
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
+ }
1954
2174
  async changeInstancePauseState(name, action) {
1955
2175
  if (action === "wake") {
1956
- await this.lifecycle.wake(name, 30_000);
2176
+ await this.explicitWake(name, 30_000);
1957
2177
  this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
1958
2178
  return "awake";
1959
2179
  }
@@ -2072,12 +2292,14 @@ export class FleetManager {
2072
2292
  * and reconcile calls leave this false so a persisted pause remains paused
2073
2293
  * across a fleet restart.
2074
2294
  */
2075
- resumePaused = false) {
2295
+ resumePaused = false,
2296
+ /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
2297
+ transition) {
2076
2298
  // Any start supersedes a pending automatic retry (it would otherwise fire
2077
2299
  // into a running instance — harmless, but noisy — or race this start).
2078
2300
  this.cancelStartupRetry(name);
2079
2301
  if (resumePaused && this.lifecycle.isPaused(name)) {
2080
- await this.lifecycle.wake(name, 30_000);
2302
+ await this.explicitWake(name, 30_000);
2081
2303
  // A successful wake clears the persisted pause marker and produces a
2082
2304
  // fresh instance_state snapshot. Drop any stale process error left by a
2083
2305
  // pre-pause crash so /api/fleet and `agend ls` converge on running.
@@ -2104,6 +2326,10 @@ export class FleetManager {
2104
2326
  this.logger.info({ name }, "Instance already running, skipping");
2105
2327
  return;
2106
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);
2107
2333
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
2108
2334
  if (config.general_topic) {
2109
2335
  this.ensureGeneralInstructions(config.working_directory, backend, name);
@@ -2127,7 +2353,7 @@ export class FleetManager {
2127
2353
  kind,
2128
2354
  backend,
2129
2355
  model: this.resolveInstanceModel(name).display,
2130
- });
2356
+ }, transition);
2131
2357
  // Only clear a stale process status after a real start succeeded. Clearing
2132
2358
  // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
2133
2359
  // a falsely running instance when lifecycle.start() returns early.
@@ -2141,18 +2367,18 @@ export class FleetManager {
2141
2367
  this.requestDiscordUsagePresenceRefresh();
2142
2368
  }
2143
2369
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
2144
- async startPersistedPausedInstance(name) {
2370
+ async startPersistedPausedInstance(name, transition) {
2145
2371
  const topicMode = this.fleetConfig?.channel?.mode === "topic"
2146
2372
  || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
2147
2373
  const fleetConfig = this.fleetConfig?.instances[name];
2148
2374
  if (fleetConfig) {
2149
- await this.startInstance(name, fleetConfig, topicMode);
2375
+ await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
2150
2376
  return;
2151
2377
  }
2152
2378
  const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
2153
2379
  if (!channel || !this.classicChannels)
2154
2380
  throw new Error(`Paused instance '${name}' is no longer configured`);
2155
- 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);
2156
2382
  }
2157
2383
  /**
2158
2384
  * Start instances with configurable concurrency and stagger delay.
@@ -2201,6 +2427,10 @@ export class FleetManager {
2201
2427
  return this.daemons.has(name);
2202
2428
  }
2203
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;
2204
2434
  this.logger.error({ err, name }, `Failed to start ${what}`);
2205
2435
  this.scheduleStartupRetry(name, 0);
2206
2436
  return false;
@@ -2322,6 +2552,9 @@ export class FleetManager {
2322
2552
  }
2323
2553
  }
2324
2554
  catch (err) {
2555
+ // Superseded by a stop/restart: that transition owns the outcome.
2556
+ if (err instanceof SupersededStartError)
2557
+ return;
2325
2558
  this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
2326
2559
  this.scheduleStartupRetry(name, attempt + 1);
2327
2560
  }
@@ -2362,6 +2595,8 @@ export class FleetManager {
2362
2595
  return this.daemons.has(ch.instanceName);
2363
2596
  }
2364
2597
  catch (err) {
2598
+ if (err instanceof SupersededStartError)
2599
+ return false;
2365
2600
  this.logger.warn({ err, instanceName: ch.instanceName }, `Failed to start ${what}`);
2366
2601
  this.scheduleStartupRetry(ch.instanceName, 0);
2367
2602
  return false;
@@ -2518,7 +2753,7 @@ export class FleetManager {
2518
2753
  }
2519
2754
  return false;
2520
2755
  }
2521
- async stopInstance(name) {
2756
+ async stopInstance(name, transition) {
2522
2757
  this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
2523
2758
  this.cancelStartupRetry(name);
2524
2759
  this.failoverActive.delete(name);
@@ -2537,7 +2772,7 @@ export class FleetManager {
2537
2772
  // the hand-off snapshotted it).
2538
2773
  const run = (async () => {
2539
2774
  try {
2540
- await this.lifecycle.stop(name);
2775
+ await this.lifecycle.stop(name, transition);
2541
2776
  }
2542
2777
  finally {
2543
2778
  this.clearNoncePromptsForInstance(name);
@@ -2559,11 +2794,16 @@ export class FleetManager {
2559
2794
  return inFlight;
2560
2795
  }
2561
2796
  const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
2562
- 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({
2563
2803
  instanceName: name,
2564
2804
  workingDirectory,
2565
2805
  reason: "restart",
2566
- }, () => this.doRestartSingleInstance(name, opts))
2806
+ }, () => this.doRestartSingleInstance(name, opts, transition)))
2567
2807
  .finally(() => {
2568
2808
  this.restartsInFlight.delete(name);
2569
2809
  this.scheduleDeliveryOutboxPump();
@@ -2574,19 +2814,73 @@ export class FleetManager {
2574
2814
  isInstanceRestarting(name) {
2575
2815
  return this.restartsInFlight.has(name);
2576
2816
  }
2577
- 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) {
2578
2846
  if (this.configPath) {
2579
2847
  this.loadConfig(this.configPath);
2580
2848
  this.routing.rebuild(this.fleetConfig);
2581
2849
  this.reregisterClassicChannels();
2582
2850
  }
2583
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;
2584
2878
  if (config) {
2585
- await this.stopInstance(name);
2879
+ await this.stopInstance(name, transition);
2586
2880
  if (opts?.freshStart)
2587
2881
  this.writeFreshStartMarker(name);
2588
2882
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
2589
- 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));
2590
2884
  this.stormWindow.markRecovered(name);
2591
2885
  return;
2592
2886
  }
@@ -2595,11 +2889,11 @@ export class FleetManager {
2595
2889
  if (channelId) {
2596
2890
  const fleetBackend = this.fleetConfig?.defaults?.backend;
2597
2891
  const adapterId = this.classicChannels.getAdapterIdByInstance(name);
2598
- await this.stopInstance(name);
2892
+ await this.stopInstance(name, transition);
2599
2893
  await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
2600
2894
  if (opts?.freshStart)
2601
2895
  this.writeFreshStartMarker(name);
2602
- 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));
2603
2897
  this.stormWindow.markRecovered(name);
2604
2898
  return;
2605
2899
  }
@@ -6002,10 +6296,15 @@ export class FleetManager {
6002
6296
  ?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
6003
6297
  ?? instanceName;
6004
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
+ }
6005
6304
  /** Persist identity to the instance's actual config store. Classic instances
6006
6305
  * are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
6007
6306
  setInstanceDisplayName(instanceName, displayName) {
6008
- const fleetInstance = this.fleetConfig?.instances[instanceName];
6307
+ const fleetInstance = this.ownInstanceConfig(instanceName);
6009
6308
  if (fleetInstance) {
6010
6309
  fleetInstance.display_name = displayName;
6011
6310
  this.saveFleetConfig();
@@ -6014,7 +6313,7 @@ export class FleetManager {
6014
6313
  return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
6015
6314
  }
6016
6315
  setInstanceDescription(instanceName, description) {
6017
- const fleetInstance = this.fleetConfig?.instances[instanceName];
6316
+ const fleetInstance = this.ownInstanceConfig(instanceName);
6018
6317
  if (fleetInstance) {
6019
6318
  fleetInstance.description = description;
6020
6319
  this.saveFleetConfig();
@@ -6970,19 +7269,26 @@ export class FleetManager {
6970
7269
  * Settings picker (#1005/#1021), so an agent and an operator see one truth.
6971
7270
  */
6972
7271
  async listEmojisFor(instanceName, refresh = false) {
6973
- const self = this.fleetConfig?.instances[instanceName];
6974
- if (!self)
7272
+ // Seeing is not setting: a ClassicBot instance has no per-instance stamp,
7273
+ // but it reacts in its own channel like any bot and must be able to see
7274
+ // the emojis it can use (only set_persona_emoji refuses it).
7275
+ // Own entries only: an inherited name (constructor, __proto__, toString…)
7276
+ // is not a fleet instance, as set_persona_emoji already holds.
7277
+ const self = this.ownInstanceConfig(instanceName);
7278
+ const classic = !self && this.isClassicPersonaTarget(instanceName);
7279
+ if (!self && !classic)
6975
7280
  return { error: this.personaEmojiMissing(instanceName) };
6976
7281
  const worldId = this.getInstanceAdapterId(instanceName);
6977
7282
  const { platform } = this.resolveStatusEmojisFor(instanceName);
6978
7283
  const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
6979
- const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
7284
+ const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self?.status_emojis });
6980
7285
  const out = {
6981
7286
  platform: platform ?? null,
6982
7287
  statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
6983
7288
  standard: platform === "telegram"
6984
7289
  ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
6985
7290
  : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
7291
+ ...(classic ? { note: "ClassicBot instances have no per-instance status emojis: the statuses above are the connection's, which an operator sets in Settings. You can still use any emoji listed here in your own reactions." } : {}),
6986
7292
  };
6987
7293
  if (platform === "discord" && worldId) {
6988
7294
  const listed = await this.listGuildEmojis(worldId, refresh);
@@ -7013,9 +7319,10 @@ export class FleetManager {
7013
7319
  * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
7014
7320
  */
7015
7321
  async previewEmojis(instanceName, args) {
7016
- const self = this.fleetConfig?.instances[instanceName];
7017
- if (!self)
7322
+ // Seeing, like list_emojis: ClassicBot instances may preview too.
7323
+ if (!this.ownInstanceConfig(instanceName) && !this.isClassicPersonaTarget(instanceName)) {
7018
7324
  return { error: this.personaEmojiMissing(instanceName) };
7325
+ }
7019
7326
  const wanted = args.emojis;
7020
7327
  if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
7021
7328
  return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
@@ -7110,7 +7417,7 @@ export class FleetManager {
7110
7417
  * An empty emoji removes that status's override.
7111
7418
  */
7112
7419
  async setPersonaEmoji(instanceName, args) {
7113
- const self = this.fleetConfig?.instances[instanceName];
7420
+ const self = this.ownInstanceConfig(instanceName);
7114
7421
  if (!self)
7115
7422
  return { error: this.personaEmojiMissing(instanceName) };
7116
7423
  const status = (args.status ?? "delivered");
@@ -7155,8 +7462,9 @@ export class FleetManager {
7155
7462
  // Re-read after the validation above, which may have waited on Discord:
7156
7463
  // another set_persona_emoji or a Settings save can have changed another
7157
7464
  // status, or replaced this instance's config object, meanwhile. Change
7158
- // only this one key on what is there now.
7159
- const current = this.fleetConfig?.instances[instanceName];
7465
+ // only this one key on what is there now. Ownership must still hold:
7466
+ // removal during the await must not fall through to an inherited entry.
7467
+ const current = this.ownInstanceConfig(instanceName);
7160
7468
  if (!current)
7161
7469
  return { error: this.personaEmojiMissing(instanceName) };
7162
7470
  const map = { ...(current.status_emojis ?? {}) };
@@ -7173,6 +7481,10 @@ export class FleetManager {
7173
7481
  const resolved = this.resolveStatusEmojisFor(instanceName);
7174
7482
  return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
7175
7483
  }
7484
+ /** A ClassicBot instance: it may see (list/preview) emojis, never set a per-instance stamp. */
7485
+ isClassicPersonaTarget(instanceName) {
7486
+ return this.classicChannels?.getAll().some(c => c.instanceName === instanceName) ?? false;
7487
+ }
7176
7488
  personaEmojiMissing(instanceName) {
7177
7489
  return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
7178
7490
  ? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
@@ -7234,6 +7546,16 @@ export class FleetManager {
7234
7546
  const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
7235
7547
  return reactionForm(resolved.platform, resolved.received);
7236
7548
  }
7549
+ /**
7550
+ * The stamp for an inbound photo / file a classic bot saved (#1080): the
7551
+ * instance's `status_emojis.photo|attachment`, then its connection's, then
7552
+ * the built-in (📸/📎, or 👌/👍 on Telegram — what these paths always stamped).
7553
+ * Anything else a saved attachment can be counts as a file.
7554
+ */
7555
+ savedAttachmentReactionFor(instanceName, adapter, adapterId, kind) {
7556
+ const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
7557
+ return reactionForm(resolved.platform, kind === "photo" ? resolved.photo : resolved.attachment);
7558
+ }
7237
7559
  reactMessageStatus(instanceName, chatId, messageId, status, threadId) {
7238
7560
  // React via the adapter BOUND to this instance — NOT the first discord world.
7239
7561
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -9683,10 +10005,16 @@ export class FleetManager {
9683
10005
  }
9684
10006
  // The installer may only have added the binary to a profile PATH; a
9685
10007
  // fresh login shell sees that, the fleet process's PATH may not.
9686
- if (!this.verifyBinaryOnLoginShell(info.binary)) {
10008
+ const installedAt = this.locateBinaryOnLoginShell(info.binary);
10009
+ if (!installedAt) {
9687
10010
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
9688
10011
  return;
9689
10012
  }
10013
+ // #1059: make it reachable from this process too. `/login` lists what
10014
+ // `which` finds on the fleet's own PATH, and instances resolve their
10015
+ // binary the same way, so a binary only a login shell could see was
10016
+ // installed, reported as verified, and then offered nowhere.
10017
+ this.adoptBinaryDirectory(installedAt, backend);
9690
10018
  if (!LOGIN_FLOWS[backend]) {
9691
10019
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
9692
10020
  return;
@@ -9746,15 +10074,35 @@ export class FleetManager {
9746
10074
  return t("install.cancelled", backend);
9747
10075
  }
9748
10076
  /** `command -v` on a login shell, so PATH additions from rc files count. */
9749
- verifyBinaryOnLoginShell(binary) {
10077
+ /**
10078
+ * The absolute path a fresh login shell resolves `binary` to, or null. Only
10079
+ * a real executable file counts: `command -v` also answers with an alias
10080
+ * definition or a function name, neither of which a spawn could run.
10081
+ */
10082
+ locateBinaryOnLoginShell(binary) {
9750
10083
  try {
9751
- const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
9752
- return result.status === 0;
10084
+ const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" });
10085
+ if (result.status !== 0)
10086
+ return null;
10087
+ const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
10088
+ if (!isAbsolute(path))
10089
+ return null;
10090
+ accessSync(path, fsConstants.X_OK);
10091
+ return statSync(path).isFile() ? path : null;
9753
10092
  }
9754
10093
  catch {
9755
- return false;
10094
+ return null;
9756
10095
  }
9757
10096
  }
10097
+ /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
10098
+ adoptBinaryDirectory(binaryPath, backend) {
10099
+ const dir = dirname(binaryPath);
10100
+ const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
10101
+ if (entries.includes(dir))
10102
+ return;
10103
+ process.env.PATH = [dir, ...entries].join(delimiter);
10104
+ this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
10105
+ }
9758
10106
  /** "Sign in now?" button after a successful install. */
9759
10107
  async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
9760
10108
  const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
@@ -10325,9 +10673,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10325
10673
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10326
10674
  const noMentionReactChatId = msg.threadId ?? msg.chatId;
10327
10675
  if (reactAdapter && noMentionReactChatId && msg.messageId) {
10328
- const emoji = msg.source === "telegram"
10329
- ? (saved.kind === "photo" ? "👌" : "👍")
10330
- : (saved.kind === "photo" ? "📸" : "📎");
10676
+ const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
10331
10677
  reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
10332
10678
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10333
10679
  }
@@ -10358,9 +10704,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10358
10704
  return;
10359
10705
  // Attachments already saved at the top of the collab block.
10360
10706
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
10361
- const emoji = msg.source === "telegram"
10362
- ? (saved.kind === "photo" ? "👌" : "👍")
10363
- : (saved.kind === "photo" ? "📸" : "📎");
10707
+ const emoji = this.savedAttachmentReactionFor(instanceName, classicAdapter, msg.adapterId, saved.kind);
10364
10708
  classicAdapter.react(collabReactChatId, msg.messageId, emoji)
10365
10709
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10366
10710
  }
@@ -10408,10 +10752,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10408
10752
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10409
10753
  const reactChatId = msg.threadId ?? msg.chatId;
10410
10754
  if (saved && reactAdapter && reactChatId && msg.messageId) {
10411
- // Telegram only supports limited emoji for reactions; use 👌 for photo, 👍 for file
10412
- const emoji = msg.source === "telegram"
10413
- ? (saved.kind === "photo" ? "👌" : "👍")
10414
- : (saved.kind === "photo" ? "📸" : "📎");
10755
+ const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
10415
10756
  reactAdapter.react(reactChatId, msg.messageId, emoji)
10416
10757
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10417
10758
  }
@@ -10457,9 +10798,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10457
10798
  classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
10458
10799
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10459
10800
  if (saved) {
10460
- const savedEmoji = msg.source === "telegram"
10461
- ? (saved.kind === "photo" ? "👌" : "👍")
10462
- : (saved.kind === "photo" ? "📸" : "📎");
10801
+ const savedEmoji = this.savedAttachmentReactionFor(instanceName, classicMsgAdapter, msg.adapterId, saved.kind);
10463
10802
  classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji)
10464
10803
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10465
10804
  }
@@ -11801,7 +12140,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11801
12140
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
11802
12141
  }
11803
12142
  /** Start a classic channel instance with lightweight config */
11804
- async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
12143
+ async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
11805
12144
  if (this.daemons.has(instanceName))
11806
12145
  return;
11807
12146
  const workDir = join(getAgendHome(), "workspaces", instanceName);
@@ -11828,7 +12167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11828
12167
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
11829
12168
  };
11830
12169
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
11831
- await this.startInstance(instanceName, config, topicMode, "classic");
12170
+ await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
11832
12171
  }
11833
12172
  /** Handle /start slash command — register classic channel */
11834
12173
  async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
@@ -11940,6 +12279,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11940
12279
  clearInterval(this.replyObligationTimer);
11941
12280
  this.replyObligationTimer = null;
11942
12281
  }
12282
+ this.wakeCoordinator?.stop();
11943
12283
  if (this.logRotateTimer) {
11944
12284
  clearInterval(this.logRotateTimer);
11945
12285
  this.logRotateTimer = null;
@@ -14001,7 +14341,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14001
14341
  this.logger.info({ name }, "Instance restart requested via HTTP");
14002
14342
  (async () => {
14003
14343
  try {
14004
- await this.restartSingleInstance(name);
14344
+ await this.restartSingleInstance(name, { explicit: true });
14005
14345
  this.logger.info({ name }, "Instance restarted");
14006
14346
  this.emitSseEvent("status", this.getUiStatus());
14007
14347
  res.writeHead(200);