@songsid/agend 2.1.10 → 2.1.11-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 (154) hide show
  1. package/dist/backend/antigravity.js +3 -2
  2. package/dist/backend/antigravity.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +7 -0
  4. package/dist/backend/claude-code.js +107 -3
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/cli-env-probe-worker.js +2 -10
  7. package/dist/backend/cli-env-probe-worker.js.map +1 -1
  8. package/dist/backend/cli-env-probe.d.ts +14 -0
  9. package/dist/backend/cli-env-probe.js +13 -0
  10. package/dist/backend/cli-env-probe.js.map +1 -0
  11. package/dist/backend/codex-metadata.d.ts +14 -0
  12. package/dist/backend/codex-metadata.js +70 -0
  13. package/dist/backend/codex-metadata.js.map +1 -0
  14. package/dist/backend/codex.d.ts +1 -2
  15. package/dist/backend/codex.js +112 -65
  16. package/dist/backend/codex.js.map +1 -1
  17. package/dist/backend/effort-metadata.d.ts +32 -0
  18. package/dist/backend/effort-metadata.js +21 -0
  19. package/dist/backend/effort-metadata.js.map +1 -0
  20. package/dist/backend/grok.js +3 -2
  21. package/dist/backend/grok.js.map +1 -1
  22. package/dist/backend/kiro-engine-ledger.d.ts +36 -0
  23. package/dist/backend/kiro-engine-ledger.js +113 -0
  24. package/dist/backend/kiro-engine-ledger.js.map +1 -0
  25. package/dist/backend/kiro-kas-store.d.ts +11 -0
  26. package/dist/backend/kiro-kas-store.js +67 -0
  27. package/dist/backend/kiro-kas-store.js.map +1 -0
  28. package/dist/backend/kiro-v3-identity.d.ts +28 -0
  29. package/dist/backend/kiro-v3-identity.js +213 -0
  30. package/dist/backend/kiro-v3-identity.js.map +1 -0
  31. package/dist/backend/kiro.d.ts +10 -0
  32. package/dist/backend/kiro.js +53 -5
  33. package/dist/backend/kiro.js.map +1 -1
  34. package/dist/backend/muse.js +3 -2
  35. package/dist/backend/muse.js.map +1 -1
  36. package/dist/backend/opencode-permission.d.ts +49 -0
  37. package/dist/backend/opencode-permission.js +181 -0
  38. package/dist/backend/opencode-permission.js.map +1 -0
  39. package/dist/backend/opencode.d.ts +37 -4
  40. package/dist/backend/opencode.js +112 -25
  41. package/dist/backend/opencode.js.map +1 -1
  42. package/dist/backend/types.d.ts +29 -1
  43. package/dist/backend/types.js.map +1 -1
  44. package/dist/channel/adapters/discord.js +35 -26
  45. package/dist/channel/adapters/discord.js.map +1 -1
  46. package/dist/channel/ipc-bridge.d.ts +1 -0
  47. package/dist/channel/ipc-bridge.js +1 -1
  48. package/dist/channel/ipc-bridge.js.map +1 -1
  49. package/dist/channel/mcp-tools.js +2 -0
  50. package/dist/channel/mcp-tools.js.map +1 -1
  51. package/dist/cli.js +48 -6
  52. package/dist/cli.js.map +1 -1
  53. package/dist/command-table.d.ts +99 -0
  54. package/dist/command-table.js +125 -0
  55. package/dist/command-table.js.map +1 -0
  56. package/dist/config.js +1 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/daemon.d.ts +60 -0
  59. package/dist/daemon.js +274 -14
  60. package/dist/daemon.js.map +1 -1
  61. package/dist/delivery-outbox.d.ts +16 -0
  62. package/dist/delivery-outbox.js +55 -6
  63. package/dist/delivery-outbox.js.map +1 -1
  64. package/dist/doctor.d.ts +3 -1
  65. package/dist/doctor.js +11 -0
  66. package/dist/doctor.js.map +1 -1
  67. package/dist/e2e-isolation.d.ts +122 -0
  68. package/dist/e2e-isolation.js +386 -0
  69. package/dist/e2e-isolation.js.map +1 -0
  70. package/dist/fleet-context.d.ts +2 -2
  71. package/dist/fleet-control-audit.d.ts +83 -0
  72. package/dist/fleet-control-audit.js +195 -0
  73. package/dist/fleet-control-audit.js.map +1 -0
  74. package/dist/fleet-manager.d.ts +69 -35
  75. package/dist/fleet-manager.js +596 -845
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/full-restart.js +2 -1
  78. package/dist/full-restart.js.map +1 -1
  79. package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -2
  80. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
  81. package/dist/host-memory.d.ts +16 -0
  82. package/dist/host-memory.js +32 -0
  83. package/dist/host-memory.js.map +1 -0
  84. package/dist/instance-lifecycle.d.ts +2 -0
  85. package/dist/instance-lifecycle.js +18 -1
  86. package/dist/instance-lifecycle.js.map +1 -1
  87. package/dist/kiro-engine-status.d.ts +71 -0
  88. package/dist/kiro-engine-status.js +68 -0
  89. package/dist/kiro-engine-status.js.map +1 -0
  90. package/dist/locale.js +72 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/log-tail.d.ts +34 -0
  93. package/dist/log-tail.js +66 -0
  94. package/dist/log-tail.js.map +1 -0
  95. package/dist/logger.d.ts +15 -0
  96. package/dist/logger.js +89 -1
  97. package/dist/logger.js.map +1 -1
  98. package/dist/login-controller.d.ts +1 -3
  99. package/dist/login-controller.js +2 -6
  100. package/dist/login-controller.js.map +1 -1
  101. package/dist/login-flows.d.ts +0 -8
  102. package/dist/login-flows.js +11 -11
  103. package/dist/login-flows.js.map +1 -1
  104. package/dist/login-manager.d.ts +5 -20
  105. package/dist/login-manager.js +0 -53
  106. package/dist/login-manager.js.map +1 -1
  107. package/dist/login-window-lock.d.ts +1 -1
  108. package/dist/login-window-lock.js +1 -1
  109. package/dist/memory-pressure.d.ts +41 -0
  110. package/dist/memory-pressure.js +117 -0
  111. package/dist/memory-pressure.js.map +1 -0
  112. package/dist/outbound-handlers.js +16 -1
  113. package/dist/outbound-handlers.js.map +1 -1
  114. package/dist/outbound-schemas.d.ts +3 -0
  115. package/dist/outbound-schemas.js +3 -0
  116. package/dist/outbound-schemas.js.map +1 -1
  117. package/dist/paths.d.ts +19 -0
  118. package/dist/paths.js +18 -5
  119. package/dist/paths.js.map +1 -1
  120. package/dist/probe-worker-pool.d.ts +28 -0
  121. package/dist/probe-worker-pool.js +129 -0
  122. package/dist/probe-worker-pool.js.map +1 -0
  123. package/dist/resource-report.d.ts +31 -0
  124. package/dist/resource-report.js +209 -0
  125. package/dist/resource-report.js.map +1 -0
  126. package/dist/rollout-index.d.ts +66 -0
  127. package/dist/rollout-index.js +124 -0
  128. package/dist/rollout-index.js.map +1 -0
  129. package/dist/slash-authz.d.ts +50 -0
  130. package/dist/slash-authz.js +19 -0
  131. package/dist/slash-authz.js.map +1 -0
  132. package/dist/spawn-gate.d.ts +13 -1
  133. package/dist/spawn-gate.js +108 -39
  134. package/dist/spawn-gate.js.map +1 -1
  135. package/dist/storm-window.d.ts +42 -0
  136. package/dist/storm-window.js +84 -1
  137. package/dist/storm-window.js.map +1 -1
  138. package/dist/tool-permissions.js +4 -2
  139. package/dist/tool-permissions.js.map +1 -1
  140. package/dist/topic-commands.d.ts +29 -2
  141. package/dist/topic-commands.js +77 -21
  142. package/dist/topic-commands.js.map +1 -1
  143. package/dist/transcript-sources.d.ts +21 -4
  144. package/dist/transcript-sources.js +55 -41
  145. package/dist/transcript-sources.js.map +1 -1
  146. package/dist/types.d.ts +5 -1
  147. package/dist/ui/settings.html +14 -13
  148. package/dist/ui/web-terminal/terminal-input.js +69 -0
  149. package/dist/ui/web-terminal/terminal.css +6 -0
  150. package/dist/ui/web-terminal/terminal.html +9 -1
  151. package/dist/ui/web-terminal/terminal.js +31 -2
  152. package/dist/web-terminal-http.js +1 -0
  153. package/dist/web-terminal-http.js.map +1 -1
  154. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
2
- import { randomBytes, randomUUID } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { freemem, totalmem, cpus, homedir } from "node:os";
5
5
  import { createServer } from "node:http";
6
6
  import { Worker } from "node:worker_threads";
7
+ import { ProbeWorkerPool } from "./probe-worker-pool.js";
7
8
  import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
@@ -12,6 +13,7 @@ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProg
12
13
  import { formatUpdateProgress } from "./update-progress.js";
13
14
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
14
15
  import { readFleetMemory } from "./process-memory.js";
16
+ import { MemoryPressure } from "./memory-pressure.js";
15
17
  import { ReplyDeduper } from "./reply-dedup.js";
16
18
  import { isMap, isScalar, parseDocument } from "yaml";
17
19
  const __filename = fileURLToPath(import.meta.url);
@@ -30,13 +32,14 @@ import { IpcClient } from "./channel/ipc-bridge.js";
30
32
  import { createAdapter } from "./channel/factory.js";
31
33
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
32
34
  import { createBackend } from "./backend/factory.js";
