@songsid/agend 2.2.0-beta.5 → 2.2.0-beta.6

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 (68) hide show
  1. package/dist/backend/claude-carry.d.ts +22 -0
  2. package/dist/backend/claude-carry.js +170 -0
  3. package/dist/backend/claude-carry.js.map +1 -0
  4. package/dist/backend/claude-code.d.ts +16 -0
  5. package/dist/backend/claude-code.js +92 -2
  6. package/dist/backend/claude-code.js.map +1 -1
  7. package/dist/backend/credential-profile.d.ts +7 -0
  8. package/dist/backend/credential-profile.js +7 -1
  9. package/dist/backend/credential-profile.js.map +1 -1
  10. package/dist/backend/kiro.js +5 -2
  11. package/dist/backend/kiro.js.map +1 -1
  12. package/dist/backend/muse.d.ts +41 -0
  13. package/dist/backend/muse.js +239 -44
  14. package/dist/backend/muse.js.map +1 -1
  15. package/dist/backend/types.d.ts +7 -0
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/background-work-ended.d.ts +18 -0
  18. package/dist/background-work-ended.js +30 -0
  19. package/dist/background-work-ended.js.map +1 -0
  20. package/dist/channel/adapters/telegram.d.ts +4 -5
  21. package/dist/channel/adapters/telegram.js +33 -17
  22. package/dist/channel/adapters/telegram.js.map +1 -1
  23. package/dist/channel/message-edit-errors.d.ts +5 -0
  24. package/dist/channel/message-edit-errors.js +24 -0
  25. package/dist/channel/message-edit-errors.js.map +1 -0
  26. package/dist/cli.js +25 -10
  27. package/dist/cli.js.map +1 -1
  28. package/dist/daemon.js +41 -0
  29. package/dist/daemon.js.map +1 -1
  30. package/dist/fleet-context.d.ts +4 -1
  31. package/dist/fleet-manager.d.ts +82 -5
  32. package/dist/fleet-manager.js +355 -60
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +44 -9
  35. package/dist/locale.js +14 -2
  36. package/dist/locale.js.map +1 -1
  37. package/dist/needs-you-hub.js +2 -0
  38. package/dist/needs-you-hub.js.map +1 -1
  39. package/dist/needs-you-live.d.ts +6 -0
  40. package/dist/needs-you-live.js +23 -3
  41. package/dist/needs-you-live.js.map +1 -1
  42. package/dist/outbound-handlers.d.ts +7 -0
  43. package/dist/outbound-handlers.js +19 -10
  44. package/dist/outbound-handlers.js.map +1 -1
  45. package/dist/restart-progress.d.ts +4 -0
  46. package/dist/restart-progress.js +7 -0
  47. package/dist/restart-progress.js.map +1 -1
  48. package/dist/service-installer.d.ts +31 -0
  49. package/dist/service-installer.js +41 -2
  50. package/dist/service-installer.js.map +1 -1
  51. package/dist/topic-commands.js +3 -1
  52. package/dist/topic-commands.js.map +1 -1
  53. package/dist/ui/shared/app-stream.js +21 -4
  54. package/dist/ui/shared/app.css +5 -2
  55. package/dist/ui/shared/usage-dialog.js +10 -1
  56. package/dist/update-install.d.ts +6 -2
  57. package/dist/update-install.js +8 -2
  58. package/dist/update-install.js.map +1 -1
  59. package/dist/update-marker.d.ts +11 -0
  60. package/dist/update-marker.js +12 -0
  61. package/dist/update-marker.js.map +1 -1
  62. package/dist/web-api.d.ts +3 -1
  63. package/dist/web-server-drain.d.ts +10 -0
  64. package/dist/web-server-drain.js +50 -0
  65. package/dist/web-server-drain.js.map +1 -0
  66. package/package.json +1 -1
  67. package/templates/launchd.plist.ejs +3 -1
  68. package/templates/systemd.service.ejs +2 -1
@@ -8,6 +8,7 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
8
8
  import { gatewayRequestContext } from "./web-request-context.js";
9
9
  import { createPublicWebGateway } from "./public-web-gateway.js";
10
10
  import { handleWebIconRequest } from "./web-icons.js";
11
+ import { trackWebServer } from "./web-server-drain.js";
11
12
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
12
13
  import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
13
14
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
@@ -30,7 +31,7 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
30
31
  import { fileURLToPath } from "node:url";
31
32
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
32
33
  import { fleetLabel } from "./fleet-label.js";
33
- import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
34
+ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
34
35
  import { formatUpdateProgress } from "./update-progress.js";
