@songsid/agend 2.3.4-beta.6 → 2.3.4-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/agend-home.d.ts +13 -0
  2. package/dist/agend-home.js +42 -0
  3. package/dist/agend-home.js.map +1 -1
  4. package/dist/auth-api.js +2 -2
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/kiro-identity.d.ts +2 -0
  7. package/dist/backend/kiro-identity.js +13 -6
  8. package/dist/backend/kiro-identity.js.map +1 -1
  9. package/dist/backend/kiro.d.ts +3 -0
  10. package/dist/backend/kiro.js +57 -4
  11. package/dist/backend/kiro.js.map +1 -1
  12. package/dist/backend/types.d.ts +6 -1
  13. package/dist/backend/types.js +3 -0
  14. package/dist/backend/types.js.map +1 -1
  15. package/dist/backend-availability.d.ts +6 -0
  16. package/dist/backend-availability.js +13 -5
  17. package/dist/backend-availability.js.map +1 -1
  18. package/dist/channel/mcp-tools.js +1 -1
  19. package/dist/channel/mcp-tools.js.map +1 -1
  20. package/dist/cli.js +4 -6
  21. package/dist/cli.js.map +1 -1
  22. package/dist/daemon.d.ts +40 -0
  23. package/dist/daemon.js +354 -15
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/delivery-consumed-watch.d.ts +23 -1
  26. package/dist/delivery-consumed-watch.js +30 -3
  27. package/dist/delivery-consumed-watch.js.map +1 -1
  28. package/dist/delivery-reconciliation.d.ts +7 -0
  29. package/dist/delivery-reconciliation.js +9 -1
  30. package/dist/delivery-reconciliation.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +33 -7
  32. package/dist/fleet-manager.js +196 -67
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/fleet-yaml-patch.d.ts +3 -0
  35. package/dist/fleet-yaml-patch.js +103 -0
  36. package/dist/fleet-yaml-patch.js.map +1 -0
  37. package/dist/general-knowledge/skills/concepts/SKILL.md +390 -0
  38. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  39. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  40. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  41. package/dist/instance-lifecycle.d.ts +2 -0
  42. package/dist/instance-lifecycle.js +38 -4
  43. package/dist/instance-lifecycle.js.map +1 -1
  44. package/dist/kiro-engine-status.d.ts +11 -0
  45. package/dist/kiro-engine-status.js +6 -0
  46. package/dist/kiro-engine-status.js.map +1 -1
  47. package/dist/kiro-resume-summary.d.ts +37 -0
  48. package/dist/kiro-resume-summary.js +85 -0
  49. package/dist/kiro-resume-summary.js.map +1 -0
  50. package/dist/locale.js +32 -0
  51. package/dist/locale.js.map +1 -1
  52. package/dist/login-controller.js +9 -1
  53. package/dist/login-controller.js.map +1 -1
  54. package/dist/login-flows.d.ts +2 -0
  55. package/dist/login-flows.js +5 -2
  56. package/dist/login-flows.js.map +1 -1
  57. package/dist/needs-you-hub.js +1 -1
  58. package/dist/needs-you-hub.js.map +1 -1
  59. package/dist/needs-you.d.ts +6 -0
  60. package/dist/needs-you.js +8 -2
  61. package/dist/needs-you.js.map +1 -1
  62. package/dist/package-preimage.js +27 -2
  63. package/dist/package-preimage.js.map +1 -1
  64. package/dist/queued-handoffs.d.ts +33 -0
  65. package/dist/queued-handoffs.js +75 -0
  66. package/dist/queued-handoffs.js.map +1 -0
  67. package/dist/reply-buttons-controller.js +10 -0
  68. package/dist/reply-buttons-controller.js.map +1 -1
  69. package/dist/stall-reporter.d.ts +21 -0
  70. package/dist/stall-reporter.js +82 -0
  71. package/dist/stall-reporter.js.map +1 -0
  72. package/dist/tmux-control.d.ts +2 -2
  73. package/dist/tmux-control.js +4 -2
  74. package/dist/tmux-control.js.map +1 -1
  75. package/dist/tmux-exec-helper.d.ts +6 -3
  76. package/dist/tmux-exec-helper.js +115 -53
  77. package/dist/tmux-exec-helper.js.map +1 -1
  78. package/dist/tool-permissions.d.ts +5 -2
  79. package/dist/tool-permissions.js +6 -5
  80. package/dist/tool-permissions.js.map +1 -1
  81. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  82. package/dist/tunnel/cloudflared-install.js +20 -1
  83. package/dist/tunnel/cloudflared-install.js.map +1 -1
  84. package/dist/tunnel/cloudflared.d.ts +8 -1
  85. package/dist/tunnel/cloudflared.js +34 -6
  86. package/dist/tunnel/cloudflared.js.map +1 -1
  87. package/dist/tunnel/lease.d.ts +3 -3
  88. package/dist/tunnel/lease.js +23 -19
  89. package/dist/tunnel/lease.js.map +1 -1
  90. package/dist/tunnel/manager.js +6 -7
  91. package/dist/tunnel/manager.js.map +1 -1
  92. package/dist/ui/panel-needs.js +4 -3
  93. package/dist/ui/shared/app-i18n.js +2 -2
  94. package/dist/ui/shared/app-needs.js +1 -1
  95. package/dist/ui/shared/app.css +16 -1
  96. package/dist/ui/shared/panel-view.js +266 -17
  97. package/dist/ui/shared/view-strings.js +26 -0
  98. package/dist/unattended-install.d.ts +1 -1
  99. package/dist/unattended-install.js +19 -5
  100. package/dist/unattended-install.js.map +1 -1
  101. package/dist/view-api.d.ts +15 -0
  102. package/dist/view-api.js +94 -33
  103. package/dist/view-api.js.map +1 -1
  104. package/dist/web-login.d.ts +4 -3
  105. package/dist/web-login.js +23 -14
  106. package/dist/web-login.js.map +1 -1
  107. package/dist/web-session.d.ts +5 -0
  108. package/dist/web-session.js +20 -8
  109. package/dist/web-session.js.map +1 -1
  110. package/launcher/agend-install.cjs +16 -2
  111. package/package.json +1 -1
