@songsid/agend 2.3.3 → 2.3.4-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/dist/agent-cli-instructions.md +1 -1
  4. package/dist/auth-api.d.ts +16 -3
  5. package/dist/auth-api.js +22 -6
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +15 -1
  8. package/dist/authority/web-authority.js +27 -10
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +11 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +12 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/types.d.ts +7 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.js +13 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/chat-log-ids.d.ts +48 -0
  19. package/dist/chat-log-ids.js +182 -0
  20. package/dist/chat-log-ids.js.map +1 -0
  21. package/dist/classic-channel-manager.d.ts +8 -1
  22. package/dist/classic-channel-manager.js +24 -2
  23. package/dist/classic-channel-manager.js.map +1 -1
  24. package/dist/config-validator.js +18 -1
  25. package/dist/config-validator.js.map +1 -1
  26. package/dist/daemon.js +8 -1
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/env-file.d.ts +2 -0
  29. package/dist/env-file.js +13 -8
  30. package/dist/env-file.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +42 -1
  32. package/dist/fleet-manager.js +206 -23
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  35. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  36. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  37. package/dist/instance-config-impact.js +1 -0
  38. package/dist/instance-config-impact.js.map +1 -1
  39. package/dist/instance-lifecycle.d.ts +2 -0
  40. package/dist/instance-lifecycle.js +23 -7
  41. package/dist/instance-lifecycle.js.map +1 -1
  42. package/dist/instructions.d.ts +1 -1
  43. package/dist/instructions.js +1 -1
  44. package/dist/instructions.js.map +1 -1
  45. package/dist/interaction-observation.d.ts +1 -0
  46. package/dist/interaction-observation.js +1 -1
  47. package/dist/interaction-observation.js.map +1 -1
  48. package/dist/locale.js +48 -0
  49. package/dist/locale.js.map +1 -1
  50. package/dist/next-steps.d.ts +12 -2
  51. package/dist/next-steps.js +19 -3
  52. package/dist/next-steps.js.map +1 -1
  53. package/dist/office-share-service.d.ts +57 -0
  54. package/dist/office-share-service.js +76 -11
  55. package/dist/office-share-service.js.map +1 -1
  56. package/dist/office-share.d.ts +8 -0
  57. package/dist/office-share.js +34 -0
  58. package/dist/office-share.js.map +1 -1
  59. package/dist/outbound-schemas.js +1 -1
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/page-share.d.ts +122 -0
  62. package/dist/page-share.js +199 -0
  63. package/dist/page-share.js.map +1 -0
  64. package/dist/public-preview.d.ts +4 -0
  65. package/dist/public-preview.js +5 -4
  66. package/dist/public-preview.js.map +1 -1
  67. package/dist/public-web-gateway.js +5 -0
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +5 -0
  70. package/dist/quickstart-api.js +1 -1
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/quickstart.d.ts +40 -0
  73. package/dist/quickstart.js +174 -28
  74. package/dist/quickstart.js.map +1 -1
  75. package/dist/remote-card-answer.js +2 -0
  76. package/dist/remote-card-answer.js.map +1 -1
  77. package/dist/remote-card-prompt.js +4 -2
  78. package/dist/remote-card-prompt.js.map +1 -1
  79. package/dist/selection-card.d.ts +21 -0
  80. package/dist/selection-card.js +129 -0
  81. package/dist/selection-card.js.map +1 -0
  82. package/dist/settings-api.js +4 -0
  83. package/dist/settings-api.js.map +1 -1
  84. package/dist/settings-change.js +5 -0
  85. package/dist/settings-change.js.map +1 -1
  86. package/dist/settings-effect.js +2 -0
  87. package/dist/settings-effect.js.map +1 -1
  88. package/dist/setup-host.js +2 -2
  89. package/dist/setup-host.js.map +1 -1
  90. package/dist/setup-wizard.js +2 -2
  91. package/dist/setup-wizard.js.map +1 -1
  92. package/dist/types.d.ts +10 -0
  93. package/dist/ui/chat-store.js +8 -1
  94. package/dist/ui/chat-strings.js +38 -2
  95. package/dist/ui/chat-thread.js +52 -10
  96. package/dist/ui/page-share-dialog.js +191 -0
  97. package/dist/ui/panel-chat.js +14 -0
  98. package/dist/ui/panel-notices.js +98 -0
  99. package/dist/ui/panel-settings.js +9 -2
  100. package/dist/ui/preview-pages.js +41 -0
  101. package/dist/ui/preview-panel-actions.js +161 -0
  102. package/dist/ui/preview-panel.js +209 -28
  103. package/dist/ui/preview-versions.js +155 -0
  104. package/dist/ui/preview.js +88 -3
  105. package/dist/ui/settings-registry.js +1 -0
  106. package/dist/ui/settings-strings.js +4 -0
  107. package/dist/ui/shared/app-i18n.js +2 -2
  108. package/dist/ui/shared/app-route.js +4 -0
  109. package/dist/ui/shared/app-shell.js +5 -3
  110. package/dist/ui/shared/app-store.js +71 -1
  111. package/dist/ui/shared/app-stream.js +1 -1
  112. package/dist/ui/shared/app.css +57 -3
  113. package/dist/ui/shared/app.js +22 -1
  114. package/dist/ui/shared/signin.js +12 -1
  115. package/dist/ui/shared/ui-icons.js +3 -0
  116. package/dist/ui/shared/web-authorize.js +3 -1
  117. package/dist/web-api.d.ts +26 -0
  118. package/dist/web-api.js +180 -1
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-auth.js +7 -1
  121. package/dist/web-auth.js.map +1 -1
  122. package/dist/web-login.d.ts +2 -0
  123. package/dist/web-login.js +3 -1
  124. package/dist/web-login.js.map +1 -1
  125. package/dist/web-notices.d.ts +51 -0
  126. package/dist/web-notices.js +118 -0
  127. package/dist/web-notices.js.map +1 -0
  128. package/dist/web-only-fleet.d.ts +22 -0
  129. package/dist/web-only-fleet.js +13 -0
  130. package/dist/web-only-fleet.js.map +1 -0
  131. package/dist/web-preview.d.ts +32 -3
  132. package/dist/web-preview.js +52 -19
  133. package/dist/web-preview.js.map +1 -1
  134. package/dist/web-session.d.ts +7 -0
  135. package/dist/web-session.js +2 -0
  136. package/dist/web-session.js.map +1 -1
  137. package/dist/web-settings-patch.d.ts +3 -1
  138. package/dist/web-settings-patch.js +4 -1
  139. package/dist/web-settings-patch.js.map +1 -1
  140. package/dist/web-shell-routes.d.ts +2 -0
  141. package/dist/web-shell-routes.js +3 -0
  142. package/dist/web-shell-routes.js.map +1 -1
  143. package/package.json +1 -1
