@songsid/agend 2.1.10 → 2.1.11-beta.1

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 (107) 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.d.ts +3 -0
  23. package/dist/backend/kiro.js +19 -4
  24. package/dist/backend/kiro.js.map +1 -1
  25. package/dist/backend/muse.js +3 -2
  26. package/dist/backend/muse.js.map +1 -1
  27. package/dist/backend/opencode.d.ts +31 -4
  28. package/dist/backend/opencode.js +66 -23
  29. package/dist/backend/opencode.js.map +1 -1
  30. package/dist/backend/types.d.ts +14 -1
  31. package/dist/backend/types.js.map +1 -1
  32. package/dist/channel/adapters/discord.js +10 -2
  33. package/dist/channel/adapters/discord.js.map +1 -1
  34. package/dist/cli.js +48 -6
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config.js +1 -0
  37. package/dist/config.js.map +1 -1
  38. package/dist/daemon.d.ts +24 -0
  39. package/dist/daemon.js +109 -12
  40. package/dist/daemon.js.map +1 -1
  41. package/dist/doctor.d.ts +3 -1
  42. package/dist/doctor.js +11 -0
  43. package/dist/doctor.js.map +1 -1
  44. package/dist/fleet-context.d.ts +2 -2
  45. package/dist/fleet-control-audit.d.ts +83 -0
  46. package/dist/fleet-control-audit.js +195 -0
  47. package/dist/fleet-control-audit.js.map +1 -0
  48. package/dist/fleet-manager.d.ts +45 -35
  49. package/dist/fleet-manager.js +327 -384
  50. package/dist/fleet-manager.js.map +1 -1
  51. package/dist/full-restart.js +2 -1
  52. package/dist/full-restart.js.map +1 -1
  53. package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -2
  54. package/dist/host-memory.d.ts +16 -0
  55. package/dist/host-memory.js +32 -0
  56. package/dist/host-memory.js.map +1 -0
  57. package/dist/instance-lifecycle.js +4 -1
  58. package/dist/instance-lifecycle.js.map +1 -1
  59. package/dist/locale.js +62 -16
  60. package/dist/locale.js.map +1 -1
  61. package/dist/log-tail.d.ts +34 -0
  62. package/dist/log-tail.js +66 -0
  63. package/dist/log-tail.js.map +1 -0
  64. package/dist/logger.d.ts +15 -0
  65. package/dist/logger.js +89 -1
  66. package/dist/logger.js.map +1 -1
  67. package/dist/login-controller.d.ts +1 -3
  68. package/dist/login-controller.js +2 -6
  69. package/dist/login-controller.js.map +1 -1
  70. package/dist/login-flows.d.ts +0 -8
  71. package/dist/login-flows.js +1 -11
  72. package/dist/login-flows.js.map +1 -1
  73. package/dist/login-manager.d.ts +5 -20
  74. package/dist/login-manager.js +0 -53
  75. package/dist/login-manager.js.map +1 -1
  76. package/dist/login-window-lock.d.ts +1 -1
  77. package/dist/login-window-lock.js +1 -1
  78. package/dist/memory-pressure.d.ts +41 -0
  79. package/dist/memory-pressure.js +117 -0
  80. package/dist/memory-pressure.js.map +1 -0
  81. package/dist/probe-worker-pool.d.ts +28 -0
  82. package/dist/probe-worker-pool.js +129 -0
  83. package/dist/probe-worker-pool.js.map +1 -0
  84. package/dist/resource-report.d.ts +31 -0
  85. package/dist/resource-report.js +209 -0
  86. package/dist/resource-report.js.map +1 -0
  87. package/dist/rollout-index.d.ts +66 -0
  88. package/dist/rollout-index.js +124 -0
  89. package/dist/rollout-index.js.map +1 -0
  90. package/dist/slash-authz.d.ts +50 -0
  91. package/dist/slash-authz.js +19 -0
  92. package/dist/slash-authz.js.map +1 -0
  93. package/dist/spawn-gate.d.ts +13 -1
  94. package/dist/spawn-gate.js +108 -39
  95. package/dist/spawn-gate.js.map +1 -1
  96. package/dist/storm-window.d.ts +42 -0
  97. package/dist/storm-window.js +84 -1
  98. package/dist/storm-window.js.map +1 -1
  99. package/dist/topic-commands.d.ts +29 -2
  100. package/dist/topic-commands.js +77 -21
  101. package/dist/topic-commands.js.map +1 -1
  102. package/dist/transcript-sources.d.ts +21 -4
  103. package/dist/transcript-sources.js +55 -41
  104. package/dist/transcript-sources.js.map +1 -1
  105. package/dist/types.d.ts +5 -1
  106. package/dist/ui/settings.html +14 -13
  107. package/package.json +1 -1
