@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.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 (150) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.js +68 -12
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/agent-token-header.d.ts +13 -0
  8. package/dist/agent-token-header.js +16 -0
  9. package/dist/agent-token-header.js.map +1 -0
  10. package/dist/backend/antigravity.js +4 -3
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/grok.js +1 -1
  13. package/dist/backend/grok.js.map +1 -1
  14. package/dist/backend/kiro.d.ts +7 -0
  15. package/dist/backend/kiro.js +82 -33
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/muse.js +1 -1
  18. package/dist/backend/muse.js.map +1 -1
  19. package/dist/backend/types.d.ts +6 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/cache-analysis.d.ts +92 -0
  22. package/dist/cache-analysis.js +147 -0
  23. package/dist/cache-analysis.js.map +1 -0
  24. package/dist/cache-ledger.d.ts +108 -0
  25. package/dist/cache-ledger.js +230 -0
  26. package/dist/cache-ledger.js.map +1 -0
  27. package/dist/cache-prices.d.ts +32 -0
  28. package/dist/cache-prices.js +73 -0
  29. package/dist/cache-prices.js.map +1 -0
  30. package/dist/cache-scan.d.ts +39 -0
  31. package/dist/cache-scan.js +115 -0
  32. package/dist/cache-scan.js.map +1 -0
  33. package/dist/cache-service.d.ts +115 -0
  34. package/dist/cache-service.js +417 -0
  35. package/dist/cache-service.js.map +1 -0
  36. package/dist/cli-entry.d.ts +19 -0
  37. package/dist/cli-entry.js +36 -0
  38. package/dist/cli-entry.js.map +1 -0
  39. package/dist/cli.js +251 -24
  40. package/dist/cli.js.map +1 -1
  41. package/dist/daemon.d.ts +6 -0
  42. package/dist/daemon.js +90 -37
  43. package/dist/daemon.js.map +1 -1
  44. package/dist/event-log.d.ts +4 -1
  45. package/dist/event-log.js +6 -3
  46. package/dist/event-log.js.map +1 -1
  47. package/dist/fleet-control-audit.d.ts +5 -0
  48. package/dist/fleet-control-audit.js +7 -0
  49. package/dist/fleet-control-audit.js.map +1 -1
  50. package/dist/fleet-level-config.js +22 -0
  51. package/dist/fleet-level-config.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +46 -0
  53. package/dist/fleet-manager.js +287 -40
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/full-restart.js +2 -3
  56. package/dist/full-restart.js.map +1 -1
  57. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  58. package/dist/install-lock.d.ts +49 -0
  59. package/dist/install-lock.js +200 -0
  60. package/dist/install-lock.js.map +1 -0
  61. package/dist/instance-config-impact.js +3 -2
  62. package/dist/instance-config-impact.js.map +1 -1
  63. package/dist/locale.js +14 -0
  64. package/dist/locale.js.map +1 -1
  65. package/dist/outbound-schemas.js +1 -1
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/package-preimage.d.ts +26 -0
  68. package/dist/package-preimage.js +96 -0
  69. package/dist/package-preimage.js.map +1 -0
  70. package/dist/public-web-gateway.js +2 -2
  71. package/dist/public-web-gateway.js.map +1 -1
  72. package/dist/quickstart.js +10 -4
  73. package/dist/quickstart.js.map +1 -1
  74. package/dist/restart-guard.d.ts +52 -0
  75. package/dist/restart-guard.js +109 -0
  76. package/dist/restart-guard.js.map +1 -0
  77. package/dist/scheduler/db.js +6 -2
  78. package/dist/scheduler/db.js.map +1 -1
  79. package/dist/service-activation.d.ts +47 -5
  80. package/dist/service-activation.js +156 -51
  81. package/dist/service-activation.js.map +1 -1
  82. package/dist/service-installer.d.ts +56 -1
  83. package/dist/service-installer.js +134 -12
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/service-plan.d.ts +65 -0
  86. package/dist/service-plan.js +108 -0
  87. package/dist/service-plan.js.map +1 -0
  88. package/dist/settings-api.js +46 -5
  89. package/dist/settings-api.js.map +1 -1
  90. package/dist/settings-change.js +34 -6
  91. package/dist/settings-change.js.map +1 -1
  92. package/dist/setup-host.js +2 -3
  93. package/dist/setup-host.js.map +1 -1
  94. package/dist/setup-wizard.js +2 -1
  95. package/dist/setup-wizard.js.map +1 -1
  96. package/dist/sqlite-open-errors.d.ts +12 -0
  97. package/dist/sqlite-open-errors.js +13 -0
  98. package/dist/sqlite-open-errors.js.map +1 -0
  99. package/dist/tmux-control.d.ts +22 -0
  100. package/dist/tmux-control.js +89 -5
  101. package/dist/tmux-control.js.map +1 -1
  102. package/dist/topic-commands.js +14 -5
  103. package/dist/topic-commands.js.map +1 -1
  104. package/dist/ui/chat-commands.js +43 -0
  105. package/dist/ui/chat-render.js +2 -2
  106. package/dist/ui/chat-store.js +30 -1
  107. package/dist/ui/chat-strings.js +22 -2
  108. package/dist/ui/chat-thread.js +47 -10
  109. package/dist/ui/fleet-cache.js +168 -0
  110. package/dist/ui/panel-chat.js +104 -18
  111. package/dist/ui/panel-fleet.js +26 -12
  112. package/dist/ui/panel-settings.js +12 -8
  113. package/dist/ui/preview-panel.js +202 -0
  114. package/dist/ui/preview.js +7 -3
  115. package/dist/ui/settings-strings.js +4 -2
  116. package/dist/ui/shared/app-route.js +1 -1
  117. package/dist/ui/shared/app-shell.js +6 -4
  118. package/dist/ui/shared/app.css +147 -3
  119. package/dist/ui/shared/signin.js +13 -1
  120. package/dist/ui/shared/ui-icons.js +2 -0
  121. package/dist/ui/signin.html +2 -1
  122. package/dist/update-dispatch.d.ts +28 -0
  123. package/dist/update-dispatch.js +73 -0
  124. package/dist/update-dispatch.js.map +1 -0
  125. package/dist/update-install.d.ts +65 -7
  126. package/dist/update-install.js +126 -26
  127. package/dist/update-install.js.map +1 -1
  128. package/dist/web-api.d.ts +14 -0
  129. package/dist/web-api.js +65 -0
  130. package/dist/web-api.js.map +1 -1
  131. package/dist/web-commands.d.ts +77 -0
  132. package/dist/web-commands.js +81 -0
  133. package/dist/web-commands.js.map +1 -0
  134. package/dist/web-shell-routes.d.ts +1 -1
  135. package/dist/web-shell-routes.js +1 -1
  136. package/dist/web-shell-routes.js.map +1 -1
  137. package/launcher/agend +110 -0
  138. package/launcher/agend-agent +110 -0
  139. package/launcher/agend-agent.cjs +4 -0
  140. package/launcher/agend.cjs +4 -0
  141. package/launcher/install-admission.cjs +105 -0
  142. package/launcher/launch.cjs +64 -0
  143. package/launcher/postinstall.cjs +114 -0
  144. package/launcher/run-in-process.cjs +13 -0
  145. package/launcher/runtime-platform.cjs +86 -0
  146. package/launcher/runtime-select.cjs +217 -0
  147. package/package.json +12 -4
  148. package/scripts/preinstall-guard.cjs +40 -21
  149. package/templates/launchd.plist.ejs +2 -2
  150. package/templates/systemd.service.ejs +1 -1
