@songsid/agend 2.3.4-beta.5 → 2.3.4-beta.7

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 (79) 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/claude-code.d.ts +3 -0
  7. package/dist/backend/claude-code.js +88 -3
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/kiro-identity.d.ts +2 -0
  10. package/dist/backend/kiro-identity.js +13 -6
  11. package/dist/backend/kiro-identity.js.map +1 -1
  12. package/dist/backend/kiro.js +18 -3
  13. package/dist/backend/kiro.js.map +1 -1
  14. package/dist/backend-availability.d.ts +6 -0
  15. package/dist/backend-availability.js +13 -5
  16. package/dist/backend-availability.js.map +1 -1
  17. package/dist/channel/mcp-tools.js +1 -1
  18. package/dist/channel/mcp-tools.js.map +1 -1
  19. package/dist/cli.js +4 -6
  20. package/dist/cli.js.map +1 -1
  21. package/dist/daemon.js +48 -8
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +30 -7
  24. package/dist/fleet-manager.js +182 -66
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/fleet-yaml-patch.d.ts +3 -0
  27. package/dist/fleet-yaml-patch.js +103 -0
  28. package/dist/fleet-yaml-patch.js.map +1 -0
  29. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  30. package/dist/instance-lifecycle.js +12 -4
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/kiro-resume-summary.d.ts +37 -0
  33. package/dist/kiro-resume-summary.js +85 -0
  34. package/dist/kiro-resume-summary.js.map +1 -0
  35. package/dist/locale.js +8 -0
  36. package/dist/locale.js.map +1 -1
  37. package/dist/login-controller.js +9 -1
  38. package/dist/login-controller.js.map +1 -1
  39. package/dist/package-preimage.js +27 -2
  40. package/dist/package-preimage.js.map +1 -1
  41. package/dist/reply-buttons-controller.js +10 -0
  42. package/dist/reply-buttons-controller.js.map +1 -1
  43. package/dist/stall-reporter.d.ts +21 -0
  44. package/dist/stall-reporter.js +82 -0
  45. package/dist/stall-reporter.js.map +1 -0
  46. package/dist/tmux-control.d.ts +2 -2
  47. package/dist/tmux-control.js +4 -2
  48. package/dist/tmux-control.js.map +1 -1
  49. package/dist/tmux-exec-helper.d.ts +6 -3
  50. package/dist/tmux-exec-helper.js +115 -53
  51. package/dist/tmux-exec-helper.js.map +1 -1
  52. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  53. package/dist/tunnel/cloudflared-install.js +20 -1
  54. package/dist/tunnel/cloudflared-install.js.map +1 -1
  55. package/dist/tunnel/cloudflared.d.ts +8 -1
  56. package/dist/tunnel/cloudflared.js +34 -6
  57. package/dist/tunnel/cloudflared.js.map +1 -1
  58. package/dist/tunnel/lease.d.ts +3 -3
  59. package/dist/tunnel/lease.js +23 -19
  60. package/dist/tunnel/lease.js.map +1 -1
  61. package/dist/tunnel/manager.js +6 -7
  62. package/dist/tunnel/manager.js.map +1 -1
  63. package/dist/ui/shared/app.css +16 -1
  64. package/dist/ui/shared/panel-view.js +266 -17
  65. package/dist/ui/shared/view-strings.js +26 -0
  66. package/dist/unattended-install.d.ts +1 -1
  67. package/dist/unattended-install.js +19 -5
  68. package/dist/unattended-install.js.map +1 -1
  69. package/dist/view-api.d.ts +15 -0
  70. package/dist/view-api.js +94 -33
  71. package/dist/view-api.js.map +1 -1
  72. package/dist/web-login.d.ts +4 -3
  73. package/dist/web-login.js +23 -14
  74. package/dist/web-login.js.map +1 -1
  75. package/dist/web-session.d.ts +5 -0
  76. package/dist/web-session.js +20 -8
  77. package/dist/web-session.js.map +1 -1
  78. package/launcher/agend-install.cjs +16 -2
  79. package/package.json +1 -1