@@ -4,6 +4,7 @@ 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,17 @@ 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 { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
62
66
  import { runBeforeDeadline } from "./deadline.js";
63
67
  import { LoginWindowLock } from "./login-window-lock.js";
64
68
  import { handleSettingsRequest } from "./settings-api.js";
65
69
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
70
+ import { recentChatContext } from "./log-tail.js";
71
+ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
66
72
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
67
73
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
68
74
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
@@ -223,12 +229,17 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
223
229
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
224
230
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
225
231
  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:"];
232
+ /**
233
+ * Prompt kinds that no longer exist: `/install-cli` (#1131) and the relay-mode login's
234
+ * provider picker (#1139). Their buttons may still be on screen after an upgrade.
235
+ */
236
+ const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:", "login-menu:"];
228
237
  const NEVER_MATCHES = /(?!)/;
229
238
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
230
- const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
231
239
  const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
240
+ /** How much of the end of a Classic chat log is read for context, and the most it grows to. */
241
+ const CHAT_LOG_TAIL_BYTES = 64 * 1024;
242
+ const CHAT_LOG_TAIL_MAX_BYTES = 4 * 1024 * 1024;
232
243
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
233
244
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
234
245
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
@@ -292,6 +303,7 @@ export class FleetManager {
292
303
  lifecycle;
293
304
  stormWindow;
294
305
  spawnGate;
306
+ memoryPressure;
295
307
  /** Fleet-level backend reachability memory (fed by pty_error / startup panes). */
296
308
  backendOutage = new BackendOutageTracker();
297
309
  /** Stable for this FleetManager OS-process lifetime; Daemon objects have their own boot IDs. */
@@ -537,6 +549,11 @@ export class FleetManager {
537
549
  watchdogTimer = null;
538
550
  startedAt = 0;
539
551
  stormOpenNotifyTimer = null;
552
+ memoryLogAt = null;
553
+ memoryDebugAt = null;
554
+ memoryLogLevel = null;
555
+ memoryNoticeAt = null;
556
+ memoryNoticeLevel = null;
540
557
  // Mirror topic: buffer cross-instance messages, flush every 3s
541
558
  mirrorBuffer = [];
542
559
  mirrorTimer = null;
@@ -575,8 +592,10 @@ export class FleetManager {
575
592
  FleetManager.sighupHandlerInstalled = true;
576
593
  }
577
594
  this.stormWindow = new StormWindow();
595
+ this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot) });
578
596
  this.spawnGate = new SpawnGate({
579
597
  storm: this.stormWindow,
598
+ memoryPressure: this.memoryPressure,
580
599
  concurrency: () => this.spawnConcurrency(),
581
600
  staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
582
601
  });
@@ -956,9 +975,54 @@ export class FleetManager {
956
975
  return Math.max(1, Math.min(20, explicit));
957
976
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
958
977
  }
