@songsid/agend 2.3.0-beta.11 → 2.3.0-beta.13

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 (111) hide show
  1. package/README.md +6 -0
  2. package/README.zh-TW.md +6 -0
  3. package/dist/agent-whats-new.js +14 -9
  4. package/dist/agent-whats-new.js.map +1 -1
  5. package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
  6. package/dist/channel/adapters/discord-client-retirement.js +57 -0
  7. package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
  8. package/dist/channel/adapters/discord.d.ts +2 -0
  9. package/dist/channel/adapters/discord.js +14 -4
  10. package/dist/channel/adapters/discord.js.map +1 -1
  11. package/dist/cli.js +12 -79
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +12 -0
  14. package/dist/config.js +13 -4
  15. package/dist/config.js.map +1 -1
  16. package/dist/daemon.d.ts +3 -0
  17. package/dist/daemon.js +25 -0
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/fleet-manager.d.ts +44 -2
  20. package/dist/fleet-manager.js +229 -63
  21. package/dist/fleet-manager.js.map +1 -1
  22. package/dist/fleet-process-handlers.d.ts +18 -0
  23. package/dist/fleet-process-handlers.js +69 -0
  24. package/dist/fleet-process-handlers.js.map +1 -0
  25. package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
  26. package/dist/general-knowledge/skills/web-appearance/SKILL.md +20 -3
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +27 -5
  28. package/dist/instance-lifecycle.d.ts +2 -0
  29. package/dist/instance-lifecycle.js +11 -6
  30. package/dist/instance-lifecycle.js.map +1 -1
  31. package/dist/outbound-schemas.d.ts +14 -1
  32. package/dist/outbound-schemas.js +8 -2
  33. package/dist/outbound-schemas.js.map +1 -1
  34. package/dist/public-web-gateway.js +4 -0
  35. package/dist/public-web-gateway.js.map +1 -1
  36. package/dist/restart-progress.d.ts +16 -1
  37. package/dist/restart-progress.js +74 -15
  38. package/dist/restart-progress.js.map +1 -1
  39. package/dist/settings-api.d.ts +4 -0
  40. package/dist/settings-api.js +27 -1
  41. package/dist/settings-api.js.map +1 -1
  42. package/dist/settings-confirmation.d.ts +3 -1
  43. package/dist/settings-confirmation.js +2 -0
  44. package/dist/settings-confirmation.js.map +1 -1
  45. package/dist/settings-effect.js +2 -1
  46. package/dist/settings-effect.js.map +1 -1
  47. package/dist/settings-http-confirmation.d.ts +11 -0
  48. package/dist/settings-http-confirmation.js +10 -0
  49. package/dist/settings-http-confirmation.js.map +1 -1
  50. package/dist/ui/app-effects.js +13 -1
  51. package/dist/ui/chat-effects.js +10 -3
  52. package/dist/ui/chat-store.js +32 -10
  53. package/dist/ui/chat-strings.js +18 -0
  54. package/dist/ui/chat-thread.js +5 -2
  55. package/dist/ui/effects-waker.js +70 -0
  56. package/dist/ui/fleet-canvas-layout.js +10 -4
  57. package/dist/ui/fleet-canvas.js +32 -2
  58. package/dist/ui/fleet-route-scene.js +207 -0
  59. package/dist/ui/fleet-route-worker.js +21 -0
  60. package/dist/ui/fleet-route.js +88 -0
  61. package/dist/ui/fleet-traffic.js +27 -11
  62. package/dist/ui/fleet-usage.js +35 -1
  63. package/dist/ui/panel-chat.js +67 -26
  64. package/dist/ui/panel-fleet.js +3 -3
  65. package/dist/ui/panel-settings.js +24 -0
  66. package/dist/ui/settings-appearance.js +2 -1
  67. package/dist/ui/settings-apply.js +3 -3
  68. package/dist/ui/settings-confirm.js +7 -2
  69. package/dist/ui/settings-owner-effects.js +6 -4
  70. package/dist/ui/settings-strings.js +2 -0
  71. package/dist/ui/shared/app-i18n.js +4 -0
  72. package/dist/ui/shared/app-prefs.js +12 -0
  73. package/dist/ui/shared/app-shell.js +38 -2
  74. package/dist/ui/shared/app-themes.js +116 -1
  75. package/dist/ui/shared/app.css +47 -3
  76. package/dist/ui/shared/card-strings.js +4 -0
  77. package/dist/ui/shared/effect-owner-source.js +8 -1
  78. package/dist/ui/shared/effect-registry.js +10 -2
  79. package/dist/ui/shared/identity-card.js +24 -6
  80. package/dist/ui/shared/identity-color.js +11 -7
  81. package/dist/ui/shared/owner-css.js +30 -1
  82. package/dist/ui/shared/ui-icons.js +2 -0
  83. package/dist/ui/shared/ui-menu.js +9 -3
  84. package/dist/ui/shared/web-authorize.js +47 -0
  85. package/dist/web-api.d.ts +6 -2
  86. package/dist/web-api.js +68 -15
  87. package/dist/web-api.js.map +1 -1
  88. package/dist/web-appearance.js +3 -1
  89. package/dist/web-appearance.js.map +1 -1
  90. package/dist/web-effect.d.ts +1 -1
  91. package/dist/web-host-guard.d.ts +11 -0
  92. package/dist/web-host-guard.js +11 -0
  93. package/dist/web-host-guard.js.map +1 -1
  94. package/dist/web-libavoid.d.ts +14 -0
  95. package/dist/web-libavoid.js +46 -0
  96. package/dist/web-libavoid.js.map +1 -0
  97. package/dist/web-owner-css-store.d.ts +44 -1
  98. package/dist/web-owner-css-store.js +70 -9
  99. package/dist/web-owner-css-store.js.map +1 -1
  100. package/dist/web-prefs.js +13 -0
  101. package/dist/web-prefs.js.map +1 -1
  102. package/dist/web-settings-tools.d.ts +5 -0
  103. package/dist/web-settings-tools.js +38 -7
  104. package/dist/web-settings-tools.js.map +1 -1
  105. package/dist/web-theme.d.ts +29 -0
  106. package/dist/web-theme.js +89 -1
  107. package/dist/web-theme.js.map +1 -1
  108. package/dist/yaml-shared-nodes.d.ts +25 -0
  109. package/dist/yaml-shared-nodes.js +185 -0
  110. package/dist/yaml-shared-nodes.js.map +1 -0
  111. package/package.json +2 -1