35
36
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
36
37
  import { readFleetMemory } from "./process-memory.js";
@@ -170,6 +171,7 @@ import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
170
171
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
171
172
  import { readLastInboundAt } from "./daemon.js";
172
173
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
174
+ import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
173
175
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
174
176
  import { TargetQueueWorker } from "./target-queue-worker.js";
175
177
  import { resolveDeliveryWorkerMode } from "./types.js";
@@ -203,7 +205,8 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
203
205
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
204
206
  const SELF_RESTART_DEADLINE_MS = 300_000;
205
207
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
206
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
208
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
209
+ import { carryClaudeConversation } from "./backend/claude-carry.js";
207
210
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
208
211
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
209
212
  import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
@@ -257,6 +260,10 @@ export function isOrphanInstanceWindowName(name) {
257
260
  /** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
258
261
  * forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
259
262
  const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
263
+ /** #1753: a carried conversation this large is logged as a warning (it is still carried). */
264
+ const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
265
+ /** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
266
+ const TG_WEB_START_WINDOW_MS = 10 * 60_000;
260
267
  const CANCEL_BTN_MAX_RETRIES = 3;
261
268
  /** Backstop: every 5min, retire a button whose instance has gone idle. Catches
262
269
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
@@ -621,6 +628,11 @@ export class FleetManager {
621
628
  webChannelEchoGuards = new Map();
622
629
  /** instanceName → restart currently executing; concurrent callers join it. */
623
630
  restartsInFlight = new Map();
631
+ /**
632
+ * #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
633
+ * caller joined with before the gap), and whether the gap has been reached.
634
+ */
635
+ restartGaps = new Map();
624
636
  /**
625
637
  * Delayed automatic retries for instances whose startup failed. Before this a
626
638
  * failed start was logged once and the instance stayed `stopped` until an
@@ -725,6 +737,7 @@ export class FleetManager {
725
737
  collabInstances = new Set();
726
738
  // Health endpoint
727
739
  healthServer = null;
740
+ healthServerDrain = null;
728
741
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
729
742
  previewListener = null;
730
743
  previewListening = false;
@@ -1789,7 +1802,7 @@ export class FleetManager {
1789
1802
  isPlannedRestart() {
1790
1803
  return this.shuttingDown || isUpdateInProgress(this.dataDir);
1791
1804
  }
1792
- finishStartup() {
1805
+ finishStartup(bootUpdateMarker = null) {
1793
1806
  this.startupComplete = true;
1794
1807
  this.startNeedsYou();
1795
1808
  // Resolve whatever a previous run — or a setup host that crashed — left
@@ -1829,20 +1842,33 @@ export class FleetManager {
1829
1842
  for (const settled of this.applyJobs.settleAfterRestart()) {
1830
1843
  this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
1831
1844
  }
1832
- // We are the post-update fleet: the update is over by definition. Clearing
1833
- // it here (rather than in the update command, which exits before the new
1834
- // fleet is up) is what keeps the quiet window from outliving the restart.
1835
- // #1660 exception: an update still owing its retire outcome keeps its
1836
- // receipt until the cleanup watch resolves it — deleting it here is what
1837
- // lost the warning. Terminal stages are still cleared unconditionally.
1838
- const leftover = readUpdateProgress(this.dataDir)?.progress;
1839
- if (!(leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")) {
1840
- clearUpdateMarker(this.dataDir);
1841
- }
1845
+ this.clearSettledUpdateMarker(bootUpdateMarker);
1842
1846
  if (this.reloadPending)
1843
1847
  this.scheduleReconcile();
1844
1848
  void this.sweepOrphanedCancelButtons();
1845
1849
  }
1850
+ /**
1851
+ * We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
1852
+ * command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
1853
+ * #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
1854
+ * deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
1855
+ * #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
1856
+ * (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
1857
+ * appends it, then clears.
1858
+ * #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
1859
+ * landing while this boot's completion was still being edited — belongs to its writer and is left alone.
1860
+ */
1861
+ clearSettledUpdateMarker(bootUpdateMarker) {
1862
+ if (this.updateCleanupWatch)
1863
+ return;
1864
+ const current = updateMarkerIdentity(this.dataDir);
1865
+ if (current && !sameUpdateMarker(current, bootUpdateMarker))
1866
+ return;
1867
+ const leftover = readUpdateProgress(this.dataDir)?.progress;
1868
+ if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
1869
+ return;
1870
+ clearUpdateMarker(this.dataDir);
1871
+ }
1846
1872
  // ── ArchiverContext bridge ────────────────────────────────────────────
1847
1873
  lastActivityMs(name) {
1848
1874
  return this.lastActivity.get(name) ?? 0;
@@ -2626,6 +2652,8 @@ export class FleetManager {
2626
2652
  startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
2627
2653
  from: profileKey(launched),
2628
2654
  to: profileKey(next),
2655
+ ...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
2656
+ ? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
2629
2657
  };
2630
2658
  }
2631
2659
  /** `[instance, effective backend, credential profile]` for everything that is
@@ -3878,12 +3906,16 @@ export class FleetManager {
3878
3906
  const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
3879
3907
  // #1220: a running CLI keeps the login it was launched with, so a new
3880
3908
  // credential_profile only takes effect through a restart.
3881
- const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
3909
+ const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
3882
3910
  if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
3883
3911
  this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
3884
3912
  ...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
3913
+ const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
3885
3914
  await this.stopInstance(ch.instanceName).catch(() => { });
3886
- if (profileSwitchStartsFresh)
3915
+ // #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
3916
+ if (profileCarry)
3917
+ await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
3918
+ else if (profileSwitchStartsFresh)
3887
3919
  this.writeFreshStartMarker(ch.instanceName);
3888
3920
  // Small delay to let tmux window clean up
3889
3921
  await new Promise(r => setTimeout(r, 2000));
@@ -4516,6 +4548,9 @@ export class FleetManager {
4516
4548
  // Published so the outage hand-off can wait for an explicit stop that began
4517
4549
  // BEFORE it (the generation fence alone only catches stops that begin after
4518
4550
  // the hand-off snapshotted it).
4551
+ // #1716: scope the marker to this stop — a marker older than beganAt is
4552
+ // from an earlier stop and never reports.
4553
+ const beganAt = Date.now();
4519
4554
  const run = (async () => {
4520
4555
  try {
4521
4556
  await this.lifecycle.stop(name, transition);
@@ -4523,6 +4558,10 @@ export class FleetManager {
4523
4558
  finally {
4524
4559
  this.clearNoncePromptsForInstance(name);
4525
4560
  }
4561
+ const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
4562
+ if (backgroundWorkEnded)
4563
+ this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
4564
+ return { backgroundWorkEnded };
4526
4565
  })().finally(() => { if (this.stopsInFlight.get(name) === run)
4527
4566
  this.stopsInFlight.delete(name); });
4528
4567
  this.stopsInFlight.set(name, run);
@@ -4537,6 +4576,16 @@ export class FleetManager {
4537
4576
  const inFlight = this.restartsInFlight.get(name);
4538
4577
  if (inFlight) {
4539
4578
  this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
4579
+ // #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
4580
+ // start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
4581
+ // carried, and said so — the restart has already started, or will start, the CLI without it.
4582
+ if (opts?.carry) {
4583
+ const gap = this.restartGaps.get(name);
4584
+ if (gap && !gap.reached && !gap.carry)
4585
+ gap.carry = opts.carry;
4586
+ else
4587
+ opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
4588
+ }
4540
4589
  return inFlight;
4541
4590
  }
4542
4591
  const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
@@ -4545,6 +4594,8 @@ export class FleetManager {
4545
4594
  // spawn gate is taken inside the transition, never the other way round, so
4546
4595
  // the start being waited for can still get its own gate slot.
4547
4596
  this.lifecycle.invalidate(name);
4597
+ const gap = { carry: opts?.carry, reached: false };
4598
+ this.restartGaps.set(name, gap);
4548
4599
  const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
4549
4600
  instanceName: name,
4550
4601
  workingDirectory,
@@ -4553,6 +4604,8 @@ export class FleetManager {
4553
4604
  }, () => this.doRestartSingleInstance(name, opts, transition)))
4554
4605
  .finally(() => {
4555
4606
  this.restartsInFlight.delete(name);
4607
+ if (this.restartGaps.get(name) === gap)
4608
+ this.restartGaps.delete(name);
4556
4609
  this.scheduleDeliveryOutboxPump();
4557
4610
  });
4558
4611
  this.restartsInFlight.set(name, run);
@@ -4622,10 +4675,41 @@ export class FleetManager {
4622
4675
  async doRestartWithSlot(name, opts, transition, config, wasPaused) {
4623
4676
  const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
4624
4677
  const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
4678
+ // #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
4679
+ const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
4680
+ // Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
4681
+ /** `launching`: the login the start will use — read only when a switch is to be carried. */
4682
+ const betweenStopAndStart = async (launching) => {
4683
+ // #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
4684
+ const gap = this.restartGaps.get(name);
4685
+ const carry = gap?.carry ?? opts?.carry;
4686
+ if (gap)
4687
+ gap.reached = true;
4688
+ if (!carry) {
4689
+ if (opts?.freshStart)
4690
+ this.writeFreshStartMarker(name);
4691
+ return;
4692
+ }
4693
+ // Only into the login this start really launches: a restart that read its configuration before the switch was
4694
+ // saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
4695
+ const launch = launching();
4696
+ if (launch.backend !== carry.backend || launch.profile !== carry.to) {
4697
+ const login = (p) => (p ? `profile "${p}"` : "the default login");
4698
+ carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
4699
+ this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
4700
+ // The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
4701
+ if (launch.backend !== carry.backend || launch.profile !== carry.from)
4702
+ this.writeFreshStartMarker(name);
4703
+ return;
4704
+ }
4705
+ carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
4706
+ };
4625
4707
  if (config) {
4626
4708
  await this.stopInstance(name, transition);
4627
- if (opts?.freshStart)
4628
- this.writeFreshStartMarker(name);
4709
+ await betweenStopAndStart(() => {
4710
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
4711
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
4712
+ });
4629
4713
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
4630
4714
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
4631
4715
  this.stormWindow.markRecovered(name);
@@ -4638,8 +4722,10 @@ export class FleetManager {
4638
4722
  const adapterId = this.classicChannels.getAdapterIdByInstance(name);
4639
4723
  await this.stopInstance(name, transition);
4640
4724
  await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
4641
- if (opts?.freshStart)
4642
- this.writeFreshStartMarker(name);
4725
+ await betweenStopAndStart(() => {
4726
+ const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
4727
+ return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
4728
+ });
4643
4729
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
4644
4730
  this.stormWindow.markRecovered(name);
4645
4731
  return;
@@ -4660,6 +4746,43 @@ export class FleetManager {
4660
4746
  this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
4661
4747
  }
4662
4748
  }
4749
+ /**
4750
+ * #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
4751
+ * the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
4752
+ * cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
4753
+ */
4754
+ async switchConversation(name, sw) {
4755
+ if (!credentialSwitchCarriesConversation(sw.backend)) {
4756
+ if (credentialSwitchStartsFresh(sw.backend))
4757
+ this.writeFreshStartMarker(name);
4758
+ return null;
4759
+ }
4760
+ let sessionId = null;
4761
+ try {
4762
+ sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
4763
+ }
4764
+ catch { /* none: nothing to carry */ }
4765
+ const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
4766
+ let result;
4767
+ try {
4768
+ result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
4769
+ }
4770
+ catch (err) {
4771
+ result = { carried: false, reason: `copy failed: ${err.message}` };
4772
+ }
4773
+ if (result.carried) {
4774
+ const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
4775
+ if (result.bytes > CARRY_LARGE_BYTES)
4776
+ this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
4777
+ else
4778
+ this.logger.info(fields, "Conversation carried to the new credential profile");
4779
+ }
4780
+ else {
4781
+ this.logger.info({ name, from: sw.from ?? "default", to: sw.to ?? "default", reason: result.reason }, "Conversation not carried to the new credential profile; starting fresh");
4782
+ this.writeFreshStartMarker(name);
4783
+ }
4784
+ return result;
4785
+ }
4663
4786
  /** Load .env file from data dir into process.env */
4664
4787
  loadEnvFile(owner) {
4665
4788
  const envPath = join(this.dataDir, ".env");
@@ -4767,6 +4890,61 @@ export class FleetManager {
4767
4890
  }
4768
4891
  return null;
4769
4892
  }
4893
+ /**
4894
+ * #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
4895
+ * — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
4896
+ * no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
4897
+ */
4898
+ dmDashboardOwner(userId, adapterId) {
4899
+ const world = this.worlds.get(adapterId);
4900
+ if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
4901
+ return null;
4902
+ const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
4903
+ const group = String(configured?.group_id ?? "");
4904
+ if (!group)
4905
+ return null;
4906
+ const topics = Object.entries(this.fleetConfig?.instances ?? {})
4907
+ .filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
4908
+ && String(this.getGroupIdForInstance(name) ?? "") === group)
4909
+ .map(([, cfg]) => cfg.topic_id.toString());
4910
+ if (topics.length !== 1)
4911
+ return null;
4912
+ const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
4913
+ return this.publicOwnerCurrent(owner) ? owner : null;
4914
+ }
4915
+ /**
4916
+ * #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
4917
+ * Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
4918
+ * admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
4919
+ */
4920
+ tgWebStartWindows = new Map();
4921
+ openTgWebStartWindow(adapterId, userId) {
4922
+ const now = performance.now();
4923
+ for (const [key, until] of this.tgWebStartWindows)
4924
+ if (until <= now)
4925
+ this.tgWebStartWindows.delete(key);
4926
+ this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
4927
+ }
4928
+ /** Uses the window up: true when it was open. */
4929
+ takeTgWebStartWindow(adapterId, userId) {
4930
+ const key = `${adapterId}\0${userId}`;
4931
+ const until = this.tgWebStartWindows.get(key);
4932
+ this.tgWebStartWindows.delete(key);
4933
+ return until !== undefined && performance.now() < until;
4934
+ }
4935
+ /** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
4936
+ async startWebInDm(msg, adapter) {
4937
+ if (!adapter)
4938
+ return;
4939
+ const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
4940
+ if (!owner) {
4941
+ await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
4942
+ return;
4943
+ }
4944
+ if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
4945
+ await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
4946
+ }
4947
+ }
4770
4948
  getPublicWebLink() {
4771
4949
  this.initializeWebSessions();
4772
4950
  return this.publicWebLink ??= new PublicWebLink({
@@ -4944,7 +5122,11 @@ export class FleetManager {
4944
5122
  this.logger.info({ code: typeof code === "string" || typeof code === "number" ? code : undefined, err: err?.message }, "Dashboard DM not delivered; answering privately where the command was used");
4945
5123
  }
4946
5124
  /** False when the menu was not shown (the caller still owes the user an answer). */
4947
- async showDashboardMenu(owner, respondButtons, retireButtons) {
5125
+ /**
5126
+ * `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
5127
+ * gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
5128
+ */
5129
+ async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
4948
5130
  if (!this.publicOwnerCurrent(owner)) {
4949
5131
  this.logger.info("Dashboard menu not shown: the General owner changed");
4950
5132
  return false;
@@ -4952,7 +5134,7 @@ export class FleetManager {
4952
5134
  const adapter = owner.binding;
4953
5135
  const status = this.getPublicWebStatus();
4954
5136
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
4955
- const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
5137
+ const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
4956
5138
  const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4957
5139
  + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4958
5140
  + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
@@ -4963,7 +5145,7 @@ export class FleetManager {
4963
5145
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4964
5146
  const posted = await this.postNonceButtonPrompt({
4965
5147
  prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4966
- chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
5148
+ chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
4967
5149
  message: menuText,
4968
5150
  choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
4969
5151
  ...(respondButtons ? { deliver: async (c) => {
@@ -5026,6 +5208,8 @@ export class FleetManager {
5026
5208
  }) : null;
5027
5209
  let lastSteps;
5028
5210
  let deliveredByDm = false;
5211
+ // #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
5212
+ let dmRefused = false;
5029
5213
  const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
5030
5214
  const token = this.webToken;
5031
5215
  if (!token || !current())
@@ -5051,6 +5235,7 @@ export class FleetManager {
5051
5235
  }
5052
5236
  catch (err) {
5053
5237
  this.logDashboardDmFailure(err);
5238
+ dmRefused = true;
5054
5239
  if (direct.type !== "discord" || !data.respondPrivate || !current())
5055
5240
  throw new Error("private delivery unavailable");
5056
5241
  const sent = await withinBudget(data.respondPrivate(text, c), deadline);
@@ -5096,14 +5281,22 @@ export class FleetManager {
5096
5281
  this.logger.warn({ err }, "Dashboard sign-in delivery failed");
5097
5282
  }
5098
5283
  // Only safe, static words enter General; never link, code or platform error text.
5099
- const outcome = t(!ok ? "dashboard.private_failed"
5100
- : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
5284
+ // #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
5285
+ // with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
5286
+ // /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
5287
+ // not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
5288
+ const telegram = owner.binding.type === "telegram";
5289
+ const dmSteps = !ok && telegram && dmRefused;
5290
+ const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
5291
+ if (dmSteps)
5292
+ this.openTgWebStartWindow(adapterId, owner.userId);
5293
+ const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
5294
+ : telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
5295
+ : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
5101
5296
  await progress;
5102
5297
  await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
5103
5298
  // #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
5104
- const failUrlButtons = (!ok && owner.binding.type === "telegram")
5105
- ? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
5106
- : undefined;
5299
+ const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
5107
5300
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
5108
5301
  return true;
5109
5302
  }
@@ -5352,6 +5545,39 @@ export class FleetManager {
5352
5545
  * starts this timer on that path (Prism a) — without this, the owed
5353
5546
  * outcome would sit in the marker unwatched. READY never waits for it.
5354
5547
  */
5548
+ /**
5549
+ * #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
5550
+ * Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
5551
+ * → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
5552
+ * after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
5553
+ * → nothing.
5554
+ */
5555
+ /**
5556
+ * #1716: background work this restart/update ended, scoped to the operation
5557
+ * so an older stop never reports (`sinceAt` null = plain boot, report
5558
+ * nothing). A method — not a module function — because the #1737
5559
+ * extracted-completion harness runs this region verbatim with only
5560
+ * `this`-methods plus a fixed context.
5561
+ */
5562
+ collectBackgroundWorkEndedNames(names, sinceAt) {
5563
+ if (sinceAt === null)
5564
+ return [];
5565
+ return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
5566
+ }
5567
+ updateCleanupAtCompletion(pending, operation) {
5568
+ if (!pending || operation !== "update")
5569
+ return null;
5570
+ const receipt = updateReceiptOf(pending);
5571
+ if (!receipt)
5572
+ return null;
5573
+ const current = readUpdateProgress(this.dataDir);
5574
+ const verdict = cleanupWatchVerdict(current, receipt);
5575
+ if (verdict.action === "append")
5576
+ return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
5577
+ if (verdict.action === "wait" && current?.progress.cleanup === "pending")
5578
+ return { watch: receipt };
5579
+ return null;
5580
+ }
5355
5581
  armCleanupWatch(receipt, summary) {
5356
5582
  this.updateCleanupWatch = { receipt, summary };
5357
5583
  if (!this.updateProgressTimer)
@@ -5514,6 +5740,8 @@ export class FleetManager {
5514
5740
  const fleet = this.loadConfig(configPath);
5515
5741
  this.slimFleetConfigAtStartup();
5516
5742
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
5743
+ // #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
5744
+ const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
5517
5745
  const savedUpdateProgress = readUpdateProgress(this.dataDir);
5518
5746
  const pendingUpdateProgress = savedUpdateProgress
5519
5747
  && savedUpdateProgress.progress.stage !== "failed"
@@ -5947,32 +6175,37 @@ export class FleetManager {
5947
6175
  const _require = createRequire(import.meta.url);
5948
6176
  const agendVersion = _require("../package.json").version ?? "unknown";
5949
6177
  await progressStart;
5950
- // #1660: when the adopted update still owes its retire outcome, the
5951
- // completion posts without the warning and this same summary object is
5952
- // kept for the watch to append it to the same receipt later. Waiting
5953
- // for cleanup before READY would deadlock with the updater (it retires
5954
- // only after the restart returns), so READY and cleanup notification
5955
- // have separate lifecycles (review checklist 3/5).
6178
+ // #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
6179
+ // a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
6180
+ // render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
6181
+ // — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
6182
+ // for cleanup (that would deadlock with an updater that retires after its restart returns).
6183
+ const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
6184
+ // #1716: background work this restart/update ended, scoped to the
6185
+ // operation so an older stop never reports. No pending operation means
6186
+ // a plain boot — nothing was stopped by an operation, report nothing.
6187
+ const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
5956
6188
  const completionSummary = {
5957
6189
  running: started,
5958
6190
  total,
5959
6191
  version: agendVersion,
5960
6192
  pausedNames,
5961
6193
  failedNames,
6194
+ backgroundWorkEndedNames,
5962
6195
  tipText: pendingProgressOperation === "update" && this.tipsEnabled()
5963
6196
  ? (() => {
5964
6197
  const tip = this.pickAvailableTip();
5965
6198
  return tip ? this.formatTip(tip) : undefined;
5966
6199
  })()
5967
6200
  : undefined,
6201
+ ...(cleanupOutcome && "render" in cleanupOutcome
6202
+ ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
6203
+ : {}),
5968
6204
  };
5969
6205
  const progressCompleted = await startupProgress.finish(completionSummary);
5970
- if (pendingUpdateProgress && pendingProgressOperation === "update"
5971
- && pendingUpdateProgress.progress.cleanup === "pending") {
5972
- const receipt = updateReceiptOf(pendingUpdateProgress);
5973
- if (receipt)
5974
- this.armCleanupWatch(receipt, completionSummary);
5975
- }
6206
+ // Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
6207
+ if (cleanupOutcome && "watch" in cleanupOutcome)
6208
+ this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
5976
6209
  if (!progressCompleted && fleet.channel?.group_id) {
5977
6210
  let text;
5978
6211
  if (pendingProgressOperation === "full-restart") {
@@ -5982,6 +6215,7 @@ export class FleetManager {
5982
6215
  version: agendVersion,
5983
6216
  pausedNames,
5984
6217
  failedNames,
6218
+ backgroundWorkEndedNames,
5985
6219
  }, pendingUpdateProgress.startedAt);
5986
6220
  }
5987
6221
  else if (failedNames.length === 0 && pausedNames.length === 0) {
@@ -5994,9 +6228,18 @@ export class FleetManager {
5994
6228
  text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
5995
6229
  + (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
5996
6230
  }
5997
- // #1660: a pending cleanup warning cannot be present yet (the updater
5998
- // retires only after the restart returns); the watch appends it to
5999
- // the adopted message when the outcome lands. Nothing to add here.
6231
+ // #1716: the fallback carries the same background-work names as the
6232
+ // completion (inline: this block is executed verbatim by the #1737
6233
+ // extracted-completion harness, which resolves `this`-methods plus a
6234
+ // fixed context — no new bare module references here).
6235
+ if (backgroundWorkEndedNames.length) {
6236
+ text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
6237
+ }
6238
+ // #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
6239
+ if (cleanupOutcome && "render" in cleanupOutcome) {
6240
+ text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
6241
+ + (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
6242
+ }
6000
6243
  await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
6001
6244
  }
6002
6245
  // After "fleet ready", so the notice is not the first thing people see of a restart.
@@ -6018,7 +6261,7 @@ export class FleetManager {
6018
6261
  // A SIGHUP may arrive after the PID/general is available but before the
6019
6262
  // rest of startup finishes. Replay one coalesced reload only after all
6020
6263
  // startup-owned lifecycle work and signal handlers are in place.
6021
- this.finishStartup();
6264
+ this.finishStartup(bootUpdateMarker);
6022
6265
  // Tell systemd we are ready only now. This used to fire right after the
6023
6266
  // generals started — before adapters, classic instances, topic creation and the
6024
6267
  // health server — so `systemctl start` returned success while the fleet was
@@ -7268,8 +7511,15 @@ export class FleetManager {
7268
7511
  async handleInboundMessage(msg) {
7269
7512
  if (msg.isBotMessage)
7270
7513
  return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7271
- const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapters.get(msg.adapterId ?? "") ?? this.adapter;
7272
- return withSpawnHoldReply(text => adapter?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
7514
+ return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
7515
+ }
7516
+ /**
7517
+ * #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
7518
+ * the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
7519
+ */
7520
+ inboundNoticeAdapter(msg) {
7521
+ const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
7522
+ return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
7273
7523
  }
7274
7524
  async handleInboundMessageCore(msg) {
7275
7525
  // #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
@@ -7303,6 +7553,15 @@ export class FleetManager {
7303
7553
  return;
7304
7554
  }
7305
7555
  const threadId = this.inboundRouteThreadId(msg);
7556
+ // #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
7557
+ // for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
7558
+ // agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
7559
+ // our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
7560
+ // (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
7561
+ if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
7562
+ this.logger.debug({ adapterId: msg.adapterId, threadId, messageId: msg.messageId, author: msg.userId }, "Inbound dropped: authored by one of this fleet's own bots in a fleet topic");
7563
+ return;
7564
+ }
7306
7565
  const rootCommand = this.telegramRootCommandTarget(msg, threadId);
7307
7566
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
7308
7567
  // Ownership and per-adapter filtering both run before the dedup claim below.
@@ -7434,10 +7693,17 @@ export class FleetManager {
7434
7693
  // #1686: intercept /start web before the ClassicBot admission flow runs.
7435
7694
  // /start alone already opens the DM for future bot-to-user messages — no further action needed.
7436
7695
  const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7437
- if (requestedBackend === "web" && isPrivateChat) {
7438
- if (this.isFleetAdmin(msg.userId, msg.adapterId ?? "")) {
7439
- await msgAdapter?.sendText(chatId, t("dashboard.start_web_ack"));
7440
- }
7696
+ // #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
7697
+ // (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
7698
+ const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
7699
+ const webStart = isPrivateChat && (requestedBackend === "web"
7700
+ || (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
7701
+ if (webStart) {
7702
+ if (requestedBackend === "web")
7703
+ this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
7704
+ // #1742: the /web menu right here; the code comes to this DM.
7705
+ if (admin)
7706
+ await this.startWebInDm(msg, msgAdapter ?? undefined);
7441
7707
  // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
7442
7708
  return;
7443
7709
  }
@@ -7864,9 +8130,14 @@ export class FleetManager {
7864
8130
  if (Date.now() - lastWarn < 30 * 60_000)
7865
8131
  return;
7866
8132
  this.rateLimitWarnedAt.set(instanceName, Date.now());
7867
- const warnAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
7868
- if (warnAdapter && msg.chatId) {
7869
- warnAdapter.sendText(msg.chatId, warning, { threadId: msg.threadId ?? undefined }).catch(() => { });
8133
+ // #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
8134
+ // inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
8135
+ // bot message it would take as input).
8136
+ const warnAdapter = this.getAdapterForInstance(instanceName);
8137
+ const dest = this.cancelButtonDestination(instanceName);
8138
+ const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
8139
+ if (warnAdapter && chatId) {
8140
+ warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
7870
8141
  }
7871
8142
  }
7872
8143
  /** Handle outbound tool calls from a daemon instance */
@@ -11679,7 +11950,8 @@ export class FleetManager {
11679
11950
  // only place some outcomes are told (cancel, later, a resend's result),
11680
11951
  // so the text goes out as a message instead (#1133).
11681
11952
  this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
11682
- await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId })
11953
+ // #1742: with its link button, if it had one — the outcome may be the only way to the DM.
11954
+ await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
11683
11955
  .catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
11684
11956
  }
11685
11957
  }
@@ -16118,11 +16390,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16118
16390
  }
16119
16391
  }
16120
16392
  this.instanceWorldBinding.delete(ch.instanceName);
16121
- await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
16393
+ // #1716: say so in the reply when background work was ended with the stop.
16394
+ // Null-safe: a foreign stopInstance stub may resolve nothing at all.
16395
+ const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
16396
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
16397
+ return { backgroundWorkEnded: false };
16398
+ });
16122
16399
  clearPausedMarker(this.getInstanceDir(ch.instanceName));
16123
16400
  this.reregisterClassicChannels();
16124
16401
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
16125
- return t("classic.stopped");
16402
+ return t("classic.stopped")
16403
+ + (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
16126
16404
  }
16127
16405
  /**
16128
16406
  * Idempotent while in flight: SIGINT and SIGTERM share one handler and the
@@ -16317,8 +16595,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16317
16595
  this.controlClient = null;
16318
16596
  if (this.healthServer) {
16319
16597
  this.healthServerListening = false;
16320
- this.healthServer.close();
16321
- this.healthServer = null;
16598
+ const server = this.healthServer;
16599
+ const drain = this.healthServerDrain ?? trackWebServer(server);
16600
+ await drain.close(this.sseClients);
16601
+ if (this.healthServer === server) {
16602
+ this.healthServer = null;
16603
+ this.healthServerDrain = null;
16604
+ }
16322
16605
  }
16323
16606
  this.stopPreviewListener();
16324
16607
  // The store writes lastSeen at most once a minute; what that debounce is still holding — and any
@@ -16647,8 +16930,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16647
16930
  if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
16648
16931
  this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
16649
16932
  observe?.(ch.instanceName, "restart", "running");
16933
+ const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
16650
16934
  await this.stopInstance(ch.instanceName).catch(() => { });
16651
- if (profile.startsFresh)
16935
+ // #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
16936
+ if (profile.carry)
16937
+ await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
16938
+ else if (profile.startsFresh)
16652
16939
  this.writeFreshStartMarker(ch.instanceName);
16653
16940
  await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
16654
16941
  observe?.(ch.instanceName, "restart", "done");
@@ -18287,7 +18574,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18287
18574
  this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
18288
18575
  }
18289
18576
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
18290
- dispatchWebHttp(req, res, port) {
18577
+ dispatchWebHttp(req, res, port, closing = false) {
18291
18578
  try {
18292
18579
  res.setHeader("Content-Type", "application/json");
18293
18580
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
@@ -18306,6 +18593,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18306
18593
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
18307
18594
  return;
18308
18595
  }
18596
+ if (closing) {
18597
+ res.writeHead(503, { Connection: "close", "Retry-After": "10" });
18598
+ res.end(JSON.stringify({ error: "fleet stopping" }));
18599
+ return;
18600
+ }
18309
18601
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
18310
18602
  // Branding is public, after Host admission: automatic favicon requests
18311
18603
  // must not become noisy web-token 401s in the browser console.
@@ -18624,7 +18916,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18624
18916
  // Defensive for direct/unit callers; normal startup initializes these before adapters.
18625
18917
  if (!this.webToken || !this.webSessions)
18626
18918
  this.initializeWebAuthTokens();
18627
- this.healthServer = createServer((req, res) => this.dispatchWebHttp(req, res, port));
18919
+ const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
18920
+ const drain = trackWebServer(server);
18921
+ this.healthServer = server;
18922
+ this.healthServerDrain = drain;
18628
18923
  const markListening = (afterTakeover = false) => {
18629
18924
  this.healthServerListening = true;
18630
18925
  this.logger.info({ port }, afterTakeover