978
+ /** Separate cooldown: changing measurements must not create new text-throttle keys. */
979
+ reportMemoryPressure(snapshot) {
980
+ if (this.shuttingDown)
981
+ return;
982
+ const now = Date.now();
983
+ if (this.memoryDebugAt === null || now - this.memoryDebugAt >= 30_000) {
984
+ this.memoryDebugAt = now;
985
+ this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
986
+ }
987
+ const changed = snapshot.level !== this.memoryLogLevel;
988
+ if (snapshot.level === "normal") {
989
+ if (this.memoryLogLevel === "critical" || this.memoryLogLevel === "elevated") {
990
+ this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
991
+ }
992
+ this.memoryLogLevel = snapshot.level;
993
+ return;
994
+ }
995
+ if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
996
+ this.memoryLogAt = now;
997
+ this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
998
+ ? "Host memory critical — deferring new CLI spawns"
999
+ : snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
1000
+ : "Host memory sample unavailable — slowing new CLI spawns");
1001
+ }
1002
+ this.memoryLogLevel = snapshot.level;
1003
+ if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
1004
+ return;
1005
+ const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
1006
+ if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1007
+ return;
1008
+ const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1009
+ 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"));
1010
+ if (this.notifyFleetError(text, { throttle: false })) {
1011
+ this.memoryNoticeAt = now;
1012
+ this.memoryNoticeLevel = snapshot.level;
1013
+ }
1014
+ }
959
1015
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
960
1016
  bindStormWindowEvents() {
961
1017
  this.stormWindow.on("opened", (snapshot) => {
1018
+ if (snapshot.kind === "window_loss") {
1019
+ // The server is fine and nothing is held: only the instances that lost
1020
+ // their window are affected, they respawn at the storm rate, and their
1021
+ // per-instance incident notices fold into this one (#1127).
1022
+ this.logger.error({ ...snapshot }, "several instances lost their tmux window at once — recovering at a reduced rate");
1023
+ this.notifyFleetError(t("storm.window_loss", snapshot.affected.length, Math.round(this.stormWindow.windowLossWindowMs / 1000), this.stormWindowLossRecoveryConcurrency()));
1024
+ return;
1025
+ }
962
1026
  for (const [name, daemon] of this.daemons) {
963
1027
  if (!daemon.isPaused)
964
1028
  this.stormWindow.addAffected(name);
@@ -986,6 +1050,10 @@ export class FleetManager {
986
1050
  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
1051
  });
988
1052
  }
1053
+ /** What the gate allows while any storm window is open (see SpawnGate.pump). */
1054
+ stormWindowLossRecoveryConcurrency() {
1055
+ return Math.min(4, this.spawnConcurrency());
1056
+ }
989
1057
  formatStormDelay(ms) {
990
1058
  if (ms >= 60_000)
991
1059
  return `${Math.round(ms / 60_000)}m`;
@@ -1226,8 +1294,17 @@ export class FleetManager {
1226
1294
  this.rawFleetConfig = raw;
1227
1295
  this.fleetConfig = loaded;
1228
1296
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
1297
+ this.warnAboutRemovedLoginMode(loaded);
1229
1298
  return this.fleetConfig;
1230
1299
  }
1300
+ relayLoginModeWarned = false;
1301
+ /** `login.mode: relay` was removed (#1139). Still accepted so an upgrade cannot stop a fleet from starting. */
1302
+ warnAboutRemovedLoginMode(config) {
1303
+ if (config.login?.mode !== "relay" || this.relayLoginModeWarned)
1304
+ return;
1305
+ this.relayLoginModeWarned = true;
1306
+ this.logger.warn("fleet.yaml sets login.mode: relay — the chat-relay login was removed; /login always uses the web terminal. Remove the setting.");
1307
+ }
1231
1308
  /**
1232
1309
  * A channel's configurable bot_token_env is an env-key writer too. Refuse
1233
1310
  * an overlap with a registry API key (or a process-reserved key) before the
@@ -1380,10 +1457,30 @@ export class FleetManager {
1380
1457
  }
1381
1458
  await data.respond(await this.topicCommands.runPauseWake(target, action));
1382
1459
  }
1460
+ /** SIGUSR2: restart the instances inside this process. Names who asked (#1120). */
1461
+ onGracefulRestartSignal(rearm) {
1462
+ this.logger.info(`Received SIGUSR2, initiating graceful restart... ${describeSignalSource(this.dataDir, "SIGUSR2")}`);
1463
+ this.restartInstances()
1464
+ .catch(err => this.logger.error({ err }, "Graceful restart failed"))
1465
+ .finally(rearm);
1466
+ }
1467
+ /** SIGUSR1: full process reload. Names who asked (#1120). */
1468
+ onFullRestartSignal() {
1469
+ this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
1470
+ this.gracefulShutdownForReload()
1471
+ .then(() => {
1472
+ this.logger.info("Full restart: shutdown complete, exiting for reload");
1473
+ process.exit(0);
1474
+ })
1475
+ .catch(err => {
1476
+ this.logger.error({ err }, "Full restart: graceful shutdown failed");
1477
+ process.exit(1);
1478
+ });
1479
+ }
1383
1480
  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"));
1481
+ const gate = this.fleetAdminGate(data.userId, adapterId);
1482
+ if (gate !== "ok") {
1483
+ await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
1387
1484
  return;
1388
1485
  }
1389
1486
  const messageId = await data.respond(t("update.progress.preparing", 0));
@@ -1395,7 +1492,11 @@ export class FleetManager {
1395
1492
  const { spawn } = await import("node:child_process");
1396
1493
  const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
1397
1494
  const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
1398
- const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
1495
+ const origin = `slash /update by ${adapterId}:${data.userId}`;
1496
+ recordInternalRequest(this.dataDir, "update", origin);
1497
+ const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
1498
+ detached: true, stdio: "ignore", env: withOrigin(origin),
1499
+ });
1399
1500
  child.once("error", err => this.failUpdateProgress(err.message));
1400
1501
  child.unref();
1401
1502
  }
@@ -1404,6 +1505,7 @@ export class FleetManager {
1404
1505
  await data.respond(t("not_authorized"));
1405
1506
  return;
1406
1507
  }
1508
+ recordInternalRequest(this.dataDir, "restart", `slash /restart ${String(data.options?.mode ?? "graceful")} by ${adapterId}:${data.userId}`);
1407
1509
  if (data.options?.mode !== "full") {
1408
1510
  await data.respond(t("restart.graceful"));
1409
1511
  process.kill(process.pid, "SIGUSR2");
@@ -2102,6 +2204,62 @@ export class FleetManager {
2102
2204
  const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
2103
2205
  return allowed.some(entry => String(entry) === String(userId));
2104
2206
  }
2207
+ /** Whether this adapter has any fleet admin at all (an empty allowlist means the admin commands are off). */
2208
+ hasFleetAdmins(adapterId) {
2209
+ return (this.getChannelConfig(adapterId)?.access?.allowed_users ?? []).length > 0;
2210
+ }
2211
+ /**
2212
+ * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
2213
+ * through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
2214
+ * `denied` only so the reply can say so. An empty list never means "everyone": these commands used to read
2215
+ * the primary channel's list and treat empty as open, so on a fleet that had not set one anyone who could
2216
+ * type a slash command could `/update` the host.
2217
+ */
2218
+ fleetAdminGate(userId, adapterId) {
2219
+ if (!this.hasFleetAdmins(adapterId))
2220
+ return "disabled";
2221
+ return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
2222
+ }
2223
+ /**
2224
+ * The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
2225
+ * Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
2226
+ */
2227
+ async authorizeSlash(data, adapterId) {
2228
+ const channelId = data.channelId;
2229
+ const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
2230
+ const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
2231
+ const scope = classic ? "classic" : fleetTarget ? "fleet" : "none";
2232
+ let speaker = "denied";
2233
+ if (scope !== "classic") {
2234
+ // The same world the typed-message path would consult: the adapter that owns the channel's instance,
2235
+ // else the one the command arrived on.
2236
+ const ownerId = fleetTarget ? this.getInstanceAdapterId(fleetTarget.name) : undefined;
2237
+ const ownerWorld = ownerId ? this.worlds.get(ownerId) : undefined;
2238
+ if (ownerId && !ownerWorld) {
2239
+ speaker = "owner-not-running";
2240
+ }
2241
+ else {
2242
+ const am = ownerWorld?.accessManager ?? this.worlds.get(adapterId)?.accessManager ?? this.accessManager;
2243
+ // An explicit fleet admin always speaks, even if the access state file disagrees with the config.
2244
+ speaker = this.isFleetAdmin(data.userId, adapterId) || (am?.isAllowed(data.userId) ?? false) ? "allowed" : "denied";
2245
+ }
2246
+ }
2247
+ const facts = {
2248
+ command: data.command,
2249
+ guildId: data.guildId,
2250
+ primaryGuildId: String(this.getChannelConfig(adapterId)?.group_id ?? ""),
2251
+ scope,
2252
+ speaker,
2253
+ };
2254
+ const decision = decideSlash(facts);
2255
+ if (decision.allow)
2256
+ return true;
2257
+ this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
2258
+ await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
2259
+ : decision.reason === "wrong-guild" ? "slash.wrong_server"
2260
+ : "not_authorized")).catch(() => { });
2261
+ return false;
2262
+ }
2105
2263
  /** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
2106
2264
  deliveryWorkerMode(target) {
2107
2265
  return resolveDeliveryWorkerMode(this.fleetConfig, target);
@@ -2334,6 +2492,10 @@ export class FleetManager {
2334
2492
  resumePaused = false,
2335
2493
  /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
2336
2494
  transition) {
2495
+ // CLI single-instance cold starts bypass startAll. Start diagnostics before
2496
+ // any lifecycle/wake work; a shutdown must not revive the stopped sampler.
2497
+ if (!this.shuttingDown)
2498
+ this.memoryPressure?.start();
2337
2499
  // Any start supersedes a pending automatic retry (it would otherwise fire
2338
2500
  // into a running instance — harmless, but noisy — or race this start).
2339
2501
  this.cancelStartupRetry(name);
@@ -2448,6 +2610,7 @@ export class FleetManager {
2448
2610
  instanceName: name,
2449
2611
  workingDirectory: config.working_directory,
2450
2612
  reason: "startup",
2613
+ stage: "lifecycle",
2451
2614
  }, async () => {
2452
2615
  if (await this.startInstanceUnattended(name, config, topicMode, "instance"))
2453
2616
  onReady?.(name);
@@ -2587,6 +2750,7 @@ export class FleetManager {
2587
2750
  instanceName: name,
2588
2751
  workingDirectory: this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name),
2589
2752
  reason: "recovery",
2753
+ stage: "lifecycle",
2590
2754
  }, () => this.startConfiguredInstance(name, topicMode));
2591
2755
  if (this.daemons.has(name)) {
2592
2756
  this.logger.info({ name, attempt: attempt + 1 }, "Automatic startup retry succeeded");
@@ -2879,6 +3043,7 @@ export class FleetManager {
2879
3043
  instanceName: name,
2880
3044
  workingDirectory,
2881
3045
  reason: "restart",
3046
+ stage: "lifecycle",
2882
3047
  }, () => this.doRestartSingleInstance(name, opts, transition)))
2883
3048
  .finally(() => {
2884
3049
  this.restartsInFlight.delete(name);
@@ -3254,6 +3419,9 @@ export class FleetManager {
3254
3419
  setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
3255
3420
  }
3256
3421
  this.initializeWebAuthTokens();
3422
+ // Must run before the first General/CLI spawn: pressure can hold startup.
3423
+ // Logs remain available even before the adapters and health listener exist.
3424
+ this.memoryPressure.start();
3257
3425
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
3258
3426
  // Set tmux socket isolation for custom AGEND_HOME
3259
3427
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -3696,27 +3864,10 @@ export class FleetManager {
3696
3864
  this.checkForUpdates();
3697
3865
  this.updateCheckTimer = setInterval(() => this.checkForUpdates(), 24 * 60 * 60 * 1000);
3698
3866
  }, 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
- };
3867
+ const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
3705
3868
  process.once("SIGUSR2", onRestart);
3706
3869
  // 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);
3870
+ process.once("SIGUSR1", () => this.onFullRestartSignal());
3720
3871
  // A SIGHUP may arrive after the PID/general is available but before the
3721
3872
  // rest of startup finishes. Replay one coalesced reload only after all
3722
3873
  // startup-owned lifecycle work and signal handlers are in place.
@@ -4002,6 +4153,10 @@ export class FleetManager {
4002
4153
  connected++;
4003
4154
  }
4004
4155
  const problems = [];
4156
+ const hostMemory = this.memoryPressure.snapshot();
4157
+ if (hostMemory.level === "critical" || hostMemory.level === "elevated") {
4158
+ problems.push(`host memory pressure is ${hostMemory.level}`);
4159
+ }
4005
4160
  if (this.adapterState.size > 0 && connected === 0)
4006
4161
  problems.push("no channel adapter is connected");
4007
4162
  if (counts.crashed > 0)
@@ -4024,6 +4179,7 @@ export class FleetManager {
4024
4179
  adapters: { total: this.adapterState.size, connected, states, details },
4025
4180
  startupComplete: this.startupComplete,
4026
4181
  memory: readFleetMemory(),
4182
+ hostMemory,
4027
4183
  problems,
4028
4184
  };
4029
4185
  }
@@ -4074,6 +4230,8 @@ export class FleetManager {
4074
4230
  this.adapter.on("slash_command", safeHandler(async (data) => {
4075
4231
  if (!isCurrentAdapter())
4076
4232
  return;
4233
+ if (!(await this.authorizeSlash(data, adapterId)))
4234
+ return;
4077
4235
  if (data.command === "start") {
4078
4236
  await this.handleClassicStartSlash(data, adapterId);
4079
4237
  }
@@ -4095,7 +4253,8 @@ export class FleetManager {
4095
4253
  await data.respond(t("classic.no_agent_start"));
4096
4254
  return;
4097
4255
  }
4098
- const replyMsgId = await data.respond("👀");
4256
+ const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
4257
+ const replyMsgId = await data.respond(textForm(status.platform, status.received));
4099
4258
  const username = data.username ?? data.userId;
4100
4259
  ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
4101
4260
  await this.forwardToClassicInstance(name, text, {
@@ -4136,7 +4295,7 @@ export class FleetManager {
4136
4295
  await data.respond(t("classic.no_agent"));
4137
4296
  return;
4138
4297
  }
4139
- const result = await this.topicCommands.sendCompact(name);
4298
+ const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
4140
4299
  await data.respond(result);
4141
4300
  }
4142
4301
  else if (data.command === "steer") {
@@ -4211,8 +4370,7 @@ export class FleetManager {
4211
4370
  // always a fleet-topic instance.
4212
4371
  const collabTarget = this.routing.resolve(data.channelId);
4213
4372
  if (collabTarget) {
4214
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4215
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
4373
+ if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
4216
4374
  await data.respond(t("not_authorized"));
4217
4375
  return;
4218
4376
  }
@@ -4237,8 +4395,7 @@ export class FleetManager {
4237
4395
  await this.handleUpdateSlash(data, adapterId);
4238
4396
  }
4239
4397
  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))) {
4398
+ if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
4242
4399
  await data.respond(t("not_authorized"));
4243
4400
  return;
4244
4401
  }
@@ -4278,13 +4435,9 @@ export class FleetManager {
4278
4435
  else if (data.command === "dashboard") {
4279
4436
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
4280
4437
  // 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"));
4438
+ const gate = this.fleetAdminGate(data.userId, adapterId);
4439
+ if (gate !== "ok") {
4440
+ await data.respond(t(gate === "disabled" ? "dashboard.disabled" : "not_authorized"));
4288
4441
  return;
4289
4442
  }
4290
4443
  await data.respond(this.topicCommands.getDashboardText());
@@ -4298,7 +4451,7 @@ export class FleetManager {
4298
4451
  await data.respond(t("classic.no_agent"));
4299
4452
  return;
4300
4453
  }
4301
- const result = await this.topicCommands.sendCompact(name);
4454
+ const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
4302
4455
  await data.respond(result);
4303
4456
  }
4304
4457
  else if (data.command === "steer") {
@@ -4444,6 +4597,8 @@ export class FleetManager {
4444
4597
  adapter.on("slash_command", safeHandler(async (data) => {
4445
4598
  if (!isCurrentAdapter())
4446
4599
  return;
4600
+ if (!(await this.authorizeSlash(data, adapterId)))
4601
+ return;
4447
4602
  if (data.command === "start") {
4448
4603
  await this.handleClassicStartSlash(data, adapterId);
4449
4604
  }
@@ -4465,7 +4620,8 @@ export class FleetManager {
4465
4620
  await data.respond(t("classic.no_agent_start"));
4466
4621
  return;
4467
4622
  }
4468
- const replyMsgId = await data.respond("👀");
4623
+ const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
4624
+ const replyMsgId = await data.respond(textForm(status.platform, status.received));
4469
4625
  const username = data.username ?? data.userId;
4470
4626
  ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
4471
4627
  await this.forwardToClassicInstance(name, text, {
@@ -4532,8 +4688,7 @@ export class FleetManager {
4532
4688
  // always a fleet-topic instance.
4533
4689
  const collabTarget2 = this.routing.resolve(data.channelId);
4534
4690
  if (collabTarget2) {
4535
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
4536
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
4691
+ if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
4537
4692
  await data.respond(t("not_authorized"));
4538
4693
  return;
4539
4694
  }
@@ -4558,8 +4713,7 @@ export class FleetManager {
4558
4713
  await this.handleUpdateSlash(data, adapterId);
4559
4714
  }
4560
4715
  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))) {
4716
+ if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
4563
4717
  await data.respond(t("not_authorized"));
4564
4718
  return;
4565
4719
  }
@@ -4599,13 +4753,9 @@ export class FleetManager {
4599
4753
  else if (data.command === "dashboard") {
4600
4754
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
4601
4755
  // 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"));
4756
+ const gate = this.fleetAdminGate(data.userId, adapterId);
4757
+ if (gate !== "ok") {
4758
+ await data.respond(t(gate === "disabled" ? "dashboard.disabled" : "not_authorized"));
4609
4759
  return;
4610
4760
  }
4611
4761
  await data.respond(this.topicCommands.getDashboardText());
@@ -4619,7 +4769,7 @@ export class FleetManager {
4619
4769
  await data.respond(t("classic.no_agent"));
4620
4770
  return;
4621
4771
  }
4622
- const result = await this.topicCommands.sendCompact(name);
4772
+ const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
4623
4773
  await data.respond(result);
4624
4774
  }
4625
4775
  else if (data.command === "steer") {
@@ -5273,7 +5423,14 @@ export class FleetManager {
5273
5423
  // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
5274
5424
  if (msg.messageId) {
5275
5425
  const classicCid = threadId || msg.chatId;
5276
- const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
5426
+ // First /start has no registration yet. A sibling can receive the same
5427
+ // targeted command first, then reject its @suffix below; it must not
5428
+ // consume the intended bot's key. Use the same Classic candidate scope
5429
+ // as routing (including foreign forum topics normalized above, #1085).
5430
+ const isTelegramClassicCandidate = msg.source === "telegram" && !threadId
5431
+ && msg.chatId !== String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
5432
+ const isClassicMsg = !!this.classicChannels
5433
+ && (this.classicChannels.hasChannel(classicCid) || isTelegramClassicCandidate);
5277
5434
  const dedupKey = isClassicMsg
5278
5435
  ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
5279
5436
  : `${msg.source}:${msg.chatId}:${msg.messageId}`;
@@ -5432,7 +5589,8 @@ export class FleetManager {
5432
5589
  return;
5433
5590
  }
5434
5591
  // Handle /compact command (admin only)
5435
- if (text === "/compact" || text.startsWith("/compact@")) {
5592
+ const classicCompact = parseCompactCommand(text);
5593
+ if (classicCompact) {
5436
5594
  if (!this.classicChannels.isAdmin(msg.userId)) {
5437
5595
  await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
5438
5596
  return;
@@ -5442,7 +5600,7 @@ export class FleetManager {
5442
5600
  await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
5443
5601
  return;
5444
5602
  }
5445
- const result = await this.topicCommands.sendCompact(compactName);
5603
+ const result = await this.topicCommands.sendCompact(compactName, classicCompact.instructions);
5446
5604
  await msgAdapter?.sendText(chatId, result);
5447
5605
  return;
5448
5606
  }
@@ -7230,6 +7388,9 @@ export class FleetManager {
7230
7388
  pendingCliEnvProbes = new Map();
7231
7389
  /** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
7232
7390
  pendingVendorCliEnvProbes = new Map();
7391
+ pendingInstanceModelProbes = new Map();
7392
+ cliEnvProbePool = new ProbeWorkerPool();
7393
+ cliEnvProbeEpoch = 0;
7233
7394
  /**
7234
7395
  * The status emojis for an instance (#1005): its own `status_emojis`, then
7235
7396
  * its channel's `options.status_emojis`, then the platform built-in. An
@@ -7558,10 +7719,18 @@ export class FleetManager {
7558
7719
  return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
7559
7720
  }
7560
7721
  /** The progress bubble's leading emoji, in the form the channel renders. */
