@songsid/agend 2.3.2-beta.5 → 2.3.2-beta.6

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 (60) hide show
  1. package/dist/channel/adapters/discord.d.ts +11 -0
  2. package/dist/channel/adapters/discord.js +50 -8
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/config-validator.js +25 -3
  5. package/dist/config-validator.js.map +1 -1
  6. package/dist/fleet-manager.d.ts +21 -5
  7. package/dist/fleet-manager.js +154 -133
  8. package/dist/fleet-manager.js.map +1 -1
  9. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +14 -1
  10. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +14 -1
  11. package/dist/locale.js +8 -0
  12. package/dist/locale.js.map +1 -1
  13. package/dist/notice-delivery.d.ts +7 -6
  14. package/dist/notice-delivery.js +16 -18
  15. package/dist/notice-delivery.js.map +1 -1
  16. package/dist/office-share-page.d.ts +13 -0
  17. package/dist/office-share-page.js +82 -0
  18. package/dist/office-share-page.js.map +1 -0
  19. package/dist/office-share-service.d.ts +89 -0
  20. package/dist/office-share-service.js +172 -0
  21. package/dist/office-share-service.js.map +1 -0
  22. package/dist/office-share.d.ts +228 -0
  23. package/dist/office-share.js +489 -0
  24. package/dist/office-share.js.map +1 -0
  25. package/dist/public-web-gateway.js +6 -0
  26. package/dist/public-web-gateway.js.map +1 -1
  27. package/dist/quickstart.js +0 -2
  28. package/dist/quickstart.js.map +1 -1
  29. package/dist/settings-api.js +1 -1
  30. package/dist/settings-api.js.map +1 -1
  31. package/dist/setup-host.js +1 -2
  32. package/dist/setup-host.js.map +1 -1
  33. package/dist/setup-wizard.js +0 -2
  34. package/dist/setup-wizard.js.map +1 -1
  35. package/dist/types.d.ts +12 -3
  36. package/dist/ui/fleet-office-pack.js +7 -2
  37. package/dist/ui/fleet-office-skin.js +5 -4
  38. package/dist/ui/fleet-office-zones.js +17 -6
  39. package/dist/ui/fleet-office.js +20 -10
  40. package/dist/ui/office-share-dialog.js +145 -0
  41. package/dist/ui/office-share-page.js +116 -0
  42. package/dist/ui/panel-settings.js +12 -8
  43. package/dist/ui/settings-registry.js +2 -2
  44. package/dist/ui/settings-strings.js +6 -6
  45. package/dist/ui/shared/app.css +28 -0
  46. package/dist/web-api.d.ts +14 -0
  47. package/dist/web-api.js +91 -0
  48. package/dist/web-api.js.map +1 -1
  49. package/dist/web-office-skins.d.ts +2 -1
  50. package/dist/web-office-skins.js +2 -1
  51. package/dist/web-office-skins.js.map +1 -1
  52. package/dist/web-settings-patch.js +1 -1
  53. package/dist/web-settings-patch.js.map +1 -1
  54. package/dist/working-recovery.d.ts +2 -2
  55. package/dist/working-recovery.js +43 -8
  56. package/dist/working-recovery.js.map +1 -1
  57. package/dist/working-state-journal.d.ts +2 -0
  58. package/dist/working-state-journal.js +16 -1
  59. package/dist/working-state-journal.js.map +1 -1
  60. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
