@songsid/agend 2.3.1 → 2.3.2-beta.1

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 (159) hide show
  1. package/dist/authority/authority-state.d.ts +4 -0
  2. package/dist/authority/authority-state.js +16 -10
  3. package/dist/authority/authority-state.js.map +1 -1
  4. package/dist/authority/session-authorizations.d.ts +7 -1
  5. package/dist/authority/session-authorizations.js +6 -4
  6. package/dist/authority/session-authorizations.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +19 -6
  8. package/dist/authority/web-authority.js +22 -8
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/channel/adapters/discord.d.ts +2 -0
  11. package/dist/channel/adapters/discord.js +33 -3
  12. package/dist/channel/adapters/discord.js.map +1 -1
  13. package/dist/channel/mcp-tools.js +5 -5
  14. package/dist/channel/mcp-tools.js.map +1 -1
  15. package/dist/channel/types.d.ts +11 -0
  16. package/dist/config-validator.js +40 -3
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/daemon.d.ts +34 -3
  19. package/dist/daemon.js +227 -46
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/delivery-outbox.d.ts +12 -1
  22. package/dist/delivery-outbox.js +67 -15
  23. package/dist/delivery-outbox.js.map +1 -1
  24. package/dist/doctor.d.ts +3 -0
  25. package/dist/doctor.js +9 -0
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/event-log.d.ts +5 -0
  28. package/dist/event-log.js +13 -0
  29. package/dist/event-log.js.map +1 -1
  30. package/dist/fleet-manager.d.ts +140 -0
  31. package/dist/fleet-manager.js +414 -29
  32. package/dist/fleet-manager.js.map +1 -1
  33. package/dist/fleet-process-handlers.d.ts +1 -0
  34. package/dist/fleet-process-handlers.js +9 -1
  35. package/dist/fleet-process-handlers.js.map +1 -1
  36. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
  37. package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
  38. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +106 -55
  39. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +107 -56
  40. package/dist/guest-seen-people.d.ts +38 -0
  41. package/dist/guest-seen-people.js +78 -0
  42. package/dist/guest-seen-people.js.map +1 -0
  43. package/dist/instance-config-impact.js +4 -1
  44. package/dist/instance-config-impact.js.map +1 -1
  45. package/dist/instance-lifecycle.d.ts +1 -0
  46. package/dist/instance-lifecycle.js +16 -1
  47. package/dist/instance-lifecycle.js.map +1 -1
  48. package/dist/locale.js +38 -10
  49. package/dist/locale.js.map +1 -1
  50. package/dist/mac-auto-restore.d.ts +117 -0
  51. package/dist/mac-auto-restore.js +294 -0
  52. package/dist/mac-auto-restore.js.map +1 -0
  53. package/dist/outbound-schemas.d.ts +2 -0
  54. package/dist/outbound-schemas.js +2 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/public-web-gateway.js +9 -1
  57. package/dist/public-web-gateway.js.map +1 -1
  58. package/dist/settings-api.d.ts +26 -0
  59. package/dist/settings-api.js +107 -1
  60. package/dist/settings-api.js.map +1 -1
  61. package/dist/settings-change.js +37 -3
  62. package/dist/settings-change.js.map +1 -1
  63. package/dist/settings-effect.js +5 -2
  64. package/dist/settings-effect.js.map +1 -1
  65. package/dist/start-attempt.d.ts +9 -5
  66. package/dist/start-attempt.js +27 -8
  67. package/dist/start-attempt.js.map +1 -1
  68. package/dist/start-failure-chat.d.ts +4 -0
  69. package/dist/start-failure-chat.js +23 -6
  70. package/dist/start-failure-chat.js.map +1 -1
  71. package/dist/start-failure-notice.d.ts +4 -0
  72. package/dist/start-failure-notice.js +8 -0
  73. package/dist/start-failure-notice.js.map +1 -1
  74. package/dist/start-failure.d.ts +25 -0
  75. package/dist/start-failure.js +75 -2
  76. package/dist/start-failure.js.map +1 -1
  77. package/dist/turn-reply-guard.d.ts +25 -0
  78. package/dist/turn-reply-guard.js +111 -12
  79. package/dist/turn-reply-guard.js.map +1 -1
  80. package/dist/types.d.ts +18 -1
  81. package/dist/ui/backend-signin.js +5 -2
  82. package/dist/ui/chat-ambient.js +1 -1
  83. package/dist/ui/chat-effects.js +4 -4
  84. package/dist/ui/chat-render.js +5 -2
  85. package/dist/ui/chat-strings.js +2 -2
  86. package/dist/ui/chat-thread.js +2 -2
  87. package/dist/ui/first-run.js +2 -2
  88. package/dist/ui/fleet-canvas-view.js +114 -0
  89. package/dist/ui/fleet-canvas.js +15 -77
  90. package/dist/ui/fleet-office-editor.js +80 -5
  91. package/dist/ui/fleet-office-layout.js +197 -0
  92. package/dist/ui/fleet-office-model.js +6 -3
  93. package/dist/ui/fleet-office-pack.d.ts +39 -0
  94. package/dist/ui/fleet-office-pack.js +240 -29
  95. package/dist/ui/fleet-office-skin.js +130 -21
  96. package/dist/ui/fleet-office-store.js +75 -4
  97. package/dist/ui/fleet-office-view.js +154 -0
  98. package/dist/ui/fleet-office-zones.js +95 -33
  99. package/dist/ui/fleet-office.js +530 -94
  100. package/dist/ui/fleet-usage.js +7 -6
  101. package/dist/ui/panel-chat.js +1 -1
  102. package/dist/ui/panel-details.js +2 -2
  103. package/dist/ui/panel-fleet.js +11 -77
  104. package/dist/ui/panel-log.js +2 -2
  105. package/dist/ui/panel-settings.js +578 -163
  106. package/dist/ui/search-dialog.js +26 -7
  107. package/dist/ui/settings-appearance.js +82 -29
  108. package/dist/ui/settings-apply.js +42 -7
  109. package/dist/ui/settings-dialogs.js +68 -22
  110. package/dist/ui/settings-draft.js +51 -0
  111. package/dist/ui/settings-guests.js +244 -78
  112. package/dist/ui/settings-learn.js +2 -2
  113. package/dist/ui/settings-model.js +2 -0
  114. package/dist/ui/settings-registry.js +142 -0
  115. package/dist/ui/settings-strings.js +102 -44
  116. package/dist/ui/settings-web-authority.js +34 -23
  117. package/dist/ui/shared/app-i18n.js +14 -0
  118. package/dist/ui/shared/app-nav.js +10 -5
  119. package/dist/ui/shared/app-prefs.js +80 -2
  120. package/dist/ui/shared/app-route.js +39 -5
  121. package/dist/ui/shared/app-shell.js +59 -4
  122. package/dist/ui/shared/app-stream.js +2 -1
  123. package/dist/ui/shared/app.css +242 -17
  124. package/dist/ui/shared/app.js +3 -0
  125. package/dist/ui/shared/effect-stock-trust.js +1 -1
  126. package/dist/ui/shared/identity-color.js +4 -4
  127. package/dist/ui/shared/signin.js +2 -2
  128. package/dist/ui/shared/ui-dialog.js +6 -1
  129. package/dist/ui/shared/ui-icons.js +2 -0
  130. package/dist/ui/shared/web-authorize.js +13 -12
  131. package/dist/web-api.d.ts +8 -2
  132. package/dist/web-api.js +160 -5
  133. package/dist/web-api.js.map +1 -1
  134. package/dist/web-auth.js +7 -1
  135. package/dist/web-auth.js.map +1 -1
  136. package/dist/web-experimental.d.ts +1 -1
  137. package/dist/web-experimental.js +1 -1
  138. package/dist/web-experimental.js.map +1 -1
  139. package/dist/web-mod-agent.js +2 -2
  140. package/dist/web-mod-agent.js.map +1 -1
  141. package/dist/web-office-skins.d.ts +108 -0
  142. package/dist/web-office-skins.js +298 -0
  143. package/dist/web-office-skins.js.map +1 -0
  144. package/dist/web-office-store.d.ts +8 -2
  145. package/dist/web-office-store.js +37 -7
  146. package/dist/web-office-store.js.map +1 -1
  147. package/dist/web-settings-patch.d.ts +6 -0
  148. package/dist/web-settings-patch.js +61 -4
  149. package/dist/web-settings-patch.js.map +1 -1
  150. package/dist/web-shell-routes.d.ts +4 -2
  151. package/dist/web-shell-routes.js +17 -7
  152. package/dist/web-shell-routes.js.map +1 -1
  153. package/dist/working-recovery.d.ts +104 -0
  154. package/dist/working-recovery.js +263 -0
  155. package/dist/working-recovery.js.map +1 -0
  156. package/dist/working-state-journal.d.ts +3 -1
  157. package/dist/working-state-journal.js +15 -2
  158. package/dist/working-state-journal.js.map +1 -1
  159. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