@@ -28,6 +28,7 @@ import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, pub
28
28
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
29
29
  import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
30
30
  import { namedTestLane, runNamedLinkTest } from "./tunnel/named-link-test.js";
31
+ import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
31
32
  import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
32
33
  import { ALL_OFFICES, WHOLE_FLEET } from "./office-share.js";
33
34
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
@@ -264,7 +265,8 @@ import { authorizeSession, decideWebGate, loadOrCreateWebToken, readSessionCooki
264
265
  import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
265
266
  import { isWebPageNavigation } from "./web-shell-routes.js";
266
267
  import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
267
- import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
268
+ import { WebNotices, WEB_NOTICES_FILE } from "./web-notices.js";
269
+ import { authorityNow, evaluateWebAuthority, HOST_OPERATOR, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
268
270
  import { AuthorityStateStore } from "./authority/authority-state.js";
269
271
  import { SessionAuthorizations } from "./authority/session-authorizations.js";
270
272
  import { SessionAuthorizationRequests } from "./web-session-authorization.js";
@@ -300,6 +302,7 @@ import { SpawnGate } from "./spawn-gate.js";
300
302
  import { BackendOutageTracker } from "./backend-outage.js";
301
303
  import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
302
304
  import { getTmuxSession } from "./config.js";
305
+ import { OwnStatusMessages, discordMessageKey } from "./chat-log-ids.js";
303
306
  // Closed migration cohort: these 11 names existed immediately before the first
304
307
  // managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
305
308
  // adding a bundled skill: its new name is not proof of old AgEnD ownership.
@@ -1201,6 +1204,11 @@ export class FleetManager {
1201
1204
  officeExists: id => this.officeStore.list().offices.some(o => o.id === id),
1202
1205
  ownerCurrent: owner => this.officeShareOwnerCurrent(owner),
1203
1206
  changed: () => this.emitSseEvent("office_shares", {}),
1207
+ // #2911: page shares on the same tunnel. Their end (expired, stopped, the feature off, the maker no longer an admin,
1208
+ // the fleet stopping) is said in the page's own channel, as its start is (pageShareNotice); never its URL.
1209
+ pageConfig: () => this.fleetConfig?.web?.page_share,
1210
+ pageCdn: () => this.fleetConfig?.web?.preview_cdn === true,
1211
+ pagesChanged: (list, ended) => this.pageSharesChanged(list, ended),
1204
1212
  log: (event, detail) => this.logger.info({ event, ...(detail ? { detail } : {}) }, "Office share link"),
1205
1213
  }));
1206
1214
  }
