@songsid/agend 2.1.6-beta.1 → 2.1.6-beta.2

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 (97) hide show
  1. package/dist/apply-job.d.ts +114 -0
  2. package/dist/apply-job.js +214 -0
  3. package/dist/apply-job.js.map +1 -0
  4. package/dist/backend/credential-profile.d.ts +114 -0
  5. package/dist/backend/credential-profile.js +242 -0
  6. package/dist/backend/credential-profile.js.map +1 -0
  7. package/dist/backend/kiro-auth-store.d.ts +25 -0
  8. package/dist/backend/kiro-auth-store.js +61 -0
  9. package/dist/backend/kiro-auth-store.js.map +1 -0
  10. package/dist/backend/kiro.d.ts +8 -0
  11. package/dist/backend/kiro.js +31 -2
  12. package/dist/backend/kiro.js.map +1 -1
  13. package/dist/cli.js +113 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config-validator.js +21 -0
  16. package/dist/config-validator.js.map +1 -1
  17. package/dist/daemon.d.ts +10 -0
  18. package/dist/daemon.js +27 -1
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/fleet-level-config.d.ts +42 -0
  21. package/dist/fleet-level-config.js +78 -0
  22. package/dist/fleet-level-config.js.map +1 -0
  23. package/dist/fleet-lock.d.ts +21 -0
  24. package/dist/fleet-lock.js +42 -8
  25. package/dist/fleet-lock.js.map +1 -1
  26. package/dist/fleet-manager.d.ts +122 -1
  27. package/dist/fleet-manager.js +537 -83
  28. package/dist/fleet-manager.js.map +1 -1
  29. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +121 -0
  30. package/dist/instance-config-impact.d.ts +55 -0
  31. package/dist/instance-config-impact.js +152 -0
  32. package/dist/instance-config-impact.js.map +1 -0
  33. package/dist/locale.js +2 -0
  34. package/dist/locale.js.map +1 -1
  35. package/dist/outbound-handlers.d.ts +5 -1
  36. package/dist/outbound-handlers.js +179 -1
  37. package/dist/outbound-handlers.js.map +1 -1
  38. package/dist/outbound-schemas.d.ts +6 -5
  39. package/dist/outbound-schemas.js +1 -0
  40. package/dist/outbound-schemas.js.map +1 -1
  41. package/dist/provider-probe.d.ts +45 -0
  42. package/dist/provider-probe.js +111 -0
  43. package/dist/provider-probe.js.map +1 -0
  44. package/dist/quickstart-api.d.ts +161 -0
  45. package/dist/quickstart-api.js +340 -0
  46. package/dist/quickstart-api.js.map +1 -0
  47. package/dist/quickstart.js +23 -50
  48. package/dist/quickstart.js.map +1 -1
  49. package/dist/self-restart-limit.d.ts +21 -0
  50. package/dist/self-restart-limit.js +129 -0
  51. package/dist/self-restart-limit.js.map +1 -0
  52. package/dist/settings-api.d.ts +15 -0
  53. package/dist/settings-api.js +126 -1
  54. package/dist/settings-api.js.map +1 -1
  55. package/dist/setup-auth.d.ts +113 -0
  56. package/dist/setup-auth.js +181 -0
  57. package/dist/setup-auth.js.map +1 -0
  58. package/dist/setup-form.d.ts +18 -0
  59. package/dist/setup-form.js +315 -0
  60. package/dist/setup-form.js.map +1 -0
  61. package/dist/setup-host.d.ts +162 -0
  62. package/dist/setup-host.js +496 -0
  63. package/dist/setup-host.js.map +1 -0
  64. package/dist/setup-marker.d.ts +8 -0
  65. package/dist/setup-marker.js +39 -0
  66. package/dist/setup-marker.js.map +1 -0
  67. package/dist/setup-tunnel-consent.d.ts +53 -0
  68. package/dist/setup-tunnel-consent.js +82 -0
  69. package/dist/setup-tunnel-consent.js.map +1 -0
  70. package/dist/tips.js +1 -1
  71. package/dist/tips.js.map +1 -1
  72. package/dist/transcript-sources.d.ts +12 -1
  73. package/dist/transcript-sources.js +19 -3
  74. package/dist/transcript-sources.js.map +1 -1
  75. package/dist/tunnel/cloudflared.d.ts +71 -0
  76. package/dist/tunnel/cloudflared.js +447 -0
  77. package/dist/tunnel/cloudflared.js.map +1 -0
  78. package/dist/tunnel/lease.d.ts +75 -0
  79. package/dist/tunnel/lease.js +209 -0
  80. package/dist/tunnel/lease.js.map +1 -0
  81. package/dist/tunnel/manager.d.ts +56 -0
  82. package/dist/tunnel/manager.js +167 -0
  83. package/dist/tunnel/manager.js.map +1 -0
  84. package/dist/tunnel/types.d.ts +119 -0
  85. package/dist/tunnel/types.js +33 -0
  86. package/dist/tunnel/types.js.map +1 -0
  87. package/dist/ui/dashboard.html +4 -3
  88. package/dist/ui/settings.html +640 -153
  89. package/dist/usage/providers.d.ts +16 -13
  90. package/dist/usage/providers.js +79 -53
  91. package/dist/usage/providers.js.map +1 -1
  92. package/dist/web-api.js +6 -4
  93. package/dist/web-api.js.map +1 -1
  94. package/dist/web-auth.d.ts +75 -0
  95. package/dist/web-auth.js +208 -2
  96. package/dist/web-auth.js.map +1 -1
  97. package/package.json +1 -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";