@@ -245,6 +245,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
245
245
  fleetConfig: FleetConfig | null;
246
246
  private rawFleetConfig;
247
247
  private rawFleetDocument;
248
+ /** #2208: the paths a saveFleetConfig() wrote, for its check that nothing else changed. */
249
+ private fleetDocumentWrites;
248
250
  private savedFleetConfigSnapshot;
249
251
  adapter: ChannelAdapter | null;
250
252
  readonly worlds: Map<string, AdapterWorld>;
@@ -411,6 +413,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
411
413
  /** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
412
414
  private startupProgress;
413
415
  private adapterState;
416
+ private adapterRetryInFlight;
414
417
  /** Web Settings secret rotation is deliberately separate from reconnect
415
418
  * recovery: a rotation must build a fresh provider client with the new token,
416
419
  * and stale callbacks from the old client must not win. */
@@ -623,6 +626,30 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
623
626
  private classicSettingsOwners;
624
627
  private queueSettingsOperation;
625
628
  private settingsGate;
629
+ /**
630
+ * #2170: a held change confirmed by the request itself — only when ALL hold, else the chat card or the host CLI, as
631
+ * before:
632
+ * - the session is web-verified (#1939: its account confirmed this browser on Discord or Telegram), asked now;
633
+ * - the change's effect owners are known and on one platform (settingsChatAuthority — the chat card's own rule), the
634
+ * verified account is on that platform and an admin of every one of them;
635
+ * - a chat card could have been asked: a connected General on that platform the change does not affect (else the
636
+ * change goes to the host CLI, as before) — the General that then hears of it;
637
+ * - and, at execution (store.decide asks `current()` again): still web-verified under the same session and account
638
+ * (not expired, revoked, signed out or the switch turned off), still those owners' admin, still a General a card
639
+ * could go to, the fleet unchanged.
640
+ * What it rests on: the account's one confirmation of this browser (#1939), kept for the session until it is revoked,
641
+ * expires or signs out — not a fresh confirmation per Apply. Whoever uses that verified session (a browser left signed
642
+ * in, its cookie) confirms the same way. A public link never qualifies (evaluateWebAuthority refuses it). General
643
+ * still hears it: a notice, not a step.
644
+ */
645
+ private settingsSelfConfirmation;
646
+ /** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
647
+ private settingsNoticeGenerals;
648
+ /**
649
+ * #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
650
+ * in, plain text, no mentions. Information only: nothing waits on it.
651
+ */
652
+ private settingsWebConfirmedNotice;
626
653
  /** Exact configured effect owners; this does not depend on prompt location. */
627
654
  private settingsChatAuthority;