7561
- progressPrefixFor(instanceName) {
7722
+ progressPrefixFor(instanceName, elapsedMs = 0, minElapsedMs = this.progressMinElapsedMs()) {
7562
7723
  const r = this.resolveStatusEmojisFor(instanceName);
7563
7724
  const builtin = builtinStatusEmojis(r.platform).progress_prefix;
7564
- return r.progress_prefix === builtin ? undefined : textForm(r.platform, r.progress_prefix);
7725
+ // Keep the built-in short/elapsed phases, but resolve their status values
7726
+ // too. A custom progress prefix takes precedence in both phases.
7727
+ // Telegram's built-in queued reaction is 👀; its elapsed *text* has always
7728
+ // used ⏳ (text is not limited to Telegram's reaction vocabulary).
7729
+ const queued = r.platform === "telegram" && r.queued === builtinStatusEmojis(r.platform).queued
7730
+ ? builtinStatusEmojis(undefined).queued : r.queued;
7731
+ const value = r.progress_prefix !== builtin ? r.progress_prefix
7732
+ : elapsedMs < minElapsedMs ? r.processing : queued;
7733
+ return textForm(r.platform, value);
7565
7734
  }
7566
7735
  /**
7567
7736
  * Is this inbound reaction one of the fleet's own delivery-status stamps?
@@ -8260,7 +8429,7 @@ export class FleetManager {
8260
8429
  }
8261
8430
  /**
8262
8431
  * 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;
8432
+ * picker and its "sign in now?" prompt; #1139: the relay login's provider picker). Still on screen after an upgrade;
8264
8433
  * a click gets the expired-prompt treatment — a private notice, the buttons
8265
8434
  * collapsed — instead of nothing.
8266
8435
  */
@@ -8281,8 +8450,6 @@ export class FleetManager {
8281
8450
  return true;
8282
8451
  if (await this.handleClassicApproval(data, adapterId, adapter))
8283
8452
  return true;
8284
- if (await this.handleLoginMenuSelect(data, adapterId, adapter))
8285
- return true;
8286
8453
  if (this.handleRetiredPromptButton(data, adapterId, adapter))
8287
8454
  return true;
8288
8455
  if (await this.handleLoginConfirm(data, adapterId, adapter))
@@ -8999,7 +9166,7 @@ export class FleetManager {
8999
9166
  return;
9000
9167
  }
9001
9168
  const publication = this.beginCancelButtonPublication(instanceName, correlationId);
9002
- const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName));
9169
+ const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
9003
9170
  try {
9004
9171
  const sent = await adapter.notifyAlert(chatId, {
9005
9172
  type: "cancel",
@@ -9215,7 +9382,8 @@ export class FleetManager {
9215
9382
  * wiped by the next elapsed tick (#528 trap 2).
9216
9383
  */
9217
9384
  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));
9385
+ const elapsedMs = Date.now() - (entry.startedAt ?? Date.now());
9386
+ return FleetManager.bubbleText(elapsedMs, this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress, this.progressPrefixFor(entry.instanceName, elapsedMs));
9219
9387
  }
9220
9388
  /** Pure composition of header + tool list, exposed for tests. */
9221
9389
  static bubbleText(elapsedMs, activity, minElapsedMs, progress, prefix) {
@@ -9521,16 +9689,11 @@ export class FleetManager {
9521
9689
  // shared ~/.codex; the other CLIs use one real home), so a single sign-in
9522
9690
  // repairs every instance of that backend, and instance delivery, pane-state
9523
9691
  // 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
- */
9692
+ /** Web-terminal login: the only sign-in path (the chat-relay mode was removed, #1139). */
9530
9693
  loginController = null;
9531
9694
  /**
9532
9695
  * One login/install window fleet-wide. Claimed synchronously before the
9533
- * first await by web login, relay login and install alike (sol B1).
9696
+ * first await by web login and install alike (sol B1).
9534
9697
  */
9535
9698
  loginWindow = new LoginWindowLock();
9536
9699
  get webLogin() {
@@ -9766,162 +9929,11 @@ export class FleetManager {
9766
9929
  this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
9767
9930
  }
9768
9931
  }
9769
- /** The sign-in itself (web, or the legacy relay) for a CLI that is installed. */
9932
+ /** The sign-in itself, in the web terminal, for a CLI that is installed. */
9770
9933
  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
- }
9934
+ return this.webLogin.start(backendArg, chat, opts);
9795
9935
  }
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);
9923
- }
9924
- /** Fleet shutdown: end any web/relay login or install window and wait for its confirmed teardown. */
9936
+ /** Fleet shutdown: end any web login or install window and wait for its confirmed teardown. */
9925
9937
  async shutdownLoginWindows() {
9926
9938
  // Close the lock FIRST: in-flight starts parked in a pre-check or
9927
9939
  // ensureSession observe !isCurrent when they resume and stop; no new
@@ -9956,19 +9968,9 @@ export class FleetManager {
9956
9968
  // a stopping fleet must not announce "login failed — fleet shutdown".
9957
9969
  await Promise.all([
9958
9970
  bounded(this.loginController?.shutdown(), "web-login"),
9959
- bounded(this.activeLogin?.session.cancel("cancelled"), "relay-login"),
9960
9971
  bounded(this.activeInstall?.session.cancel("cancelled"), "install"),
9961
9972
  ]);
9962
9973
  }
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
9974
  /** `/login cancel` — abort the active session and remove its window. */
9973
9975
  async cancelLoginSession() {
9974
9976
  // `/login cancel` also stops an install that `/login` started — and the
@@ -9983,31 +9985,7 @@ export class FleetManager {
9983
9985
  }
9984
9986
  if (this.loginController?.isActive())
9985
9987
  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
- }
9988
+ return t("login.no_session");
10011
9989
  }