@@ -1215,6 +1223,17 @@ export class FleetManager {
1215
1223
  : share ? t("office_share.revoked_notice", who, office) : t("office_share.revoked_all_notice", who, count);
1216
1224
  this.notifyFleetError(text, { throttle: false });
1217
1225
  }
1226
+ /** #2911: the open page shares changed — the page hears it; each one handed out that ended is said in its channel. */
1227
+ pageSharesChanged(_list, ended) {
1228
+ this.emitSseEvent("page_shares", {});
1229
+ for (const s of ended)
1230
+ this.notifyInstanceTopic(s.instance, t("page_share.ended_notice", s.title || t("page_share.untitled")));
1231
+ }
1232
+ /** #2911: a page share was made — said in the channel of the instance whose chat it came from (never its URL). */
1233
+ pageShareNotice(who, share) {
1234
+ const minutes = Math.round((share.expiresAt - share.createdAt) / 60_000);
1235
+ this.notifyInstanceTopic(share.instance, t("page_share.created_notice", who, share.title || t("page_share.untitled"), minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`));
1236
+ }
1218
1237
  /** A share's owner: a person (`person:<adapter>:<user>`) still a fleet admin there; the host's own admin session stays. */
1219
1238
  officeShareOwnerCurrent(owner) {
1220
1239
  const m = /^person:([^:]+):(.+)$/.exec(owner);
@@ -1612,6 +1631,17 @@ export class FleetManager {
1612
1631
  const actor = verified();
1613
1632
  if (!actor)
1614
1633
  return null;
1634
+ // #2910 D1: a web-only fleet's host operator (its `agend web --code` session on this computer) confirms its own
1635
+ // change — the trust `agend settings confirm` has here; there is no account to be an admin of and no General to
1636
+ // ask. Asked again at execution: still the host operator now (this computer, not revoked or run out), still web-only.
1637
+ const hostOperator = (a) => !!a && a.adapter_id === HOST_OPERATOR.adapterId && a.user_id === HOST_OPERATOR.userId && this.isWebOnlyFleet();
1638
+ if (hostOperator(actor)) {
1639
+ return {
1640
+ label: t("settings_confirm.host_operator_label"),
1641
+ current: () => hostOperator(verified()),
1642
+ done: view => { this.logger.info({ id: view.id, state: view.state }, "Settings change confirmed from the web by this computer's admin"); },
1643
+ };
1644
+ }
1615
1645
  const chat = this.settingsChatAuthority(store, id);
1616
1646
  if (!chat)
1617
1647
  return null;
@@ -5690,8 +5720,7 @@ export class FleetManager {
5690
5720
  }
5691
5721
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
5692
5722
  async startPersistedPausedInstance(name, transition) {
5693
- const topicMode = this.fleetConfig?.channel?.mode === "topic"
5694
- || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
5723
+ const topicMode = daemonTopicMode(this.fleetConfig);
5695
5724
  const fleetConfig = this.fleetConfig?.instances[name];
5696
5725
  if (fleetConfig) {
5697
5726
  await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
@@ -5902,8 +5931,7 @@ export class FleetManager {
5902
5931
  this.logger.info({ name, attempt: attempt + 1 }, "Startup retry deferred — tmux storm window is blocking spawns");
5903
5932
  return;
5904
5933
  }
5905
- const topicMode = this.fleetConfig?.channel?.mode === "topic"
5906
- || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
5934
+ const topicMode = daemonTopicMode(this.fleetConfig);
5907
5935
  this.startupRetriesRunning.add(name);
5908
5936
  try {
5909
5937
  await this.spawnGate.run({
@@ -6426,7 +6454,7 @@ export class FleetManager {
6426
6454
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
6427
6455
  return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
6428
6456
  });
6429
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
6457
+ const topicMode = daemonTopicMode(this.fleetConfig);
6430
6458
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => opts?.kiroMigration ? this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition, undefined, opts.kiroMigration)
6431
6459
  : this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
6432
6460
  this.stormWindow.markRecovered(name);
@@ -6729,6 +6757,7 @@ export class FleetManager {
6729
6757
  if (!publicPreviewWanted(this.fleetConfig?.web))
6730
6758
  return null;
6731
6759
  return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
6760
+ cdn: () => this.fleetConfig?.web?.preview_cdn === true,
6732
6761
  provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
6733
6762
  log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
6734
6763
  },
@@ -7690,7 +7719,8 @@ export class FleetManager {
7690
7719
  return;
7691
7720
  // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
7692
7721
  // sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
7693
- this.notifyFleetError(t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
7722
+ // #2910 D2: a web-only fleet has no /web to revoke with — its Notice names the host's way (every browser signed out).
7723
+ this.notifyFleetError(t(this.webOnlyPromptPlace() ? "web.login_notice_web_only" : "web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
7694
7724
  }
7695
7725
  getDashboardAccess() {
7696
7726
  return { ready: this.healthServerListening, token: this.webToken };
@@ -8110,7 +8140,8 @@ export class FleetManager {
8110
8140
  // Must run before the first General/CLI spawn: pressure can hold startup.
8111
8141
  // Logs remain available even before the adapters and health listener exist.
8112
8142
  this.memoryPressure.start();
8113
- const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
8143
+ // #2910: a web-only fleet's agents run in topic mode too (their cross-instance tools work; the dashboard is their place).
8144
+ const topicMode = daemonTopicMode(fleet);
8114
8145
  // Set tmux socket isolation for custom AGEND_HOME
8115
8146
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
8116
8147
  TmuxManager.setSocketName(getSocket());
@@ -8327,7 +8358,8 @@ export class FleetManager {
8327
8358
  }
8328
8359
  if (generalsCreated && !this.configHistory?.isFallback(this.configPath))
8329
8360
  this.saveFleetConfig();
8330
- if (topicMode && (fleet.channel || fleet.channels?.length)) {
8361
+ // #2910: the scheduler (schedules, the task board, decisions) runs on a web-only fleet too; it needs no chat.
8362
+ if (topicMode) {
8331
8363
  const schedulerConfig = {
8332
8364
  ...DEFAULT_SCHEDULER_CONFIG,
8333
8365
  ...this.fleetConfig?.defaults.scheduler,
@@ -9109,6 +9141,9 @@ export class FleetManager {
9109
9141
  return;
9110
9142
  await this.handleInboundMessage(msg);
9111
9143
  }, this.logger, "adapter.message"));
9144
+ // #2940: a deleted message's chat-log line goes (and it is never logged after this).
9145
+ adapter.on("message_deleted", (e) => { if (isCurrentAdapter())
9146
+ this.onPlatformMessageDeleted(e); });
9112
9147
  adapter.on("reaction", safeHandler(async (r) => {
9113
9148
  if (!isCurrentAdapter())
9114
9149
  return;
@@ -9259,6 +9294,9 @@ export class FleetManager {
9259
9294
  return;
9260
9295
  await this.handleInboundMessage(msg);
9261
9296
  }, this.logger, `adapter[${adapterId}].message`));
9297
+ // #2940: a deleted message's chat-log line goes (and it is never logged after this).
9298
+ adapter.on("message_deleted", (e) => { if (isCurrentAdapter())
9299
+ this.onPlatformMessageDeleted(e); });
9262
9300
  adapter.on("reaction", safeHandler(async (r) => {
9263
9301
  if (!isCurrentAdapter())
9264
9302
  return;
@@ -10836,7 +10874,10 @@ export class FleetManager {
10836
10874
  // else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
10837
10875
  const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
10838
10876
  if (this.worlds.size === 0 && !webOnlyReply) {
10839
- respond(null, "Channel adapters are not ready — retry shortly");
10877
+ // #2910: on a web-only fleet there will never be an adapter — say so, so the agent does not retry forever.
10878
+ respond(null, this.isWebOnlyFleet()
10879
+ ? `${tool} is not available on a web-only fleet (no Discord or Telegram connection); answer with reply`
10880
+ : "Channel adapters are not ready — retry shortly");
10840
10881
  return;
10841
10882
  }
10842
10883
  // Resolve threadId: use sender's topic_id if sender is a known fleet instance,
@@ -11036,9 +11077,9 @@ export class FleetManager {
11036
11077
  }
11037
11078
  /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
11038
11079
  /** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
11080
+ /** #2910: no Discord or Telegram connection at all (src/web-only-fleet.ts). */
11039
11081
  isWebOnlyFleet() {
11040
- const config = this.fleetConfig;
11041
- return config != null && !config.channel && !(config.channels?.length);
11082
+ return isWebOnlyFleetConfig(this.fleetConfig);
11042
11083
  }
11043
11084
  /** On a fleet with no chat platform, the dashboard is where an instance-health prompt is posted (#1307 item 6). */
11044
11085
  webOnlyPromptPlace() {
@@ -11413,6 +11454,9 @@ export class FleetManager {
11413
11454
  // through the target's adapter, so a Telegram-created schedule for a
11414
11455
  // Discord target announced its failure into the wrong platform.
11415
11456
  const adapter = this.scheduleSourceAdapter(schedule);
11457
+ // #2910 D2: a web-only fleet has no chat to say it in: a Notice about the schedule's target.
11458
+ if (!adapter && this.recordWebNotice("schedule", `⏰ Schedule "${schedule.label ?? schedule.id}" trigger failed: instance ${schedule.target} is offline.`, schedule.target))
11459
+ return;
11416
11460
  if (!adapter) {
11417
11461
  this.logger.warn({ scheduleId: schedule.id, chatId: schedule.reply_chat_id, source: schedule.source }, "No adapter can reach the schedule's source chat — failure notice not sent");
11418
11462
  return;
@@ -14221,6 +14265,8 @@ export class FleetManager {
14221
14265
  * back to posting at the bare group id, which is the defect this replaced.
14222
14266
  */
14223
14267
  postDailySummary(text) {
14268
+ if (this.recordWebNotice("summary", text))
14269
+ return; // #2910 D2: a web-only fleet's Notices
14224
14270
  const target = this.fleetNoticeTarget();
14225
14271
  if (!this.adapter || !target) {
14226
14272
  this.logger.warn("Daily summary has no postable target — set a General topic or channel.options.general_channel_id");
@@ -14273,6 +14319,10 @@ export class FleetManager {
14273
14319
  dispatched = true;
14274
14320
  }
14275
14321
  }
14322
+ // #2910 D2: a web-only fleet has no General: the notice goes to the dashboard's Notices instead (and spends the
14323
+ // throttle window like a post would).
14324
+ if (!dispatched && this.recordWebNotice("fleet", body))
14325
+ dispatched = true;
14276
14326
  if (!dispatched) {
14277
14327
  // Do NOT record the throttle key. It used to be claimed before delivery
14278
14328
  // was attempted, so a message nobody could receive — every fleet error
@@ -14295,6 +14345,31 @@ export class FleetManager {
14295
14345
  }
14296
14346
  return true;
14297
14347
  }
14348
+ webNotices = null;
14349
+ /** #2910 D2 (web-api): the Notices on a web-only fleet; null on any other (its General says them). */
14350
+ webNoticesFeed() { return this.webOnlyPromptPlace() ? this.webNoticeStore() : null; }
14351
+ /** #2910 D2: the web-only fleet's Notices (web-notices.json), read on first use. */
14352
+ webNoticeStore() {
14353
+ if (!this.dataDir)
14354
+ return null;
14355
+ this.webNotices ??= new WebNotices(join(this.dataDir, WEB_NOTICES_FILE), this.logger);
14356
+ return this.webNotices;
14357
+ }
14358
+ /**
14359
+ * #2910 D2: on a web-only fleet (no chat connection at all), keep what General or an agent's topic would have been
14360
+ * told as a Notice, and push it to the dashboard. False on any other fleet (its chat says it; nothing is kept).
14361
+ */
14362
+ recordWebNotice(kind, text, instance) {
14363
+ if (!this.webOnlyPromptPlace())
14364
+ return false;
14365
+ const store = this.webNoticeStore();
14366
+ if (!store)
14367
+ return false;
14368
+ const known = instance !== undefined && Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? instance : undefined;
14369
+ const notice = store.add({ kind, text: stripPlaceRefs(text), ...(known ? { instance: known } : {}) });
14370
+ this.emitSseEvent("notice", { notice, unread: store.unread() });
14371
+ return true;
14372
+ }
14298
14373
  static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
14299
14374
  fleetErrorNotices = new Map();
14300
14375
  /**
@@ -14308,6 +14383,9 @@ export class FleetManager {
14308
14383
  * would have to make this async and change every caller.
14309
14384
  */
14310
14385
  notifyInstanceTopic(instanceName, text, extraOpts) {
14386
+ // #2910 D2: a web-only fleet has no topics: what the agent's topic would have been told is a Notice about it.
14387
+ if (this.webOnlyPromptPlace())
14388
+ return this.recordWebNotice("instance", text, instanceName);
14311
14389
  const send = this.sendToInstanceTopic(instanceName, text, extraOpts);
14312
14390
  if (!send)
14313
14391
  return false;
@@ -15584,6 +15662,7 @@ export class FleetManager {
15584
15662
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
15585
15663
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
15586
15664
  policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
15665
+ hostOperator: this.isWebOnlyFleet(),
15587
15666
  });
15588
15667
  }
15589
15668
  sessionAuthorizations = null;
@@ -15616,6 +15695,33 @@ export class FleetManager {
15616
15695
  store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
15617
15696
  return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs, peer) };
15618
15697
  }
15698
+ /**
15699
+ * #2910 D1 (auth-api, at sign-in): a web-only fleet's host session — signed in with the host's own code (`agend web
15700
+ * --code`) on this computer — is the host operator, authorized from now for max_session_hours (as an account's
15701
+ * Confirm would be), under the current epoch: a revocation ends it, and only a new sign-in on this computer gives it
15702
+ * again. A fleet with a chat connection, another session, or another peer: nothing (§3 as before).
15703
+ */
15704
+ authorizeHostSession(session, peer) {
15705
+ const store = this.sessionAuthorizationStore();
15706
+ if (!store || !session.host || session.owner || !this.isWebOnlyFleet())
15707
+ return;
15708
+ const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
15709
+ const check = evaluateWebAuthority({
15710
+ session, gateway: false, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
15711
+ isAdmin: () => false,
15712
+ authorization: { handle: session.handle, owner: HOST_OPERATOR, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
15713
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
15714
+ hostOperator: true,
15715
+ });
15716
+ if (check.refusal) {
15717
+ this.logger.info({ handle: session.handle, refusal: check.refusal }, "Host web session not authorized");
15718
+ return;
15719
+ }
15720
+ const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
15721
+ store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
15722
+ const { durable } = store.authorize(session.handle, HOST_OPERATOR, now, state.epoch, now + policy.maxSessionMs, peer);
15723
+ this.logger.info({ handle: session.handle, durable }, "Host web session authorized (this computer's admin)");
15724
+ }
15619
15725
  authorityStateStore = null;
15620
15726
  /**
15621
15727
  * #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
@@ -15671,7 +15777,10 @@ export class FleetManager {
15671
15777
  const sessions = new Map((this.webSessions?.list(undefined, () => true) ?? []).map(r => [r.handle, r]));
15672
15778
  const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
15673
15779
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
15674
- .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
15780
+ .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label,
15781
+ // #2910 D1: the host operator's own session says whose it is, not "host:operator".
15782
+ account: a.owner.adapterId === HOST_OPERATOR.adapterId && a.owner.userId === HOST_OPERATOR.userId && this.isWebOnlyFleet()
15783
+ ? t("settings_confirm.host_operator_label") : `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
15675
15784
  until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
15676
15785
  // Over a link: the temporary one (§0.3), else the named one (a record from before §0.3 has no peer).
15677
15786
  ...(sessions.get(a.handle).surface === "gateway" ? (a.peer === "quick_link" ? { quick: true } : { named: true }) : {}) })) : [];