@@ -167,6 +167,11 @@ export interface RestartOpts {
167
167
  sessionOomRetry?: SessionOomRetry;
168
168
  /** Internal pre-invalidation owner fence for an unclear-OOM action. */
169
169
  recoveryCurrent?: () => boolean;
170
+ /**
171
+ * #1490 row 27: the lifecycle the caller acts for (lifecyclePin), asked before anything is invalidated. A button or
172
+ * menu offered for one instance never restarts a later one of the same name, or one restarted since.
173
+ */
174
+ ownerCurrent?: () => boolean;
170
175
  carry?: {
171
176
  backend: string;
172
177
  from: string | null;
@@ -271,6 +276,11 @@ export type GuestAuditEntry = {
271
276
  name: string;
272
277
  outcome: string;
273
278
  };
279
+ /** #1490 row 27: a restart asked for one lifecycle of an instance after it was stopped, restarted or replaced. */
280
+ export declare class RestartSupersededError extends Error {
281
+ /** `shown`: the instance as the chat list names it (shownInstanceName). */
282
+ constructor(shown: string);
283
+ }
274
284
  export declare class FleetManager implements FleetContext, LifecycleContext, ArchiverContext, StatuslineWatcherContext, OutboundContext, AgentEndpointContext {
275
285
  dataDir: string;
276
286
  private static signalTarget;
@@ -578,6 +588,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
578
588
  private watchdogTimer;
579
589
  private startupMemoryDeadline;
580
590
  private eventLoopWatch;
591
+ private stallReporter;
581
592
  private startedAt;
582
593
  private stormOpenNotifyTimer;
583
594
  private memoryLogAt;
@@ -618,6 +629,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
618
629
  private readonly webChatRemovals;
619
630
  /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
620
631
  private readonly webChatRemovalsPending;
632
+ /**
633
+ * #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
634
+ * restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
635
+ * or menu is claimed or offered, asked after every await before acting by name.
636
+ */
637
+ /** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
638
+ private shownInstanceName;
639
+ private lifecyclePin;
621
640
  private webChatGeneration;
622
641
  /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
623
642
  private webChatIdentity;
@@ -3668,12 +3687,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
3668
3687
  */
3669
3688
  private locateBinaryOnLoginShell;
3670
3689
  /**
3671
- * #1092: the binary in one of the directories its own installer installs to,
3672
- * when no login shell can see it (the installer recorded its PATH in an
3673
- * interactive-only rc file). The same executable-file check as the login
3674
- * shell lookup; a symlink to an executable counts, as codex's launcher is one.
3690
+ * #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
3691
+ * installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
3692
+ * backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
3693
+ * accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
3675
3694
  */
3676
- private locateInInstallerBinDirs;
3695
+ private locateInstalledBackend;
3677
3696
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
3678
3697
  private adoptBinaryDirectory;
3679
3698
  /** Discord native `/login` slash — shared by every adapter dispatch block. */
@@ -4006,8 +4025,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
4006
4025
  * restart backends only read it at launch, so it is persisted and the
4007
4026
  * instance respawns.
4008
4027
  */
4009
- applyEffort(instanceName: string, requested: string): Promise<string>;
4010
- applyModel(instanceName: string, model: string): Promise<string>;
4028
+ applyEffort(instanceName: string, requested: string, opts?: {
4029
+ current?: () => boolean;
4030
+ }): Promise<string>;
4031
+ applyModel(instanceName: string, model: string, opts?: {
4032
+ current?: () => boolean;
4033
+ }): Promise<string>;
4011
4034
  /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
4012
4035
  private persistInstanceModel;
4013
4036
  /**
@@ -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;
@@ -1026,6 +1035,7 @@ export class FleetManager {
1026
1035
  watchdogTimer = null;
1027
1036
  startupMemoryDeadline = null;
1028
1037
  eventLoopWatch = null;
1038
+ stallReporter = null;
1029
1039
  startedAt = 0;
1030
1040
  stormOpenNotifyTimer = null;
1031
1041
  memoryLogAt = null;
@@ -1069,6 +1079,37 @@ export class FleetManager {
1069
1079
  webChatRemovals = new Map();
1070
1080
  /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
1071
1081
  webChatRemovalsPending = new Map();
1082
+ /**
1083
+ * #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
1084
+ * restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
1085
+ * or menu is claimed or offered, asked after every await before acting by name.
1086
+ */
1087
+ /** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
1088
+ shownInstanceName(name) {
1089
+ try {
1090
+ const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === name) ?? null;
1091
+ return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic, profile: profileIdentities(this.dataDir).get(name) }).display_name || name;
1092
+ }
1093
+ catch {
1094
+ return name;
1095
+ }
1096
+ }
1097
+ lifecyclePin(name) {
1098
+ // Fail closed: a lifecycle that cannot say its epoch holds nothing (and never stops a prompt from being posted).
1099
+ let epoch;
1100
+ try {
1101
+ epoch = this.lifecycle?.epochOf(name);
1102
+ }
1103
+ catch {
1104
+ return () => false;
1105
+ }
1106
+ return () => { try {
1107
+ return !this.shuttingDown && this.lifecycle?.epochOf(name) === epoch;
1108
+ }
1109
+ catch {
1110
+ return false;
1111
+ } };
1112
+ }
1072
1113
  webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
1073
1114
  /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
1074
1115
  webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
@@ -5641,6 +5682,23 @@ export class FleetManager {
5641
5682
  if (resumePaused)
5642
5683
  this.lifecycle.markActivitySeedNow(name);
5643
5684
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
5685
+ // #1490 row 67: off-loop resolve before skill/instruction publication
5686
+ // constructs a backend (resolveKnowledgeWorkDir). Capture stop/lifecycle
5687
+ // ownership first: a Stop during this wait finishes and bumps the epoch,
5688
+ // and the inner lifecycle check would otherwise capture the new epoch and
5689
+ // accept the stale start. Lifecycle start looks up again before its own
5690
+ // createBackend; the probe cache makes that cheap.
5691
+ const epoch = this.lifecycle.epochOf(name);
5692
+ const stopGen = this.explicitStopGeneration.get(name) ?? 0;
5693
+ const resolved = await resolveBackendBinary(backend);
5694
+ execution?.assert();
5695
+ if (this.lifecycle.epochOf(name) !== epoch
5696
+ || (this.explicitStopGeneration.get(name) ?? 0) !== stopGen) {
5697
+ throw new SupersededStartError(name);
5698
+ }
5699
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
5700
+ if (binary)
5701
+ noteResolvedBinary(binary, resolved ?? binary);
5644
5702
  if (config.general_topic) {
5645
5703
  this.ensureGeneralInstructions(config.working_directory, backend, name);
5646
5704
  }
@@ -6291,6 +6349,8 @@ export class FleetManager {
6291
6349
  }
6292
6350
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
6293
6351
  async restartSingleInstance(name, opts) {
6352
+ if (opts?.ownerCurrent && !opts.ownerCurrent())
6353
+ throw new RestartSupersededError(this.shownInstanceName(name));
6294
6354
  const migration = this.kiroMigrationRuns?.get(name);
6295
6355
  if (migration && migration.token !== opts?.kiroMigration)
6296
6356
  throw new Error(t("kiro_migrate.in_progress"));
@@ -7591,7 +7651,7 @@ export class FleetManager {
7591
7651
  return { kind: "refused" };
7592
7652
  if (device.tier === "guest")
7593
7653
  return this.requestGuestDeviceCode(device, input, token);
7594
- const paused = this.webLoginCodes.pausedForMs();
7654
+ const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
7595
7655
  if (paused > 0)
7596
7656
  return { kind: "limited", retryAfterMs: paused };
7597
7657
  const admitted = this.codeRequestLimiter.admit(device.idHash);
@@ -8076,6 +8136,9 @@ export class FleetManager {
8076
8136
  this.watchdogTimer = startSdWatchdog();
8077
8137
  this.eventLoopWatch?.stop();
8078
8138
  this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
8139
+ // #3009: the event-loop watch runs on the main thread; this one does not, so a stall that never ends is still named.
8140
+ this.stallReporter?.stop();
8141
+ this.stallReporter = startStallReporter({ logFile: join(this.dataDir, "daemon.log") });
8079
8142
  try {
8080
8143
  await this.startAllWithinDeadline(configPath, deadline);
8081
8144
  }
@@ -8089,6 +8152,8 @@ export class FleetManager {
8089
8152
  this.watchdogTimer = null;
8090
8153
  this.eventLoopWatch?.stop();
8091
8154
  this.eventLoopWatch = null;
8155
+ this.stallReporter?.stop();
8156
+ this.stallReporter = null;
8092
8157
  }
8093
8158
  }
8094
8159
  }
@@ -8356,6 +8421,10 @@ export class FleetManager {
8356
8421
  const generalDir = join(getAgendHome(), name);
8357
8422
  mkdirSync(generalDir, { recursive: true, mode: 0o700 });
8358
8423
  const backendName = fleet.defaults.backend ?? "claude-code";
8424
+ const resolved = await resolveBackendBinary(backendName);
8425
+ const binary = BACKEND_INSTALLATION_INFO[backendName]?.binary;
8426
+ if (binary)
8427
+ noteResolvedBinary(binary, resolved ?? binary);
8359
8428
  this.ensureGeneralInstructions(generalDir, backendName, name);
8360
8429
  fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
8361
8430
  generalsCreated = true;
@@ -12866,6 +12935,9 @@ export class FleetManager {
12866
12935
  await this.lifecycle.remove(name, authorization, execution, transition);
12867
12936
  else
12868
12937
  await this.lifecycle.remove(name, authorization);
12938
+ // #1490 row 27: its hang/exit offers were for the instance now gone (stopInstance clears them; removal stops
12939
+ // through the lifecycle directly). The offers' own lifecycle pins refuse them too.
12940
+ this.clearNoncePromptsForInstance(name);
12869
12941
  // #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
12870
12942
  // throws above: its replies stay its own).
12871
12943
  this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
@@ -14540,6 +14612,7 @@ export class FleetManager {
14540
14612
  threadId: opts.threadId,
14541
14613
  expiredText: opts.expiredText,
14542
14614
  createdAt: Date.now(),
14615
+ lifecycleCurrent: this.lifecyclePin(opts.instanceName),
14543
14616
  ...opts.extra,
14544
14617
  };
14545
14618
  entry.timer = setTimeout(() => {
@@ -16253,21 +16326,27 @@ export class FleetManager {
16253
16326
  if (claimed === "consumed")
16254
16327
  return true;
16255
16328
  const { entry: pending, action } = claimed;
16329
+ // #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
16330
+ const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
16256
16331
  this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
16257
16332
  if (action === "ignore") {
16258
16333
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
16259
16334
  return true;
16260
16335
  }
16336
+ if (!current()) {
16337
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
16338
+ return true;
16339
+ }
16261
16340
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
16262
16341
  try {
16263
- await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
16342
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
16264
16343
  ...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
16265
16344
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
16266
16345
  }
16267
16346
  catch (err) {
16268
16347
  const message = err instanceof Error ? err.message : String(err);
16269
16348
  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"));
16349
+ 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
16350
  }
16272
16351
  return true;
16273
16352
  }
@@ -17379,7 +17458,10 @@ export class FleetManager {
17379
17458
  return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
17380
17459
  dataDir: this.dataDir, lock: this.loginWindow,
17381
17460
  current: owner => this.browserSignInOwnerCurrent(owner),
17382
- installed: async () => ONBOARDING_BACKENDS.filter(backend => this.isCliInstalled(backend)),
17461
+ installed: async () => {
17462
+ const found = await Promise.all(ONBOARDING_BACKENDS.map(async (backend) => (await this.isCliInstalledOffLoop(backend)) ? backend : null));
17463
+ return found.filter((backend) => backend !== null);
17464
+ },
17383
17465
  checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
17384
17466
  checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
17385
17467
  transport: () => createStructuredSignInTransport(),
@@ -17483,13 +17565,13 @@ export class FleetManager {
17483
17565
  * #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
17484
17566
  * with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
17485
17567
  */
17486
- startWebDashboardLogin(backendArg, chat, opts) {
17568
+ async startWebDashboardLogin(backendArg, chat, opts) {
17487
17569
  const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
17488
17570
  if (isRemovedBackend(wanted))
17489
- return Promise.resolve(removedBackendMessage(wanted));
17571
+ return removedBackendMessage(wanted);
17490
17572
  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));
17573
+ if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !(await this.isCliInstalledOffLoop(wanted))) {
17574
+ return t("login.web_dashboard_not_installed", wanted, info.install);
17493
17575
  }
17494
17576
  if (!opts.skipAuthCheck)
17495
17577
  this.recordLoginFlow(wanted, "login", chat.userId);
@@ -17537,8 +17619,10 @@ export class FleetManager {
17537
17619
  ? removedBackendMessage(backend)
17538
17620
  : !installed.has(backend)
17539
17621
  ? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
17622
+ // #2981: one short line in the bare /login list — the full refusal (why, and what to run) is `/login <it>`'s;
17623
+ // a bare /login used to post Antigravity's whole paragraph to someone who never asked about it.
17540
17624
  : flow?.remoteLogin === "unsupported"
17541
- ? t("login.remote_unsupported_agent_cli", backend, flow.command)
17625
+ ? t("login.host_only", flow.command)
17542
17626
  : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
17543
17627
  await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
17544
17628
  .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
@@ -17981,9 +18065,14 @@ export class FleetManager {
17981
18065
  // session only judges that verdict; it does not read the output for URLs or prompts.
17982
18066
  // M-2281 (#2980): no terminal to ask on, low priority, stoppable as a whole, the user's own $SHELL — see
17983
18067
  // unattended-install.ts. A prompt nobody can answer fails at once instead of waiting out the timeout.
18068
+ const shell = (await loginShell()) ?? null;
18069
+ if (!this.loginWindow.isCurrent(claim)) {
18070
+ this.loginWindow.release(claim);
18071
+ return t("login.web_shutting_down");
18072
+ }
17984
18073
  const flow = {
17985
18074
  backend,
17986
- command: unattendedInstallCommand(info.unattended ?? info.install),
18075
+ command: unattendedInstallCommand(info.unattended ?? info.install, shell),
17987
18076
  successPattern: /$^/,
17988
18077
  timeoutMs: this.installTimeoutMs,
17989
18078
  };
@@ -18027,7 +18116,9 @@ export class FleetManager {
18027
18116
  return;
18028
18117
  // The installer may only have added the binary to a profile PATH; a
18029
18118
  // fresh login shell sees that, the fleet process's PATH may not.
18030
- const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
18119
+ // #3009 (leader, cid-3017-3029-overlap): both lookups off the fleet's main thread — the login shell's own check,
18120
+ // then the installer's own directories through backend-availability's check (a killable child tests each one).
18121
+ const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? (await this.locateInstalledBackend(backend));
18031
18122
  if (handoff.cancelled || this.installHandoff !== handoff)
18032
18123
  return;
18033
18124
  if (!installedAt) {
@@ -18119,38 +18210,27 @@ export class FleetManager {
18119
18210
  */
18120
18211
  async locateBinaryOnLoginShell(binary) {
18121
18212
  try {
18122
- const result = await runOnLoginShell(`command -v ${binary}`);
18213
+ // #3009: the executable-file check runs inside the same login shell (a killable child), never as accessSync/statSync
18214
+ // on the fleet's thread — a path on a filesystem that stopped answering held the whole fleet there.
18215
+ const result = await runOnLoginShell(`p=$(command -v ${binary}) && [ -f "$p" ] && [ -x "$p" ] && printf '%s\n' "$p"`);
18123
18216
  if (result.status !== 0)
18124
18217
  return null;
18125
18218
  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;
18219
+ return isAbsolute(path) ? path : null;
18130
18220
  }
18131
18221
  catch {
18132
18222
  return null;
18133
18223
  }
18134
18224
  }
18135
18225
  /**
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.
18226
+ * #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
18227
+ * installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
18228
+ * backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
18229
+ * accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
18140
18230
  */
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;
18231
+ async locateInstalledBackend(backend) {
18232
+ const info = BACKEND_INSTALLATION_INFO[backend];
18233
+ return info ? (await findExecutableIn(info.binary, info.binDirs?.(process.env, homedir()) ?? [])) ?? null : null;
18154
18234
  }
18155
18235
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
18156
18236
  adoptBinaryDirectory(binaryPath, backend) {
@@ -18466,20 +18546,26 @@ export class FleetManager {
18466
18546
  if (claimed === "consumed")
18467
18547
  return true;
18468
18548
  const { entry: pending, action } = claimed;
18549
+ // #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
18550
+ const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
18469
18551
  this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
18470
18552
  if (action === "wait") {
18471
18553
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
18472
18554
  return true;
18473
18555
  }
18556
+ if (!current()) {
18557
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
18558
+ return true;
18559
+ }
18474
18560
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
18475
18561
  try {
18476
- await this.restartSingleInstance(pending.instanceName, { explicit: true });
18562
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
18477
18563
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
18478
18564
  }
18479
18565
  catch (err) {
18480
18566
  const message = err instanceof Error ? err.message : String(err);
18481
18567
  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"));
18568
+ 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
18569
  }
18484
18570
  return true;
18485
18571
  }
@@ -18555,6 +18641,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18555
18641
  resolveKnowledgeWorkDir(workDir, backend, instanceName) {
18556
18642
  // Backend resolution may create/normalize the real cwd. Instructions and
18557
18643
  // skills must land where the CLI actually runs, not an assumed path.
18644
+ // Caller pins resolveBackendBinary first so createBackend does not `which`.
18558
18645
  try {
18559
18646
  const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
18560
18647
  .resolveWorkingDirectory?.(workDir, instanceName);
@@ -19877,7 +19964,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19877
19964
  }));
19878
19965
  const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19879
19966
  timer.unref?.();
19880
- this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
19967
+ this.pendingEffortSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
19881
19968
  try {
19882
19969
  await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
19883
19970
  }
@@ -19912,7 +19999,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19912
19999
  }
19913
20000
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19914
20001
  timer.unref?.();
19915
- this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20002
+ this.pendingEffortSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
19916
20003
  try {
19917
20004
  const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
19918
20005
  const pending = this.pendingEffortSelects.get(nonce);
@@ -19937,6 +20024,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19937
20024
  const pending = this.pendingEffortSelects.get(match[1]);
19938
20025
  if (!pending)
19939
20026
  return true;
20027
+ // #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
20028
+ if (!pending.instanceCurrent()) {
20029
+ this.pendingEffortSelects.delete(match[1]);
20030
+ clearTimeout(pending.timer);
20031
+ data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
20032
+ await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
20033
+ return true;
20034
+ }
19940
20035
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
19941
20036
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
19942
20037
  const cbChannel = data.threadId ?? data.chatId;
@@ -19979,17 +20074,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19979
20074
  let result;
19980
20075
  try {
19981
20076
  // 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);
20077
+ result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
20078
+ : this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20079
+ ? await withSpawnHoldReply(async (text) => {
20080
+ if (pending.adapter && pending.adapterChatId) {
20081
+ if (progressMsgId)
20082
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20083
+ else
20084
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20085
+ }
19987
20086
  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");
20087
+ await pending.respond(text);
20088
+ }, () => this.applyEffort(pending.instanceName, level, { current: pending.instanceCurrent })) : t("menu.click_stale");
19993
20089
  }
19994
20090
  catch (err) {
19995
20091
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -20046,7 +20142,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20046
20142
  const choices = this.modelMenuChoices(name, nonce, options, currentModel);
20047
20143
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
20048
20144
  timer.unref?.();
20049
- this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
20145
+ 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
20146
  try {
20051
20147
  await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
20052
20148
  }
@@ -20082,7 +20178,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20082
20178
  }
20083
20179
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
20084
20180
  timer.unref?.();
20085
- this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20181
+ this.pendingModelSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20086
20182
  try {
20087
20183
  const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
20088
20184
  const pending = this.pendingModelSelects.get(nonce);
@@ -20255,6 +20351,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20255
20351
  const pending = this.pendingModelSelects.get(match[1]);
20256
20352
  if (!pending)
20257
20353
  return true;
20354
+ // #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
20355
+ if (!pending.instanceCurrent()) {
20356
+ this.pendingModelSelects.delete(match[1]);
20357
+ clearTimeout(pending.timer);
20358
+ data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
20359
+ await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
20360
+ return true;
20361
+ }
20258
20362
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
20259
20363
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
20260
20364
  const cbChannel = data.threadId ?? data.chatId;
@@ -20315,17 +20419,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20315
20419
  let result;
20316
20420
  try {
20317
20421
  // 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);
20422
+ result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
20423
+ : this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20424
+ ? await withSpawnHoldReply(async (text) => {
20425
+ if (pending.adapter && pending.adapterChatId) {
20426
+ if (progressMsgId)
20427
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20428
+ else
20429
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20430
+ }
20323
20431
  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");
20432
+ await pending.respond(text);
20433
+ }, () => this.applyModel(pending.instanceName, model, { current: pending.instanceCurrent })) : t("menu.click_stale");
20329
20434
  }
20330
20435
  catch (err) {
20331
20436
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -20418,7 +20523,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20418
20523
  * restart backends only read it at launch, so it is persisted and the
20419
20524
  * instance respawns.
20420
20525
  */
20421
- async applyEffort(instanceName, requested) {
20526
+ async applyEffort(instanceName, requested, opts) {
20527
+ // #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first and fenced into the restart.
20528
+ if (opts?.current && !opts.current())
20529
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20422
20530
  const level = requested.trim().toLowerCase();
20423
20531
  const backendName = this.backendNameForInstance(instanceName);
20424
20532
  let strategy = "unsupported";
@@ -20453,10 +20561,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20453
20561
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
20454
20562
  return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
20455
20563
  }
20456
- await this.restartSingleInstance(instanceName);
20564
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20457
20565
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
20458
20566
  }
20459
- async applyModel(instanceName, model) {
20567
+ async applyModel(instanceName, model, opts) {
20568
+ // #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first, after the runtime switch's
20569
+ // await, and fenced into the restart.
20570
+ if (opts?.current && !opts.current())
20571
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20460
20572
  // Reject model names with newlines or control characters: they would be
20461
20573
  // persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
20462
20574
  if (/[\x00-\x1f\x7f]/.test(model)) {
@@ -20482,6 +20594,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20482
20594
  // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
20483
20595
  if (strategy === "runtime" && backendName === "claude-code") {
20484
20596
  const result = await this.runRuntimeModelSwitch(instanceName, model);
20597
+ if (opts?.current && !opts.current())
20598
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20485
20599
  if (result.outcome === "mismatch")
20486
20600
  return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
20487
20601
  if (result.outcome === "not_sent")
@@ -20502,7 +20616,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20502
20616
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
20503
20617
  return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20504
20618
  }
20505
- await this.restartSingleInstance(instanceName);
20619
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20506
20620
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20507
20621
  }
20508
20622
  /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
@@ -21435,6 +21549,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21435
21549
  }
21436
21550
  this.eventLoopWatch?.stop();
21437
21551
  this.eventLoopWatch = null;
21552
+ this.stallReporter?.stop();
21553
+ this.stallReporter = null;
21438
21554
  // A login/install window is a dedicated tmux server with its own TTL
21439
21555
  // timer and HTTP listener living in THIS process: without an explicit
21440
21556
  // shutdown it would outlive us as an owner-less login CLI (sol B3).