10012
9990
  /**
10013
9991
  * After a successful login: wake the paused instances of that backend AND
@@ -10125,20 +10103,6 @@ export class FleetManager {
10125
10103
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, text);
10126
10104
  return true;
10127
10105
  }
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
10106
  // ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
10143
10107
  //
10144
10108
  // Same dedicated-window model as /login (and the same LoginSession state
@@ -10156,7 +10120,7 @@ export class FleetManager {
10156
10120
  return t("install.unsupported", backendArg);
10157
10121
  if (checkBinaryInstalled(info.binary))
10158
10122
  return t("install.already", backend, info.binary);
10159
- // Reserve the fleet-wide window before the first await (shared with web/relay
10123
+ // Reserve the fleet-wide window before the first await (shared with web
10160
10124
  // login). Owned by this method until the session is published.
10161
10125
  const claim = this.loginWindow.tryClaim("install", backend);
10162
10126
  if (!claim)
@@ -10175,10 +10139,9 @@ export class FleetManager {
10175
10139
  this.loginWindow.release(claim);
10176
10140
  return t("install.failed", backend, err.message);
10177
10141
  }
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.
10142
+ // A synthetic flow: success is decided by the installer's exit code
10143
+ // (LoginSession treats a clean exit as success), never by pane text. The
10144
+ // session only judges that verdict; it does not read the output for URLs or prompts.
10182
10145
  const flow = {
10183
10146
  backend,
10184
10147
  command: info.install,
@@ -10186,9 +10149,6 @@ export class FleetManager {
10186
10149
  timeoutMs: 10 * 60 * 1000,
10187
10150
  };
10188
10151
  const session = new LoginSession(flow, tmux, {
10189
- onMenu: () => { },
10190
- onAuthHint: () => { },
10191
- onNeedInput: () => { },
10192
10152
  onDone: async ({ ok, detail, cleanupFailed }) => {
10193
10153
  this.activeInstall = null;
10194
10154
  this.loginWindow.release(claim);
@@ -11145,7 +11105,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11145
11105
  for (const backend of SYSINFO_BACKEND_IDS) {
11146
11106
  if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
11147
11107
  continue;
11148
- void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
11108
+ void this.probeBackendBounded(backend).catch(err => {
11149
11109
  this.logger.debug({ err, backend }, "Background CLI version refresh failed");
11150
11110
  });
11151
11111
  }
@@ -11168,19 +11128,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11168
11128
  return vendorFlight;
11169
11129
  if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
11170
11130
  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);
11131
+ if (this.shuttingDown)
11132
+ return null;
11133
+ const instanceDir = join(getAgendHome(), "cli-env");
11134
+ const epoch = this.cliEnvProbeEpoch;
11135
+ const input = {
11136
+ mode: "env", backend, instanceDir,
11137
+ config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
11138
+ refreshVendorCatalog: opts.refreshVendorCatalog === true,
11139
+ };
11140
+ const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
11141
+ if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
11142
+ return null;
11143
+ try {
11144
+ return this.persistCliEnvProbeResult(backend, probed);
11145
+ }
11146
+ catch (err) {
11147
+ this.logger.warn({ backend, err }, "CLI env cache write failed");
11148
+ return null;
11149
+ }
11150
+ }).finally(() => {
11184
11151
  const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
11185
11152
  if (flights.get(backend) === bounded)
11186
11153
  flights.delete(backend);
@@ -11188,18 +11155,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11188
11155
  (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
11189
11156
  return bounded;
11190
11157
  }
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
- };
11158
+ stopCliEnvProbes() {
11159
+ this.cliEnvProbeEpoch++;
11160
+ this.cliEnvProbePool.close();
11161
+ this.pendingCliEnvProbes.clear();
11162
+ this.pendingVendorCliEnvProbes.clear();
11163
+ this.pendingInstanceModelProbes.clear();
11164
+ }
11165
+ runBackendProbeWorker(key, input) {
11166
+ if (this.shuttingDown)
11167
+ return Promise.resolve(null);
11168
+ this.cliEnvProbePool.reopen();
11169
+ return this.cliEnvProbePool.run(key, () => this.startCliEnvProbeWorker(input), {
11170
+ deadlineMs: CLI_ENV_PROBE_DEADLINE_MS,
11171
+ 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"),
11172
+ onError: err => this.logger.warn({ backend: input.backend, err }, "CLI env worker failed to start or stop"),
11173
+ });
11174
+ }
11175
+ startCliEnvProbeWorker(input) {
11176
+ const { backend } = input;
11177
+ const workerData = input;
11203
11178
  // `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
11204
11179
  // Published builds load the compiled worker and require no tsx dependency.
11205
11180
  const worker = import.meta.url.endsWith(".ts")
@@ -11207,35 +11182,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11207
11182
  : new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
11208
11183
  let settled = false;
11209
11184
  let resolveProbe;
11185
+ let markStopped;
11186
+ const stopped = new Promise(resolve => { markStopped = resolve; });
11210
11187
  const promise = new Promise(resolve => { resolveProbe = resolve; });
11211
11188
  const finish = (value) => {
11212
11189
  if (settled)
11213
11190
  return;
11214
11191
  settled = true;
11215
11192
  resolveProbe(value);
11216
- void worker.terminate().catch(err => {
11193
+ // Admission stays occupied until terminate resolves or an exit is seen.
11194
+ // A rejected termination does not prove the isolate has stopped.
11195
+ try {
11196
+ void worker.terminate().then(markStopped).catch(err => {
11197
+ this.logger.debug({ backend, err }, "CLI env worker termination failed");
11198
+ });
11199
+ }
11200
+ catch (err) {
11217
11201
  this.logger.debug({ backend, err }, "CLI env worker termination failed");
11218
- });
11202
+ }
11219
11203
  };
11220
11204
  worker.once("message", (message) => {
11221
11205
  if (settled)
11222
11206
  return;
11223
- if (!message.ok) {
11207
+ if (!message.ok)
11224
11208
  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
- }
11209
+ finish(message.ok ? message.result ?? null : null);
11239
11210
  });
11240
11211
  worker.once("error", err => {
11241
11212
  if (settled)
@@ -11244,12 +11215,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11244
11215
  finish(null);
11245
11216
  });
11246
11217
  worker.once("exit", code => {
11218
+ markStopped();
11247
11219
  if (settled)
11248
11220
  return;
11249
11221
  this.logger.warn({ backend, code }, "CLI env worker exited without a result");
11250
11222
  finish(null);
11251
11223
  });
11252
- return { promise, terminate: () => { finish(null); } };
11224
+ return { promise, stopped, terminate: () => { finish(null); } };
11253
11225
  }
11254
11226
  /**
11255
11227
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
@@ -11299,32 +11271,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11299
11271
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
11300
11272
  return option.id === currentModel ? `✓ ${label}` : label;
11301
11273
  }
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. */
11274
+ /** Preserve the existing cache merge rules for worker probes. */
11328
11275
  persistCliEnvProbeResult(backend, probed) {
11329
11276
  const env = { backend, probedAt: Date.now(), ...probed };
11330
11277
  // An empty result must never overwrite a catalog we already have. Some
@@ -11471,17 +11418,30 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11471
11418
  try {
11472
11419
  const inst = this.fleetConfig?.instances?.[instanceName];
11473
11420
  const instanceDir = this.getInstanceDir(instanceName);
11474
- const be = createBackend(backend, instanceDir);
11475
- if (!be.listModels)
11476
- return [];
11477
- return await be.listModels({
11421
+ const config = structuredClone({
11478
11422
  workingDirectory: inst?.working_directory ?? "",
11479
11423
  instanceDir,
11480
11424
  instanceName,
11481
11425
  mcpServers: {},
11482
11426
  model: inst?.model,
11483
11427
  backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
11484
- }) ?? [];
11428
+ });
11429
+ // Include the config snapshot, so a changed provider/profile cannot join
11430
+ // a still-running probe for the previous instance configuration.
11431
+ const key = JSON.stringify({ backend, instanceDir, config });
11432
+ const pending = this.pendingInstanceModelProbes.get(key);
11433
+ if (pending)
11434
+ return pending;
11435
+ const epoch = this.cliEnvProbeEpoch;
11436
+ const bounded = this.runBackendProbeWorker(`instance:${backend}:${instanceDir}`, {
11437
+ mode: "models", backend, instanceDir, config,
11438
+ }).then(result => Array.isArray(result) && !this.shuttingDown && epoch === this.cliEnvProbeEpoch ? result : [])
11439
+ .finally(() => {
11440
+ if (this.pendingInstanceModelProbes.get(key) === bounded)
11441
+ this.pendingInstanceModelProbes.delete(key);
11442
+ });
11443
+ this.pendingInstanceModelProbes.set(key, bounded);
11444
+ return bounded;
11485
11445
  }
11486
11446
  catch {
11487
11447
  // listModels is documented never to throw, but a backend constructor can
@@ -11990,12 +11950,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11990
11950
  /** How this instance's backend applies an effort change. */
11991
11951
  effortStrategyFor(instanceName) {
11992
11952
  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";
11953
+ const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
11954
+ return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
11999
11955
  }
12000
11956
  catch {
12001
11957
  return "unsupported";
@@ -12004,10 +11960,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12004
11960
  /** Effort levels this instance's backend actually accepts (empty = unsupported). */
12005
11961
  effortLevelsFor(instanceName) {
12006
11962
  try {
12007
- const backend = createBackend(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
12008
- if ((backend.getEffortStrategy?.() ?? "unsupported") === "unsupported")
12009
- return [];
12010
- return backend.getEffortLevels?.() ?? [];
11963
+ const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
11964
+ return strategy === "unsupported" ? [] : levels;
12011
11965
  }
12012
11966
  catch {
12013
11967
  return [];
@@ -12147,21 +12101,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12147
12101
  try {
12148
12102
  if (!existsSync(logFile))
12149
12103
  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;
12104
+ // Only the end of today's log is needed (#1161): a day's log used to be read and split whole for every message.
12105
+ return recentChatContext(logFile, maxLines, CHAT_LOG_TAIL_BYTES, CHAT_LOG_TAIL_MAX_BYTES);
12165
12106
  }
12166
12107
  catch {
12167
12108
  return undefined;
@@ -12417,6 +12358,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12417
12358
  // make `agend stop` wait forever for its own queue.
12418
12359
  this.stormWindow.shutdown();
12419
12360
  this.spawnGate.shutdown();
12361
+ this.memoryPressure?.stop();
12362
+ this.stopCliEnvProbes();
12420
12363
  for (const pending of this.startupRetries.values())
12421
12364
  clearTimeout(pending.timer);
12422
12365
  this.startupRetries.clear();