@@ -39,6 +39,7 @@ import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS,
39
39
  import { isProbeableRouteTarget } from "./fleet-context.js";
40
40
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
41
41
  import { EventLog } from "./event-log.js";
42
+ import { classifySqliteOpenError } from "./sqlite-open-errors.js";
42
43
  import { AdapterWorld } from "./adapter-world.js";
43
44
  import { CostGuard, formatCents } from "./cost-guard.js";
44
45
  import { startEventLoopWatch } from "./event-loop-watch.js";
@@ -129,7 +130,8 @@ import { tightenInstanceDirs } from "./private-dir.js";
129
130
  import { decideSlash } from "./slash-authz.js";
130
131
  import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
131
132
  import { runVisibilityCommand } from "./cross-instance-notice.js";
132
- import { UPDATE_COMMAND, installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
133
+ import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
134
+ import { resolveInstalledAgend, updateCommand } from "./update-dispatch.js";
133
135
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
134
136
  import { runBeforeDeadline } from "./deadline.js";
135
137
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -156,6 +158,10 @@ import { NeedsYouHub } from "./needs-you-hub.js";
156
158
  import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
157
159
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
158
160
  import { buildOrgChart } from "./web-org.js";
161
+ import { CacheService, WINDOWS } from "./cache-service.js";
162
+ import { runWebCommand } from "./web-commands.js";
163
+ import { claudeProjectKey } from "./backend/claude-code.js";
164
+ import { sharedRolloutIndex } from "./rollout-index.js";
159
165
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
160
166
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
161
167
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
@@ -1898,11 +1904,16 @@ export class FleetManager {
1898
1904
  const { spawn } = await import("node:child_process");
1899
1905
  // Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
1900
1906
  // stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
1901
- // "not a beta" and sent a beta install to @latest.
1902
- const command = UPDATE_COMMAND;
1907
+ // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
1908
+ const installed = await resolveInstalledAgend();
1909
+ if (!installed.ok) {
1910
+ this.failUpdateProgress(`/update cannot verify the installed AgEnD (${installed.reason}). Run \`agend update\` from a shell.`);
1911
+ return;
1912
+ }
1903
1913
  const origin = `slash /update by ${adapterId}:${data.userId}`;
1904
1914
  recordInternalRequest(this.dataDir, "update", origin);
1905
- const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
1915
+ const { command, args } = updateCommand(installed.agend);
1916
+ const child = spawn(command, args, {
1906
1917
  detached: true, stdio: "ignore", env: withOrigin(origin),
1907
1918
  });
1908
1919
  child.once("error", err => this.failUpdateProgress(err.message));
@@ -8581,6 +8592,17 @@ export class FleetManager {
8581
8592
  this.writeFleetConfigBackup(source);
8582
8593
  const tempPath = `${this.configPath}.tmp-${process.pid}`;
8583
8594
  writeFileSync(tempPath, output, "utf-8");
8595
+ // #1056 (Fable's 2.2 audit): the patcher writes leaves, not configs, so a wrong patch can produce a file the
8596
+ // validator — or the next start — refuses (a bare channels[0], a channels list with a null). Load and validate
8597
+ // what is about to replace fleet.yaml; a result that adds an error is not written, and the save fails instead.
8598
+ const refusal = this.savedFleetConfigProblem(tempPath);
8599
+ if (refusal) {
8600
+ try {
8601
+ unlinkSync(tempPath);
8602
+ }
8603
+ catch { /* already gone */ }
8604
+ throw new Error(refusal);
8605
+ }
8584
8606
  if (existsSync(this.configPath))
8585
8607
  chmodSync(tempPath, statSync(this.configPath).mode);
8586
8608
  renameSync(tempPath, this.configPath);
@@ -8588,6 +8610,27 @@ export class FleetManager {
8588
8610
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
8589
8611
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
8590
8612
  }
8613
+ /** Why the config written to `candidate` must not replace fleet.yaml, or null: it does not load, or it adds errors. */
8614
+ savedFleetConfigProblem(candidate) {
8615
+ let after;
8616
+ try {
8617
+ after = validateFleetConfig(loadFleetConfig(candidate));
8618
+ }
8619
+ catch (err) {
8620
+ return `Refusing to save fleet.yaml: the result would not load (${err.message})`;
8621
+ }
8622
+ let had = new Set();
8623
+ try {
8624
+ if (this.configPath && existsSync(this.configPath)) {
8625
+ had = new Set(validateFleetConfig(loadFleetConfig(this.configPath)).errors.map(e => `${e.path}\u0000${e.message}`));
8626
+ }
8627
+ }
8628
+ catch { /* the current file does not load: every error of the result counts as new */ }
8629
+ const introduced = after.errors.filter(e => !had.has(`${e.path}\u0000${e.message}`));
8630
+ return introduced.length
8631
+ ? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
8632
+ : null;
8633
+ }
8591
8634
  /** One-time upgrade migration; invalid YAML is never rewritten. */
8592
8635
  slimFleetConfigAtStartup() {
8593
8636
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
@@ -8646,6 +8689,20 @@ export class FleetManager {
8646
8689
  }
8647
8690
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8648
8691
  if (isRecord(before) && isRecord(after)) {
8692
+ // #1056: a legacy `channel` is being migrated (the caller removed it and kept `channels`). The file has no
8693
+ // `channels` for the leaf patch below to land on — the snapshot's list is only the normalized alias — so a bare
8694
+ // channels[0] holding just the changed field would be written while `channel` is deleted. Move the connection
8695
+ // as it is in the file NOW (a concurrent or manual edit, an option AgEnD does not know) into channels[0]
8696
+ // first; the patch then applies only this save's own changes on top, with the usual identity/order check.
8697
+ if (path.length === 0 && this.rawFleetConfig.channel && !this.rawFleetConfig.channels
8698
+ && after.channel === undefined && Array.isArray(after.channels) && !document.hasIn(["channels"])) {
8699
+ const fresh = document.getIn(["channel"], true);
8700
+ if (isMap(fresh)) {
8701
+ const list = document.createNode([]);
8702
+ list.items.push(fresh.clone());
8703
+ document.setIn(["channels"], list);
8704
+ }
8705
+ }
8649
8706
  const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
8650
8707
  for (const key of keys) {
8651
8708
  // `channel` is a derived alias when the raw file uses `channels`.
@@ -8790,6 +8847,21 @@ export class FleetManager {
8790
8847
  * to a plain add (never worse than the old behaviour).
8791
8848
  */
8792
8849
  lastStatusEmoji = new Map();
8850
+ /**
8851
+ * #1056: every status emoji each bot really stamped, per `${adapterId}:${messageId}` (match keys). A connection's
8852
+ * status set can change while AgEnD runs, so "is this a stamp?" cannot be answered from the current set alone: a
8853
+ * bot's earlier stamp in the old emoji must still be plumbing, not an agent's reaction. Unbounded for the same
8854
+ * reason as lastStatusEmoji (a few short strings per stamped message).
8855
+ */
8856
+ stampedStatus = new Map();
8857
+ noteStamped(adapter, messageId, emoji) {
8858
+ const adapterId = typeof adapter.id === "string" ? adapter.id : "?";
8859
+ const k = `${adapterId}:${messageId}`;
8860
+ let set = this.stampedStatus.get(k);
8861
+ if (!set)
8862
+ this.stampedStatus.set(k, set = new Set());
8863
+ set.add(statusMatchKey(emoji));
8864
+ }
8793
8865
  /**
8794
8866
  * One in-flight status update per bot+message: updates run strictly in call
8795
8867
  * order, so a delayed ❌ add can never land after a newer ✅'s removal —
@@ -8812,7 +8884,7 @@ export class FleetManager {
8812
8884
  */
8813
8885
  resolveStatusEmojisFor(instanceName, adapterId, adapter) {
8814
8886
  const worldId = adapterId ?? this.getInstanceAdapterId(instanceName);
8815
- const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
8887
+ const channel = this.statusEmojiChannel(worldId);
8816
8888
  // The adapter that will react decides the vocabulary: a Telegram bot gets
8817
8889
  // Telegram's reaction set even if the channel lookup fell back elsewhere.
8818
8890
  const reacting = adapter ?? (worldId ? this.worlds.get(worldId)?.adapter : undefined) ?? this.adapter;
@@ -8833,6 +8905,22 @@ export class FleetManager {
8833
8905
  return { ...resolved, platform };
8834
8906
  }
8835
8907
  warnedStatusEmojis = new Set();
8908
+ /**
8909
+ * The connection whose `options.status_emojis` an instance on `worldId` stamps with. #1056: read from the live config
8910
+ * first — a Settings save replaces `fleetConfig.channels`, and the running world keeps the object it started with —
8911
+ * so a change to the connection's emojis applies at the next stamp, as an instance's own override does. Only the
8912
+ * emoji options are taken from it: the platform is still the running adapter's (resolveStatusEmojisFor).
8913
+ */
8914
+ statusEmojiChannel(worldId) {
8915
+ const started = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
8916
+ if (!worldId)
8917
+ return started;
8918
+ const list = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
8919
+ const live = list.find(ch => (ch.id ?? ch.type) === worldId);
8920
+ if (!live || !started)
8921
+ return live ?? started;
8922
+ return { ...started, options: { ...(started.options ?? {}), status_emojis: live.options?.status_emojis } };
8923
+ }
8836
8924
  /**
8837
8925
  * A Discord connection's server emojis for the Settings picker (#1005), from
8838
8926
  * every server its bot can draw on (#1021): the primary `group_id` server,
@@ -8922,11 +9010,18 @@ export class FleetManager {
8922
9010
  return { error: this.personaEmojiMissing(instanceName) };
8923
9011
  const worldId = this.getInstanceAdapterId(instanceName);
8924
9012
  const { platform } = this.resolveStatusEmojisFor(instanceName);
8925
- const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
9013
+ const channel = this.statusEmojiChannel(worldId);
8926
9014
  const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self?.status_emojis });
8927
9015
  const out = {
8928
9016
  platform: platform ?? null,
8929
- statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
9017
+ // #1056: progress_prefix is the emoji at the start of the progress message, not a reaction stamp.
9018
+ statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source, kind: e.key === "progress_prefix" ? "text_prefix" : "reaction" })),
9019
+ // #1056: say inline why the lists differ by platform, so an agent need not know it already.
9020
+ platform_note: platform === "telegram"
9021
+ ? "Telegram has no server custom emoji, so there is no server_emojis list: standard.reactions is the complete set a status reaction can use. progress_prefix is message text, not a reaction, so any single emoji works there."
9022
+ : platform === "discord"
9023
+ ? "A status reaction can be any single emoji, or a server emoji from server_emojis as <:name:id> (one of the servers this bot is in). progress_prefix is message text, not a reaction."
9024
+ : "This instance has no chat connection, so no platform rules apply yet.",
8930
9025
  standard: platform === "telegram"
8931
9026
  ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
8932
9027
  : { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
@@ -9365,6 +9460,9 @@ export class FleetManager {
9365
9460
  const reactorWorlds = new Set(worlds.filter(w => w.botUserId === r.userId).map(w => w.id));
9366
9461
  if (reactorWorlds.size === 0)
9367
9462
  return false; // a human, or a bot outside this fleet
9463
+ // #1056: what this bot really stamped on this message, whatever its status set says now.
9464
+ if ([...reactorWorlds].some(id => this.stampedStatus.get(`${id}:${r.messageId}`)?.has(key)))
9465
+ return true;
9368
9466
  return [...names]
9369
9467
  .filter(n => reactorWorlds.has(this.getInstanceAdapterId(n) ?? ""))
9370
9468
  .some(n => statusMatchKeys(this.resolveStatusEmojisFor(n)).includes(key));
@@ -9372,6 +9470,8 @@ export class FleetManager {
9372
9470
  // Fallback: the built-in ladder (as before #1005) plus every value an
9373
9471
  // operator configured. Not the Telegram built-ins — 👎 is AgEnD's failed
9374
9472
  // stamp there, but also far too common a human reaction to swallow blind.
9473
+ if (worlds.some(w => this.stampedStatus.get(`${w.id}:${r.messageId}`)?.has(key)))
9474
+ return true; // #1056, as above
9375
9475
  const keys = new Set(statusMatchKeys(builtinStatusEmojis(undefined)));
9376
9476
  for (const name of names) {
9377
9477
  const resolved = this.resolveStatusEmojisFor(name);
@@ -9394,9 +9494,15 @@ export class FleetManager {
9394
9494
  const emoji = this.receivedReactionFor(instanceName, adapter, msg.adapterId);
9395
9495
  // Classic's system receipt is status-owned too. An ordinary react call
9396
9496
  // would label it as an agent/other reaction and suppress later statuses.
9497
+ // #1056: recorded as this bot's stamp like the delivery statuses (provenance survives a status-set change) —
9498
+ // on Telegram once the adapter took the slot, on Discord before the add (its gateway can echo it first).
9397
9499
  if (adapter instanceof TelegramAdapter) {
9398
- return adapter.reactDeliveryStatus(msg.chatId, msg.messageId, emoji, msg.timestamp.getTime()).then(() => { });
9500
+ return adapter.reactDeliveryStatus(msg.chatId, msg.messageId, emoji, msg.timestamp.getTime()).then((took) => {
9501
+ if (took)
9502
+ this.noteStamped(adapter, msg.messageId, emoji);
9503
+ });
9399
9504
  }
9505
+ this.noteStamped(adapter, msg.messageId, emoji);
9400
9506
  return adapter.react(msg.threadId ?? msg.chatId, msg.messageId, emoji);
9401
9507
  }
9402
9508
  reactClassicForwardedAttachment(instanceName, adapter, msg, kind) {
@@ -9482,8 +9588,10 @@ export class FleetManager {
9482
9588
  if (await adapter.reactDeliveryStatus(chatId, messageId, emoji, receivedAt)) {
9483
9589
  if (emoji == null)
9484
9590
  this.lastStatusEmoji.delete(key);
9485
- else
9591
+ else {
9486
9592
  this.lastStatusEmoji.set(key, { emoji, status });
9593
+ this.noteStamped(adapter, messageId, emoji);
9594
+ }
9487
9595
  }
9488
9596
  return;
9489
9597
  }
@@ -9507,6 +9615,8 @@ export class FleetManager {
9507
9615
  // Best effort: a failed removal must not block the new status.
9508
9616
  await adapter.unreact(target, messageId, last.emoji, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
9509
9617
  }
9618
+ // Recorded before the add: Discord can report the reaction back before react() resolves.
9619
+ this.noteStamped(adapter, messageId, emoji);
9510
9620
  await adapter.react(target, messageId, emoji, threadId);
9511
9621
  this.lastStatusEmoji.set(key, { emoji, status });
9512
9622
  }
@@ -9694,13 +9804,28 @@ export class FleetManager {
9694
9804
  wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) {
9695
9805
  return this.deliveryOutbox?.wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) ?? false;
9696
9806
  }
9807
+ /** How long opening events.db waits for another process's lock (a field so tests need not wait 5 s). */
9808
+ eventLogBusyTimeoutMs = 5000;
9697
9809
  openEventLog() {
9698
9810
  const dbPath = join(this.dataDir, "events.db");
9699
9811
  try {
9700
- return new EventLog(dbPath);
9812
+ return new EventLog(dbPath, { busyTimeoutMs: this.eventLogBusyTimeoutMs });
9701
9813
  }
9702
9814
  catch (err) {
9703
- this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
9815
+ // #1490: only a file SQLite proved corrupt is moved aside. A lock (it outlasted the busy timeout), a driver that
9816
+ // cannot load (ABI), or a permission/I/O problem says nothing against the file: it stays where it is, history
9817
+ // intact, and the fleet runs without event logging until it restarts.
9818
+ const kind = classifySqliteOpenError(err);
9819
+ if (kind !== "corrupt") {
9820
+ const key = kind === "busy" ? "eventlog.locked" : kind === "abi" ? "eventlog.abi" : "eventlog.unopenable";
9821
+ this.logger.error({ err, dbPath, kind }, `events.db not opened (${kind}) — left in place; continuing without event logging`);
9822
+ try {
9823
+ this.notifyFleetError(t(key));
9824
+ }
9825
+ catch { /* best effort: adapters may not be up yet; the log line stands */ }
9826
+ return null;
9827
+ }
9828
+ this.logger.error({ err, dbPath }, "events.db is corrupt — moving it aside and starting a fresh one");
9704
9829
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
9705
9830
  for (const suffix of ["", "-wal", "-shm"]) {
9706
9831
  try {
@@ -9709,7 +9834,7 @@ export class FleetManager {
9709
9834
  catch { /* may not exist */ }
9710
9835
  }
9711
9836
  try {
9712
- return new EventLog(dbPath);
9837
+ return new EventLog(dbPath, { busyTimeoutMs: this.eventLogBusyTimeoutMs });
9713
9838
  }
9714
9839
  catch (retryErr) {
9715
9840
  // History is worth losing; a fleet that won't start is not.
@@ -10253,6 +10378,38 @@ export class FleetManager {
10253
10378
  },
10254
10379
  });
10255
10380
  }
10381
+ cacheService = null;
10382
+ /**
10383
+ * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
10384
+ * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
10385
+ * (cache-service.ts) — never a vendor call, never a synchronous transcript read.
10386
+ */
10387
+ cacheReport(window) {
10388
+ const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
10389
+ this.cacheService ??= new CacheService({
10390
+ // fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
10391
+ instances: () => {
10392
+ const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
10393
+ name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
10394
+ }));
10395
+ const taken = new Set(fleet.map(i => i.name));
10396
+ const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
10397
+ name: ch.instanceName,
10398
+ backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
10399
+ workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
10400
+ ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
10401
+ }));
10402
+ return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
10403
+ },
10404
+ claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
10405
+ claudeKey: claudeProjectKey,
10406
+ codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
10407
+ listRollouts: root => sharedRolloutIndex(root).list(),
10408
+ metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
10409
+ log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
10410
+ });
10411
+ return this.cacheService.report(w);
10412
+ }
10256
10413
  /** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
10257
10414
  acknowledgeNeedsItem(id, principal) {
10258
10415
  if (!this.needsYou)
@@ -10680,29 +10837,18 @@ export class FleetManager {
10680
10837
  });
10681
10838
  return nonce ? null : t("clear.prompt_unavailable");
10682
10839
  }
10683
- /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
10684
- async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
10685
- const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
10686
- if (claimed === null)
10687
- return false;
10688
- if (claimed === "consumed")
10689
- return true;
10690
- const { entry: pending, action } = claimed;
10691
- this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
10692
- if (action === "cancel") {
10693
- await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
10694
- return true;
10695
- }
10696
- // The nonce was claimed synchronously. Keep the exact owner across platform
10697
- // retirement awaits; a replacement with the same name is not this clear's
10698
- // target, and a role granted at claim time may be revoked while editing.
10699
- const ipc = this.instanceIpcClients.get(pending.instanceName);
10700
- const daemon = this.daemons.get(pending.instanceName);
10701
- const epoch = this.getDeliveryEpoch(pending.instanceName);
10702
- const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
10840
+ /**
10841
+ * What a confirmed /clear must still find when it acts — shared by the platform's Confirm button and the web
10842
+ * (#1269): the same daemon and IPC client, the same interaction owner, lifecycle epoch and delivery epoch as when the
10843
+ * fence was taken. Null when there is no owner to pin (then nothing may be cleared). The reads are cached and
10844
+ * synchronous; they never probe the pane.
10845
+ */
10846
+ clearTargetFence(instanceName) {
10847
+ const ipc = this.instanceIpcClients.get(instanceName);
10848
+ const daemon = this.daemons.get(instanceName);
10849
+ const epoch = this.getDeliveryEpoch(instanceName);
10703
10850
  // Object identity survives a resident daemon's respawn/freeze, and stop
10704
10851
  // invalidates its lifecycle epoch before the queued work replaces objects.
10705
- // These reads are cached and synchronous; they never probe the pane.
10706
10852
  const readOwner = () => {
10707
10853
  try {
10708
10854
  const owner = daemon?.getInteractionSnapshot?.()?.owner;
@@ -10718,7 +10864,7 @@ export class FleetManager {
10718
10864
  };
10719
10865
  const readLifecycleEpoch = () => {
10720
10866
  try {
10721
- const value = this.lifecycle?.epochOf(pending.instanceName);
10867
+ const value = this.lifecycle?.epochOf(instanceName);
10722
10868
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
10723
10869
  }
10724
10870
  catch {
@@ -10726,8 +10872,112 @@ export class FleetManager {
10726
10872
  }
10727
10873
  };
10728
10874
  const owner = readOwner(), lifecycleEpoch = readLifecycleEpoch();
10875
+ if (owner === null || lifecycleEpoch === null)
10876
+ return null;
10877
+ return () => {
10878
+ try {
10879
+ const currentOwner = readOwner();
10880
+ return this.daemons.get(instanceName) === daemon
10881
+ && this.instanceIpcClients.get(instanceName) === ipc
10882
+ && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
10883
+ && readLifecycleEpoch() === lifecycleEpoch
10884
+ && this.isDeliveryEpochCurrent(instanceName, epoch);
10885
+ }
10886
+ catch {
10887
+ return false;
10888
+ }
10889
+ };
10890
+ }
10891
+ /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
10892
+ webClearTokens = new Map();
10893
+ /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
10894
+ webCommand(input, opts) {
10895
+ const webMeta = (instance) => {
10896
+ const topicId = this.fleetConfig?.instances[instance]?.topic_id;
10897
+ return {
10898
+ chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
10899
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
10900
+ };
10901
+ };
10902
+ const choicesFromCache = (instance) => {
10903
+ const cached = this.readCliEnv(this.backendNameForInstance(instance)); // the cached catalog only: never a probe here
10904
+ if (!cached?.models.length)
10905
+ return null;
10906
+ const current = this.resolveInstanceModel(instance).model;
10907
+ return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
10908
+ };
10909
+ return runWebCommand({
10910
+ // Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
10911
+ scope: (instance) => {
10912
+ const instances = this.fleetConfig?.instances;
10913
+ if (instances && Object.prototype.hasOwnProperty.call(instances, instance))
10914
+ return isGeneralInstance(this.fleetConfig, instance) ? "general" : "fleet";
10915
+ return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
10916
+ },
10917
+ ctx: (instance) => this.topicCommands.getCtxText(instance),
10918
+ compact: (instance, instructions) => this.topicCommands.sendCompact(instance, instructions),
10919
+ applyModel: (instance, name) => this.applyModel(instance, name),
10920
+ modelChoices: choicesFromCache,
10921
+ applyEffort: (instance, level) => this.applyEffort(instance, level),
10922
+ effortChoices: (instance) => {
10923
+ const levels = this.effortLevelsFor(instance);
10924
+ if (!levels.length)
10925
+ return null;
10926
+ const current = this.resolveInstanceEffort(instance).effort;
10927
+ return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
10928
+ },
10929
+ cancel: (instance) => this.cancelInstance(instance),
10930
+ steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
10931
+ btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
10932
+ pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
10933
+ save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
10934
+ clearAsk: (instance) => {
10935
+ if (!this.topicCommands.supportsClear(instance))
10936
+ return { refused: t("clear.unsupported") };
10937
+ const fence = this.clearTargetFence(instance);
10938
+ if (!fence)
10939
+ return { refused: t("clear.not_connected") };
10940
+ const now = performance.now();
10941
+ for (const [k, v] of this.webClearTokens)
10942
+ if (v.deadline < now)
10943
+ this.webClearTokens.delete(k);
10944
+ const token = randomBytes(16).toString("hex");
10945
+ this.webClearTokens.set(token, { instance, fence, deadline: now + CLEAR_CONFIRM_TIMEOUT_MS });
10946
+ return { token, message: t("clear.confirm_message", instance) };
10947
+ },
10948
+ clearConfirm: async (instance, token) => {
10949
+ const entry = this.webClearTokens.get(token);
10950
+ this.webClearTokens.delete(token); // used once, whatever happens next
10951
+ if (!entry || entry.instance !== instance || performance.now() > entry.deadline)
10952
+ return { status: 409, text: t("clear.expired", instance) };
10953
+ this.eventLog?.insert(instance, "clear_action", { action: "confirm", userId: "web" });
10954
+ // sendClear sends its first IPC synchronously: no await separates this check from the effect.
10955
+ if (this.shuttingDown || !entry.fence())
10956
+ return { status: 409, text: t("menu.click_stale") };
10957
+ return { status: 200, text: await this.topicCommands.sendClear(instance) };
10958
+ },
10959
+ t: (key, ...args) => t(key, ...args),
10960
+ }, input, opts);
10961
+ }
10962
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
10963
+ async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
10964
+ const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
10965
+ if (claimed === null)
10966
+ return false;
10967
+ if (claimed === "consumed")
10968
+ return true;
10969
+ const { entry: pending, action } = claimed;
10970
+ this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
10971
+ if (action === "cancel") {
10972
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
10973
+ return true;
10974
+ }
10975
+ // The nonce was claimed synchronously. Keep the exact owner across platform
10976
+ // retirement awaits; a replacement with the same name is not this clear's
10977
+ // target, and a role granted at claim time may be revoked while editing.
10978
+ const target = this.clearTargetFence(pending.instanceName);
10979
+ const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
10729
10980
  const current = () => {
10730
- const currentOwner = readOwner();
10731
10981
  try {
10732
10982
  return !this.shuttingDown
10733
10983
  && !!data.userId && !!pending.authChannelId
@@ -10739,11 +10989,7 @@ export class FleetManager {
10739
10989
  ? pending.adapter.type !== "telegram" || pending.chatId === pending.authChannelId
10740
10990
  : String(this.getChannelConfig(callbackAdapterId)?.group_id ?? "") === pending.chatId)
10741
10991
  && this.getChannelConfig(callbackAdapterId)?.group_id === groupId
10742
- && this.daemons.get(pending.instanceName) === daemon
10743
- && this.instanceIpcClients.get(pending.instanceName) === ipc
10744
- && owner !== null && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
10745
- && lifecycleEpoch !== null && readLifecycleEpoch() === lifecycleEpoch
10746
- && this.isDeliveryEpochCurrent(pending.instanceName, epoch);
10992
+ && target !== null && target();
10747
10993
  }
10748
10994
  catch {
10749
10995
  return false;
@@ -14623,6 +14869,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14623
14869
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
14624
14870
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
14625
14871
  const needsStopped = this.stopNeedsYou();
14872
+ this.cacheService?.stop();
14626
14873
  const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
14627
14874
  this.ipcStoppingInstances.add("__fleet_stopping__");
14628
14875
  // Release held delivery promises before awaiting daemon shutdown, then