@songsid/agend 2.1.5 → 2.1.6-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/dist/agent-endpoint.d.ts +18 -0
  2. package/dist/agent-endpoint.js +53 -2
  3. package/dist/agent-endpoint.js.map +1 -1
  4. package/dist/apply-job.d.ts +114 -0
  5. package/dist/apply-job.js +214 -0
  6. package/dist/apply-job.js.map +1 -0
  7. package/dist/backend/claude-code.d.ts +25 -0
  8. package/dist/backend/claude-code.js +127 -0
  9. package/dist/backend/claude-code.js.map +1 -1
  10. package/dist/backend/codex.d.ts +57 -0
  11. package/dist/backend/codex.js +183 -0
  12. package/dist/backend/codex.js.map +1 -1
  13. package/dist/backend/credential-profile.d.ts +159 -0
  14. package/dist/backend/credential-profile.js +340 -0
  15. package/dist/backend/credential-profile.js.map +1 -0
  16. package/dist/backend/factory.js +4 -1
  17. package/dist/backend/factory.js.map +1 -1
  18. package/dist/backend/kiro-auth-store.d.ts +25 -0
  19. package/dist/backend/kiro-auth-store.js +61 -0
  20. package/dist/backend/kiro-auth-store.js.map +1 -0
  21. package/dist/backend/kiro.d.ts +8 -0
  22. package/dist/backend/kiro.js +31 -2
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/muse.d.ts +90 -0
  25. package/dist/backend/muse.js +437 -0
  26. package/dist/backend/muse.js.map +1 -0
  27. package/dist/backend/types.d.ts +54 -0
  28. package/dist/backend/types.js.map +1 -1
  29. package/dist/channel/access-manager.d.ts +20 -0
  30. package/dist/channel/access-manager.js +25 -0
  31. package/dist/channel/access-manager.js.map +1 -1
  32. package/dist/channel/adapters/discord.d.ts +17 -0
  33. package/dist/channel/adapters/discord.js +72 -11
  34. package/dist/channel/adapters/discord.js.map +1 -1
  35. package/dist/channel/adapters/telegram.d.ts +6 -0
  36. package/dist/channel/adapters/telegram.js +36 -0
  37. package/dist/channel/adapters/telegram.js.map +1 -1
  38. package/dist/channel/markdown-chunk.d.ts +8 -3
  39. package/dist/channel/markdown-chunk.js +12 -3
  40. package/dist/channel/markdown-chunk.js.map +1 -1
  41. package/dist/channel/mcp-server.js +9 -12
  42. package/dist/channel/mcp-server.js.map +1 -1
  43. package/dist/channel/mcp-tools.d.ts +6 -2
  44. package/dist/channel/mcp-tools.js +6 -36
  45. package/dist/channel/mcp-tools.js.map +1 -1
  46. package/dist/channel/tool-router.js +32 -3
  47. package/dist/channel/tool-router.js.map +1 -1
  48. package/dist/channel/types.d.ts +18 -0
  49. package/dist/cli.js +113 -0
  50. package/dist/cli.js.map +1 -1
  51. package/dist/config-validator.js +45 -5
  52. package/dist/config-validator.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +96 -0
  54. package/dist/connection-secrets.js +25 -0
  55. package/dist/connection-secrets.js.map +1 -0
  56. package/dist/daemon.d.ts +76 -0
  57. package/dist/daemon.js +489 -103
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/event-log.d.ts +9 -0
  60. package/dist/event-log.js +21 -0
  61. package/dist/event-log.js.map +1 -1
  62. package/dist/fleet-level-config.d.ts +42 -0
  63. package/dist/fleet-level-config.js +78 -0
  64. package/dist/fleet-level-config.js.map +1 -0
  65. package/dist/fleet-lock.d.ts +21 -0
  66. package/dist/fleet-lock.js +42 -8
  67. package/dist/fleet-lock.js.map +1 -1
  68. package/dist/fleet-manager.d.ts +425 -2
  69. package/dist/fleet-manager.js +2055 -182
  70. package/dist/fleet-manager.js.map +1 -1
  71. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
  72. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
  73. package/dist/instance-config-impact.d.ts +55 -0
  74. package/dist/instance-config-impact.js +152 -0
  75. package/dist/instance-config-impact.js.map +1 -0
  76. package/dist/instance-lifecycle.js +8 -4
  77. package/dist/instance-lifecycle.js.map +1 -1
  78. package/dist/locale.js +24 -0
  79. package/dist/locale.js.map +1 -1
  80. package/dist/logger.js +42 -25
  81. package/dist/logger.js.map +1 -1
  82. package/dist/muse-usage-relay.d.ts +72 -0
  83. package/dist/muse-usage-relay.js +419 -0
  84. package/dist/muse-usage-relay.js.map +1 -0
  85. package/dist/outbound-handlers.d.ts +5 -1
  86. package/dist/outbound-handlers.js +199 -1
  87. package/dist/outbound-handlers.js.map +1 -1
  88. package/dist/outbound-schemas.d.ts +7 -5
  89. package/dist/outbound-schemas.js +3 -2
  90. package/dist/outbound-schemas.js.map +1 -1
  91. package/dist/provider-probe.d.ts +48 -0
  92. package/dist/provider-probe.js +121 -0
  93. package/dist/provider-probe.js.map +1 -0
  94. package/dist/provider-secret-registry.d.ts +98 -0
  95. package/dist/provider-secret-registry.js +334 -0
  96. package/dist/provider-secret-registry.js.map +1 -0
  97. package/dist/quickstart-api.d.ts +164 -0
  98. package/dist/quickstart-api.js +351 -0
  99. package/dist/quickstart-api.js.map +1 -0
  100. package/dist/quickstart.js +23 -50
  101. package/dist/quickstart.js.map +1 -1
  102. package/dist/scheduler/db.js +17 -5
  103. package/dist/scheduler/db.js.map +1 -1
  104. package/dist/scheduler/db.test.js +34 -1
  105. package/dist/scheduler/db.test.js.map +1 -1
  106. package/dist/scheduler/types.d.ts +4 -0
  107. package/dist/scheduler/types.js.map +1 -1
  108. package/dist/secret-store.d.ts +25 -0
  109. package/dist/secret-store.js +165 -0
  110. package/dist/secret-store.js.map +1 -0
  111. package/dist/self-restart-limit.d.ts +21 -0
  112. package/dist/self-restart-limit.js +129 -0
  113. package/dist/self-restart-limit.js.map +1 -0
  114. package/dist/settings-api.d.ts +116 -0
  115. package/dist/settings-api.js +530 -5
  116. package/dist/settings-api.js.map +1 -1
  117. package/dist/setup-auth.d.ts +113 -0
  118. package/dist/setup-auth.js +181 -0
  119. package/dist/setup-auth.js.map +1 -0
  120. package/dist/setup-form.d.ts +18 -0
  121. package/dist/setup-form.js +315 -0
  122. package/dist/setup-form.js.map +1 -0
  123. package/dist/setup-host.d.ts +162 -0
  124. package/dist/setup-host.js +496 -0
  125. package/dist/setup-host.js.map +1 -0
  126. package/dist/setup-marker.d.ts +8 -0
  127. package/dist/setup-marker.js +39 -0
  128. package/dist/setup-marker.js.map +1 -0
  129. package/dist/setup-tunnel-consent.d.ts +53 -0
  130. package/dist/setup-tunnel-consent.js +82 -0
  131. package/dist/setup-tunnel-consent.js.map +1 -0
  132. package/dist/setup-wizard.js +4 -0
  133. package/dist/setup-wizard.js.map +1 -1
  134. package/dist/steer-capability.js +4 -1
  135. package/dist/steer-capability.js.map +1 -1
  136. package/dist/tips.js +1 -1
  137. package/dist/tips.js.map +1 -1
  138. package/dist/tmux-manager.js +29 -4
  139. package/dist/tmux-manager.js.map +1 -1
  140. package/dist/tool-permissions-notice.d.ts +38 -0
  141. package/dist/tool-permissions-notice.js +92 -0
  142. package/dist/tool-permissions-notice.js.map +1 -0
  143. package/dist/tool-permissions.d.ts +120 -0
  144. package/dist/tool-permissions.js +277 -0
  145. package/dist/tool-permissions.js.map +1 -0
  146. package/dist/topic-commands.js +1 -0
  147. package/dist/topic-commands.js.map +1 -1
  148. package/dist/transcript-sources.d.ts +12 -1
  149. package/dist/transcript-sources.js +19 -3
  150. package/dist/transcript-sources.js.map +1 -1
  151. package/dist/tunnel/cloudflared.d.ts +71 -0
  152. package/dist/tunnel/cloudflared.js +447 -0
  153. package/dist/tunnel/cloudflared.js.map +1 -0
  154. package/dist/tunnel/lease.d.ts +75 -0
  155. package/dist/tunnel/lease.js +209 -0
  156. package/dist/tunnel/lease.js.map +1 -0
  157. package/dist/tunnel/manager.d.ts +56 -0
  158. package/dist/tunnel/manager.js +167 -0
  159. package/dist/tunnel/manager.js.map +1 -0
  160. package/dist/tunnel/types.d.ts +119 -0
  161. package/dist/tunnel/types.js +33 -0
  162. package/dist/tunnel/types.js.map +1 -0
  163. package/dist/types.d.ts +2 -0
  164. package/dist/ui/dashboard.html +4 -3
  165. package/dist/ui/settings.html +808 -156
  166. package/dist/ui/view.html +2 -2
  167. package/dist/usage/i18n-keys.d.ts +1 -1
  168. package/dist/usage/i18n-keys.js +1 -1
  169. package/dist/usage/i18n-keys.js.map +1 -1
  170. package/dist/usage/providers.d.ts +23 -14
  171. package/dist/usage/providers.js +220 -58
  172. package/dist/usage/providers.js.map +1 -1
  173. package/dist/usage/usage-api.d.ts +13 -0
  174. package/dist/usage/usage-api.js +69 -1
  175. package/dist/usage/usage-api.js.map +1 -1
  176. package/dist/web-api.js +8 -5
  177. package/dist/web-api.js.map +1 -1
  178. package/dist/web-auth.d.ts +75 -0
  179. package/dist/web-auth.js +208 -2
  180. package/dist/web-auth.js.map +1 -1
  181. package/dist/web-terminal.d.ts +30 -1
  182. package/dist/web-terminal.js +86 -3
  183. package/dist/web-terminal.js.map +1 -1
  184. package/package.json +2 -1
@@ -5,7 +5,6 @@ import { freemem, totalmem, cpus } from "node:os";
5
5
  import { createServer } from "node:http";
6
6
  import { join, dirname, basename } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { isDeepStrictEqual } from "node:util";
9
8
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
10
9
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
11
10
  import { formatUpdateProgress } from "./update-progress.js";
@@ -45,7 +44,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
45
44
  import { outboundHandlers } from "./outbound-handlers.js";
46
45
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
47
46
  import { handleViewRequest, isViewPath } from "./view-api.js";
48
- import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
47
+ import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
49
48
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
50
49
  import { LoginSession } from "./login-manager.js";
51
50
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
@@ -53,15 +52,30 @@ import { runBeforeDeadline } from "./deadline.js";
53
52
  import { LoginWindowLock } from "./login-window-lock.js";
54
53
  import { handleSettingsRequest } from "./settings-api.js";
55
54
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
56
- import { handleAgentRequest } from "./agent-endpoint.js";
55
+ import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
57
56
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
58
57
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
59
58
  import { validateFleetConfig } from "./config-validator.js";
60
59
  import { readLastInboundAt } from "./daemon.js";
61
60
  import { clearPausedMarker } from "./pause-marker.js";
62
- import { releaseProcessFleetLock } from "./fleet-lock.js";
61
+ import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
62
+ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
63
+ import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
64
+ import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
63
65
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
64
- import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
66
+ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
67
+ import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
68
+ import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
69
+ import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
70
+ import { SecretStore } from "./secret-store.js";
71
+ import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
72
+ import { verifyDiscordToken, verifyTelegramToken } from "./provider-probe.js";
73
+ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
74
+ /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
75
+ const SELF_RESTART_DEADLINE_MS = 300_000;
76
+ import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
77
+ import { instanceCredentialProfile } from "./backend/credential-profile.js";
78
+ import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
65
79
  import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
66
80
  import { launchFullRestartHelper } from "./full-restart.js";
67
81
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
@@ -179,31 +193,6 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
179
193
  * emoji never changes the documented delivery-state protocol.
180
194
  */
181
195
  const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