628
655
  private promptSettingsChange;
@@ -1357,7 +1384,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1357
1384
  * snapshot use the fleet startup/retry state. */
1358
1385
  private readyProgressAdapter;
1359
1386
  /** Last-resort completion after RestartProgress could not deliver to its
1360
- * adopted target. It stays bounded and never wakes a not-ready gateway. */
1387
+ * adopted target (or had no message: a small fleet, a failed first post). It
1388
+ * never wakes a not-ready gateway. #2213: an outage at fleet start no longer
1389
+ * loses it — bounded at startup, then retried in the background once the
1390
+ * adapter is ready again, never posted twice after an uncertain attempt. */
1361
1391
  private sendFleetStartCompletionFallback;
1362
1392
  stopInstance(name: string, transition?: TransitionHandle): Promise<{
1363
1393
  backgroundWorkEnded: boolean;
@@ -1683,8 +1713,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1683
1713
  private sweepOrphanedWebUploads;
1684
1714
  /** Start the shared channel adapter(s) for topic mode */
1685
1715
  private startSharedAdapter;
1686
- /** Exponential backoff retry for a single failed adapter (background, non-blocking). */
1716
+ /** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
1687
1717
  private scheduleAdapterRetry;
1718
+ /** One login per connection, shared by the background timer and the operator's Retry now. */
1719
+ private attemptAdapterRetry;
1720
+ retryAdapterNow(adapterId: string): {
1721
+ queued: boolean;
1722
+ already_running?: boolean;
1723
+ };
1688
1724
  /** Notify admin about adapter failure (uses any available adapter). */
1689
1725
  private notifyAdapterFailure;
1690
1726
  /** Notify admin that a retried adapter reconnected. */
@@ -2126,6 +2162,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
2126
2162
  private slimFleetConfigAtStartup;
2127
2163
  private writeFleetConfigBackup;
2128
2164
  private patchFleetDocument;
2165
+ /**
2166
+ * #2208: before every write to the raw document — the place is unshared first (an alias on the way becomes its own
2167
+ * copy; an anchor's other aliases get theirs), so the write changes that place only; and it is recorded for the
2168
+ * save's check that nothing else changed.
2169
+ */
2170
+ private writingFleetDocument;
2129
2171
  getSettingsConfigPath(): string;
2130
2172
  removeInstance(name: string, authorization: ExplicitInstanceRemoval, execution?: SettingsExecution): Promise<void>;
2131
2173
  private removeInstanceOwned;
@@ -38,6 +38,7 @@ import { readFleetMemory } from "./process-memory.js";
38
38
  import { MemoryPressure } from "./memory-pressure.js";
39
39
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
40
40
  import { isMap, isScalar, parseDocument } from "yaml";
41
+ import { changedOutside, detachForWrite } from "./yaml-shared-nodes.js";
41
42
  import { kiroLogGate } from "./kiro-log-gate.js";
42
43
  const __filename = fileURLToPath(import.meta.url);
43
44
  const __dirname = dirname(__filename);
@@ -227,7 +228,7 @@ import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } f
227
228
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
228
229
  import { isWebPageNavigation } from "./web-shell-routes.js";
229
230
  import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