1
2
  import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
2
3
  import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
3
4
  import { SettingsBaselines } from "./settings-baseline.js";
@@ -158,6 +159,7 @@ import { agendVersion } from "./agent-whats-new.js";
158
159
  import { OwnerCssStore } from "./web-owner-css-store.js";
159
160
  import { ThemeStore } from "./web-theme-store.js";
160
161
  import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
162
+ import { OfficeSkinStore } from "./web-office-skins.js";
161
163
  import { WebAppearance } from "./web-appearance.js";
162
164
  import { WebEffectLibrary } from "./web-effect-library.js";
163
165
  import { WebEffectTrials } from "./web-effect-trials.js";
@@ -605,6 +607,89 @@ export class FleetManager {
605
607
  managerBootId = randomUUID();
606
608
  deliveryOutbox = null;
607
609
  deliveryOutboxRecovered = false;
610
+ workingRecovery = null;
611
+ workingRecoveryTimer = null;
612
+ workingRecoveryCause = "unknown_interruption";
613
+ recoveryEnabled(name) {
614
+ const own = this.fleetConfig?.instances[name]?.resume_interrupted_work;
615
+ return own ?? this.fleetConfig?.defaults?.resume_interrupted_work ?? true;
616
+ }
617
+ initializeWorkingRecovery(outbox) {
618
+ const store = outbox.workingRecovery;
619
+ this.workingRecoveryCause = store.beginRun(this.managerBootId, process.pid, Date.now());
620
+ this.workingRecovery = new WorkingRecoveryCoordinator({
621
+ store, now: () => performance.now(), active: () => this.startupComplete && !this.shuttingDown && outbox.isOpen,
622
+ pressure: () => {
623
+ const p = this.memoryPressure.snapshot();
624
+ return p.sampledAt === null || Date.now() - p.sampledAt > 60_000 ? { ...p, level: "unknown" } : p;
625
+ }, storm: () => this.stormWindow.isActive(),
626
+ enabled: name => this.recoveryEnabled(name),
627
+ live: name => {
628
+ const daemon = this.daemons.get(name);
629
+ if (this.sessionOomStopped(name))
630
+ return { owner: "", configuration: "", sessionId: null, idle: false,
631
+ paused: false, stopped: true, legacyPending: false, legacyCovered: false, nativeWork: false, cancelled: false, background: null };
632
+ return daemon?.getWorkingRecoveryState?.() ?? null;
633
+ },
634
+ card: id => this.scheduler?.db.board.findCard(id) ?? (this.scheduler ? undefined : null),
635
+ laneFree: name => !this.isInstanceRestarting(name) && !this.activeDurableTargets.has(name)
636
+ && outbox.countForTarget(name, ["queued", "retry_wait", "delivering", "submission_started", "reconciliation_pending"]) === 0,
637
+ admit: (episode, tasks, advisory) => outbox.admitWorkingRecovery(episode, buildWorkingRecoveryPrompt(tasks, this.workingRecoveryCause, advisory), advisory),
638
+ summary: (group, rows) => {
639
+ const general = this.findGeneralInstance();
640
+ if (!general || !this.daemons.has(general))
641
+ return;
642
+ // Our own informational notice must not supersede General's recovery
643
+ // as unrelated pending work before it crosses the input boundary.
644
+ const generalEpisode = store.get(general);
645
+ if (generalEpisode?.outcome === "admitted" && generalEpisode.deliveryId) {
646
+ const state = outbox.get(generalEpisode.deliveryId)?.state;
647
+ if (!state || !["delivered", "uncertain", "failed", "cancelled"].includes(state))
648
+ return;
649
+ }
650
+ const deferredAdvisories = rows.filter(row => row.outcome === "deferred"
651
+ && (this.daemons.get(row.instance)?.getWorkingRecoveryState?.().background ?? null) === null)
652
+ .map(row => `${row.instance} (${row.reason})`);
653
+ store.summary(group, () => outbox.admitSystemNotice(general, `working-recovery-summary:${group}`, `[system:working-recovery-summary] Observed cause: unknown interruption${this.workingRecoveryCause === "unknown_interruption" ? "" : `; prior requested action: ${this.workingRecoveryCause}`}. ${rows.length} interrupted instances; continuation queued ${rows.filter(r => r.outcome === "admitted").length}, excluded ${rows.filter(r => r.outcome === "excluded").length}, still deferred ${rows.filter(r => r.outcome === "deferred").length}. ${rows.filter(r => r.reason === "advisory_background_unknown").length ? `Advisory, background unknown: ${rows.filter(r => r.reason === "advisory_background_unknown").map(r => r.instance).join(", ")}. ` : ""}${deferredAdvisories.length ? `Deferred advisory candidates (background unknown; all other gates still apply): ${deferredAdvisories.join(", ")}. ` : ""}Details are in the Log. Deferred work remains eligible; no action or confirmation is requested.`));
654
+ },
655
+ log: (episode, outcome, reason) => {
656
+ const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
657
+ this.logger.info({ name: episode.instance, episode: episode.key, outcome, reason,
658
+ observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery decision");
659
+ this.eventLog?.insert(episode.instance, "working_state", {
660
+ message: `Interrupted work: ${outcome} (${reason}); observed cause unknown_interruption${requestedAction ? `; prior requested action ${requestedAction}` : ""}.`,
661
+ reason, outcome, observedCause: "unknown_interruption", requestedAction
662
+ });
663
+ },
664
+ });
665
+ if (this.workingRecoveryTimer)
666
+ clearInterval(this.workingRecoveryTimer);
667
+ this.workingRecoveryTimer = setInterval(() => {
668
+ if (!outbox.isOpen || this.shuttingDown)
669
+ return;
670
+ try {
671
+ this.workingRecovery?.tick();
672
+ this.scheduleDeliveryOutboxPump();
673
+ }
674
+ catch {
675
+ this.logger.warn("Working recovery deferred: prerequisites or durable store unavailable");
676
+ }
677
+ }, 1_000);
678
+ this.workingRecoveryTimer.unref?.();
679
+ }
680
+ recoveryDeliveryEligible(row, writing = false) {
681
+ if (row.kind !== WORKING_RECOVERY_KIND)
682
+ return true;
683
+ const outbox = this.deliveryOutbox, episode = outbox?.workingRecovery.get(row.targetInstance);
684
+ if (!episode || episode.deliveryId !== row.deliveryId
685
+ || outbox.hasOtherPendingWork(row.targetInstance, row.deliveryId)
686
+ || this.workingRecovery?.verdict(episode).outcome === "excluded") {
687
+ outbox?.withdrawWorkingRecovery(row.deliveryId, "recovery superseded or no longer authorized before input");
688
+ return false;
689
+ }
690
+ return (writing ? this.workingRecovery?.accepts(row.targetInstance, episode.key)
691
+ : this.workingRecovery?.canDispatch(row.targetInstance, episode.key)) === true;
692
+ }
608
693
  /** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
609
694
  wakeCoordinator = null;
610
695
  /**
@@ -1054,7 +1139,12 @@ export class FleetManager {
1054
1139
  }
1055
1140
  officeStoreInstance;
1056
1141
  /** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
1057
- get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
1142
+ get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir, id => this.officeSkins.pack(id))); }
1143
+ officeSkinsInstance;
1144
+ /** #2513: the Office view's uploaded skin packs (<AGEND_HOME>/web-office-skins/). */
1145
+ get officeSkins() { return (this.officeSkinsInstance ??= new OfficeSkinStore(this.dataDir)); }
1146
+ /** How often a person may upload or delete a skin pack; memory only, as the offices' limiter. */
1147
+ officeSkinWrites = new OfficeWriteLimiter(undefined, { perWindow: 10, windowMs: 60_000 });
1058
1148
  /** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
1059
1149
  officeWrites = new OfficeWriteLimiter();
1060
1150
  /** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
@@ -1846,6 +1936,7 @@ export class FleetManager {
1846
1936
  }
1847
1937
  this.deliveryOutbox = outbox;
1848
1938
  this.deliveryOutboxRecovered = true;
1939
+ this.initializeWorkingRecovery(outbox);
1849
1940
  outbox.on("working_references", (names) => {
1850
1941
  for (const name of names)
1851
1942
  this.observeWorkingState(name, "obligations");
@@ -2124,6 +2215,10 @@ export class FleetManager {
2124
2215
  lastActivityAt: this.lastActivity.get(name) ?? null, references, totalReferences, unavailable });
2125
2216
  if (!entry)
2126
2217
  return;
2218
+ if (reason === "cancelled")
2219
+ outbox.workingRecovery.cancel(name, observation.configuration, observation.sessionId);
2220
+ if (entry.prior)
2221
+ outbox.workingRecovery.discover(name, entry.prior);
2127
2222
  const state = entry.current.observation.executionState ?? "unknown";
2128
2223
  const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
2129
2224
  owed: entry.current.references.filter(r => r.kind === "owed").length,
@@ -2217,7 +2312,7 @@ export class FleetManager {
2217
2312
  // Phase 2c: a lane a queue worker owns is never claimed here.
2218
2313
  target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
2219
2314
  ? null
2220
- : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
2315
+ : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets, row => this.recoveryDeliveryEligible(row));
2221
2316
  if (!claimed)
2222
2317
  break;
2223
2318
  this.activeDurableTargets.add(claimed.targetInstance);
@@ -2279,7 +2374,7 @@ export class FleetManager {
2279
2374
  available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
2280
2375
  blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
2281
2376
  daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
2282
- claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
2377
+ claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set(), row => this.recoveryDeliveryEligible(row)),
2283
2378
  tryAcquireBudget: () => {
2284
2379
  if (this.activeDurableTargets.size >= 8)
2285
2380
  return false;
@@ -2320,7 +2415,24 @@ export class FleetManager {
2320
2415
  // including time spent waiting for a handoff/idle gate to return.
2321
2416
  try {
2322
2417
  let sent;
2323
- if (claimed.kind === "raw_paste") {
2418
+ if (claimed.kind === WORKING_RECOVERY_KIND) {
2419
+ const daemon = this.daemons.get(target), key = claimed.sourceKey.replace(/^working-recovery:/, "");
2420
+ if (!daemon || typeof payload.content !== "string")
2421
+ throw new Error("Working recovery owner unavailable");
2422
+ const release = this.workingRecovery?.reserveDispatch(target, key);
2423
+ if (!release) {
2424
+ outbox.retryBeforeBegin(claimed.deliveryId, daemon.bootId, claimed.attemptNo, "working recovery pace or prerequisites pending", 5_000);
2425
+ return;
2426
+ }
2427
+ try {
2428
+ await daemon.deliverWorkingRecovery(payload.content, claimed, () => this.daemons.get(target) === daemon && this.recoveryDeliveryEligible(claimed, true));
2429
+ }
2430
+ finally {
2431
+ release();
2432
+ }
2433
+ sent = true;
2434
+ }
2435
+ else if (claimed.kind === "raw_paste") {
2324
2436
  if (payload.type !== "raw_paste" || typeof payload.content !== "string") {
2325
2437
  throw new Error("Durable raw_paste row has an invalid payload");
2326
2438
  }
@@ -3004,6 +3116,7 @@ export class FleetManager {
3004
3116
  }
3005
3117
  /** SIGUSR1: full process reload. Names who asked (#1120). */
3006
3118
  onFullRestartSignal() {
3119
+ this.recordWorkingRestartIntent("manual_restart");
3007
3120
  this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
3008
3121
  this.gracefulShutdownForReload()
3009
3122
  .then(() => {
@@ -4866,7 +4979,7 @@ export class FleetManager {
4866
4979
  return new WakeCoordinator({
4867
4980
  mode: target => this.deliveryWorkerMode(target),
4868
4981
  available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
4869
- listPending: () => this.deliveryOutbox?.listPending() ?? [],
4982
+ listPending: () => this.deliveryOutbox?.listPending().filter(row => row.kind !== WORKING_RECOVERY_KIND) ?? [],
4870
4983
  isPaused: target => !this.sessionOomStopped(target) && this.lifecycle.isPaused(target),
4871
4984
  pauseReason: target => readPauseReason(this.getInstanceDir(target)),
4872
4985
  isRestarting: target => this.isInstanceRestarting(target),
@@ -6722,7 +6835,8 @@ export class FleetManager {
6722
6835
  const [adapterId, userId] = k.split("\u0000");
6723
6836
  accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
6724
6837
  }
6725
- for (const key of Object.keys(cfg?.narrow ?? {})) {
6838
+ // #2348: every granted account is listed too, signed in or not.
6839
+ for (const key of [...Object.keys(cfg?.narrow ?? {}), ...Object.keys(cfg?.grants ?? {})]) {
6726
6840
  const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
6727
6841
  if (at > 0 && !accounts.has(k))
6728
6842
  accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
@@ -6731,10 +6845,153 @@ export class FleetManager {
6731
6845
  const owner = { adapterId: a.adapterId, userId: a.userId };
6732
6846
  const badge = this.guestBadge(owner);
6733
6847
  // `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
6848
+ const grant = own(cfg?.grants, `${a.adapterId}:${a.userId}`);
6849
+ const sent = this.guestCodeSent.get(`${a.adapterId}:${a.userId}`);
6734
6850
  return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
6735
- devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null };
6851
+ devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null,
6852
+ // #2348: the grant as fleet.yaml has it (null: none), and the last code an admin sent from Settings.
6853
+ grant: grant ? { ...(grant.name ? { name: grant.name } : {}), instances: [...grant.instances] } : null,
6854
+ ...(sent ? { code: { at: sent.at, outcome: sent.outcome.kind, ...(sent.outcome.kind === "sent" ? { expiresAt: sent.at + sent.outcome.expiresInMs } : {}),
6855
+ ...(sent.outcome.kind === "dm_failed" ? { reason: sent.outcome.reason, platform: sent.outcome.platform, ...(sent.outcome.startUrl ? { startUrl: sent.outcome.startUrl } : {}) } : {}) } } : {}) };
6736
6856
  }).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
6737
- return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests };
6857
+ // #2348: `explicit` — grants are the scope (default deny); false: the derived scope of #1920, until grants are saved.
6858
+ return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, explicit: cfg?.grants !== undefined, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests,
6859
+ audit: this.guestAudit() };
6860
+ }
6861
+ /**
6862
+ * #2499 review: the guest audit an admin reads — the newest grant changes and admin-sent codes from the event log
6863
+ * (`fleet` rows: no agent's Log shows them). Through guestOverview only: Settings' admin gate, never over the public
6864
+ * link, never a guest's. The person's name as the card shows it; a session handle stays on the server (`by` → whether
6865
+ * a person saved it here or it was confirmed in General).
6866
+ */
6867
+ guestAudit(limit = FleetManager.GUEST_AUDIT_SHOWN) {
6868
+ if (!this.eventLog)
6869
+ return [];
6870
+ // One read, newest first by row id: a total order (created_at is to the second), so the cap never drops a newer row.
6871
+ let rows = [];
6872
+ try {
6873
+ rows = this.eventLog.latestOfTypes("fleet", ["guest_grant_changed", "guest_code_sent"], limit);
6874
+ }
6875
+ catch {
6876
+ return [];
6877
+ }
6878
+ const names = (list) => (Array.isArray(list) ? list.filter((n) => typeof n === "string") : []);
6879
+ return rows.flatMap((r) => {
6880
+ const p = r.payload ?? {}, account = typeof p.account === "string" ? p.account : "";
6881
+ const at = account.indexOf(":");
6882
+ if (at <= 0)
6883
+ return [];
6884
+ const name = this.webPerson({ adapterId: account.slice(0, at), userId: account.slice(at + 1) }).name;
6885
+ const when = Date.parse(r.created_at.includes("T") ? r.created_at : `${r.created_at.replace(" ", "T")}Z`);
6886
+ return r.event_type === "guest_grant_changed"
6887
+ ? [{ kind: "grant", at: when, account, name, via: p.via === "confirmed" ? "confirmed" : "direct", added: names(p.added), removed: names(p.removed),
6888
+ endedSessions: typeof p.endedSessions === "number" ? p.endedSessions : 0 }]
6889
+ : [{ kind: "code", at: when, account, name, outcome: typeof p.outcome === "string" ? p.outcome : "unknown" }];
6890
+ });
6891
+ }
6892
+ static GUEST_AUDIT_SHOWN = 20;
6893
+ /**
6894
+ * #2348: Settings → Guests' picker — the people AgEnD has seen in chat (seen_people, from human receipts: never a bot,
6895
+ * never the web), without fleet admins (they sign in as admins) or accounts of a connection the fleet no longer has.
6896
+ * Admin only (settings-api.ts), never over the public link.
6897
+ */
6898
+ guestPeople() {
6899
+ const seen = this.deliveryOutbox?.isOpen ? this.deliveryOutbox.seenPeople() : [];
6900
+ const people = seen.filter((p) => this.worlds.has(p.adapterId) && !this.isFleetAdmin(p.userId, p.adapterId)).map((p) => {
6901
+ const owner = { adapterId: p.adapterId, userId: p.userId };
6902
+ return { adapterId: p.adapterId, userId: p.userId, platform: this.worlds.get(p.adapterId)?.adapter.type ?? p.adapterId, username: p.username,
6903
+ name: this.webPerson(owner).name, lastSeen: p.lastSeen, instances: p.instances.map((i) => i.name) };
6904
+ });
6905
+ return { people };
6906
+ }
6907
+ /**
6908
+ * #2348: why this account may not be given a grant, or null. Only someone AgEnD has seen in chat, on a connection the
6909
+ * fleet runs, who is not a fleet admin of it (an admin is never a guest).
6910
+ */
6911
+ guestGrantRefusal(adapterId, userId) {
6912
+ if (!this.worlds.has(adapterId) && !this.fleetConfig?.channels?.some((c) => (c.id ?? c.type) === adapterId))
6913
+ return "no such connection in this fleet";
6914
+ if (this.isFleetAdmin(userId, adapterId))
6915
+ return "a fleet admin signs in as an admin, not as a guest";
6916
+ if (!this.deliveryOutbox?.isOpen || !this.deliveryOutbox.isSeenPerson(adapterId, userId))
6917
+ return "only someone AgEnD has seen in chat can be given guest access";
6918
+ return null;
6919
+ }
6920
+ /**
6921
+ * #2348 delta §5: a grant changed with a save — one event-log entry per person, shown in Settings → Guests' Recent
6922
+ * changes (guestOverview: admin-only, never over the public link or to a guest): who saved it (an admin's
6923
+ * session handle, the header token, or null when replayed for a confirmation), whether it applied at once or was
6924
+ * confirmed in General, the agents added and taken away, and the sessions a removal ended. Never message content.
6925
+ */
6926
+ noteGuestGrantChange(change) {
6927
+ this.logger.info({ account: change.account, via: change.via, added: change.added.length, removed: change.removed.length }, "Guest access changed in Settings");
6928
+ try {
6929
+ this.eventLog?.insert("fleet", "guest_grant_changed", { ...change });
6930
+ }
6931
+ catch { /* the log line stands */ }
6932
+ }
6933
+ /** #2348: an admin's Send sign-in code, per admin (the caller): at most this many in ADMIN_CODE_WINDOW_MS. */
6934
+ static ADMIN_CODE_MAX = 10;
6935
+ static ADMIN_CODE_WINDOW_MS = 10 * 60_000;
6936
+ adminCodeSends = new Map();
6937
+ /** #2348: the last admin-sent code per account, for the Settings card ("Code sent 18:42 · expires 18:52", or why not). */
6938
+ guestCodeSent = new Map();
6939
+ /**
6940
+ * #2348: Settings → Guests' "Send sign-in code" — a guest code to an account an admin granted access, by DM only (no
6941
+ * chat to answer in, nothing posted anywhere). The same issuance as the person's own /web (one code per account, the
6942
+ * code store's caps and breaker, the public link's audience when it is open) and the same once-a-minute limit per
6943
+ * account, plus a per-admin cap. Refused without guest sign-in, for a fleet admin, or with nothing granted. Audited.
6944
+ */
6945
+ async sendGuestCodeByAdmin(owner, by,
6946
+ /** #2365 review: the initiating admin is still signed in and still an admin (settings-api re-checks the session). */
6947
+ current = () => true) {
6948
+ const guests = this.fleetConfig?.web?.guests;
6949
+ if (guests?.enabled !== true)
6950
+ return { status: 409, body: { ok: false, outcome: "guests_off", error: "Guest sign-in is off" } };
6951
+ if (this.isFleetAdmin(owner.userId, owner.adapterId))
6952
+ return { status: 409, body: { ok: false, outcome: "admin", error: "A fleet admin signs in as an admin" } };
6953
+ if (!this.guestScope(owner).size)
6954
+ return { status: 409, body: { ok: false, outcome: "nothing", error: "Nothing is granted to this person yet: save their access first" } };
6955
+ const token = this.webToken;
6956
+ const adapter = this.adapters.get(owner.adapterId);
6957
+ if (this.shuttingDown || !token || !adapter)
6958
+ return { status: 503, body: { ok: false, outcome: "starting", error: "AgEnD is starting or this connection is not running" } };
6959
+ const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
6960
+ const now = performance.now();
6961
+ if (this.guestCodeInFlight.has(key) || (asked !== undefined && now - asked < 60_000)) {
6962
+ return { status: 429, body: { ok: false, outcome: "wait", retryAfterMs: asked !== undefined ? Math.max(0, 60_000 - (now - asked)) : 60_000 } };
6963
+ }
6964
+ const mine = (this.adminCodeSends.get(by) ?? []).filter((at) => now - at < FleetManager.ADMIN_CODE_WINDOW_MS);
6965
+ if (mine.length >= FleetManager.ADMIN_CODE_MAX)
6966
+ return { status: 429, body: { ok: false, outcome: "admin_limit", retryAfterMs: FleetManager.ADMIN_CODE_WINDOW_MS - (now - mine[0]) } };
6967
+ this.adminCodeSends.set(by, [...mine, now]);
6968
+ while (this.adminCodeSends.size > 256)
6969
+ this.adminCodeSends.delete(this.adminCodeSends.keys().next().value);
6970
+ let outcome = { kind: "busy" };
6971
+ this.guestCodeInFlight.add(key);
6972
+ try {
6973
+ await this.issueGuestCode({ replyHere: async () => undefined }, owner, key, adapter, token, (o) => { outcome = o; }, current);
6974
+ }
6975
+ finally {
6976
+ this.guestCodeInFlight.delete(key);
6977
+ }
6978
+ const done = outcome;
6979
+ // #2365 review: the admin's session ended (or stopped being an admin's) while the code was on its way: nothing was
6980
+ // sent, or what was made is withdrawn — the answer says so, as to anyone no longer signed in.
6981
+ if (done.kind === "revoked" && !current())
6982
+ return { status: 401, body: { ok: false, outcome: "revoked", error: "no longer authorized" } };
6983
+ this.guestCodeSent.set(`${owner.adapterId}:${owner.userId}`, { at: Date.now(), outcome: done });
6984
+ while (this.guestCodeSent.size > 512)
6985
+ this.guestCodeSent.delete(this.guestCodeSent.keys().next().value);
6986
+ // Audited: who asked, for whom, what happened (never the code).
6987
+ this.logger.info({ by, adapterId: owner.adapterId, outcome: done.kind }, "Guest sign-in code sent by an admin from Settings");
6988
+ try {
6989
+ this.eventLog?.insert("fleet", "guest_code_sent", { by, account: `${owner.adapterId}:${owner.userId}`, outcome: done.kind, instances: this.guestScope(owner).size });
6990
+ }
6991
+ catch { /* the log line stands */ }
6992
+ const status = done.kind === "sent" ? 200 : done.kind === "dm_failed" ? 502 : 409;
6993
+ return { status, body: { ok: done.kind === "sent", outcome: done.kind, ...(done.kind === "sent" ? { expiresAt: Date.now() + done.expiresInMs, publicLink: done.publicLink } : {}),
6994
+ ...(done.kind === "dm_failed" ? { platform: done.platform, reason: done.reason, ...(done.startUrl ? { startUrl: done.startUrl } : {}) } : {}) } };
6738
6995
  }
6739
6996
  /** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
6740
6997
  guestBadge(owner) {
@@ -6803,24 +7060,35 @@ export class FleetManager {
6803
7060
  return true;
6804
7061
  }
6805
7062
  /** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
6806
- async issueGuestCode(ask, owner, key, adapter, token) {
7063
+ async issueGuestCode(ask, owner, key, adapter, token,
7064
+ /** #2348: how it ended, for a caller with no chat to answer in (Settings → Guests' Send sign-in code). */
7065
+ report = () => { },
7066
+ /**
7067
+ * #2365 review: the one who asked may still ask — for an admin's Send sign-in code, that admin's session as it is
7068
+ * now. Asked with the revocation checks, after every wait: false ends the issuance, and withdraws its code if one was made.
7069
+ */
7070
+ stillAllowed = () => true) {
6807
7071
  const say = (text) => ask.replyHere(text).catch(() => undefined);
6808
7072
  const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6809
- const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
7073
+ const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && stillAllowed();
6810
7074
  // §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
6811
7075
  await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
6812
- if (!unrevoked())
6813
- return; // revoked while its rooms were read: no code
7076
+ if (!unrevoked()) {
7077
+ report({ kind: "revoked" });
7078
+ return;
7079
+ } // revoked while its rooms were read: no code
6814
7080
  const scope = this.guestScope(owner);
6815
7081
  if (!scope.size) {
6816
7082
  const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
6817
7083
  await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
7084
+ report({ kind: "nothing" });
6818
7085
  return;
6819
7086
  }
6820
7087
  this.initializeWebSessions();
6821
7088
  const codes = this.webGuestLoginCodes;
6822
7089
  if (codes.pausedForMs() > 0) {
6823
7090
  await say(t("web.guest_paused"));
7091
+ report({ kind: "paused" });
6824
7092
  return;
6825
7093
  }
6826
7094
  // §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
@@ -6831,6 +7099,7 @@ export class FleetManager {
6831
7099
  recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
6832
7100
  if (!login) {
6833
7101
  await say(t("web.guest_busy"));
7102
+ report({ kind: "busy" });
6834
7103
  return;
6835
7104
  }
6836
7105
  this.guestCodeAskedAt.delete(key);
@@ -6865,13 +7134,17 @@ export class FleetManager {
6865
7134
  throw new Error("guest no longer admitted");
6866
7135
  await say(t("web.guest_sent"));
6867
7136
  }
7137
+ report({ kind: "sent", expiresInMs: LOGIN_CODE_TTL_MS, publicLink: !!open });
6868
7138
  }
6869
7139
  catch (err) {
6870
7140
  codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
6871
7141
  this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
6872
- if (!unrevoked())
6873
- return; // revoked meanwhile: the revoke's own answer said so
7142
+ if (!unrevoked()) {
7143
+ report({ kind: "revoked" });
7144
+ return;
7145
+ } // revoked meanwhile: the revoke's own answer said so
6874
7146
  const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
7147
+ report({ kind: "dm_failed", platform: adapter.type, reason: String(err?.message ?? err).slice(0, 200), ...(start ? { startUrl: start } : {}) });
6875
7148
  // #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
6876
7149
  // the DM — once, within the window.
6877
7150
  if (adapter.type === "telegram")
@@ -7418,6 +7691,7 @@ export class FleetManager {
7418
7691
  const startupStartedAt = Date.now();
7419
7692
  FleetManager.signalTarget = this;
7420
7693
  this.startupComplete = false;
7694
+ this.workingRecovery?.reset();
7421
7695
  this.startupDashboard = null;
7422
7696
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
7423
7697
  // anything arriving during it is still part of the stop.
@@ -7996,12 +8270,17 @@ export class FleetManager {
7996
8270
  // health server — so `systemctl start` returned success while the fleet was
7997
8271
  // still deaf: no path existed for a user message to arrive.
7998
8272
  sdNotify("READY=1");
8273
+ this.workingRecovery?.ready();
7999
8274
  // #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
8000
8275
  try {
8001
8276
  const { ownPackage, recordStarted } = await import("./start-failure.js");
8002
8277
  const version = ownPackage().version;
8003
8278
  if (version)
8004
8279
  recordStarted(this.dataDir, version, process.pid, Date.now());
8280
+ // #2481: a start completed — the failed tree an automatic restore kept for a report can go.
8281
+ const prefix = ownPackage().prefix;
8282
+ if (prefix)
8283
+ void (await import("./mac-auto-restore.js")).pruneFailedTrees(prefix).catch(() => { });
8005
8284
  }
8006
8285
  catch (err) {
8007
8286
  this.logger.debug({ err }, "fleet-started record not written");
@@ -10666,6 +10945,7 @@ export class FleetManager {
10666
10945
  // overwritten with coordinates the target's adapter cannot address.
10667
10946
  ...(foreignWorld ? {} : { chat_id: reply_chat_id, thread_id: reply_thread_id }),
10668
10947
  user: "scheduler",
10948
+ source: "schedule",
10669
10949
  ...(adapterId ? { adapter_id: adapterId } : {}),
10670
10950
  },
10671
10951
  }, { waitForIdle: true, ...(retryCurrent ? { stillCurrent: retryCurrent } : {}) });
@@ -14760,7 +15040,7 @@ export class FleetManager {
14760
15040
  ...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
14761
15041
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14762
15042
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
14763
- policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
15043
+ policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
14764
15044
  });
14765
15045
  }
14766
15046
  sessionAuthorizations = null;
@@ -14785,13 +15065,13 @@ export class FleetManager {
14785
15065
  session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14786
15066
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14787
15067
  authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
14788
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
15068
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
14789
15069
  });
14790
15070
  if (check.refusal)
14791
15071
  return { ok: false, refusal: check.refusal };
14792
15072
  const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
14793
15073
  store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
14794
- return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
15074
+ return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs, peer) };
14795
15075
  }
14796
15076
  authorityStateStore = null;
14797
15077
  /**
@@ -14800,13 +15080,24 @@ export class FleetManager {
14800
15080
  */
14801
15081
  webAuthorityState() {
14802
15082
  if (!this.dataDir)
14803
- return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
15083
+ return { enabled: false, loopbackOnly: true, namedLink: false, quickLink: false, epoch: 0 };
14804
15084
  this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
14805
15085
  const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
14806
- // #2453 (§0.2): the named-link option seen going off — the authority of every session over the link ends now, for
14807
- // good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
14808
- if (state.namedLinkClosed)
14809
- this.sessionAuthorizationStore()?.prune(a => this.webSessions?.findByHandle(a.handle)?.surface === "local");
15086
+ // #2453 (§0.2) / §0.3: a link's option seen going off — the authority of every session over that link ends now, for
15087
+ // good: their authorizations go (a later "on" can never bring one back without its account's Confirm). A record from
15088
+ // before §0.3 has no peer: over the gateway, only a named-link session could have been authorized then.
15089
+ if (state.namedLinkClosed || state.quickLinkClosed) {
15090
+ const ended = (a) => {
15091
+ const session = this.webSessions?.findByHandle(a.handle);
15092
+ if (!session)
15093
+ return true;
15094
+ if (session.surface === "local")
15095
+ return false;
15096
+ const peer = a.peer ?? "named_link";
15097
+ return (!!state.namedLinkClosed && peer === "named_link") || (!!state.quickLinkClosed && peer === "quick_link");
15098
+ };
15099
+ this.sessionAuthorizationStore()?.prune(a => !ended(a));
15100
+ }
14810
15101
  return state;
14811
15102
  }
14812
15103
  /**
@@ -14839,7 +15130,8 @@ export class FleetManager {
14839
15130
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
14840
15131
  .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
14841
15132
  until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
14842
- ...(sessions.get(a.handle).surface === "gateway" ? { named: true } : {}) })) : [];
15133
+ // Over a link: the temporary one (§0.3), else the named one (a record from before §0.3 has no peer).
15134
+ ...(sessions.get(a.handle).surface === "gateway" ? (a.peer === "quick_link" ? { quick: true } : { named: true }) : {}) })) : [];
14843
15135
  const host = this.fleetConfig?.web?.public_link?.hostname;
14844
15136
  return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
14845
15137
  }
@@ -14854,9 +15146,17 @@ export class FleetManager {
14854
15146
  const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
14855
15147
  // The host's clock, with its zone: the person may read the DM on a phone in another one.
14856
15148
  const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
15149
+ const quickHost = () => { try {
15150
+ const u = this.publicWebLink?.openSignin()?.url;
15151
+ return u ? new URL(u).host : "?";
15152
+ }
15153
+ catch {
15154
+ return "?";
15155
+ } };
14857
15156
  const where = (peer) => peer === "named_link"
14858
15157
  ? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
14859
- : t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
15158
+ : peer === "quick_link" ? t("web_auth.peer_quick_link", quickHost()) // §0.3: the internet
15159
+ : t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
14860
15160
  return this.authorizationRequests = new SessionAuthorizationRequests({
14861
15161
  now: () => performance.now(), wallNow: () => Date.now(),
14862
15162
  maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
@@ -14890,8 +15190,8 @@ export class FleetManager {
14890
15190
  const record = this.webSessions?.findByHandle(handle);
14891
15191
  if (!record)
14892
15192
  return { ok: false, refusal: "session_gone" };
14893
- // §0.2: a named-link session is judged as the gateway request it is (its exposure still the current one).
14894
- const r = this.authorizeWebSession(record, peer, peer === "named_link");
15193
+ // §0.2 / §0.3: a session over a link is judged as the gateway request it is (its exposure still the current one).
15194
+ const r = this.authorizeWebSession(record, peer, peer === "named_link" || peer === "quick_link");
14895
15195
  // What the card says: the end of the authorization, or the session's own end when that comes first.
14896
15196
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
14897
15197
  },
@@ -14937,7 +15237,7 @@ export class FleetManager {
14937
15237
  session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14938
15238
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14939
15239
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
14940
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
15240
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
14941
15241
  });
14942
15242
  return r.refusal;
14943
15243
  }
@@ -17764,7 +18064,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17764
18064
  const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
17765
18065
  const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
17766
18066
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
17767
- const isMentioned = mentionTag && text.includes(mentionTag);
18067
+ // #2551: a native reply to one of THIS bot's messages is addressed to it, as a mention is (a Discord reply carries
18068
+ // its ping in the reference, not the text). One hop only: a reply to a message of ours that itself answered a bot
18069
+ // (or the sender) is a chain of bots answering each other — a mention still carries it on. A reply to our answer
18070
+ // to a person is the ordinary handoff, and is addressed.
18071
+ const repliesToUs = !!adapterBotUserId && msg.replyToAuthorId === adapterBotUserId && msg.replyToChain !== true;
18072
+ const isMentioned = (mentionTag && text.includes(mentionTag)) || repliesToUs;
17768
18073
  if (!isMentioned) {
17769
18074
  // Bare attachment (no @mention) — already saved above; just acknowledge.
17770
18075
  if (saved) {
@@ -20033,6 +20338,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20033
20338
  }
20034
20339
  tmuxExecHelperTimer = null;
20035
20340
  stopAllInFlight = null;
20341
+ /** Signal-handler continuation, after successful graceful shutdown only. */
20342
+ noteRequestedShutdown() {
20343
+ const progress = readUpdateProgress(this.dataDir)?.progress;
20344
+ const update = progress && updateProgressOperation(progress) === "update"
20345
+ && ["installed", "stopping", "starting"].includes(progress.stage);
20346
+ this.recordWorkingRestartIntent(update ? "update" : "manual_restart");
20347
+ }
20348
+ recordWorkingRestartIntent(cause) {
20349
+ this.workingRecoveryCause = cause;
20350
+ try {
20351
+ this.deliveryOutbox?.workingRecovery.requested(this.managerBootId, cause);
20352
+ }
20353
+ catch {
20354
+ this.logger.warn("Could not record requested restart; next recovery will report an unknown interruption");
20355
+ }
20356
+ }
20036
20357
  /** Stops release memory. Reassess between batches without waiting for recovery. */
20037
20358
  async stopInstancesInBatches(entries, stop) {
20038
20359
  let index = 0;
@@ -20050,15 +20371,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20050
20371
  }
20051
20372
  }