182
- const HOT_INSTANCE_CONFIG_KEYS = new Set([
183
- "tool_progress",
184
- "reply_completion_guard",
185
- "mcp_proxy_reply",
186
- "auto_pause_after",
187
- "warm_cap",
188
- "display_name",
189
- "description",
190
- "tags",
191
- "log_level",
192
- ]);
193
- function splitHotColdConfig(config) {
194
- const hot = {};
195
- const cold = {};
196
- for (const [key, value] of Object.entries(config)) {
197
- (HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
198
- }
199
- return { hot, cold };
200
- }
201
- function hotConfigUpdate(config) {
202
- const update = {};
203
- for (const key of HOT_INSTANCE_CONFIG_KEYS)
204
- update[key] = config[key] ?? null;
205
- return update;
206
- }
207
196
  /**
208
197
  * How long a delivery waits out a disconnected instance IPC before giving up.
209
198
  *
@@ -232,6 +221,8 @@ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
232
221
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
233
222
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
234
223
  const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
224
+ /** How long shutdown will spend retiring still-armed button prompts. */
225
+ const NONCE_RETIRE_BUDGET_MS = 5_000;
235
226
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // hard validity bound for the cached CLI env
236
227
  /**
237
228
  * How old the cached CLI env may be before `/model` re-probes it live.
@@ -383,6 +374,10 @@ export class FleetManager {
383
374
  // reply, on cancel, or when a newer button supersedes it for the same
384
375
  // instance. Per-button tracking means a failed delete never strands a button.
385
376
  cancelButtons = new Map();
377
+ /** Latest publication generation per instance. Late results from older
378
+ * generations are retired without touching the current button. */
379
+ cancelButtonPublications = new Map();
380
+ nextCancelButtonPublicationGeneration = 0;
386
381
  /** Pending idle-edge retirement, one timer per instance. */
387
382
  cancelButtonIdleRetireTimers = new Map();
388
383
  /** Duplicate-reply suppression across both the MCP and HTTP reply paths. */
@@ -445,6 +440,40 @@ export class FleetManager {
445
440
  adapterRestarting = new Set();
446
441
  // Adapter isolation: track state per adapter for retry + visibility
447
442
  adapterState = new Map();
443
+ /** Web Settings secret rotation is deliberately separate from reconnect
444
+ * recovery: a rotation must build a fresh provider client with the new token,
445
+ * and stale callbacks from the old client must not win. */
446
+ connectionSecretChallenges = new Map();
447
+ connectionSecretChallengesByKey = new Map();
448
+ connectionSecretJobs = new Map();
449
+ connectionSecretJobSession = new Map();
450
+ connectionSecretInFlight = new Map();
451
+ connectionSecretGenerations = new Map();
452
+ /** Local epoch that fences a challenge across adapter replacement and
453
+ * provider reconnect generations. The adapter's own generation can reset
454
+ * when a new adapter object is constructed, so keep an independent epoch. */
455
+ connectionSecretAdapterRefs = new Map();
456
+ connectionSecretHealthGenerations = new Map();
457
+ /** Generic API-key verifier/apply state. The challenge scope contains the
458
+ * resolved spec/env key, so a request can never retarget another provider. */
459
+ providerSecretChallenges = new Map();
460
+ providerSecretChallengesByKey = new Map();
461
+ providerSecretJobs = new Map();
462
+ providerSecretJobSession = new Map();
463
+ providerSecretInFlight = new Map();
464
+ providerSecretGenerations = new Map();
465
+ /** Test seam only; production always uses the fixed HTTPS client. */
466
+ providerSecretHttpClient;
467
+ /** Code-owned activation hooks; never populated from a request. */
468
+ providerSecretReloadHooks = new Map();
469
+ /** In-memory snapshots for narrow hot consumers (currently Groq voice). */
470
+ providerSecretHotSnapshots = new Map();
471
+ /** Web Settings connection-binding step-up challenges and apply jobs. */
472
+ connectionBindingChallenges = new Map();
473
+ connectionBindingChallengesByKey = new Map();
474
+ connectionBindingJobs = new Map();
475
+ connectionBindingJobSession = new Map();
476
+ connectionBindingInFlight = new Map();
448
477
  collabInstances = new Set();
449
478
  // Health endpoint
450
479
  healthServer = null;
@@ -458,6 +487,9 @@ export class FleetManager {
458
487
  fullRestartLauncher = launchFullRestartHelper;
459
488
  eventLogPruneTimer = null;
460
489
  logRotateTimer = null;
490
+ discordPresenceTimer = null;
491
+ discordPresenceInFlight = null;
492
+ static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
461
493
  /** Days of event/activity history to keep. */
462
494
  static EVENT_LOG_RETENTION_DAYS = 30;
463
495
  watchdogTimer = null;
@@ -468,7 +500,29 @@ export class FleetManager {
468
500
  mirrorTimer = null;
469
501
  // Web UI: SSE clients + auth token
470
502
  sseClients = new Set();
471
- webToken = null;
503
+ /**
504
+ * Read from disk on every access rather than cached at startup: `agend
505
+ * web-token rotate` runs in a separate process, and a cached copy would keep
506
+ * authorizing revoked links and cookies until the fleet restarted.
507
+ */
508
+ get webToken() { return readWebToken(this.dataDir); }
509
+ /**
510
+ * Set while a Settings apply job is driving the reconcile. The reconcile
511
+ * stays the single doer; it just says out loud what it is doing to whom, so
512
+ * the job's rows are the work rather than a prediction of it.
513
+ */
514
+ applyJobStoreCache = null;
515
+ /** The apply that currently owns the reconcile slot, reserved synchronously
516
+ * so a second request cannot slip in before the first one starts working. */
517
+ activeApplyJobId = null;
518
+ /** The fleet-level signature this process actually came up on. */
519
+ appliedFleetLevel = null;
520
+ /** The config behind that signature, kept so a "needs restart" log can name
521
+ * which keys moved rather than just asserting that something did. */
522
+ startupFleetConfig = null;
523
+ /** Set when the file on disk and the in-memory config disagree on a
524
+ * startup-only key at startup. See checkStartupSignatureConsistency(). */
525
+ fleetSignatureMismatch = null;
472
526
  viewToken = null;
473
527
  healthServerListening = false;
474
528
  constructor(dataDir) {
@@ -544,26 +598,48 @@ export class FleetManager {
544
598
  this.scheduleReconcile();
545
599
  }
546
600
  scheduleReconcile() {
547
- if (this.reconcileInFlight) {
601
+ const started = this.startExclusiveReconcile();
602
+ if (!started) {
548
603
  this.reloadPending = true;
549
604
  this.logger.info("Config reconciliation already running — coalesced reload request");
550
605
  return;
551
606
  }
552
- this.reloadPending = false;
553
- this.reconcileInFlight = this.reconcileInstances()
554
- .catch(err => {
607
+ started.catch(err => {
555
608
  // Almost always a YAML parse error. Log-only meant the user edited
556
609
  // fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
557
610
  this.logger.error({ err }, "SIGHUP config reload failed");
558
611
  const message = err instanceof Error ? err.message : String(err);
559
612
  this.notifyFleetError(t("fleet.reload_failed", message));
560
- })
613
+ });
614
+ }
615
+ /**
616
+ * Take the reconcile slot, or refuse.
617
+ *
618
+ * Only one reconcile may touch lifecycle and config at a time — two of them
619
+ * stop and start the same instance in parallel. SIGHUP and a Settings apply
620
+ * are the same operation from two entrances, so they share the one slot: the
621
+ * signal coalesces into a pending replay, the apply is told the fleet is busy.
622
+ *
623
+ * The returned promise is the caller's to handle; the stored one is already
624
+ * handled, so a rejection never escapes as an unhandled rejection.
625
+ */
626
+ startExclusiveReconcile(observer) {
627
+ if (this.reconcileInFlight)
628
+ return null;
629
+ this.reloadPending = false;
630
+ let settle;
631
+ const caller = new Promise((resolve, reject) => {
632
+ settle = (err, outcome) => (err ? reject(err instanceof Error ? err : new Error(String(err))) : resolve(outcome ?? {}));
633
+ });
634
+ this.reconcileInFlight = this.reconcileInstances(observer)
635
+ .then(outcome => settle(null, outcome), err => settle(err))
561
636
  .finally(() => {
562
637
  this.reconcileInFlight = null;
563
638
  if (this.reloadPending && this.startupComplete) {
564
639
  this.scheduleReconcile();
565
640
  }
566
641
  });
642
+ return caller;
567
643
  }
568
644
  /**
569
645
  * Is the fleet going down (or coming back up) on purpose?
@@ -579,6 +655,39 @@ export class FleetManager {
579
655
  }
580
656
  finishStartup() {
581
657
  this.startupComplete = true;
658
+ // Resolve whatever a previous run — or a setup host that crashed — left
659
+ // behind. A tunnel nobody is tracking is a public entrance nobody is
660
+ // watching, and the fleet starting is the moment there is finally a process
661
+ // around to notice. Never throws: a lease that cannot be resolved blocks
662
+ // the next tunnel and says so, it does not block the fleet.
663
+ this.announceToolPermissionsChange();
664
+ void reapStaleTunnel(this.dataDir)
665
+ .then(outcome => {
666
+ if (outcome.kind === "manual")
667
+ this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
668
+ else if (outcome.kind === "reaped")
669
+ this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
670
+ })
671
+ .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
672
+ // An existing installation has never written the setup marker — it predates
673
+ // it — so `agend setup` would open a pre-fleet form for a fleet that plainly
674
+ // exists. A fleet that just came up on a config with agents in it is proof
675
+ // enough that setup happened.
676
+ if (Object.keys(this.fleetConfig?.instances ?? {}).length > 0 && !isSetupComplete(this.dataDir)) {
677
+ markSetupComplete(this.dataDir);
678
+ }
679
+ // After slimFleetConfigAtStartup() and the general/topic fixups, all of
680
+ // which may rewrite fleet.yaml — the baseline has to be what this process
681
+ // is actually running, compared against what a reconcile would load.
682
+ this.appliedFleetLevel = this.fleetLevelSignature();
683
+ this.startupFleetConfig = this.fleetConfig ? structuredClone(this.fleetConfig) : null;
684
+ this.checkStartupSignatureConsistency();
685
+ // A job from the process that just died cannot still be running here. The
686
+ // restart applied the saved config to every instance, so its open rows are
687
+ // finished — by the restart, which is what the user needs told.
688
+ for (const settled of this.applyJobs.settleAfterRestart()) {
689
+ this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
690
+ }
582
691
  // We are the post-update fleet: the update is over by definition. Clearing
583
692
  // it here (rather than in the update command, which exits before the new
584
693
  // fleet is up) is what keeps the quiet window from outliving the restart.
@@ -704,11 +813,30 @@ export class FleetManager {
704
813
  if (this.rawFleetDocument.errors.length > 0) {
705
814
  throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
706
815
  }
707
- this.rawFleetConfig = loadRawFleetConfig(configPath);
708
- this.fleetConfig = loadFleetConfig(configPath);
816
+ const raw = loadRawFleetConfig(configPath);
817
+ const loaded = loadFleetConfig(configPath);
818
+ this.assertProviderSecretEnvKeys(loaded);
819
+ this.rawFleetConfig = raw;
820
+ this.fleetConfig = loaded;
709
821
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
710
822
  return this.fleetConfig;
711
823
  }
824
+ /**
825
+ * A channel's configurable bot_token_env is an env-key writer too. Refuse
826
+ * an overlap with a registry API key (or a process-reserved key) before the
827
+ * config becomes live; otherwise a Discord token could be written into
828
+ * GROQ_API_KEY by a perfectly valid-looking rotation request.
829
+ */
830
+ assertProviderSecretEnvKeys(config) {
831
+ const registryKeys = providerRegistryEnvKeys();
832
+ const channels = config.channels ?? (config.channel ? [config.channel] : []);
833
+ for (const channel of channels) {
834
+ const key = channel.bot_token_env;
835
+ if (registryKeys.has(key) || isReservedProviderEnvKey(key)) {
836
+ throw new Error(`bot_token_env ${key} conflicts with a protected provider secret key`);
837
+ }
838
+ }
839
+ }
712
840
  /** User-authored fleet.yaml, before defaults are merged into instances. */
713
841
  getRawFleetConfig() {
714
842
  return structuredClone(this.rawFleetConfig);
@@ -1112,31 +1240,56 @@ export class FleetManager {
1112
1240
  */
1113
1241
  getActiveUsageProviderIds() {
1114
1242
  const providers = new Set();
1115
- for (const backend of this.getActiveBackendIds()) {
1243
+ // Per instance, not per backend: two agents on two kiro subscriptions are
1244
+ // two rows, and filtering by the bare backend id would hide both.
1245
+ for (const [name, backend, profile] of this.activeBackendBindings()) {
1246
+ void name;
1116
1247
  const provider = usageProviderIdForBackend(backend);
1117
- if (provider)
1118
- providers.add(provider);
1248
+ if (!provider)
1249
+ continue;
1250
+ providers.add(profile ? `${provider}:${profile}` : provider);
1119
1251
  }
1120
1252
  return providers;
1121
1253
  }
1122
- /** Effective backends with a running or persisted-paused fleet/Classic instance. */
1123
- getActiveBackendIds() {
1124
- const backends = new Set();
1125
- const add = (name, backend) => {
1254
+ /** Subscription providers used by the running/paused instances owned by one adapter. */
1255
+ getUsageProviderIdsForAdapter(adapterId) {
1256
+ const providers = new Set();
1257
+ for (const [name, backend, profile] of this.activeBackendBindings()) {
1258
+ if (this.getInstanceAdapterId(name) !== adapterId)
1259
+ continue;
1260
+ const provider = usageProviderIdForBackend(backend);
1261
+ if (!provider)
1262
+ continue;
1263
+ providers.add(profile ? `${provider}:${profile}` : provider);
1264
+ }
1265
+ return providers;
1266
+ }
1267
+ /** `[instance, effective backend, credential profile]` for everything that is
1268
+ * running or paused — the one place both usage views agree on who is live. */
1269
+ activeBackendBindings() {
1270
+ const bindings = [];
1271
+ const add = (name, backend, profile) => {
1126
1272
  const status = this.getInstanceStatus(name);
1127
1273
  if (status !== "running" && status !== "paused")
1128
1274
  return;
1129
1275
  if (backend)
1130
- backends.add(backend);
1276
+ bindings.push([name, backend, profile]);
1131
1277
  };
1132
1278
  for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
1133
1279
  // loadFleetConfig() has already merged the fleet default into each row.
1134
- add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
1280
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1281
+ add(name, backend, instanceCredentialProfile(config, this.fleetConfig?.defaults, backend));
1135
1282
  }
1136
1283
  for (const channel of this.classicChannels?.getAll() ?? []) {
1137
- add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
1284
+ const backend = this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend);
1285
+ // Classic channels carry no backend_options, so they run the shared login.
1286
+ add(channel.instanceName, backend, null);
1138
1287
  }
1139
- return backends;
1288
+ return bindings;
1289
+ }
1290
+ /** Effective backends with a running or persisted-paused fleet/Classic instance. */
1291
+ getActiveBackendIds() {
1292
+ return new Set(this.activeBackendBindings().map(([, backend]) => backend));
1140
1293
  }
1141
1294
  isClassicInstance(name) {
1142
1295
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
@@ -1181,12 +1334,17 @@ export class FleetManager {
1181
1334
  }
1182
1335
  scheduleIdleButtonRetirement(name) {
1183
1336
  this.cancelIdleButtonRetirement(name);
1337
+ // Bind this edge to the publication that was current when idle was
1338
+ // observed. A later inbound may start a new generation before this timer
1339
+ // fires; the old edge must not mark that newer button for retirement.
1340
+ const publication = this.cancelButtonPublications.get(name);
1184
1341
  const timer = setTimeout(() => {
1185
1342
  // Ignore a superseded timer even if it was already queued to run.
1186
1343
  if (this.cancelButtonIdleRetireTimers.get(name) !== timer)
1187
1344
  return;
1188
1345
  this.cancelButtonIdleRetireTimers.delete(name);
1189
1346
  if (this.getInstanceExecutionState(name) === "idle") {
1347
+ this.markCancelButtonPublicationForRetirement(name, publication);
1190
1348
  this.retireInstanceButtons(name);
1191
1349
  }
1192
1350
  }, CANCEL_BTN_IDLE_RETIRE_GRACE_MS);
@@ -1539,7 +1697,7 @@ export class FleetManager {
1539
1697
  if (!wasRunning)
1540
1698
  return;
1541
1699
  const hotOnly = changedFields.length > 0
1542
- && changedFields.every(field => field === "tool_progress" || field === "reply_completion_guard");
1700
+ && changedFields.every(field => CLASSIC_HOT_CONFIG_KEYS.has(field));
1543
1701
  if (hotOnly) {
1544
1702
  this.applyHotConfigUpdate(instanceName, this.classicBehaviorUpdate(instanceName));
1545
1703
  this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
@@ -1936,6 +2094,22 @@ export class FleetManager {
1936
2094
  names.add(channel.instanceName);
1937
2095
  return [...names];
1938
2096
  }
2097
+ /**
2098
+ * Probe the same executable set exposed by the web backend catalog.
2099
+ *
2100
+ * This deliberately has no cache: an `/install-cli` completion can add a
2101
+ * binary to PATH while the fleet process remains alive, and the next bare
2102
+ * `/login` must see it without requiring a restart or an explicit cache
2103
+ * invalidation call.
2104
+ */
2105
+ probeInstalledBackends() {
2106
+ const installed = new Set();
2107
+ for (const [backend, info] of Object.entries(BACKEND_INSTALLATION_INFO)) {
2108
+ if (checkBinaryInstalled(info.binary))
2109
+ installed.add(backend);
2110
+ }
2111
+ return installed;
2112
+ }
1939
2113
  /**
1940
2114
  * One fleet-level notice per burst, not one per instance: a post-update herd
1941
2115
  * fails many instances within the same second. Two notices per incident at
@@ -2165,7 +2339,9 @@ export class FleetManager {
2165
2339
  }
2166
2340
  /** Initialize auth before any adapter can answer /dashboard. */
2167
2341
  initializeWebAuthTokens() {
2168
- this.webToken = loadOrCreateWebToken(this.dataDir);
2342
+ // Creates web.token if absent; the value is then read back per request by
2343
+ // the `webToken` getter, so nothing is cached here.
2344
+ loadOrCreateWebToken(this.dataDir);
2169
2345
  this.viewToken = randomBytes(24).toString("hex");
2170
2346
  const viewTokenPath = join(this.dataDir, "view.token");
2171
2347
  writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
@@ -2729,6 +2905,7 @@ export class FleetManager {
2729
2905
  }
2730
2906
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
2731
2907
  await adapterStartup;
2908
+ this.startDiscordUsagePresence();
2732
2909
  // Bind every fleet instance deterministically. Explicit channel_id wins;
2733
2910
  // otherwise channels[0] is authoritative. Do not infer identity from
2734
2911
  // concurrent adapter startup or whichever bot receives a message first.
@@ -2877,6 +3054,53 @@ export class FleetManager {
2877
3054
  this.logger.warn({ health }, "Fleet started with problems — see /health");
2878
3055
  }
2879
3056
  }
3057
+ /** Keep Discord profile activity aligned with the same cached usage source as /usage. */
3058
+ startDiscordUsagePresence() {
3059
+ if (this.discordPresenceTimer)
3060
+ clearInterval(this.discordPresenceTimer);
3061
+ void this.refreshDiscordUsagePresence();
3062
+ this.discordPresenceTimer = setInterval(() => {
3063
+ void this.refreshDiscordUsagePresence();
3064
+ }, FleetManager.DISCORD_PRESENCE_REFRESH_MS);
3065
+ this.discordPresenceTimer.unref?.();
3066
+ }
3067
+ refreshDiscordUsagePresence() {
3068
+ if (this.discordPresenceInFlight)
3069
+ return this.discordPresenceInFlight;
3070
+ const run = (async () => {
3071
+ const targets = [...this.adapters.values()]
3072
+ .filter(adapter => adapter.type === "discord" && typeof adapter.setActivity === "function");
3073
+ if (targets.length === 0)
3074
+ return;
3075
+ try {
3076
+ // Fetch the shared snapshot once, then scope the projection to each
3077
+ // adapter's own fleet/Classic instances. Passing the fleet-wide active
3078
+ // set here would make every bot advertise providers owned by a sibling
3079
+ // bot (notably ClassicBot's Grok/Antigravity rows).
3080
+ const payload = await getUsageSnapshot(false);
3081
+ for (const adapter of targets) {
3082
+ try {
3083
+ const scoped = filterUsageProviders(payload, this.getUsageProviderIdsForAdapter(adapter.id));
3084
+ adapter.setActivity?.(formatDiscordUsageActivity(scoped));
3085
+ }
3086
+ catch {
3087
+ // Presence is cosmetic; a failed update must not affect delivery.
3088
+ }
3089
+ }
3090
+ }
3091
+ catch {
3092
+ // Usage providers are best-effort and may be offline. Keep the last
3093
+ // activity rather than replacing it with an untruthful blank state.
3094
+ this.logger.debug("Discord usage presence refresh skipped");
3095
+ }
3096
+ })();
3097
+ const done = run.finally(() => {
3098
+ if (this.discordPresenceInFlight === done)
3099
+ this.discordPresenceInFlight = null;
3100
+ });
3101
+ this.discordPresenceInFlight = done;
3102
+ return done;
3103
+ }
2880
3104
  /**
2881
3105
  * Delete inbox files older than retentionDays (by mtime). Cleans the shared
2882
3106
  * inbox (`<dataDir>/inbox`) and every workspace inbox
@@ -2994,6 +3218,11 @@ export class FleetManager {
2994
3218
  }
2995
3219
  bindAdapterHealth(adapter, adapterId) {
2996
3220
  adapter.on("gateway_health", (snapshot) => {
3221
+ // A token rotation tears down the old EventEmitter before constructing
3222
+ // the replacement. A late health frame from that old client must never
3223
+ // make a failed/new-generation adapter look connected.
3224
+ if (this.adapters.get(adapterId) !== adapter)
3225
+ return;
2997
3226
  const previous = this.adapterState.get(adapterId);
2998
3227
  const status = snapshot.status === "connected" ? "connected"
2999
3228
  : snapshot.status === "stopped" ? "failed"
@@ -3074,7 +3303,7 @@ export class FleetManager {
3074
3303
  };
3075
3304
  }
3076
3305
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
3077
- async startSingleAdapter(fleet, channelConfig) {
3306
+ async startSingleAdapter(fleet, channelConfig, onStarted) {
3078
3307
  const botToken = process.env[channelConfig.bot_token_env];
3079
3308
  if (!botToken) {
3080
3309
  this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
@@ -3082,7 +3311,9 @@ export class FleetManager {
3082
3311
  }
3083
3312
  const accessDir = join(this.dataDir, "access");
3084
3313
  mkdirSync(accessDir, { recursive: true });
3085
- const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
3314
+ const accessStatePath = join(accessDir, "access.json");
3315
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
3316
+ this.warnIfAccessModeOverridden(accessManager, accessStatePath);
3086
3317
  this.accessManager = accessManager;
3087
3318
  const inboxDir = join(this.dataDir, "inbox");
3088
3319
  mkdirSync(inboxDir, { recursive: true });
@@ -3098,13 +3329,20 @@ export class FleetManager {
3098
3329
  this.worlds.set(adapterId, world);
3099
3330
  this.adapters.set(adapterId, adapter);
3100
3331
  this.bindAdapterHealth(adapter, adapterId);
3332
+ const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
3101
3333
  this.adapter.on("message", safeHandler(async (msg) => {
3334
+ if (!isCurrentAdapter())
3335
+ return;
3102
3336
  await this.handleInboundMessage(msg);
3103
3337
  }, this.logger, "adapter.message"));
3104
3338
  this.adapter.on("reaction", safeHandler(async (r) => {
3339
+ if (!isCurrentAdapter())
3340
+ return;
3105
3341
  await this.handleInboundReaction(r);
3106
3342
  }, this.logger, "adapter.reaction"));
3107
3343
  this.adapter.on("callback_query", safeHandler(async (data) => {
3344
+ if (!isCurrentAdapter())
3345
+ return;
3108
3346
  if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
3109
3347
  return;
3110
3348
  if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
@@ -3145,6 +3383,8 @@ export class FleetManager {
3145
3383
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
3146
3384
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
3147
3385
  this.adapter.on("slash_command", safeHandler(async (data) => {
3386
+ if (!isCurrentAdapter())
3387
+ return;
3148
3388
  if (data.command === "start") {
3149
3389
  await this.handleClassicStartSlash(data, adapterId);
3150
3390
  }
@@ -3424,6 +3664,8 @@ export class FleetManager {
3424
3664
  // Non-blocking: /model & status views read the cache; never delays startup.
3425
3665
  this.probeCliEnvs();
3426
3666
  this.adapter.on("started", safeHandler((username, userId) => {
3667
+ if (!isCurrentAdapter())
3668
+ return;
3427
3669
  this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
3428
3670
  // Concurrent startup can insert a secondary world first. Update the
3429
3671
  // configured primary world, not Map insertion order.
@@ -3435,6 +3677,7 @@ export class FleetManager {
3435
3677
  }
3436
3678
  if (userId)
3437
3679
  this.botUserId = userId;
3680
+ onStarted?.();
3438
3681
  }, this.logger, "adapter.started"));
3439
3682
  this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
3440
3683
  this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
@@ -3443,10 +3686,14 @@ export class FleetManager {
3443
3686
  this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
3444
3687
  }, this.logger, "adapter.handler_error"));
3445
3688
  this.adapter.on("error", (err) => {
3689
+ if (!isCurrentAdapter())
3690
+ return;
3446
3691
  this.logger.error({ err }, "Primary adapter fatal error");
3447
3692
  this.restartAdapter(this.adapter, adapterId).catch(() => { });
3448
3693
  });
3449
3694
  this.adapter.on("new_group_detected", safeHandler(async (data) => {
3695
+ if (!isCurrentAdapter())
3696
+ return;
3450
3697
  const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
3451
3698
  const generalId = this.findGeneralInstance();
3452
3699
  // No user to promote: the bot was just added, nobody has run /start yet.
@@ -3458,6 +3705,9 @@ export class FleetManager {
3458
3705
  if (fleet.channel?.group_id) {
3459
3706
  this.adapter.setChatId(String(fleet.channel.group_id));
3460
3707
  }
3708
+ if (this.discordPresenceTimer && this.adapter.type === "discord") {
3709
+ void this.refreshDiscordUsagePresence();
3710
+ }
3461
3711
  this.startTopicCleanupPoller();
3462
3712
  // Prune stale external sessions every 5 minutes
3463
3713
  this.sessionPruneTimer = setInterval(() => {
@@ -3465,7 +3715,7 @@ export class FleetManager {
3465
3715
  }, 5 * 60 * 1000);
3466
3716
  }
3467
3717
  /** Start an additional (non-primary) adapter */
3468
- async startAdditionalAdapter(channelConfig, registerCommands = true) {
3718
+ async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted) {
3469
3719
  const adapterId = channelConfig.id ?? channelConfig.type;
3470
3720
  const botToken = process.env[channelConfig.bot_token_env];
3471
3721
  if (!botToken) {
@@ -3474,7 +3724,9 @@ export class FleetManager {
3474
3724
  }
3475
3725
  const accessDir = join(this.dataDir, "access");
3476
3726
  mkdirSync(accessDir, { recursive: true });
3477
- const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, `access-${adapterId}.json`));
3727
+ const accessStatePath = join(accessDir, `access-${adapterId}.json`);
3728
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
3729
+ this.warnIfAccessModeOverridden(accessManager, accessStatePath);
3478
3730
  const inboxDir = join(this.dataDir, "inbox");
3479
3731
  mkdirSync(inboxDir, { recursive: true });
3480
3732
  const adapter = await createAdapter(channelConfig, {
@@ -3488,14 +3740,21 @@ export class FleetManager {
3488
3740
  this.worlds.set(adapterId, world);
3489
3741
  this.adapters.set(adapterId, adapter);
3490
3742
  this.bindAdapterHealth(adapter, adapterId);
3743
+ const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
3491
3744
  // Wire up event handlers (same as primary, routes through shared handleInboundMessage)
3492
3745
  adapter.on("message", safeHandler(async (msg) => {
3746
+ if (!isCurrentAdapter())
3747
+ return;
3493
3748
  await this.handleInboundMessage(msg);
3494
3749
  }, this.logger, `adapter[${adapterId}].message`));
3495
3750
  adapter.on("reaction", safeHandler(async (r) => {
3751
+ if (!isCurrentAdapter())
3752
+ return;
3496
3753
  await this.handleInboundReaction(r);
3497
3754
  }, this.logger, `adapter[${adapterId}].reaction`));
3498
3755
  adapter.on("callback_query", safeHandler(async (data) => {
3756
+ if (!isCurrentAdapter())
3757
+ return;
3499
3758
  if (await this.handleTipDismiss(data, adapterId, adapter))
3500
3759
  return;
3501
3760
  if (await this.handleTipUnlock(data, adapterId, adapter))
@@ -3536,6 +3795,8 @@ export class FleetManager {
3536
3795
  this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
3537
3796
  // Slash commands: classic bot + admin commands
3538
3797
  adapter.on("slash_command", safeHandler(async (data) => {
3798
+ if (!isCurrentAdapter())
3799
+ return;
3539
3800
  if (data.command === "start") {
3540
3801
  await this.handleClassicStartSlash(data, adapterId);
3541
3802
  }
@@ -3759,6 +4020,8 @@ export class FleetManager {
3759
4020
  }
3760
4021
  }, this.logger, `adapter[${adapterId}].slash_command`));
3761
4022
  adapter.on("started", safeHandler((username, userId) => {
4023
+ if (!isCurrentAdapter())
4024
+ return;
3762
4025
  this.logger.info(`[${adapterId}] Bot @${username} polling started.`);
3763
4026
  const world = this.worlds.get(adapterId);
3764
4027
  if (world) {
@@ -3766,14 +4029,19 @@ export class FleetManager {
3766
4029
  if (userId)
3767
4030
  world.botUserId = userId;
3768
4031
  }
4032
+ onStarted?.();
3769
4033
  }, this.logger, `adapter[${adapterId}].started`));
3770
4034
  adapter.on("new_group_detected", safeHandler(async (data) => {
4035
+ if (!isCurrentAdapter())
4036
+ return;
3771
4037
  const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
3772
4038
  const generalId = this.findGeneralInstance(adapterId);
3773
4039
  if (generalId)
3774
4040
  await this.promptClassicApproval({ generalName: generalId, message: adminMsg, groupId: data.groupId, scope: data.source === "telegram" ? "group" : "guild" });
3775
4041
  }, this.logger, `adapter[${adapterId}].new_group_detected`));
3776
4042
  adapter.on("error", (err) => {
4043
+ if (!isCurrentAdapter())
4044
+ return;
3777
4045
  this.logger.error({ err, adapterId }, "Additional adapter fatal error");
3778
4046
  this.restartAdapter(adapter, adapterId).catch(() => { });
3779
4047
  });
@@ -3782,6 +4050,9 @@ export class FleetManager {
3782
4050
  if (channelConfig.group_id) {
3783
4051
  adapter.setChatId(String(channelConfig.group_id));
3784
4052
  }
4053
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4054
+ void this.refreshDiscordUsagePresence();
4055
+ }
3785
4056
  this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
3786
4057
  }
3787
4058
  /** Connect IPC to a single instance with all handlers */
@@ -3852,22 +4123,15 @@ export class FleetManager {
3852
4123
  }
3853
4124
  await this.handleOutboundFromInstance(name, msg);
3854
4125
  }
3855
- else if (msg.type === "fleet_schedule_create" || msg.type === "fleet_schedule_list" ||
3856
- msg.type === "fleet_schedule_update" || msg.type === "fleet_schedule_delete") {
3857
- this.handleScheduleCrud(name, msg);
3858
- }
3859
- else if (msg.type === "fleet_decision_create" || msg.type === "fleet_decision_list" ||
3860
- msg.type === "fleet_decision_update") {
3861
- this.handleDecisionCrud(name, msg);
3862
- }
3863
- else if (msg.type === "fleet_task") {
3864
- this.handleTaskCrud(name, msg);
3865
- }
3866
- else if (msg.type === "fleet_set_display_name") {
3867
- this.handleSetDisplayName(name, msg);
3868
- }
3869
- else if (msg.type === "fleet_set_description") {
3870
- this.handleSetDescription(name, msg);
4126
+ else if (toolForIpcType(msg.type) !== null) {
4127
+ // Sink 2 of 3. Each of these types IS a tool and reaches its own
4128
+ // handler without passing through the outbound path — which is how
4129
+ // `update_decision`, a coordinator-only tool, kept a way through.
4130
+ const typed = this.checkToolPermission("ipc-typed", name, toolForIpcType(msg.type));
4131
+ if (typed.allowed)
4132
+ this.dispatchTypedIpc(name, msg);
4133
+ else
4134
+ this.refuseTypedIpc(name, msg, typed.message);
3871
4135
  }
3872
4136
  else if (msg.type === "instance_process_state") {
3873
4137
  this.cacheInstanceProcessStatus(name, msg.status);
@@ -4031,6 +4295,9 @@ export class FleetManager {
4031
4295
  // watchdog/manual/error triggers must converge here instead of stop/start.
4032
4296
  await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
4033
4297
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
4298
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4299
+ void this.refreshDiscordUsagePresence();
4300
+ }
4034
4301
  this.logger.info({ id }, "Adapter gateway rebuilt successfully");
4035
4302
  }
4036
4303
  catch (err) {
@@ -4056,6 +4323,9 @@ export class FleetManager {
4056
4323
  await adapter.start();
4057
4324
  this.logger.info({ id, attempt }, "Adapter restarted successfully");
4058
4325
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
4326
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4327
+ void this.refreshDiscordUsagePresence();
4328
+ }
4059
4329
  return;
4060
4330
  }
4061
4331
  catch (err) {
@@ -4236,13 +4506,36 @@ export class FleetManager {
4236
4506
  const target = this.routing.resolve(threadId);
4237
4507
  if (!target)
4238
4508
  return "fleet topic: no instance routed for this thread";
4239
- // Fleet topic: allow if collab enabled OR access mode is open
4509
+ // Fleet topic: this gate lets the copy through when the adapter is open OR
4510
+ // collab is on for the instance. "Through" is not "delivered" — access
4511
+ // control runs next, and the two arms are not symmetric there:
4512
+ //
4513
+ // open adapter → isAllowed() returns true outright: really admitted.
4514
+ // locked + collab → the bot's user id still has to be on the allowlist,
4515
+ // so the message is normally refused a few lines later
4516
+ // under "Access DENIED for non-allowed user".
4517
+ //
4518
+ // Collab alone therefore does not admit a bot on a locked adapter; it only
4519
+ // declines to drop the copy here and leaves the decision to access control.
4240
4520
  const isOpen = this.getChannelConfig(msg.adapterId)?.access?.mode === "open";
4241
4521
  if (!isOpen && !this.collabInstances.has(target.name)) {
4242
4522
  return `fleet topic: adapter not open and collab off for ${target.name}`;
4243
4523
  }
4244
4524
  return null;
4245
4525
  }
4526
+ /**
4527
+ * Say so when an adapter's access mode is coming from its state file rather
4528
+ * than from fleet.yaml. The state file wins by design — a pairing done at
4529
+ * runtime has to survive a restart — but that also means an edit to
4530
+ * `access.mode` silently does nothing, which is indistinguishable from the
4531
+ * fleet not having reloaded. Naming the file is what makes it fixable.
4532
+ */
4533
+ warnIfAccessModeOverridden(accessManager, statePath) {
4534
+ const configured = accessManager.overriddenConfigMode();
4535
+ if (!configured)
4536
+ return;
4537
+ this.logger.warn({ statePath, configured, inEffect: accessManager.getMode() }, `access mode "${accessManager.getMode()}" comes from the state file and overrides "${configured}" in fleet.yaml; delete ${statePath} to go back to the configured value`);
4538
+ }
4246
4539
  async handleInboundMessage(msg) {
4247
4540
  const threadId = msg.threadId || undefined;
4248
4541
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
@@ -4809,6 +5102,55 @@ export class FleetManager {
4809
5102
  }
4810
5103
  }
4811
5104
  /** Handle outbound tool calls from a daemon instance */
5105
+ /**
5106
+ * Would this instance be allowed to use this tool?
5107
+ *
5108
+ * Stage 1 asks and records; nothing is refused yet. The recording is not only
5109
+ * an observation window: `logActivity("tool_call")` sits on the outbound path
5110
+ * only, so today the fleet has no idea what the agent endpoint or the typed
5111
+ * IPC handlers are being asked to do — the one face with no authorization is
5112
+ * also the one face with no telemetry.
5113
+ *
5114
+ * The profile comes from the instance the socket belongs to. Deliberately not
5115
+ * `senderSessionName`, which arrives inside the message: a caller that fills
5116
+ * in its own identity has not been identified.
5117
+ */
5118
+ /**
5119
+ * Say once, at startup, what the new default means for this fleet.
5120
+ *
5121
+ * Nothing is rewritten: an explicit `tool_set: full` is a choice somebody
5122
+ * made, and marking an instance as a coordinator is a judgement about how
5123
+ * their fleet is organised. Both stay theirs — this only makes sure they are
5124
+ * not discovered by an agent failing at three in the morning.
5125
+ */
5126
+ announceToolPermissionsChange() {
5127
+ try {
5128
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 19).replace("T", " ");
5129
+ const recent = [...(this.eventLog?.toolUseByInstance(thirtyDaysAgo) ?? new Map())]
5130
+ .map(([instance, tools]) => ({ instance, tools }));
5131
+ const notice = buildToolPermissionsNotice({
5132
+ defaultsToolSet: this.fleetConfig?.defaults?.tool_set,
5133
+ instances: (this.fleetConfig?.instances ?? {}),
5134
+ recent,
5135
+ });
5136
+ if (notice)
5137
+ this.logger.warn({ notice }, notice);
5138
+ }
5139
+ catch (err) {
5140
+ // Advice is not worth failing a startup over.
5141
+ this.logger.debug({ err }, "tool-permissions: could not build the migration notice");
5142
+ }
5143
+ }
5144
+ checkToolPermission(sink, instanceName, tool) {
5145
+ const profile = resolveToolSet(this.fleetConfig?.instances[instanceName], instanceName);
5146
+ const allowed = mayUseTool(profile, tool);
5147
+ if (!allowed) {
5148
+ this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: refused");
5149
+ return { allowed: false, message: toolRefusedMessage(profile, tool) };
5150
+ }
5151
+ this.logger.debug({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: allowed");
5152
+ return { allowed: true, message: "" };
5153
+ }
4812
5154
  async handleOutboundFromInstance(instanceName, msg) {
4813
5155
  this.touchActivity(instanceName);
4814
5156
  this.setTopicIcon(instanceName, "green");
@@ -4830,6 +5172,19 @@ export class FleetManager {
4830
5172
  this.logger.warn({ instanceName, tool, requestId, fleetRequestId, error }, "Fleet outbound result could not be returned — instance IPC is disconnected");
4831
5173
  }
4832
5174
  };
5175
+ // Sink 1 of 3, and the first thing decided. Every MCP call and every direct
5176
+ // write to channel.sock lands here, whether or not mcp-server was ever
5177
+ // involved — which is why this is the boundary and the tool list the model
5178
+ // was shown is not.
5179
+ //
5180
+ // Above the adapter check on purpose: "retry shortly" is the wrong answer
5181
+ // to a call that will never be allowed, and an agent that believes it is a
5182
+ // timing problem will keep trying.
5183
+ const permitted = this.checkToolPermission("ipc-outbound", instanceName, tool);
5184
+ if (!permitted.allowed) {
5185
+ respond(null, permitted.message);
5186
+ return;
5187
+ }
4833
5188
  if (this.worlds.size === 0) {
4834
5189
  respond(null, "Channel adapters are not ready — retry shortly");
4835
5190
  return;
@@ -5087,6 +5442,15 @@ export class FleetManager {
5087
5442
  */
5088
5443
  scheduleSourceAdapter(schedule) {
5089
5444
  const chatId = String(schedule.reply_chat_id);
5445
+ // New schedules carry the adapter that owned the source chat at creation.
5446
+ // Keep that persona across source-instance rebinding, but never trust a
5447
+ // stale adapter after the chat has moved to another world.
5448
+ const persistedWorld = schedule.reply_adapter_id
5449
+ ? this.worlds.get(schedule.reply_adapter_id)
5450
+ : undefined;
5451
+ if (persistedWorld && String(persistedWorld.groupId) === chatId) {
5452
+ return persistedWorld.adapter;
5453
+ }
5090
5454
  const sourceAdapterId = schedule.source ? this.getInstanceAdapterId(schedule.source) : undefined;
5091
5455
  const sourceWorld = sourceAdapterId ? this.worlds.get(sourceAdapterId) : undefined;
5092
5456
  if (sourceWorld && String(sourceWorld.groupId) === chatId)
@@ -5118,6 +5482,58 @@ export class FleetManager {
5118
5482
  threadId: schedule.reply_thread_id ?? undefined,
5119
5483
  }).catch((err) => this.logger.error({ err }, "Failed to send schedule failure notification"));
5120
5484
  }
5485
+ /**
5486
+ * The typed IPC messages, all through one door.
5487
+ *
5488
+ * They used to be five sibling branches on the dispatch, which is why the
5489
+ * permission question had five places to be forgotten. Routing them together
5490
+ * means the check above happens once and cannot be skipped by adding a
5491
+ * sixth — a new type has to appear in `IPC_TYPE_TOOLS` to be dispatched at
5492
+ * all.
5493
+ */
5494
+ /**
5495
+ * Answer a refused typed message the way its handler would have.
5496
+ *
5497
+ * These are request/response over IPC: dropping the message silently leaves
5498
+ * the caller waiting for a reply that never comes, and a hung agent is a
5499
+ * worse failure than a refused one.
5500
+ */
5501
+ refuseTypedIpc(name, msg, message) {
5502
+ const ipc = this.instanceIpcClients.get(name);
5503
+ const fleetRequestId = msg.fleetRequestId;
5504
+ if (!ipc || !fleetRequestId)
5505
+ return;
5506
+ const type = String(msg.type);
5507
+ const responseType = type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
5508
+ : type.startsWith("fleet_decision_") ? "fleet_decision_response"
5509
+ : type === "fleet_task" ? "fleet_task_response"
5510
+ : type === "fleet_set_display_name" ? "fleet_display_name_response"
5511
+ : "fleet_description_response";
5512
+ ipc.send({ type: responseType, fleetRequestId, error: message });
5513
+ }
5514
+ dispatchTypedIpc(name, msg) {
5515
+ const type = String(msg.type);
5516
+ if (type.startsWith("fleet_schedule_")) {
5517
+ this.handleScheduleCrud(name, msg);
5518
+ return;
5519
+ }
5520
+ if (type.startsWith("fleet_decision_")) {
5521
+ this.handleDecisionCrud(name, msg);
5522
+ return;
5523
+ }
5524
+ if (type === "fleet_task") {
5525
+ this.handleTaskCrud(name, msg);
5526
+ return;
5527
+ }
5528
+ if (type === "fleet_set_display_name") {
5529
+ this.handleSetDisplayName(name, msg);
5530
+ return;
5531
+ }
5532
+ if (type === "fleet_set_description") {
5533
+ this.handleSetDescription(name, msg);
5534
+ return;
5535
+ }
5536
+ }
5121
5537
  handleScheduleCrud(instanceName, msg) {
5122
5538
  const fleetRequestId = msg.fleetRequestId;
5123
5539
  const payload = (msg.payload ?? {});
@@ -5125,36 +5541,27 @@ export class FleetManager {
5125
5541
  const ipc = this.instanceIpcClients.get(instanceName);
5126
5542
  if (!ipc)
5127
5543
  return;
5544
+ if (!this.scheduler) {
5545
+ // It did answer before, with whatever TypeError fell out of the try
5546
+ // block — "Cannot read properties of null (reading 'list')" is a stack
5547
+ // trace wearing an error message, and the agent reading it cannot tell
5548
+ // that the fleet simply has no scheduler.
5549
+ ipc.send({ type: "fleet_schedule_response", fleetRequestId, error: "Schedules are unavailable — the fleet scheduler is not running" });
5550
+ return;
5551
+ }
5128
5552
  try {
5129
- let result;
5130
- switch (msg.type) {
5131
- case "fleet_schedule_create": {
5132
- const params = {
5133
- cron: payload.cron,
5134
- at: payload.at,
5135
- message: payload.message,
5136
- source: instanceName,
5137
- target: payload.target || instanceName,
5138
- reply_chat_id: meta.chat_id,
5139
- reply_thread_id: meta.thread_id || null,
5140
- label: payload.label,
5141
- timezone: payload.timezone,
5142
- silent: !!(payload.silent),
5143
- };
5144
- result = this.scheduler.create(params);
5145
- break;
5146
- }
5147
- case "fleet_schedule_list":
5148
- result = this.scheduler.list(payload.target);
5149
- break;
5150
- case "fleet_schedule_update":
5151
- result = this.scheduler.update(payload.id, payload);
5152
- break;
5153
- case "fleet_schedule_delete":
5154
- this.scheduler.delete(payload.id);
5155
- result = "ok";
5156
- break;
5157
- }
5553
+ const op = String(msg.type).replace("fleet_schedule_", "");
5554
+ const result = this.performScheduleOp(instanceName, op, payload, {
5555
+ // The daemon sends its last chat id, which is unset until the instance
5556
+ // has had a chat message — and cross-instance traffic never sets it. So
5557
+ // a worker that only takes delegated tasks, the very instance #895 lets
5558
+ // self-schedule, hit "NOT NULL constraint failed: schedules.reply_chat_id".
5559
+ // No chat means no reply chat, exactly as on the agent endpoint.
5560
+ chatId: meta.chat_id ?? "",
5561
+ threadId: meta.thread_id || null,
5562
+ adapterId: meta.adapter_id || null,
5563
+ silent: !!(payload.silent),
5564
+ });
5158
5565
  ipc.send({ type: "fleet_schedule_response", fleetRequestId, result });
5159
5566
  }
5160
5567
  catch (err) {
@@ -5166,8 +5573,15 @@ export class FleetManager {
5166
5573
  const payload = (msg.payload ?? {});
5167
5574
  const meta = (msg.meta ?? {});
5168
5575
  const ipc = this.instanceIpcClients.get(instanceName);
5169
- if (!ipc || !this.scheduler)
5576
+ if (!ipc)
5170
5577
  return;
5578
+ if (!this.scheduler) {
5579
+ // Returning silently left the caller waiting for a response that was
5580
+ // never coming. Over IPC that is a hang, and a hang is a worse answer
5581
+ // than an error.
5582
+ ipc.send({ type: "fleet_decision_response", fleetRequestId, error: "Decisions are unavailable — the fleet scheduler is not running" });
5583
+ return;
5584
+ }
5171
5585
  const db = this.scheduler.db;
5172
5586
  const projectRoot = meta.working_directory || this.fleetConfig?.instances[instanceName]?.working_directory || "";
5173
5587
  try {
@@ -5283,23 +5697,76 @@ export class FleetManager {
5283
5697
  async handleScheduleCrudHttp(instance, op, args) {
5284
5698
  if (!this.scheduler)
5285
5699
  return { error: "Scheduler not available" };
5700
+ if (op !== "create" && op !== "list" && op !== "update" && op !== "delete") {
5701
+ return { error: `Unknown schedule op: ${op}` };
5702
+ }
5703
+ // No bound chat on this path, as before: a schedule made through the agent
5704
+ // endpoint has nowhere of its own to reply.
5705
+ return this.performScheduleOp(instance, op, args, { chatId: "", threadId: null });
5706
+ }
5707
+ /**
5708
+ * The only place an agent's request creates, changes or removes a schedule
5709
+ * (#895). The IPC handler (MCP calls, direct channel.sock writes) and the
5710
+ * agent endpoint (agent-cli, HTTP agent mode) are adapters over this, so the
5711
+ * target check cannot be present on one face and missing on the other — the
5712
+ * shape #804 had to close twice.
5713
+ *
5714
+ * `caller` is the instance the server resolved for the request; any
5715
+ * `source` in `args` is ignored, and a schedule's source is always its caller.
5716
+ * A refusal is a ToolNotPermittedError: 403 on the agent endpoint, the error
5717
+ * of the schedule response on IPC.
5718
+ */
5719
+ performScheduleOp(caller, op, args, reply) {
5720
+ const scheduler = this.scheduler;
5721
+ // Shape first, so the value the permission decision reads is the value the
5722
+ // scheduler gets. `target` used to be filtered through typeof for the
5723
+ // decision and passed raw to the scheduler, whose `target.startsWith`
5724
+ // then threw a TypeError for `target: 123` (#897). A non-string is refused
5725
+ // — never coerced: turning 123 into "123" would decide an identity
5726
+ // question on a value the caller did not send. `null` means absent, as
5727
+ // omitting it always did, and is removed before the scheduler sees it.
5728
+ if (args.target === null) {
5729
+ args = { ...args };
5730
+ delete args.target;
5731
+ }
5732
+ if (args.target !== undefined && typeof args.target !== "string") {
5733
+ throw new Error(`${op}_schedule: "target" must be an instance name (a string), not ${typeof args.target}.`);
5734
+ }
5735
+ if ((op === "update" || op === "delete") && typeof args.id !== "string") {
5736
+ throw new Error(`${op}_schedule: "id" must be a schedule id (a string) — get one from list_schedules.`);
5737
+ }
5738
+ const requestedTarget = args.target;
5739
+ if (op !== "list") {
5740
+ const profile = resolveToolSet(this.fleetConfig?.instances[caller], caller);
5741
+ const existing = op === "create" ? null : scheduler.get(args.id);
5742
+ const refusal = scheduleOpRefusal(profile, caller, op, { requestedTarget, existing });
5743
+ if (refusal) {
5744
+ this.logger.warn({ instance: caller, profile, op, target: requestedTarget ?? existing?.target, scheduleId: args.id }, "tool-permissions: schedule op refused");
5745
+ throw new ToolNotPermittedError(refusal);
5746
+ }
5747
+ }
5286
5748
  switch (op) {
5287
5749
  case "create":
5288
- return this.scheduler.create({
5750
+ return scheduler.create({
5289
5751
  cron: args.cron,
5290
5752
  at: args.at,
5291
5753
  message: args.message,
5292
- source: instance, target: args.target || instance,
5293
- reply_chat_id: "", reply_thread_id: null,
5754
+ source: caller,
5755
+ target: requestedTarget || caller,
5756
+ reply_chat_id: reply.chatId,
5757
+ reply_thread_id: reply.threadId,
5758
+ ...(reply.adapterId !== undefined ? { reply_adapter_id: reply.adapterId } : {}),
5294
5759
  label: args.label,
5295
5760
  timezone: args.timezone,
5761
+ ...(reply.silent !== undefined ? { silent: reply.silent } : {}),
5296
5762
  });
5297
- case "list": return this.scheduler.list(args.target);
5298
- case "update": return this.scheduler.update(args.id, args);
5763
+ case "list":
5764
+ return scheduler.list(requestedTarget);
5765
+ case "update":
5766
+ return scheduler.update(args.id, args);
5299
5767
  case "delete":
5300
- this.scheduler.delete(args.id);
5768
+ scheduler.delete(args.id);
5301
5769
  return "ok";
5302
- default: return { error: `Unknown schedule op: ${op}` };
5303
5770
  }
5304
5771
  }
5305
5772
  async handleDecisionCrudHttp(instance, op, args) {
@@ -5441,8 +5908,15 @@ export class FleetManager {
5441
5908
  const payload = (msg.payload ?? {});
5442
5909
  const meta = (msg.meta ?? {});
5443
5910
  const ipc = this.instanceIpcClients.get(instanceName);
5444
- if (!ipc || !this.scheduler)
5911
+ if (!ipc)
5912
+ return;
5913
+ if (!this.scheduler) {
5914
+ // Returning silently left the caller waiting for a response that was
5915
+ // never coming. Over IPC that is a hang, and a hang is a worse answer
5916
+ // than an error.
5917
+ ipc.send({ type: "fleet_task_response", fleetRequestId, error: "The task board is unavailable — the fleet scheduler is not running" });
5445
5918
  return;
5919
+ }
5446
5920
  const db = this.scheduler.db;
5447
5921
  const action = payload.action;
5448
5922
  try {
@@ -6790,6 +7264,39 @@ export class FleetManager {
6790
7264
  * (or an assist for one they stopped) must not stay clickable for the rest
6791
7265
  * of its 15 minutes.
6792
7266
  */
7267
+ /**
7268
+ * Collapse every still-armed button prompt, for a fleet that is going away.
7269
+ *
7270
+ * The map is in memory; the buttons are in the chat for up to 24 hours. A
7271
+ * restart therefore leaves them looking live — pressing one takes the stale
7272
+ * branch, which acknowledges the click and drops the dismissal without
7273
+ * saying so. Retiring them here means the user meets a spent prompt instead
7274
+ * of a live-looking dead one.
7275
+ *
7276
+ * Best effort and bounded. Each collapse is a platform call, a day of tips
7277
+ * can be many of them, and shutdown still has instances to stop. Whatever
7278
+ * has not finished by the deadline is abandoned — that is exactly today's
7279
+ * behaviour, so the budget can only leave things no worse than before.
7280
+ */
7281
+ async retirePendingNoncePrompts(budgetMs = NONCE_RETIRE_BUDGET_MS) {
7282
+ const entries = [...this.pendingNonceButtons.values()];
7283
+ this.pendingNonceButtons.clear();
7284
+ for (const entry of entries)
7285
+ if (entry.timer)
7286
+ clearTimeout(entry.timer);
7287
+ const collapses = entries
7288
+ .filter(entry => entry.messageId && entry.adapter.editMessageRemoveButtons)
7289
+ .map(entry => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")));
7290
+ if (!collapses.length)
7291
+ return;
7292
+ await Promise.race([
7293
+ Promise.allSettled(collapses),
7294
+ new Promise(resolve => {
7295
+ const timer = setTimeout(resolve, budgetMs);
7296
+ timer.unref?.();
7297
+ }),
7298
+ ]);
7299
+ }
6793
7300
  clearNoncePromptsForInstance(instanceName) {
6794
7301
  for (const [nonce, entry] of this.pendingNonceButtons) {
6795
7302
  if (entry.instanceName !== instanceName)
@@ -7098,6 +7605,30 @@ export class FleetManager {
7098
7605
  }
7099
7606
  return false;
7100
7607
  }
7608
+ beginCancelButtonPublication(instanceName, correlationId) {
7609
+ // A new handoff supersedes any idle timer left by the previous turn. If the
7610
+ // instance is still idle after this publication, the post-await level check
7611
+ // below starts a fresh grace period for this generation.
7612
+ this.cancelIdleButtonRetirement(instanceName);
7613
+ const publication = {
7614
+ generation: ++this.nextCancelButtonPublicationGeneration,
7615
+ correlationId,
7616
+ inFlight: true,
7617
+ retirePending: false,
7618
+ };
7619
+ this.cancelButtonPublications.set(instanceName, publication);
7620
+ return publication;
7621
+ }
7622
+ /** Remember a clear/cancel/idle decision that arrived before notifyAlert
7623
+ * returned a message id. `expected` fences an idle timer to its generation. */
7624
+ markCancelButtonPublicationForRetirement(instanceName, expected) {
7625
+ const publication = this.cancelButtonPublications.get(instanceName);
7626
+ if (!publication?.inFlight)
7627
+ return;
7628
+ if (expected && publication !== expected)
7629
+ return;
7630
+ publication.retirePending = true;
7631
+ }
7101
7632
  async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
7102
7633
  // Post first, retire after (see the tail of this method). Retiring up front
7103
7634
  // meant that from the delete until the new message came back — a chat API
@@ -7136,6 +7667,7 @@ export class FleetManager {
7136
7667
  this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
7137
7668
  return;
7138
7669
  }
7670
+ const publication = this.beginCancelButtonPublication(instanceName, correlationId);
7139
7671
  try {
7140
7672
  const sent = await adapter.notifyAlert(chatId, {
7141
7673
  type: "cancel",
@@ -7143,6 +7675,7 @@ export class FleetManager {
7143
7675
  message: "👀 處理中…",
7144
7676
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
7145
7677
  }, threadId ? { threadId } : undefined);
7678
+ publication.inFlight = false;
7146
7679
  const entry = {
7147
7680
  instanceName,
7148
7681
  adapterId,
@@ -7164,6 +7697,16 @@ export class FleetManager {
7164
7697
  // empty even if the daemon's reset broadcast is still in flight.
7165
7698
  toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
7166
7699
  };
7700
+ // A newer send started while this API call was pending. Track this late
7701
+ // message just long enough for the normal bounded delete/retry machinery
7702
+ // to remove it; critically, do not sweep the newer generation.
7703
+ if (this.cancelButtonPublications.get(instanceName) !== publication) {
7704
+ this.cancelButtons.set(sent.messageId, entry);
7705
+ this.persistCancelButtons();
7706
+ this.logger.debug({ instanceName, messageId: sent.messageId, generation: publication.generation }, "Retiring superseded cancel-button publication");
7707
+ this.retireButton(entry);
7708
+ return;
7709
+ }
7167
7710
  this.startProgressTicker(entry);
7168
7711
  // Idle-check backstop: every 5min, if the instance is idle, retire the
7169
7712
  // button. Covers turns that end without hitting a clear trigger (reply /
@@ -7200,8 +7743,30 @@ export class FleetManager {
7200
7743
  }
7201
7744
  this.persistCancelButtons();
7202
7745
  this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
7746
+ // Retirement is normally edge-triggered, but the edge (or an explicit
7747
+ // reply/cancel clear) may have happened while notifyAlert was in flight.
7748
+ // Reconcile the level after publication so a late button cannot resurrect
7749
+ // on an already-idle instance. Preserve the existing two-second grace:
7750
+ // a working transition cancels this timer just as it does for a normal
7751
+ // idle edge.
7752
+ if (publication.retirePending) {
7753
+ this.retireButton(entry);
7754
+ }
7755
+ else if (this.getInstanceExecutionState(instanceName) === "idle") {
7756
+ this.scheduleIdleButtonRetirement(instanceName);
7757
+ }
7203
7758
  }
7204
7759
  catch (e) {
7760
+ if (this.cancelButtonPublications.get(instanceName) === publication) {
7761
+ this.cancelButtonPublications.delete(instanceName);
7762
+ // beginCancelButtonPublication cancelled the previous turn's idle
7763
+ // timer so it could not act on this generation. If the provider POST
7764
+ // failed while the instance remained idle, restore that retirement
7765
+ // opportunity for any older button still on screen.
7766
+ if (this.getInstanceExecutionState(instanceName) === "idle") {
7767
+ this.scheduleIdleButtonRetirement(instanceName);
7768
+ }
7769
+ }
7205
7770
  this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
7206
7771
  }
7207
7772
  }
@@ -7554,6 +8119,7 @@ export class FleetManager {
7554
8119
  }
7555
8120
  /** Retire all cancel buttons for an instance — on reply or cancel. */
7556
8121
  clearCancelButton(instanceName) {
8122
+ this.markCancelButtonPublicationForRetirement(instanceName);
7557
8123
  this.retireInstanceButtons(instanceName);
7558
8124
  }
7559
8125
  /** Retire the cross-instance button matching a delegate→report correlation id.
@@ -7562,6 +8128,11 @@ export class FleetManager {
7562
8128
  clearCancelButtonByCorrelation(correlationId) {
7563
8129
  if (!correlationId)
7564
8130
  return;
8131
+ for (const [instanceName, publication] of this.cancelButtonPublications) {
8132
+ if (publication.inFlight && publication.correlationId === correlationId) {
8133
+ this.markCancelButtonPublicationForRetirement(instanceName, publication);
8134
+ }
8135
+ }
7565
8136
  for (const e of [...this.cancelButtons.values()]) {
7566
8137
  if (e.correlationId === correlationId)
7567
8138
  this.retireButton(e);
@@ -7666,11 +8237,39 @@ export class FleetManager {
7666
8237
  for (const name of this.configuredBackendInstanceNames()) {
7667
8238
  configured.add(this.backendNameOf(name));
7668
8239
  }
7669
- const choices = Object.keys(LOGIN_FLOWS)
7670
- .filter(backend => configured.size === 0 || configured.has(backend))
7671
- .map(backend => ({ action: backend, label: backend }));
8240
+ const installed = this.probeInstalledBackends();
8241
+ const candidates = new Set([...installed, ...configured]);
8242
+ const unsupported = [];
8243
+ const choices = [...candidates].sort().flatMap(backend => {
8244
+ const flow = LOGIN_FLOWS[backend];
8245
+ const remoteLogin = !!flow && flow.remoteLogin !== "unsupported";
8246
+ const status = [];
8247
+ if (installed.has(backend))
8248
+ status.push(t("login.status_installed"));
8249
+ if (configured.has(backend))
8250
+ status.push(t("login.status_configured"));
8251
+ if (remoteLogin)
8252
+ status.push(t("login.status_auth"));
8253
+ else
8254
+ status.push(t("login.status_unsupported"));
8255
+ if (!remoteLogin) {
8256
+ unsupported.push({ backend, flow, status });
8257
+ return [];
8258
+ }
8259
+ return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
8260
+ });
8261
+ if (unsupported.length) {
8262
+ const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${flow?.remoteLogin === "unsupported"
8263
+ ? t("login.remote_unsupported_agent_cli", backend, flow.command)
8264
+ : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
8265
+ await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
8266
+ .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
8267
+ }
7672
8268
  if (choices.length === 0) {
7673
- await chat.adapter.sendText(chat.chatId, t("login.unsupported", [...configured].join(", ")), { threadId: chat.threadId });
8269
+ if (!unsupported.length) {
8270
+ await chat.adapter.sendText(chat.chatId, t("login.none_available"), { threadId: chat.threadId })
8271
+ .catch(err => this.logger.warn({ err }, "Failed to post empty login chooser guidance"));
8272
+ }
7674
8273
  return;
7675
8274
  }
7676
8275
  await this.postNonceButtonPrompt({
@@ -8259,6 +8858,12 @@ export class FleetManager {
8259
8858
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
8260
8859
  return;
8261
8860
  }
8861
+ // The button is deliberately best-effort: it is a short-lived
8862
+ // capability and can be lost during an adapter reconnect. Always
8863
+ // publish a durable completion line first so a successful install can
8864
+ // never look like it silently disappeared. Include the binary that
8865
+ // passed the fresh-login-shell verification and the exact next step.
8866
+ await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
8262
8867
  await this.postNonceButtonPrompt({
8263
8868
  prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
8264
8869
  alertType: "login",
@@ -8510,6 +9115,8 @@ export class FleetManager {
8510
9115
  // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
8511
9116
  // same files their writeConfig() appends fleet instructions to.
8512
9117
  "grok": "AGENTS.md",
9118
+ // muse init scaffolds AGENTS.md and the binary reads it as project rules.
9119
+ "muse": "AGENTS.md",
8513
9120
  "antigravity": ".agents/agents.md",
8514
9121
  "mock": "CLAUDE.md",
8515
9122
  };
@@ -8585,6 +9192,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8585
9192
  // vendor-canonical location is .grok/skills.
8586
9193
  "opencode": [".agents", "skills"],
8587
9194
  "grok": [".grok", "skills"],
9195
+ // Live-verified on muse 1.3.0: `muse skills list --source project` sees
9196
+ // .agents/skills and ignores .muse/skills.
9197
+ "muse": [".agents", "skills"],
8588
9198
  "antigravity": [".agents", "skills"],
8589
9199
  };
8590
9200
  /** Copy general-knowledge steering + all role-eligible skills to General. */
@@ -9169,8 +9779,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9169
9779
  * A model list is an aid: a vendor that stops answering must degrade to the
9170
9780
  * previous list, never stall the command that asked for it.
9171
9781
  */
9172
- async probeBackendBounded(backend) {
9173
- const work = this.probeBackend(backend);
9782
+ async probeBackendBounded(backend, opts = {}) {
9783
+ const work = this.probeBackend(backend, opts);
9174
9784
  work.catch(() => { });
9175
9785
  let timer;
9176
9786
  const deadline = new Promise(resolve => {
@@ -9234,12 +9844,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9234
9844
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
9235
9845
  return option.id === currentModel ? `✓ ${label}` : label;
9236
9846
  }
9237
- /** Probe one backend's CLI env and cache it. Best-effort; never throws. */
9238
- async probeBackend(backend) {
9847
+ /**
9848
+ * Probe one backend's CLI env and cache it. Best-effort; never throws.
9849
+ *
9850
+ * `refreshVendorCatalog` is the "🔄 Refresh models" path only (#886): first
9851
+ * ask the CLI to refetch its own catalog, for backends whose probe merely
9852
+ * reads a file the CLI maintains. Every other caller leaves it off, so the
9853
+ * startup and /model probes behave exactly as before. A failed vendor
9854
+ * refresh fails the probe (null), which the menu reports instead of passing
9855
+ * the old list off as fresh.
9856
+ */
9857
+ async probeBackend(backend, opts = {}) {
9239
9858
  try {
9240
9859
  const be = createBackend(backend, join(getAgendHome(), "cli-env"));
9241
9860
  if (!be.probeCLIEnv)
9242
9861
  return null;
9862
+ if (opts.refreshVendorCatalog && be.refreshModelCatalog)
9863
+ await be.refreshModelCatalog();
9243
9864
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
9244
9865
  const env = { backend, probedAt: Date.now(), ...probed };
9245
9866
  // An empty result must never overwrite a catalog we already have. Some
@@ -9288,22 +9909,48 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9288
9909
  }
9289
9910
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
9290
9911
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
9912
+ return (await this.getModelOptionsWithSource(instanceName, { refresh, onLiveProbe })).models;
9913
+ }
9914
+ /**
9915
+ * The model list plus where it came from. `source` is "live" only when a probe
9916
+ * actually ran and answered; a refresh that failed or ran out of time reports
9917
+ * "cache" with the previous list, so a menu can say "could not refresh"
9918
+ * instead of presenting an old list as a fresh one.
9919
+ */
9920
+ async getModelOptionsWithSource(instanceName, opts = {}) {
9291
9921
  const backendName = this.backendNameForInstance(instanceName);
9292
9922
  const cached = this.readCliEnv(backendName);
9293
- if (!refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached))
9294
- return cached.models;
9923
+ if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
9924
+ return { models: cached.models, source: "cache" };
9925
+ }
9295
9926
  // About to go to the vendor: let the caller say so. A silent 1–10s pause on
9296
9927
  // an interactive command reads as another hang, which is the wrong lesson to
9297
9928
  // teach a user who has just been bitten by one.
9298
- onLiveProbe?.();
9929
+ opts.onLiveProbe?.();
9299
9930
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
9300
9931
  // A newly released model is invisible until this runs, which is why staleness
9301
9932
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
9302
- const env = await this.probeBackendBounded(backendName);
9933
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
9303
9934
  if (env?.models.length)
9304
- return env.models;
9935
+ return { models: env.models, source: "live" };
9305
9936
  // Probe failed or timed out: the previous list is still the best answer.
9306
- return cached?.models ?? [];
9937
+ return { models: cached?.models ?? [], source: "cache" };
9938
+ }
9939
+ /**
9940
+ * The /model menu's choices, in the one order all three pickers share:
9941
+ * 🔄 Refresh first, then models, then (claude) "More models…". Refresh takes
9942
+ * a slot inside Discord's 25-option select cap, so it is paid for from the
9943
+ * model rows, never from "More models…".
9944
+ */
9945
+ modelMenuChoices(instanceName, nonce, options, currentModel) {
9946
+ const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
9947
+ const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
9948
+ for (const o of options.slice(0, isClaude ? 23 : 24)) {
9949
+ choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
9950
+ }
9951
+ if (isClaude)
9952
+ choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
9953
+ return choices;
9307
9954
  }
9308
9955
  /**
9309
9956
  * Model catalog behind the `list_models` tool.
@@ -9604,14 +10251,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9604
10251
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
9605
10252
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(name);
9606
10253
  const nonce = randomBytes(6).toString("hex");
9607
- const isClaude = this.backendNameForInstance(name) === "claude-code";
9608
- const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
9609
- id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
9610
- label: this.modelChoiceLabel(o, currentModel),
9611
- }));
9612
- if (isClaude) {
9613
- choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
9614
- }
10254
+ const choices = this.modelMenuChoices(name, nonce, options, currentModel);
9615
10255
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
9616
10256
  timer.unref?.();
9617
10257
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
@@ -9637,15 +10277,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9637
10277
  }
9638
10278
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
9639
10279
  const nonce = randomBytes(6).toString("hex");
9640
- const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
9641
- // Keep the more-models entry inside Discord's 25-option select cap.
9642
- const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
9643
- id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
9644
- label: this.modelChoiceLabel(o, currentModel),
9645
- }));
9646
- if (isClaude) {
9647
- choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
9648
- }
10280
+ const choices = this.modelMenuChoices(instanceName, nonce, options, currentModel);
9649
10281
  const respond = async (text) => {
9650
10282
  await adapter.sendText(chatId, text, { threadId });
9651
10283
  return undefined;
@@ -9729,6 +10361,64 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9729
10361
  await pending.respond(t("model.more_unavailable")).catch(() => { });
9730
10362
  }
9731
10363
  }
10364
+ /**
10365
+ * "🔄 Refresh models" (#886): probe live past both caches and redraw the menu.
10366
+ *
10367
+ * Past AgEnD's cli-env cache (refresh=true) and, where the backend supports
10368
+ * it, past the CLI's own catalog cache too (codex: `codex debug models`).
10369
+ * Without the second half, a codex refresh re-read the same models_cache.json
10370
+ * and showed the old list as new.
10371
+ *
10372
+ * A failed refresh keeps the previous list and says so. It never blanks the
10373
+ * menu, because a picker with no rows cannot even offer another refresh.
10374
+ */
10375
+ async refreshModelMenu(pending) {
10376
+ const { models, source } = await this.getModelOptionsWithSource(pending.instanceName, {
10377
+ refresh: true, refreshVendorCatalog: true,
10378
+ });
10379
+ if (models.length === 0) {
10380
+ await pending.respond(t("model.list_unavailable", pending.instanceName)).catch(() => { });
10381
+ return;
10382
+ }
10383
+ const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(pending.instanceName);
10384
+ const nonce = randomBytes(6).toString("hex");
10385
+ const choices = this.modelMenuChoices(pending.instanceName, nonce, models, currentModel);
10386
+ const status = source === "live" ? t("model.refreshed") : t("model.refresh_failed");
10387
+ const timer = setTimeout(() => {
10388
+ const p = this.pendingModelSelects.get(nonce);
10389
+ if (p) {
10390
+ this.pendingModelSelects.delete(nonce);
10391
+ p.respond(t("model.selection_expired")).catch(() => { });
10392
+ }
10393
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
10394
+ timer.unref?.();
10395
+ this.pendingModelSelects.set(nonce, { ...pending, model: "", timer });
10396
+ try {
10397
+ if (pending.respondChoices) {
10398
+ // Discord select menu: edit the same interaction reply in place.
10399
+ await pending.respondChoices(`${status}\n${t("model.menu", `**${currentDisplay}**`)}`, choices);
10400
+ return;
10401
+ }
10402
+ if (pending.adapter && pending.adapterChatId) {
10403
+ // Telegram: retire the consumed keyboard, then post the redrawn menu.
10404
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
10405
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, t("model.refresh"), pending.adapterThreadId).catch(() => { });
10406
+ }
10407
+ const menuMessageId = await pending.adapter.promptUser(pending.adapterChatId, `${status}\n${t("model.menu", currentDisplay)}`, choices, { threadId: pending.adapterThreadId });
10408
+ const fresh = this.pendingModelSelects.get(nonce);
10409
+ if (fresh)
10410
+ fresh.menuMessageId = menuMessageId;
10411
+ return;
10412
+ }
10413
+ await pending.respond(t("model.usage")).catch(() => { });
10414
+ }
10415
+ catch (err) {
10416
+ this.pendingModelSelects.delete(nonce);
10417
+ clearTimeout(timer);
10418
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Refreshed model menu failed");
10419
+ await pending.respond(t("model.usage")).catch(() => { });
10420
+ }
10421
+ }
9732
10422
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
9733
10423
  async handleModelSelection(data) {
9734
10424
  if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
@@ -9754,6 +10444,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9754
10444
  await this.expandClaudeModelMenu(pending);
9755
10445
  return true;
9756
10446
  }
10447
+ // "🔄 Refresh models" is navigation too: re-probe past every cache and
10448
+ // redraw the same menu with what came back.
10449
+ if (model === "__refresh__") {
10450
+ await this.refreshModelMenu(pending);
10451
+ return true;
10452
+ }
9757
10453
  // Send immediate "⏳ Switching..." feedback, then apply in background.
9758
10454
  const progressText = t("model.switching", pending.instanceName, model);
9759
10455
  let progressMsgId;
@@ -10287,6 +10983,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10287
10983
  clearInterval(this.logRotateTimer);
10288
10984
  this.logRotateTimer = null;
10289
10985
  }
10986
+ if (this.discordPresenceTimer) {
10987
+ clearInterval(this.discordPresenceTimer);
10988
+ this.discordPresenceTimer = null;
10989
+ }
10290
10990
  // Cancel-button timers were never cleared here. The idle-check interval is not
10291
10991
  // unref'd, so it held the event loop open past shutdown and kept retrying
10292
10992
  // deletes against an adapter that was already gone.
@@ -10299,6 +10999,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10299
10999
  clearInterval(entry.progressTimer);
10300
11000
  }
10301
11001
  this.cancelButtons.clear();
11002
+ this.cancelButtonPublications.clear();
10302
11003
  for (const timer of this.cancelButtonIdleRetireTimers.values())
10303
11004
  clearTimeout(timer);
10304
11005
  this.cancelButtonIdleRetireTimers.clear();
@@ -10323,11 +11024,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10323
11024
  for (const pending of this.pendingClassicStarts.values())
10324
11025
  clearTimeout(pending.timer);
10325
11026
  this.pendingClassicStarts.clear();
10326
- for (const pending of this.pendingNonceButtons.values()) {
10327
- if (pending.timer)
10328
- clearTimeout(pending.timer);
10329
- }
10330
- this.pendingNonceButtons.clear();
11027
+ // Adapters are still connected here — they are stopped further down — so
11028
+ // this is the last moment the prompts can be collapsed.
11029
+ await this.retirePendingNoncePrompts();
10331
11030
  this.topicArchiver.stop();
10332
11031
  this.scheduler?.shutdown();
10333
11032
  // Stop instances in parallel batches to avoid long sequential waits.
@@ -10512,9 +11211,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10512
11211
  * Whitelisted runtime fields are pushed into live daemons; all other instance
10513
11212
  * fields, plus cold fleet-level settings, retain restart semantics.
10514
11213
  */
10515
- async reconcileInstances() {
11214
+ async reconcileInstances(observe) {
10516
11215
  if (!this.configPath)
10517
- return;
11216
+ return {};
10518
11217
  const oldConfig = this.fleetConfig;
10519
11218
  const previousRawConfig = this.rawFleetConfig;
10520
11219
  const previousRawDocument = this.rawFleetDocument;
@@ -10557,7 +11256,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10557
11256
  ? t("fleet.reload_removed_all", oldCount)
10558
11257
  : t("fleet.reload_removed_half", oldCount, newCount);
10559
11258
  this.notifyFleetError(t("fleet.reload_rejected", why));
10560
- return;
11259
+ // Reported, not swallowed: an apply job whose config was refused used to
11260
+ // mark every row done and tell the user "changes applied".
11261
+ return { rejected: why };
10561
11262
  }
10562
11263
  // Classic behavior settings share the fleet defaults but are not entries
10563
11264
  // in fleet.yaml. Snapshot the old effective chain before reloading the
@@ -10588,29 +11289,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10588
11289
  this.scheduler?.reload();
10589
11290
  const newInstances = this.fleetConfig.instances;
10590
11291
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
10591
- // Detect fleet-level changes which still need a restart. Hot defaults are
10592
- // reconciled below and must not produce a misleading restart warning.
10593
- const oldDefaultColdWithFleetFields = oldConfig?.defaults
10594
- ? splitHotColdConfig(oldConfig.defaults).cold
10595
- : {};
10596
- const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
10597
- ? splitHotColdConfig(this.fleetConfig.defaults).cold
10598
- : {};
10599
- // `tips` is a fleet-owned hot switch; it is read at post time and never
10600
- // belongs in a daemon config or a "restart required" warning.
10601
- const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
10602
- const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
10603
- const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
10604
- const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
10605
- if (oldFleetLevel !== newFleetLevel) {
10606
- this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
11292
+ // Only what a fresh process can adopt, and only relative to the signature
11293
+ // this process came up on: a Settings edit mutates this.fleetConfig in place
11294
+ // before the reload, so comparing the pre-load copy sees nothing at all.
11295
+ const newFleetLevel = this.fleetLevelSignature();
11296
+ if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
11297
+ this.logger.warn({
11298
+ keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
11299
+ }, "Fleet-level config changed — restart AgEnD for it to take effect");
11300
+ // Terminal, and deliberately not "done": this reconcile cannot adopt a
11301
+ // fleet-level change, and saying otherwise would claim AgEnD is running
11302
+ // on a configuration it is not running on.
11303
+ observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
10607
11304
  }
10608
11305
  // Stop removed instances (skip classic bot instances — they're managed by classicBot.yaml)
10609
11306
  const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
10610
11307
  for (const name of this.daemons.keys()) {
10611
11308
  if (!(name in newInstances) && !classicNames.has(name)) {
10612
11309
  this.logger.info({ name }, "Instance removed from config — stopping");
10613
- await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
11310
+ observe?.(name, "restart", "running");
11311
+ await this.stopInstance(name)
11312
+ .then(() => observe?.(name, "restart", "done"))
11313
+ .catch(err => {
11314
+ observe?.(name, "restart", "failed", err.message);
11315
+ this.logger.error({ err, name }, "Failed to stop removed instance");
11316
+ });
10614
11317
  }
10615
11318
  }
10616
11319
  // Start new + reconcile modified instances. Hot values are always sent as a
@@ -10620,20 +11323,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10620
11323
  if (!this.daemons.has(name)) {
10621
11324
  // New instance — startInstance already calls connectIpcToInstance
10622
11325
  this.logger.info({ name }, "New instance in config — starting");
11326
+ observe?.(name, "restart", "running");
10623
11327
  await this.startInstanceUnattended(name, config, topicMode, "new instance");
11328
+ observe?.(name, "restart", "done");
10624
11329
  }
10625
11330
  else if (oldConfig?.instances[name]) {
10626
11331
  const daemon = this.daemons.get(name);
10627
11332
  const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
10628
- const oldParts = splitHotColdConfig(runtimeConfig);
10629
- const newParts = splitHotColdConfig(config);
10630
- // Every field not explicitly classified hot is cold by default.
10631
- if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
11333
+ const change = classifyInstanceChange(runtimeConfig, config);
11334
+ if (change === "restart") {
10632
11335
  this.logger.info({ name }, "Instance config changed — restarting");
11336
+ observe?.(name, "restart", "running");
10633
11337
  await this.stopInstance(name).catch(() => { });
10634
11338
  await this.startInstanceUnattended(name, config, topicMode, "modified instance");
11339
+ observe?.(name, "restart", "done");
10635
11340
  }
10636
- else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
11341
+ else if (change === "hot") {
11342
+ observe?.(name, "hot", "running");
10637
11343
  const update = hotConfigUpdate(config);
10638
11344
  const ipc = this.instanceIpcClients.get(name);
10639
11345
  const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
@@ -10644,6 +11350,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10644
11350
  this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
10645
11351
  }
10646
11352
  this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
11353
+ observe?.(name, "hot", "done");
10647
11354
  }
10648
11355
  }
10649
11356
  }
@@ -10661,25 +11368,1155 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10661
11368
  const autoPauseAfter = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
10662
11369
  if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter) {
10663
11370
  this.logger.info({ instanceName: ch.instanceName }, "Classic cold config changed — restarting");
11371
+ observe?.(ch.instanceName, "restart", "running");
10664
11372
  await this.stopInstance(ch.instanceName).catch(() => { });
10665
11373
  await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
11374
+ observe?.(ch.instanceName, "restart", "done");
10666
11375
  continue;
10667
11376
  }
10668
11377
  const toolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
10669
11378
  const replyCompletionGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
10670
11379
  if (old.toolProgress !== toolProgress || old.replyCompletionGuard !== replyCompletionGuard) {
11380
+ observe?.(ch.instanceName, "hot", "running");
10671
11381
  this.applyHotConfigUpdate(ch.instanceName, {
10672
11382
  tool_progress: toolProgress,
10673
11383
  reply_completion_guard: replyCompletionGuard,
10674
11384
  });
10675
11385
  this.logger.info({ instanceName: ch.instanceName }, "Classic inherited hot config reloaded");
11386
+ observe?.(ch.instanceName, "hot", "done");
10676
11387
  }
10677
11388
  }
10678
11389
  }
10679
11390
  // warm_cap is fleet-owned; enforce the reloaded value immediately against
10680
11391
  // currently idle instances instead of waiting for a future state edge.
10681
11392
  this.enforceWarmCap();
11393
+ // appliedFleetLevel is deliberately NOT updated here. It means "the
11394
+ // fleet-level config this process started on"; a reconcile does not restart
11395
+ // the process, so moving it would erase the fact that a restart is still
11396
+ // owed and silence every later reminder.
10682
11397
  this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
11398
+ return {};
11399
+ }
11400
+ /**
11401
+ * `{channel, cold defaults}` as one comparable string — the part of the config
11402
+ * a running fleet process cannot adopt without restarting.
11403
+ */
11404
+ fleetLevelSignature(config = this.fleetConfig) {
11405
+ return fleetLevelSignature(config);
11406
+ }
11407
+ /**
11408
+ * The config a reconcile is about to load, not the one held in memory.
11409
+ *
11410
+ * Settings mutates the in-memory object and writes the file; only the file
11411
+ * goes back through defaults expansion. Forecasting from memory therefore
11412
+ * misses every instance that a changed fleet default will restart — the user
11413
+ * is told "restart AgEnD" and not told that five agents are about to go down.
11414
+ */
11415
+ nextFleetConfig() {
11416
+ if (!this.configPath)
11417
+ return this.fleetConfig;
11418
+ try {
11419
+ return loadFleetConfig(this.configPath);
11420
+ }
11421
+ catch (err) {
11422
+ // An unparseable file is the reconcile's problem to report; the forecast
11423
+ // falls back to what is running rather than failing the request.
11424
+ this.logger.debug({ err }, "Apply plan fell back to the in-memory config");
11425
+ return this.fleetConfig;
11426
+ }
11427
+ }
11428
+ /**
11429
+ * Does the config this process is running match the one a reconcile would
11430
+ * load off disk?
11431
+ *
11432
+ * If not, every apply reports a fleet-level change that a restart cannot
11433
+ * clear — restart, recompute, disagree again — a self-sustaining loop that
11434
+ * the rate limit can only slow to three naggings an hour. Startup rewrites
11435
+ * the file in three places before this point (slimFleetConfigAtStartup, the
11436
+ * general auto-create, the general fixup), so the two can genuinely diverge.
11437
+ *
11438
+ * An offer to restart that cannot possibly succeed is worse than no offer, so
11439
+ * the panel shows the mismatch instead of a button.
11440
+ */
11441
+ checkStartupSignatureConsistency() {
11442
+ if (!this.configPath) {
11443
+ this.fleetSignatureMismatch = null;
11444
+ return;
11445
+ }
11446
+ let onDisk;
11447
+ try {
11448
+ onDisk = loadFleetConfig(this.configPath);
11449
+ }
11450
+ catch (err) {
11451
+ this.logger.warn({ err }, "Could not re-read fleet.yaml to check the startup signature");
11452
+ this.fleetSignatureMismatch = null;
11453
+ return;
11454
+ }
11455
+ if (fleetLevelSignature(onDisk) === this.appliedFleetLevel) {
11456
+ this.fleetSignatureMismatch = null;
11457
+ return;
11458
+ }
11459
+ this.fleetSignatureMismatch = fleetLevelDifferences(this.fleetConfig, onDisk);
11460
+ this.logger.warn({
11461
+ keys: this.fleetSignatureMismatch,
11462
+ configPath: this.configPath,
11463
+ }, "fleet.yaml and the running configuration disagree on startup-only keys — every apply will ask for a restart that cannot clear it");
11464
+ }
11465
+ /**
11466
+ * Is a live adapter already long-polling this bot token?
11467
+ *
11468
+ * Telegram's `getUpdates` has exactly one consumer: a second poller takes
11469
+ * turns with the first and both miss messages. The setup wizard's "post in
11470
+ * the group and I'll detect it" step is a second poller, so it has to know
11471
+ * when the answer is "not against this token, not while I'm running".
11472
+ */
11473
+ isBotTokenInUse(token) {
11474
+ if (!token)
11475
+ return false;
11476
+ const configured = this.fleetConfig?.channels
11477
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
11478
+ for (const channel of configured) {
11479
+ const envVar = channel?.bot_token_env;
11480
+ if (!envVar)
11481
+ continue;
11482
+ // Compare the value, not the variable name: the same token can be
11483
+ // reached through a differently named variable.
11484
+ if (process.env[envVar] === token)
11485
+ return true;
11486
+ }
11487
+ return false;
11488
+ }
11489
+ /** Non-null when startup found the running config and fleet.yaml disagreeing. */
11490
+ fleetSignatureMismatchKeys() {
11491
+ return this.fleetSignatureMismatch;
11492
+ }
11493
+ /**
11494
+ * Restart AgEnD itself on behalf of a Settings apply.
11495
+ *
11496
+ * Deliberately a separate action from Apply: the panel is reachable from
11497
+ * outside the LAN, and "restart the whole fleet" must never be something a
11498
+ * single Apply click can carry along with it.
11499
+ *
11500
+ * The order below is the safety envelope, and the order matters:
11501
+ * concurrency and consistency first (cheap, and a restart during a reconcile
11502
+ * is the dangerous one), then the state checks, then the rate limit, then the
11503
+ * audit notice — and only once all of that holds is the attempt written to
11504
+ * disk and fsynced, before anything spawns.
11505
+ */
11506
+ async requestSettingsSelfRestart(jobId, key) {
11507
+ // A retry after a lost response must not restart a second time. The key is
11508
+ // recorded on the job, which survives the restart it triggers.
11509
+ const already = this.applyJobs.findByRestartKey(key);
11510
+ if (already)
11511
+ return { ok: true, jobId: already.id, reused: true };
11512
+ if (this.reconcileInFlight || this.activeApplyJobId) {
11513
+ return { ok: false, status: 409, error: "a configuration reload is running — try again once it finishes" };
11514
+ }
11515
+ if (this.fleetSignatureMismatch) {
11516
+ // Restarting cannot clear this, so offering it would be a loop.
11517
+ return {
11518
+ ok: false,
11519
+ status: 409,
11520
+ error: `fleet.yaml and the running configuration disagree on ${this.fleetSignatureMismatch.join(", ")} — check fleet.log before restarting`,
11521
+ };
11522
+ }
11523
+ const job = this.applyJobs.get(jobId);
11524
+ const row = job?.targets.find(item => item.target === APPLY_FLEET_TARGET);
11525
+ if (!job || !row || row.status !== "restart-required") {
11526
+ return { ok: false, status: 409, error: "that apply has no pending fleet-level change" };
11527
+ }
11528
+ // The row alone is not enough: an old job keeps its row for the whole
11529
+ // retention window, so a change made and then reverted would still leave a
11530
+ // job that looks restartable. The live signature is the authority — and it
11531
+ // must be the same view of the config the plan uses.
11532
+ if (this.appliedFleetLevel === null || this.appliedFleetLevel === this.fleetLevelSignature(this.nextFleetConfig())) {
11533
+ return { ok: false, status: 409, error: "no fleet-level change is pending any more" };
11534
+ }
11535
+ const allowance = checkSelfRestartAllowance(this.dataDir);
11536
+ if (!allowance.allowed) {
11537
+ if (allowance.reason === "unreadable") {
11538
+ // Fail closed: with the limit's own state in doubt, "no attempts yet"
11539
+ // is the one reading that must not be assumed.
11540
+ return {
11541
+ ok: false,
11542
+ status: 503,
11543
+ error: "the restart rate-limit file cannot be read — remove self-restart.json from the data dir on the host, or run `agend restart` there",
11544
+ };
11545
+ }
11546
+ return {
11547
+ ok: false,
11548
+ status: 429,
11549
+ error: allowance.reason === "too-soon"
11550
+ ? "AgEnD was restarted from Settings very recently"
11551
+ : "too many Settings-triggered restarts in the last hour",
11552
+ retryAfterSeconds: allowance.retryAfterSeconds,
11553
+ };
11554
+ }
11555
+ // Recorded before the notice, not after. If recording keeps failing (a
11556
+ // read-only data dir), posting first would let whoever holds the token spam
11557
+ // the channel with "restarting…" notices for restarts that never happen.
11558
+ // The cost is that a failed announcement still spends an attempt, which is
11559
+ // the right way round for a rate limit.
11560
+ if (!recordSelfRestartAttempt(this.dataDir)) {
11561
+ this.logger.error("Self-restart attempt could not be recorded — refusing to restart unmetered");
11562
+ return { ok: false, status: 503, error: "could not record the restart attempt" };
11563
+ }
11564
+ // Out-of-band notice before the restart, so a panel restart is visible where
11565
+ // the admins are. Refusing when it cannot be posted is the same rule as
11566
+ // refusing when the progress marker cannot be written: no untraceable
11567
+ // restarts.
11568
+ const notice = await this.postSelfRestartNotice();
11569
+ if (!notice) {
11570
+ return {
11571
+ ok: false,
11572
+ status: 409,
11573
+ error: "no chat channel is available to announce the restart — run `agend restart` on the host instead",
11574
+ };
11575
+ }
11576
+ // Consume the row: it moves to running, which is also what lets the next
11577
+ // process settle it (settleAfterRestart only touches non-terminal rows).
11578
+ this.applyJobs.update(jobId, current => {
11579
+ const target = current.targets.find(item => item.target === APPLY_FLEET_TARGET);
11580
+ if (target)
11581
+ target.status = "running";
11582
+ current.restart_key = key;
11583
+ current.deadlineMs = SELF_RESTART_DEADLINE_MS;
11584
+ });
11585
+ this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
11586
+ const launched = await this.requestFullRestart(notice.adapter, notice.chatId, notice.threadId, notice.messageId)
11587
+ .catch(err => {
11588
+ this.logger.error({ err }, "Settings-triggered self restart failed to launch");
11589
+ return false;
11590
+ });
11591
+ if (!launched) {
11592
+ this.applyJobs.setTargetStatus(jobId, APPLY_FLEET_TARGET, "failed", "restart could not be launched");
11593
+ this.applyJobs.finish(jobId, "restart could not be launched");
11594
+ this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
11595
+ return { ok: false, status: 409, error: "the restart could not be launched — see fleet.log" };
11596
+ }
11597
+ return { ok: true, jobId };
11598
+ }
11599
+ /** The audit notice, and the message the restart progress will edit. */
11600
+ async postSelfRestartNotice() {
11601
+ const groupId = this.fleetConfig?.channel?.group_id;
11602
+ const adapter = this.adapter;
11603
+ if (!groupId || !adapter)
11604
+ return null;
11605
+ const generalName = this.findGeneralInstance();
11606
+ const rawThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
11607
+ const threadId = rawThreadId != null ? String(rawThreadId) : undefined;
11608
+ try {
11609
+ const sent = await adapter.sendText(String(groupId), t("restart.settings_triggered"), { threadId });
11610
+ if (!sent?.messageId)
11611
+ return null;
11612
+ return { adapter, chatId: sent.chatId, threadId: sent.threadId, messageId: sent.messageId };
11613
+ }
11614
+ catch (err) {
11615
+ this.logger.error({ err }, "Could not announce the Settings-triggered restart — refusing to restart silently");
11616
+ return null;
11617
+ }
11618
+ }
11619
+ /** Jobs outlive this process on purpose; see apply-job.ts. */
11620
+ get applyJobs() {
11621
+ return (this.applyJobStoreCache ??= new ApplyJobStore(this.dataDir, Date.now, this.logger));
11622
+ }
11623
+ /** The only channel metadata exposed to the Settings secret UI. */
11624
+ listSecureConnections() {
11625
+ const channels = this.fleetConfig?.channels
11626
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
11627
+ return channels.map((channel, index) => {
11628
+ const id = channel.id ?? channel.type ?? `channel-${index}`;
11629
+ const world = this.worlds.get(id);
11630
+ const state = this.adapterState.get(id);
11631
+ return {
11632
+ id,
11633
+ type: channel.type,
11634
+ token_env: channel.bot_token_env,
11635
+ token_present: !!process.env[channel.bot_token_env],
11636
+ group_id: channel.group_id != null ? String(channel.group_id) : null,
11637
+ general_channel_id: channel.options?.general_channel_id != null
11638
+ ? String(channel.options.general_channel_id)
11639
+ : null,
11640
+ status: state?.status ?? (world ? "starting" : "stopped"),
11641
+ ...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
11642
+ };
11643
+ });
11644
+ }
11645
+ secureConnectionChannel(connectionId) {
11646
+ const channels = this.fleetConfig?.channels
11647
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
11648
+ const matches = channels.filter((channel, index) => (channel.id ?? channel.type ?? `channel-${index}`) === connectionId);
11649
+ // Ambiguous fallback IDs (for example two unlabelled Discord channels)
11650
+ // must fail closed rather than rotating the first matching token.
11651
+ return matches.length === 1 ? matches[0] : undefined;
11652
+ }
11653
+ secureConnectionGeneration(connectionId) {
11654
+ const adapter = this.adapters.get(connectionId);
11655
+ const healthGeneration = adapter?.getHealthSnapshot?.().generation ?? 0;
11656
+ let generation = this.connectionSecretGenerations.get(connectionId) ?? 0;
11657
+ const previousAdapter = this.connectionSecretAdapterRefs.get(connectionId);
11658
+ const previousHealthGeneration = this.connectionSecretHealthGenerations.get(connectionId);
11659
+ if (this.connectionSecretAdapterRefs.has(connectionId) && previousAdapter !== adapter)
11660
+ generation++;
11661
+ if (this.connectionSecretHealthGenerations.has(connectionId) && previousHealthGeneration !== healthGeneration)
11662
+ generation++;
11663
+ this.connectionSecretAdapterRefs.set(connectionId, adapter);
11664
+ this.connectionSecretHealthGenerations.set(connectionId, healthGeneration);
11665
+ this.connectionSecretGenerations.set(connectionId, generation);
11666
+ return generation;
11667
+ }
11668
+ /** Provider API-key rows exposed to Settings (never the env key or secret). */
11669
+ providerSecretsEnabled() {
11670
+ return this.fleetConfig?.web?.provider_secrets === true;
11671
+ }
11672
+ listProviderSecrets() {
11673
+ return PROVIDER_SECRET_SPECS.map(spec => ({
11674
+ id: spec.id,
11675
+ display_name: spec.displayName,
11676
+ kind: spec.kind,
11677
+ token_present: !!process.env[spec.envKey],
11678
+ verifier: spec.verifier ? "available" : "unsupported",
11679
+ activation: spec.activation,
11680
+ stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
11681
+ }));
11682
+ }
11683
+ providerSecretStaleConsumers(envKey) {
11684
+ // A child inherits the manager's environment at spawn. We cannot inspect
11685
+ // a child process's private environment safely, so report the conservative
11686
+ // set of already-running children; the UI can then say "restart these"
11687
+ // rather than claiming an existing process reloaded.
11688
+ if (envKey === "GROQ_API_KEY")
11689
+ return [];
11690
+ return [...this.children.keys()].sort();
11691
+ }
11692
+ providerSecretGeneration(envKey) {
11693
+ return this.providerSecretGenerations.get(envKey) ?? 0;
11694
+ }
11695
+ providerSecretEnvAllowed(envKey) {
11696
+ const configured = new Set((this.fleetConfig?.channels
11697
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
11698
+ .map(channel => channel.bot_token_env));
11699
+ return !configured.has(envKey) && providerRegistryEnvKeys().has(envKey);
11700
+ }
11701
+ async verifyProviderSecret(input) {
11702
+ const spec = providerSecretSpec(input.specId);
11703
+ if (!spec || !this.providerSecretEnvAllowed(spec.envKey)) {
11704
+ return { ok: false, status: "invalid", error: "provider secret is not configured" };
11705
+ }
11706
+ if (!spec.verifier)
11707
+ return { ok: false, status: "unsupported_verifier", error: "this provider has no supported verifier" };
11708
+ if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret) || /[^\x20-\x7e]/.test(input.secret)) {
11709
+ return { ok: false, status: "invalid", error: "secret is invalid" };
11710
+ }
11711
+ const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
11712
+ const existingId = this.providerSecretChallengesByKey.get(challengeKey);
11713
+ const existing = existingId ? this.providerSecretChallenges.get(existingId) : undefined;
11714
+ if (existing && existing.expiresAt > Date.now()) {
11715
+ return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, spec_id: spec.id, activation: spec.activation };
11716
+ }
11717
+ if (existingId)
11718
+ this.providerSecretChallengesByKey.delete(challengeKey);
11719
+ const result = await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
11720
+ if (!result.ok) {
11721
+ // Do not log provider detail: the HTTP verifier already redacted it and
11722
+ // this endpoint has no need to disclose whether a key was close to valid.
11723
+ this.logger.warn({ specId: spec.id, status: result.status }, "Provider API-key verification failed");
11724
+ return { ok: false, status: result.status, error: result.status === "unsupported_verifier" ? "this provider has no supported verifier" : "provider rejected or unavailable" };
11725
+ }
11726
+ const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
11727
+ const challenge = {
11728
+ id: opaqueId("provider_verify"),
11729
+ specId: spec.id,
11730
+ envKey: spec.envKey,
11731
+ kind: "api_key",
11732
+ sessionBinding: input.sessionBinding,
11733
+ generation: this.providerSecretGeneration(spec.envKey),
11734
+ operation: "provider-secret.apply",
11735
+ idempotencyKey: input.idempotencyKey,
11736
+ expiresAt,
11737
+ secret: input.secret,
11738
+ };
11739
+ this.providerSecretChallenges.set(challenge.id, challenge);
11740
+ this.providerSecretChallengesByKey.set(challengeKey, challenge.id);
11741
+ const expiryTimer = setTimeout(() => {
11742
+ if (this.providerSecretChallenges.get(challenge.id) !== challenge)
11743
+ return;
11744
+ this.providerSecretChallenges.delete(challenge.id);
11745
+ if (this.providerSecretChallengesByKey.get(challengeKey) === challenge.id)
11746
+ this.providerSecretChallengesByKey.delete(challengeKey);
11747
+ }, SECRET_CHALLENGE_TTL_MS);
11748
+ expiryTimer.unref?.();
11749
+ return { ok: true, verification_id: challenge.id, expires_at: expiresAt, spec_id: spec.id, activation: spec.activation };
11750
+ }
11751
+ /** Naming aliases used by integrations that call this an API-key operation. */
11752
+ verifyProviderApiKey(input) {
11753
+ return this.verifyProviderSecret(input);
11754
+ }
11755
+ startProviderSecretApply(input) {
11756
+ for (const [jobId, job] of this.providerSecretJobs) {
11757
+ if (job.specId === input.specId && job.idempotencyKey === input.idempotencyKey
11758
+ && this.providerSecretJobSession.get(jobId) === input.sessionBinding)
11759
+ return { job, reused: true };
11760
+ }
11761
+ const challenge = this.providerSecretChallenges.get(input.verificationId);
11762
+ const spec = providerSecretSpec(input.specId);
11763
+ if (!challenge || challenge.expiresAt <= Date.now()) {
11764
+ if (challenge)
11765
+ this.providerSecretChallenges.delete(input.verificationId);
11766
+ return { error: "verification expired; verify the secret again" };
11767
+ }
11768
+ if (!spec || challenge.specId !== spec.id || challenge.envKey !== spec.envKey || challenge.kind !== "api_key"
11769
+ || challenge.sessionBinding !== input.sessionBinding || challenge.operation !== "provider-secret.apply"
11770
+ || challenge.idempotencyKey !== input.idempotencyKey || challenge.generation !== this.providerSecretGeneration(challenge.envKey)) {
11771
+ return { error: "verification does not match this provider or session" };
11772
+ }
11773
+ const existingId = this.providerSecretInFlight.get(challenge.envKey);
11774
+ if (existingId) {
11775
+ const existing = this.providerSecretJobs.get(existingId) ?? null;
11776
+ if (existing?.idempotencyKey === input.idempotencyKey)
11777
+ return { job: existing, reused: true };
11778
+ return { busy: existing };
11779
+ }
11780
+ this.providerSecretChallenges.delete(input.verificationId);
11781
+ this.providerSecretChallengesByKey.delete(`${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`);
11782
+ const job = {
11783
+ id: opaqueId("provider_apply"), specId: spec.id, envKey: spec.envKey,
11784
+ idempotencyKey: input.idempotencyKey, result: "applying", status: "running", startedAt: Date.now(),
11785
+ stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
11786
+ };
11787
+ this.providerSecretJobs.set(job.id, job);
11788
+ this.providerSecretJobSession.set(job.id, input.sessionBinding);
11789
+ this.providerSecretInFlight.set(spec.envKey, job.id);
11790
+ queueMicrotask(() => void this.runProviderSecretApply(job, challenge.secret));
11791
+ return { job, reused: false };
11792
+ }
11793
+ startProviderApiKeyApply(input) {
11794
+ return this.startProviderSecretApply(input);
11795
+ }
11796
+ getProviderSecretApply(jobId, sessionBinding) {
11797
+ if (this.providerSecretJobSession.get(jobId) !== sessionBinding)
11798
+ return null;
11799
+ return this.providerSecretJobs.get(jobId) ?? null;
11800
+ }
11801
+ getProviderApiKeyApply(jobId, sessionBinding) {
11802
+ return this.getProviderSecretApply(jobId, sessionBinding);
11803
+ }
11804
+ async runProviderSecretApply(job, secret) {
11805
+ const spec = providerSecretSpec(job.specId);
11806
+ const allowed = new Set([
11807
+ ...PROVIDER_SECRET_SPECS.map(item => item.envKey),
11808
+ ...(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).map(channel => channel.bot_token_env),
11809
+ ]);
11810
+ let store = null;
11811
+ let before = null;
11812
+ const previousProcessValue = process.env[job.envKey];
11813
+ let wrote = false;
11814
+ try {
11815
+ if (!spec || !this.providerSecretEnvAllowed(job.envKey))
11816
+ throw new Error("provider secret is not configured");
11817
+ // Construct inside the transaction: symlink/permission refusal must
11818
+ // settle the job as a safe failure, not escape the queued microtask.
11819
+ store = new SecretStore(join(this.dataDir, ".env"), allowed);
11820
+ before = store.write(job.envKey, secret);
11821
+ wrote = true;
11822
+ process.env[job.envKey] = secret;
11823
+ if (spec.activation === "reload_hook" && spec.reloadHookId) {
11824
+ await this.runProviderSecretReloadHook(spec.reloadHookId, secret, previousProcessValue);
11825
+ job.result = "reloaded";
11826
+ }
11827
+ else {
11828
+ job.result = "applied_next_use";
11829
+ }
11830
+ this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1);
11831
+ job.status = "done";
11832
+ job.finishedAt = Date.now();
11833
+ }
11834
+ catch (err) {
11835
+ const safe = safeSecretError(err, secret);
11836
+ this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
11837
+ try {
11838
+ if (wrote && before && store)
11839
+ store.restore(before);
11840
+ if (previousProcessValue === undefined)
11841
+ delete process.env[job.envKey];
11842
+ else
11843
+ process.env[job.envKey] = previousProcessValue;
11844
+ // SecretStore.write is itself transactional; when it fails before a
11845
+ // snapshot is returned there is no new value to roll back. Report the
11846
+ // truthful no-op rather than claiming rollback_failed.
11847
+ job.result = "rolled_back";
11848
+ if (!wrote || !before)
11849
+ job.error = "provider secret was not applied";
11850
+ }
11851
+ catch (rollbackErr) {
11852
+ this.logger.error({ specId: job.specId, reason: safeSecretError(rollbackErr, secret, previousProcessValue ? [previousProcessValue] : []) }, "Provider API-key rollback failed");
11853
+ job.result = "rollback_failed";
11854
+ job.error = "provider secret rollback failed; operator attention required";
11855
+ }
11856
+ job.status = "done";
11857
+ job.finishedAt = Date.now();
11858
+ }
11859
+ finally {
11860
+ this.providerSecretInFlight.delete(job.envKey);
11861
+ secret = "";
11862
+ }
11863
+ }
11864
+ /** Groq is currently read from process.env per voice request, so the hook is
11865
+ * intentionally a no-op. Keeping it as a named code-owned hook makes the
11866
+ * hot activation contract explicit and gives tests a failure seam; no generic
11867
+ * SIGHUP or caller-provided hook is ever executed. */
11868
+ async runProviderSecretReloadHook(hookId, _next, _previous) {
11869
+ if (hookId !== "groq.voice")
11870
+ throw new Error("unknown provider secret reload hook");
11871
+ const before = this.providerSecretHotSnapshots.get(hookId);
11872
+ this.providerSecretHotSnapshots.set(hookId, _next);
11873
+ const hook = this.providerSecretReloadHooks.get(hookId);
11874
+ try {
11875
+ if (hook)
11876
+ await hook(_next, before);
11877
+ }
11878
+ catch (err) {
11879
+ if (before === undefined)
11880
+ this.providerSecretHotSnapshots.delete(hookId);
11881
+ else
11882
+ this.providerSecretHotSnapshots.set(hookId, before);
11883
+ throw err;
11884
+ }
11885
+ }
11886
+ normalizeConnectionBinding(input) {
11887
+ // IDs arrive from JSON and may be Discord snowflakes. Do not accept a
11888
+ // number here: JSON.parse may already have rounded it before verification.
11889
+ if (typeof input.group_id !== "string")
11890
+ return null;
11891
+ const groupId = input.group_id.trim();
11892
+ if (!groupId || groupId.length > 128 || /[\r\n\0]/.test(groupId))
11893
+ return null;
11894
+ let general;
11895
+ if (input.general_channel_id === null || input.general_channel_id === undefined || input.general_channel_id === "") {
11896
+ general = input.general_channel_id === null ? null : undefined;
11897
+ }
11898
+ else if (typeof input.general_channel_id === "string") {
11899
+ general = input.general_channel_id.trim();
11900
+ if (!general || general.length > 128 || /[\r\n\0]/.test(general))
11901
+ return null;
11902
+ }
11903
+ else {
11904
+ return null;
11905
+ }
11906
+ return general === undefined ? { group_id: groupId } : { group_id: groupId, general_channel_id: general };
11907
+ }
11908
+ connectionBindingChannelConfig(channel, binding) {
11909
+ const candidate = structuredClone(channel);
11910
+ // IDs are intentionally normalized to strings at this boundary. Discord
11911
+ // snowflakes must never become YAML numbers (precision loss is silent).
11912
+ candidate.group_id = String(binding.group_id);
11913
+ if (binding.general_channel_id !== undefined) {
11914
+ const options = { ...(candidate.options ?? {}) };
11915
+ if (binding.general_channel_id === null)
11916
+ delete options.general_channel_id;
11917
+ else
11918
+ options.general_channel_id = String(binding.general_channel_id);
11919
+ if (Object.keys(options).length === 0)
11920
+ delete candidate.options;
11921
+ else
11922
+ candidate.options = options;
11923
+ }
11924
+ return candidate;
11925
+ }
11926
+ /** Verify a prospective group/guild binding without mutating fleet state. */
11927
+ async verifyConnectionBinding(input) {
11928
+ const channel = this.secureConnectionChannel(input.connectionId);
11929
+ const binding = this.normalizeConnectionBinding(input.binding);
11930
+ const adapter = this.adapters.get(input.connectionId);
11931
+ if (!channel || !binding || !adapter?.verifyBinding) {
11932
+ return { ok: false, error: "connection binding is unsupported or invalid" };
11933
+ }
11934
+ const key = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
11935
+ const existingId = this.connectionBindingChallengesByKey.get(key);
11936
+ const existing = existingId ? this.connectionBindingChallenges.get(existingId) : undefined;
11937
+ if (existing && existing.expiresAt > Date.now()) {
11938
+ return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, binding: existing.binding, probe: existing.probe };
11939
+ }
11940
+ if (existingId)
11941
+ this.connectionBindingChallengesByKey.delete(key);
11942
+ const beforeGeneration = this.secureConnectionGeneration(input.connectionId);
11943
+ let probe;
11944
+ try {
11945
+ probe = await adapter.verifyBinding(binding.group_id, binding.general_channel_id ?? undefined);
11946
+ }
11947
+ catch (err) {
11948
+ this.logger.warn({ connectionId: input.connectionId, reason: safeSecretError(err) }, "Settings connection binding verification failed");
11949
+ return { ok: false, error: "binding verification failed" };
11950
+ }
11951
+ const afterGeneration = this.secureConnectionGeneration(input.connectionId);
11952
+ if (beforeGeneration !== afterGeneration || this.adapters.get(input.connectionId) !== adapter) {
11953
+ return { ok: false, error: "connection changed while binding was verified" };
11954
+ }
11955
+ if (probe.group_id !== binding.group_id || !probe.can_view || !probe.can_send) {
11956
+ return { ok: false, error: "provider did not confirm the requested binding" };
11957
+ }
11958
+ const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
11959
+ const challenge = {
11960
+ id: opaqueId("binding_verify"),
11961
+ connectionId: input.connectionId,
11962
+ sessionBinding: input.sessionBinding,
11963
+ generation: afterGeneration,
11964
+ operation: "binding.apply",
11965
+ idempotencyKey: input.idempotencyKey,
11966
+ expiresAt,
11967
+ binding,
11968
+ probe,
11969
+ };
11970
+ this.connectionBindingChallenges.set(challenge.id, challenge);
11971
+ this.connectionBindingChallengesByKey.set(key, challenge.id);
11972
+ const expiryTimer = setTimeout(() => {
11973
+ if (this.connectionBindingChallenges.get(challenge.id) !== challenge)
11974
+ return;
11975
+ this.connectionBindingChallenges.delete(challenge.id);
11976
+ if (this.connectionBindingChallengesByKey.get(key) === challenge.id)
11977
+ this.connectionBindingChallengesByKey.delete(key);
11978
+ }, SECRET_CHALLENGE_TTL_MS);
11979
+ expiryTimer.unref?.();
11980
+ return { ok: true, verification_id: challenge.id, expires_at: expiresAt, binding, probe };
11981
+ }
11982
+ startConnectionBindingApply(input) {
11983
+ for (const [jobId, job] of this.connectionBindingJobs) {
11984
+ if (job.connectionId === input.connectionId && job.idempotencyKey === input.idempotencyKey
11985
+ && this.connectionBindingJobSession.get(jobId) === input.sessionBinding)
11986
+ return { job, reused: true };
11987
+ }
11988
+ const challenge = this.connectionBindingChallenges.get(input.verificationId);
11989
+ if (!challenge || challenge.expiresAt <= Date.now()) {
11990
+ this.connectionBindingChallenges.delete(input.verificationId);
11991
+ return { error: "binding verification expired; verify the binding again" };
11992
+ }
11993
+ if (challenge.connectionId !== input.connectionId || challenge.sessionBinding !== input.sessionBinding
11994
+ || challenge.operation !== "binding.apply" || challenge.idempotencyKey !== input.idempotencyKey
11995
+ || challenge.generation !== this.secureConnectionGeneration(input.connectionId)) {
11996
+ return { error: "binding verification does not match this connection or session" };
11997
+ }
11998
+ const existingId = this.connectionBindingInFlight.get(input.connectionId);
11999
+ if (existingId) {
12000
+ const existing = this.connectionBindingJobs.get(existingId) ?? null;
12001
+ if (existing?.idempotencyKey === input.idempotencyKey)
12002
+ return { job: existing, reused: true };
12003
+ return { busy: existing };
12004
+ }
12005
+ this.connectionBindingChallenges.delete(input.verificationId);
12006
+ this.connectionBindingChallengesByKey.delete(`${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`);
12007
+ const job = {
12008
+ id: opaqueId("binding_apply"), connectionId: input.connectionId, idempotencyKey: input.idempotencyKey,
12009
+ result: "applying", status: "running", startedAt: Date.now(),
12010
+ };
12011
+ this.connectionBindingJobs.set(job.id, job);
12012
+ this.connectionBindingJobSession.set(job.id, input.sessionBinding);
12013
+ this.connectionBindingInFlight.set(input.connectionId, job.id);
12014
+ queueMicrotask(() => void this.runConnectionBindingApply(job, challenge.binding));
12015
+ return { job, reused: false };
12016
+ }
12017
+ getConnectionBindingApply(jobId, sessionBinding) {
12018
+ if (this.connectionBindingJobSession.get(jobId) !== sessionBinding)
12019
+ return null;
12020
+ return this.connectionBindingJobs.get(jobId) ?? null;
12021
+ }
12022
+ async runConnectionBindingApply(job, binding) {
12023
+ try {
12024
+ await this.rebuildAdapterForBinding(job.connectionId, binding);
12025
+ job.result = "applied";
12026
+ }
12027
+ catch (err) {
12028
+ const reason = safeSecretError(err);
12029
+ job.result = /rollback failed/i.test(reason) ? "rollback_failed" : "rolled_back";
12030
+ job.error = job.result === "rollback_failed"
12031
+ ? "binding rollback failed; adapter requires operator attention"
12032
+ : "binding was not applied; previous binding was restored";
12033
+ this.logger.warn({ connectionId: job.connectionId, reason: safeSecretError(err) }, "Settings connection binding apply failed");
12034
+ }
12035
+ finally {
12036
+ job.status = "done";
12037
+ job.finishedAt = Date.now();
12038
+ this.connectionBindingInFlight.delete(job.connectionId);
12039
+ }
12040
+ }
12041
+ /** Stop, rebuild and wait for a new adapter before committing YAML binding. */
12042
+ async rebuildAdapterForBinding(connectionId, binding) {
12043
+ const channel = this.secureConnectionChannel(connectionId);
12044
+ if (!channel || !this.fleetConfig)
12045
+ throw new Error("connection not found");
12046
+ const candidate = this.connectionBindingChannelConfig(channel, binding);
12047
+ const oldAdapter = this.adapters.get(connectionId);
12048
+ const oldWorld = this.worlds.get(connectionId);
12049
+ const oldPrimary = this.adapter;
12050
+ const oldAccess = this.accessManager;
12051
+ const oldState = this.adapterState.get(connectionId);
12052
+ const oldChannel = structuredClone(channel);
12053
+ const primary = this.getPrimaryAdapterId() === connectionId;
12054
+ if (primary && this.sessionPruneTimer) {
12055
+ clearInterval(this.sessionPruneTimer);
12056
+ this.sessionPruneTimer = null;
12057
+ }
12058
+ let fresh;
12059
+ let persistedBinding = false;
12060
+ try {
12061
+ this.adapterState.set(connectionId, { status: "retrying", retryCount: oldState?.retryCount ?? 0 });
12062
+ if (oldAdapter) {
12063
+ oldAdapter.removeAllListeners();
12064
+ await oldAdapter.stop().catch(() => { });
12065
+ if (this.adapters.get(connectionId) === oldAdapter)
12066
+ this.adapters.delete(connectionId);
12067
+ if (this.worlds.get(connectionId)?.adapter === oldAdapter)
12068
+ this.worlds.delete(connectionId);
12069
+ if (primary && this.adapter === oldAdapter)
12070
+ this.adapter = null;
12071
+ }
12072
+ let startedResolve = null;
12073
+ const started = new Promise(resolve => { startedResolve = resolve; });
12074
+ const onStarted = () => { startedResolve?.(); };
12075
+ if (primary)
12076
+ await this.startSingleAdapter(this.fleetConfig, candidate, onStarted);
12077
+ else
12078
+ await this.startAdditionalAdapter(candidate, true, onStarted);
12079
+ fresh = this.adapters.get(connectionId);
12080
+ if (!fresh)
12081
+ throw new Error("new adapter did not start");
12082
+ const deadline = Date.now() + 15_000;
12083
+ if (!fresh.getHealthSnapshot) {
12084
+ let timer;
12085
+ const timeout = new Promise((_, reject) => {
12086
+ timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline - Date.now()));
12087
+ timer.unref?.();
12088
+ });
12089
+ try {
12090
+ await Promise.race([started, timeout]);
12091
+ }
12092
+ finally {
12093
+ if (timer)
12094
+ clearTimeout(timer);
12095
+ }
12096
+ }
12097
+ else {
12098
+ while (Date.now() < deadline) {
12099
+ const health = fresh.getHealthSnapshot?.();
12100
+ if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
12101
+ break;
12102
+ await new Promise(resolve => setTimeout(resolve, 100));
12103
+ }
12104
+ const health = fresh.getHealthSnapshot?.();
12105
+ if (health && health.status !== "connected" && this.adapterState.get(connectionId)?.status !== "connected") {
12106
+ throw new Error("new adapter did not become connected");
12107
+ }
12108
+ }
12109
+ if (fresh.setChatId)
12110
+ fresh.setChatId(String(candidate.group_id));
12111
+ // Commit only after the replacement adapter is ready. No allowlist,
12112
+ // topic, instance or schedule fields are touched here.
12113
+ channel.group_id = String(binding.group_id);
12114
+ if (binding.general_channel_id !== undefined) {
12115
+ const options = { ...(channel.options ?? {}) };
12116
+ if (binding.general_channel_id === null)
12117
+ delete options.general_channel_id;
12118
+ else
12119
+ options.general_channel_id = String(binding.general_channel_id);
12120
+ if (Object.keys(options).length === 0)
12121
+ delete channel.options;
12122
+ else
12123
+ channel.options = options;
12124
+ }
12125
+ this.saveFleetConfig();
12126
+ persistedBinding = true;
12127
+ this.routing.rebuild(this.fleetConfig);
12128
+ this.reregisterClassicChannels();
12129
+ this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
12130
+ }
12131
+ catch (err) {
12132
+ if (fresh && fresh !== oldAdapter)
12133
+ await fresh.stop().catch(() => { });
12134
+ // Restore only the binding object in memory; unrelated connection and
12135
+ // instance state remains exactly as it was before the attempt.
12136
+ for (const key of Object.keys(channel)) {
12137
+ if (!(key in oldChannel))
12138
+ delete channel[key];
12139
+ }
12140
+ Object.assign(channel, oldChannel);
12141
+ this.adapters.delete(connectionId);
12142
+ this.worlds.delete(connectionId);
12143
+ this.adapterState.delete(connectionId);
12144
+ if (oldAdapter) {
12145
+ try {
12146
+ const onStarted = () => { };
12147
+ if (primary)
12148
+ await this.startSingleAdapter(this.fleetConfig, oldChannel, onStarted);
12149
+ else
12150
+ await this.startAdditionalAdapter(oldChannel, true, onStarted);
12151
+ this.adapterState.set(connectionId, oldState ?? { status: "connected", retryCount: 0 });
12152
+ }
12153
+ catch (restoreErr) {
12154
+ throw new Error(`binding rollback failed: ${safeSecretError(restoreErr)}`);
12155
+ }
12156
+ }
12157
+ else {
12158
+ if (primary)
12159
+ this.adapter = oldPrimary;
12160
+ if (oldWorld)
12161
+ this.worlds.set(connectionId, oldWorld);
12162
+ if (oldAdapter)
12163
+ this.adapters.set(connectionId, oldAdapter);
12164
+ this.accessManager = oldAccess;
12165
+ }
12166
+ // The binding is committed to YAML before routing is rebuilt. If the
12167
+ // post-commit rebuild fails, restore the durable document as well as the
12168
+ // in-memory channel; otherwise a reload would resurrect the failed
12169
+ // binding that the running fleet just rolled back.
12170
+ if (persistedBinding)
12171
+ this.saveFleetConfig();
12172
+ this.routing.rebuild(this.fleetConfig);
12173
+ this.reregisterClassicChannels();
12174
+ throw err;
12175
+ }
12176
+ }
12177
+ async verifyConnectionSecret(input) {
12178
+ const channel = this.secureConnectionChannel(input.connectionId);
12179
+ if (!channel || (channel.type !== "discord" && channel.type !== "telegram")) {
12180
+ return { ok: false, error: "connection not found or unsupported" };
12181
+ }
12182
+ if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret)) {
12183
+ return { ok: false, error: "secret is invalid" };
12184
+ }
12185
+ const challengeKey = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
12186
+ const existingId = this.connectionSecretChallengesByKey.get(challengeKey);
12187
+ const existing = existingId ? this.connectionSecretChallenges.get(existingId) : undefined;
12188
+ if (existing && existing.expiresAt > Date.now()) {
12189
+ return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt };
12190
+ }
12191
+ if (existingId)
12192
+ this.connectionSecretChallengesByKey.delete(challengeKey);
12193
+ // Fixed provider endpoints only. Never use a user-supplied URL and never
12194
+ // call Telegram getUpdates (the running adapter owns that long poll).
12195
+ const identity = channel.type === "discord"
12196
+ ? await verifyDiscordToken(input.secret)
12197
+ : await verifyTelegramToken(input.secret);
12198
+ if (!identity.valid) {
12199
+ this.logger.warn({ connectionId: input.connectionId, provider: channel.type }, "Settings connection secret verification failed");
12200
+ return { ok: false, error: "provider rejected the secret" };
12201
+ }
12202
+ const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
12203
+ const challenge = {
12204
+ id: opaqueId("verify"),
12205
+ connectionId: input.connectionId,
12206
+ sessionBinding: input.sessionBinding,
12207
+ generation: this.secureConnectionGeneration(input.connectionId),
12208
+ operation: "secret.apply",
12209
+ idempotencyKey: input.idempotencyKey,
12210
+ expiresAt,
12211
+ secret: input.secret,
12212
+ };
12213
+ this.connectionSecretChallenges.set(challenge.id, challenge);
12214
+ this.connectionSecretChallengesByKey.set(challengeKey, challenge.id);
12215
+ const expiryTimer = setTimeout(() => {
12216
+ if (this.connectionSecretChallenges.get(challenge.id) !== challenge)
12217
+ return;
12218
+ this.connectionSecretChallenges.delete(challenge.id);
12219
+ if (this.connectionSecretChallengesByKey.get(challengeKey) === challenge.id) {
12220
+ this.connectionSecretChallengesByKey.delete(challengeKey);
12221
+ }
12222
+ }, SECRET_CHALLENGE_TTL_MS);
12223
+ expiryTimer.unref?.();
12224
+ return {
12225
+ ok: true,
12226
+ verification_id: challenge.id,
12227
+ expires_at: expiresAt,
12228
+ identity: { id: identity.id, username: identity.username },
12229
+ };
12230
+ }
12231
+ startConnectionSecretApply(input) {
12232
+ for (const [jobId, job] of this.connectionSecretJobs) {
12233
+ if (job.connectionId === input.connectionId
12234
+ && job.idempotencyKey === input.idempotencyKey
12235
+ && this.connectionSecretJobSession.get(jobId) === input.sessionBinding) {
12236
+ return { job, reused: true };
12237
+ }
12238
+ }
12239
+ const challenge = this.connectionSecretChallenges.get(input.verificationId);
12240
+ if (!challenge || challenge.expiresAt <= Date.now()) {
12241
+ this.connectionSecretChallenges.delete(input.verificationId);
12242
+ return { error: "verification expired; verify the secret again" };
12243
+ }
12244
+ if (challenge.connectionId !== input.connectionId
12245
+ || challenge.sessionBinding !== input.sessionBinding
12246
+ || challenge.operation !== "secret.apply"
12247
+ || challenge.idempotencyKey !== input.idempotencyKey
12248
+ || challenge.generation !== this.secureConnectionGeneration(input.connectionId)) {
12249
+ return { error: "verification does not match this connection or session" };
12250
+ }
12251
+ const existingId = this.connectionSecretInFlight.get(input.connectionId);
12252
+ if (existingId) {
12253
+ const existing = this.connectionSecretJobs.get(existingId) ?? null;
12254
+ if (existing?.idempotencyKey === input.idempotencyKey)
12255
+ return { job: existing, reused: true };
12256
+ return { busy: existing };
12257
+ }
12258
+ // Consume the challenge before scheduling work. A lost HTTP response can
12259
+ // retry with the same idempotency key and rejoin the job, but a second
12260
+ // request cannot replay the secret into a second adapter.
12261
+ this.connectionSecretChallenges.delete(input.verificationId);
12262
+ this.connectionSecretChallengesByKey.delete(`${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`);
12263
+ const job = {
12264
+ id: opaqueId("secret_apply"),
12265
+ connectionId: input.connectionId,
12266
+ idempotencyKey: input.idempotencyKey,
12267
+ result: "applying",
12268
+ status: "running",
12269
+ startedAt: Date.now(),
12270
+ };
12271
+ this.connectionSecretJobs.set(job.id, job);
12272
+ this.connectionSecretJobSession.set(job.id, input.sessionBinding);
12273
+ this.connectionSecretInFlight.set(input.connectionId, job.id);
12274
+ queueMicrotask(() => void this.runConnectionSecretApply(job, challenge.secret));
12275
+ return { job, reused: false };
12276
+ }
12277
+ getConnectionSecretApply(jobId, sessionBinding) {
12278
+ if (this.connectionSecretJobSession.get(jobId) !== sessionBinding)
12279
+ return null;
12280
+ return this.connectionSecretJobs.get(jobId) ?? null;
12281
+ }
12282
+ async runConnectionSecretApply(job, secret) {
12283
+ const channel = this.secureConnectionChannel(job.connectionId);
12284
+ const envKey = channel?.bot_token_env;
12285
+ const allowed = new Set((this.fleetConfig?.channels
12286
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
12287
+ .map(item => item.bot_token_env));
12288
+ let before = null;
12289
+ let oldToken;
12290
+ let replaced = false;
12291
+ try {
12292
+ if (!channel || !envKey || !allowed.has(envKey))
12293
+ throw new Error("connection is not configured for secret rotation");
12294
+ const owners = (this.fleetConfig?.channels
12295
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
12296
+ .filter(item => item.bot_token_env === envKey);
12297
+ if (owners.length !== 1)
12298
+ throw new Error("secret key is shared by multiple connections");
12299
+ const store = new SecretStore(join(this.dataDir, ".env"), allowed);
12300
+ before = store.write(envKey, secret);
12301
+ replaced = true;
12302
+ oldToken = process.env[envKey];
12303
+ process.env[envKey] = secret;
12304
+ const generation = this.secureConnectionGeneration(job.connectionId) + 1;
12305
+ this.connectionSecretGenerations.set(job.connectionId, generation);
12306
+ const applied = await this.rebuildAdapterForSecret(job.connectionId, channel);
12307
+ if (!applied) {
12308
+ job.result = "restart_required";
12309
+ job.status = "done";
12310
+ job.finishedAt = Date.now();
12311
+ return;
12312
+ }
12313
+ job.result = "applied";
12314
+ job.status = "done";
12315
+ job.finishedAt = Date.now();
12316
+ }
12317
+ catch (err) {
12318
+ const message = safeSecretError(err, secret);
12319
+ this.logger.warn({ connectionId: job.connectionId, reason: message }, "Settings connection secret apply failed");
12320
+ if (!replaced || !before || !envKey) {
12321
+ job.result = "rollback_failed";
12322
+ job.error = "secret was not applied";
12323
+ }
12324
+ else {
12325
+ try {
12326
+ const store = new SecretStore(join(this.dataDir, ".env"), new Set([envKey]));
12327
+ store.restore(before);
12328
+ if (oldToken === undefined)
12329
+ delete process.env[envKey];
12330
+ else
12331
+ process.env[envKey] = oldToken;
12332
+ // Build a fresh adapter from the restored token. If the old adapter
12333
+ // was stopped already, this is the only safe way to return to the
12334
+ // previous runtime without claiming a disk-only rollback succeeded.
12335
+ const restored = await this.rebuildAdapterForSecret(job.connectionId, channel, true);
12336
+ if (!restored)
12337
+ throw new Error("adapter rollback did not become connected");
12338
+ job.result = "rolled_back";
12339
+ }
12340
+ catch (rollbackErr) {
12341
+ this.logger.error({ connectionId: job.connectionId, reason: safeSecretError(rollbackErr, oldToken, [secret]) }, "Settings connection secret rollback failed");
12342
+ job.result = "rollback_failed";
12343
+ job.error = "secret rollback failed; adapter is disabled";
12344
+ }
12345
+ }
12346
+ job.status = "done";
12347
+ job.finishedAt = Date.now();
12348
+ }
12349
+ finally {
12350
+ this.connectionSecretInFlight.delete(job.connectionId);
12351
+ // Do not retain the token after the apply (success or rollback).
12352
+ secret = "";
12353
+ }
12354
+ }
12355
+ /** Stop the old provider client and construct a new one from process.env. */
12356
+ async rebuildAdapterForSecret(connectionId, channel, force = false) {
12357
+ const old = this.adapters.get(connectionId);
12358
+ if (!old && !force)
12359
+ return false; // The secret is valid on disk; the next start adopts it.
12360
+ const primary = this.getPrimaryAdapterId() === connectionId;
12361
+ const previousState = this.adapterState.get(connectionId);
12362
+ this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
12363
+ if (primary && this.sessionPruneTimer) {
12364
+ clearInterval(this.sessionPruneTimer);
12365
+ this.sessionPruneTimer = null;
12366
+ }
12367
+ if (old) {
12368
+ old.removeAllListeners();
12369
+ await old.stop().catch(() => { });
12370
+ if (this.adapters.get(connectionId) === old)
12371
+ this.adapters.delete(connectionId);
12372
+ if (this.worlds.get(connectionId)?.adapter === old)
12373
+ this.worlds.delete(connectionId);
12374
+ if (primary && this.adapter === old)
12375
+ this.adapter = null;
12376
+ }
12377
+ let startedResolve = null;
12378
+ const started = new Promise(resolve => { startedResolve = resolve; });
12379
+ const onStarted = () => { startedResolve?.(); };
12380
+ if (primary)
12381
+ await this.startSingleAdapter(this.fleetConfig, channel, onStarted);
12382
+ else
12383
+ await this.startAdditionalAdapter(channel, true, onStarted);
12384
+ const fresh = this.adapters.get(connectionId);
12385
+ if (!fresh)
12386
+ throw new Error("new adapter did not start");
12387
+ const deadline = Date.now() + 15_000;
12388
+ // Telegram has no gateway health snapshot. Its start() method launches the
12389
+ // grammY polling loop in the background, so completion of start() is not a
12390
+ // connected signal. The adapter's `started` event is emitted only after the
12391
+ // first provider getMe succeeds; require that event before claiming apply.
12392
+ if (!fresh.getHealthSnapshot) {
12393
+ let timer;
12394
+ const timeout = new Promise((_, reject) => {
12395
+ timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline - Date.now()));
12396
+ timer.unref?.();
12397
+ });
12398
+ try {
12399
+ await Promise.race([started, timeout]);
12400
+ }
12401
+ finally {
12402
+ if (timer)
12403
+ clearTimeout(timer);
12404
+ }
12405
+ this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
12406
+ return true;
12407
+ }
12408
+ while (Date.now() < deadline) {
12409
+ const health = fresh.getHealthSnapshot?.();
12410
+ if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
12411
+ return true;
12412
+ await new Promise(resolve => setTimeout(resolve, 100));
12413
+ }
12414
+ throw new Error("new adapter did not become connected");
12415
+ }
12416
+ /**
12417
+ * What a reconcile is about to do, per target.
12418
+ *
12419
+ * A forecast, not the record: it is built from the same `classifyInstanceChange`
12420
+ * the reconcile decides with, but the rows that end up in the job are the ones
12421
+ * the reconcile reports as it works. A target the forecast missed (a Classic
12422
+ * instance inheriting a changed default) is added when it is first touched.
12423
+ */
12424
+ planConfigApply() {
12425
+ const rows = [];
12426
+ const nextConfig = this.nextFleetConfig();
12427
+ const next = nextConfig?.instances ?? {};
12428
+ const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
12429
+ for (const [name, config] of Object.entries(next)) {
12430
+ const daemon = this.daemons.get(name);
12431
+ if (!daemon) {
12432
+ rows.push({ target: name, kind: "restart" });
12433
+ continue;
12434
+ }
12435
+ const runtime = daemon.getConfigSnapshot?.();
12436
+ if (!runtime)
12437
+ continue;
12438
+ const change = classifyInstanceChange(runtime, config);
12439
+ if (change !== "none")
12440
+ rows.push({ target: name, kind: change === "restart" ? "restart" : "hot" });
12441
+ }
12442
+ for (const name of this.daemons.keys()) {
12443
+ if (!(name in next) && !classicNames.has(name))
12444
+ rows.push({ target: name, kind: "restart" });
12445
+ }
12446
+ if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
12447
+ rows.push({ target: APPLY_FLEET_TARGET, kind: "restart" });
12448
+ }
12449
+ return rows;
12450
+ }
12451
+ /**
12452
+ * Start (or re-join) a Settings apply.
12453
+ *
12454
+ * The key is the client's. Handing back the existing job for a repeated key is
12455
+ * the whole point: the retry after a lost response must not apply everything a
12456
+ * second time.
12457
+ */
12458
+ startSettingsApply(key) {
12459
+ // The key is checked first on purpose: a retry of the apply that is running
12460
+ // right now must get its own job back, not "busy".
12461
+ const existing = this.applyJobs.findByKey(key);
12462
+ if (existing)
12463
+ return { job: existing, reused: true };
12464
+ if (this.reconcileInFlight || this.activeApplyJobId) {
12465
+ return { busy: this.activeApplyJobId ? this.applyJobs.get(this.activeApplyJobId) : null };
12466
+ }
12467
+ const job = this.applyJobs.create(key, this.planConfigApply());
12468
+ // Reserved synchronously: the work starts a microtask later, and a second
12469
+ // request arriving in that gap must see the slot taken.
12470
+ this.activeApplyJobId = job.id;
12471
+ // Start after the caller has its answer, so the first thing the page renders
12472
+ // is the whole plan with every row still pending — not a job the reconcile
12473
+ // has already half-finished synchronously.
12474
+ queueMicrotask(() => void this.runSettingsApply(job.id));
12475
+ return { job, reused: false };
12476
+ }
12477
+ async runSettingsApply(jobId) {
12478
+ const emit = () => {
12479
+ const job = this.applyJobs.get(jobId);
12480
+ // An accelerator only: these frames carry no event id, so a client that
12481
+ // reconnects cannot ask for what it missed. GET is the authority.
12482
+ if (job)
12483
+ this.emitSseEvent("apply_progress", viewOf(job));
12484
+ };
12485
+ // Passed in rather than parked on `this`: a shared field would let a second
12486
+ // reconcile redirect this job's reporting into another job's rows.
12487
+ const observer = (target, kind, status, error) => {
12488
+ this.applyJobs.update(jobId, job => {
12489
+ let row = job.targets.find(item => item.target === target);
12490
+ if (!row) {
12491
+ row = { target, kind, status: "pending" };
12492
+ job.targets.push(row);
12493
+ }
12494
+ row.kind = kind;
12495
+ row.status = status;
12496
+ if (error)
12497
+ row.error = error;
12498
+ });
12499
+ emit();
12500
+ };
12501
+ emit();
12502
+ try {
12503
+ const started = this.startExclusiveReconcile(observer);
12504
+ if (!started) {
12505
+ // The slot was reserved before the microtask, so this means a SIGHUP
12506
+ // reconcile started in between. Report it instead of applying twice.
12507
+ this.applyJobs.finish(jobId, "a config reload was already running");
12508
+ return;
12509
+ }
12510
+ const outcome = await started;
12511
+ this.applyJobs.finish(jobId, outcome.rejected);
12512
+ }
12513
+ catch (err) {
12514
+ this.applyJobs.finish(jobId, err instanceof Error ? err.message : String(err));
12515
+ }
12516
+ finally {
12517
+ this.activeApplyJobId = null;
12518
+ emit();
12519
+ }
10683
12520
  }
10684
12521
  async restartInstances() {
10685
12522
  if (!this.configPath) {
@@ -10972,6 +12809,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10972
12809
  this.initializeWebAuthTokens();
10973
12810
  this.healthServer = createServer((req, res) => {
10974
12811
  res.setHeader("Content-Type", "application/json");
12812
+ // No Referer to a tunnel host, an upstream proxy, or any page linked from
12813
+ // the panel — the dashboard URL is itself a credential-bearing address.
12814
+ res.setHeader("Referrer-Policy", "no-referrer");
12815
+ // Authorization now depends on a cookie, so a shared cache (a tunnel, a
12816
+ // corporate proxy) must not serve one visitor's response to another.
12817
+ res.setHeader("Vary", "Cookie");
10975
12818
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
10976
12819
  // Browsers request this automatically and AgEnD does not ship an icon.
10977
12820
  // It is neither user data nor an API route, so do not turn the harmless
@@ -10997,15 +12840,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10997
12840
  // like the other /view data routes (usage-api.ts rejects non-GET).
10998
12841
  }
10999
12842
  else {
11000
- // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
12843
+ // All other endpoints require a session cookie or an X-Agend-Token
12844
+ // header; a `?token=` in the URL is only redeemed for a cookie on a GET.
11001
12845
  // /ui/* will also re-check in web-api.ts, which is harmless.
11002
12846
  const parsedUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
11003
- const headerToken = req.headers["x-agend-token"];
11004
- const providedToken = parsedUrl.searchParams.get("token")
11005
- ?? (typeof headerToken === "string" ? headerToken : null);
11006
- if (!this.webToken || providedToken !== this.webToken) {
11007
- res.writeHead(401);
11008
- res.end(JSON.stringify({ error: WEB_TOKEN_INVALID_MESSAGE }));
12847
+ const decision = decideWebGate(req, parsedUrl, this.webToken);
12848
+ if (decision.kind === "reject") {
12849
+ res.writeHead(decision.status);
12850
+ res.end(JSON.stringify({ error: decision.message }));
12851
+ return;
12852
+ }
12853
+ if (decision.kind === "exchange") {
12854
+ res.setHeader("Set-Cookie", decision.setCookie);
12855
+ res.setHeader("Location", decision.location);
12856
+ // A cached redirect would replay a Set-Cookie for a rotated token.
12857
+ res.setHeader("Cache-Control", "no-store");
12858
+ res.writeHead(302);
12859
+ // Browsers follow the Location; a script that does not gets told why
12860
+ // its URL token stopped being echoed back as data.
12861
+ res.end(JSON.stringify({ redirect: decision.location }));
11009
12862
  return;
11010
12863
  }
11011
12864
  }
@@ -11243,8 +13096,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11243
13096
  this.logger.info({ port }, afterTakeover
11244
13097
  ? "Health endpoint listening (after takeover)"
11245
13098
  : "Health endpoint listening");
11246
- this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
11247
- this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
13099
+ // Never the token: fleet.log is readable by anything that can read the
13100
+ // data dir, is copied into bug reports, and is tailed in shared terminals.
13101
+ // `/dashboard` and `agend web` are the ways to get an authorized link.
13102
+ this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
13103
+ this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
11248
13104
  };
11249
13105
  this.healthServer.on("error", (err) => {
11250
13106
  this.healthServerListening = false;
@@ -11261,8 +13117,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11261
13117
  if (existsSync(pidPath)) {
11262
13118
  const oldPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
11263
13119
  if (oldPid && oldPid !== process.pid) {
11264
- process.kill(oldPid, "SIGTERM");
11265
- this.logger.info({ oldPid }, "Killed old fleet process");
13120
+ // fleet.pid is a claim, not proof. A stale or wrong entry points
13121
+ // at whatever now holds that pid, and this used to SIGTERM it —
13122
+ // an unrelated process killed because a port was busy. Confirm
13123
+ // the target really is an AgEnD fleet, and when that cannot be
13124
+ // confirmed, do not signal: not killing costs a dashboard, and
13125
+ // killing costs somebody else's process.
13126
+ const commandLine = readProcessCommandLine(oldPid);
13127
+ if (isFleetStartCommandLine(commandLine)) {
13128
+ process.kill(oldPid, "SIGTERM");
13129
+ this.logger.info({ oldPid }, "Killed old fleet process");
13130
+ }
13131
+ else {
13132
+ this.logger.warn({
13133
+ oldPid,
13134
+ // Truncated: this is an unrelated process's command line, and
13135
+ // fleet.log is copied into bug reports.
13136
+ commandLine: commandLine ? `${commandLine.slice(0, 60)}${commandLine.length > 60 ? "…" : ""}` : "(unreadable)",
13137
+ }, "fleet.pid does not name an AgEnD fleet process — not signalling it");
13138
+ }
11266
13139
  }
11267
13140
  }
11268
13141
  }