230
- import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
231
+ import { authorityNow, classifyWebPeer, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
231
232
  import { AuthorityStateStore } from "./authority/authority-state.js";
232
233
  import { SessionAuthorizations } from "./authority/session-authorizations.js";
233
234
  import { SessionAuthorizationRequests } from "./web-session-authorization.js";
@@ -250,7 +251,7 @@ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome,
250
251
  import { carryClaudeConversation } from "./backend/claude-carry.js";
251
252
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
252
253
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
253
- import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
254
+ import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
254
255
  import { launchFullRestartHelper } from "./full-restart.js";
255
256
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
256
257
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
@@ -589,6 +590,8 @@ export class FleetManager {
589
590
  fleetConfig = null;
590
591
  rawFleetConfig = {};
591
592
  rawFleetDocument = null;
593
+ /** #2208: the paths a saveFleetConfig() wrote, for its check that nothing else changed. */
594
+ fleetDocumentWrites = null;
592
595
  savedFleetConfigSnapshot = null;
593
596
  adapter = null;
594
597
  worlds = new Map();
@@ -784,6 +787,7 @@ export class FleetManager {
784
787
  /** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
785
788
  startupProgress = null;
786
789
  adapterState = new Map();
790
+ adapterRetryInFlight = new Map();
787
791
  /** Web Settings secret rotation is deliberately separate from reconnect
788
792
  * recovery: a rotation must build a fresh provider client with the new token,
789
793
  * and stale callbacks from the old client must not win. */
@@ -1283,6 +1287,7 @@ export class FleetManager {
1283
1287
  current: () => current() && sessions === this.webSessions && !!this.webToken
1284
1288
  && sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
1285
1289
  }, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
1290
+ selfConfirm: (req, id) => this.settingsSelfConfirmation(store, req, id),
1286
1291
  job: async (id) => {
1287
1292
  const settlement = this.settingsJobSettlements.get(id);
1288
1293
  if (!settlement)
@@ -1297,6 +1302,86 @@ export class FleetManager {
1297
1302
  this.settingsConfirmation = gate;
1298
1303
  return gate;
1299
1304
  }
1305
+ /**
1306
+ * #2170: a held change confirmed by the request itself — only when ALL hold, else the chat card or the host CLI, as
1307
+ * before:
1308
+ * - the session is web-verified (#1939: its account confirmed this browser on Discord or Telegram), asked now;
1309
+ * - the change's effect owners are known and on one platform (settingsChatAuthority — the chat card's own rule), the
1310
+ * verified account is on that platform and an admin of every one of them;
1311
+ * - a chat card could have been asked: a connected General on that platform the change does not affect (else the
1312
+ * change goes to the host CLI, as before) — the General that then hears of it;
1313
+ * - and, at execution (store.decide asks `current()` again): still web-verified under the same session and account
1314
+ * (not expired, revoked, signed out or the switch turned off), still those owners' admin, still a General a card
1315
+ * could go to, the fleet unchanged.
1316
+ * What it rests on: the account's one confirmation of this browser (#1939), kept for the session until it is revoked,
1317
+ * expires or signs out — not a fresh confirmation per Apply. Whoever uses that verified session (a browser left signed
1318
+ * in, its cookie) confirms the same way. A public link never qualifies (evaluateWebAuthority refuses it). General
1319
+ * still hears it: a notice, not a step.
1320
+ */
1321
+ settingsSelfConfirmation(store, req, id) {
1322
+ const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
1323
+ if (auth.kind !== "ok")
1324
+ return null;
1325
+ const record = auth.session, gateway = !!gatewayRequestContext(req), peer = classifyWebPeer(req);
1326
+ const verified = () => {
1327
+ const now = this.webAuthority({ session: record, gateway, peer });
1328
+ // A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
1329
+ return now.authority.kind === "web-verified" && now.authority.actor && now.authority.session?.handle === record.handle ? now.authority.actor : null;
1330
+ };
1331
+ const actor = verified();
1332
+ if (!actor)
1333
+ return null;
1334
+ const chat = this.settingsChatAuthority(store, id);
1335
+ if (!chat)
1336
+ return null;
1337
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
1338
+ const own = channels.filter(ch => (ch.id ?? ch.type) === actor.adapter_id);
1339
+ if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
1340
+ return null;
1341
+ const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
1342
+ if (!this.settingsNoticeGenerals(affected, chat.platform).length)
1343
+ return null;
1344
+ const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
1345
+ return {
1346
+ label: `${name} (web-verified)`,
1347
+ // admin() asks chat.current() too; a General a card could go to must still be there (else the host CLI's).
1348
+ current: () => !!verified() && chat.admin(actor.user_id) && this.settingsNoticeGenerals(affected, chat.platform).length > 0,
1349
+ done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
1350
+ };
1351
+ }
1352
+ /** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
1353
+ settingsNoticeGenerals(affected, platform) {
1354
+ const excluded = new Set(affected), found = [];
1355
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
1356
+ if (!config.general_topic || !this.daemons.get(name))
1357
+ continue;
1358
+ const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
1359
+ if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform || this.adapterState.get(adapterId)?.status !== "connected")
1360
+ continue;
1361
+ const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
1362
+ if (chatId && topic)
1363
+ found.push({ adapterId, adapter, chatId, topic });
1364
+ }
1365
+ return found;
1366
+ }
1367
+ /**
1368
+ * #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
1369
+ * in, plain text, no mentions. Information only: nothing waits on it.
1370
+ */
1371
+ async settingsWebConfirmedNotice(view, affected, who, platform) {
1372
+ const outcome = view.outcome ? view.outcome.message : view.state;
1373
+ const text = `✅ Settings change confirmed from the web by ${who} (web-verified)\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}\nOutcome: ${outcome}`;
1374
+ for (const { adapterId, adapter, chatId, topic } of this.settingsNoticeGenerals(affected, platform)) {
1375
+ try {
1376
+ await adapter.sendText(chatId, text, { threadId: topic, allowedMentions: { parse: [] } });
1377
+ return;
1378
+ }
1379
+ catch {
1380
+ this.logger.info({ id: view.id, adapterId }, "Settings web-confirmation notice unavailable; trying another General");
1381
+ }
1382
+ }
1383
+ this.logger.info({ id: view.id }, "Settings change confirmed from the web: no General reachable for the notice");
1384
+ }
1300
1385
  /** Exact configured effect owners; this does not depend on prompt location. */
1301
1386
  settingsChatAuthority(store, id) {
1302
1387
  const scope = store.authorityOf(id);
@@ -5297,34 +5382,18 @@ export class FleetManager {
5297
5382
  return this.adapterState.get(adapterId)?.status === "connected" ? adapter : undefined;
5298
5383
  }
5299
5384
  /** Last-resort completion after RestartProgress could not deliver to its
5300
- * adopted target. It stays bounded and never wakes a not-ready gateway. */
5385
+ * adopted target (or had no message: a small fleet, a failed first post). It
5386
+ * never wakes a not-ready gateway. #2213: an outage at fleet start no longer
5387
+ * loses it — bounded at startup, then retried in the background once the
5388
+ * adapter is ready again, never posted twice after an uncertain attempt. */
5301
5389
  async sendFleetStartCompletionFallback(chatId, text, threadId) {
5302
5390
  const adapterId = this.getPrimaryAdapterId();
5303
- const adapter = adapterId ? this.readyProgressAdapter(adapterId) : undefined;
5304
- if (!adapter) {
5305
- this.logger.error({ adapterId }, "Fleet start completion fallback skipped because the primary adapter is not ready");
5391
+ if (!adapterId) {
5392
+ this.logger.error({ adapterId }, "Fleet start completion fallback skipped because there is no primary adapter");
5306
5393
  return false;
5307
5394
  }
5308
- let timer;
5309
- const timeout = new Promise(resolve => {
5310
- timer = setTimeout(() => resolve({ status: "timeout" }), RESTART_PROGRESS_TERMINAL_TIMEOUT_MS);
5311
- timer.unref?.();
5312
- });
5313
- const delivery = Promise.resolve()
5314
- .then(() => adapter.sendText(chatId, text, { threadId }))
5315
- .then(() => ({ status: "sent" }), err => ({ status: "failed", err }));
5316
- const result = await Promise.race([delivery, timeout]);
5317
- if (timer)
5318
- clearTimeout(timer);
5319
- if (result.status === "sent")
5320
- return true;
5321
- if (result.status === "failed") {
5322
- this.logger.error({ err: result.err }, "Failed to send fleet start completion fallback");
5323
- }
5324
- else {
5325
- this.logger.error({ timeout_ms: RESTART_PROGRESS_TERMINAL_TIMEOUT_MS }, "Timed out sending fleet start completion fallback");
5326
- }
5327
- return false;
5395
+ const poster = new RestartProgress(0, Date.now(), this.logger);
5396
+ return poster.postCompletion({ resolveAdapter: () => this.readyProgressAdapter(adapterId), chatId, threadId }, text);
5328
5397
  }
5329
5398
  async stopInstance(name, transition) {
5330
5399
  this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
@@ -7537,48 +7606,109 @@ export class FleetManager {
7537
7606
  const err = results[i].reason;
7538
7607
  this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
7539
7608
  this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
7540
- this.scheduleAdapterRetry(adapterId, channelConfigs[i], i === 0 ? fleet : undefined);
7609
+ this.scheduleAdapterRetry(adapterId);
7541
7610
  // Notify admin via whichever adapter is already up
7542
7611
  this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
7543
7612
  }
7544
7613
  }
7545
7614
  }
7546
- /** Exponential backoff retry for a single failed adapter (background, non-blocking). */
7547
- scheduleAdapterRetry(adapterId, channelConfig, fleet) {
7548
- const MAX_RETRIES = 10;
7615
+ /** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
7616
+ scheduleAdapterRetry(adapterId) {
7549
7617
  const INITIAL_DELAY_MS = 5_000;
7550
7618
  const MAX_DELAY_MS = 5 * 60_000;
7551
7619
  const state = this.adapterState.get(adapterId);
7552
- if (!state || state.retryCount >= MAX_RETRIES) {
7553
- if (state) {
7554
- state.status = "failed";
7555
- this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
7556
- this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
7557
- }
7620
+ if (this.shuttingDown || !state || state.retryTimer || this.adapterRetryInFlight.has(adapterId))
7558
7621
  return;
7559
- }
7560
- const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
7622
+ const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, Math.min(state.retryCount, 6)), MAX_DELAY_MS);
7561
7623
  this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
7562
- state.retryTimer = setTimeout(async () => {
7624
+ const timer = setTimeout(() => {
7625
+ const current = this.adapterState.get(adapterId);
7626
+ if (this.shuttingDown || current?.retryTimer !== timer)
7627
+ return;
7628
+ current.retryTimer = undefined;
7629
+ void this.attemptAdapterRetry(adapterId);
7630
+ }, delay);
7631
+ state.retryTimer = timer;
7632
+ timer.unref?.();
7633
+ }
7634
+ /** One login per connection, shared by the background timer and the operator's Retry now. */
7635
+ attemptAdapterRetry(adapterId) {
7636
+ const pending = this.adapterRetryInFlight.get(adapterId);
7637
+ if (pending)
7638
+ return pending;
7639
+ if (this.shuttingDown || !this.secureConnectionChannel(adapterId))
7640
+ return Promise.resolve();
7641
+ const attempt = Promise.resolve().then(async () => {
7642
+ const channel = this.secureConnectionChannel(adapterId);
7643
+ if (this.shuttingDown || !channel)
7644
+ return;
7645
+ const generation = this.settingsGeneration;
7646
+ const binding = JSON.stringify([channel, this.getPrimaryAdapterId()]);
7647
+ const token = process.env[channel.bot_token_env];
7648
+ const operation = { owner: Symbol("adapter-retry"), assert: () => {
7649
+ if (this.shuttingDown || generation !== this.settingsGeneration
7650
+ || JSON.stringify([this.secureConnectionChannel(adapterId), this.getPrimaryAdapterId()]) !== binding
7651
+ || process.env[channel.bot_token_env] !== token)
7652
+ throw new Error("adapter retry superseded");
7653
+ } };
7654
+ const state = this.adapterState.get(adapterId) ?? { status: "retrying", retryCount: 0 };
7655
+ state.status = "retrying";
7563
7656
  state.retryCount++;
7657
+ this.adapterState.set(adapterId, state);
7564
7658
  try {
7565
- if (fleet) {
7566
- await this.startSingleAdapter(fleet, channelConfig);
7567
- }
7568
- else {
7569
- await this.startAdditionalAdapter(channelConfig);
7570
- }
7571
- state.status = "connected";
7572
- state.lastError = undefined;
7659
+ if (this.getPrimaryAdapterId() === adapterId)
7660
+ await this.startSingleAdapter(this.fleetConfig, channel, undefined, operation);
7661
+ else
7662
+ await this.startAdditionalAdapter(channel, true, undefined, operation);
7663
+ if (this.shuttingDown || generation !== this.settingsGeneration)
7664
+ return;
7665
+ if (!this.adapters.has(adapterId))
7666
+ throw new Error("Adapter token is not configured");
7667
+ // Health events can replace the state object during login. Update its current owner.
7668
+ const current = this.adapterState.get(adapterId);
7669
+ current.status = "connected";
7670
+ current.lastError = undefined;
7573
7671
  this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
7574
7672
  this.notifyAdapterRecovery(adapterId, state.retryCount);
7575
7673
  }
7576
7674
  catch (err) {
7577
- state.lastError = err?.message ?? String(err);
7578
- this.logger.warn({ adapterId, attempt: state.retryCount, err: state.lastError }, "Adapter retry failed");
7579
- this.scheduleAdapterRetry(adapterId, channelConfig, fleet);
7675
+ if (this.shuttingDown || generation !== this.settingsGeneration)
7676
+ return;
7677
+ const current = this.adapterState.get(adapterId);
7678
+ current.status = "retrying";
7679
+ current.lastError = err?.message ?? String(err);
7680
+ this.logger.warn({ adapterId, attempt: state.retryCount, err: current.lastError }, "Adapter retry failed");
7580
7681
  }
7581
- }, delay);
7682
+ }).finally(() => {
7683
+ if (this.adapterRetryInFlight.get(adapterId) !== attempt)
7684
+ return;
7685
+ this.adapterRetryInFlight.delete(adapterId);
7686
+ const channel = this.secureConnectionChannel(adapterId);
7687
+ if (!this.shuttingDown && channel && this.adapterState.get(adapterId)?.status !== "connected") {
7688
+ this.scheduleAdapterRetry(adapterId);
7689
+ }
7690
+ });
7691
+ this.adapterRetryInFlight.set(adapterId, attempt);
7692
+ return attempt;
7693
+ }
7694
+ retryAdapterNow(adapterId) {
7695
+ if (this.shuttingDown)
7696
+ throw new Error("The fleet is stopping");
7697
+ if (!this.secureConnectionChannel(adapterId))
7698
+ throw new Error("Connection not found");
7699
+ if (this.adapterRetryInFlight.has(adapterId) || this.adapterRestarting.has(adapterId))
7700
+ return { queued: true, already_running: true };
7701
+ const state = this.adapterState.get(adapterId);
7702
+ if (state?.retryTimer) {
7703
+ clearTimeout(state.retryTimer);
7704
+ state.retryTimer = undefined;
7705
+ }
7706
+ const adapter = this.adapters.get(adapterId);
7707
+ if (adapter && this.connectionStatus(adapterId, state, this.worlds.get(adapterId)).status === "connected")
7708
+ return { queued: false };
7709
+ const run = adapter ? this.restartAdapter(adapter, adapterId) : this.attemptAdapterRetry(adapterId);
7710
+ void run.catch(err => this.logger.warn({ adapterId, err }, "Requested adapter retry failed"));
7711
+ return { queued: true };
7582
7712
  }
7583
7713
  /** Notify admin about adapter failure (uses any available adapter). */
7584
7714
  notifyAdapterFailure(adapterId, error) {
@@ -7633,6 +7763,7 @@ export class FleetManager {
7633
7763
  : snapshot.status === "stopped" ? "failed"
7634
7764
  : "retrying";
7635
7765
  this.adapterState.set(adapterId, {
7766
+ ...previous,
7636
7767
  status,
7637
7768
  retryCount: previous?.retryCount ?? 0,
7638
7769
  lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
@@ -9458,6 +9589,16 @@ export class FleetManager {
9458
9589
  respond(null, permitted.message);
9459
9590
  return;
9460
9591
  }
9592
+ // Fleet tools resolve their own targets. A missing chat world must not prevent local
9593
+ // delivery, inspection or receipt lookup; only channel tools need reply routing below.
9594
+ const handler = outboundHandlers.get(tool);
9595
+ if (handler && !usesChannelAddress(tool)) {
9596
+ this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
9597
+ await handler(this, args, respond, {
9598
+ instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
9599
+ });
9600
+ return;
9601
+ }
9461
9602
  // A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
9462
9603
  // else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
9463
9604
  const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
@@ -9649,19 +9790,14 @@ export class FleetManager {
9649
9790
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
9650
9791
  return;
9651
9792
  }
9652
- // Log tool calls for activity visualization
9653
- const senderLabel = senderSessionName ?? instanceName;
9654
- this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
9655
- // Dispatch fleet-specific tools via handler map
9656
- const handler = outboundHandlers.get(tool);
9657
9793
  if (handler) {
9794
+ this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
9658
9795
  await handler(this, args, respond, {
9659
9796
  instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
9660
9797
  });
9661
9798
  }
9662
- else {
9799
+ else
9663
9800
  respond(null, `Unknown tool: ${tool}`);
9664
- }
9665
9801
  }
9666
9802
  /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
9667
9803
  /** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
@@ -11013,6 +11149,10 @@ export class FleetManager {
11013
11149
  throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
11014
11150
  }
11015
11151
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
11152
+ // #2208: what the file says before this save, aliases expanded — every write below is recorded, and anything that
11153
+ // changes outside them (a place sharing a node with one written) refuses the save.
11154
+ const fileBefore = this.rawFleetDocument.toJS({ maxAliasCount: -1 });
11155
+ const writes = this.fleetDocumentWrites = []; // a throw below leaves it to the next save
11016
11156
  this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
11017
11157
  // Settings edits are expressed against the raw config, so apply them before
11018
11158
  // canonicalization. A non-identity value equal to its inherited default is
@@ -11023,6 +11163,7 @@ export class FleetManager {
11023
11163
  // (or a legacy scalar occupies that parent). Removing an override which
11024
11164
  // is already absent is an idempotent no-op, not a failed Settings save.
11025
11165
  if (this.rawFleetDocument.hasIn(patch.path)) {
11166
+ this.writingFleetDocument(this.rawFleetDocument, patch.path);
11026
11167
  this.rawFleetDocument.deleteIn(patch.path);
11027
11168
  }
11028
11169
  }
@@ -11034,8 +11175,10 @@ export class FleetManager {
11034
11175
  const rawAfterPatches = this.rawFleetDocument.toJS();
11035
11176
  const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
11036
11177
  for (const path of redundantPaths) {
11037
- if (this.rawFleetDocument.hasIn(path))
11178
+ if (this.rawFleetDocument.hasIn(path)) {
11179
+ this.writingFleetDocument(this.rawFleetDocument, path);
11038
11180
  this.rawFleetDocument.deleteIn(path);
11181
+ }
11039
11182
  // Avoid leaving empty operational maps such as `terminal: {}` while
11040
11183
  // preserving the instance mapping itself and all surrounding comments.
11041
11184
  for (let depth = path.length - 1; depth > 2; depth--) {
@@ -11043,9 +11186,14 @@ export class FleetManager {
11043
11186
  const parent = this.rawFleetDocument.getIn(parentPath, true);
11044
11187
  if (!isMap(parent) || parent.items.length > 0)
11045
11188
  break;
11189
+ this.writingFleetDocument(this.rawFleetDocument, parentPath);
11046
11190
  this.rawFleetDocument.deleteIn(parentPath);
11047
11191
  }
11048
11192
  }
11193
+ this.fleetDocumentWrites = null;
11194
+ const stray = changedOutside(fileBefore, this.rawFleetDocument.toJS({ maxAliasCount: -1 }), writes);
11195
+ if (stray)
11196
+ throw new Error(`Refusing to save fleet.yaml: this change would also change ${stray}, which shares a value with what it edits. Give each its own value in fleet.yaml.`);
11049
11197
  noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
11050
11198
  const output = String(this.rawFleetDocument);
11051
11199
  if (redundantPaths.length > 0)
@@ -11156,11 +11304,15 @@ export class FleetManager {
11156
11304
  // Remove from the end so YAML sequence indexes do not shift underneath us.
11157
11305
  for (let i = before.length - 1; i >= after.length; i--) {
11158
11306
  const itemPath = [...path, i];
11159
- if (document.hasIn(itemPath))
11307
+ if (document.hasIn(itemPath)) {
11308
+ this.writingFleetDocument(document, itemPath);
11160
11309
  document.deleteIn(itemPath);
11310
+ }
11161
11311
  }
11162
- for (let i = shared; i < after.length; i++)
11312
+ for (let i = shared; i < after.length; i++) {
11313
+ this.writingFleetDocument(document, [...path, i]);
11163
11314
  document.setIn([...path, i], after[i]);
11315
+ }
11164
11316
  return;
11165
11317
  }
11166
11318
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -11176,6 +11328,7 @@ export class FleetManager {
11176
11328
  if (isMap(fresh)) {
11177
11329
  const list = document.createNode([]);
11178
11330
  list.items.push(fresh.clone());
11331
+ this.writingFleetDocument(document, ["channels"]);
11179
11332
  document.setIn(["channels"], list);
11180
11333
  }
11181
11334
  }
@@ -11197,13 +11350,17 @@ export class FleetManager {
11197
11350
  // Effective config contains inherited objects that may not exist in the
11198
11351
  // raw YAML at all. yaml.deleteIn() is not idempotent for a missing nested
11199
11352
  // parent, so guard it explicitly.
11200
- if (document.hasIn(path))
11353
+ if (document.hasIn(path)) {
11354
+ this.writingFleetDocument(document, path);
11201
11355
  document.deleteIn(path);
11356
+ }
11202
11357
  }
11203
11358
  else if (path.length === 0) {
11359
+ this.writingFleetDocument(document, path);
11204
11360
  document.contents = document.createNode(after);
11205
11361
  }
11206
11362
  else {
11363
+ this.writingFleetDocument(document, path);
11207
11364
  const currentNode = document.getIn(path, true);
11208
11365
  if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
11209
11366
  currentNode.value = after;
@@ -11218,6 +11375,15 @@ export class FleetManager {
11218
11375
  }
11219
11376
  }
11220
11377
  }
11378
+ /**
11379
+ * #2208: before every write to the raw document — the place is unshared first (an alias on the way becomes its own
11380
+ * copy; an anchor's other aliases get theirs), so the write changes that place only; and it is recorded for the
11381
+ * save's check that nothing else changed.
11382
+ */
11383
+ writingFleetDocument(document, path) {
11384
+ detachForWrite(document, path);
11385
+ this.fleetDocumentWrites?.push([...path]);
11386
+ }
11221
11387
  getSettingsConfigPath() { return this.configPath; }
11222
11388
  async removeInstance(name, authorization, execution) {
11223
11389
  if (execution)
@@ -20493,7 +20659,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20493
20659
  all = false;
20494
20660
  const message = err?.message ?? String(err);
20495
20661
  this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
20496
- this.scheduleAdapterRetry(id, channel);
20662
+ this.scheduleAdapterRetry(id);
20497
20663
  }
20498
20664
  }
20499
20665
  this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));