20052
20373
  async doStopAll() {
20374
+ // The scheduler closes before individual daemons stop. Preserve the owned
20375
+ // task/reply boundary while those stores are still available, not a later
20376
+ // shutdown snapshot whose unavailable sources cannot authorize recovery.
20377
+ for (const name of this.daemons.keys())
20378
+ this.observeWorkingState(name, "before_stop");
20053
20379
  this.startupMemoryDeadline?.stop();
20054
20380
  this.startupMemoryDeadline = null;
20055
20381
  this.startupComplete = false;
20382
+ this.workingRecovery?.reset();
20056
20383
  this.startupDashboard = null;
20057
20384
  this.reloadPending = false;
20058
20385
  // Before anything is stopped: everything that dies from here on dies
20059
20386
  // because we asked it to. Set synchronously — doStopAll runs to its first
20060
20387
  // await in the same tick as the signal handler, so no event can slip in.
20061
20388
  this.shuttingDown = true;
20389
+ this.workingRecovery?.reset();
20390
+ if (this.workingRecoveryTimer)
20391
+ clearInterval(this.workingRecoveryTimer);
20392
+ this.workingRecoveryTimer = null;
20062
20393
  if (this.updateProgressTimer) {
20063
20394
  clearInterval(this.updateProgressTimer);
20064
20395
  this.updateProgressTimer = null;
@@ -20738,6 +21069,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20738
21069
  fleetSignatureMismatchKeys() {
20739
21070
  return this.fleetSignatureMismatch;
20740
21071
  }
21072
+ /**
21073
+ * #2347 restart flow: saved changes that take effect only after AgEnD restarts — the startup-only keys fleet.yaml
21074
+ * now differs on, and the newest apply job whose fleet row can carry the restart (`job_id`, null when none can: a
21075
+ * hand edit, or a job past its retention). Null when nothing is pending, and when a restart could not clear it
21076
+ * (the signature mismatch the panel reports instead). The same test requestSettingsSelfRestart makes.
21077
+ */
21078
+ settingsRestartPending() {
21079
+ if (this.appliedFleetLevel === null || this.fleetSignatureMismatch)
21080
+ return null;
21081
+ const next = this.nextFleetConfig();
21082
+ if (this.appliedFleetLevel === this.fleetLevelSignature(next))
21083
+ return null;
21084
+ let job = null;
21085
+ // A job whose restart was asked for already has its row in flight ("running"), so it is never offered again.
21086
+ for (const candidate of this.applyJobs.all()) {
21087
+ const row = candidate.targets.find(item => item.target === APPLY_FLEET_TARGET);
21088
+ if (row?.status === "restart-required" && (!job || candidate.startedAt >= job.startedAt))
21089
+ job = candidate;
21090
+ }
21091
+ return { keys: fleetLevelDifferences(this.startupFleetConfig, next), job_id: job ? job.id : null };
21092
+ }
20741
21093
  /**
20742
21094
  * Restart AgEnD itself on behalf of a Settings apply.
20743
21095
  *
@@ -21988,6 +22340,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21988
22340
  this.logger.info("No instances to restart");
21989
22341
  return;
21990
22342
  }
22343
+ this.recordWorkingRestartIntent("manual_restart");
22344
+ this.workingRecovery?.reset();
21991
22345
  this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
21992
22346
  const groupId = this.fleetConfig?.channel?.group_id;
21993
22347
  const generalName = this.findGeneralInstance();
@@ -22088,7 +22442,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22088
22442
  this.startStatuslineWatcher(name);
22089
22443
  }
22090
22444
  }
22445
+ this.workingRecovery?.ready();
22091
22446
  this.logger.info("Graceful restart complete");
22447
+ // The in-process interval keeps its label, but a later unrelated process
22448
+ // crash must not inherit this completed request as its shutdown intent.
22449
+ try {
22450
+ this.deliveryOutbox?.workingRecovery.clearRequested(this.managerBootId);
22451
+ }
22452
+ catch {
22453
+ this.logger.warn("Could not clear completed in-process restart intent");
22454
+ }
22092
22455
  const configuredNames = this.configuredStartupInstanceNames(fleet, topicMode);
22093
22456
  const total = configuredNames.length;
22094
22457
  const started = configuredNames.filter(name => this.daemons.has(name)).length;
@@ -22143,6 +22506,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22143
22506
  }
22144
22507
  const ipc = this.instanceIpcClients.get(name);
22145
22508
  if (ipc?.connected) {
22509
+ daemon?.noteLegacyRestartReminder?.();
22146
22510
  ipc.send({
22147
22511
  type: "fleet_inbound",
22148
22512
  content: msg,
@@ -22290,6 +22654,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22290
22654
  const guests = this.fleetConfig?.web?.guests;
22291
22655
  if (!owner || guests?.enabled !== true)
22292
22656
  return NO_GUEST_SCOPE;
22657
+ // #2348: explicit grants, once the key exists, are the whole scope — no narrowing on top (a grant is already exact).
22658
+ if (guests.grants !== undefined) {
22659
+ const granted = this.guestReach(owner);
22660
+ return granted.size ? granted : NO_GUEST_SCOPE;
22661
+ }
22293
22662
  const scope = new Set(this.guestReach(owner));
22294
22663
  const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
22295
22664
  if (narrow) {
@@ -22310,6 +22679,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22310
22679
  const scope = new Set();
22311
22680
  if (guests?.enabled !== true)
22312
22681
  return scope;
22682
+ // #2348: with explicit grants, what an admin granted this account — of the instances that exist now (fleet.yaml's, or
22683
+ // a ClassicBot room's) — and nothing else: no connection access, no room membership, no open_instances.
22684
+ if (guests.grants !== undefined) {
22685
+ const key = `${owner.adapterId}:${owner.userId}`;
22686
+ const grant = Object.hasOwn(guests.grants, key) ? guests.grants[key] : undefined;
22687
+ if (!grant || !Array.isArray(grant.instances))
22688
+ return scope;
22689
+ const rooms = new Set((this.classicChannels?.getAll() ?? []).map((r) => r.instanceName));
22690
+ for (const name of grant.instances) {
22691
+ if ((this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, name)) || rooms.has(name))
22692
+ scope.add(name);
22693
+ }
22694
+ return scope;
22695
+ }
22313
22696
  const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
22314
22697
  const world = this.worlds.get(owner.adapterId);
22315
22698
  if (world && world.accessManager.isAllowed(owner.userId)) {
@@ -22351,7 +22734,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22351
22734
  const owner = record.owner;
22352
22735
  if (this.guestScope(owner).size)
22353
22736
  return "ok";
22354
- if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
22737
+ // #2348: with explicit grants no room's membership can give anything: nothing granted is gone, not pending. Nor does
22738
+ // a connection's start matter: grants are config (and ClassicBot rooms), read without the adapter.
22739
+ if (owner && this.fleetConfig?.web?.guests?.enabled === true && this.fleetConfig.web.guests.grants === undefined) {
22355
22740
  // Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
22356
22741
  // request until its configured world exists. Removed connections and initialized denied access stay final.
22357
22742
  const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);