@@ -27,7 +27,7 @@ import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from
27
27
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
28
28
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
29
29
  import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
30
- import { unattendedInstallCommand } from "./unattended-install.js";
30
+ import { unattendedInstallCommand, loginShell } from "./unattended-install.js";
31
31
  import { InstallStream } from "./install-stream.js";
32
32
  import { namedTestLane, runNamedLinkTest } from "./tunnel/named-link-test.js";
33
33
  import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
@@ -39,7 +39,7 @@ import { withinBudget } from "./monotonic-budget.js";
39
39
  import { measureSyncWork } from "./sync-work-attribution.js";
40
40
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
41
41
  import { ProfileControlServer } from "./profile-control.js";
42
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
42
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, realpathSync, lstatSync, closeSync } from "node:fs";
43
43
  import { atomicWriteFileSync } from "./atomic-write.js";
44
44
  import { createHash, randomBytes, randomUUID } from "node:crypto";
45
45
  import { freemem, totalmem, homedir } from "node:os";
@@ -56,6 +56,7 @@ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./ki
56
56
  import { fleetLabel } from "./fleet-label.js";
57
57
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
58
58
  import { formatUpdateProgress } from "./update-progress.js";
59
+ import { startStallReporter } from "./stall-reporter.js";
59
60
  import { sdNotify, sdNotifyBlocking, setSdNotifyWarn, startSdWatchdog } from "./sd-notify.js";
60
61
  import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
61
62
  import { readFleetMemory } from "./process-memory.js";
@@ -74,7 +75,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
74
75
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
75
76
  import { EventLog } from "./event-log.js";
76
77
  import { binaryProbe } from "./binary-probe.js";
77
- import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
78
+ import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
78
79
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
79
80
  import { OutboxOpenError } from "./outbox-open-error.js";
80
81
  import { AdapterWorld } from "./adapter-world.js";
@@ -627,6 +628,14 @@ function emojiListFilter(opts) {
627
628
  }
628
629
  /** #1920: a guest's scope until phase 2 derives one: nothing. */