@@ -59,9 +58,18 @@ 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";
63
64
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
64
- import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
65
+ import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
66
+ import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
67
+ import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
68
+ /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
69
+ const SELF_RESTART_DEADLINE_MS = 300_000;
70
+ import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
71
+ import { instanceCredentialProfile } from "./backend/credential-profile.js";
72
+ import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
65
73
  import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
66
74
  import { launchFullRestartHelper } from "./full-restart.js";
67
75
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
@@ -179,31 +187,6 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
179
187
  * emoji never changes the documented delivery-state protocol.
180
188
  */
181
189
  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
190
  /**
208
191
  * How long a delivery waits out a disconnected instance IPC before giving up.
209
192
  *
@@ -472,7 +455,29 @@ export class FleetManager {
472
455
  mirrorTimer = null;
473
456
  // Web UI: SSE clients + auth token
474
457
  sseClients = new Set();
475
- webToken = null;
458
+ /**
459
+ * Read from disk on every access rather than cached at startup: `agend
460
+ * web-token rotate` runs in a separate process, and a cached copy would keep
461
+ * authorizing revoked links and cookies until the fleet restarted.
462
+ */
463
+ get webToken() { return readWebToken(this.dataDir); }
464
+ /**
465
+ * Set while a Settings apply job is driving the reconcile. The reconcile
466
+ * stays the single doer; it just says out loud what it is doing to whom, so
467
+ * the job's rows are the work rather than a prediction of it.
468
+ */
469
+ applyJobStoreCache = null;
470
+ /** The apply that currently owns the reconcile slot, reserved synchronously
471
+ * so a second request cannot slip in before the first one starts working. */
472
+ activeApplyJobId = null;
473
+ /** The fleet-level signature this process actually came up on. */
474
+ appliedFleetLevel = null;
475
+ /** The config behind that signature, kept so a "needs restart" log can name
476
+ * which keys moved rather than just asserting that something did. */
477
+ startupFleetConfig = null;
478
+ /** Set when the file on disk and the in-memory config disagree on a
479
+ * startup-only key at startup. See checkStartupSignatureConsistency(). */
480
+ fleetSignatureMismatch = null;
476
481
  viewToken = null;
477
482
  healthServerListening = false;