2
- import { loginNoticeMode, tipNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
2
+ import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
3
3
  import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
4
4
  import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
5
5
  import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
@@ -22,6 +22,9 @@ import { PublicLinkAutoOpen } from "./public-link-auto-open.js";
22
22
  import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from "./public-link-owner.js";
23
23
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
24
24
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
25
+ import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
26
+ import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
27
+ import { ALL_OFFICES } from "./office-share.js";
25
28
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
26
29
  import { ManagedTunnel } from "./tunnel/manager.js";
27
30
  import { withinBudget } from "./monotonic-budget.js";
@@ -627,6 +630,7 @@ export class FleetManager {
627
630
  workingRecovery = null;
628
631
  workingRecoveryTimer = null;
629
632
  workingRecoveryCause = "unknown_interruption";
633
+ workingRecoveryGroup = this.managerBootId;
630
634
  recoveryEnabled(name) {
631
635
  const own = this.fleetConfig?.instances[name]?.resume_interrupted_work;
632
636
  return own ?? this.fleetConfig?.defaults?.resume_interrupted_work ?? true;
@@ -667,7 +671,16 @@ export class FleetManager {
667
671
  const deferredAdvisories = rows.filter(row => row.outcome === "deferred"
668
672
  && (this.daemons.get(row.instance)?.getWorkingRecoveryState?.().background ?? null) === null)
669
673
  .map(row => `${row.instance} (${row.reason})`);
670
- 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.`));
674
+ const posted = 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.`));
675
+ if (posted) {
676
+ const counts = { interrupted: rows.length, queued: rows.filter(row => row.outcome === "admitted").length,
677
+ excluded: rows.filter(row => row.outcome === "excluded").length, deferred: rows.filter(row => row.outcome === "deferred").length };
678
+ const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
679
+ this.logger.info({ group, ...counts, observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery summary queued for General");
680
+ this.eventLog?.insert(general, "working_state", { reason: "recovery_summary", group, ...counts,
681
+ observedCause: "unknown_interruption", requestedAction,
682
+ message: `Interrupted work summary queued for General: ${counts.interrupted} interrupted; ${counts.queued} queued, ${counts.excluded} excluded, ${counts.deferred} deferred.` });
683
+ }
671
684
  },
672
685
  log: (episode, outcome, reason) => {
673
686
  const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
@@ -1166,6 +1179,51 @@ export class FleetManager {
1166
1179
  officeSkinWrites = new OfficeWriteLimiter(undefined, { perWindow: 10, windowMs: 60_000 });
1167
1180
  /** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
1168
1181
  officeWrites = new OfficeWriteLimiter();
1182
+ officeSharesInstance;
1183
+ /**
1184
+ * #2636: Office share links — the live Office view for people outside the fleet, read-only, on a quick tunnel of
1185
+ * their own (a manager and lease of its own: never the fleet's one tunnel lane nor the public link's preview tunnel).
1186
+ */
1187
+ get officeShares() {
1188
+ return (this.officeSharesInstance ??= new OfficeShareService({
1189
+ source: {
1190
+ status: () => this.getUiStatus(),
1191
+ org: () => this.orgChart(),
1192
+ offices: () => this.officeStore.list(),
1193
+ acts: () => this.officeActs.live(),
1194
+ traffic: () => this.trafficSnapshot(),
1195
+ needs: () => this.needsYouItems().map(i => i.instance),
1196
+ hidden: name => crossInstanceVisibility(this.fleetConfig, name) === "hidden",
1197
+ skins: this.officeSkins,
1198
+ },
1199
+ manager: new ManagedTunnel({ dataDir: join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR), log: () => this.logger.warn("Office share tunnel cleanup requires attention") }),
1200
+ ensure: signal => ensureCloudflared({ dataDir: this.dataDir, pinnedOnly: true, signal }),
1201
+ provider: binary => new CloudflaredProvider({ binaryName: binary, protocol: this.fleetConfig?.web?.public_link?.protocol ?? "http2" }),
1202
+ config: () => this.fleetConfig?.web?.office_share,
1203
+ officeExists: id => this.officeStore.list().offices.some(o => o.id === id),
1204
+ ownerCurrent: owner => this.officeShareOwnerCurrent(owner),
1205
+ changed: () => this.emitSseEvent("office_shares", {}),
1206
+ log: (event, detail) => this.logger.info({ event, ...(detail ? { detail } : {}) }, "Office share link"),
1207
+ }));
1208
+ }
1209
+ /**
1210
+ * #2636: a share made or ended — said in General, as a sign-in is (who, which office, for how long; never its URL).
1211
+ * `share` null with `count`: "Revoke all".
1212
+ */
1213
+ officeShareNotice(event, who, share, count) {
1214
+ const office = !share ? "" : share.office === ALL_OFFICES ? t("office_share.all") : this.officeStore.list().offices.find(o => o.id === share.office)?.name ?? share.office;
1215
+ const minutes = share ? Math.round((share.expiresAt - share.createdAt) / 60_000) : 0;
1216
+ const text = event === "created" ? t("office_share.created_notice", who, office, minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`)
1217
+ : share ? t("office_share.revoked_notice", who, office) : t("office_share.revoked_all_notice", who, count);
1218
+ this.notifyFleetError(text, { throttle: false });
1219
+ }
1220
+ /** A share's owner: a person (`person:<adapter>:<user>`) still a fleet admin there; the host's own admin session stays. */
1221
+ officeShareOwnerCurrent(owner) {
1222
+ const m = /^person:([^:]+):(.+)$/.exec(owner);
1223
+ if (!m)
1224
+ return owner === "admin-session";
1225
+ return this.hasFleetAdmins(m[1]) && this.isFleetAdmin(m[2], m[1]);
1226
+ }
1169
1227
  officeActsInstance;
1170
1228
  /** #2578: the agents' acts in the Office view (office_view / office_set), memory only; every change is `office_acts`. */
1171
1229
  get officeActs() {
@@ -2352,7 +2410,7 @@ export class FleetManager {
2352
2410
  if (reason === "cancelled")
2353
2411
  outbox.workingRecovery.cancel(name, observation.configuration, observation.sessionId);
2354
2412
  if (entry.prior)
2355
- outbox.workingRecovery.discover(name, entry.prior);
2413
+ outbox.workingRecovery.discover(name, entry.prior, this.workingRecoveryGroup);
2356
2414
  const state = entry.current.observation.executionState ?? "unknown";
2357
2415
  const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
2358
2416
  owed: entry.current.references.filter(r => r.kind === "owed").length,
@@ -2876,8 +2934,9 @@ export class FleetManager {
2876
2934
  // the next tunnel and says so, it does not block the fleet.
2877
2935
  this.announceToolPermissionsChange();
2878
2936
  this.tightenInstanceDirectories();
2879
- // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
2880
- for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
2937
+ // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way (and
2938
+ // #2636's Office share tunnel's).
2939
+ for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR), join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR)]) {
2881
2940
  void reapStaleTunnel(leaseDir)
2882
2941
  .then(outcome => {
2883
2942
  if (outcome.kind === "manual")
@@ -5531,36 +5590,40 @@ export class FleetManager {
5531
5590
  * config file races. Stagger applies between outer lifecycle admissions.
5532
5591
  * TODO: per-instance startup timeout (existing issue, not introduced here)
5533
5592
  */
5534
- async startInstancesWithConcurrency(entries, topicMode, onReady) {
5593
+ async startInstancesWithConcurrency(entries, topicMode, onReady, classic = []) {
5535
5594
  // Persisted pauses are intentionally preserved across fleet restarts. Filter
5536
5595
  // them before grouping/staggering: startInstance() retains its own guard as
5537
5596
  // a final backstop, but putting a no-op entry in this queue still consumes a
5538
5597
  // full stagger slot for every distinct working directory.
5539
5598
  const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
5599
+ // ClassicBot rooms queue with the fleet instances, at the same pressure pacing (#2642 follow-up): they used to start
5600
+ // only after the whole fleet, the chat connections, topics and icons. A name the fleet also configures is the fleet's.
5601
+ const fleetNames = new Set(entries.map(([name]) => name));
5602
+ const runnableClassic = classic.filter(ch => !this.lifecycle.isPaused(ch.instanceName) && !fleetNames.has(ch.instanceName));
5540
5603
  const pausedCount = entries.length - runnableEntries.length;
5541
5604
  if (pausedCount > 0) {
5542
5605
  this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
5543
5606
  }
5544
- if (runnableEntries.length === 0)
5607
+ if (runnableEntries.length === 0 && runnableClassic.length === 0)
5545
5608
  return;
5546
5609
  if (this.fleetConfig?.defaults?.startup?.concurrency == null) {
5547
5610
  this.logger.info({
5548
5611
  concurrency: this.spawnConcurrency(),
5549
5612
  freeMemMB: Math.round(freemem() / (1024 * 1024)),
5550
- totalInstances: runnableEntries.length,
5613
+ totalInstances: runnableEntries.length + runnableClassic.length,
5551
5614
  }, "Adaptive startup concurrency");
5552
5615
  }
5553
5616
  const startedAt = performance.now();
5554
5617
  let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
5555
- await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
5618
+ const admit = (name, workingDirectory, start) => this.spawnGate.run({
5556
5619
  instanceName: name,
5557
- workingDirectory: config.working_directory,
5620
+ workingDirectory,
5558
5621
  reason: "startup",
5559
5622
  stage: "lifecycle",
5560
5623
  }, async () => {
5561
5624
  peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
5562
5625
  try {
5563
- if (await this.startInstanceUnattended(name, config, topicMode, "instance")) {
5626
+ if (await start()) {
5564
5627
  readyInstances++;
5565
5628
  onReady?.(name);
5566
5629
  }
@@ -5568,9 +5631,13 @@ export class FleetManager {
5568
5631
  finally {
5569
5632
  active--;
5570
5633
  }
5571
- })));
5634
+ });
5635
+ await Promise.all([
5636
+ ...runnableEntries.map(([name, config]) => admit(name, config.working_directory, () => this.startInstanceUnattended(name, config, topicMode, "instance"))),
5637
+ ...runnableClassic.map(ch => admit(ch.instanceName, join(getAgendHome(), "workspaces", ch.instanceName), () => this.startClassicInstanceUnattended(ch, "classic instance"))),
5638
+ ]);
5572
5639
  this.logger.info({
5573
- totalInstances: runnableEntries.length, readyInstances,
5640
+ totalInstances: runnableEntries.length + runnableClassic.length, classicInstances: runnableClassic.length, readyInstances,
5574
5641
  elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
5575
5642
  peakConcurrentStarts,
5576
5643
  }, "Fleet startup batch completed");
@@ -6581,33 +6648,11 @@ export class FleetManager {
6581
6648
  getPublicWebStatus() {
6582
6649
  return this.publicWebLink?.status() ?? { state: "closed" };
6583
6650
  }
6584
- /** Notices use the first explicit admin of the owning connection; never a guest/paired account. */
6585
- noticeAdmin(adapterId) {
6586
- return this.adminListOf(adapterId)?.find(id => id.length > 0);
6587
- }
6588
- /** Best-effort private delivery. The caller retains its existing topic fallback and authority fences. */
6589
- async tryNoticeDm(adapter, userId, text) {
6590
- if (!userId || !adapter?.sendDirect)
6591
- return false;
6592
- try {
6593
- await withinBudget(adapter.sendDirect(userId, text, { disablePreview: true }), performance.now() + 2_000);
6594
- return true;
6595
- }
6596
- catch {
6597
- this.logger.info({ adapterId: adapter.id }, "Fleet notice DM failed; falling back to the topic");
6598
- return false;
6599
- }
6600
- }
6601
6651
  async confirmPublicWebLogin(info, current) {
6602
6652
  if (!current() || !this.publicOwnerCurrent(info.owner))
6603
6653
  throw new Error("public owner unavailable");
6604
6654
  const adapter = info.owner.binding;
6605
- const text = t("web.public_login_notice", info.label, info.handle.slice(0, 8));
6606
- if (loginNoticeMode(this.fleetConfig?.web) !== "dm" || !await this.tryNoticeDm(adapter, info.owner.userId, text)) {
6607
- if (!current() || !this.publicOwnerCurrent(info.owner))
6608
- throw new Error("public owner changed");
6609
- await adapter.sendText(info.owner.chatId, text, { threadId: info.owner.threadId, allowedMentions: { parse: [] } });
6610
- }
6655
+ await adapter.sendText(info.owner.chatId, t("web.public_login_notice", info.label, info.handle.slice(0, 8)), { threadId: info.owner.threadId, allowedMentions: { parse: [] } });
6611
6656
  if (!current() || !this.publicOwnerCurrent(info.owner))
6612
6657
  throw new Error("public owner changed");
6613
6658
  }
@@ -6625,12 +6670,7 @@ export class FleetManager {
6625
6670
  if (!open)
6626
6671
  throw new Error("guest public sign-in unavailable");
6627
6672
  const adapter = open.owner.binding;
6628
- const text = t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8));
6629
- if (loginNoticeMode(this.fleetConfig?.web) !== "dm" || !await this.tryNoticeDm(adapter, open.owner.userId, text)) {
6630
- if (!admitted())
6631
- throw new Error("guest public sign-in changed");
6632
- await adapter.sendText(open.owner.chatId, text, { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
6633
- }
6673
+ await adapter.sendText(open.owner.chatId, t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8)), { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
6634
6674
  if (!admitted())
6635
6675
  throw new Error("guest public sign-in changed");
6636
6676
  }
@@ -7468,26 +7508,18 @@ export class FleetManager {
7468
7508
  }
7469
7509
  /** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
7470
7510
  onWebLogin(info) {
7471
- // Keep the existing false policy: guest and public-link security notices remain mandatory.
7472
- const guest = info.tier === "guest" && info.owner;
7473
- if (!guest && this.fleetConfig?.web?.notify_login === false)
7474
- return;
7475
- const text = guest
7476
- ? t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8))
7477
- : t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8));
7478
- if (loginNoticeMode(this.fleetConfig?.web) === "dm") {
7479
- const adapterId = info.owner?.adapterId;
7480
- const adapter = adapterId ? this.adapters.get(adapterId) : undefined;
7481
- // A local guest's own account must never receive the admin's security notice.
7482
- const userId = guest ? (adapterId ? this.noticeAdmin(adapterId) : undefined)
7483
- : info.owner && this.isFleetAdmin(info.owner.userId, adapterId) ? info.owner.userId : undefined;
7484
- void this.tryNoticeDm(adapter, userId, text).then(sent => { if (!sent)
7485
- this.notifyFleetError(text); })
7486
- .catch(() => this.logger.warn("Web sign-in notice could not be delivered"));
7511
+ // #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
7512
+ // and how many agents they now reach — admins stay aware of everyone who is not one of them.
7513
+ if (info.tier === "guest" && info.owner) {
7514
+ this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
7487
7515
  return;
7488
7516
  }
7489
- // Exactly the old topic path, including the session-handle throttle key.
7490
- this.notifyFleetError(text);
7517
+ // Never by DM (hmsg-3b8ac2af: Telegram cannot DM a user who has not started the bot); a legacy "dm" reads as on.
7518
+ if (!loginNoticeMode(this.fleetConfig?.web))
7519
+ return;
7520
+ // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
7521
+ // sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
7522
+ this.notifyFleetError(t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
7491
7523
  }
7492
7524
  getDashboardAccess() {
7493
7525
  return { ready: this.healthServerListening, token: this.webToken };
@@ -8265,9 +8297,11 @@ export class FleetManager {
8265
8297
  this.rotateAllInstanceLogs();
8266
8298
  }, 24 * 60 * 60_000);
8267
8299
  this.logRotateTimer.unref?.();
8268
- // Phase 2: Start remaining instances with staggered concurrency
8269
- if (others.length > 0) {
8270
- await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady());
8300
+ // Phase 2: Start remaining instances, and the ClassicBot rooms with them, with staggered concurrency. A room needs no
8301
+ // chat connection to spawn: its open-list is set above (reregisterClassicChannels) and inbound queues until it is up.
8302
+ const classicAtBoot = topicMode && (fleet.channel || fleet.channels?.length) ? this.classicChannels?.getAll() ?? [] : [];
8303
+ if (others.length > 0 || classicAtBoot.length > 0) {
8304
+ await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
8271
8305
  }
8272
8306
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
8273
8307
  await adapterStartup;
@@ -8309,22 +8343,7 @@ export class FleetManager {
8309
8343
  // IPC is already wired by startInstancesWithConcurrency → startInstance →
8310
8344
  // connectIpcToInstance. The previous 3s sleep + connectToInstances loop
8311
8345
  // was redundant.
8312
- // Start classic channel instances (parallel, concurrency 3)
8313
- if (this.classicChannels) {
8314
- const fleetBackend = this.fleetConfig?.defaults?.backend;
8315
- const channels = this.classicChannels.getAll()
8316
- .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
8317
- const concurrency = 3;
8318
- let idx = 0;
8319
- while (idx < channels.length) {
8320
- const batch = channels.slice(idx, idx + concurrency);
8321
- await Promise.allSettled(batch.map(async (ch) => {
8322
- if (await this.startClassicInstanceUnattended(ch, "classic instance"))
8323
- startupProgress.markReady();
8324
- }));
8325
- idx += concurrency;
8326
- }
8327
- }
8346
+ // The ClassicBot rooms started in Phase 2, with the fleet.
8328
8347
  for (const name of Object.keys(fleet.instances)) {
8329
8348
  this.startStatuslineWatcher(name);
8330
8349
  }
@@ -8591,24 +8610,42 @@ export class FleetManager {
8591
8610
  if (channelConfigs.length === 0)
8592
8611
  return;
8593
8612
  // Start ALL adapters in parallel — any single failure doesn't block others.
8594
- const results = await Promise.allSettled(channelConfigs.map((cfg, i) => i === 0
8613
+ const settled = channelConfigs.map(() => false);
8614
+ // Each connection is recorded (connected, or retrying with its retry scheduled) as its own login settles.
8615
+ const recorded = channelConfigs.map((cfg, i) => (i === 0
8595
8616
  ? this.startSingleAdapter(fleet, cfg)
8596
- : this.startAdditionalAdapter(cfg)));
8597
- // Track state + schedule background retry for failures.
8598
- for (let i = 0; i < channelConfigs.length; i++) {
8599
- const adapterId = channelConfigs[i].id ?? channelConfigs[i].type;
8600
- if (results[i].status === "fulfilled") {
8601
- this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
8602
- }
8603
- else {
8604
- const err = results[i].reason;
8605
- this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
8606
- this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
8607
- this.scheduleAdapterRetry(adapterId);
8608
- // Notify admin via whichever adapter is already up
8609
- this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
8610
- }
8617
+ : this.startAdditionalAdapter(cfg)).then(() => ({ status: "fulfilled" }), (reason) => ({ status: "rejected", reason }))
8618
+ .then(result => { settled[i] = true; this.recordAdapterStartup(cfg, result); })
8619
+ .catch(err => this.logger.error({ err }, "Recording a chat connection's startup failed")));
8620
+ // #2642: bounded. Startup continues past a connection still logging in after this long (ClassicBot instances,
8621
+ // READY); that connection is recorded, or retried, when its login settles, and joins the fleet as it would anyway.
8622
+ let timer;
8623
+ const inTime = await Promise.race([
8624
+ Promise.all(recorded).then(() => true),
8625
+ new Promise(resolve => { timer = setTimeout(() => resolve(false), FleetManager.ADAPTER_STARTUP_WAIT_MS); timer.unref?.(); }),
8626
+ ]).finally(() => clearTimeout(timer));
8627
+ if (!inTime) {
8628
+ this.logger.warn({
8629
+ pending: channelConfigs.filter((_, i) => !settled[i]).map(cfg => cfg.id ?? cfg.type),
8630
+ waitedMs: FleetManager.ADAPTER_STARTUP_WAIT_MS,
8631
+ }, "Chat connection still connecting — fleet startup continues without waiting for it");
8632
+ }
8633
+ }
8634
+ /** How long fleet startup waits for its chat connections before it continues without the slow ones (#2642). */
8635
+ static ADAPTER_STARTUP_WAIT_MS = 60_000;
8636
+ /** Track a connection's startup outcome; a failure gets the background retry. */
8637
+ recordAdapterStartup(cfg, result) {
8638
+ const adapterId = cfg.id ?? cfg.type;
8639
+ if (result.status === "fulfilled") {
8640
+ this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
8641
+ return;
8611
8642
  }
8643
+ const err = result.reason;
8644
+ this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
8645
+ this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
8646
+ this.scheduleAdapterRetry(adapterId);
8647
+ // Notify admin via whichever adapter is already up
8648
+ this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
8612
8649
  }
8613
8650
  /** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
8614
8651
  scheduleAdapterRetry(adapterId) {
@@ -12249,10 +12286,11 @@ export class FleetManager {
12249
12286
  const { office, ...dashboard } = patch.value;
12250
12287
  return [{ ...patch, value: dashboard }, { path: [...path.slice(0, -1), "office_access"], value: office, remove: office === null }];
12251
12288
  }
12289
+ // #2581's DM choice was withdrawn: an old string is just "on", posted in the topic, and its route is dropped.
12252
12290
  if (patch.path.join(".") === "web.notify_login" && patch.value === "dm")
12253
- return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "login"], value: "dm" }];
12291
+ return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "login"], remove: true }];
12254
12292
  if (patch.path.join(".") === "defaults.tips" && patch.value === "dm")
12255
- return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "tips"], value: "dm" }];
12293
+ return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "tips"], remove: true }];
12256
12294
  return [patch];
12257
12295
  });
12258
12296
  // Re-read immediately before patching so an unrelated concurrent/manual
@@ -12408,12 +12446,13 @@ export class FleetManager {
12408
12446
  }
12409
12447
  /** One-time upgrade migration; invalid YAML is never rewritten. */
12410
12448
  migrateNoticeDeliveryStorage() {
12411
- if (this.fleetConfig && this.configPath && (this.fleetConfig.web?.notify_login === "dm" || this.fleetConfig.defaults.tips === "dm")
12449
+ if (this.fleetConfig && this.configPath && (this.fleetConfig.web?.notify_login === "dm" || this.fleetConfig.defaults.tips === "dm"
12450
+ || this.fleetConfig.web?.notice_delivery !== undefined)
12412
12451
  && validateFleetConfig(this.rawFleetConfig).valid) {
12413
12452
  try {
12414
12453
  this.writeFleetConfigBackup(readFileSync(this.configPath, "utf8"));
12415
12454
  this.saveFleetConfig();
12416
- this.logger.info("Stored DM notice routing with downgrade-safe boolean fields");
12455
+ this.logger.info("Notices no longer go by DM: stored them as topic booleans and dropped the old DM routing");
12417
12456
  }
12418
12457
  catch (err) {
12419
12458
  this.logger.warn({ err }, "Could not migrate DM notice routing; keeping the original file");
@@ -15073,14 +15112,14 @@ export class FleetManager {
15073
15112
  }
15074
15113
  }
15075
15114
  /** Post one fresh nonce-armed tip in a known General channel. */
15076
- async promptTip(generalName, adapter, chatId, threadId, directUserId) {
15115
+ async promptTip(generalName, adapter, chatId, threadId) {
15077
15116
  const state = this.readTipState();
15078
15117
  if (!state)
15079
15118
  return "unavailable";
15080
15119
  if (!state.advancedUnlocked
15081
15120
  && visibleTipLevels(true).has("advanced")
15082
15121
  && canUnlockAdvancedTips(state.dismissed)) {
15083
- return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId, directUserId)
15122
+ return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
15084
15123
  ? "posted"
15085
15124
  : "unavailable";
15086
15125
  }
@@ -15096,7 +15135,6 @@ export class FleetManager {
15096
15135
  chatId,
15097
15136
  threadId,
15098
15137
  message: this.formatTip(tip),
15099
- ...(directUserId ? { deliver: (choices) => withinBudget(adapter.sendDirect(directUserId, this.formatTip(tip), { choices, disablePreview: true }), performance.now() + 5_000) } : {}),
15100
15138
  choices: [
15101
15139
  { action: "dismiss", label: t("tips.dismiss") },
15102
15140
  { action: "confused", label: t("tips.confused") },
@@ -15108,7 +15146,7 @@ export class FleetManager {
15108
15146
  });
15109
15147
  return nonce ? "posted" : "unavailable";
15110
15148
  }
15111
- async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId, directUserId) {
15149
+ async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
15112
15150
  const nonce = await this.postNonceButtonPrompt({
15113
15151
  prefix: TIP_UNLOCK_CALLBACK_PREFIX,
15114
15152
  alertType: "tip",
@@ -15118,7 +15156,6 @@ export class FleetManager {
15118
15156
  chatId,
15119
15157
  threadId,
15120
15158
  message: t("tips.advanced.unlock_prompt"),
15121
- ...(directUserId ? { deliver: (choices) => withinBudget(adapter.sendDirect(directUserId, t("tips.advanced.unlock_prompt"), { choices, disablePreview: true }), performance.now() + 5_000) } : {}),
15122
15159
  choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
15123
15160
  expiredText: t("tips.advanced.expired"),
15124
15161
  // No allowAnyUser: unlocking changes a persistent setting, which typed and slash `/tips advanced on` reserve for a
@@ -15138,15 +15175,6 @@ export class FleetManager {
15138
15175
  const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
15139
15176
  if (!adapter || !chatId)
15140
15177
  return;
15141
- if (this.fleetConfig && tipNoticeMode(this.fleetConfig) === "dm" && adapter.sendDirect) {
15142
- const userId = this.noticeAdmin(adapter.id);
15143
- if (userId) {
15144
- const result = await this.promptTip(generalName, adapter, userId, undefined, userId);
15145
- if (result !== "unavailable")
15146
- return;
15147
- // The failed prompt disarmed its nonce; a new topic prompt is safe.
15148
- }
15149
- }
15150
15178
  const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
15151
15179
  if (result === "unavailable") {
15152
15180
  this.logger.warn({ generalName }, "Daily tip could not be posted");
@@ -20723,8 +20751,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20723
20751
  /** Signal-handler continuation, after successful graceful shutdown only. */
20724
20752
  noteRequestedShutdown() {
20725
20753
  const progress = readUpdateProgress(this.dataDir)?.progress;
20726
- const update = progress && updateProgressOperation(progress) === "update"
20727
- && ["installed", "stopping", "starting"].includes(progress.stage);
20754
+ // Host CLI updates carry a generic marker with no chat progress target.
20755
+ const update = progress ? updateProgressOperation(progress) === "update"
20756
+ && ["installed", "stopping", "starting"].includes(progress.stage) : isUpdateInProgress(this.dataDir);
20728
20757
  this.recordWorkingRestartIntent(update ? "update" : "manual_restart");
20729
20758
  }
20730
20759
  recordWorkingRestartIntent(cause) {
@@ -20784,6 +20813,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20784
20813
  const settingsControlStopped = settingsControl?.close();
20785
20814
  this.publicLinkAutoOpen?.stop();
20786
20815
  const publicStopped = this.publicWebLink?.close("fleet shutdown");
20816
+ const sharesStopped = this.officeSharesInstance?.close(); // #2636: every Office share ends, and its tunnel
20787
20817
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
20788
20818
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
20789
20819
  const needsStopped = this.stopNeedsYou();
@@ -20822,6 +20852,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20822
20852
  // timer and HTTP listener living in THIS process: without an explicit
20823
20853
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
20824
20854
  await publicStopped;
20855
+ await sharesStopped?.catch(() => { });
20825
20856
  await profileStopped;
20826
20857
  await needsStopped;
20827
20858
  await settingsControlStopped;
@@ -22724,6 +22755,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22724
22755
  this.logger.info("No instances to restart");
22725
22756
  return;
22726
22757
  }
22758
+ this.workingRecoveryGroup = randomUUID();
22727
22759
  this.recordWorkingRestartIntent("manual_restart");
22728
22760
  this.workingRecovery?.reset();
22729
22761
  this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
@@ -22799,29 +22831,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22799
22831
  }
22800
22832
  // General is ready again; now its topic can own the live progress message.
22801
22833
  await restartProgress.start(progressTarget);
22802
- if (restartOthers.length > 0) {
22803
- await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady());
22834
+ // The ClassicBot rooms (killed during orphan cleanup) restart with the fleet instances, in the same queue; their
22835
+ // open-lists are refreshed first so a room's inbound is never dropped while it starts.
22836
+ const restartClassic = topicMode ? this.classicChannels?.getAll() ?? [] : [];
22837
+ if (restartClassic.length > 0)
22838
+ this.reregisterClassicChannels();
22839
+ if (restartOthers.length > 0 || restartClassic.length > 0) {
22840
+ await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady(), restartClassic);
22804
22841
  }
22805
22842
  if (topicMode) {
22806
22843
  this.routing.rebuild(this.fleetConfig);
22807
22844
  this.reregisterClassicChannels();
22808
22845
  // startInstance already calls connectIpcToInstance, no need for connectToInstances here
22809
- // Restart classic channel instances (killed during orphan cleanup)
22810
- if (this.classicChannels) {
22811
- const fleetBackend = this.fleetConfig?.defaults?.backend;
22812
- const channels = this.classicChannels.getAll()
22813
- .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
22814
- const concurrency = 3;
22815
- let idx = 0;
22816
- while (idx < channels.length) {
22817
- const batch = channels.slice(idx, idx + concurrency);
22818
- await Promise.allSettled(batch.map(async (ch) => {
22819
- if (await this.startClassicInstanceUnattended(ch, "classic instance"))
22820
- restartProgress.markReady();
22821
- }));
22822
- idx += concurrency;
22823
- }
22824
- }
22825
22846
  for (const name of Object.keys(fleet.instances)) {
22826
22847
  this.startStatuslineWatcher(name);
22827
22848
  }