35
+ import { readEffortMetadata } from "./backend/effort-metadata.js";
33
36
  import { isModelCompatible, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
34
37
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
35
38
  import { processAttachments } from "./channel/attachment-handler.js";
36
39
  import { routeToolCall } from "./channel/tool-router.js";
37
40
  import { Scheduler } from "./scheduler/index.js";
38
41
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
39
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
42
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
40
43
  import { DailySummary } from "./daily-summary.js";
41
44
  import { WebhookEmitter } from "./webhook-emitter.js";
42
45
  import { TmuxControlClient } from "./tmux-control.js";
@@ -55,14 +58,18 @@ export const DURABLE_DELIVERY_LANE_ALERT_MS = 35 * 60_000;
55
58
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
56
59
  import { handleViewRequest, isViewPath } from "./view-api.js";
57
60
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
58
- import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
61
+ import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
59
62
  import { LoginSession } from "./login-manager.js";
60
63
  import { tightenInstanceDirs } from "./private-dir.js";
61
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
64
+ import { decideSlash } from "./slash-authz.js";
65
+ import { commandSpec, decideCommand } from "./command-table.js";
66
+ import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
62
67
  import { runBeforeDeadline } from "./deadline.js";
63
68
  import { LoginWindowLock } from "./login-window-lock.js";
64
69
  import { handleSettingsRequest } from "./settings-api.js";
65
70
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
71
+ import { recentChatContext } from "./log-tail.js";
72
+ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
66
73
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
67
74
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
68
75
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
@@ -223,12 +230,17 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
223
230
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
224
231
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
225
232
  export const LOGIN_CALLBACK_PREFIX = "login:";
226
- /** Prompt kinds `/install-cli` used, before it became part of `/login` (#1131). */
227
- const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:"];
233
+ /**
234
+ * Prompt kinds that no longer exist: `/install-cli` (#1131) and the relay-mode login's
235
+ * provider picker (#1139). Their buttons may still be on screen after an upgrade.
236
+ */
237
+ const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:", "login-menu:"];
228
238
  const NEVER_MATCHES = /(?!)/;
229
239
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
230
- const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
231
240
  const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
241
+ /** How much of the end of a Classic chat log is read for context, and the most it grows to. */
242
+ const CHAT_LOG_TAIL_BYTES = 64 * 1024;
243
+ const CHAT_LOG_TAIL_MAX_BYTES = 4 * 1024 * 1024;
232
244
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
233
245
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
234
246
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
@@ -292,6 +304,7 @@ export class FleetManager {
292
304
  lifecycle;
293
305
  stormWindow;
294
306
  spawnGate;
307
+ memoryPressure;
295
308
  /** Fleet-level backend reachability memory (fed by pty_error / startup panes). */
296
309
  backendOutage = new BackendOutageTracker();
297
310
  /** Stable for this FleetManager OS-process lifetime; Daemon objects have their own boot IDs. */
@@ -445,6 +458,7 @@ export class FleetManager {
445
458
  startupRetryNotices = new Map();
446
459
  /** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
447
460
  unsupportedCliNotices = new Map();
461
+ kiroIncompatNotices = new Map();
448
462
  /** Backoff between automatic startup retries; the last step repeats while the backend is down. */
449
463
  static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
450
464
  /** Hard cap on automatic startup retries (3 backoff steps + up to 3 more during a backend outage). */
@@ -537,6 +551,11 @@ export class FleetManager {
537
551
  watchdogTimer = null;
538
552
  startedAt = 0;
539
553
  stormOpenNotifyTimer = null;
554
+ memoryLogAt = null;
555
+ memoryDebugAt = null;
556
+ memoryLogLevel = null;
557
+ memoryNoticeAt = null;
558
+ memoryNoticeLevel = null;
540
559
  // Mirror topic: buffer cross-instance messages, flush every 3s
541
560
  mirrorBuffer = [];
542
561
  mirrorTimer = null;
@@ -575,8 +594,10 @@ export class FleetManager {
575
594
  FleetManager.sighupHandlerInstalled = true;
576
595
  }
577
596
  this.stormWindow = new StormWindow();
597
+ this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot) });
578
598
  this.spawnGate = new SpawnGate({
579
599
  storm: this.stormWindow,
600
+ memoryPressure: this.memoryPressure,
580
601
  concurrency: () => this.spawnConcurrency(),
581
602
  staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
582
603
  });
@@ -956,9 +977,54 @@ export class FleetManager {
956
977
  return Math.max(1, Math.min(20, explicit));
957
978
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
958
979
  }
980
+ /** Separate cooldown: changing measurements must not create new text-throttle keys. */
981
+ reportMemoryPressure(snapshot) {
982
+ if (this.shuttingDown)
983
+ return;
984
+ const now = Date.now();
985
+ if (this.memoryDebugAt === null || now - this.memoryDebugAt >= 30_000) {
986
+ this.memoryDebugAt = now;
987
+ this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
988
+ }
989
+ const changed = snapshot.level !== this.memoryLogLevel;
990
+ if (snapshot.level === "normal") {
991
+ if (this.memoryLogLevel === "critical" || this.memoryLogLevel === "elevated") {
992
+ this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
993
+ }
994
+ this.memoryLogLevel = snapshot.level;
995
+ return;
996
+ }
997
+ if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
998
+ this.memoryLogAt = now;
999
+ this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
1000
+ ? "Host memory critical — deferring new CLI spawns"
1001
+ : snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
1002
+ : "Host memory sample unavailable — slowing new CLI spawns");
1003
+ }
1004
+ this.memoryLogLevel = snapshot.level;
1005
+ if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
1006
+ return;
1007
+ const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
1008
+ if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1009
+ return;
1010
+ const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1011
+ const text = t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing"));
1012
+ if (this.notifyFleetError(text, { throttle: false })) {
1013
+ this.memoryNoticeAt = now;
1014
+ this.memoryNoticeLevel = snapshot.level;
1015
+ }
1016
+ }
959
1017
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
960
1018
  bindStormWindowEvents() {
961
1019
  this.stormWindow.on("opened", (snapshot) => {
1020
+ if (snapshot.kind === "window_loss") {
1021
+ // The server is fine and nothing is held: only the instances that lost
1022
+ // their window are affected, they respawn at the storm rate, and their
1023
+ // per-instance incident notices fold into this one (#1127).
1024
+ this.logger.error({ ...snapshot }, "several instances lost their tmux window at once — recovering at a reduced rate");
1025
+ this.notifyFleetError(t("storm.window_loss", snapshot.affected.length, Math.round(this.stormWindow.windowLossWindowMs / 1000), this.stormWindowLossRecoveryConcurrency()));
1026
+ return;
1027
+ }
962
1028
  for (const [name, daemon] of this.daemons) {
963
1029
  if (!daemon.isPaused)
964
1030
  this.stormWindow.addAffected(name);
@@ -986,6 +1052,10 @@ export class FleetManager {
986
1052
  this.notifyFleetError(t(reason === "timeout" ? "storm.timed_out" : "storm.recovered", snapshot.recovered.length, snapshot.affected.length, unresolved.length > 0 ? unresolved.join(", ") : t("storm.none")));
987
1053
  });
988
1054
  }
1055
+ /** What the gate allows while any storm window is open (see SpawnGate.pump). */
1056
+ stormWindowLossRecoveryConcurrency() {
1057
+ return Math.min(4, this.spawnConcurrency());
1058
+ }
989
1059
  formatStormDelay(ms) {
990
1060
  if (ms >= 60_000)
991
1061
  return `${Math.round(ms / 60_000)}m`;
@@ -1226,8 +1296,17 @@ export class FleetManager {
1226
1296
  this.rawFleetConfig = raw;
1227
1297
  this.fleetConfig = loaded;
1228
1298
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
1299
+ this.warnAboutRemovedLoginMode(loaded);
1229
1300
  return this.fleetConfig;
1230
1301
  }
1302
+ relayLoginModeWarned = false;
1303
+ /** `login.mode: relay` was removed (#1139). Still accepted so an upgrade cannot stop a fleet from starting. */
1304
+ warnAboutRemovedLoginMode(config) {
1305
+ if (config.login?.mode !== "relay" || this.relayLoginModeWarned)
1306
+ return;
1307
+ this.relayLoginModeWarned = true;
1308
+ this.logger.warn("fleet.yaml sets login.mode: relay — the chat-relay login was removed; /login always uses the web terminal. Remove the setting.");
1309
+ }
1231
1310
  /**
1232
1311
  * A channel's configurable bot_token_env is an env-key writer too. Refuse
1233
1312
  * an overlap with a registry API key (or a process-reserved key) before the
@@ -1349,7 +1428,7 @@ export class FleetManager {
1349
1428
  const action = data.command;
1350
1429
  const classicName = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1351
1430
  if (classicName) {
1352
- if (!this.classicChannels?.isAdmin(data.userId)) {
1431
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
1353
1432
  await data.respond(t("permission.denied"));
1354
1433
  return;
1355
1434
  }
@@ -1380,10 +1459,30 @@ export class FleetManager {
1380
1459
  }
1381
1460
  await data.respond(await this.topicCommands.runPauseWake(target, action));
1382
1461
  }
1462
+ /** SIGUSR2: restart the instances inside this process. Names who asked (#1120). */
1463
+ onGracefulRestartSignal(rearm) {
1464
+ this.logger.info(`Received SIGUSR2, initiating graceful restart... ${describeSignalSource(this.dataDir, "SIGUSR2")}`);
1465
+ this.restartInstances()
1466
+ .catch(err => this.logger.error({ err }, "Graceful restart failed"))
1467
+ .finally(rearm);
1468
+ }
1469
+ /** SIGUSR1: full process reload. Names who asked (#1120). */
1470
+ onFullRestartSignal() {
1471
+ this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
1472
+ this.gracefulShutdownForReload()
1473
+ .then(() => {
1474
+ this.logger.info("Full restart: shutdown complete, exiting for reload");
1475
+ process.exit(0);
1476
+ })
1477
+ .catch(err => {
1478
+ this.logger.error({ err }, "Full restart: graceful shutdown failed");
1479
+ process.exit(1);
1480
+ });
1481
+ }
1383
1482
  async handleUpdateSlash(data, adapterId) {
1384
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1385
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1386
- await data.respond(t("not_authorized"));
1483
+ const gate = this.fleetAdminGate(data.userId, adapterId);
1484
+ if (gate !== "ok") {
1485
+ await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
1387
1486
  return;
1388
1487
  }
1389
1488
  const messageId = await data.respond(t("update.progress.preparing", 0));
@@ -1395,7 +1494,11 @@ export class FleetManager {
1395
1494
  const { spawn } = await import("node:child_process");
1396
1495
  const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
1397
1496
  const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
1398
- const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
1497
+ const origin = `slash /update by ${adapterId}:${data.userId}`;
1498
+ recordInternalRequest(this.dataDir, "update", origin);
1499
+ const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
1500
+ detached: true, stdio: "ignore", env: withOrigin(origin),
1501
+ });
1399
1502
  child.once("error", err => this.failUpdateProgress(err.message));
1400
1503
  child.unref();
1401
1504
  }
@@ -1404,6 +1507,7 @@ export class FleetManager {
1404
1507
  await data.respond(t("not_authorized"));
1405
1508
  return;
1406
1509
  }
1510
+ recordInternalRequest(this.dataDir, "restart", `slash /restart ${String(data.options?.mode ?? "graceful")} by ${adapterId}:${data.userId}`);
1407
1511
  if (data.options?.mode !== "full") {
1408
1512
  await data.respond(t("restart.graceful"));
1409
1513
  process.kill(process.pid, "SIGUSR2");
@@ -2102,6 +2206,288 @@ export class FleetManager {
2102
2206
  const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
2103
2207
  return allowed.some(entry => String(entry) === String(userId));
2104
2208
  }
2209
+ /** Whether this adapter has any fleet admin at all (an empty allowlist means the admin commands are off). */
2210
+ hasFleetAdmins(adapterId) {
2211
+ return (this.getChannelConfig(adapterId)?.access?.allowed_users ?? []).length > 0;
2212
+ }
2213
+ /**
2214
+ * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
2215
+ * through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
2216
+ * `denied` only so the reply can say so. An empty list never means "everyone": these commands used to read
2217
+ * the primary channel's list and treat empty as open, so on a fleet that had not set one anyone who could
2218
+ * type a slash command could `/update` the host.
2219
+ */
2220
+ fleetAdminGate(userId, adapterId) {
2221
+ if (!this.hasFleetAdmins(adapterId))
2222
+ return "disabled";
2223
+ return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
2224
+ }
2225
+ /**
2226
+ * Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
2227
+ *
2228
+ * Two checks before any command's own code runs, and the order matters: the DOOR (src/slash-authz.ts — may
2229
+ * this caller speak here at all) and then the COMMAND TABLE (src/command-table.ts — does this command apply
2230
+ * in this kind of channel, and which kind of admin does it need). The table can only narrow what the door
2231
+ * let through. A command nobody registered is answered, not left to time out.
2232
+ */
2233
+ async dispatchSlash(data, adapterId, adapter) {
2234
+ const scope = await this.authorizeSlash(data, adapterId);
2235
+ if (!scope)
2236
+ return;
2237
+ const spec = commandSpec(data.command);
2238
+ if (!spec) {
2239
+ this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
2240
+ await data.respond(t("slash.unknown_command")).catch(() => { });
2241
+ return;
2242
+ }
2243
+ const decision = decideCommand(spec, scope, {
2244
+ fleetAdmin: () => this.fleetAdminGate(data.userId, adapterId),
2245
+ channelAdmin: () => this.isModelAdmin(data.userId, data.channelId, adapterId),
2246
+ classicAdmin: () => !!this.classicChannels?.isAdmin(data.userId),
2247
+ });
2248
+ if (!decision.allow) {
2249
+ this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
2250
+ const [key, ...args] = decision.reply;
2251
+ await data.respond(t(key, ...args)).catch(() => { });
2252
+ return;
2253
+ }
2254
+ if (data.command === "start") {
2255
+ await this.handleClassicStartSlash(data, adapterId);
2256
+ }
2257
+ else if (data.command === "stop") {
2258
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
2259
+ await data.respond(reply);
2260
+ }
2261
+ else if (data.command === "pause" || data.command === "wake") {
2262
+ await this.handlePauseWakeSlash(data, adapterId);
2263
+ }
2264
+ else if (data.command === "chat") {
2265
+ const text = data.text ?? "";
2266
+ if (!text) {
2267
+ await data.respond(t("chat.usage"));
2268
+ return;
2269
+ }
2270
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
2271
+ if (!name) {
2272
+ await data.respond(t("classic.no_agent_start"));
2273
+ return;
2274
+ }
2275
+ const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
2276
+ const replyMsgId = await data.respond(textForm(status.platform, status.received));
2277
+ const username = data.username ?? data.userId;
2278
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
2279
+ await this.forwardToClassicInstance(name, text, {
2280
+ chatId: data.channelId,
2281
+ threadId: data.channelId,
2282
+ messageId: replyMsgId ?? "",
2283
+ userId: data.userId,
2284
+ username,
2285
+ source: "discord",
2286
+ timestamp: new Date(),
2287
+ });
2288
+ }
2289
+ else if (data.command === "save") {
2290
+ await this.handleSlashSave(data, adapterId);
2291
+ }
2292
+ else if (data.command === "load") {
2293
+ // load is kiro-cli/classic only — no claude-code equivalent. (Classic admins only: the command table.)
2294
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
2295
+ if (!name) {
2296
+ await data.respond(t("classic.no_agent_start"));
2297
+ return;
2298
+ }
2299
+ const filename = data.options?.filename;
2300
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
2301
+ await data.respond(t("filename.invalid"));
2302
+ return;
2303
+ }
2304
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
2305
+ await data.respond(t("save.sent", `/chat load ${filename}`, name));
2306
+ }
2307
+ else if (data.command === "model") {
2308
+ await this.handleModelSlash(data, adapterId);
2309
+ }
2310
+ else if (data.command === "clear") {
2311
+ await this.handleClearSlash(data, adapterId);
2312
+ }
2313
+ else if (data.command === "effort") {
2314
+ await this.handleEffortSlash(data, adapterId);
2315
+ }
2316
+ else if (data.command === "cancel") {
2317
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2318
+ if (!name) {
2319
+ await data.respond(t("classic.no_agent"));
2320
+ return;
2321
+ }
2322
+ const ok = this.cancelInstance(name);
2323
+ await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
2324
+ }
2325
+ else if (data.command === "ctx") {
2326
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2327
+ if (!name) {
2328
+ await data.respond(t("classic.no_agent"));
2329
+ return;
2330
+ }
2331
+ // Single source of truth (statusline.json + robust tmux pane fallback).
2332
+ await data.respond(await this.topicCommands.getCtxText(name));
2333
+ }
2334
+ else if (data.command === "collab") {
2335
+ // The scope the door and the table authorized is the scope this acts in. A channel can be in both the
2336
+ // ClassicBot registry and the routing table (two configurations that each validate); the door and
2337
+ // `isModelAdmin` read the registry first, so a ClassicBot admin was judged for the ClassicBot channel
2338
+ // here and must not be handed the fleet instance's collab switch by a second, routing-first lookup.
2339
+ const collabTarget2 = scope === "classic" ? undefined : this.routing.resolve(data.channelId);
2340
+ if (collabTarget2) {
2341
+ const isCollab = this.toggleFleetCollab(collabTarget2.name);
2342
+ await data.respond(isCollab ? t("collab.on") : t("collab.off"));
2343
+ return;
2344
+ }
2345
+ if (!this.classicChannels?.isClassicChannel(data.channelId, adapterId)) {
2346
+ await data.respond(t("classic.no_agent_start"));
2347
+ return;
2348
+ }
2349
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
2350
+ await data.respond(newState
2351
+ ? t("collab.on.classic")
2352
+ : t("collab.off.classic"));
2353
+ }
2354
+ else if (data.command === "update") {
2355
+ await this.handleUpdateSlash(data, adapterId);
2356
+ }
2357
+ else if (data.command === "doctor") {
2358
+ await data.respond(await this.runBackendDoctor());
2359
+ }
2360
+ else if (data.command === "usage") {
2361
+ // Same permission level as /ctx (none). The reply is still ephemeral —
2362
+ // the adapter defers non-chat commands that way — so it never spams the
2363
+ // channel either way.
2364
+ try {
2365
+ const { getUsageSnapshot } = await import("./usage/usage-api.js");
2366
+ const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2367
+ // slash_command is Discord-only; editReply renders Markdown natively.
2368
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2369
+ }
2370
+ catch (err) {
2371
+ await data.respond(t("usage.failed", err.message));
2372
+ }
2373
+ }
2374
+ else if (data.command === "tips") {
2375
+ await this.handleTipsSlash(data, adapterId);
2376
+ }
2377
+ else if (data.command === "status") {
2378
+ // Admin-gated (like the topic path): the merged table shows every
2379
+ // instance's cost and IPC health.
2380
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
2381
+ await data.respond(t("cmd.admin_required", "/status"));
2382
+ return;
2383
+ }
2384
+ const text = await this.topicCommands.getStatusText();
2385
+ await data.respond(text);
2386
+ }
2387
+ else if (data.command === "sysinfo") {
2388
+ // Slash commands are Discord-only; use plain lines (no markdown table)
2389
+ await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
2390
+ }
2391
+ else if (data.command === "dashboard") {
2392
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
2393
+ // the web-token-bearing URLs are only visible to the caller.
2394
+ await data.respond(this.topicCommands.getDashboardText());
2395
+ }
2396
+ else if (data.command === "restart") {
2397
+ await this.handleRestartSlash(data, adapterId);
2398
+ }
2399
+ else if (data.command === "compact") {
2400
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2401
+ if (!name) {
2402
+ await data.respond(t("classic.no_agent"));
2403
+ return;
2404
+ }
2405
+ const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
2406
+ await data.respond(result);
2407
+ }
2408
+ else if (data.command === "steer") {
2409
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2410
+ if (!name) {
2411
+ await data.respond(t("classic.no_agent"));
2412
+ return;
2413
+ }
2414
+ const steerText = String(data.options?.message ?? "").trim();
2415
+ if (!steerText) {
2416
+ await data.respond(t("steer.usage"));
2417
+ return;
2418
+ }
2419
+ // chat_id/message_id stay empty: a slash interaction has no channel
2420
+ // message to react to, and an empty chat_id keeps updateLastChat from
2421
+ // rerouting the instance's replies to the slash context.
2422
+ const result = this.topicCommands.sendSteer(name, steerText, {
2423
+ chatId: "", messageId: "", username: data.username ?? "user",
2424
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2425
+ });
2426
+ await data.respond(result);
2427
+ }
2428
+ else if (data.command === "btw") {
2429
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2430
+ if (!name) {
2431
+ await data.respond(t("classic.no_agent"));
2432
+ return;
2433
+ }
2434
+ const btwText = String(data.options?.message ?? "").trim();
2435
+ if (!btwText) {
2436
+ await data.respond(t("btw.usage"));
2437
+ return;
2438
+ }
2439
+ const result = this.topicCommands.sendBtw(name, btwText, {
2440
+ chatId: "", messageId: "", username: data.username ?? "user",
2441
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2442
+ });
2443
+ await data.respond(result);
2444
+ }
2445
+ else if (data.command === "login") {
2446
+ await this.handleLoginSlash(data, adapterId, adapter);
2447
+ }
2448
+ }
2449
+ /**
2450
+ * The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
2451
+ * Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
2452
+ */
2453
+ async authorizeSlash(data, adapterId) {
2454
+ const channelId = data.channelId;
2455
+ const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
2456
+ const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
2457
+ const scope = classic ? "classic" : fleetTarget ? "fleet" : "none";
2458
+ // The table tells the General dispatcher from an instance's own channel; the door does not need to.
2459
+ const commandScope = classic ? "classic" : fleetTarget ? (fleetTarget.kind === "general" ? "general" : "fleet") : "none";
2460
+ let speaker = "denied";
2461
+ if (scope !== "classic") {
2462
+ // The same world the typed-message path would consult: the adapter that owns the channel's instance,
2463
+ // else the one the command arrived on.
2464
+ const ownerId = fleetTarget ? this.getInstanceAdapterId(fleetTarget.name) : undefined;
2465
+ const ownerWorld = ownerId ? this.worlds.get(ownerId) : undefined;
2466
+ if (ownerId && !ownerWorld) {
2467
+ speaker = "owner-not-running";
2468
+ }
2469
+ else {
2470
+ const am = ownerWorld?.accessManager ?? this.worlds.get(adapterId)?.accessManager ?? this.accessManager;
2471
+ // An explicit fleet admin always speaks, even if the access state file disagrees with the config.
2472
+ speaker = this.isFleetAdmin(data.userId, adapterId) || (am?.isAllowed(data.userId) ?? false) ? "allowed" : "denied";
2473
+ }
2474
+ }
2475
+ const facts = {
2476
+ command: data.command,
2477
+ guildId: data.guildId,
2478
+ primaryGuildId: String(this.getChannelConfig(adapterId)?.group_id ?? ""),
2479
+ scope,
2480
+ speaker,
2481
+ };
2482
+ const decision = decideSlash(facts);
2483
+ if (decision.allow)
2484
+ return commandScope;
2485
+ this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
2486
+ await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
2487
+ : decision.reason === "wrong-guild" ? "slash.wrong_server"
2488
+ : "not_authorized")).catch(() => { });
2489
+ return null;
2490
+ }
2105
2491
  /** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
2106
2492
  deliveryWorkerMode(target) {
2107
2493
  return resolveDeliveryWorkerMode(this.fleetConfig, target);
@@ -2334,6 +2720,10 @@ export class FleetManager {
2334
2720
  resumePaused = false,
2335
2721
  /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
2336
2722
  transition) {
2723
+ // CLI single-instance cold starts bypass startAll. Start diagnostics before
2724
+ // any lifecycle/wake work; a shutdown must not revive the stopped sampler.
2725
+ if (!this.shuttingDown)
2726
+ this.memoryPressure?.start();
2337
2727
  // Any start supersedes a pending automatic retry (it would otherwise fire
2338
2728
  // into a running instance — harmless, but noisy — or race this start).
2339
2729
  this.cancelStartupRetry(name);
@@ -2448,6 +2838,7 @@ export class FleetManager {
2448
2838
  instanceName: name,
2449
2839
  workingDirectory: config.working_directory,
2450
2840
  reason: "startup",
2841
+ stage: "lifecycle",
2451
2842
  }, async () => {
2452
2843
  if (await this.startInstanceUnattended(name, config, topicMode, "instance"))
2453
2844
  onReady?.(name);
@@ -2587,6 +2978,7 @@ export class FleetManager {
2587
2978
  instanceName: name,
2588
2979
  workingDirectory: this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name),
2589
2980
  reason: "recovery",
2981
+ stage: "lifecycle",
2590
2982
  }, () => this.startConfiguredInstance(name, topicMode));
2591
2983
  if (this.daemons.has(name)) {
2592
2984
  this.logger.info({ name, attempt: attempt + 1 }, "Automatic startup retry succeeded");
@@ -2704,8 +3096,58 @@ export class FleetManager {
2704
3096
  return false;
2705
3097
  this.logger.error({ name, reason: err.message }, `Not starting ${what}: the installed CLI cannot run it as configured`);
2706
3098
  this.queueUnsupportedCliNotice(name, err.message);
3099
+ this.queueKiroIncompatNotice(name, err.message);
2707
3100
  return true;
2708
3101
  }
3102
+ /**
3103
+ * LifecycleContext: a kiro instance the installed kiro-cli cannot run as
3104
+ * configured (#1109) — at start, or at a respawn after kiro-cli replaced
3105
+ * itself. The operator already gets the plain notice; this tells every
3106
+ * General, as an agent, so it can explain the kiro engine move when asked.
3107
+ * One notice per refusal reason, like the operator's.
3108
+ */
3109
+ queueKiroIncompatNotice(name, reason) {
3110
+ // The effective backend, ClassicBot channels included: they are not in fleetConfig.instances.
3111
+ if (this.backendNameForInstance(name) !== "kiro-cli")
3112
+ return;
3113
+ const pending = this.kiroIncompatNotices.get(reason);
3114
+ if (pending) {
3115
+ if (!pending.names.includes(name))
3116
+ pending.names.push(name);
3117
+ return;
3118
+ }
3119
+ const timer = setTimeout(() => {
3120
+ const entry = this.kiroIncompatNotices.get(reason);
3121
+ this.kiroIncompatNotices.delete(reason);
3122
+ if (entry)
3123
+ this.sendKiroIncompatToGenerals(entry.names, reason);
3124
+ }, FleetManager.STARTUP_RETRY_NOTICE_AGGREGATE_MS);
3125
+ timer.unref?.();
3126
+ this.kiroIncompatNotices.set(reason, { names: [name], timer });
3127
+ }
3128
+ /**
3129
+ * Held in the delivery outbox until each General takes it: the same kiro-cli
3130
+ * may have stopped the General too, and it should still hear once it is back.
3131
+ * Admitted once per day per refusal (reason + names) and General.
3132
+ */
3133
+ sendKiroIncompatToGenerals(names, reason, now = new Date()) {
3134
+ const generals = Object.entries(this.fleetConfig?.instances ?? {})
3135
+ .filter(([, config]) => config.general_topic === true).map(([general]) => general);
3136
+ if (generals.length === 0)
3137
+ return;
3138
+ try {
3139
+ this.ensureDeliveryOutbox();
3140
+ const sorted = [...names].sort();
3141
+ const digest = createHash("sha256").update(`${reason}\u0000${sorted.join(",")}`).digest("hex").slice(0, 16);
3142
+ const content = t("fleet.kiro_incompat_general", sorted.join(", "), reason);
3143
+ for (const general of generals) {
3144
+ this.deliveryOutbox.admitSystemNotice(general, `kiro-incompat:${now.toISOString().slice(0, 10)}:${digest}:${general}`, content, now);
3145
+ }
3146
+ }
3147
+ catch (err) {
3148
+ this.logger.warn({ err, names }, "Could not queue the kiro incompatibility notice for General");
3149
+ }
3150
+ }
2709
3151
  /**
2710
3152
  * One notice per refusal reason: a kiro-cli that dropped the legacy UI
2711
3153
  * refuses every kiro instance at once, and that is one message, not ten.
@@ -2879,6 +3321,7 @@ export class FleetManager {
2879
3321
  instanceName: name,
2880
3322
  workingDirectory,
2881
3323
  reason: "restart",
3324
+ stage: "lifecycle",
2882
3325
  }, () => this.doRestartSingleInstance(name, opts, transition)))
2883
3326
  .finally(() => {
2884
3327
  this.restartsInFlight.delete(name);
@@ -3254,6 +3697,9 @@ export class FleetManager {
3254
3697
  setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
3255
3698
  }
3256
3699
  this.initializeWebAuthTokens();
3700
+ // Must run before the first General/CLI spawn: pressure can hold startup.
3701
+ // Logs remain available even before the adapters and health listener exist.
3702
+ this.memoryPressure.start();
3257
3703
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
3258
3704
  // Set tmux socket isolation for custom AGEND_HOME
3259
3705
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -3696,27 +4142,10 @@ export class FleetManager {
3696
4142
  this.checkForUpdates();
3697
4143
  this.updateCheckTimer = setInterval(() => this.checkForUpdates(), 24 * 60 * 60 * 1000);
3698
4144
  }, 60 * 60 * 1000);
3699
- const onRestart = () => {
3700
- this.logger.info("Received SIGUSR2, initiating graceful restart...");
3701
- this.restartInstances()
3702
- .catch(err => this.logger.error({ err }, "Graceful restart failed"))
3703
- .finally(() => process.once("SIGUSR2", onRestart));
3704
- };
4145
+ const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
3705
4146
  process.once("SIGUSR2", onRestart);
3706
4147
  // SIGUSR1: full process reload (graceful stop → exit → CLI restarts)
3707
- const onFullRestart = () => {
3708
- this.logger.info("Received SIGUSR1, initiating full restart (process reload)...");
3709
- this.gracefulShutdownForReload()
3710
- .then(() => {
3711
- this.logger.info("Full restart: shutdown complete, exiting for reload");
3712
- process.exit(0);
3713
- })
3714
- .catch(err => {
3715
- this.logger.error({ err }, "Full restart: graceful shutdown failed");
3716
- process.exit(1);
3717
- });
3718
- };
3719
- process.once("SIGUSR1", onFullRestart);
4148
+ process.once("SIGUSR1", () => this.onFullRestartSignal());
3720
4149
  // A SIGHUP may arrive after the PID/general is available but before the
3721
4150
  // rest of startup finishes. Replay one coalesced reload only after all
3722
4151
  // startup-owned lifecycle work and signal handlers are in place.
@@ -4002,6 +4431,10 @@ export class FleetManager {
4002
4431
  connected++;
4003
4432
  }
4004
4433
  const problems = [];
4434
+ const hostMemory = this.memoryPressure.snapshot();
4435
+ if (hostMemory.level === "critical" || hostMemory.level === "elevated") {
4436
+ problems.push(`host memory pressure is ${hostMemory.level}`);
4437
+ }
4005
4438
  if (this.adapterState.size > 0 && connected === 0)
4006
4439
  problems.push("no channel adapter is connected");
4007
4440
  if (counts.crashed > 0)
@@ -4024,6 +4457,7 @@ export class FleetManager {
4024
4457
  adapters: { total: this.adapterState.size, connected, states, details },
4025
4458
  startupComplete: this.startupComplete,
4026
4459
  memory: readFleetMemory(),
4460
+ hostMemory,
4027
4461
  problems,
4028
4462
  };
4029
4463
  }
@@ -4074,273 +4508,7 @@ export class FleetManager {
4074
4508
  this.adapter.on("slash_command", safeHandler(async (data) => {
4075
4509
  if (!isCurrentAdapter())
4076
4510
  return;
4077
- if (data.command === "start") {
4078
- await this.handleClassicStartSlash(data, adapterId);
4079
- }
4080
- else if (data.command === "stop") {
4081
- const reply = await this.handleClassicStop(data.channelId, adapterId);
4082
- await data.respond(reply);
4083
- }
4084
- else if (data.command === "pause" || data.command === "wake") {
4085
- await this.handlePauseWakeSlash(data, adapterId);
4086
- }
4087
- else if (data.command === "chat") {
4088
- const text = data.text ?? "";
4089
- if (!text) {
4090
- await data.respond(t("chat.usage"));
4091
- return;
4092
- }
4093
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
4094
- if (!name) {
4095
- await data.respond(t("classic.no_agent_start"));
4096
- return;
4097
- }
4098
- const replyMsgId = await data.respond("👀");
4099
- const username = data.username ?? data.userId;
4100
- ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
4101
- await this.forwardToClassicInstance(name, text, {
4102
- chatId: data.channelId,
4103
- threadId: data.channelId,
4104
- messageId: replyMsgId ?? "",
4105
- userId: data.userId,
4106
- username,
4107
- source: "discord",
4108
- timestamp: new Date(),
4109
- });
4110
- }
4111
- else if (data.command === "save") {
4112
- await this.handleSlashSave(data, adapterId);
4113
- }
4114
- else if (data.command === "load") {
4115
- // load is kiro-cli/classic only — no claude-code equivalent.
4116
- if (!this.classicChannels?.isAdmin(data.userId)) {
4117
- await data.respond(t("admin.required"));
4118
- return;
4119
- }
4120
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
4121
- if (!name) {
4122
- await data.respond(t("classic.no_agent_start"));
4123
- return;
4124
- }
4125
- const filename = data.options?.filename;
4126
- if (!SAVE_FILENAME_RE.test(filename ?? "")) {
4127
- await data.respond(t("filename.invalid"));
4128
- return;
4129
- }
4130
- this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
4131
- await data.respond(t("save.sent", `/chat load ${filename}`, name));
4132
- }
4133
- else if (data.command === "compact") {
4134
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4135
- if (!name) {
4136
- await data.respond(t("classic.no_agent"));
4137
- return;
4138
- }
4139
- const result = await this.topicCommands.sendCompact(name);
4140
- await data.respond(result);
4141
- }
4142
- else if (data.command === "steer") {
4143
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4144
- if (!name) {
4145
- await data.respond(t("classic.no_agent"));
4146
- return;
4147
- }
4148
- const steerText = String(data.options?.message ?? "").trim();
4149
- if (!steerText) {
4150
- await data.respond(t("steer.usage"));
4151
- return;
4152
- }
4153
- // chat_id/message_id stay empty: a slash interaction has no channel
4154
- // message to react to, and an empty chat_id keeps updateLastChat from
4155
- // rerouting the instance's replies to the slash context.
4156
- const result = this.topicCommands.sendSteer(name, steerText, {
4157
- chatId: "", messageId: "", username: data.username ?? "user",
4158
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
4159
- });
4160
- await data.respond(result);
4161
- }
4162
- else if (data.command === "btw") {
4163
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4164
- if (!name) {
4165
- await data.respond(t("classic.no_agent"));
4166
- return;
4167
- }
4168
- const btwText = String(data.options?.message ?? "").trim();
4169
- if (!btwText) {
4170
- await data.respond(t("btw.usage"));
4171
- return;
4172
- }
4173
- const result = this.topicCommands.sendBtw(name, btwText, {
4174
- chatId: "", messageId: "", username: data.username ?? "user",
4175
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
4176
- });
4177
- await data.respond(result);
4178
- }
4179
- else if (data.command === "login") {
4180
- await this.handleLoginSlash(data, adapterId, this.adapter);
4181
- }
4182
- else if (data.command === "clear") {
4183
- await this.handleClearSlash(data, adapterId);
4184
- }
4185
- else if (data.command === "model") {
4186
- await this.handleModelSlash(data, adapterId);
4187
- }
4188
- else if (data.command === "effort") {
4189
- await this.handleEffortSlash(data, adapterId);
4190
- }
4191
- else if (data.command === "cancel") {
4192
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4193
- if (!name) {
4194
- await data.respond(t("classic.no_agent"));
4195
- return;
4196
- }
4197
- const ok = this.cancelInstance(name);
4198
- await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
4199
- }
4200
- else if (data.command === "ctx") {
4201
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4202
- if (!name) {
4203
- await data.respond(t("classic.no_agent"));
4204
- return;
4205
- }
4206
- // Single source of truth (statusline.json + robust tmux pane fallback).
4207
- await data.respond(await this.topicCommands.getCtxText(name));
4208
- }
4209
- else if (data.command === "collab") {
4210
- // Classic no longer lives in the routing engine, so a routing hit here is
4211
- // always a fleet-topic instance.
4212
- const collabTarget = this.routing.resolve(data.channelId);
4213
- if (collabTarget) {
4214
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4215
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
4216
- await data.respond(t("not_authorized"));
4217
- return;
4218
- }
4219
- const isCollab = this.toggleFleetCollab(collabTarget.name);
4220
- await data.respond(isCollab ? t("collab.on") : t("collab.off"));
4221
- return;
4222
- }
4223
- if (!this.classicChannels?.isAdmin(data.userId)) {
4224
- await data.respond(t("admin.required"));
4225
- return;
4226
- }
4227
- if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
4228
- await data.respond(t("classic.no_agent_start"));
4229
- return;
4230
- }
4231
- const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
4232
- await data.respond(newState
4233
- ? t("collab.on.classic")
4234
- : t("collab.off.classic"));
4235
- }
4236
- else if (data.command === "update") {
4237
- await this.handleUpdateSlash(data, adapterId);
4238
- }
4239
- else if (data.command === "doctor") {
4240
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4241
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
4242
- await data.respond(t("not_authorized"));
4243
- return;
4244
- }
4245
- await data.respond(await this.runBackendDoctor());
4246
- }
4247
- else if (data.command === "usage") {
4248
- // Same permission level as /ctx (none). The reply is still ephemeral —
4249
- // the adapter defers non-chat commands that way — so it never spams the
4250
- // channel either way.
4251
- try {
4252
- const { getUsageSnapshot } = await import("./usage/usage-api.js");
4253
- const { renderUsageMarkdown } = await import("./usage/format-rich.js");
4254
- // slash_command is Discord-only; editReply renders Markdown natively.
4255
- await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
4256
- }
4257
- catch (err) {
4258
- await data.respond(t("usage.failed", err.message));
4259
- }
4260
- }
4261
- else if (data.command === "tips") {
4262
- await this.handleTipsSlash(data, adapterId);
4263
- }
4264
- else if (data.command === "status") {
4265
- // Admin-gated (like the topic path): the merged table shows every
4266
- // instance's cost and IPC health.
4267
- if (!this.isFleetAdmin(data.userId, adapterId)) {
4268
- await data.respond(t("cmd.admin_required", "/status"));
4269
- return;
4270
- }
4271
- const text = await this.topicCommands.getStatusText();
4272
- await data.respond(text);
4273
- }
4274
- else if (data.command === "sysinfo") {
4275
- // Slash commands are Discord-only; use plain lines (no markdown table)
4276
- await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
4277
- }
4278
- else if (data.command === "dashboard") {
4279
- // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
4280
- // the web-token-bearing URLs are only visible to the caller.
4281
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4282
- if (allowed.length === 0) {
4283
- await data.respond(t("dashboard.disabled"));
4284
- return;
4285
- }
4286
- if (!allowed.some(u => String(u) === String(data.userId))) {
4287
- await data.respond(t("not_authorized"));
4288
- return;
4289
- }
4290
- await data.respond(this.topicCommands.getDashboardText());
4291
- }
4292
- else if (data.command === "restart") {
4293
- await this.handleRestartSlash(data, adapterId);
4294
- }
4295
- else if (data.command === "compact") {
4296
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4297
- if (!name) {
4298
- await data.respond(t("classic.no_agent"));
4299
- return;
4300
- }
4301
- const result = await this.topicCommands.sendCompact(name);
4302
- await data.respond(result);
4303
- }
4304
- else if (data.command === "steer") {
4305
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4306
- if (!name) {
4307
- await data.respond(t("classic.no_agent"));
4308
- return;
4309
- }
4310
- const steerText = String(data.options?.message ?? "").trim();
4311
- if (!steerText) {
4312
- await data.respond(t("steer.usage"));
4313
- return;
4314
- }
4315
- // chat_id/message_id stay empty: a slash interaction has no channel
4316
- // message to react to, and an empty chat_id keeps updateLastChat from
4317
- // rerouting the instance's replies to the slash context.
4318
- const result = this.topicCommands.sendSteer(name, steerText, {
4319
- chatId: "", messageId: "", username: data.username ?? "user",
4320
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
4321
- });
4322
- await data.respond(result);
4323
- }
4324
- else if (data.command === "btw") {
4325
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4326
- if (!name) {
4327
- await data.respond(t("classic.no_agent"));
4328
- return;
4329
- }
4330
- const btwText = String(data.options?.message ?? "").trim();
4331
- if (!btwText) {
4332
- await data.respond(t("btw.usage"));
4333
- return;
4334
- }
4335
- const result = this.topicCommands.sendBtw(name, btwText, {
4336
- chatId: "", messageId: "", username: data.username ?? "user",
4337
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
4338
- });
4339
- await data.respond(result);
4340
- }
4341
- else if (data.command === "login") {
4342
- await this.handleLoginSlash(data, adapterId, this.adapter);
4343
- }
4511
+ await this.dispatchSlash(data, adapterId, adapter);
4344
4512
  }, this.logger, "adapter.slash_command"));
4345
4513
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
4346
4514
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -4444,224 +4612,7 @@ export class FleetManager {
4444
4612
  adapter.on("slash_command", safeHandler(async (data) => {
4445
4613
  if (!isCurrentAdapter())
4446
4614
  return;
4447
- if (data.command === "start") {
4448
- await this.handleClassicStartSlash(data, adapterId);
4449
- }
4450
- else if (data.command === "stop") {
4451
- const reply = await this.handleClassicStop(data.channelId, adapterId);
4452
- await data.respond(reply);
4453
- }
4454
- else if (data.command === "pause" || data.command === "wake") {
4455
- await this.handlePauseWakeSlash(data, adapterId);
4456
- }
4457
- else if (data.command === "chat") {
4458
- const text = data.text ?? "";
4459
- if (!text) {
4460
- await data.respond(t("chat.usage"));
4461
- return;
4462
- }
4463
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
4464
- if (!name) {
4465
- await data.respond(t("classic.no_agent_start"));
4466
- return;
4467
- }
4468
- const replyMsgId = await data.respond("👀");
4469
- const username = data.username ?? data.userId;
4470
- ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
4471
- await this.forwardToClassicInstance(name, text, {
4472
- chatId: data.channelId,
4473
- threadId: data.channelId,
4474
- messageId: replyMsgId ?? "",
4475
- userId: data.userId,
4476
- username,
4477
- source: channelConfig.type,
4478
- timestamp: new Date(),
4479
- });
4480
- }
4481
- else if (data.command === "save") {
4482
- await this.handleSlashSave(data, adapterId);
4483
- }
4484
- else if (data.command === "load") {
4485
- // load is kiro-cli/classic only — no claude-code equivalent.
4486
- if (!this.classicChannels?.isAdmin(data.userId)) {
4487
- await data.respond(t("admin.required"));
4488
- return;
4489
- }
4490
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
4491
- if (!name) {
4492
- await data.respond(t("classic.no_agent_start"));
4493
- return;
4494
- }
4495
- const filename = data.options?.filename;
4496
- if (!SAVE_FILENAME_RE.test(filename ?? "")) {
4497
- await data.respond(t("filename.invalid"));
4498
- return;
4499
- }
4500
- this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
4501
- await data.respond(t("save.sent", `/chat load ${filename}`, name));
4502
- }
4503
- else if (data.command === "model") {
4504
- await this.handleModelSlash(data, adapterId);
4505
- }
4506
- else if (data.command === "clear") {
4507
- await this.handleClearSlash(data, adapterId);
4508
- }
4509
- else if (data.command === "effort") {
4510
- await this.handleEffortSlash(data, adapterId);
4511
- }
4512
- else if (data.command === "cancel") {
4513
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4514
- if (!name) {
4515
- await data.respond(t("classic.no_agent"));
4516
- return;
4517
- }
4518
- const ok = this.cancelInstance(name);
4519
- await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
4520
- }
4521
- else if (data.command === "ctx") {
4522
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4523
- if (!name) {
4524
- await data.respond(t("classic.no_agent"));
4525
- return;
4526
- }
4527
- // Single source of truth (statusline.json + robust tmux pane fallback).
4528
- await data.respond(await this.topicCommands.getCtxText(name));
4529
- }
4530
- else if (data.command === "collab") {
4531
- // Classic no longer lives in the routing engine, so a routing hit here is
4532
- // always a fleet-topic instance.
4533
- const collabTarget2 = this.routing.resolve(data.channelId);
4534
- if (collabTarget2) {
4535
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4536
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
4537
- await data.respond(t("not_authorized"));
4538
- return;
4539
- }
4540
- const isCollab = this.toggleFleetCollab(collabTarget2.name);
4541
- await data.respond(isCollab ? t("collab.on") : t("collab.off"));
4542
- return;
4543
- }
4544
- if (!this.classicChannels?.isAdmin(data.userId)) {
4545
- await data.respond(t("admin.required"));
4546
- return;
4547
- }
4548
- if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
4549
- await data.respond(t("classic.no_agent_start"));
4550
- return;
4551
- }
4552
- const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
4553
- await data.respond(newState
4554
- ? t("collab.on.classic")
4555
- : t("collab.off.classic"));
4556
- }
4557
- else if (data.command === "update") {
4558
- await this.handleUpdateSlash(data, adapterId);
4559
- }
4560
- else if (data.command === "doctor") {
4561
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4562
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
4563
- await data.respond(t("not_authorized"));
4564
- return;
4565
- }
4566
- await data.respond(await this.runBackendDoctor());
4567
- }
4568
- else if (data.command === "usage") {
4569
- // Same permission level as /ctx (none). The reply is still ephemeral —
4570
- // the adapter defers non-chat commands that way — so it never spams the
4571
- // channel either way.
4572
- try {
4573
- const { getUsageSnapshot } = await import("./usage/usage-api.js");
4574
- const { renderUsageMarkdown } = await import("./usage/format-rich.js");
4575
- // slash_command is Discord-only; editReply renders Markdown natively.
4576
- await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
4577
- }
4578
- catch (err) {
4579
- await data.respond(t("usage.failed", err.message));
4580
- }
4581
- }
4582
- else if (data.command === "tips") {
4583
- await this.handleTipsSlash(data, adapterId);
4584
- }
4585
- else if (data.command === "status") {
4586
- // Admin-gated (like the topic path): the merged table shows every
4587
- // instance's cost and IPC health.
4588
- if (!this.isFleetAdmin(data.userId, adapterId)) {
4589
- await data.respond(t("cmd.admin_required", "/status"));
4590
- return;
4591
- }
4592
- const text = await this.topicCommands.getStatusText();
4593
- await data.respond(text);
4594
- }
4595
- else if (data.command === "sysinfo") {
4596
- // Slash commands are Discord-only; use plain lines (no markdown table)
4597
- await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
4598
- }
4599
- else if (data.command === "dashboard") {
4600
- // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
4601
- // the web-token-bearing URLs are only visible to the caller.
4602
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4603
- if (allowed.length === 0) {
4604
- await data.respond(t("dashboard.disabled"));
4605
- return;
4606
- }
4607
- if (!allowed.some(u => String(u) === String(data.userId))) {
4608
- await data.respond(t("not_authorized"));
4609
- return;
4610
- }
4611
- await data.respond(this.topicCommands.getDashboardText());
4612
- }
4613
- else if (data.command === "restart") {
4614
- await this.handleRestartSlash(data, adapterId);
4615
- }
4616
- else if (data.command === "compact") {
4617
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4618
- if (!name) {
4619
- await data.respond(t("classic.no_agent"));
4620
- return;
4621
- }
4622
- const result = await this.topicCommands.sendCompact(name);
4623
- await data.respond(result);
4624
- }
4625
- else if (data.command === "steer") {
4626
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4627
- if (!name) {
4628
- await data.respond(t("classic.no_agent"));
4629
- return;
4630
- }
4631
- const steerText = String(data.options?.message ?? "").trim();
4632
- if (!steerText) {
4633
- await data.respond(t("steer.usage"));
4634
- return;
4635
- }
4636
- // chat_id/message_id stay empty: a slash interaction has no channel
4637
- // message to react to, and an empty chat_id keeps updateLastChat from
4638
- // rerouting the instance's replies to the slash context.
4639
- const result = this.topicCommands.sendSteer(name, steerText, {
4640
- chatId: "", messageId: "", username: data.username ?? "user",
4641
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
4642
- });
4643
- await data.respond(result);
4644
- }
4645
- else if (data.command === "btw") {
4646
- const name = this.resolveSlashTarget(data.channelId, adapterId);
4647
- if (!name) {
4648
- await data.respond(t("classic.no_agent"));
4649
- return;
4650
- }
4651
- const btwText = String(data.options?.message ?? "").trim();
4652
- if (!btwText) {
4653
- await data.respond(t("btw.usage"));
4654
- return;
4655
- }
4656
- const result = this.topicCommands.sendBtw(name, btwText, {
4657
- chatId: "", messageId: "", username: data.username ?? "user",
4658
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
4659
- });
4660
- await data.respond(result);
4661
- }
4662
- else if (data.command === "login") {
4663
- await this.handleLoginSlash(data, adapterId, adapter);
4664
- }
4615
+ await this.dispatchSlash(data, adapterId, adapter);
4665
4616
  }, this.logger, `adapter[${adapterId}].slash_command`));
4666
4617
  adapter.on("started", safeHandler((username, userId) => {
4667
4618
  if (!isCurrentAdapter())
@@ -5273,7 +5224,14 @@ export class FleetManager {
5273
5224
  // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
5274
5225
  if (msg.messageId) {
5275
5226
  const classicCid = threadId || msg.chatId;
5276
- const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
5227
+ // First /start has no registration yet. A sibling can receive the same
5228
+ // targeted command first, then reject its @suffix below; it must not
5229
+ // consume the intended bot's key. Use the same Classic candidate scope
5230
+ // as routing (including foreign forum topics normalized above, #1085).
5231
+ const isTelegramClassicCandidate = msg.source === "telegram" && !threadId
5232
+ && msg.chatId !== String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
5233
+ const isClassicMsg = !!this.classicChannels
5234
+ && (this.classicChannels.hasChannel(classicCid) || isTelegramClassicCandidate);
5277
5235
  const dedupKey = isClassicMsg
5278
5236
  ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
5279
5237
  : `${msg.source}:${msg.chatId}:${msg.messageId}`;
@@ -5432,7 +5390,8 @@ export class FleetManager {
5432
5390
  return;
5433
5391
  }
5434
5392
  // Handle /compact command (admin only)
5435
- if (text === "/compact" || text.startsWith("/compact@")) {
5393
+ const classicCompact = parseCompactCommand(text);
5394
+ if (classicCompact) {
5436
5395
  if (!this.classicChannels.isAdmin(msg.userId)) {
5437
5396
  await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
5438
5397
  return;
@@ -5442,7 +5401,7 @@ export class FleetManager {
5442
5401
  await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
5443
5402
  return;
5444
5403
  }
5445
- const result = await this.topicCommands.sendCompact(compactName);
5404
+ const result = await this.topicCommands.sendCompact(compactName, classicCompact.instructions);
5446
5405
  await msgAdapter?.sendText(chatId, result);
5447
5406
  return;
5448
5407
  }
@@ -7230,6 +7189,9 @@ export class FleetManager {
7230
7189
  pendingCliEnvProbes = new Map();
7231
7190
  /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
7232
7191
  pendingVendorCliEnvProbes = new Map();
7192
+ pendingInstanceModelProbes = new Map();
7193
+ cliEnvProbePool = new ProbeWorkerPool();
7194
+ cliEnvProbeEpoch = 0;
7233
7195
  /**
7234
7196
  * The status emojis for an instance (#1005): its own `status_emojis`, then
7235
7197
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -7558,10 +7520,18 @@ export class FleetManager {
7558
7520
  return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
7559
7521
  }
7560
7522
  /** The progress bubble's leading emoji, in the form the channel renders. */