629
630
  const NO_GUEST_SCOPE = new Set();
631
+ /** #1490 row 27: a restart asked for one lifecycle of an instance after it was stopped, restarted or replaced. */
632
+ export class RestartSupersededError extends Error {
633
+ /** `shown`: the instance as the chat list names it (shownInstanceName). */
634
+ constructor(shown) {
635
+ super(t("restart.superseded", shown));
636
+ this.name = "RestartSupersededError";
637
+ }
638
+ }
630
639
  export class FleetManager {
631
640
  dataDir;
632
641
  static signalTarget = null;
@@ -878,6 +887,7 @@ export class FleetManager {
878
887
  startupRetryNotices = new Map();
879
888
  /** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
880
889
  unsupportedCliNotices = new Map();
890
+ kiroCompatibilityHolds = new Map();
881
891
  kiroIncompatNotices = new Map();
882
892
  /** Backoff between automatic startup retries; the last step repeats while the backend is down. */
883
893
  static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
@@ -1026,6 +1036,7 @@ export class FleetManager {
1026
1036
  watchdogTimer = null;
1027
1037
  startupMemoryDeadline = null;
1028
1038
  eventLoopWatch = null;
1039
+ stallReporter = null;
1029
1040
  startedAt = 0;
1030
1041
  stormOpenNotifyTimer = null;
1031
1042
  memoryLogAt = null;
@@ -1069,6 +1080,37 @@ export class FleetManager {
1069
1080
  webChatRemovals = new Map();
1070
1081
  /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
1071
1082
  webChatRemovalsPending = new Map();
1083
+ /**
1084
+ * #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
1085
+ * restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
1086
+ * or menu is claimed or offered, asked after every await before acting by name.
1087
+ */
1088
+ /** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
1089
+ shownInstanceName(name) {
1090
+ try {
1091
+ const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === name) ?? null;
1092
+ return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic, profile: profileIdentities(this.dataDir).get(name) }).display_name || name;
1093
+ }
1094
+ catch {
1095
+ return name;
1096
+ }
1097
+ }
1098
+ lifecyclePin(name) {
1099
+ // Fail closed: a lifecycle that cannot say its epoch holds nothing (and never stops a prompt from being posted).
1100
+ let epoch;
1101
+ try {
1102
+ epoch = this.lifecycle?.epochOf(name);
1103
+ }
1104
+ catch {
1105
+ return () => false;
1106
+ }
1107
+ return () => { try {
1108
+ return !this.shuttingDown && this.lifecycle?.epochOf(name) === epoch;
1109
+ }
1110
+ catch {
1111
+ return false;
1112
+ } };
1113
+ }
1072
1114
  webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
1073
1115
  /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
1074
1116
  webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
@@ -5641,6 +5683,23 @@ export class FleetManager {
5641
5683
  if (resumePaused)
5642
5684
  this.lifecycle.markActivitySeedNow(name);
5643
5685
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
5686
+ // #1490 row 67: off-loop resolve before skill/instruction publication
5687
+ // constructs a backend (resolveKnowledgeWorkDir). Capture stop/lifecycle
5688
+ // ownership first: a Stop during this wait finishes and bumps the epoch,
5689
+ // and the inner lifecycle check would otherwise capture the new epoch and
5690
+ // accept the stale start. Lifecycle start looks up again before its own
5691
+ // createBackend; the probe cache makes that cheap.
5692
+ const epoch = this.lifecycle.epochOf(name);
5693
+ const stopGen = this.explicitStopGeneration.get(name) ?? 0;
5694
+ const resolved = await resolveBackendBinary(backend);
5695
+ execution?.assert();
5696
+ if (this.lifecycle.epochOf(name) !== epoch
5697
+ || (this.explicitStopGeneration.get(name) ?? 0) !== stopGen) {
5698
+ throw new SupersededStartError(name);
5699
+ }
5700
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
5701
+ if (binary)
5702
+ noteResolvedBinary(binary, resolved ?? binary);
5644
5703
  if (config.general_topic) {
5645
5704
  this.ensureGeneralInstructions(config.working_directory, backend, name);
5646
5705
  }
@@ -6097,6 +6156,17 @@ export class FleetManager {
6097
6156
  this.queueKiroIncompatNotice(name, err.message);
6098
6157
  return true;
6099
6158
  }
6159
+ /** No pane is needed for a compatibility refusal to remain in Needs you. */
6160
+ setKiroCompatibilityHold(name, message) {
6161
+ const holds = this.kiroCompatibilityHolds ??= new Map();
6162
+ if ((message === null && !holds.has(name)) || (message !== null && holds.get(name)?.message === message))
6163
+ return;
6164
+ if (message === null)
6165
+ holds.delete(name);
6166
+ else
6167
+ holds.set(name, { message, since: Date.now() });
6168
+ this.needsYou?.poke();
6169
+ }
6100
6170
  /**
6101
6171
  * LifecycleContext: a kiro instance the installed kiro-cli cannot run as
6102
6172
  * configured (#1109) — at start, or at a respawn after kiro-cli replaced
@@ -6291,6 +6361,8 @@ export class FleetManager {
6291
6361
  }
6292
6362
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
6293
6363
  async restartSingleInstance(name, opts) {
6364
+ if (opts?.ownerCurrent && !opts.ownerCurrent())
6365
+ throw new RestartSupersededError(this.shownInstanceName(name));
6294
6366
  const migration = this.kiroMigrationRuns?.get(name);
6295
6367
  if (migration && migration.token !== opts?.kiroMigration)
6296
6368
  throw new Error(t("kiro_migrate.in_progress"));
@@ -7591,7 +7663,7 @@ export class FleetManager {
7591
7663
  return { kind: "refused" };
7592
7664
  if (device.tier === "guest")
7593
7665
  return this.requestGuestDeviceCode(device, input, token);
7594
- const paused = this.webLoginCodes.pausedForMs();
7666
+ const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
7595
7667
  if (paused > 0)
7596
7668
  return { kind: "limited", retryAfterMs: paused };
7597
7669
  const admitted = this.codeRequestLimiter.admit(device.idHash);
@@ -8076,6 +8148,9 @@ export class FleetManager {
8076
8148
  this.watchdogTimer = startSdWatchdog();
8077
8149
  this.eventLoopWatch?.stop();
8078
8150
  this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
8151
+ // #3009: the event-loop watch runs on the main thread; this one does not, so a stall that never ends is still named.
8152
+ this.stallReporter?.stop();
8153
+ this.stallReporter = startStallReporter({ logFile: join(this.dataDir, "daemon.log") });
8079
8154
  try {
8080
8155
  await this.startAllWithinDeadline(configPath, deadline);
8081
8156
  }
@@ -8089,6 +8164,8 @@ export class FleetManager {
8089
8164
  this.watchdogTimer = null;
8090
8165
  this.eventLoopWatch?.stop();
8091
8166
  this.eventLoopWatch = null;
8167
+ this.stallReporter?.stop();
8168
+ this.stallReporter = null;
8092
8169
  }
8093
8170
  }
8094
8171
  }
@@ -8356,6 +8433,10 @@ export class FleetManager {
8356
8433
  const generalDir = join(getAgendHome(), name);
8357
8434
  mkdirSync(generalDir, { recursive: true, mode: 0o700 });
8358
8435
  const backendName = fleet.defaults.backend ?? "claude-code";
8436
+ const resolved = await resolveBackendBinary(backendName);
8437
+ const binary = BACKEND_INSTALLATION_INFO[backendName]?.binary;
8438
+ if (binary)
8439
+ noteResolvedBinary(binary, resolved ?? binary);
8359
8440
  this.ensureGeneralInstructions(generalDir, backendName, name);
8360
8441
  fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
8361
8442
  generalsCreated = true;
@@ -12866,6 +12947,9 @@ export class FleetManager {
12866
12947
  await this.lifecycle.remove(name, authorization, execution, transition);
12867
12948
  else
12868
12949
  await this.lifecycle.remove(name, authorization);
12950
+ // #1490 row 27: its hang/exit offers were for the instance now gone (stopInstance clears them; removal stops
12951
+ // through the lifecycle directly). The offers' own lifecycle pins refuse them too.
12952
+ this.clearNoncePromptsForInstance(name);
12869
12953
  // #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
12870
12954
  // throws above: its replies stay its own).
12871
12955
  this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
@@ -14540,6 +14624,7 @@ export class FleetManager {
14540
14624
  threadId: opts.threadId,
14541
14625
  expiredText: opts.expiredText,
14542
14626
  createdAt: Date.now(),
14627
+ lifecycleCurrent: this.lifecyclePin(opts.instanceName),
14543
14628
  ...opts.extra,
14544
14629
  };
14545
14630
  entry.timer = setTimeout(() => {
@@ -15062,7 +15147,8 @@ export class FleetManager {
15062
15147
  out.push({
15063
15148
  name, state: p.state ?? undefined,
15064
15149
  interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
15065
- interactionSummary: p.interaction_summary ?? null,
15150
+ interactionSummary: p.interaction?.kind === "kiro_choice" ? t("kiro3.running") : p.interaction_summary ?? null,
15151
+ kiroCompatibility: this.kiroCompatibilityHolds?.get(name),
15066
15152
  pauseReason, pausedAt,
15067
15153
  crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
15068
15154
  signIn: pauseReason === "auth" ? this.signInFor(name) : null,
@@ -16253,21 +16339,27 @@ export class FleetManager {
16253
16339
  if (claimed === "consumed")
16254
16340
  return true;
16255
16341
  const { entry: pending, action } = claimed;
16342
+ // #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
16343
+ const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
16256
16344
  this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
16257
16345
  if (action === "ignore") {
16258
16346
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
16259
16347
  return true;
16260
16348
  }
16349
+ if (!current()) {
16350
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
16351
+ return true;
16352
+ }
16261
16353
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
16262
16354
  try {
16263
- await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
16355
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
16264
16356
  ...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
16265
16357
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
16266
16358
  }
16267
16359
  catch (err) {
16268
16360
  const message = err instanceof Error ? err.message : String(err);
16269
16361
  this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
16270
- await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
16362
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
16271
16363
  }
16272
16364
  return true;
16273
16365
  }
@@ -17379,7 +17471,10 @@ export class FleetManager {
17379
17471
  return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
17380
17472
  dataDir: this.dataDir, lock: this.loginWindow,
17381
17473
  current: owner => this.browserSignInOwnerCurrent(owner),
17382
- installed: async () => ONBOARDING_BACKENDS.filter(backend => this.isCliInstalled(backend)),
17474
+ installed: async () => {
17475
+ const found = await Promise.all(ONBOARDING_BACKENDS.map(async (backend) => (await this.isCliInstalledOffLoop(backend)) ? backend : null));
17476
+ return found.filter((backend) => backend !== null);
17477
+ },
17383
17478
  checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
17384
17479
  checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
17385
17480
  transport: () => createStructuredSignInTransport(),
@@ -17483,13 +17578,13 @@ export class FleetManager {
17483
17578
  * #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
17484
17579
  * with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
17485
17580
  */
17486
- startWebDashboardLogin(backendArg, chat, opts) {
17581
+ async startWebDashboardLogin(backendArg, chat, opts) {
17487
17582
  const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
17488
17583
  if (isRemovedBackend(wanted))
17489
- return Promise.resolve(removedBackendMessage(wanted));
17584
+ return removedBackendMessage(wanted);
17490
17585
  const info = BACKEND_INSTALLATION_INFO[wanted];
17491
- if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
17492
- return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
17586
+ if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !(await this.isCliInstalledOffLoop(wanted))) {
17587
+ return t("login.web_dashboard_not_installed", wanted, info.install);
17493
17588
  }
17494
17589
  if (!opts.skipAuthCheck)
17495
17590
  this.recordLoginFlow(wanted, "login", chat.userId);
@@ -17537,8 +17632,10 @@ export class FleetManager {
17537
17632
  ? removedBackendMessage(backend)
17538
17633
  : !installed.has(backend)
17539
17634
  ? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
17635
+ // #2981: one short line in the bare /login list — the full refusal (why, and what to run) is `/login <it>`'s;
17636
+ // a bare /login used to post Antigravity's whole paragraph to someone who never asked about it.
17540
17637
  : flow?.remoteLogin === "unsupported"
17541
- ? t("login.remote_unsupported_agent_cli", backend, flow.command)
17638
+ ? t("login.host_only", flow.command)
17542
17639
  : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
17543
17640
  await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
17544
17641
  .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
@@ -17981,9 +18078,14 @@ export class FleetManager {
17981
18078
  // session only judges that verdict; it does not read the output for URLs or prompts.
17982
18079
  // M-2281 (#2980): no terminal to ask on, low priority, stoppable as a whole, the user's own $SHELL — see
17983
18080
  // unattended-install.ts. A prompt nobody can answer fails at once instead of waiting out the timeout.
18081
+ const shell = (await loginShell()) ?? null;
18082
+ if (!this.loginWindow.isCurrent(claim)) {
18083
+ this.loginWindow.release(claim);
18084
+ return t("login.web_shutting_down");
18085
+ }
17984
18086
  const flow = {
17985
18087
  backend,
17986
- command: unattendedInstallCommand(info.unattended ?? info.install),
18088
+ command: unattendedInstallCommand(info.unattended ?? info.install, shell),
17987
18089
  successPattern: /$^/,
17988
18090
  timeoutMs: this.installTimeoutMs,
17989
18091
  };
@@ -18027,7 +18129,9 @@ export class FleetManager {
18027
18129
  return;
18028
18130
  // The installer may only have added the binary to a profile PATH; a
18029
18131
  // fresh login shell sees that, the fleet process's PATH may not.
18030
- const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
18132
+ // #3009 (leader, cid-3017-3029-overlap): both lookups off the fleet's main thread — the login shell's own check,
18133
+ // then the installer's own directories through backend-availability's check (a killable child tests each one).
18134
+ const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? (await this.locateInstalledBackend(backend));
18031
18135
  if (handoff.cancelled || this.installHandoff !== handoff)
18032
18136
  return;
18033
18137
  if (!installedAt) {
@@ -18119,38 +18223,27 @@ export class FleetManager {
18119
18223
  */
18120
18224
  async locateBinaryOnLoginShell(binary) {
18121
18225
  try {
18122
- const result = await runOnLoginShell(`command -v ${binary}`);
18226
+ // #3009: the executable-file check runs inside the same login shell (a killable child), never as accessSync/statSync
18227
+ // on the fleet's thread — a path on a filesystem that stopped answering held the whole fleet there.
18228
+ const result = await runOnLoginShell(`p=$(command -v ${binary}) && [ -f "$p" ] && [ -x "$p" ] && printf '%s\n' "$p"`);
18123
18229
  if (result.status !== 0)
18124
18230
  return null;
18125
18231
  const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
18126
- if (!isAbsolute(path))
18127
- return null;
18128
- accessSync(path, fsConstants.X_OK);
18129
- return statSync(path).isFile() ? path : null;
18232
+ return isAbsolute(path) ? path : null;
18130
18233
  }
18131
18234
  catch {
18132
18235
  return null;
18133
18236
  }
18134
18237
  }
18135
18238
  /**
18136
- * #1092: the binary in one of the directories its own installer installs to,
18137
- * when no login shell can see it (the installer recorded its PATH in an
18138
- * interactive-only rc file). The same executable-file check as the login
18139
- * shell lookup; a symlink to an executable counts, as codex's launcher is one.
18239
+ * #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
18240
+ * installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
18241
+ * backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
18242
+ * accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
18140
18243
  */
18141
- locateInInstallerBinDirs(info) {
18142
- for (const dir of info.binDirs?.(process.env, homedir()) ?? []) {
18143
- if (!isAbsolute(dir))
18144
- continue;
18145
- const path = join(dir, info.binary);
18146
- try {
18147
- accessSync(path, fsConstants.X_OK);
18148
- if (statSync(path).isFile())
18149
- return path;
18150
- }
18151
- catch { /* not there */ }
18152
- }
18153
- return null;
18244
+ async locateInstalledBackend(backend) {
18245
+ const info = BACKEND_INSTALLATION_INFO[backend];
18246
+ return info ? (await findExecutableIn(info.binary, info.binDirs?.(process.env, homedir()) ?? [])) ?? null : null;
18154
18247
  }
18155
18248
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
18156
18249
  adoptBinaryDirectory(binaryPath, backend) {
@@ -18466,20 +18559,26 @@ export class FleetManager {
18466
18559
  if (claimed === "consumed")
18467
18560
  return true;
18468
18561
  const { entry: pending, action } = claimed;
18562
+ // #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
18563
+ const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
18469
18564
  this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
18470
18565
  if (action === "wait") {
18471
18566
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
18472
18567
  return true;
18473
18568
  }
18569
+ if (!current()) {
18570
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
18571
+ return true;
18572
+ }
18474
18573
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
18475
18574
  try {
18476
- await this.restartSingleInstance(pending.instanceName, { explicit: true });
18575
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
18477
18576
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
18478
18577
  }
18479
18578
  catch (err) {
18480
18579
  const message = err instanceof Error ? err.message : String(err);
18481
18580
  this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
18482
- await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
18581
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
18483
18582
  }
18484
18583
  return true;
18485
18584
  }
@@ -18555,6 +18654,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18555
18654
  resolveKnowledgeWorkDir(workDir, backend, instanceName) {
18556
18655
  // Backend resolution may create/normalize the real cwd. Instructions and
18557
18656
  // skills must land where the CLI actually runs, not an assumed path.
18657
+ // Caller pins resolveBackendBinary first so createBackend does not `which`.
18558
18658
  try {
18559
18659
  const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
18560
18660
  .resolveWorkingDirectory?.(workDir, instanceName);
@@ -19877,7 +19977,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19877
19977
  }));
19878
19978
  const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19879
19979
  timer.unref?.();
19880
- this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
19980
+ this.pendingEffortSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
19881
19981
  try {
19882
19982
  await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
19883
19983
  }
@@ -19912,7 +20012,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19912
20012
  }
19913
20013
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19914
20014
  timer.unref?.();
19915
- this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20015
+ this.pendingEffortSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
19916
20016
  try {
19917
20017
  const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
19918
20018
  const pending = this.pendingEffortSelects.get(nonce);
@@ -19937,6 +20037,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19937
20037
  const pending = this.pendingEffortSelects.get(match[1]);
19938
20038
  if (!pending)
19939
20039
  return true;
20040
+ // #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
20041
+ if (!pending.instanceCurrent()) {
20042
+ this.pendingEffortSelects.delete(match[1]);
20043
+ clearTimeout(pending.timer);
20044
+ data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
20045
+ await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
20046
+ return true;
20047
+ }
19940
20048
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
19941
20049
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
19942
20050
  const cbChannel = data.threadId ?? data.chatId;
@@ -19979,17 +20087,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19979
20087
  let result;
19980
20088
  try {
19981
20089
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
19982
- result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
19983
- ? await withSpawnHoldReply(async (text) => {
19984
- if (pending.adapter && pending.adapterChatId) {
19985
- if (progressMsgId)
19986
- await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20090
+ result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
20091
+ : this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20092
+ ? await withSpawnHoldReply(async (text) => {
20093
+ if (pending.adapter && pending.adapterChatId) {
20094
+ if (progressMsgId)
20095
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20096
+ else
20097
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20098
+ }
19987
20099
  else
19988
- await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
19989
- }
19990
- else
19991
- await pending.respond(text);
19992
- }, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
20100
+ await pending.respond(text);
20101
+ }, () => this.applyEffort(pending.instanceName, level, { current: pending.instanceCurrent })) : t("menu.click_stale");
19993
20102
  }
19994
20103
  catch (err) {
19995
20104
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -20046,7 +20155,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20046
20155
  const choices = this.modelMenuChoices(name, nonce, options, currentModel);
20047
20156
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
20048
20157
  timer.unref?.();
20049
- this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
20158
+ this.pendingModelSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
20050
20159
  try {
20051
20160
  await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
20052
20161
  }
@@ -20082,7 +20191,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20082
20191
  }
20083
20192
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
20084
20193
  timer.unref?.();
20085
- this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20194
+ this.pendingModelSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20086
20195
  try {
20087
20196
  const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
20088
20197
  const pending = this.pendingModelSelects.get(nonce);
@@ -20255,6 +20364,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20255
20364
  const pending = this.pendingModelSelects.get(match[1]);
20256
20365
  if (!pending)
20257
20366
  return true;
20367
+ // #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
20368
+ if (!pending.instanceCurrent()) {
20369
+ this.pendingModelSelects.delete(match[1]);
20370
+ clearTimeout(pending.timer);
20371
+ data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
20372
+ await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
20373
+ return true;
20374
+ }
20258
20375
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
20259
20376
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
20260
20377
  const cbChannel = data.threadId ?? data.chatId;
@@ -20315,17 +20432,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20315
20432
  let result;
20316
20433
  try {
20317
20434
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
20318
- result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20319
- ? await withSpawnHoldReply(async (text) => {
20320
- if (pending.adapter && pending.adapterChatId) {
20321
- if (progressMsgId)
20322
- await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20435
+ result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
20436
+ : this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20437
+ ? await withSpawnHoldReply(async (text) => {
20438
+ if (pending.adapter && pending.adapterChatId) {
20439
+ if (progressMsgId)
20440
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20441
+ else
20442
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20443
+ }
20323
20444
  else
20324
- await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20325
- }
20326
- else
20327
- await pending.respond(text);
20328
- }, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
20445
+ await pending.respond(text);
20446
+ }, () => this.applyModel(pending.instanceName, model, { current: pending.instanceCurrent })) : t("menu.click_stale");
20329
20447
  }
20330
20448
  catch (err) {
20331
20449
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -20418,7 +20536,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20418
20536
  * restart backends only read it at launch, so it is persisted and the
20419
20537
  * instance respawns.
20420
20538
  */
20421
- async applyEffort(instanceName, requested) {
20539
+ async applyEffort(instanceName, requested, opts) {
20540
+ // #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first and fenced into the restart.
20541
+ if (opts?.current && !opts.current())
20542
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20422
20543
  const level = requested.trim().toLowerCase();
20423
20544
  const backendName = this.backendNameForInstance(instanceName);
20424
20545
  let strategy = "unsupported";
@@ -20453,10 +20574,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20453
20574
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
20454
20575
  return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
20455
20576
  }
20456
- await this.restartSingleInstance(instanceName);
20577
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20457
20578
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
20458
20579
  }
20459
- async applyModel(instanceName, model) {
20580
+ async applyModel(instanceName, model, opts) {
20581
+ // #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first, after the runtime switch's
20582
+ // await, and fenced into the restart.
20583
+ if (opts?.current && !opts.current())
20584
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20460
20585
  // Reject model names with newlines or control characters: they would be
20461
20586
  // persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
20462
20587
  if (/[\x00-\x1f\x7f]/.test(model)) {
@@ -20482,6 +20607,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20482
20607
  // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
20483
20608
  if (strategy === "runtime" && backendName === "claude-code") {
20484
20609
  const result = await this.runRuntimeModelSwitch(instanceName, model);
20610
+ if (opts?.current && !opts.current())
20611
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20485
20612
  if (result.outcome === "mismatch")
20486
20613
  return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
20487
20614
  if (result.outcome === "not_sent")
@@ -20502,7 +20629,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20502
20629
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
20503
20630
  return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20504
20631
  }
20505
- await this.restartSingleInstance(instanceName);
20632
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20506
20633
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20507
20634
  }
20508
20635
  /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
@@ -21435,6 +21562,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21435
21562
  }
21436
21563
  this.eventLoopWatch?.stop();
21437
21564
  this.eventLoopWatch = null;
21565
+ this.stallReporter?.stop();
21566
+ this.stallReporter = null;
21438
21567
  // A login/install window is a dedicated tmux server with its own TTL
21439
21568
  // timer and HTTP listener living in THIS process: without an explicit
21440
21569
  // shutdown it would outlive us as an owner-less login CLI (sol B3).