478
483
  constructor(dataDir) {
@@ -548,26 +553,48 @@ export class FleetManager {
548
553
  this.scheduleReconcile();
549
554
  }
550
555
  scheduleReconcile() {
551
- if (this.reconcileInFlight) {
556
+ const started = this.startExclusiveReconcile();
557
+ if (!started) {
552
558
  this.reloadPending = true;
553
559
  this.logger.info("Config reconciliation already running — coalesced reload request");
554
560
  return;
555
561
  }
556
- this.reloadPending = false;
557
- this.reconcileInFlight = this.reconcileInstances()
558
- .catch(err => {
562
+ started.catch(err => {
559
563
  // Almost always a YAML parse error. Log-only meant the user edited
560
564
  // fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
561
565
  this.logger.error({ err }, "SIGHUP config reload failed");
562
566
  const message = err instanceof Error ? err.message : String(err);
563
567
  this.notifyFleetError(t("fleet.reload_failed", message));
564
- })
568
+ });
569
+ }
570
+ /**
571
+ * Take the reconcile slot, or refuse.
572
+ *
573
+ * Only one reconcile may touch lifecycle and config at a time — two of them
574
+ * stop and start the same instance in parallel. SIGHUP and a Settings apply
575
+ * are the same operation from two entrances, so they share the one slot: the
576
+ * signal coalesces into a pending replay, the apply is told the fleet is busy.
577
+ *
578
+ * The returned promise is the caller's to handle; the stored one is already
579
+ * handled, so a rejection never escapes as an unhandled rejection.
580
+ */
581
+ startExclusiveReconcile(observer) {
582
+ if (this.reconcileInFlight)
583
+ return null;
584
+ this.reloadPending = false;
585
+ let settle;
586
+ const caller = new Promise((resolve, reject) => {
587
+ settle = (err, outcome) => (err ? reject(err instanceof Error ? err : new Error(String(err))) : resolve(outcome ?? {}));
588
+ });
589
+ this.reconcileInFlight = this.reconcileInstances(observer)
590
+ .then(outcome => settle(null, outcome), err => settle(err))
565
591
  .finally(() => {
566
592
  this.reconcileInFlight = null;
567
593
  if (this.reloadPending && this.startupComplete) {
568
594
  this.scheduleReconcile();
569
595
  }
570
596
  });
597
+ return caller;
571
598
  }
572
599
  /**
573
600
  * Is the fleet going down (or coming back up) on purpose?
@@ -583,6 +610,38 @@ export class FleetManager {
583
610
  }
584
611
  finishStartup() {
585
612
  this.startupComplete = true;
613
+ // Resolve whatever a previous run — or a setup host that crashed — left
614
+ // behind. A tunnel nobody is tracking is a public entrance nobody is
615
+ // watching, and the fleet starting is the moment there is finally a process
616
+ // around to notice. Never throws: a lease that cannot be resolved blocks
617
+ // the next tunnel and says so, it does not block the fleet.
618
+ void reapStaleTunnel(this.dataDir)
619
+ .then(outcome => {
620
+ if (outcome.kind === "manual")
621
+ this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
622
+ else if (outcome.kind === "reaped")
623
+ this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
624
+ })
625
+ .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
626
+ // An existing installation has never written the setup marker — it predates
627
+ // it — so `agend setup` would open a pre-fleet form for a fleet that plainly
628
+ // exists. A fleet that just came up on a config with agents in it is proof
629
+ // enough that setup happened.
630
+ if (Object.keys(this.fleetConfig?.instances ?? {}).length > 0 && !isSetupComplete(this.dataDir)) {
631
+ markSetupComplete(this.dataDir);
632
+ }
633
+ // After slimFleetConfigAtStartup() and the general/topic fixups, all of
634
+ // which may rewrite fleet.yaml — the baseline has to be what this process
635
+ // is actually running, compared against what a reconcile would load.
636
+ this.appliedFleetLevel = this.fleetLevelSignature();
637
+ this.startupFleetConfig = this.fleetConfig ? structuredClone(this.fleetConfig) : null;
638
+ this.checkStartupSignatureConsistency();
639
+ // A job from the process that just died cannot still be running here. The
640
+ // restart applied the saved config to every instance, so its open rows are
641
+ // finished — by the restart, which is what the user needs told.
642
+ for (const settled of this.applyJobs.settleAfterRestart()) {
643
+ this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
644
+ }
586
645
  // We are the post-update fleet: the update is over by definition. Clearing
587
646
  // it here (rather than in the update command, which exits before the new
588
647
  // fleet is up) is what keeps the quiet window from outliving the restart.
@@ -1116,31 +1175,43 @@ export class FleetManager {
1116
1175
  */
1117
1176
  getActiveUsageProviderIds() {
1118
1177
  const providers = new Set();
1119
- for (const backend of this.getActiveBackendIds()) {
1178
+ // Per instance, not per backend: two agents on two kiro subscriptions are
1179
+ // two rows, and filtering by the bare backend id would hide both.
1180
+ for (const [name, backend, profile] of this.activeBackendBindings()) {
1181
+ void name;
1120
1182
  const provider = usageProviderIdForBackend(backend);
1121
- if (provider)
1122
- providers.add(provider);
1183
+ if (!provider)
1184
+ continue;
1185
+ providers.add(profile ? `${provider}:${profile}` : provider);
1123
1186
  }
1124
1187
  return providers;
1125
1188
  }
1126
- /** Effective backends with a running or persisted-paused fleet/Classic instance. */
1127
- getActiveBackendIds() {
1128
- const backends = new Set();
1129
- const add = (name, backend) => {
1189
+ /** `[instance, effective backend, credential profile]` for everything that is
1190
+ * running or paused — the one place both usage views agree on who is live. */
1191
+ activeBackendBindings() {
1192
+ const bindings = [];
1193
+ const add = (name, backend, profile) => {
1130
1194
  const status = this.getInstanceStatus(name);
1131
1195
  if (status !== "running" && status !== "paused")
1132
1196
  return;
1133
1197
  if (backend)
1134
- backends.add(backend);
1198
+ bindings.push([name, backend, profile]);
1135
1199
  };
1136
1200
  for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
1137
1201
  // loadFleetConfig() has already merged the fleet default into each row.
1138
- add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
1202
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1203
+ add(name, backend, instanceCredentialProfile(config, this.fleetConfig?.defaults, backend));
1139
1204
  }
1140
1205
  for (const channel of this.classicChannels?.getAll() ?? []) {
1141
- add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
1206
+ const backend = this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend);
1207
+ // Classic channels carry no backend_options, so they run the shared login.
1208
+ add(channel.instanceName, backend, null);
1142
1209
  }
1143
- return backends;
1210
+ return bindings;
1211
+ }
1212
+ /** Effective backends with a running or persisted-paused fleet/Classic instance. */
1213
+ getActiveBackendIds() {
1214
+ return new Set(this.activeBackendBindings().map(([, backend]) => backend));
1144
1215
  }
1145
1216
  isClassicInstance(name) {
1146
1217
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
@@ -1548,7 +1619,7 @@ export class FleetManager {
1548
1619
  if (!wasRunning)
1549
1620
  return;
1550
1621
  const hotOnly = changedFields.length > 0
1551
- && changedFields.every(field => field === "tool_progress" || field === "reply_completion_guard");
1622
+ && changedFields.every(field => CLASSIC_HOT_CONFIG_KEYS.has(field));
1552
1623
  if (hotOnly) {
1553
1624
  this.applyHotConfigUpdate(instanceName, this.classicBehaviorUpdate(instanceName));
1554
1625
  this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
@@ -2174,7 +2245,9 @@ export class FleetManager {
2174
2245
  }
2175
2246
  /** Initialize auth before any adapter can answer /dashboard. */
2176
2247
  initializeWebAuthTokens() {
2177
- this.webToken = loadOrCreateWebToken(this.dataDir);
2248
+ // Creates web.token if absent; the value is then read back per request by
2249
+ // the `webToken` getter, so nothing is cached here.
2250
+ loadOrCreateWebToken(this.dataDir);
2178
2251
  this.viewToken = randomBytes(24).toString("hex");
2179
2252
  const viewTokenPath = join(this.dataDir, "view.token");
2180
2253
  writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
@@ -10586,9 +10659,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10586
10659
  * Whitelisted runtime fields are pushed into live daemons; all other instance
10587
10660
  * fields, plus cold fleet-level settings, retain restart semantics.
10588
10661
  */
10589
- async reconcileInstances() {
10662
+ async reconcileInstances(observe) {
10590
10663
  if (!this.configPath)
10591
- return;
10664
+ return {};
10592
10665
  const oldConfig = this.fleetConfig;
10593
10666
  const previousRawConfig = this.rawFleetConfig;
10594
10667
  const previousRawDocument = this.rawFleetDocument;
@@ -10631,7 +10704,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10631
10704
  ? t("fleet.reload_removed_all", oldCount)
10632
10705
  : t("fleet.reload_removed_half", oldCount, newCount);
10633
10706
  this.notifyFleetError(t("fleet.reload_rejected", why));
10634
- return;
10707
+ // Reported, not swallowed: an apply job whose config was refused used to
10708
+ // mark every row done and tell the user "changes applied".
10709
+ return { rejected: why };
10635
10710
  }
10636
10711
  // Classic behavior settings share the fleet defaults but are not entries
10637
10712
  // in fleet.yaml. Snapshot the old effective chain before reloading the
@@ -10662,29 +10737,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10662
10737
  this.scheduler?.reload();
10663
10738
  const newInstances = this.fleetConfig.instances;
10664
10739
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
10665
- // Detect fleet-level changes which still need a restart. Hot defaults are
10666
- // reconciled below and must not produce a misleading restart warning.
10667
- const oldDefaultColdWithFleetFields = oldConfig?.defaults
10668
- ? splitHotColdConfig(oldConfig.defaults).cold
10669
- : {};
10670
- const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
10671
- ? splitHotColdConfig(this.fleetConfig.defaults).cold
10672
- : {};
10673
- // `tips` is a fleet-owned hot switch; it is read at post time and never
10674
- // belongs in a daemon config or a "restart required" warning.
10675
- const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
10676
- const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
10677
- const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
10678
- const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
10679
- if (oldFleetLevel !== newFleetLevel) {
10680
- this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
10740
+ // Only what a fresh process can adopt, and only relative to the signature
10741
+ // this process came up on: a Settings edit mutates this.fleetConfig in place
10742
+ // before the reload, so comparing the pre-load copy sees nothing at all.
10743
+ const newFleetLevel = this.fleetLevelSignature();
10744
+ if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
10745
+ this.logger.warn({
10746
+ keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
10747
+ }, "Fleet-level config changed — restart AgEnD for it to take effect");
10748
+ // Terminal, and deliberately not "done": this reconcile cannot adopt a
10749
+ // fleet-level change, and saying otherwise would claim AgEnD is running
10750
+ // on a configuration it is not running on.
10751
+ observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
10681
10752
  }
10682
10753
  // Stop removed instances (skip classic bot instances — they're managed by classicBot.yaml)
10683
10754
  const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
10684
10755
  for (const name of this.daemons.keys()) {
10685
10756
  if (!(name in newInstances) && !classicNames.has(name)) {
10686
10757
  this.logger.info({ name }, "Instance removed from config — stopping");
10687
- await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
10758
+ observe?.(name, "restart", "running");
10759
+ await this.stopInstance(name)
10760
+ .then(() => observe?.(name, "restart", "done"))
10761
+ .catch(err => {
10762
+ observe?.(name, "restart", "failed", err.message);
10763
+ this.logger.error({ err, name }, "Failed to stop removed instance");
10764
+ });
10688
10765
  }
10689
10766
  }
10690
10767
  // Start new + reconcile modified instances. Hot values are always sent as a
@@ -10694,20 +10771,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10694
10771
  if (!this.daemons.has(name)) {
10695
10772
  // New instance — startInstance already calls connectIpcToInstance
10696
10773
  this.logger.info({ name }, "New instance in config — starting");
10774
+ observe?.(name, "restart", "running");
10697
10775
  await this.startInstanceUnattended(name, config, topicMode, "new instance");
10776
+ observe?.(name, "restart", "done");
10698
10777
  }
10699
10778
  else if (oldConfig?.instances[name]) {
10700
10779
  const daemon = this.daemons.get(name);
10701
10780
  const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
10702
- const oldParts = splitHotColdConfig(runtimeConfig);
10703
- const newParts = splitHotColdConfig(config);
10704
- // Every field not explicitly classified hot is cold by default.
10705
- if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
10781
+ const change = classifyInstanceChange(runtimeConfig, config);
10782
+ if (change === "restart") {
10706
10783
  this.logger.info({ name }, "Instance config changed — restarting");
10784
+ observe?.(name, "restart", "running");
10707
10785
  await this.stopInstance(name).catch(() => { });
10708
10786
  await this.startInstanceUnattended(name, config, topicMode, "modified instance");
10787
+ observe?.(name, "restart", "done");
10709
10788
  }
10710
- else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
10789
+ else if (change === "hot") {
10790
+ observe?.(name, "hot", "running");
10711
10791
  const update = hotConfigUpdate(config);
10712
10792
  const ipc = this.instanceIpcClients.get(name);
10713
10793
  const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
@@ -10718,6 +10798,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10718
10798
  this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
10719
10799
  }
10720
10800
  this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
10801
+ observe?.(name, "hot", "done");
10721
10802
  }
10722
10803
  }
10723
10804
  }
@@ -10735,25 +10816,362 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10735
10816
  const autoPauseAfter = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
10736
10817
  if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter) {
10737
10818
  this.logger.info({ instanceName: ch.instanceName }, "Classic cold config changed — restarting");
10819
+ observe?.(ch.instanceName, "restart", "running");
10738
10820
  await this.stopInstance(ch.instanceName).catch(() => { });
10739
10821
  await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
10822
+ observe?.(ch.instanceName, "restart", "done");
10740
10823
  continue;
10741
10824
  }
10742
10825
  const toolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
10743
10826
  const replyCompletionGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
10744
10827
  if (old.toolProgress !== toolProgress || old.replyCompletionGuard !== replyCompletionGuard) {
10828
+ observe?.(ch.instanceName, "hot", "running");
10745
10829
  this.applyHotConfigUpdate(ch.instanceName, {
10746
10830
  tool_progress: toolProgress,
10747
10831
  reply_completion_guard: replyCompletionGuard,
10748
10832
  });
10749
10833
  this.logger.info({ instanceName: ch.instanceName }, "Classic inherited hot config reloaded");
10834
+ observe?.(ch.instanceName, "hot", "done");
10750
10835
  }
10751
10836
  }
10752
10837
  }
10753
10838
  // warm_cap is fleet-owned; enforce the reloaded value immediately against
10754
10839
  // currently idle instances instead of waiting for a future state edge.
10755
10840
  this.enforceWarmCap();
10841
+ // appliedFleetLevel is deliberately NOT updated here. It means "the
10842
+ // fleet-level config this process started on"; a reconcile does not restart
10843
+ // the process, so moving it would erase the fact that a restart is still
10844
+ // owed and silence every later reminder.
10756
10845
  this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
10846
+ return {};
10847
+ }
10848
+ /**
10849
+ * `{channel, cold defaults}` as one comparable string — the part of the config
10850
+ * a running fleet process cannot adopt without restarting.
10851
+ */
10852
+ fleetLevelSignature(config = this.fleetConfig) {
10853
+ return fleetLevelSignature(config);
10854
+ }
10855
+ /**
10856
+ * The config a reconcile is about to load, not the one held in memory.
10857
+ *
10858
+ * Settings mutates the in-memory object and writes the file; only the file
10859
+ * goes back through defaults expansion. Forecasting from memory therefore
10860
+ * misses every instance that a changed fleet default will restart — the user
10861
+ * is told "restart AgEnD" and not told that five agents are about to go down.
10862
+ */
10863
+ nextFleetConfig() {
10864
+ if (!this.configPath)
10865
+ return this.fleetConfig;
10866
+ try {
10867
+ return loadFleetConfig(this.configPath);
10868
+ }
10869
+ catch (err) {
10870
+ // An unparseable file is the reconcile's problem to report; the forecast
10871
+ // falls back to what is running rather than failing the request.
10872
+ this.logger.debug({ err }, "Apply plan fell back to the in-memory config");
10873
+ return this.fleetConfig;
10874
+ }
10875
+ }
10876
+ /**
10877
+ * Does the config this process is running match the one a reconcile would
10878
+ * load off disk?
10879
+ *
10880
+ * If not, every apply reports a fleet-level change that a restart cannot
10881
+ * clear — restart, recompute, disagree again — a self-sustaining loop that
10882
+ * the rate limit can only slow to three naggings an hour. Startup rewrites
10883
+ * the file in three places before this point (slimFleetConfigAtStartup, the
10884
+ * general auto-create, the general fixup), so the two can genuinely diverge.
10885
+ *
10886
+ * An offer to restart that cannot possibly succeed is worse than no offer, so
10887
+ * the panel shows the mismatch instead of a button.
10888
+ */
10889
+ checkStartupSignatureConsistency() {
10890
+ if (!this.configPath) {
10891
+ this.fleetSignatureMismatch = null;
10892
+ return;
10893
+ }
10894
+ let onDisk;
10895
+ try {
10896
+ onDisk = loadFleetConfig(this.configPath);
10897
+ }
10898
+ catch (err) {
10899
+ this.logger.warn({ err }, "Could not re-read fleet.yaml to check the startup signature");
10900
+ this.fleetSignatureMismatch = null;
10901
+ return;
10902
+ }
10903
+ if (fleetLevelSignature(onDisk) === this.appliedFleetLevel) {
10904
+ this.fleetSignatureMismatch = null;
10905
+ return;
10906
+ }
10907
+ this.fleetSignatureMismatch = fleetLevelDifferences(this.fleetConfig, onDisk);
10908
+ this.logger.warn({
10909
+ keys: this.fleetSignatureMismatch,
10910
+ configPath: this.configPath,
10911
+ }, "fleet.yaml and the running configuration disagree on startup-only keys — every apply will ask for a restart that cannot clear it");
10912
+ }
10913
+ /**
10914
+ * Is a live adapter already long-polling this bot token?
10915
+ *
10916
+ * Telegram's `getUpdates` has exactly one consumer: a second poller takes
10917
+ * turns with the first and both miss messages. The setup wizard's "post in
10918
+ * the group and I'll detect it" step is a second poller, so it has to know
10919
+ * when the answer is "not against this token, not while I'm running".
10920
+ */
10921
+ isBotTokenInUse(token) {
10922
+ if (!token)
10923
+ return false;
10924
+ const configured = this.fleetConfig?.channels
10925
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
10926
+ for (const channel of configured) {
10927
+ const envVar = channel?.bot_token_env;
10928
+ if (!envVar)
10929
+ continue;
10930
+ // Compare the value, not the variable name: the same token can be
10931
+ // reached through a differently named variable.
10932
+ if (process.env[envVar] === token)
10933
+ return true;
10934
+ }
10935
+ return false;
10936
+ }
10937
+ /** Non-null when startup found the running config and fleet.yaml disagreeing. */
10938
+ fleetSignatureMismatchKeys() {
10939
+ return this.fleetSignatureMismatch;
10940
+ }
10941
+ /**
10942
+ * Restart AgEnD itself on behalf of a Settings apply.
10943
+ *
10944
+ * Deliberately a separate action from Apply: the panel is reachable from
10945
+ * outside the LAN, and "restart the whole fleet" must never be something a
10946
+ * single Apply click can carry along with it.
10947
+ *
10948
+ * The order below is the safety envelope, and the order matters:
10949
+ * concurrency and consistency first (cheap, and a restart during a reconcile
10950
+ * is the dangerous one), then the state checks, then the rate limit, then the
10951
+ * audit notice — and only once all of that holds is the attempt written to
10952
+ * disk and fsynced, before anything spawns.
10953
+ */
10954
+ async requestSettingsSelfRestart(jobId, key) {
10955
+ // A retry after a lost response must not restart a second time. The key is
10956
+ // recorded on the job, which survives the restart it triggers.
10957
+ const already = this.applyJobs.findByRestartKey(key);
10958
+ if (already)
10959
+ return { ok: true, jobId: already.id, reused: true };
10960
+ if (this.reconcileInFlight || this.activeApplyJobId) {
10961
+ return { ok: false, status: 409, error: "a configuration reload is running — try again once it finishes" };
10962
+ }
10963
+ if (this.fleetSignatureMismatch) {
10964
+ // Restarting cannot clear this, so offering it would be a loop.
10965
+ return {
10966
+ ok: false,
10967
+ status: 409,
10968
+ error: `fleet.yaml and the running configuration disagree on ${this.fleetSignatureMismatch.join(", ")} — check fleet.log before restarting`,
10969
+ };
10970
+ }
10971
+ const job = this.applyJobs.get(jobId);
10972
+ const row = job?.targets.find(item => item.target === APPLY_FLEET_TARGET);
10973
+ if (!job || !row || row.status !== "restart-required") {
10974
+ return { ok: false, status: 409, error: "that apply has no pending fleet-level change" };
10975
+ }
10976
+ // The row alone is not enough: an old job keeps its row for the whole
10977
+ // retention window, so a change made and then reverted would still leave a
10978
+ // job that looks restartable. The live signature is the authority — and it
10979
+ // must be the same view of the config the plan uses.
10980
+ if (this.appliedFleetLevel === null || this.appliedFleetLevel === this.fleetLevelSignature(this.nextFleetConfig())) {
10981
+ return { ok: false, status: 409, error: "no fleet-level change is pending any more" };
10982
+ }
10983
+ const allowance = checkSelfRestartAllowance(this.dataDir);
10984
+ if (!allowance.allowed) {
10985
+ if (allowance.reason === "unreadable") {
10986
+ // Fail closed: with the limit's own state in doubt, "no attempts yet"
10987
+ // is the one reading that must not be assumed.
10988
+ return {
10989
+ ok: false,
10990
+ status: 503,
10991
+ 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",
10992
+ };
10993
+ }
10994
+ return {
10995
+ ok: false,
10996
+ status: 429,
10997
+ error: allowance.reason === "too-soon"
10998
+ ? "AgEnD was restarted from Settings very recently"
10999
+ : "too many Settings-triggered restarts in the last hour",
11000
+ retryAfterSeconds: allowance.retryAfterSeconds,
11001
+ };
11002
+ }
11003
+ // Recorded before the notice, not after. If recording keeps failing (a
11004
+ // read-only data dir), posting first would let whoever holds the token spam
11005
+ // the channel with "restarting…" notices for restarts that never happen.
11006
+ // The cost is that a failed announcement still spends an attempt, which is
11007
+ // the right way round for a rate limit.
11008
+ if (!recordSelfRestartAttempt(this.dataDir)) {
11009
+ this.logger.error("Self-restart attempt could not be recorded — refusing to restart unmetered");
11010
+ return { ok: false, status: 503, error: "could not record the restart attempt" };
11011
+ }
11012
+ // Out-of-band notice before the restart, so a panel restart is visible where
11013
+ // the admins are. Refusing when it cannot be posted is the same rule as
11014
+ // refusing when the progress marker cannot be written: no untraceable
11015
+ // restarts.
11016
+ const notice = await this.postSelfRestartNotice();
11017
+ if (!notice) {
11018
+ return {
11019
+ ok: false,
11020
+ status: 409,
11021
+ error: "no chat channel is available to announce the restart — run `agend restart` on the host instead",
11022
+ };
11023
+ }
11024
+ // Consume the row: it moves to running, which is also what lets the next
11025
+ // process settle it (settleAfterRestart only touches non-terminal rows).
11026
+ this.applyJobs.update(jobId, current => {
11027
+ const target = current.targets.find(item => item.target === APPLY_FLEET_TARGET);
11028
+ if (target)
11029
+ target.status = "running";
11030
+ current.restart_key = key;
11031
+ current.deadlineMs = SELF_RESTART_DEADLINE_MS;
11032
+ });
11033
+ this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
11034
+ const launched = await this.requestFullRestart(notice.adapter, notice.chatId, notice.threadId, notice.messageId)
11035
+ .catch(err => {
11036
+ this.logger.error({ err }, "Settings-triggered self restart failed to launch");
11037
+ return false;
11038
+ });
11039
+ if (!launched) {
11040
+ this.applyJobs.setTargetStatus(jobId, APPLY_FLEET_TARGET, "failed", "restart could not be launched");
11041
+ this.applyJobs.finish(jobId, "restart could not be launched");
11042
+ this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
11043
+ return { ok: false, status: 409, error: "the restart could not be launched — see fleet.log" };
11044
+ }
11045
+ return { ok: true, jobId };
11046
+ }
11047
+ /** The audit notice, and the message the restart progress will edit. */
11048
+ async postSelfRestartNotice() {
11049
+ const groupId = this.fleetConfig?.channel?.group_id;
11050
+ const adapter = this.adapter;
11051
+ if (!groupId || !adapter)
11052
+ return null;
11053
+ const generalName = this.findGeneralInstance();
11054
+ const rawThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
11055
+ const threadId = rawThreadId != null ? String(rawThreadId) : undefined;
11056
+ try {
11057
+ const sent = await adapter.sendText(String(groupId), t("restart.settings_triggered"), { threadId });
11058
+ if (!sent?.messageId)
11059
+ return null;
11060
+ return { adapter, chatId: sent.chatId, threadId: sent.threadId, messageId: sent.messageId };
11061
+ }
11062
+ catch (err) {
11063
+ this.logger.error({ err }, "Could not announce the Settings-triggered restart — refusing to restart silently");
11064
+ return null;
11065
+ }
11066
+ }
11067
+ /** Jobs outlive this process on purpose; see apply-job.ts. */
11068
+ get applyJobs() {
11069
+ return (this.applyJobStoreCache ??= new ApplyJobStore(this.dataDir, Date.now, this.logger));
11070
+ }
11071
+ /**
11072
+ * What a reconcile is about to do, per target.
11073
+ *
11074
+ * A forecast, not the record: it is built from the same `classifyInstanceChange`
11075
+ * the reconcile decides with, but the rows that end up in the job are the ones
11076
+ * the reconcile reports as it works. A target the forecast missed (a Classic
11077
+ * instance inheriting a changed default) is added when it is first touched.
11078
+ */
11079
+ planConfigApply() {
11080
+ const rows = [];
11081
+ const nextConfig = this.nextFleetConfig();
11082
+ const next = nextConfig?.instances ?? {};
11083
+ const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
11084
+ for (const [name, config] of Object.entries(next)) {
11085
+ const daemon = this.daemons.get(name);
11086
+ if (!daemon) {
11087
+ rows.push({ target: name, kind: "restart" });
11088
+ continue;
11089
+ }
11090
+ const runtime = daemon.getConfigSnapshot?.();
11091
+ if (!runtime)
11092
+ continue;
11093
+ const change = classifyInstanceChange(runtime, config);
11094
+ if (change !== "none")
11095
+ rows.push({ target: name, kind: change === "restart" ? "restart" : "hot" });
11096
+ }
11097
+ for (const name of this.daemons.keys()) {
11098
+ if (!(name in next) && !classicNames.has(name))
11099
+ rows.push({ target: name, kind: "restart" });
11100
+ }
11101
+ if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
11102
+ rows.push({ target: APPLY_FLEET_TARGET, kind: "restart" });
11103
+ }
11104
+ return rows;
11105
+ }
11106
+ /**
11107
+ * Start (or re-join) a Settings apply.
11108
+ *
11109
+ * The key is the client's. Handing back the existing job for a repeated key is
11110
+ * the whole point: the retry after a lost response must not apply everything a
11111
+ * second time.
11112
+ */
11113
+ startSettingsApply(key) {
11114
+ // The key is checked first on purpose: a retry of the apply that is running
11115
+ // right now must get its own job back, not "busy".
11116
+ const existing = this.applyJobs.findByKey(key);
11117
+ if (existing)
11118
+ return { job: existing, reused: true };
11119
+ if (this.reconcileInFlight || this.activeApplyJobId) {
11120
+ return { busy: this.activeApplyJobId ? this.applyJobs.get(this.activeApplyJobId) : null };
11121
+ }
11122
+ const job = this.applyJobs.create(key, this.planConfigApply());
11123
+ // Reserved synchronously: the work starts a microtask later, and a second
11124
+ // request arriving in that gap must see the slot taken.
11125
+ this.activeApplyJobId = job.id;
11126
+ // Start after the caller has its answer, so the first thing the page renders
11127
+ // is the whole plan with every row still pending — not a job the reconcile
11128
+ // has already half-finished synchronously.
11129
+ queueMicrotask(() => void this.runSettingsApply(job.id));
11130
+ return { job, reused: false };
11131
+ }
11132
+ async runSettingsApply(jobId) {
11133
+ const emit = () => {
11134
+ const job = this.applyJobs.get(jobId);
11135
+ // An accelerator only: these frames carry no event id, so a client that
11136
+ // reconnects cannot ask for what it missed. GET is the authority.
11137
+ if (job)
11138
+ this.emitSseEvent("apply_progress", viewOf(job));
11139
+ };
11140
+ // Passed in rather than parked on `this`: a shared field would let a second
11141
+ // reconcile redirect this job's reporting into another job's rows.
11142
+ const observer = (target, kind, status, error) => {
11143
+ this.applyJobs.update(jobId, job => {
11144
+ let row = job.targets.find(item => item.target === target);
11145
+ if (!row) {
11146
+ row = { target, kind, status: "pending" };
11147
+ job.targets.push(row);
11148
+ }
11149
+ row.kind = kind;
11150
+ row.status = status;
11151
+ if (error)
11152
+ row.error = error;
11153
+ });
11154
+ emit();
11155
+ };
11156
+ emit();
11157
+ try {
11158
+ const started = this.startExclusiveReconcile(observer);
11159
+ if (!started) {
11160
+ // The slot was reserved before the microtask, so this means a SIGHUP
11161
+ // reconcile started in between. Report it instead of applying twice.
11162
+ this.applyJobs.finish(jobId, "a config reload was already running");
11163
+ return;
11164
+ }
11165
+ const outcome = await started;
11166
+ this.applyJobs.finish(jobId, outcome.rejected);
11167
+ }
11168
+ catch (err) {
11169
+ this.applyJobs.finish(jobId, err instanceof Error ? err.message : String(err));
11170
+ }
11171
+ finally {
11172
+ this.activeApplyJobId = null;
11173
+ emit();
11174
+ }
10757
11175
  }
10758
11176
  async restartInstances() {
10759
11177
  if (!this.configPath) {
@@ -11046,6 +11464,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11046
11464
  this.initializeWebAuthTokens();
11047
11465
  this.healthServer = createServer((req, res) => {
11048
11466
  res.setHeader("Content-Type", "application/json");
11467
+ // No Referer to a tunnel host, an upstream proxy, or any page linked from
11468
+ // the panel — the dashboard URL is itself a credential-bearing address.
11469
+ res.setHeader("Referrer-Policy", "no-referrer");
11470
+ // Authorization now depends on a cookie, so a shared cache (a tunnel, a
11471
+ // corporate proxy) must not serve one visitor's response to another.
11472
+ res.setHeader("Vary", "Cookie");
11049
11473
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
11050
11474
  // Browsers request this automatically and AgEnD does not ship an icon.
11051
11475
  // It is neither user data nor an API route, so do not turn the harmless
@@ -11071,15 +11495,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11071
11495
  // like the other /view data routes (usage-api.ts rejects non-GET).
11072
11496
  }
11073
11497
  else {
11074
- // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
11498
+ // All other endpoints require a session cookie or an X-Agend-Token
11499
+ // header; a `?token=` in the URL is only redeemed for a cookie on a GET.
11075
11500
  // /ui/* will also re-check in web-api.ts, which is harmless.
11076
11501
  const parsedUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
11077
- const headerToken = req.headers["x-agend-token"];
11078
- const providedToken = parsedUrl.searchParams.get("token")
11079
- ?? (typeof headerToken === "string" ? headerToken : null);
11080
- if (!this.webToken || providedToken !== this.webToken) {
11081
- res.writeHead(401);
11082
- res.end(JSON.stringify({ error: WEB_TOKEN_INVALID_MESSAGE }));
11502
+ const decision = decideWebGate(req, parsedUrl, this.webToken);
11503
+ if (decision.kind === "reject") {
11504
+ res.writeHead(decision.status);
11505
+ res.end(JSON.stringify({ error: decision.message }));
11506
+ return;
11507
+ }
11508
+ if (decision.kind === "exchange") {
11509
+ res.setHeader("Set-Cookie", decision.setCookie);
11510
+ res.setHeader("Location", decision.location);
11511
+ // A cached redirect would replay a Set-Cookie for a rotated token.
11512
+ res.setHeader("Cache-Control", "no-store");
11513
+ res.writeHead(302);
11514
+ // Browsers follow the Location; a script that does not gets told why
11515
+ // its URL token stopped being echoed back as data.
11516
+ res.end(JSON.stringify({ redirect: decision.location }));
11083
11517
  return;
11084
11518
  }
11085
11519
  }
@@ -11317,8 +11751,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11317
11751
  this.logger.info({ port }, afterTakeover
11318
11752
  ? "Health endpoint listening (after takeover)"
11319
11753
  : "Health endpoint listening");
11320
- this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
11321
- this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
11754
+ // Never the token: fleet.log is readable by anything that can read the
11755
+ // data dir, is copied into bug reports, and is tailed in shared terminals.
11756
+ // `/dashboard` and `agend web` are the ways to get an authorized link.
11757
+ this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
11758
+ this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
11322
11759
  };
11323
11760
  this.healthServer.on("error", (err) => {
11324
11761
  this.healthServerListening = false;
@@ -11335,8 +11772,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11335
11772
  if (existsSync(pidPath)) {
11336
11773
  const oldPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
11337
11774
  if (oldPid && oldPid !== process.pid) {
11338
- process.kill(oldPid, "SIGTERM");
11339
- this.logger.info({ oldPid }, "Killed old fleet process");
11775
+ // fleet.pid is a claim, not proof. A stale or wrong entry points
11776
+ // at whatever now holds that pid, and this used to SIGTERM it —
11777
+ // an unrelated process killed because a port was busy. Confirm
11778
+ // the target really is an AgEnD fleet, and when that cannot be
11779
+ // confirmed, do not signal: not killing costs a dashboard, and
11780
+ // killing costs somebody else's process.
11781
+ const commandLine = readProcessCommandLine(oldPid);
11782
+ if (isFleetStartCommandLine(commandLine)) {
11783
+ process.kill(oldPid, "SIGTERM");
11784
+ this.logger.info({ oldPid }, "Killed old fleet process");
11785
+ }
11786
+ else {
11787
+ this.logger.warn({
11788
+ oldPid,
11789
+ // Truncated: this is an unrelated process's command line, and
11790
+ // fleet.log is copied into bug reports.
11791
+ commandLine: commandLine ? `${commandLine.slice(0, 60)}${commandLine.length > 60 ? "…" : ""}` : "(unreadable)",
11792
+ }, "fleet.pid does not name an AgEnD fleet process — not signalling it");
11793
+ }
11340
11794
  }
11341
11795
  }
11342
11796
  }