7561
- progressPrefixFor(instanceName) {
7523
+ progressPrefixFor(instanceName, elapsedMs = 0, minElapsedMs = this.progressMinElapsedMs()) {
7562
7524
  const r = this.resolveStatusEmojisFor(instanceName);
7563
7525
  const builtin = builtinStatusEmojis(r.platform).progress_prefix;
7564
- return r.progress_prefix === builtin ? undefined : textForm(r.platform, r.progress_prefix);
7526
+ // Keep the built-in short/elapsed phases, but resolve their status values
7527
+ // too. A custom progress prefix takes precedence in both phases.
7528
+ // Telegram's built-in queued reaction is 👀; its elapsed *text* has always
7529
+ // used ⏳ (text is not limited to Telegram's reaction vocabulary).
7530
+ const queued = r.platform === "telegram" && r.queued === builtinStatusEmojis(r.platform).queued
7531
+ ? builtinStatusEmojis(undefined).queued : r.queued;
7532
+ const value = r.progress_prefix !== builtin ? r.progress_prefix
7533
+ : elapsedMs < minElapsedMs ? r.processing : queued;
7534
+ return textForm(r.platform, value);
7565
7535
  }
7566
7536
  /**
7567
7537
  * Is this inbound reaction one of the fleet's own delivery-status stamps?
@@ -8260,7 +8230,7 @@ export class FleetManager {
8260
8230
  }
8261
8231
  /**
8262
8232
  * A button from a prompt kind that no longer exists (#1131: `/install-cli`'s
8263
- * picker and its "sign in now?" prompt). Still on screen after an upgrade;
8233
+ * picker and its "sign in now?" prompt; #1139: the relay login's provider picker). Still on screen after an upgrade;
8264
8234
  * a click gets the expired-prompt treatment — a private notice, the buttons
8265
8235
  * collapsed — instead of nothing.
8266
8236
  */
@@ -8281,8 +8251,6 @@ export class FleetManager {
8281
8251
  return true;
8282
8252
  if (await this.handleClassicApproval(data, adapterId, adapter))
8283
8253
  return true;
8284
- if (await this.handleLoginMenuSelect(data, adapterId, adapter))
8285
- return true;
8286
8254
  if (this.handleRetiredPromptButton(data, adapterId, adapter))
8287
8255
  return true;
8288
8256
  if (await this.handleLoginConfirm(data, adapterId, adapter))
@@ -8858,10 +8826,17 @@ export class FleetManager {
8858
8826
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
8859
8827
  */
8860
8828
  async handleSlashSave(data, adapterId) {
8861
- if (!this.classicChannels?.isAdmin(data.userId)) {
8829
+ // The admin of the channel's own kind (a fleet admin in a fleet channel). It used to ask for a ClassicBot
8830
+ // admin everywhere, so a fleet admin was refused in their own channel and a ClassicBot admin could paste
8831
+ // into a fleet instance.
8832
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
8862
8833
  await data.respond(t("admin.required"));
8863
8834
  return;
8864
8835
  }
8836
+ if (!this.classicChannels) {
8837
+ await data.respond(t("classic.no_agent_start"));
8838
+ return;
8839
+ }
8865
8840
  // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
8866
8841
  const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
8867
8842
  const target = classicName
@@ -8999,7 +8974,7 @@ export class FleetManager {
8999
8974
  return;
9000
8975
  }
9001
8976
  const publication = this.beginCancelButtonPublication(instanceName, correlationId);
9002
- const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName));
8977
+ const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
9003
8978
  try {
9004
8979
  const sent = await adapter.notifyAlert(chatId, {
9005
8980
  type: "cancel",
@@ -9215,7 +9190,8 @@ export class FleetManager {
9215
9190
  * wiped by the next elapsed tick (#528 trap 2).
9216
9191
  */
9217
9192
  composeBubbleText(entry) {
9218
- return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress, this.progressPrefixFor(entry.instanceName));
9193
+ const elapsedMs = Date.now() - (entry.startedAt ?? Date.now());
9194
+ return FleetManager.bubbleText(elapsedMs, this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress, this.progressPrefixFor(entry.instanceName, elapsedMs));
9219
9195
  }
9220
9196
  /** Pure composition of header + tool list, exposed for tests. */
9221
9197
  static bubbleText(elapsedMs, activity, minElapsedMs, progress, prefix) {
@@ -9521,16 +9497,11 @@ export class FleetManager {
9521
9497
  // shared ~/.codex; the other CLIs use one real home), so a single sign-in
9522
9498
  // repairs every instance of that backend, and instance delivery, pane-state
9523
9499
  // detection, tool progress, and mcp_proxy_reply never observe login output.
9524
- activeLogin = null;
9525
- /**
9526
- * Web-terminal login (v2.1.5 default, `login.mode: web`). The relay code
9527
- * below (activeLogin/LoginSession) is `login.mode: relay`, kept for one
9528
- * release as the rollback lever and removed in 2.1.6.
9529
- */
9500
+ /** Web-terminal login: the only sign-in path (the chat-relay mode was removed, #1139). */
9530
9501
  loginController = null;
9531
9502
  /**
9532
9503
  * One login/install window fleet-wide. Claimed synchronously before the
9533
- * first await by web login, relay login and install alike (sol B1).
9504
+ * first await by web login and install alike (sol B1).
9534
9505
  */
9535
9506
  loginWindow = new LoginWindowLock();
9536
9507
  get webLogin() {
@@ -9766,162 +9737,11 @@ export class FleetManager {
9766
9737
  this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
9767
9738
  }
9768
9739
  }
9769
- /** The sign-in itself (web, or the legacy relay) for a CLI that is installed. */
9740
+ /** The sign-in itself, in the web terminal, for a CLI that is installed. */
9770
9741
  async launchSignIn(backendArg, chat, opts = {}) {
9771
- if (this.webLogin.mode() === "web")
9772
- return this.webLogin.start(backendArg, chat, opts);
9773
- // ── legacy relay mode (login.mode: relay) — removed in 2.1.6 ──
9774
- const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
9775
- const flow = LOGIN_FLOWS[backend];
9776
- if (!flow)
9777
- return t("login.unsupported", backendArg);
9778
- // Declined in every mode — never a silent relay fallback (e.g. Antigravity).
9779
- if (flow.remoteLogin === "unsupported")
9780
- return t("login.remote_unsupported_agent_cli", backend, flow.command);
9781
- // Reserve the window before the pre-check await. The claim is owned by this
9782
- // region until it is transferred to launchLoginSession; any other exit
9783
- // (buttons only, throw, shutdown) releases it.
9784
- const claim = this.loginWindow.tryClaim("relay", backend);
9785
- if (!claim)
9786
- return this.loginWindow.busyMessage();
9787
- let transferred = false;
9788
- try {
9789
- return await this.startRelayClaimed(flow, backend, chat, opts, claim, () => { transferred = true; });
9790
- }
9791
- finally {
9792
- if (!transferred)
9793
- this.loginWindow.release(claim);
9794
- }
9795
- }
9796
- async startRelayClaimed(flow, backend, chat, opts, claim, markTransferred) {
9797
- // Token-free pre-check (5s cap): re-login while auth still works is
9798
- // usually a mistake, so it needs a confirmed click. An invalid OR
9799
- // uncertain result (timeout, missing binary) proceeds straight to login —
9800
- // an unreliable probe must never block the re-login the admin asked for.
9801
- if (!opts.skipAuthCheck && flow.authCheck) {
9802
- const status = await checkAuthStatus(flow.authCheck);
9803
- if (!this.loginWindow.isCurrent(claim))
9804
- return t("login.web_shutting_down"); // fleet shut down while we probed
9805
- if (status === "valid") {
9806
- const failure = await this.postChooser({
9807
- prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
9808
- alertType: "login",
9809
- instanceName: backend,
9810
- adapter: chat.adapter,
9811
- adapterId: chat.adapterId,
9812
- chatId: chat.chatId,
9813
- threadId: chat.threadId,
9814
- message: t("login.still_valid", backend),
9815
- choices: [
9816
- { action: "go", label: t("login.relogin_go") },
9817
- { action: "cancel", label: t("login.relogin_cancel") },
9818
- ],
9819
- expiredText: t("buttons.stale"),
9820
- });
9821
- return failure ?? null;
9822
- }
9823
- }
9824
- markTransferred(); // launchLoginSession owns the claim from here
9825
- return this.launchLoginSession(flow, backend, chat, claim);
9826
- }
9827
- /** Create the login window and session (pre-check already settled). */
9828
- async launchLoginSession(flow, backend, chat, claim) {
9829
- // Owns `claim`: released on any failure before the session is published,
9830
- // and by onDone afterwards. A shutdown during ensureSession stops us.
9831
- let tmux;
9832
- try {
9833
- const sessionName = getTmuxSession();
9834
- await TmuxManager.ensureSession(sessionName);
9835
- if (!this.loginWindow.isCurrent(claim)) {
9836
- this.loginWindow.release(claim);
9837
- return t("login.web_shutting_down");
9838
- }
9839
- tmux = new TmuxManager(sessionName, "");
9840
- }
9841
- catch (err) {
9842
- this.loginWindow.release(claim);
9843
- return t("login.failed", backend, err.message);
9844
- }
9845
- const session = new LoginSession(flow, tmux, {
9846
- onMenu: async (options) => {
9847
- const failure = await this.postChooser({
9848
- prefix: LOGIN_MENU_CALLBACK_PREFIX,
9849
- alertType: "login",
9850
- instanceName: backend,
9851
- adapter: chat.adapter,
9852
- adapterId: chat.adapterId,
9853
- chatId: chat.chatId,
9854
- threadId: chat.threadId,
9855
- message: t("login.choose_provider"),
9856
- choices: options.map((label, index) => ({ action: String(index), label })),
9857
- expiredText: t("buttons.stale"),
9858
- });
9859
- if (failure) {
9860
- await chat.adapter.sendText(chat.chatId, failure, { threadId: chat.threadId })
9861
- .catch(err => this.logger.warn({ err, backend }, "Could not report a login menu that failed to post"));
9862
- }
9863
- },
9864
- onAuthHint: async (url, code) => {
9865
- await this.sendLoginSecret(chat, backend, url, code);
9866
- },
9867
- onNeedInput: async (promptExcerpt) => {
9868
- await chat.adapter.sendText(chat.chatId, t("login.need_input", backend, promptExcerpt), { threadId: chat.threadId }).catch(() => { });
9869
- },
9870
- onDone: async ({ ok, detail, cleanupFailed }) => {
9871
- this.activeLogin = null;
9872
- this.loginWindow.release(claim);
9873
- if (cleanupFailed) {
9874
- await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
9875
- }
9876
- const send = (text) => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
9877
- let text;
9878
- if (ok) {
9879
- // Same order as the web-login path: the login result goes out first,
9880
- // then the recovery reports its own outcome. Waiting for recovery to
9881
- // build this message is what made a successful login look hung.
9882
- await send(t("login.completed", backend));
9883
- await announcePostLoginRecovery(backend, () => this.recoverBackendInstances(backend), send);
9884
- return;
9885
- }
9886
- else if (detail === "cancelled") {
9887
- // The cancel command's own reply already announced this — a second
9888
- // message here was a duplicate.
9889
- return;
9890
- }
9891
- else {
9892
- text = t("login.failed", backend, detail);
9893
- }
9894
- await chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
9895
- },
9896
- }, this.logger);
9897
- // Claim the slot before the first await so two admins racing /login cannot
9898
- // both create windows; release on startup failure.
9899
- this.activeLogin = { session, backend, chat };
9900
- try {
9901
- await session.start();
9902
- }
9903
- catch (err) {
9904
- this.activeLogin = null;
9905
- this.loginWindow.release(claim);
9906
- const text = t("login.failed", backend, err.message);
9907
- return err.cleanupFailed ? `${text}\n${t("login.web_cleanup_failed", backend)}` : text;
9908
- }
9909
- if (session.state === "done") {
9910
- // Cancelled (user or shutdown) while starting: start() joined the
9911
- // teardown, so nothing is left. onDone already released the claim.
9912
- this.activeLogin = null;
9913
- this.loginWindow.release(claim);
9914
- return this.loginWindow.isClosed ? t("login.web_shutting_down") : t("login.cancelled", backend);
9915
- }
9916
- if (!this.loginWindow.isCurrent(claim)) {
9917
- await session.cancel("cancelled").catch(() => { });
9918
- this.activeLogin = null;
9919
- this.loginWindow.release(claim);
9920
- return t("login.web_shutting_down");
9921
- }
9922
- return t("login.started", backend);
9742
+ return this.webLogin.start(backendArg, chat, opts);
9923
9743
  }
9924
- /** Fleet shutdown: end any web/relay login or install window and wait for its confirmed teardown. */
9744
+ /** Fleet shutdown: end any web login or install window and wait for its confirmed teardown. */
9925
9745
  async shutdownLoginWindows() {
9926
9746
  // Close the lock FIRST: in-flight starts parked in a pre-check or
9927
9747
  // ensureSession observe !isCurrent when they resume and stop; no new
@@ -9956,19 +9776,9 @@ export class FleetManager {
9956
9776
  // a stopping fleet must not announce "login failed — fleet shutdown".
9957
9777
  await Promise.all([
9958
9778
  bounded(this.loginController?.shutdown(), "web-login"),
9959
- bounded(this.activeLogin?.session.cancel("cancelled"), "relay-login"),
9960
9779
  bounded(this.activeInstall?.session.cancel("cancelled"), "install"),
9961
9780
  ]);
9962
9781
  }
9963
- /** `/login code <text>` — paste admin-supplied text into the login window. */
9964
- async loginSubmitInput(text) {
9965
- if (this.loginController?.isActive())
9966
- return t("login.web_code_not_needed");
9967
- if (!this.activeLogin)
9968
- return t("login.no_session");
9969
- const ok = await this.activeLogin.session.submitInput(text);
9970
- return ok ? t("login.input_sent") : t("login.input_failed");
9971
- }
9972
9782
  /** `/login cancel` — abort the active session and remove its window. */
9973
9783
  async cancelLoginSession() {
9974
9784
  // `/login cancel` also stops an install that `/login` started — and the
@@ -9983,31 +9793,7 @@ export class FleetManager {
9983
9793
  }
9984
9794
  if (this.loginController?.isActive())
9985
9795
  return this.loginController.cancel();
9986
- if (!this.activeLogin)
9987
- return t("login.no_session");
9988
- const backend = this.activeLogin.backend;
9989
- await this.activeLogin.session.cancel();
9990
- return t("login.cancelled", backend);
9991
- }
9992
- /**
9993
- * The URL (+ code) is a live credential: whoever completes it binds THEIR
9994
- * account to this fleet's CLI. Telegram gets an HTML spoiler (same treatment
9995
- * as the dashboard token); other adapters get plain text with the warning.
9996
- */
9997
- async sendLoginSecret(chat, backend, url, code) {
9998
- const codeLine = code ? `\n${t("login.auth_code", code)}` : "";
9999
- try {
10000
- if (chat.adapter.type === "telegram") {
10001
- const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
10002
- await chat.adapter.sendText(chat.chatId, `${esc(t("login.auth_hint", backend))}\n<tg-spoiler>${esc(url)}${esc(codeLine)}</tg-spoiler>`, { threadId: chat.threadId, format: "html" });
10003
- }
10004
- else {
10005
- await chat.adapter.sendText(chat.chatId, `${t("login.auth_hint", backend)}\n${url}${codeLine}`, { threadId: chat.threadId });
10006
- }
10007
- }
10008
- catch (err) {
10009
- this.logger.warn({ err: err.message, backend }, "Failed to deliver login URL");
10010
- }
9796
+ return t("login.no_session");
10011
9797
  }
10012
9798
  /**
10013
9799
  * After a successful login: wake the paused instances of that backend AND
@@ -10125,20 +9911,6 @@ export class FleetManager {
10125
9911
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, text);
10126
9912
  return true;
10127
9913
  }
10128
- /** Kiro provider button → drive the CLI's arrow-key selector. */
10129
- async handleLoginMenuSelect(data, callbackAdapterId, receivingAdapter) {
10130
- const claimed = this.consumeNonceCallback(LOGIN_MENU_CALLBACK_PREFIX, /^login-menu:([0-9a-f]+):(\d)$/, data, callbackAdapterId, receivingAdapter);
10131
- if (claimed === null)
10132
- return false;
10133
- if (claimed === "consumed")
10134
- return true;
10135
- const { entry, action } = claimed;
10136
- const index = Number(action);
10137
- const label = this.activeLogin?.session.flow.menu?.options[index] ?? action;
10138
- const ok = await this.activeLogin?.session.selectMenuOption(index) ?? false;
10139
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
10140
- return true;
10141
- }
10142
9914
  // ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
10143
9915
  //
10144
9916
  // Same dedicated-window model as /login (and the same LoginSession state
@@ -10156,7 +9928,7 @@ export class FleetManager {
10156
9928
  return t("install.unsupported", backendArg);
10157
9929
  if (checkBinaryInstalled(info.binary))
10158
9930
  return t("install.already", backend, info.binary);
10159
- // Reserve the fleet-wide window before the first await (shared with web/relay
9931
+ // Reserve the fleet-wide window before the first await (shared with web
10160
9932
  // login). Owned by this method until the session is published.
10161
9933
  const claim = this.loginWindow.tryClaim("install", backend);
10162
9934
  if (!claim)
@@ -10175,10 +9947,9 @@ export class FleetManager {
10175
9947
  this.loginWindow.release(claim);
10176
9948
  return t("install.failed", backend, err.message);
10177
9949
  }
10178
- // A synthetic login flow: success is decided by the installer's exit code
10179
- // (LoginSession treats a clean exit as success), never by pane text — and
10180
- // installer output that happens to contain a URL must not be forwarded as
10181
- // an auth hint, hence the no-op events below.
9950
+ // A synthetic flow: success is decided by the installer's exit code
9951
+ // (LoginSession treats a clean exit as success), never by pane text. The
9952
+ // session only judges that verdict; it does not read the output for URLs or prompts.
10182
9953
  const flow = {
10183
9954
  backend,
10184
9955
  command: info.install,
@@ -10186,9 +9957,6 @@ export class FleetManager {
10186
9957
  timeoutMs: 10 * 60 * 1000,
10187
9958
  };
10188
9959
  const session = new LoginSession(flow, tmux, {
10189
- onMenu: () => { },
10190
- onAuthHint: () => { },
10191
- onNeedInput: () => { },
10192
9960
  onDone: async ({ ok, detail, cleanupFailed }) => {
10193
9961
  this.activeInstall = null;
10194
9962
  this.loginWindow.release(claim);
@@ -11145,7 +10913,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11145
10913
  for (const backend of SYSINFO_BACKEND_IDS) {
11146
10914
  if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
11147
10915
  continue;
11148
- void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
10916
+ void this.probeBackendBounded(backend).catch(err => {
11149
10917
  this.logger.debug({ err, backend }, "Background CLI version refresh failed");
11150
10918
  });
11151
10919
  }
@@ -11168,19 +10936,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11168
10936
  return vendorFlight;
11169
10937
  if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
11170
10938
  return vendorFlight ?? regularFlight;
11171
- const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
11172
- const work = worker?.promise ?? this.probeBackend(backend, opts);
11173
- work.catch(() => { });
11174
- let timer;
11175
- const deadline = new Promise(resolve => {
11176
- timer = setTimeout(() => {
11177
- worker?.terminate();
11178
- this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
11179
- resolve(null);
11180
- }, CLI_ENV_PROBE_DEADLINE_MS);
11181
- });
11182
- const bounded = Promise.race([work, deadline]).finally(() => {
11183
- clearTimeout(timer);
10939
+ if (this.shuttingDown)
10940
+ return null;
10941
+ const instanceDir = join(getAgendHome(), "cli-env");
10942
+ const epoch = this.cliEnvProbeEpoch;
10943
+ const input = {
10944
+ mode: "env", backend, instanceDir,
10945
+ config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
10946
+ refreshVendorCatalog: opts.refreshVendorCatalog === true,
10947
+ };
10948
+ const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
10949
+ if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
10950
+ return null;
10951
+ try {
10952
+ return this.persistCliEnvProbeResult(backend, probed);
10953
+ }
10954
+ catch (err) {
10955
+ this.logger.warn({ backend, err }, "CLI env cache write failed");
10956
+ return null;
10957
+ }
10958
+ }).finally(() => {
11184
10959
  const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
11185
10960
  if (flights.get(backend) === bounded)
11186
10961
  flights.delete(backend);
@@ -11188,18 +10963,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11188
10963
  (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
11189
10964
  return bounded;
11190
10965
  }
11191
- startCliEnvProbeWorker(backend, opts) {
11192
- const workerData = {
11193
- backend,
11194
- instanceDir: join(getAgendHome(), "cli-env"),
11195
- config: {
11196
- workingDirectory: "",
11197
- instanceDir: join(getAgendHome(), "cli-env"),
11198
- instanceName: `probe-${backend}`,
11199
- mcpServers: {},
11200
- },
11201
- refreshVendorCatalog: opts.refreshVendorCatalog === true,
11202
- };
10966
+ stopCliEnvProbes() {
10967
+ this.cliEnvProbeEpoch++;
10968
+ this.cliEnvProbePool.close();
10969
+ this.pendingCliEnvProbes.clear();
10970
+ this.pendingVendorCliEnvProbes.clear();
10971
+ this.pendingInstanceModelProbes.clear();
10972
+ }
10973
+ runBackendProbeWorker(key, input) {
10974
+ if (this.shuttingDown)
10975
+ return Promise.resolve(null);
10976
+ this.cliEnvProbePool.reopen();
10977
+ return this.cliEnvProbePool.run(key, () => this.startCliEnvProbeWorker(input), {
10978
+ deadlineMs: CLI_ENV_PROBE_DEADLINE_MS,
10979
+ onTimeout: () => this.logger.warn({ backend: input.backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list"),
10980
+ onError: err => this.logger.warn({ backend: input.backend, err }, "CLI env worker failed to start or stop"),
10981
+ });
10982
+ }
10983
+ startCliEnvProbeWorker(input) {
10984
+ const { backend } = input;
10985
+ const workerData = input;
11203
10986
  // `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
11204
10987
  // Published builds load the compiled worker and require no tsx dependency.
11205
10988
  const worker = import.meta.url.endsWith(".ts")
@@ -11207,35 +10990,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11207
10990
  : new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
11208
10991
  let settled = false;
11209
10992
  let resolveProbe;
10993
+ let markStopped;
10994
+ const stopped = new Promise(resolve => { markStopped = resolve; });
11210
10995
  const promise = new Promise(resolve => { resolveProbe = resolve; });
11211
10996
  const finish = (value) => {
11212
10997
  if (settled)
11213
10998
  return;
11214
10999
  settled = true;
11215
11000
  resolveProbe(value);
11216
- void worker.terminate().catch(err => {
11001
+ // Admission stays occupied until terminate resolves or an exit is seen.
11002
+ // A rejected termination does not prove the isolate has stopped.
11003
+ try {
11004
+ void worker.terminate().then(markStopped).catch(err => {
11005
+ this.logger.debug({ backend, err }, "CLI env worker termination failed");
11006
+ });
11007
+ }
11008
+ catch (err) {
11217
11009
  this.logger.debug({ backend, err }, "CLI env worker termination failed");
11218
- });
11010
+ }
11219
11011
  };
11220
11012
  worker.once("message", (message) => {
11221
11013
  if (settled)
11222
11014
  return;
11223
- if (!message.ok) {
11015
+ if (!message.ok)
11224
11016
  this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
11225
- finish(null);
11226
- }
11227
- else if (!message.result) {
11228
- finish(null);
11229
- }
11230
- else {
11231
- try {
11232
- finish(this.persistCliEnvProbeResult(backend, message.result));
11233
- }
11234
- catch (err) {
11235
- this.logger.warn({ backend, err }, "CLI env cache write failed");
11236
- finish(null);
11237
- }
11238
- }
11017
+ finish(message.ok ? message.result ?? null : null);
11239
11018
  });
11240
11019
  worker.once("error", err => {
11241
11020
  if (settled)
@@ -11244,12 +11023,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11244
11023
  finish(null);
11245
11024
  });
11246
11025
  worker.once("exit", code => {
11026
+ markStopped();
11247
11027
  if (settled)
11248
11028
  return;
11249
11029
  this.logger.warn({ backend, code }, "CLI env worker exited without a result");
11250
11030
  finish(null);
11251
11031
  });
11252
- return { promise, terminate: () => { finish(null); } };
11032
+ return { promise, stopped, terminate: () => { finish(null); } };
11253
11033
  }
11254
11034
  /**
11255
11035
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
@@ -11299,32 +11079,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11299
11079
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
11300
11080
  return option.id === currentModel ? `✓ ${label}` : label;
11301
11081
  }
11302
- /**
11303
- * Probe one backend's CLI env and cache it. Best-effort; never throws.
11304
- *
11305
- * `refreshVendorCatalog` is the "🔄 Refresh models" path only (#886): first
11306
- * ask the CLI to refetch its own catalog, for backends whose probe merely
11307
- * reads a file the CLI maintains. Every other caller leaves it off, so the
11308
- * startup and /model probes behave exactly as before. A failed vendor
11309
- * refresh fails the probe (null), which the menu reports instead of passing
11310
- * the old list off as fresh.
11311
- */
11312
- async probeBackend(backend, opts = {}) {
11313
- try {
11314
- const be = createBackend(backend, join(getAgendHome(), "cli-env"));
11315
- if (!be.probeCLIEnv)
11316
- return null;
11317
- if (opts.refreshVendorCatalog && be.refreshModelCatalog)
11318
- await be.refreshModelCatalog();
11319
- const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
11320
- return this.persistCliEnvProbeResult(backend, probed);
11321
- }
11322
- catch (err) {
11323
- this.logger.warn({ err, backend }, "CLI env probe failed");
11324
- return null;
11325
- }
11326
- }
11327
- /** Preserve the existing cache merge rules for both in-process and worker probes. */
11082
+ /** Preserve the existing cache merge rules for worker probes. */
11328
11083
  persistCliEnvProbeResult(backend, probed) {
11329
11084
  const env = { backend, probedAt: Date.now(), ...probed };
11330
11085
  // An empty result must never overwrite a catalog we already have. Some
@@ -11471,17 +11226,30 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11471
11226
  try {
11472
11227
  const inst = this.fleetConfig?.instances?.[instanceName];
11473
11228
  const instanceDir = this.getInstanceDir(instanceName);
11474
- const be = createBackend(backend, instanceDir);
11475
- if (!be.listModels)
11476
- return [];
11477
- return await be.listModels({
11229
+ const config = structuredClone({
11478
11230
  workingDirectory: inst?.working_directory ?? "",
11479
11231
  instanceDir,
11480
11232
  instanceName,
11481
11233
  mcpServers: {},
11482
11234
  model: inst?.model,
11483
11235
  backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
11484
- }) ?? [];
11236
+ });
11237
+ // Include the config snapshot, so a changed provider/profile cannot join
11238
+ // a still-running probe for the previous instance configuration.
11239
+ const key = JSON.stringify({ backend, instanceDir, config });
11240
+ const pending = this.pendingInstanceModelProbes.get(key);
11241
+ if (pending)
11242
+ return pending;
11243
+ const epoch = this.cliEnvProbeEpoch;
11244
+ const bounded = this.runBackendProbeWorker(`instance:${backend}:${instanceDir}`, {
11245
+ mode: "models", backend, instanceDir, config,
11246
+ }).then(result => Array.isArray(result) && !this.shuttingDown && epoch === this.cliEnvProbeEpoch ? result : [])
11247
+ .finally(() => {
11248
+ if (this.pendingInstanceModelProbes.get(key) === bounded)
11249
+ this.pendingInstanceModelProbes.delete(key);
11250
+ });
11251
+ this.pendingInstanceModelProbes.set(key, bounded);
11252
+ return bounded;
11485
11253
  }
11486
11254
  catch {
11487
11255
  // listModels is documented never to throw, but a backend constructor can
@@ -11990,12 +11758,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11990
11758
  /** How this instance's backend applies an effort change. */
11991
11759
  effortStrategyFor(instanceName) {
11992
11760
  try {
11993
- const backend = createBackend(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
11994
- const strategy = backend.getEffortStrategy?.() ?? "unsupported";
11995
- // A backend claiming support but listing no levels is unusable either way.
11996
- return strategy !== "unsupported" && (backend.getEffortLevels?.() ?? []).length > 0
11997
- ? strategy
11998
- : "unsupported";
11761
+ const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
11762
+ return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
11999
11763
  }
12000
11764
  catch {
12001
11765
  return "unsupported";
@@ -12004,10 +11768,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12004
11768
  /** Effort levels this instance's backend actually accepts (empty = unsupported). */
12005
11769
  effortLevelsFor(instanceName) {
12006
11770
  try {
12007
- const backend = createBackend(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
12008
- if ((backend.getEffortStrategy?.() ?? "unsupported") === "unsupported")
12009
- return [];
12010
- return backend.getEffortLevels?.() ?? [];
11771
+ const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
11772
+ return strategy === "unsupported" ? [] : levels;
12011
11773
  }
12012
11774
  catch {
12013
11775
  return [];
@@ -12147,21 +11909,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12147
11909
  try {
12148
11910
  if (!existsSync(logFile))
12149
11911
  return undefined;
12150
- const lines = readFileSync(logFile, "utf-8").trim().split("\n");
12151
- // The triggering message is written before forwardToClassicInstance runs
12152
- // and is included separately under [User message]. Exclude that newest
12153
- // log entry so the agent does not receive the same message twice. A chat
12154
- // message may span physical lines, so remove from its timestamped entry
12155
- // header rather than blindly dropping only the final continuation line.
12156
- const entryHeader = /^\[\d{4}-\d{2}-\d{2}T[^\]]+\] <.*> /;
12157
- let currentEntryStart = lines.length - 1;
12158
- while (currentEntryStart > 0 && !entryHeader.test(lines[currentEntryStart])) {
12159
- currentEntryStart--;
12160
- }
12161
- lines.splice(currentEntryStart);
12162
- if (lines.length === 0 || maxLines <= 0)
12163
- return undefined;
12164
- return lines.slice(-maxLines).join("\n") || undefined;
11912
+ // Only the end of today's log is needed (#1161): a day's log used to be read and split whole for every message.
11913
+ return recentChatContext(logFile, maxLines, CHAT_LOG_TAIL_BYTES, CHAT_LOG_TAIL_MAX_BYTES);
12165
11914
  }
12166
11915
  catch {
12167
11916
  return undefined;
@@ -12417,6 +12166,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12417
12166
  // make `agend stop` wait forever for its own queue.
12418
12167
  this.stormWindow.shutdown();
12419
12168
  this.spawnGate.shutdown();
12169
+ this.memoryPressure?.stop();
12170
+ this.stopCliEnvProbes();
12420
12171
  for (const pending of this.startupRetries.values())
12421
12172
  clearTimeout(pending.timer);
12422
12173
  this.startupRetries.clear();