@@ -15781,6 +15890,8 @@ export class FleetManager {
15781
15890
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
15782
15891
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
15783
15892
  policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
15893
+ // #2910 D1: a host operator's session is never asked about (no account to ask): only a sign-in authorizes it.
15894
+ hostOperator: this.isWebOnlyFleet(),
15784
15895
  });
15785
15896
  return r.refusal;
15786
15897
  }
@@ -16088,8 +16199,10 @@ export class FleetManager {
16088
16199
  ? [{ action: "resume", label: t("inst.session_oom_resume") }, { action: "fresh", label: t("inst.session_oom_fresh") }]
16089
16200
  : [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }];
16090
16201
  const extra = sessionOomRetry ? { sessionOomRetry, ...(current ? { confirmationCurrent: current } : {}) } : undefined;
16091
- this.notifyInstanceTopic(instanceName, (oom ? message : t("exit.instance_notice", instanceName)) + placeSuffix({ kind: "general" }, "General"));
16092
16202
  const web = this.webOnlyPromptPlace();
16203
+ // A pointer to General's controls; on a web-only fleet (#2910 D2) the controls are in Needs you and nothing points there.
16204
+ if (!web)
16205
+ this.notifyInstanceTopic(instanceName, (oom ? message : t("exit.instance_notice", instanceName)) + placeSuffix({ kind: "general" }, "General"));
16093
16206
  if (web) {
16094
16207
  await this.postNonceButtonPrompt({
16095
16208
  prefix, alertType: "exit_restart", instanceName, ...web,
@@ -16168,11 +16281,19 @@ export class FleetManager {
16168
16281
  * contain usernames or secret-adjacent text and stays in daemon.log.
16169
16282
  */
16170
16283
  async notifyInteractivePrompt(instanceName, kind) {
16171
- this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
16284
+ // #2910 D2: on a web-only fleet the assist prompt is in Needs you; there is no General to point to.
16285
+ if (!this.webOnlyPromptPlace())
16286
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
16172
16287
  // Multi-world fleets can have one General per bot/platform. Route the assist
16173
16288
  // request to the General bound to the same adapter as the blocked instance.
16174
16289
  const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
16175
16290
  if (!generalName) {
16291
+ // #2910 D4: a web-only fleet with no General to ask for help — and a password or sudo prompt is not answered from a
16292
+ // web page: a Notice says where it waits (its View, where the person types it).
16293
+ if (this.webOnlyPromptPlace()) {
16294
+ this.recordWebNotice("instance", t("interactive.web_only_notice", instanceName, this.interactivePromptLabel(kind)), instanceName);
16295
+ return;
16296
+ }
16176
16297
  this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
16177
16298
  return;
16178
16299
  }
@@ -16331,6 +16452,10 @@ export class FleetManager {
16331
16452
  * was gone; this is the fallback).
16332
16453
  */
16333
16454
  async postRemoteCard(instanceName, card) {
16455
+ // #2910 D4: a CLI's own dialog read as a card (selection-card.ts) is put to people only on a web-only fleet — the
16456
+ // dashboard; a chat fleet keeps its parked notice and its General's controls, as before.
16457
+ if (card.kind === "question" && card.webOnly && !this.webOnlyPromptPlace())
16458
+ return;
16334
16459
  this.retireRemoteCard(instanceName, null, t("card.answered_in_pane"));
16335
16460
  const place = this.remoteCardPlace(instanceName);
16336
16461
  if (!place) {
@@ -16660,6 +16785,9 @@ export class FleetManager {
16660
16785
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
16661
16786
  }, threadId ? { threadId } : undefined);
16662
16787
  publication.inFlight = false;
16788
+ // #2940: a status bubble of ours — never a chat-log line, for any bot of this fleet in the room; one a sibling
16789
+ // already logged (its gateway event can come before this answer) is taken out again.
16790
+ this.noteOwnStatusMessage(adapter, sent);
16663
16791
  const entry = {
16664
16792
  instanceName,
16665
16793
  adapterId,
@@ -18771,6 +18899,45 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18771
18899
  await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
18772
18900
  return true;
18773
18901
  }
18902
+ /** #2940: this fleet's own status bubbles and the messages the platform said were deleted (ids, never text). */
18903
+ ownStatusMessages = new OwnStatusMessages();
18904
+ /**
18905
+ * #2940: a status bubble of ours was posted: never logged, and a line already logged for it (by a sibling bot) goes.
18906
+ * Discord only (#2943 review: the only platform where our bots see each other's posts), keyed by channel and id.
18907
+ */
18908
+ noteOwnStatusMessage(adapter, sent) {
18909
+ if (adapter.type !== "discord")
18910
+ return;
18911
+ const key = discordMessageKey(sent.threadId ?? sent.chatId, sent.messageId);
18912
+ if (!key)
18913
+ return;
18914
+ this.ownStatusMessages.addStatus(key);
18915
+ const removed = ClassicChannelManager.loggedLines.remove(key);
18916
+ if (removed)
18917
+ this.logger.debug({ key, removed }, "A status bubble logged before its id was known was taken out of the chat log");
18918
+ }
18919
+ /** #2940: Discord says a message was deleted — its chat-log line goes, and it is never logged after this. */
18920
+ onPlatformMessageDeleted(e) {
18921
+ const key = discordMessageKey(e?.channelId, e?.messageId);
18922
+ if (!key)
18923
+ return;
18924
+ this.ownStatusMessages.markDeleted(key);
18925
+ const removed = ClassicChannelManager.loggedLines.remove(key);
18926
+ if (removed)
18927
+ this.logger.debug({ key, removed }, "A deleted message was taken out of the chat log");
18928
+ }
18929
+ /** #2940: the key a Discord message's chat-log line is kept under (none for another platform: nothing is tracked). */
18930
+ chatLogKey(msg) {
18931
+ return msg.source === "discord" ? discordMessageKey(msg.threadId ?? msg.chatId, msg.messageId) : undefined;
18932
+ }
18933
+ /** #2940: whether `msg` must not enter the chat log (only its key is looked at; the debug trail names keys, not text). */
18934
+ chatLogExcludes(instanceName, msg) {
18935
+ const key = this.chatLogKey(msg);
18936
+ if (!this.ownStatusMessages.excluded(key))
18937
+ return false;
18938
+ this.logger.debug({ instanceName, key }, "Not logged: a status bubble of this fleet, or a deleted message");
18939
+ return true;
18940
+ }
18774
18941
  /** Handle a message in a classic channel: log it, forward only /chat messages */
18775
18942
  async handleClassicChannelMessage(instanceName, msg) {
18776
18943
  const text = msg.text ?? "";
@@ -18814,7 +18981,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18814
18981
  : (msg.attachments?.length
18815
18982
  ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
18816
18983
  : "");
18817
- ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
18984
+ // #2940: one of this fleet's own status bubbles (any bot's), or a message already deleted, never enters the log.
18985
+ if (this.chatLogExcludes(instanceName, msg))
18986
+ return;
18987
+ ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText, this.chatLogKey(msg));
18818
18988
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
18819
18989
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
18820
18990
  // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
@@ -18915,7 +19085,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18915
19085
  const attachmentTag = saved ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
18916
19086
  : msg.attachments?.length ? ` [${msg.attachments.map(a => `📎 ${a.kind}${a.filename ? `: ${a.filename}` : ""}`).join(", ")}]`
18917
19087
  : "";
18918
- ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText);
19088
+ // #2940: likewise in a room without collab (a bot's message reaches here only through a /chat text command).
19089
+ if (!this.chatLogExcludes(instanceName, msg))
19090
+ ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText, this.chatLogKey(msg));
18919
19091
  // Bare attachment without /chat: save + log only, don't trigger agent
18920
19092
  if (!isChat) {
18921
19093
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
@@ -21624,7 +21796,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21624
21796
  this.reconcilePreviewListener();
21625
21797
  this.publicWebLink?.refresh();
21626
21798
  const newInstances = this.fleetConfig.instances;
21627
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
21799
+ const topicMode = daemonTopicMode(this.fleetConfig);
21628
21800
  // Only what a fresh process can adopt, and only relative to the signature
21629
21801
  // this process came up on: a Settings edit mutates this.fleetConfig in place
21630
21802
  // before the reload, so comparing the pre-load copy sees nothing at all.
@@ -23203,7 +23375,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23203
23375
  }
23204
23376
  const fleet = this.loadConfig(this.configPath);
23205
23377
  this.fleetConfig = fleet;
23206
- const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
23378
+ const topicMode = daemonTopicMode(fleet);
23207
23379
  const restartProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), restartStartedAt, this.logger);
23208
23380
  // Phase 1: generals first
23209
23381
  const restartEntries = Object.entries(fleet.instances);
@@ -23355,7 +23527,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23355
23527
  // Only notify when target is genuinely newer (semver), so a beta user on
23356
23528
  // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
23357
23529
  if (target && this.semverGt(target, currentVersion)) {
23358
- const generalId = this.findGeneralInstance();
23530
+ // #2910 D2 (#2925 review): a web-only fleet first — even with a General instance it has no chat for /update.
23531
+ const generalId = this.webOnlyPromptPlace() ? null : this.findGeneralInstance();
23359
23532
  if (generalId) {
23360
23533
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
23361
23534
  // link preview looked bad. Version + /update instruction is enough.
@@ -23366,6 +23539,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23366
23539
  if (key)
23367
23540
  this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
23368
23541
  }
23542
+ else if (this.webOnlyPromptPlace() && (fromChannel || updateNoticeKey(currentVersion, target))) {
23543
+ // #2910 D2: a web-only fleet: a Notice, once per version (the check runs daily); installed from this computer.
23544
+ const text = t("update.available_web_only", `v${target}`, `v${currentVersion}`);
23545
+ if (this.webNoticeStore()?.latest("update")?.text !== text)
23546
+ this.recordWebNotice("update", text);
23547
+ }
23369
23548
  }
23370
23549
  }
23371
23550
  catch { /* silent — network issues */ }
@@ -23679,7 +23858,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23679
23858
  if (decision.reason === "no-credential" && req.method === "GET"
23680
23859
  && String(req.headers.accept ?? "").includes("text/html")
23681
23860
  && isWebPageNavigation(requestPath)) {
23682
- serveSigninPage(res, 401);
23861
+ serveSigninPage(res, 401, { webOnly: this.isWebOnlyFleet() });
23683
23862
  return;
23684
23863
  }
23685
23864
  res.writeHead(decision.status);
@@ -23902,7 +24081,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23902
24081
  (async () => {
23903
24082
  let held = false;
23904
24083
  try {
23905
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
24084
+ const topicMode = daemonTopicMode(this.fleetConfig);
23906
24085
  await withSpawnHoldReply(async (message) => {
23907
24086
  held = true;
23908
24087
  res.writeHead(202, { "Content-Type": "application/json" });
@@ -24137,7 +24316,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
24137
24316
  }
24138
24317
  const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
24139
24318
  // #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
24140
- extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
24319
+ extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; },
24320
+ // #2911 (7): CDN libraries in previews, as fleet.yaml says now (a change applies to the next preview started).
24321
+ cdn: () => this.fleetConfig?.web?.preview_cdn === true });
24141
24322
  this.previewListener = listener;
24142
24323
  listener.server.on("error", (err) => {
24143
24324
  if (this.previewListener !== listener)
@@ -24357,6 +24538,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
24357
24538
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
24358
24539
  // shown here rather than in chat). null when normal, unknown or advisory.
24359
24540
  host_memory: this.hostMemoryIndicator(),
24541
+ // #2910 D2: a web-only fleet's newest Notice — a polling page (no SSE `notice`) reads the list again when it moves.
24542
+ ...(this.webOnlyPromptPlace() ? { notices_newest: this.webNoticeStore()?.newest() ?? 0 } : {}),
24360
24543
  // #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
24361
24544
  ...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
24362
24545
  };