@songsid/agend 2.1.4-beta.2 → 2.1.4-beta.21

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 (128) hide show
  1. package/dist/agent-endpoint.js +1 -1
  2. package/dist/agent-endpoint.js.map +1 -1
  3. package/dist/backend/antigravity.d.ts +1 -0
  4. package/dist/backend/antigravity.js +4 -0
  5. package/dist/backend/antigravity.js.map +1 -1
  6. package/dist/backend/claude-code.js +23 -2
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/codex.d.ts +17 -0
  9. package/dist/backend/codex.js +122 -7
  10. package/dist/backend/codex.js.map +1 -1
  11. package/dist/backend/kiro.d.ts +1 -0
  12. package/dist/backend/kiro.js +14 -0
  13. package/dist/backend/kiro.js.map +1 -1
  14. package/dist/backend/opencode.d.ts +27 -11
  15. package/dist/backend/opencode.js +48 -37
  16. package/dist/backend/opencode.js.map +1 -1
  17. package/dist/backend/types.d.ts +14 -0
  18. package/dist/backend/types.js +7 -0
  19. package/dist/backend/types.js.map +1 -1
  20. package/dist/channel/adapters/discord.js +76 -25
  21. package/dist/channel/adapters/discord.js.map +1 -1
  22. package/dist/channel/adapters/telegram.js +5 -3
  23. package/dist/channel/adapters/telegram.js.map +1 -1
  24. package/dist/channel/mcp-server.js +6 -20
  25. package/dist/channel/mcp-server.js.map +1 -1
  26. package/dist/channel/mcp-tools.js +4 -2
  27. package/dist/channel/mcp-tools.js.map +1 -1
  28. package/dist/channel/types.d.ts +1 -1
  29. package/dist/classic-channel-manager.d.ts +40 -3
  30. package/dist/classic-channel-manager.js +218 -31
  31. package/dist/classic-channel-manager.js.map +1 -1
  32. package/dist/cli.js +159 -36
  33. package/dist/cli.js.map +1 -1
  34. package/dist/completion-install.d.ts +70 -0
  35. package/dist/completion-install.js +152 -0
  36. package/dist/completion-install.js.map +1 -0
  37. package/dist/completion.d.ts +7 -1
  38. package/dist/completion.js +14 -4
  39. package/dist/completion.js.map +1 -1
  40. package/dist/config-validator.js +38 -7
  41. package/dist/config-validator.js.map +1 -1
  42. package/dist/config.d.ts +2 -0
  43. package/dist/config.js +10 -1
  44. package/dist/config.js.map +1 -1
  45. package/dist/daemon.d.ts +171 -5
  46. package/dist/daemon.js +696 -82
  47. package/dist/daemon.js.map +1 -1
  48. package/dist/doctor.d.ts +26 -0
  49. package/dist/doctor.js +216 -0
  50. package/dist/doctor.js.map +1 -0
  51. package/dist/fleet-context.d.ts +16 -0
  52. package/dist/fleet-manager.d.ts +233 -10
  53. package/dist/fleet-manager.js +1828 -493
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/fleet-yaml-slim.d.ts +10 -0
  56. package/dist/fleet-yaml-slim.js +59 -0
  57. package/dist/fleet-yaml-slim.js.map +1 -0
  58. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  59. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  60. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  61. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  62. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  63. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  64. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  65. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  66. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  67. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  68. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  69. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
  70. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  71. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  72. package/dist/instance-lifecycle.d.ts +8 -0
  73. package/dist/instance-lifecycle.js +46 -25
  74. package/dist/instance-lifecycle.js.map +1 -1
  75. package/dist/instructions.d.ts +12 -0
  76. package/dist/instructions.js +33 -0
  77. package/dist/instructions.js.map +1 -1
  78. package/dist/locale.js +656 -220
  79. package/dist/locale.js.map +1 -1
  80. package/dist/outbound-handlers.d.ts +8 -0
  81. package/dist/outbound-handlers.js +44 -5
  82. package/dist/outbound-handlers.js.map +1 -1
  83. package/dist/outbound-schemas.d.ts +5 -0
  84. package/dist/outbound-schemas.js +7 -0
  85. package/dist/outbound-schemas.js.map +1 -1
  86. package/dist/quickstart.js +25 -0
  87. package/dist/quickstart.js.map +1 -1
  88. package/dist/restart-progress.d.ts +9 -1
  89. package/dist/restart-progress.js +30 -6
  90. package/dist/restart-progress.js.map +1 -1
  91. package/dist/scheduler/db.d.ts +5 -0
  92. package/dist/scheduler/db.js +35 -0
  93. package/dist/scheduler/db.js.map +1 -1
  94. package/dist/service-installer.d.ts +11 -0
  95. package/dist/service-installer.js +84 -18
  96. package/dist/service-installer.js.map +1 -1
  97. package/dist/settings-api.js +1 -1
  98. package/dist/settings-api.js.map +1 -1
  99. package/dist/tips.d.ts +39 -0
  100. package/dist/tips.js +351 -0
  101. package/dist/tips.js.map +1 -0
  102. package/dist/tool-progress.d.ts +40 -0
  103. package/dist/tool-progress.js +289 -0
  104. package/dist/tool-progress.js.map +1 -0
  105. package/dist/topic-commands.d.ts +28 -4
  106. package/dist/topic-commands.js +227 -65
  107. package/dist/topic-commands.js.map +1 -1
  108. package/dist/transcript-monitor.d.ts +15 -2
  109. package/dist/transcript-monitor.js +63 -17
  110. package/dist/transcript-monitor.js.map +1 -1
  111. package/dist/transcript-sources.d.ts +106 -0
  112. package/dist/transcript-sources.js +428 -0
  113. package/dist/transcript-sources.js.map +1 -0
  114. package/dist/types.d.ts +11 -0
  115. package/dist/ui/dashboard.html +55 -32
  116. package/dist/ui/settings.html +120 -51
  117. package/dist/ui/view.html +23 -15
  118. package/dist/update-marker.d.ts +30 -0
  119. package/dist/update-marker.js +71 -20
  120. package/dist/update-marker.js.map +1 -1
  121. package/dist/update-progress.d.ts +6 -0
  122. package/dist/update-progress.js +29 -0
  123. package/dist/update-progress.js.map +1 -0
  124. package/dist/usage/usage-api.d.ts +8 -2
  125. package/dist/usage/usage-api.js +24 -3
  126. package/dist/usage/usage-api.js.map +1 -1
  127. package/dist/workflow-templates/default.md +2 -1
  128. package/package.json +1 -1
@@ -3,12 +3,14 @@ import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
4
  import { join, dirname, basename } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { isDeepStrictEqual } from "node:util";
6
7
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
7
- import { clearUpdateMarker, isUpdateInProgress } from "./update-marker.js";
8
+ import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
9
+ import { formatUpdateProgress } from "./update-progress.js";
8
10
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
9
11
  import { readFleetMemory } from "./process-memory.js";
10
12
  import { ReplyDeduper } from "./reply-dedup.js";
11
- import { isScalar, parseDocument } from "yaml";
13
+ import { isMap, isScalar, parseDocument } from "yaml";
12
14
  const __filename = fileURLToPath(import.meta.url);
13
15
  const __dirname = dirname(__filename);
14
16
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
@@ -29,7 +31,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
29
31
  import { routeToolCall } from "./channel/tool-router.js";
30
32
  import { Scheduler } from "./scheduler/index.js";
31
33
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
32
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
34
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
33
35
  import { DailySummary } from "./daily-summary.js";
34
36
  import { WebhookEmitter } from "./webhook-emitter.js";
35
37
  import { TmuxControlClient } from "./tmux-control.js";
@@ -41,9 +43,9 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
41
43
  import { outboundHandlers } from "./outbound-handlers.js";
42
44
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
43
45
  import { handleViewRequest, isViewPath } from "./view-api.js";
44
- import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
46
+ import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
45
47
  import { handleSettingsRequest } from "./settings-api.js";
46
- import { setLocale, detectLocale, t } from "./locale.js";
48
+ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
47
49
  import { handleAgentRequest } from "./agent-endpoint.js";
48
50
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
49
51
  import { validateFleetConfig } from "./config-validator.js";
@@ -53,6 +55,8 @@ import { releaseProcessFleetLock } from "./fleet-lock.js";
53
55
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
54
56
  import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
55
57
  import { RestartProgress } from "./restart-progress.js";
58
+ import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
59
+ import { canUnlockAdvancedTips, DailyTipScheduler, selectTip } from "./tips.js";
56
60
  import { getTmuxSession } from "./config.js";
57
61
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
58
62
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -105,6 +109,8 @@ const CANCEL_BTN_IDLE_RETIRE_GRACE_MS = 2_000;
105
109
  * button alone (the idle edge retires it when the run really ends).
106
110
  */
107
111
  const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
112
+ /** Bound for the daemon to capture the pane and answer a post-reply state query. */
113
+ const REPLY_STATE_REFRESH_TIMEOUT_MS = 2_000;
108
114
  /**
109
115
  * The daemon only broadcasts execution state on TRANSITIONS, so a long
110
116
  * single-state run sends nothing for hours. The idle backstop therefore pokes a
@@ -134,6 +140,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
134
140
  * exactly one progress message per turn, and it is the cancel button itself.
135
141
  */
136
142
  const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
143
+ /** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
144
+ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
137
145
  /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
138
146
  /**
139
147
  * Default delay before the button starts showing elapsed time. Configurable via
@@ -153,6 +161,36 @@ const PROGRESS_ACTIVITY_MAX_CHARS = 48;
153
161
  * 🫡 passes too — it reads as a deliberate acknowledgement, not plumbing.
154
162
  */
155
163
  const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
164
+ /**
165
+ * Reactions that are neither delivery plumbing nor meaningful conversational
166
+ * feedback. Keep this separate from DELIVERY_STATUS_EMOJIS so adding a UI-only
167
+ * emoji never changes the documented delivery-state protocol.
168
+ */
169
+ const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
170
+ const HOT_INSTANCE_CONFIG_KEYS = new Set([
171
+ "tool_progress",
172
+ "mcp_proxy_reply",
173
+ "auto_pause_after",
174
+ "warm_cap",
175
+ "display_name",
176
+ "description",
177
+ "tags",
178
+ "log_level",
179
+ ]);
180
+ function splitHotColdConfig(config) {
181
+ const hot = {};
182
+ const cold = {};
183
+ for (const [key, value] of Object.entries(config)) {
184
+ (HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
185
+ }
186
+ return { hot, cold };
187
+ }
188
+ function hotConfigUpdate(config) {
189
+ const update = {};
190
+ for (const key of HOT_INSTANCE_CONFIG_KEYS)
191
+ update[key] = config[key] ?? null;
192
+ return update;
193
+ }
156
194
  /**
157
195
  * How long a delivery waits out a disconnected instance IPC before giving up.
158
196
  *
@@ -166,7 +204,15 @@ const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
166
204
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
167
205
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
168
206
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
169
- const INTERACTIVE_ASSIST_TIMEOUT_MS = 15 * 60_000;
207
+ const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
208
+ const HANG_CALLBACK_PREFIX = "hang:";
209
+ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
210
+ const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
211
+ const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
212
+ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
213
+ /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
214
+ const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
215
+ const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
170
216
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
171
217
  export class FleetManager {
172
218
  dataDir;
@@ -213,6 +259,7 @@ export class FleetManager {
213
259
  costGuard = null;
214
260
  statuslineWatcher;
215
261
  dailySummary = null;
262
+ dailyTipScheduler = null;
216
263
  webhookEmitter = null;
217
264
  // Topic icon + auto-archive state
218
265
  topicIcons = {};
@@ -225,6 +272,13 @@ export class FleetManager {
225
272
  warmCapEvicting = new Set();
226
273
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
227
274
  idleGatedDeliveryTails = new Map();
275
+ /**
276
+ * Per-instance cancellation epoch for deliveries which have not reached the
277
+ * daemon yet. A cancel advances the epoch; queued work captures the old value
278
+ * and becomes a no-op, while messages arriving after the click capture the new
279
+ * value and remain deliverable.
280
+ */
281
+ deliveryEpochs = new Map();
228
282
  /** Non-user work must observe a fresh idle snapshot after the latest delivery. */
229
283
  lastDeliveryAt = new Map();
230
284
  /** State-cache updates wake event-driven idle waiters without busy polling. */
@@ -241,6 +295,8 @@ export class FleetManager {
241
295
  replyDeduper = new ReplyDeduper();
242
296
  /** instanceName → what it is doing right now, when the backend can tell us. */
243
297
  instanceActivity = new Map();
298
+ /** instanceName → this turn's tool list (multi-line), for the bubble. */
299
+ instanceProgress = new Map();
244
300
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
245
301
  ipcWaitTails = new Map();
246
302
  /** instanceName → restart currently executing; concurrent callers join it. */
@@ -255,8 +311,8 @@ export class FleetManager {
255
311
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
256
312
  pendingEffortSelects = new Map();
257
313
  pendingModelSelects = new Map();
258
- /** One-shot General assistance buttons for stable interactive terminal prompts. */
259
- pendingInteractivePromptAssists = new Map();
314
+ /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
315
+ pendingNonceButtons = new Map();
260
316
  // Model failover state
261
317
  failoverActive = new Map(); // instance → current failover model
262
318
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
@@ -278,6 +334,10 @@ export class FleetManager {
278
334
  healthServer = null;
279
335
  healthPortRetried = false;
280
336
  updateCheckTimer = null;
337
+ updateProgressTimer = null;
338
+ updateProgressEditRunning = false;
339
+ lastUpdateProgressText = null;
340
+ updateCompletionTipText = null;
281
341
  eventLogPruneTimer = null;
282
342
  logRotateTimer = null;
283
343
  /** Days of event/activity history to keep. */
@@ -326,7 +386,7 @@ export class FleetManager {
326
386
  // fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
327
387
  this.logger.error({ err }, "SIGHUP config reload failed");
328
388
  const message = err instanceof Error ? err.message : String(err);
329
- this.notifyFleetError(`⚠️ fleet.yaml reload FAILED — ${message}\nThe previous configuration is still running.`);
389
+ this.notifyFleetError(t("fleet.reload_failed", message));
330
390
  })
331
391
  .finally(() => {
332
392
  this.reconcileInFlight = null;
@@ -557,6 +617,65 @@ export class FleetManager {
557
617
  }
558
618
  await data.respond(await this.topicCommands.runPauseWake(target, action));
559
619
  }
620
+ async handleUpdateSlash(data, adapterId) {
621
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
622
+ if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
623
+ await data.respond(t("not_authorized"));
624
+ return;
625
+ }
626
+ const messageId = await data.respond(t("update.progress.preparing", 0));
627
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
628
+ if (messageId && adapter) {
629
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
630
+ this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
631
+ }
632
+ const { spawn } = await import("node:child_process");
633
+ const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
634
+ const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
635
+ const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
636
+ child.once("error", err => this.failUpdateProgress(err.message));
637
+ child.unref();
638
+ }
639
+ async handleTipsSlash(data, adapterId) {
640
+ if (!this.fleetConfig)
641
+ return;
642
+ const mode = typeof data.options?.mode === "string" ? data.options.mode : "";
643
+ if (mode === "advanced on") {
644
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
645
+ await data.respond(t("permission.denied"));
646
+ return;
647
+ }
648
+ await data.respond(this.unlockAdvancedTips(data.userId)
649
+ ? t("tips.advanced.unlocked")
650
+ : t("tips.unavailable"));
651
+ return;
652
+ }
653
+ if (mode === "on" || mode === "off") {
654
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
655
+ await data.respond(t("permission.denied"));
656
+ return;
657
+ }
658
+ this.fleetConfig.defaults.tips = mode === "on";
659
+ this.saveFleetConfig();
660
+ await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
661
+ return;
662
+ }
663
+ const route = this.routing.resolve(data.channelId);
664
+ if (!route || route.kind !== "general") {
665
+ await data.respond(t("tips.general_only"));
666
+ return;
667
+ }
668
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
669
+ if (!adapter) {
670
+ await data.respond(t("tips.unavailable"));
671
+ return;
672
+ }
673
+ const chatId = this.getGroupIdForInstance(route.name) || data.channelId;
674
+ const result = await this.promptTip(route.name, adapter, chatId, data.channelId);
675
+ await data.respond(t(result === "posted"
676
+ ? "tips.posted"
677
+ : result === "empty" ? "tips.empty" : "tips.unavailable"));
678
+ }
560
679
  /** Admin-only full conversation reset for fleet-topic and Classic instances. */
561
680
  async handleClearSlash(data, adapterId) {
562
681
  if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
@@ -568,7 +687,14 @@ export class FleetManager {
568
687
  await data.respond(t("classic.no_agent"));
569
688
  return;
570
689
  }
571
- await data.respond(await this.topicCommands.sendClear(name));
690
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
691
+ if (!adapter) {
692
+ await data.respond(t("clear.prompt_unavailable"));
693
+ return;
694
+ }
695
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
696
+ const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
697
+ await data.respond(fallback ?? t("clear.confirm_posted"));
572
698
  }
573
699
  /** Get the adapter bound to an instance, falling back to primary adapter */
574
700
  getAdapterForInstance(name) {
@@ -702,6 +828,30 @@ export class FleetManager {
702
828
  return null;
703
829
  return this.instanceStateCache.get(name)?.state ?? null;
704
830
  }
831
+ /**
832
+ * Subscription providers currently relevant to this fleet. Stopped/crashed
833
+ * rows do not keep a usage card alive, while persisted paused instances do.
834
+ * Classic channels use their effective backend merge chain, not the raw row.
835
+ */
836
+ getActiveUsageProviderIds() {
837
+ const providers = new Set();
838
+ const add = (name, backend) => {
839
+ const status = this.getInstanceStatus(name);
840
+ if (status !== "running" && status !== "paused")
841
+ return;
842
+ const provider = usageProviderIdForBackend(backend);
843
+ if (provider)
844
+ providers.add(provider);
845
+ };
846
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
847
+ // loadFleetConfig() has already merged the fleet default into each row.
848
+ add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
849
+ }
850
+ for (const channel of this.classicChannels?.getAll() ?? []) {
851
+ add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
852
+ }
853
+ return providers;
854
+ }
705
855
  isClassicInstance(name) {
706
856
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
707
857
  }
@@ -814,7 +964,7 @@ export class FleetManager {
814
964
  .finally(() => this.warmCapEvicting.delete(victim));
815
965
  }
816
966
  }
817
- waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
967
+ waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
818
968
  const isReady = () => {
819
969
  const snapshot = this.instanceStateCache.get(instanceName);
820
970
  return snapshot?.state === "idle"
@@ -836,8 +986,12 @@ export class FleetManager {
836
986
  this.instanceIdleWaiters.delete(instanceName);
837
987
  resolve(idle);
838
988
  };
839
- const check = () => { if (isReady())
840
- finish(true); };
989
+ const check = () => {
990
+ if (cancelled?.())
991
+ finish(false);
992
+ else if (isReady())
993
+ finish(true);
994
+ };
841
995
  const query = () => {
842
996
  const ipc = this.instanceIpcClients.get(instanceName);
843
997
  if (ipc?.connected) {
@@ -853,22 +1007,74 @@ export class FleetManager {
853
1007
  query();
854
1008
  });
855
1009
  }
856
- async deliverWithIdleGate(instanceName, payload, timeoutMs) {
1010
+ /**
1011
+ * Ask the daemon to capture the pane before answering, then wait for any state
1012
+ * report produced after this request. The ordinary query is intentionally
1013
+ * cache-only (idle gates call it frequently); this opt-in refresh is reserved
1014
+ * for lifecycle decisions where a stale state would strand UI.
1015
+ */
1016
+ refreshInstanceExecutionState(instanceName, timeoutMs) {
1017
+ const ipc = this.instanceIpcClients.get(instanceName);
1018
+ if (!ipc?.connected)
1019
+ return Promise.resolve(false);
1020
+ const previous = this.instanceStateCache.get(instanceName);
1021
+ return new Promise(resolve => {
1022
+ let settled = false;
1023
+ const finish = (refreshed) => {
1024
+ if (settled)
1025
+ return;
1026
+ settled = true;
1027
+ clearTimeout(timeout);
1028
+ const waiters = this.instanceIdleWaiters.get(instanceName);
1029
+ waiters?.delete(check);
1030
+ if (waiters?.size === 0)
1031
+ this.instanceIdleWaiters.delete(instanceName);
1032
+ resolve(refreshed);
1033
+ };
1034
+ const check = () => {
1035
+ if (this.instanceStateCache.get(instanceName) !== previous)
1036
+ finish(true);
1037
+ };
1038
+ const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
1039
+ waiters.add(check);
1040
+ this.instanceIdleWaiters.set(instanceName, waiters);
1041
+ const timeout = setTimeout(() => finish(false), timeoutMs);
1042
+ timeout.unref?.();
1043
+ const sent = ipc.send({
1044
+ type: "query_instance_state",
1045
+ requestId: `reply-grace-${Date.now()}`,
1046
+ refresh: true,
1047
+ });
1048
+ if (!sent)
1049
+ finish(false);
1050
+ });
1051
+ }
1052
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1053
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1054
+ return;
857
1055
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
858
1056
  if (this.lifecycle.isPaused(instanceName)) {
859
1057
  const wakeStartedAt = Date.now();
860
1058
  await this.lifecycle.wake(instanceName, 30_000);
1059
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1060
+ return;
861
1061
  // Waking added one to the warm count — make room by evicting a different
862
1062
  // LRU idle instance (never this one; it's about to work).
863
1063
  this.enforceWarmCap(instanceName);
864
1064
  // Never satisfy a post-wake gate from a stale pre-pause cache entry.
865
1065
  idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
866
1066
  }
867
- const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
1067
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
1068
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1069
+ this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
1070
+ return;
1071
+ }
868
1072
  if (!idle) {
869
1073
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
870
1074
  }
871
- await this.sendWhenConnected(instanceName, payload);
1075
+ await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
1076
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1077
+ return;
872
1078
  this.lastDeliveryAt.set(instanceName, Date.now());
873
1079
  }
874
1080
  /**
@@ -887,7 +1093,9 @@ export class FleetManager {
887
1093
  * instance, *including* when the socket happens to be up: otherwise a message
888
1094
  * arriving after the reconnect could overtake one that has been waiting for it.
889
1095
  */
890
- async sendWhenConnected(instanceName, payload) {
1096
+ async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1097
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1098
+ return;
891
1099
  const queued = this.ipcWaitTails.get(instanceName);
892
1100
  if (!queued) {
893
1101
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -896,7 +1104,11 @@ export class FleetManager {
896
1104
  }
897
1105
  const attempt = (queued ?? Promise.resolve())
898
1106
  .catch(() => { })
899
- .then(() => this.sendAfterIpcReturns(instanceName, payload));
1107
+ .then(() => {
1108
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1109
+ return;
1110
+ return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
1111
+ });
900
1112
  // The chain stores a settled-either-way promise so one failed delivery cannot
901
1113
  // wedge every later one, and so `queued` above is safe to await unguarded.
902
1114
  const tail = attempt.catch(() => { });
@@ -913,10 +1125,12 @@ export class FleetManager {
913
1125
  }
914
1126
  }
915
1127
  /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
916
- async sendAfterIpcReturns(instanceName, payload) {
1128
+ async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
917
1129
  const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
918
1130
  let warned = false;
919
1131
  for (;;) {
1132
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1133
+ return;
920
1134
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
921
1135
  // so a cached reference would stay dead forever.
922
1136
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -934,6 +1148,8 @@ export class FleetManager {
934
1148
  }
935
1149
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
936
1150
  async deliverToInstance(instanceName, payload, options = {}) {
1151
+ const deliveryEpoch = this.getDeliveryEpoch(instanceName);
1152
+ const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
937
1153
  const meta = payload.meta && typeof payload.meta === "object"
938
1154
  ? payload.meta
939
1155
  : undefined;
@@ -945,16 +1161,20 @@ export class FleetManager {
945
1161
  if (!waitForIdle) {
946
1162
  if (this.lifecycle.isPaused(instanceName)) {
947
1163
  await this.lifecycle.wake(instanceName, 30_000);
1164
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1165
+ return;
948
1166
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
949
1167
  }
950
- await this.sendWhenConnected(instanceName, payload);
1168
+ await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
1169
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1170
+ return;
951
1171
  // A cross-instance item arriving before the daemon observes this turn as
952
1172
  // working must not trust the stale idle snapshot from before the send.
953
1173
  this.lastDeliveryAt.set(instanceName, Date.now());
954
1174
  return;
955
1175
  }
956
1176
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
957
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
1177
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
958
1178
  this.idleGatedDeliveryTails.set(instanceName, delivery);
959
1179
  try {
960
1180
  await delivery;
@@ -1033,19 +1253,34 @@ export class FleetManager {
1033
1253
  this.logger.info({ name }, "Instance already running, skipping");
1034
1254
  return;
1035
1255
  }
1256
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1036
1257
  if (config.general_topic) {
1037
1258
  // antigravity (agy) does not read MCP instructions — fleet context and
1038
1259
  // routing instructions are not injected, so it cannot act as a dispatcher.
1039
- const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1040
1260
  if (backend === "antigravity") {
1041
1261
  this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
1042
- this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
1262
+ this.notifyInstanceTopic(name, t("general.antigravity_unsupported"));
1263
+ }
1264
+ this.ensureGeneralInstructions(config.working_directory, backend, name);
1265
+ }
1266
+ else if (kind === "fleet-topic") {
1267
+ // Workers get only role-eligible on-demand skills. Classic instances are
1268
+ // deliberately excluded: their workspace and conversation lifecycle are
1269
+ // managed independently from fleet-topic workers.
1270
+ try {
1271
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
1272
+ this.syncRoleSkills(skillsWorkDir, backend, "worker");
1273
+ }
1274
+ catch (err) {
1275
+ // Skill publishing is additive. A read-only or temporarily unavailable
1276
+ // workspace must not turn an otherwise valid worker startup into a
1277
+ // fleet outage.
1278
+ this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
1043
1279
  }
1044
- this.ensureGeneralInstructions(config.working_directory, config.backend);
1045
1280
  }
1046
1281
  await this.lifecycle.start(name, config, topicMode, {
1047
1282
  kind,
1048
- backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
1283
+ backend,
1049
1284
  model: this.resolveInstanceModel(name).display,
1050
1285
  });
1051
1286
  // Only clear a stale process status after a real start succeeded. Clearing
@@ -1210,7 +1445,18 @@ export class FleetManager {
1210
1445
  this.instanceStateCache.delete(name);
1211
1446
  this.instanceProcessStatus.delete(name);
1212
1447
  this.lastDeliveryAt.delete(name);
1213
- return this.lifecycle.stop(name);
1448
+ // A pending hang/exit/assist offer refers to the instance being torn down;
1449
+ // left alone it would stay clickable for the rest of its 15 minutes. Clear
1450
+ // again AFTER the stop completes: the teardown itself can emit hang /
1451
+ // interactive-prompt / clean-exit events whose handlers post fresh prompts
1452
+ // while the stop is still awaiting (the TOCTOU sol's review called out).
1453
+ this.clearNoncePromptsForInstance(name);
1454
+ try {
1455
+ return await this.lifecycle.stop(name);
1456
+ }
1457
+ finally {
1458
+ this.clearNoncePromptsForInstance(name);
1459
+ }
1214
1460
  }
1215
1461
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
1216
1462
  async restartSingleInstance(name, opts) {
@@ -1310,6 +1556,76 @@ export class FleetManager {
1310
1556
  getDashboardAccess() {
1311
1557
  return { ready: this.healthServerListening, token: this.webToken };
1312
1558
  }
1559
+ beginUpdateProgress(adapter, chatId, threadId, messageId) {
1560
+ persistUpdateProgress(this.dataDir, {
1561
+ adapterId: adapter.id,
1562
+ chatId,
1563
+ ...(threadId ? { threadId } : {}),
1564
+ messageId,
1565
+ });
1566
+ this.lastUpdateProgressText = null;
1567
+ this.updateCompletionTipText = null;
1568
+ this.startUpdateProgressMonitor(adapter);
1569
+ }
1570
+ failUpdateProgress(message) {
1571
+ setUpdateProgressStage(this.dataDir, "failed", { error: message });
1572
+ }
1573
+ /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
1574
+ startUpdateProgressMonitor(initialAdapter) {
1575
+ if (this.updateProgressTimer)
1576
+ clearInterval(this.updateProgressTimer);
1577
+ const tick = async () => {
1578
+ if (this.updateProgressEditRunning)
1579
+ return;
1580
+ const marker = readUpdateProgress(this.dataDir);
1581
+ if (!marker) {
1582
+ if (this.updateProgressTimer)
1583
+ clearInterval(this.updateProgressTimer);
1584
+ this.updateProgressTimer = null;
1585
+ return;
1586
+ }
1587
+ const target = marker.progress.target;
1588
+ const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
1589
+ if (!adapter)
1590
+ return;
1591
+ let text = formatUpdateProgress(marker);
1592
+ // No-restart updates complete in the old fleet process, so they never
1593
+ // reach RestartProgress.finish(). Append the same optional tip here.
1594
+ if (marker.progress.stage === "complete" && this.tipsEnabled()) {
1595
+ if (this.updateCompletionTipText === null) {
1596
+ const tip = this.pickAvailableTip();
1597
+ this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
1598
+ }
1599
+ if (this.updateCompletionTipText)
1600
+ text += `\n\n${this.updateCompletionTipText}`;
1601
+ }
1602
+ if (text === this.lastUpdateProgressText)
1603
+ return;
1604
+ this.updateProgressEditRunning = true;
1605
+ try {
1606
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
1607
+ this.lastUpdateProgressText = text;
1608
+ if (marker.progress.stage === "failed" || marker.progress.stage === "complete") {
1609
+ clearUpdateMarker(this.dataDir);
1610
+ if (this.updateProgressTimer)
1611
+ clearInterval(this.updateProgressTimer);
1612
+ this.updateProgressTimer = null;
1613
+ }
1614
+ }
1615
+ catch (err) {
1616
+ this.logger.warn({ err }, "Failed to edit update progress");
1617
+ }
1618
+ finally {
1619
+ this.updateProgressEditRunning = false;
1620
+ }
1621
+ };
1622
+ void tick();
1623
+ // Poll stages faster than the elapsed-time display. npm verification and
1624
+ // service-file refresh can be short; 200ms prevents those stages from being
1625
+ // skipped while the text cache still limits normal edits to once per second.
1626
+ this.updateProgressTimer = setInterval(() => void tick(), 200);
1627
+ this.updateProgressTimer.unref?.();
1628
+ }
1313
1629
  /** Start all instances from fleet config */
1314
1630
  async startAll(configPath) {
1315
1631
  const startupStartedAt = Date.now();
@@ -1323,7 +1639,17 @@ export class FleetManager {
1323
1639
  // Rotate fleet.log if oversized (before any logging)
1324
1640
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1325
1641
  const fleet = this.loadConfig(configPath);
1642
+ this.slimFleetConfigAtStartup();
1326
1643
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
1644
+ const savedUpdateProgress = readUpdateProgress(this.dataDir);
1645
+ const pendingUpdateProgress = savedUpdateProgress
1646
+ && savedUpdateProgress.progress.stage !== "failed"
1647
+ && savedUpdateProgress.progress.stage !== "complete"
1648
+ ? savedUpdateProgress
1649
+ : null;
1650
+ if (pendingUpdateProgress) {
1651
+ setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
1652
+ }
1327
1653
  this.initializeWebAuthTokens();
1328
1654
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
1329
1655
  // Set tmux socket isolation for custom AGEND_HOME
@@ -1371,9 +1697,8 @@ export class FleetManager {
1371
1697
  // routing does NOT go through the routing engine (single-key, can't hold two
1372
1698
  // bots in one channel) — it resolves per-bot via getInstanceByChannel.
1373
1699
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
1374
- const primaryCh = fleet.channels?.[0] ?? fleet.channel;
1375
- if (primaryCh)
1376
- this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
1700
+ const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
1701
+ this.classicChannels.configureAdapters(classicAdapters);
1377
1702
  // Restore the persisted bot binding so replies/cancel go through the right
1378
1703
  // bot after a restart (before this, inbound would re-bind lazily).
1379
1704
  for (const ch of this.classicChannels.getAll()) {
@@ -1542,7 +1867,7 @@ export class FleetManager {
1542
1867
  const generalDir = join(getAgendHome(), name);
1543
1868
  mkdirSync(generalDir, { recursive: true });
1544
1869
  const backendName = fleet.defaults.backend ?? "claude-code";
1545
- this.ensureGeneralInstructions(generalDir, backendName);
1870
+ this.ensureGeneralInstructions(generalDir, backendName, name);
1546
1871
  fleet.instances[name] = {
1547
1872
  ...DEFAULT_INSTANCE_CONFIG,
1548
1873
  working_directory: generalDir,
@@ -1561,6 +1886,13 @@ export class FleetManager {
1561
1886
  this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
1562
1887
  this.scheduler.init();
1563
1888
  this.logger.info("Scheduler initialized");
1889
+ // Tips share the daily-report clock but remain an independent internal
1890
+ // job: disabling daily_summary must not disable tips (and vice versa).
1891
+ // The callback checks defaults.tips at fire time, so /tips on|off is hot.
1892
+ this.dailyTipScheduler = new DailyTipScheduler(summaryConfig, costGuardConfig.timezone, () => this.sendTipToGeneral().catch(err => {
1893
+ this.logger.warn({ err }, "Failed to send daily tip");
1894
+ }));
1895
+ this.dailyTipScheduler.start();
1564
1896
  // Inject active decisions as env var for MCP instructions.
1565
1897
  // Snapshotted at startup — new decisions via post_decision are available
1566
1898
  // through list_decisions tool but not auto-injected until restart.
@@ -1580,7 +1912,7 @@ export class FleetManager {
1580
1912
  const allEntries = Object.entries(fleet.instances);
1581
1913
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
1582
1914
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
1583
- const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
1915
+ const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
1584
1916
  if (generals.length > 0) {
1585
1917
  for (const [name, cfg] of generals) {
1586
1918
  try {
@@ -1619,7 +1951,19 @@ export class FleetManager {
1619
1951
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1620
1952
  }
1621
1953
  })();
1622
- progressStart = adapterStartup.then(() => startupProgress.start(this.restartProgressTarget()));
1954
+ progressStart = adapterStartup.then(() => {
1955
+ if (pendingUpdateProgress) {
1956
+ const saved = pendingUpdateProgress.progress.target;
1957
+ const adapter = this.adapters.get(saved.adapterId);
1958
+ const target = adapter ? {
1959
+ adapter,
1960
+ chatId: saved.chatId,
1961
+ threadId: saved.threadId,
1962
+ } : null;
1963
+ return startupProgress.resume(target, saved.messageId);
1964
+ }
1965
+ return startupProgress.start(this.restartProgressTarget());
1966
+ });
1623
1967
  }
1624
1968
  // The systemd watchdog answers exactly one question: is this process still
1625
1969
  // turning its event loop? Pinging from a timer proves that, and after the
@@ -1731,6 +2075,12 @@ export class FleetManager {
1731
2075
  version: agendVersion,
1732
2076
  pausedNames,
1733
2077
  failedNames,
2078
+ tipText: pendingUpdateProgress && this.tipsEnabled()
2079
+ ? (() => {
2080
+ const tip = this.pickAvailableTip();
2081
+ return tip ? this.formatTip(tip) : undefined;
2082
+ })()
2083
+ : undefined,
1734
2084
  });
1735
2085
  if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
1736
2086
  let text;
@@ -1892,14 +2242,14 @@ export class FleetManager {
1892
2242
  notifyAdapterFailure(adapterId, error) {
1893
2243
  const generalId = this.findGeneralInstance();
1894
2244
  if (generalId) {
1895
- this.notifyInstanceTopic(generalId, `⚠️ Adapter "${adapterId}" failed to start: ${error}\nRetrying in background. Other adapters unaffected.`);
2245
+ this.notifyInstanceTopic(generalId, t("adapter.start_failed", adapterId, error));
1896
2246
  }
1897
2247
  }
1898
2248
  /** Notify admin that a retried adapter reconnected. */
1899
2249
  notifyAdapterRecovery(adapterId, attempts) {
1900
2250
  const generalId = this.findGeneralInstance();
1901
2251
  if (generalId) {
1902
- this.notifyInstanceTopic(generalId, `✅ Adapter "${adapterId}" reconnected (after ${attempts} ${attempts === 1 ? "retry" : "retries"}).`);
2252
+ this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
1903
2253
  }
1904
2254
  }
1905
2255
  /** Get adapter states for /status visibility. */
@@ -1997,7 +2347,15 @@ export class FleetManager {
1997
2347
  await this.handleInboundReaction(r);
1998
2348
  }, this.logger, "adapter.reaction"));
1999
2349
  this.adapter.on("callback_query", safeHandler(async (data) => {
2000
- if (await this.handleInteractivePromptAssist(data, adapterId))
2350
+ if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
2351
+ return;
2352
+ if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
2353
+ return;
2354
+ if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2355
+ return;
2356
+ if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
2357
+ return;
2358
+ if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
2001
2359
  return;
2002
2360
  if (await this.handleClassicBackendSelection(data))
2003
2361
  return;
@@ -2005,25 +2363,8 @@ export class FleetManager {
2005
2363
  return;
2006
2364
  if (await this.handleEffortSelection(data))
2007
2365
  return;
2008
- if (data.callbackData.startsWith("hang:")) {
2009
- const parts = data.callbackData.split(":");
2010
- const action = parts[1];
2011
- const instanceName = parts[2];
2012
- if (action === "restart") {
2013
- await this.stopInstance(instanceName);
2014
- const config = this.fleetConfig?.instances[instanceName];
2015
- if (config) {
2016
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
2017
- await this.startInstance(instanceName, config, topicMode);
2018
- // startInstance already calls connectIpcToInstance
2019
- }
2020
- this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
2021
- }
2022
- else {
2023
- this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
2024
- }
2366
+ if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
2025
2367
  return;
2026
- }
2027
2368
  if (data.callbackData.startsWith("cancel:")) {
2028
2369
  this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
2029
2370
  return;
@@ -2102,6 +2443,43 @@ export class FleetManager {
2102
2443
  const result = await this.topicCommands.sendCompact(name);
2103
2444
  await data.respond(result);
2104
2445
  }
2446
+ else if (data.command === "steer") {
2447
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2448
+ if (!name) {
2449
+ await data.respond(t("classic.no_agent"));
2450
+ return;
2451
+ }
2452
+ const steerText = String(data.options?.message ?? "").trim();
2453
+ if (!steerText) {
2454
+ await data.respond(t("steer.usage"));
2455
+ return;
2456
+ }
2457
+ // chat_id/message_id stay empty: a slash interaction has no channel
2458
+ // message to react to, and an empty chat_id keeps updateLastChat from
2459
+ // rerouting the instance's replies to the slash context.
2460
+ const result = this.topicCommands.sendSteer(name, steerText, {
2461
+ chatId: "", messageId: "", username: data.username ?? "user",
2462
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2463
+ });
2464
+ await data.respond(result);
2465
+ }
2466
+ else if (data.command === "btw") {
2467
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2468
+ if (!name) {
2469
+ await data.respond(t("classic.no_agent"));
2470
+ return;
2471
+ }
2472
+ const btwText = String(data.options?.message ?? "").trim();
2473
+ if (!btwText) {
2474
+ await data.respond(t("btw.usage"));
2475
+ return;
2476
+ }
2477
+ const result = this.topicCommands.sendBtw(name, btwText, {
2478
+ chatId: "", messageId: "", username: data.username ?? "user",
2479
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2480
+ });
2481
+ await data.respond(result);
2482
+ }
2105
2483
  else if (data.command === "clear") {
2106
2484
  await this.handleClearSlash(data, adapterId);
2107
2485
  }
@@ -2157,17 +2535,7 @@ export class FleetManager {
2157
2535
  : t("collab.off.classic"));
2158
2536
  }
2159
2537
  else if (data.command === "update") {
2160
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
2161
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
2162
- await data.respond(t("not_authorized"));
2163
- return;
2164
- }
2165
- await data.respond(t("update.running"));
2166
- const { spawn } = await import("node:child_process");
2167
- const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
2168
- const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
2169
- const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
2170
- child.unref();
2538
+ await this.handleUpdateSlash(data, adapterId);
2171
2539
  }
2172
2540
  else if (data.command === "doctor") {
2173
2541
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
@@ -2185,12 +2553,15 @@ export class FleetManager {
2185
2553
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2186
2554
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2187
2555
  // slash_command is Discord-only; editReply renders Markdown natively.
2188
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2556
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2189
2557
  }
2190
2558
  catch (err) {
2191
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2559
+ await data.respond(t("usage.failed", err.message));
2192
2560
  }
2193
2561
  }
2562
+ else if (data.command === "tips") {
2563
+ await this.handleTipsSlash(data, adapterId);
2564
+ }
2194
2565
  else if (data.command === "status") {
2195
2566
  // Admin-gated (like the topic path): the merged table shows every
2196
2567
  // instance's cost and IPC health.
@@ -2236,6 +2607,43 @@ export class FleetManager {
2236
2607
  const result = await this.topicCommands.sendCompact(name);
2237
2608
  await data.respond(result);
2238
2609
  }
2610
+ else if (data.command === "steer") {
2611
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2612
+ if (!name) {
2613
+ await data.respond(t("classic.no_agent"));
2614
+ return;
2615
+ }
2616
+ const steerText = String(data.options?.message ?? "").trim();
2617
+ if (!steerText) {
2618
+ await data.respond(t("steer.usage"));
2619
+ return;
2620
+ }
2621
+ // chat_id/message_id stay empty: a slash interaction has no channel
2622
+ // message to react to, and an empty chat_id keeps updateLastChat from
2623
+ // rerouting the instance's replies to the slash context.
2624
+ const result = this.topicCommands.sendSteer(name, steerText, {
2625
+ chatId: "", messageId: "", username: data.username ?? "user",
2626
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2627
+ });
2628
+ await data.respond(result);
2629
+ }
2630
+ else if (data.command === "btw") {
2631
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2632
+ if (!name) {
2633
+ await data.respond(t("classic.no_agent"));
2634
+ return;
2635
+ }
2636
+ const btwText = String(data.options?.message ?? "").trim();
2637
+ if (!btwText) {
2638
+ await data.respond(t("btw.usage"));
2639
+ return;
2640
+ }
2641
+ const result = this.topicCommands.sendBtw(name, btwText, {
2642
+ chatId: "", messageId: "", username: data.username ?? "user",
2643
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2644
+ });
2645
+ await data.respond(result);
2646
+ }
2239
2647
  }, this.logger, "adapter.slash_command"));
2240
2648
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2241
2649
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2312,7 +2720,15 @@ export class FleetManager {
2312
2720
  await this.handleInboundReaction(r);
2313
2721
  }, this.logger, `adapter[${adapterId}].reaction`));
2314
2722
  adapter.on("callback_query", safeHandler(async (data) => {
2315
- if (await this.handleInteractivePromptAssist(data, adapterId))
2723
+ if (await this.handleTipDismiss(data, adapterId, adapter))
2724
+ return;
2725
+ if (await this.handleTipUnlock(data, adapterId, adapter))
2726
+ return;
2727
+ if (await this.handleClearConfirmation(data, adapterId, adapter))
2728
+ return;
2729
+ if (await this.handleExitRestartPrompt(data, adapterId, adapter))
2730
+ return;
2731
+ if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
2316
2732
  return;
2317
2733
  if (await this.handleClassicBackendSelection(data))
2318
2734
  return;
@@ -2320,24 +2736,8 @@ export class FleetManager {
2320
2736
  return;
2321
2737
  if (await this.handleEffortSelection(data))
2322
2738
  return;
2323
- if (data.callbackData.startsWith("hang:")) {
2324
- const parts = data.callbackData.split(":");
2325
- const action = parts[1];
2326
- const instanceName = parts[2];
2327
- if (action === "restart") {
2328
- await this.stopInstance(instanceName);
2329
- const config = this.fleetConfig?.instances[instanceName];
2330
- if (config) {
2331
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
2332
- await this.startInstance(instanceName, config, topicMode);
2333
- }
2334
- adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
2335
- }
2336
- else {
2337
- adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
2338
- }
2739
+ if (await this.handleHangPrompt(data, adapterId, adapter))
2339
2740
  return;
2340
- }
2341
2741
  if (data.callbackData.startsWith("cancel:")) {
2342
2742
  this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
2343
2743
  return;
@@ -2461,17 +2861,7 @@ export class FleetManager {
2461
2861
  : t("collab.off.classic"));
2462
2862
  }
2463
2863
  else if (data.command === "update") {
2464
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
2465
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
2466
- await data.respond(t("not_authorized"));
2467
- return;
2468
- }
2469
- await data.respond(t("update.running"));
2470
- const { spawn } = await import("node:child_process");
2471
- const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
2472
- const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
2473
- const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
2474
- child.unref();
2864
+ await this.handleUpdateSlash(data, adapterId);
2475
2865
  }
2476
2866
  else if (data.command === "doctor") {
2477
2867
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
@@ -2489,12 +2879,15 @@ export class FleetManager {
2489
2879
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2490
2880
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2491
2881
  // slash_command is Discord-only; editReply renders Markdown natively.
2492
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2882
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2493
2883
  }
2494
2884
  catch (err) {
2495
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2885
+ await data.respond(t("usage.failed", err.message));
2496
2886
  }
2497
2887
  }
2888
+ else if (data.command === "tips") {
2889
+ await this.handleTipsSlash(data, adapterId);
2890
+ }
2498
2891
  else if (data.command === "status") {
2499
2892
  // Admin-gated (like the topic path): the merged table shows every
2500
2893
  // instance's cost and IPC health.
@@ -2540,6 +2933,43 @@ export class FleetManager {
2540
2933
  const result = await this.topicCommands.sendCompact(name);
2541
2934
  await data.respond(result);
2542
2935
  }
2936
+ else if (data.command === "steer") {
2937
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2938
+ if (!name) {
2939
+ await data.respond(t("classic.no_agent"));
2940
+ return;
2941
+ }
2942
+ const steerText = String(data.options?.message ?? "").trim();
2943
+ if (!steerText) {
2944
+ await data.respond(t("steer.usage"));
2945
+ return;
2946
+ }
2947
+ // chat_id/message_id stay empty: a slash interaction has no channel
2948
+ // message to react to, and an empty chat_id keeps updateLastChat from
2949
+ // rerouting the instance's replies to the slash context.
2950
+ const result = this.topicCommands.sendSteer(name, steerText, {
2951
+ chatId: "", messageId: "", username: data.username ?? "user",
2952
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2953
+ });
2954
+ await data.respond(result);
2955
+ }
2956
+ else if (data.command === "btw") {
2957
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2958
+ if (!name) {
2959
+ await data.respond(t("classic.no_agent"));
2960
+ return;
2961
+ }
2962
+ const btwText = String(data.options?.message ?? "").trim();
2963
+ if (!btwText) {
2964
+ await data.respond(t("btw.usage"));
2965
+ return;
2966
+ }
2967
+ const result = this.topicCommands.sendBtw(name, btwText, {
2968
+ chatId: "", messageId: "", username: data.username ?? "user",
2969
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2970
+ });
2971
+ await data.respond(result);
2972
+ }
2543
2973
  }, this.logger, `adapter[${adapterId}].slash_command`));
2544
2974
  await adapter.start();
2545
2975
  if (channelConfig.group_id) {
@@ -2657,6 +3087,9 @@ export class FleetManager {
2657
3087
  else if (msg.type === "instance_activity") {
2658
3088
  this.cacheInstanceActivity(name, msg.activity);
2659
3089
  }
3090
+ else if (msg.type === "instance_progress") {
3091
+ this.cacheInstanceProgress(name, msg.progress || null);
3092
+ }
2660
3093
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
2661
3094
  this.cacheInstanceExecutionState(name, msg);
2662
3095
  if (msg.type === "instance_state_response") {
@@ -2845,6 +3278,10 @@ export class FleetManager {
2845
3278
  this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
2846
3279
  return;
2847
3280
  }
3281
+ if (IGNORED_REACTION_EMOJIS.has(r.emoji)) {
3282
+ this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring non-contextual emoji reaction");
3283
+ return;
3284
+ }
2848
3285
  this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
2849
3286
  if (r.action === "add") {
2850
3287
  this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
@@ -3100,6 +3537,42 @@ export class FleetManager {
3100
3537
  await msgAdapter?.sendText(chatId, result);
3101
3538
  return;
3102
3539
  }
3540
+ // /steer — interject into the running turn. Not admin-gated: anyone who
3541
+ // can talk to this agent can send it a message; steer only changes when
3542
+ // it lands, and it keeps the full [user:] formatting (unlike /raw).
3543
+ if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
3544
+ const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3545
+ if (!steerName) {
3546
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3547
+ return;
3548
+ }
3549
+ const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
3550
+ if (!steerContent) {
3551
+ await msgAdapter?.sendText(chatId, t("steer.usage"));
3552
+ return;
3553
+ }
3554
+ const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
3555
+ await msgAdapter?.sendText(chatId, result);
3556
+ return;
3557
+ }
3558
+ // /btw — Claude Code's native side-thread command. Like /steer it is
3559
+ // not admin-gated, but it remains a distinct raw CLI command so Claude
3560
+ // does not fold the question into the active task.
3561
+ if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
3562
+ const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3563
+ if (!btwName) {
3564
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3565
+ return;
3566
+ }
3567
+ const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
3568
+ if (!btwContent) {
3569
+ await msgAdapter?.sendText(chatId, t("btw.usage"));
3570
+ return;
3571
+ }
3572
+ const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
3573
+ await msgAdapter?.sendText(chatId, result);
3574
+ return;
3575
+ }
3103
3576
  // Handle /clear command (admin only) — unlike /compact this starts a
3104
3577
  // fresh conversation and intentionally discards the current history.
3105
3578
  if (text === "/clear" || text.startsWith("/clear@")) {
@@ -3112,7 +3585,11 @@ export class FleetManager {
3112
3585
  await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3113
3586
  return;
3114
3587
  }
3115
- await msgAdapter?.sendText(chatId, await this.topicCommands.sendClear(clearName));
3588
+ if (!msgAdapter)
3589
+ return;
3590
+ const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
3591
+ if (fallback)
3592
+ await msgAdapter.sendText(chatId, fallback);
3116
3593
  return;
3117
3594
  }
3118
3595
  // Handle /cancel command
@@ -3123,7 +3600,7 @@ export class FleetManager {
3123
3600
  return;
3124
3601
  }
3125
3602
  const ok = this.cancelInstance(cancelName);
3126
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
3603
+ await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
3127
3604
  return;
3128
3605
  }
3129
3606
  // Handle /ctx command
@@ -3160,7 +3637,7 @@ export class FleetManager {
3160
3637
  const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
3161
3638
  const cmd = saveCommandForBackend(backend, filename);
3162
3639
  if (!cmd) {
3163
- await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
3640
+ await msgAdapter?.sendText(chatId, t("save.unsupported"));
3164
3641
  return;
3165
3642
  }
3166
3643
  this.pasteRawToClassicInstance(saveName, cmd);
@@ -3378,10 +3855,10 @@ export class FleetManager {
3378
3855
  return;
3379
3856
  let warning = "";
3380
3857
  if (rl.five_hour_pct >= 95) {
3381
- warning = `⚠️ ${instanceName} at ${Math.round(rl.five_hour_pct)}% of 5h rate limit. Responses may be slower.`;
3858
+ warning = t("rate_limit.five_hour", instanceName, Math.round(rl.five_hour_pct));
3382
3859
  }
3383
3860
  else if (rl.seven_day_pct >= 95) {
3384
- warning = `⚠️ ${instanceName} at ${Math.round(rl.seven_day_pct)}% weekly usage. Responses may be slower or fail.`;
3861
+ warning = t("rate_limit.weekly", instanceName, Math.round(rl.seven_day_pct));
3385
3862
  }
3386
3863
  if (!warning)
3387
3864
  return;
@@ -3505,7 +3982,7 @@ export class FleetManager {
3505
3982
  this.clearCancelButton(instanceName);
3506
3983
  }
3507
3984
  else {
3508
- void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
3985
+ void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
3509
3986
  }
3510
3987
  this.reactDone(instanceName);
3511
3988
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
@@ -3542,7 +4019,11 @@ export class FleetManager {
3542
4019
  if (silent) {
3543
4020
  const ipc = this.instanceIpcClients.get(target);
3544
4021
  if (ipc) {
3545
- ipc.send({ type: "raw_paste", content: message });
4022
+ ipc.send({
4023
+ type: "raw_paste",
4024
+ content: message,
4025
+ delivery_epoch: this.getDeliveryEpoch(target),
4026
+ });
3546
4027
  this.scheduler.recordRun(id, "delivered");
3547
4028
  this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
3548
4029
  }
@@ -3557,10 +4038,19 @@ export class FleetManager {
3557
4038
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
3558
4039
  const deliver = async () => {
3559
4040
  try {
4041
+ // A schedule has no live inbound adapter context. Seed the daemon with
4042
+ // the target instance's configured world so replies use its persona
4043
+ // after a fresh start instead of falling back to channels[0].
4044
+ const adapterId = this.getInstanceAdapterId(target);
3560
4045
  await this.deliverToInstance(target, {
3561
4046
  type: "fleet_schedule_trigger",
3562
4047
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
3563
- meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
4048
+ meta: {
4049
+ chat_id: reply_chat_id,
4050
+ thread_id: reply_thread_id,
4051
+ user: "scheduler",
4052
+ ...(adapterId ? { adapter_id: adapterId } : {}),
4053
+ },
3564
4054
  }, { waitForIdle: true });
3565
4055
  // A scheduled trigger also puts the instance to work — show a cancel button.
3566
4056
  void this.sendCancelButton(target);
@@ -3708,7 +4198,29 @@ export class FleetManager {
3708
4198
  }
3709
4199
  /** Resolve display name for an instance, fallback to instance name. */
3710
4200
  resolveDisplayName(instanceName) {
3711
- return this.fleetConfig?.instances[instanceName]?.display_name ?? instanceName;
4201
+ return this.fleetConfig?.instances[instanceName]?.display_name
4202
+ ?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
4203
+ ?? instanceName;
4204
+ }
4205
+ /** Persist identity to the instance's actual config store. Classic instances
4206
+ * are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
4207
+ setInstanceDisplayName(instanceName, displayName) {
4208
+ const fleetInstance = this.fleetConfig?.instances[instanceName];
4209
+ if (fleetInstance) {
4210
+ fleetInstance.display_name = displayName;
4211
+ this.saveFleetConfig();
4212
+ return true;
4213
+ }
4214
+ return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
4215
+ }
4216
+ setInstanceDescription(instanceName, description) {
4217
+ const fleetInstance = this.fleetConfig?.instances[instanceName];
4218
+ if (fleetInstance) {
4219
+ fleetInstance.description = description;
4220
+ this.saveFleetConfig();
4221
+ return true;
4222
+ }
4223
+ return this.classicChannels?.setDescriptionByInstance(instanceName, description) ?? false;
3712
4224
  }
3713
4225
  handleSetDisplayName(instanceName, msg) {
3714
4226
  const fleetRequestId = msg.fleetRequestId;
@@ -3721,8 +4233,10 @@ export class FleetManager {
3721
4233
  ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: "Name must be 1-30 characters" });
3722
4234
  return;
3723
4235
  }
3724
- this.fleetConfig.instances[instanceName].display_name = displayName;
3725
- this.saveFleetConfig();
4236
+ if (!this.setInstanceDisplayName(instanceName, displayName)) {
4237
+ ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
4238
+ return;
4239
+ }
3726
4240
  this.logger.info({ instanceName, displayName }, "Display name set");
3727
4241
  ipc.send({ type: "fleet_display_name_response", fleetRequestId, result: { display_name: displayName } });
3728
4242
  }
@@ -3737,8 +4251,10 @@ export class FleetManager {
3737
4251
  ipc.send({ type: "fleet_description_response", fleetRequestId, error: "Description cannot be empty" });
3738
4252
  return;
3739
4253
  }
3740
- this.fleetConfig.instances[instanceName].description = description;
3741
- this.saveFleetConfig();
4254
+ if (!this.setInstanceDescription(instanceName, description)) {
4255
+ ipc.send({ type: "fleet_description_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
4256
+ return;
4257
+ }
3742
4258
  this.logger.info({ instanceName, description: description.slice(0, 80) }, "Description set");
3743
4259
  ipc.send({ type: "fleet_description_response", fleetRequestId, result: { description } });
3744
4260
  }
@@ -3867,8 +4383,8 @@ export class FleetManager {
3867
4383
  return { error: "Fleet config not available" };
3868
4384
  if (!name || name.length > 30)
3869
4385
  return { error: "Name must be 1-30 characters" };
3870
- this.fleetConfig.instances[instance].display_name = name;
3871
- this.saveFleetConfig();
4386
+ if (!this.setInstanceDisplayName(instance, name))
4387
+ return { error: `Instance '${instance}' not found` };
3872
4388
  return { display_name: name };
3873
4389
  }
3874
4390
  async handleSetDescriptionHttp(instance, description) {
@@ -3876,8 +4392,8 @@ export class FleetManager {
3876
4392
  return { error: "Fleet config not available" };
3877
4393
  if (!description)
3878
4394
  return { error: "Description cannot be empty" };
3879
- this.fleetConfig.instances[instance].description = description;
3880
- this.saveFleetConfig();
4395
+ if (!this.setInstanceDescription(instance, description))
4396
+ return { error: `Instance '${instance}' not found` };
3881
4397
  return { description };
3882
4398
  }
3883
4399
  summarizeToolCall(tool, args) {
@@ -4009,7 +4525,8 @@ export class FleetManager {
4009
4525
  }
4010
4526
  /**
4011
4527
  * Patch only values changed in the effective config into the original YAML
4012
- * document. Unknown keys, explicit overrides and comments remain untouched.
4528
+ * document. Unknown keys and comments remain untouched; redundant
4529
+ * non-identity instance leaves are canonicalized to inheritance afterward.
4013
4530
  */
4014
4531
  saveFleetConfig(explicitPatches = []) {
4015
4532
  if (!this.fleetConfig || !this.configPath)
@@ -4025,9 +4542,9 @@ export class FleetManager {
4025
4542
  }
4026
4543
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
4027
4544
  this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
4028
- // Settings edits are expressed against the raw config. Persist them even
4029
- // when the chosen override equals the inherited effective value, a case
4030
- // the before/after runtime diff cannot observe.
4545
+ // Settings edits are expressed against the raw config, so apply them before
4546
+ // canonicalization. A non-identity value equal to its inherited default is
4547
+ // intentionally stored as inheritance rather than an explicit duplicate.
4031
4548
  for (const patch of explicitPatches) {
4032
4549
  if (patch.remove) {
4033
4550
  // YAML's deleteIn throws when an inherited nested key has no raw parent
@@ -4042,7 +4559,24 @@ export class FleetManager {
4042
4559
  this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
4043
4560
  }
4044
4561
  }
4562
+ const rawAfterPatches = this.rawFleetDocument.toJS();
4563
+ const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
4564
+ for (const path of redundantPaths) {
4565
+ if (this.rawFleetDocument.hasIn(path))
4566
+ this.rawFleetDocument.deleteIn(path);
4567
+ // Avoid leaving empty operational maps such as `terminal: {}` while
4568
+ // preserving the instance mapping itself and all surrounding comments.
4569
+ for (let depth = path.length - 1; depth > 2; depth--) {
4570
+ const parentPath = path.slice(0, depth);
4571
+ const parent = this.rawFleetDocument.getIn(parentPath, true);
4572
+ if (!isMap(parent) || parent.items.length > 0)
4573
+ break;
4574
+ this.rawFleetDocument.deleteIn(parentPath);
4575
+ }
4576
+ }
4045
4577
  const output = String(this.rawFleetDocument);
4578
+ if (redundantPaths.length > 0)
4579
+ this.writeFleetConfigBackup(source);
4046
4580
  const tempPath = `${this.configPath}.tmp-${process.pid}`;
4047
4581
  writeFileSync(tempPath, output, "utf-8");
4048
4582
  if (existsSync(this.configPath))
@@ -4050,7 +4584,36 @@ export class FleetManager {
4050
4584
  renameSync(tempPath, this.configPath);
4051
4585
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
4052
4586
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
4053
- this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
4587
+ this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
4588
+ }
4589
+ /** One-time upgrade migration; invalid YAML is never rewritten. */
4590
+ slimFleetConfigAtStartup() {
4591
+ const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
4592
+ if (redundantPaths.length === 0)
4593
+ return;
4594
+ const validation = validateFleetConfig(this.rawFleetConfig);
4595
+ if (!validation.valid) {
4596
+ this.logger.warn({ errors: validation.errors, redundantDefaults: redundantPaths.length }, "Skipping fleet.yaml default slimming because the raw config is invalid");
4597
+ return;
4598
+ }
4599
+ try {
4600
+ this.saveFleetConfig();
4601
+ this.logger.info({ strippedDefaults: redundantPaths.length, backup: `${this.configPath}.bak` }, "Slimmed redundant instance defaults in fleet.yaml");
4602
+ }
4603
+ catch (err) {
4604
+ // A migration must not turn a previously bootable fleet into an outage.
4605
+ this.logger.warn({ err }, "Could not slim fleet.yaml; continuing with the original config");
4606
+ }
4607
+ }
4608
+ writeFleetConfigBackup(source) {
4609
+ if (!this.configPath)
4610
+ return;
4611
+ const backupPath = `${this.configPath}.bak`;
4612
+ const tempPath = `${backupPath}.tmp-${process.pid}`;
4613
+ writeFileSync(tempPath, source, "utf-8");
4614
+ if (existsSync(this.configPath))
4615
+ chmodSync(tempPath, statSync(this.configPath).mode);
4616
+ renameSync(tempPath, backupPath);
4054
4617
  }
4055
4618
  patchFleetDocument(document, path, before, after) {
4056
4619
  if (Object.is(before, after))
@@ -4102,7 +4665,12 @@ export class FleetManager {
4102
4665
  currentNode.value = after;
4103
4666
  }
4104
4667
  else {
4105
- document.setIn(path, after);
4668
+ // Use a YAML collection node for newly-added objects. Passing the raw
4669
+ // object creates a scalar wrapper: it serializes, but nested hasIn /
4670
+ // deleteIn cannot traverse it (notably when slimming create_instance).
4671
+ document.setIn(path, after !== null && typeof after === "object"
4672
+ ? document.createNode(after)
4673
+ : after);
4106
4674
  }
4107
4675
  }
4108
4676
  }
@@ -4193,7 +4761,7 @@ export class FleetManager {
4193
4761
  from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
4194
4762
  });
4195
4763
  this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
4196
- this.notifyInstanceTopic(name, `⚡ Rate limit ${fiveHourPct}% — next rotation will use ${fallbackModel} (was ${primaryModel})`);
4764
+ this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
4197
4765
  }
4198
4766
  else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
4199
4767
  // Recover: switch back to primary
@@ -4204,7 +4772,7 @@ export class FleetManager {
4204
4772
  restored: primaryModel, five_hour_pct: fiveHourPct,
4205
4773
  });
4206
4774
  this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
4207
- this.notifyInstanceTopic(name, `✅ Rate limit recovered (${fiveHourPct}%) — next rotation will use ${primaryModel}`);
4775
+ this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
4208
4776
  }
4209
4777
  }
4210
4778
  toggleFleetCollab(instanceName) {
@@ -4258,9 +4826,9 @@ export class FleetManager {
4258
4826
  if (output)
4259
4827
  return output;
4260
4828
  if (e.code === "ENOENT") {
4261
- return "Backend doctor unavailable — agend CLI not found in PATH";
4829
+ return t("doctor.cli_missing");
4262
4830
  }
4263
- return stripAnsi(e.message ?? "").trim() || "Doctor failed";
4831
+ return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
4264
4832
  }
4265
4833
  }
4266
4834
  /** Drop event/activity rows older than the retention window. Best-effort. */
@@ -4362,7 +4930,7 @@ export class FleetManager {
4362
4930
  this.fleetErrorNotices.delete(oldest);
4363
4931
  }
4364
4932
  const body = suppressed > 0
4365
- ? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
4933
+ ? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
4366
4934
  : text;
4367
4935
  // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
4368
4936
  // and a fleet-level fault is exactly when the General may be down. The topic
@@ -4412,81 +4980,122 @@ export class FleetManager {
4412
4980
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
4413
4981
  }
4414
4982
  }
4415
- interactivePromptLabel(kind) {
4416
- const key = `interactive.kind.${kind}`;
4417
- const translated = t(key);
4418
- return translated === key ? kind.replaceAll("_", " ") : translated;
4419
- }
4983
+ // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
4984
+ //
4985
+ // One shared lifecycle for every "notification with decision buttons":
4986
+ // post with a 128-bit nonce, arm a bounded expiry, bind the click to the
4987
+ // exact adapter+chat+thread+message that created it, require fleet admin for
4988
+ // actions that mutate runtime state (Tips are the harmless exception),
4989
+ // consume exactly once. The features differ only in what they post
4990
+ // and what a consumed click does.
4420
4991
  /**
4421
- * Surface a stable terminal prompt in both places that matter:
4422
- * - the blocked instance gets a plain pointer to General;
4423
- * - General gets one-shot Confirm/Cancel controls on its own bound adapter.
4424
- *
4425
- * Only the prompt category crosses channels. The captured terminal line can
4426
- * contain usernames or secret-adjacent text and stays in daemon.log.
4992
+ * Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
4993
+ * The entry is registered before the send and rolled back if the send
4994
+ * fails, so a nonce in the map always refers to a message that exists (or
4995
+ * is about to). Returns the nonce, or null when the alert could not be sent.
4427
4996
  */
4428
- async notifyInteractivePrompt(instanceName, kind) {
4429
- this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
4430
- // Multi-world fleets can have one General per bot/platform. Route the assist
4431
- // request to the General bound to the same adapter as the blocked instance.
4432
- const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
4433
- if (!generalName) {
4434
- this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
4435
- return;
4436
- }
4437
- const adapterId = this.getInstanceAdapterId(generalName);
4438
- const adapter = this.getAdapterForInstance(generalName);
4439
- const chatId = this.getGroupIdForInstance(generalName);
4440
- const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
4441
- const threadId = topicId != null ? String(topicId) : undefined;
4442
- if (!adapter || !adapterId || !chatId) {
4443
- this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
4444
- return;
4445
- }
4446
- const nonce = randomBytes(8).toString("hex");
4447
- const label = this.interactivePromptLabel(kind);
4997
+ async postNonceButtonPrompt(opts) {
4998
+ // 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
4999
+ // callback_data cap still holds: longest id is "interactive-assist:" (19)
5000
+ // + 32 hex + ":confirm" (8) = 59 bytes.
5001
+ const nonce = randomBytes(16).toString("hex");
4448
5002
  const entry = {
4449
- instanceName,
4450
- generalName,
4451
- kind,
4452
- adapterId,
4453
- adapter,
4454
- chatId,
4455
- threadId,
5003
+ prefix: opts.prefix,
5004
+ instanceName: opts.instanceName,
5005
+ adapterId: opts.adapterId,
5006
+ adapter: opts.adapter,
5007
+ chatId: opts.chatId,
5008
+ threadId: opts.threadId,
5009
+ expiredText: opts.expiredText,
5010
+ ...opts.extra,
4456
5011
  };
4457
5012
  entry.timer = setTimeout(() => {
4458
- const pending = this.pendingInteractivePromptAssists.get(nonce);
5013
+ const pending = this.pendingNonceButtons.get(nonce);
4459
5014
  if (pending !== entry)
4460
5015
  return;
4461
- this.pendingInteractivePromptAssists.delete(nonce);
5016
+ this.pendingNonceButtons.delete(nonce);
4462
5017
  if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
4463
- entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, t("interactive.expired", entry.instanceName), entry.threadId).catch(err => this.logger.debug({ err, instanceName }, "Failed to expire interactive prompt controls"));
5018
+ entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
4464
5019
  }
4465
- }, INTERACTIVE_ASSIST_TIMEOUT_MS);
5020
+ }, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
4466
5021
  entry.timer.unref?.();
4467
- this.pendingInteractivePromptAssists.set(nonce, entry);
5022
+ this.pendingNonceButtons.set(nonce, entry);
4468
5023
  try {
4469
- const sent = await adapter.notifyAlert(chatId, {
4470
- type: "interactive_prompt",
4471
- instanceName,
4472
- message: t("interactive.general_notice", instanceName, label),
4473
- choices: [
4474
- { id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:confirm`, label: t("interactive.confirm") },
4475
- { id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:cancel`, label: t("interactive.cancel") },
4476
- ],
4477
- }, threadId ? { threadId } : undefined);
5024
+ const sent = await opts.adapter.notifyAlert(opts.chatId, {
5025
+ type: opts.alertType,
5026
+ instanceName: opts.instanceName,
5027
+ message: opts.message,
5028
+ choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
5029
+ }, opts.threadId ? { threadId: opts.threadId } : undefined);
4478
5030
  entry.messageId = sent.messageId;
5031
+ return nonce;
4479
5032
  }
4480
5033
  catch (err) {
4481
- this.pendingInteractivePromptAssists.delete(nonce);
5034
+ this.pendingNonceButtons.delete(nonce);
4482
5035
  if (entry.timer)
4483
5036
  clearTimeout(entry.timer);
4484
- this.logger.warn({ err, instanceName, generalName }, "Failed to send interactive prompt assistance controls");
5037
+ this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
5038
+ return null;
5039
+ }
5040
+ }
5041
+ /**
5042
+ * Claim a nonce-armed callback exactly once.
5043
+ *
5044
+ * Returns:
5045
+ * - null — the callback is not for this prefix; try the next handler
5046
+ * - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
5047
+ * - the entry+action — the click is authorized and now claimed (removed
5048
+ * from the map before any await, so double clicks cannot act twice)
5049
+ *
5050
+ * A stale click (expired nonce, or a pre-upgrade button whose payload no
5051
+ * longer parses) collapses the clicked message so the dead button stops
5052
+ * inviting clicks — the same courtesy the cancel button extends.
5053
+ */
5054
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
5055
+ if (!data.callbackData.startsWith(prefix))
5056
+ return null;
5057
+ const match = data.callbackData.match(actionRe);
5058
+ let pending = match ? this.pendingNonceButtons.get(match[1]) : undefined;
5059
+ // The map is shared across prompt kinds. A nonce that resolves to an entry
5060
+ // of a DIFFERENT kind is not a usable capability for this handler — treat
5061
+ // it as stale rather than acting across kinds (fail closed).
5062
+ if (pending && pending.prefix !== prefix)
5063
+ pending = undefined;
5064
+ if (!match || !pending) {
5065
+ const adapter = receivingAdapter ?? this.adapter;
5066
+ adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
5067
+ return "consumed";
5068
+ }
5069
+ // Bind the capability to the exact message/world that created it. Telegram
5070
+ // keyboards are visible to everyone, so mutating actions require fleet admin;
5071
+ // a Tip acknowledgement only records that the shared content was read.
5072
+ const isAuthorized = data.userId
5073
+ ? pending.allowAnyUser
5074
+ ? true
5075
+ : pending.authChannelId
5076
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
5077
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
5078
+ : false;
5079
+ if (pending.adapterId !== callbackAdapterId
5080
+ || data.chatId !== pending.chatId
5081
+ || (pending.threadId != null && data.threadId !== pending.threadId)
5082
+ || (pending.messageId != null && data.messageId !== pending.messageId)
5083
+ || !isAuthorized) {
5084
+ // Deliberately does NOT consume the nonce: the real admin can still click.
5085
+ this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
5086
+ return "consumed";
4485
5087
  }
5088
+ // Claim before any await: double clicks and duplicate callback delivery can
5089
+ // never act twice.
5090
+ this.pendingNonceButtons.delete(match[1]);
5091
+ if (pending.timer)
5092
+ clearTimeout(pending.timer);
5093
+ return { entry: pending, action: match[2] };
4486
5094
  }
4487
- async retireInteractivePromptButtons(pending, messageId, text) {
5095
+ /** Collapse a consumed prompt's buttons into a final status line. */
5096
+ async retireNonceButtons(pending, messageId, text) {
4488
5097
  if (!pending.adapter.editMessageRemoveButtons) {
4489
- this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove interactive prompt controls");
5098
+ this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
4490
5099
  return;
4491
5100
  }
4492
5101
  try {
@@ -4494,58 +5103,395 @@ export class FleetManager {
4494
5103
  }
4495
5104
  catch (err) {
4496
5105
  // The action was already atomically consumed. An edit failure must not
4497
- // prevent Confirm from reaching General or make Cancel actionable again.
4498
- this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire interactive prompt controls");
5106
+ // undo that or make the button actionable again.
5107
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
4499
5108
  }
4500
5109
  }
4501
- /** Consume a General assist button exactly once. */
4502
- async handleInteractivePromptAssist(data, callbackAdapterId) {
4503
- if (!data.callbackData.startsWith(INTERACTIVE_ASSIST_CALLBACK_PREFIX))
5110
+ tipsEnabled() {
5111
+ return this.fleetConfig?.defaults.tips !== false;
5112
+ }
5113
+ readTipState() {
5114
+ if (!this.scheduler)
5115
+ return null;
5116
+ try {
5117
+ return {
5118
+ dismissed: this.scheduler.db.listDismissedTipIds(),
5119
+ advancedUnlocked: this.scheduler.db.isAdvancedTipsUnlocked(),
5120
+ };
5121
+ }
5122
+ catch (err) {
5123
+ // Tips are additive. A damaged/locked scheduler DB must never block fleet
5124
+ // startup, update completion, or a General command.
5125
+ this.logger.warn({ err }, "Failed to read tip state");
5126
+ return null;
5127
+ }
5128
+ }
5129
+ pickAvailableTip() {
5130
+ const state = this.readTipState();
5131
+ return state ? selectTip(state.dismissed, Math.random, state.advancedUnlocked) : null;
5132
+ }
5133
+ tipText(tip) {
5134
+ return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
5135
+ }
5136
+ formatTip(tip) {
5137
+ return `💡 ${t("tips.label")}: ${this.tipText(tip)}`;
5138
+ }
5139
+ /** Persistently enable advanced tips through the admin-only slash command. */
5140
+ unlockAdvancedTips(userId) {
5141
+ if (!this.scheduler)
4504
5142
  return false;
4505
- const match = data.callbackData.match(/^interactive-assist:([0-9a-f]+):(confirm|cancel)$/);
4506
- if (!match)
5143
+ try {
5144
+ this.scheduler.db.unlockAdvancedTips(userId);
5145
+ this.eventLog?.insert(this.findGeneralInstance() ?? "general", "tips_advanced_unlocked", {
5146
+ userId,
5147
+ source: "command",
5148
+ });
4507
5149
  return true;
4508
- const pending = this.pendingInteractivePromptAssists.get(match[1]);
4509
- if (!pending)
5150
+ }
5151
+ catch (err) {
5152
+ this.logger.warn({ err, userId }, "Failed to unlock advanced tips from command");
5153
+ return false;
5154
+ }
5155
+ }
5156
+ /** Post one fresh nonce-armed tip in a known General channel. */
5157
+ async promptTip(generalName, adapter, chatId, threadId) {
5158
+ const state = this.readTipState();
5159
+ if (!state)
5160
+ return "unavailable";
5161
+ if (!state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5162
+ return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
5163
+ ? "posted"
5164
+ : "unavailable";
5165
+ }
5166
+ const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked);
5167
+ if (!tip)
5168
+ return this.scheduler ? "empty" : "unavailable";
5169
+ const nonce = await this.postNonceButtonPrompt({
5170
+ prefix: TIP_DISMISS_CALLBACK_PREFIX,
5171
+ alertType: "tip",
5172
+ instanceName: generalName,
5173
+ adapter,
5174
+ adapterId: adapter.id,
5175
+ chatId,
5176
+ threadId,
5177
+ message: this.formatTip(tip),
5178
+ choices: [{ action: "dismiss", label: t("tips.dismiss") }],
5179
+ // Expiry removes only the stale button; the useful tip remains readable.
5180
+ expiredText: this.formatTip(tip),
5181
+ extra: { allowAnyUser: true, tipId: tip.id },
5182
+ timeoutMs: TIP_BUTTON_TIMEOUT_MS,
5183
+ });
5184
+ return nonce ? "posted" : "unavailable";
5185
+ }
5186
+ async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
5187
+ const nonce = await this.postNonceButtonPrompt({
5188
+ prefix: TIP_UNLOCK_CALLBACK_PREFIX,
5189
+ alertType: "tip",
5190
+ instanceName: generalName,
5191
+ adapter,
5192
+ adapterId: adapter.id,
5193
+ chatId,
5194
+ threadId,
5195
+ message: t("tips.advanced.unlock_prompt"),
5196
+ choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
5197
+ expiredText: t("tips.advanced.expired"),
5198
+ extra: { allowAnyUser: true },
5199
+ timeoutMs: TIP_BUTTON_TIMEOUT_MS,
5200
+ });
5201
+ return nonce !== null;
5202
+ }
5203
+ async sendTipToGeneral() {
5204
+ if (!this.tipsEnabled())
5205
+ return;
5206
+ const generalName = this.findGeneralInstance();
5207
+ if (!generalName)
5208
+ return;
5209
+ const adapter = this.getAdapterForInstance(generalName);
5210
+ const chatId = this.getGroupIdForInstance(generalName);
5211
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5212
+ if (!adapter || !chatId)
5213
+ return;
5214
+ const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
5215
+ if (result === "unavailable") {
5216
+ this.logger.warn({ generalName }, "Daily tip could not be posted");
5217
+ }
5218
+ }
5219
+ async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
5220
+ const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss)$/, data, callbackAdapterId, receivingAdapter);
5221
+ if (claimed === null)
5222
+ return false;
5223
+ if (claimed === "consumed")
4510
5224
  return true;
4511
- // Bind the capability to the exact message/world that created it. Telegram
4512
- // keyboards are visible to everyone, so the click also requires fleet admin.
4513
- if (pending.adapterId !== callbackAdapterId
4514
- || data.chatId !== pending.chatId
4515
- || (pending.threadId != null && data.threadId !== pending.threadId)
4516
- || (pending.messageId != null && data.messageId !== pending.messageId)
4517
- || !data.userId
4518
- || !this.isFleetAdmin(data.userId, callbackAdapterId)) {
4519
- this.logger.warn({ instanceName: pending.instanceName, userId: data.userId }, "Rejected unauthorized or mismatched interactive prompt callback");
5225
+ const { entry: pending } = claimed;
5226
+ if (!pending.tipId || !data.userId || !this.scheduler) {
5227
+ this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
5228
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
4520
5229
  return true;
4521
5230
  }
4522
- // Claim before any await: double clicks and duplicate callback delivery can
4523
- // never inject two messages into General.
4524
- this.pendingInteractivePromptAssists.delete(match[1]);
4525
- if (pending.timer)
4526
- clearTimeout(pending.timer);
4527
- const action = match[2];
5231
+ try {
5232
+ this.scheduler.db.dismissTip(data.userId, pending.tipId);
5233
+ }
5234
+ catch (err) {
5235
+ this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip dismissal");
5236
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5237
+ return true;
5238
+ }
5239
+ this.eventLog?.insert(pending.instanceName, "tip_dismissed", {
5240
+ tipId: pending.tipId,
5241
+ userId: data.userId,
5242
+ });
5243
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId,
5244
+ // Dismissal changes future selection only. Keep the useful tip text in
5245
+ // chat history and remove just the now-consumed button.
5246
+ pending.expiredText);
5247
+ const state = this.readTipState();
5248
+ if (state && !state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5249
+ await this.promptAdvancedTipUnlock(pending.instanceName, pending.adapter, pending.chatId, pending.threadId);
5250
+ }
5251
+ return true;
5252
+ }
5253
+ async handleTipUnlock(data, callbackAdapterId, receivingAdapter) {
5254
+ const claimed = this.consumeNonceCallback(TIP_UNLOCK_CALLBACK_PREFIX, /^tip-unlock:([0-9a-f]+):(unlock)$/, data, callbackAdapterId, receivingAdapter);
5255
+ if (claimed === null)
5256
+ return false;
5257
+ if (claimed === "consumed")
5258
+ return true;
5259
+ const { entry: pending } = claimed;
5260
+ if (!data.userId || !this.scheduler) {
5261
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5262
+ return true;
5263
+ }
5264
+ try {
5265
+ this.scheduler.db.unlockAdvancedTips(data.userId);
5266
+ }
5267
+ catch (err) {
5268
+ this.logger.warn({ err, userId: data.userId }, "Failed to unlock advanced tips");
5269
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5270
+ return true;
5271
+ }
5272
+ this.eventLog?.insert(pending.instanceName, "tips_advanced_unlocked", { userId: data.userId });
5273
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.advanced.unlocked"));
5274
+ return true;
5275
+ }
5276
+ /** Post the destructive `/clear` confirmation in the invoking channel. */
5277
+ async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
5278
+ if (!this.topicCommands.supportsClear(instanceName))
5279
+ return t("clear.unsupported");
5280
+ const adapterId = adapter.id;
5281
+ if (!adapterId)
5282
+ return t("clear.prompt_unavailable");
5283
+ const nonce = await this.postNonceButtonPrompt({
5284
+ prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
5285
+ alertType: "clear_confirm",
5286
+ instanceName,
5287
+ adapter,
5288
+ adapterId,
5289
+ chatId,
5290
+ threadId,
5291
+ message: t("clear.confirm_message", instanceName),
5292
+ choices: [
5293
+ { action: "confirm", label: t("clear.confirm") },
5294
+ { action: "cancel", label: t("clear.cancel") },
5295
+ ],
5296
+ expiredText: t("clear.expired", instanceName),
5297
+ extra: { authChannelId: channelId },
5298
+ timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
5299
+ });
5300
+ return nonce ? null : t("clear.prompt_unavailable");
5301
+ }
5302
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
5303
+ async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
5304
+ const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
5305
+ if (claimed === null)
5306
+ return false;
5307
+ if (claimed === "consumed")
5308
+ return true;
5309
+ const { entry: pending, action } = claimed;
5310
+ this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
5311
+ if (action === "cancel") {
5312
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
5313
+ return true;
5314
+ }
5315
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
5316
+ try {
5317
+ const result = await this.topicCommands.sendClear(pending.instanceName);
5318
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
5319
+ }
5320
+ catch (err) {
5321
+ const message = err instanceof Error ? err.message : String(err);
5322
+ this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
5323
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("clear.failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show clear command error"));
5324
+ }
5325
+ return true;
5326
+ }
5327
+ /**
5328
+ * Drop every pending prompt that refers to an instance being stopped or
5329
+ * restarted — a restart offer for an instance the operator just restarted
5330
+ * (or an assist for one they stopped) must not stay clickable for the rest
5331
+ * of its 15 minutes.
5332
+ */
5333
+ clearNoncePromptsForInstance(instanceName) {
5334
+ for (const [nonce, entry] of this.pendingNonceButtons) {
5335
+ if (entry.instanceName !== instanceName)
5336
+ continue;
5337
+ this.pendingNonceButtons.delete(nonce);
5338
+ if (entry.timer)
5339
+ clearTimeout(entry.timer);
5340
+ if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
5341
+ entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
5342
+ .catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
5343
+ }
5344
+ }
5345
+ }
5346
+ /** A clean exit is intentional from the CLI's perspective, but often not from
5347
+ * the operator's. Keep the instance notice passive and put the action in the
5348
+ * same-world General topic where an administrator can make the choice. */
5349
+ async notifyNormalExit(instanceName) {
5350
+ this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
5351
+ const worldId = this.getInstanceAdapterId(instanceName);
5352
+ const generalName = this.findGeneralInstance(worldId);
5353
+ if (!generalName) {
5354
+ this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
5355
+ return;
5356
+ }
5357
+ const adapterId = this.getInstanceAdapterId(generalName);
5358
+ const adapter = this.getAdapterForInstance(generalName);
5359
+ const chatId = this.getGroupIdForInstance(generalName);
5360
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5361
+ const threadId = topicId != null ? String(topicId) : undefined;
5362
+ if (!adapter || !adapterId || !chatId) {
5363
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
5364
+ return;
5365
+ }
5366
+ await this.postNonceButtonPrompt({
5367
+ prefix: EXIT_RESTART_CALLBACK_PREFIX,
5368
+ alertType: "exit_restart",
5369
+ instanceName,
5370
+ adapter,
5371
+ adapterId,
5372
+ chatId,
5373
+ threadId,
5374
+ message: t("exit.general_notice", instanceName),
5375
+ choices: [
5376
+ { action: "restart", label: t("exit.restart") },
5377
+ { action: "ignore", label: t("exit.ignore") },
5378
+ ],
5379
+ expiredText: t("exit.expired", instanceName),
5380
+ });
5381
+ }
5382
+ /** Consume a clean-exit Restart/Ignore button exactly once. */
5383
+ async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
5384
+ const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
5385
+ if (claimed === null)
5386
+ return false;
5387
+ if (claimed === "consumed")
5388
+ return true;
5389
+ const { entry: pending, action } = claimed;
5390
+ this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
5391
+ if (action === "ignore") {
5392
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
5393
+ return true;
5394
+ }
5395
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
5396
+ try {
5397
+ await this.restartSingleInstance(pending.instanceName);
5398
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
5399
+ }
5400
+ catch (err) {
5401
+ const message = err instanceof Error ? err.message : String(err);
5402
+ this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
5403
+ 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"));
5404
+ }
5405
+ return true;
5406
+ }
5407
+ interactivePromptLabel(kind) {
5408
+ const key = `interactive.kind.${kind}`;
5409
+ const translated = t(key);
5410
+ return translated === key ? kind.replaceAll("_", " ") : translated;
5411
+ }
5412
+ /**
5413
+ * Surface a stable terminal prompt in both places that matter:
5414
+ * - the blocked instance gets a plain pointer to General;
5415
+ * - General gets one-shot Confirm/Cancel controls on its own bound adapter.
5416
+ *
5417
+ * Only the prompt category crosses channels. The captured terminal line can
5418
+ * contain usernames or secret-adjacent text and stays in daemon.log.
5419
+ */
5420
+ async notifyInteractivePrompt(instanceName, kind) {
5421
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
5422
+ // Multi-world fleets can have one General per bot/platform. Route the assist
5423
+ // request to the General bound to the same adapter as the blocked instance.
5424
+ const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
5425
+ if (!generalName) {
5426
+ this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
5427
+ return;
5428
+ }
5429
+ const adapterId = this.getInstanceAdapterId(generalName);
5430
+ const adapter = this.getAdapterForInstance(generalName);
5431
+ const chatId = this.getGroupIdForInstance(generalName);
5432
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5433
+ const threadId = topicId != null ? String(topicId) : undefined;
5434
+ if (!adapter || !adapterId || !chatId) {
5435
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
5436
+ return;
5437
+ }
5438
+ const label = this.interactivePromptLabel(kind);
5439
+ await this.postNonceButtonPrompt({
5440
+ prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
5441
+ alertType: "interactive_prompt",
5442
+ instanceName,
5443
+ adapter,
5444
+ adapterId,
5445
+ chatId,
5446
+ threadId,
5447
+ message: t("interactive.general_notice", instanceName, label),
5448
+ choices: [
5449
+ { action: "confirm", label: t("interactive.confirm") },
5450
+ { action: "cancel", label: t("interactive.cancel") },
5451
+ ],
5452
+ expiredText: t("interactive.expired", instanceName),
5453
+ extra: { generalName, promptKind: kind },
5454
+ });
5455
+ }
5456
+ /** Consume a General assist button exactly once. */
5457
+ async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
5458
+ const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
5459
+ if (claimed === null)
5460
+ return false;
5461
+ if (claimed === "consumed")
5462
+ return true;
5463
+ const { entry: pending, action } = claimed;
4528
5464
  this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
4529
5465
  action,
4530
5466
  userId: data.userId,
4531
5467
  generalName: pending.generalName,
4532
5468
  });
4533
5469
  if (action === "cancel") {
4534
- await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
5470
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
5471
+ return true;
5472
+ }
5473
+ // Fail closed on a malformed entry. generalName is set at posting time for
5474
+ // every interactive-assist entry; its absence means entry confusion, and
5475
+ // falling back to the blocked instance would type the assist text into the
5476
+ // very terminal prompt this feature exists to keep humans in front of.
5477
+ const generalName = pending.generalName;
5478
+ if (!generalName) {
5479
+ this.logger.error({ instanceName: pending.instanceName }, "Interactive-assist entry has no General target — refusing to deliver");
5480
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName));
4535
5481
  return true;
4536
5482
  }
4537
5483
  // Injecting into General while General itself is blocked would type into the
4538
5484
  // terminal prompt instead of the agent input. Fail safe and require attach.
4539
- if (pending.instanceName === pending.generalName) {
4540
- await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
5485
+ if (pending.instanceName === generalName) {
5486
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
4541
5487
  return true;
4542
5488
  }
4543
- await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
5489
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
4544
5490
  try {
4545
- await this.deliverToInstance(pending.generalName, {
5491
+ await this.deliverToInstance(generalName, {
4546
5492
  type: "fleet_inbound",
4547
- content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.kind)),
4548
- targetSession: pending.generalName,
5493
+ content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
5494
+ targetSession: generalName,
4549
5495
  meta: {
4550
5496
  chat_id: pending.chatId,
4551
5497
  message_id: pending.messageId ?? data.messageId,
@@ -4604,7 +5550,7 @@ export class FleetManager {
4604
5550
  const force = target.kind === "classic" && !!data.options?.force;
4605
5551
  const cmd = saveCommandForBackend(backend, filename, force);
4606
5552
  if (!cmd) {
4607
- await data.respond(SAVE_UNSUPPORTED_MSG);
5553
+ await data.respond(t("save.unsupported"));
4608
5554
  return;
4609
5555
  }
4610
5556
  if (target.kind === "classic") {
@@ -4657,7 +5603,7 @@ export class FleetManager {
4657
5603
  }
4658
5604
  return false;
4659
5605
  }
4660
- async sendCancelButton(instanceName, correlationId) {
5606
+ async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
4661
5607
  // Post first, retire after (see the tail of this method). Retiring up front
4662
5608
  // meant that from the delete until the new message came back — a chat API
4663
5609
  // round trip, and every reply goes through here — the instance had NO live
@@ -4718,6 +5664,10 @@ export class FleetManager {
4718
5664
  // not re-edit identical text — which put a "(edited)" mark on Discord
4719
5665
  // with nothing visibly changed.
4720
5666
  lastProgressText: "👀 處理中…",
5667
+ // A reply can re-post the same in-flight bubble below the reply. Carry
5668
+ // its current list into that replacement; fresh inbound work must start
5669
+ // empty even if the daemon's reset broadcast is still in flight.
5670
+ toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
4721
5671
  };
4722
5672
  this.startProgressTicker(entry);
4723
5673
  // Idle-check backstop: every 5min, if the instance is idle, retire the
@@ -4771,16 +5721,28 @@ export class FleetManager {
4771
5721
  static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
4772
5722
  if (elapsedMs < minElapsedMs)
4773
5723
  return "👀 處理中…";
5724
+ const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
5725
+ const detail = FleetManager.sanitizeActivity(activity);
5726
+ return detail
5727
+ ? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
5728
+ : `⏳ 處理中… (已進行 ${elapsed})`;
5729
+ }
5730
+ /** Render elapsed time consistently in live and retained progress bubbles. */
5731
+ static formatProgressElapsed(elapsedMs) {
4774
5732
  const totalSeconds = Math.floor(elapsedMs / 1000);
4775
5733
  const minutes = Math.floor(totalSeconds / 60);
4776
5734
  const seconds = totalSeconds % 60;
4777
- const elapsed = minutes >= 60
5735
+ return minutes >= 60
4778
5736
  ? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
4779
5737
  : `${minutes}m ${String(seconds).padStart(2, "0")}s`;
4780
- const detail = FleetManager.sanitizeActivity(activity);
4781
- return detail
4782
- ? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
4783
- : `⏳ 處理中… (已進行 ${elapsed})`;
5738
+ }
5739
+ /** Final read-only form of a bubble that contains semantic tool history.
5740
+ * Neutral wording is intentional: the same retirement primitive is used for
5741
+ * normal completion, a mid-turn bubble replacement, and user cancellation,
5742
+ * so claiming every retained checkpoint is "completed" would be false. */
5743
+ composeRetiredBubbleText(entry) {
5744
+ const elapsed = FleetManager.formatProgressElapsed(Date.now() - (entry.startedAt ?? Date.now()));
5745
+ return `🧾 工具歷程 (記錄至 ${elapsed})\n${entry.toolProgress}`;
4784
5746
  }
4785
5747
  /**
4786
5748
  * Make a tool summary safe to paste into a channel message.
@@ -4813,6 +5775,88 @@ export class FleetManager {
4813
5775
  else
4814
5776
  this.instanceActivity.delete(name);
4815
5777
  }
5778
+ /**
5779
+ * Cache the turn's tool-progress list and push it into the instance's live
5780
+ * bubble, coalesced so a burst of tool events cannot flood the Bot API
5781
+ * (Telegram's flood limit is per-chat across ALL forum topics, so edits are
5782
+ * rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
5783
+ */
5784
+ cacheInstanceProgress(name, progress) {
5785
+ if (progress)
5786
+ this.instanceProgress.set(name, progress);
5787
+ else
5788
+ this.instanceProgress.delete(name);
5789
+ for (const entry of this.cancelButtons.values()) {
5790
+ if (entry.instanceName !== name)
5791
+ continue;
5792
+ // Empty means the daemon completed/reset the turn. Clear the live cache
5793
+ // for the next turn, but retain this bubble's last non-empty list so its
5794
+ // retirement can preserve the history instead of deleting the message.
5795
+ if (progress)
5796
+ entry.toolProgress = progress;
5797
+ this.scheduleProgressEdit(entry);
5798
+ }
5799
+ }
5800
+ /** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
5801
+ scheduleProgressEdit(entry) {
5802
+ if (entry.retiring)
5803
+ return;
5804
+ const since = Date.now() - (entry.lastProgressEditAt ?? 0);
5805
+ if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
5806
+ this.refreshBubble(entry);
5807
+ return;
5808
+ }
5809
+ if (entry.progressEditTimer)
5810
+ return; // trailing edit already scheduled
5811
+ entry.progressEditTimer = setTimeout(() => {
5812
+ entry.progressEditTimer = undefined;
5813
+ this.refreshBubble(entry);
5814
+ }, TOOL_PROGRESS_EDIT_MIN_MS - since);
5815
+ entry.progressEditTimer.unref?.();
5816
+ }
5817
+ /**
5818
+ * The ONE composer for the bubble text. Both writers — the elapsed-time
5819
+ * ticker and the tool-progress push — go through here; two independent
5820
+ * renderers editing the same message is how the progress list used to get
5821
+ * wiped by the next elapsed tick (#528 trap 2).
5822
+ */
5823
+ composeBubbleText(entry) {
5824
+ return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
5825
+ }
5826
+ /** Pure composition of header + tool list, exposed for tests. */
5827
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
5828
+ const header = FleetManager.progressText(elapsedMs,
5829
+ // The single-line activity detail is redundant once a tool list exists.
5830
+ progress ? undefined : activity, minElapsedMs);
5831
+ return progress ? `${header}\n${progress}` : header;
5832
+ }
5833
+ /** Recompose and edit the bubble in place; skips when nothing changed. */
5834
+ refreshBubble(entry) {
5835
+ if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
5836
+ clearInterval(entry.progressTimer);
5837
+ return;
5838
+ }
5839
+ const text = this.composeBubbleText(entry);
5840
+ if (text === entry.lastProgressText)
5841
+ return; // nothing changed — skip the API call
5842
+ const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5843
+ if (!adapter?.editAlert)
5844
+ return;
5845
+ entry.lastProgressText = text;
5846
+ entry.lastProgressEditAt = Date.now();
5847
+ adapter.editAlert(entry.chatId, entry.messageId, {
5848
+ type: "cancel",
5849
+ instanceName: entry.instanceName,
5850
+ message: text,
5851
+ choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5852
+ }, entry.threadId ? { threadId: entry.threadId } : undefined)
5853
+ .catch(err => {
5854
+ // A failed progress edit must never escalate: the button still works and
5855
+ // the next tick retries. Common causes are a deleted message or a
5856
+ // rate limit.
5857
+ this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5858
+ });
5859
+ }
4816
5860
  /**
4817
5861
  * Refresh the button's text in place while the instance keeps working.
4818
5862
  *
@@ -4830,31 +5874,7 @@ export class FleetManager {
4830
5874
  return PROGRESS_MIN_ELAPSED_MS;
4831
5875
  }
4832
5876
  startProgressTicker(entry) {
4833
- const tick = () => {
4834
- if (!this.cancelButtons.has(entry.messageId)) {
4835
- clearInterval(entry.progressTimer);
4836
- return;
4837
- }
4838
- const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
4839
- if (text === entry.lastProgressText)
4840
- return; // nothing changed — skip the API call
4841
- const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
4842
- if (!adapter?.editAlert)
4843
- return;
4844
- entry.lastProgressText = text;
4845
- adapter.editAlert(entry.chatId, entry.messageId, {
4846
- type: "cancel",
4847
- instanceName: entry.instanceName,
4848
- message: text,
4849
- choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
4850
- }, entry.threadId ? { threadId: entry.threadId } : undefined)
4851
- .catch(err => {
4852
- // A failed progress edit must never escalate: the button still works and
4853
- // the next tick retries. Common causes are a deleted message or a
4854
- // rate limit.
4855
- this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
4856
- });
4857
- };
5877
+ const tick = () => this.refreshBubble(entry);
4858
5878
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
4859
5879
  entry.progressTimer.unref?.();
4860
5880
  // One extra tick right when the threshold passes, so a 30s threshold shows
@@ -4868,8 +5888,11 @@ export class FleetManager {
4868
5888
  }
4869
5889
  /**
4870
5890
  * After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
4871
- * it has not, retire its button. Re-arming replaces the previous timer, so a
4872
- * burst of replies ends with exactly one pending check.
5891
+ * it has not, retire its button. The daemon is asked for a fresh pane capture
5892
+ * before deciding. Reading only the transition cache stranded the first
5893
+ * post-restart bubble when its startup "working" report never got a matching
5894
+ * idle edge. Re-arming replaces the previous timer, so a burst of replies ends
5895
+ * with exactly one pending check.
4873
5896
  */
4874
5897
  armReplyGrace(instanceName) {
4875
5898
  for (const entry of this.cancelButtons.values()) {
@@ -4881,14 +5904,26 @@ export class FleetManager {
4881
5904
  entry.replyGraceTimer = undefined;
4882
5905
  if (!this.cancelButtons.has(entry.messageId))
4883
5906
  return;
4884
- if (!this.getInstanceIdle(instanceName))
4885
- return; // resumed — a long run keeps its button
4886
- this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
4887
- this.retireButton(entry);
5907
+ void this.finishReplyGrace(instanceName, entry);
4888
5908
  }, REPLY_RETIRE_GRACE_MS);
4889
5909
  entry.replyGraceTimer.unref?.();
4890
5910
  }
4891
5911
  }
5912
+ async finishReplyGrace(instanceName, entry) {
5913
+ const refreshed = await this.refreshInstanceExecutionState(instanceName, REPLY_STATE_REFRESH_TIMEOUT_MS);
5914
+ if (!this.cancelButtons.has(entry.messageId) || entry.retiring)
5915
+ return;
5916
+ if (!refreshed) {
5917
+ // Fail safe: without an authoritative answer, retain a potentially live
5918
+ // Cancel button. The 5-minute/30-minute/24-hour safety nets still apply.
5919
+ this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel reply-grace state refresh timed out");
5920
+ return;
5921
+ }
5922
+ if (!this.getInstanceIdle(instanceName))
5923
+ return; // genuine long run keeps its button
5924
+ this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
5925
+ this.retireButton(entry);
5926
+ }
4892
5927
  /** Retire (delete) every cancel button belonging to an instance. */
4893
5928
  retireInstanceButtons(instanceName) {
4894
5929
  // Snapshot first — retireButton may delete entries from the map on success.
@@ -4910,7 +5945,7 @@ export class FleetManager {
4910
5945
  this.deleteButtonMessage(entry)
4911
5946
  .then(() => {
4912
5947
  this.discardButton(entry);
4913
- this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
5948
+ this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
4914
5949
  })
4915
5950
  .catch((err) => this.scheduleButtonRetry(entry, err));
4916
5951
  }
@@ -4922,6 +5957,8 @@ export class FleetManager {
4922
5957
  clearInterval(entry.idleCheckTimer);
4923
5958
  if (entry.progressTimer)
4924
5959
  clearInterval(entry.progressTimer);
5960
+ if (entry.progressEditTimer)
5961
+ clearTimeout(entry.progressEditTimer);
4925
5962
  if (entry.replyGraceTimer)
4926
5963
  clearTimeout(entry.replyGraceTimer);
4927
5964
  this.cancelButtons.delete(entry.messageId);
@@ -4999,12 +6036,21 @@ export class FleetManager {
4999
6036
  // Continue the same retire cycle (bypass the retiring-guard in retireButton).
5000
6037
  entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
5001
6038
  }
5002
- /** Delete one button's message via its own adapter. Resolves on success,
6039
+ /** Retire one cancel bubble via its own adapter. A bubble that accumulated
6040
+ * tool progress is kept as a read-only history message; a plain bubble keeps
6041
+ * the legacy delete/edit-to-checkmark behavior. Resolves on success and
5003
6042
  * rejects on failure so the caller can retry. */
5004
6043
  deleteButtonMessage(e) {
5005
6044
  const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
5006
6045
  if (!adapter)
5007
6046
  return Promise.reject(new Error("no adapter for cancel button"));
6047
+ if (e.toolProgress && adapter.editMessageRemoveButtons) {
6048
+ return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeRetiredBubbleText(e), e.threadId);
6049
+ }
6050
+ // All production adapters currently implement editMessageRemoveButtons.
6051
+ // A third-party adapter without it degrades to the legacy delete behavior:
6052
+ // removing a live Cancel control is safer than retaining an actionable,
6053
+ // untracked button forever.
5008
6054
  if (adapter.deleteMessage)
5009
6055
  return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
5010
6056
  if (adapter.editMessageRemoveButtons)
@@ -5056,11 +6102,27 @@ export class FleetManager {
5056
6102
  const daemon = this.daemons.get(instanceName);
5057
6103
  if (!daemon)
5058
6104
  return false;
6105
+ const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
6106
+ daemon.clearPendingDeliveries?.(deliveryEpoch);
5059
6107
  daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
5060
6108
  this.lastInboundMsg.delete(instanceName);
5061
6109
  this.clearCancelButton(instanceName);
5062
6110
  return true;
5063
6111
  }
6112
+ getDeliveryEpoch(instanceName) {
6113
+ return this.deliveryEpochs.get(instanceName) ?? 0;
6114
+ }
6115
+ isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
6116
+ return deliveryEpoch === this.getDeliveryEpoch(instanceName);
6117
+ }
6118
+ /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
6119
+ cancelPendingDeliveries(instanceName) {
6120
+ const next = this.getDeliveryEpoch(instanceName) + 1;
6121
+ this.deliveryEpochs.set(instanceName, next);
6122
+ for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
6123
+ check();
6124
+ return next;
6125
+ }
5064
6126
  queueMirrorMessage(text) {
5065
6127
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
5066
6128
  if (mirrorTopicId == null || !this.adapter)
@@ -5095,13 +6157,29 @@ export class FleetManager {
5095
6157
  }
5096
6158
  async sendHangNotification(instanceName, unchangedForMs) {
5097
6159
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
5098
- if (!adapter)
5099
- return;
5100
- const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
5101
- const groupId = channelCfg?.group_id;
5102
- if (!groupId)
6160
+ const adapterId = this.getInstanceAdapterId(instanceName);
6161
+ // Same three-way addressing as sendCancelButton: fleet topic → group+thread,
6162
+ // Classic → its own channel (Classic instances are absent from
6163
+ // fleetConfig.instances, so the topic path can never address them), else the
6164
+ // world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
6165
+ // because on channels[]-configured fleets the legacy channel: block is empty.
6166
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
6167
+ const groupId = this.getGroupIdForInstance(instanceName) || undefined;
6168
+ let chatId;
6169
+ let threadId;
6170
+ if (topicId != null && groupId) {
6171
+ chatId = String(groupId);
6172
+ threadId = String(topicId);
6173
+ }
6174
+ else {
6175
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
6176
+ if (!chatId && groupId)
6177
+ chatId = String(groupId);
6178
+ }
6179
+ if (!adapter || !adapterId || !chatId) {
6180
+ this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
5103
6181
  return;
5104
- const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
6182
+ }
5105
6183
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
5106
6184
  const configuredMinutes = instanceHangConfig?.timeout_minutes
5107
6185
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
@@ -5110,17 +6188,52 @@ export class FleetManager {
5110
6188
  ? configuredMinutes
5111
6189
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
5112
6190
  this.setTopicIcon(instanceName, "red");
5113
- await adapter.notifyAlert(String(groupId), {
5114
- type: "hang",
6191
+ await this.postNonceButtonPrompt({
6192
+ prefix: HANG_CALLBACK_PREFIX,
6193
+ alertType: "hang",
5115
6194
  instanceName,
5116
- message: `⚠️ ${instanceName} may be stuck — pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
6195
+ adapter,
6196
+ adapterId,
6197
+ chatId,
6198
+ threadId,
6199
+ message: t("hang.detected", instanceName, unchangedMinutes),
5117
6200
  choices: [
5118
- { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
5119
- { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
6201
+ { action: "restart", label: t("hang.restart") },
6202
+ { action: "wait", label: t("hang.wait") },
5120
6203
  ],
5121
- }, {
5122
- threadId: threadId != null ? String(threadId) : undefined,
5123
- }).catch(e => this.logger.warn({ err: e }, "Failed to send hang notification"));
6204
+ expiredText: t("hang.expired", instanceName),
6205
+ });
6206
+ }
6207
+ /**
6208
+ * Consume a hang Force-restart / Keep-waiting button exactly once. Restart
6209
+ * goes through restartSingleInstance — serialized against concurrent restart
6210
+ * sources, and with a Classic-instance fallback (the previous hand-rolled
6211
+ * stop+start silently left Classic instances stopped while reporting
6212
+ * "restarted").
6213
+ */
6214
+ async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
6215
+ const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
6216
+ if (claimed === null)
6217
+ return false;
6218
+ if (claimed === "consumed")
6219
+ return true;
6220
+ const { entry: pending, action } = claimed;
6221
+ this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
6222
+ if (action === "wait") {
6223
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
6224
+ return true;
6225
+ }
6226
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
6227
+ try {
6228
+ await this.restartSingleInstance(pending.instanceName);
6229
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
6230
+ }
6231
+ catch (err) {
6232
+ const message = err instanceof Error ? err.message : String(err);
6233
+ this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
6234
+ 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"));
6235
+ }
6236
+ return true;
5124
6237
  }
5125
6238
  // ── Topic icon + auto-archive ─────────────────────────────────────────────
5126
6239
  static INSTRUCTIONS_FILENAME = {
@@ -5129,6 +6242,10 @@ export class FleetManager {
5129
6242
  "gemini-cli": "GEMINI.md",
5130
6243
  "opencode": "AGENTS.md",
5131
6244
  "kiro-cli": ".kiro/steering/project.md",
6245
+ // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
6246
+ // same files their writeConfig() appends fleet instructions to.
6247
+ "grok": "AGENTS.md",
6248
+ "antigravity": ".agents/agents.md",
5132
6249
  "mock": "CLAUDE.md",
5133
6250
  };
5134
6251
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -5138,146 +6255,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
5138
6255
  Do NOT modify project files directly — delegate file changes to the project's instance.
5139
6256
  You CAN write code snippets, explain code, and answer technical questions directly.
5140
6257
 
5141
- -----
5142
-
5143
- ## Task Classification
5144
-
5145
- Classify every incoming request before acting.
5146
-
5147
- ### Handle Directly (ALL conditions must be true)
5148
-
5149
- - No file system access needed
5150
- - No external execution needed
5151
- - Answerable from static knowledge
5152
- - ≤ 2 reasoning steps
5153
-
5154
- Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
5155
-
5156
- ### Delegate to 1 Instance
5157
-
5158
- - Task scoped to a single project or repo
5159
- - Requires file access, code changes, or execution
5160
-
5161
- ### Coordinate Multiple Instances
5162
-
5163
- - Task spans multiple repos or domains
5164
- - Requires outputs from one instance to feed into another
5165
- - Benefits from parallel execution (max 3 instances per task)
5166
-
5167
- -----
5168
-
5169
- ## Instance Discovery (in this order)
5170
- 1. list_teams() → reuse existing teams first
5171
- 2. list_instances() → find by working_directory, description, or tags
5172
- 3. describe_instance() → confirm capabilities before delegating
5173
- 4. create_instance() → only if no suitable instance exists
5174
-
5175
- Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
5176
-
5177
- -----
5178
-
5179
- ## Delegation Protocol
5180
-
5181
- Every delegation via send_to_instance() MUST include:
5182
-
5183
- 1. Task scope — what exactly to do, bounded clearly
5184
- 2. Expected output — what to return and in what form
5185
- 3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
5186
-
5187
- ### Loop Prevention
5188
-
5189
- - Never re-delegate a task back to the instance that sent it to you
5190
- - If a task has bounced 3 times, stop and solve locally or reduce scope
5191
-
5192
- ### Execution Strategy
6258
+ ## Task Routing
5193
6259
 
5194
- Parallel — use only when tasks are independent with no shared state
5195
- Sequential — use when one task's output feeds into the next
6260
+ - **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
6261
+ - **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
6262
+ - **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
5196
6263
 
5197
- -----
6264
+ Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
5198
6265
 
5199
- ## Result Handling
6266
+ ## Reply Contract
5200
6267
 
5201
- When an instance reports back, classify the outcome:
5202
-
5203
- - Success → Summarize key results for user. Omit internal coordination noise.
5204
- - Partial → State what succeeded, what remains, proposed next steps.
5205
- - Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
5206
- - No response → Ping again after reasonable wait. If still silent: report to user with options.
5207
-
5208
- ### Output to User
5209
-
5210
- Every final response to the user should contain:
5211
-
5212
- - Result — the actual answer or deliverable
5213
- - Gaps — anything incomplete or unresolved (omit if none)
5214
-
5215
- -----
5216
-
5217
- ## Shared Decisions
5218
-
5219
- Use post_decision() / list_decisions() for any choice that affects more than 1 instance, changes an API contract, introduces a new dependency, or alters deployment process.
5220
-
5221
- When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
5222
-
5223
- -----
6268
+ Every final response to the user contains: the result (the actual answer or deliverable) and gaps (anything incomplete — omit if none). Summarize instance reports; omit internal coordination noise.
5224
6269
 
5225
6270
  ## After Restart
5226
6271
 
5227
- After a restart, run this sequence BEFORE processing any new messages:
5228
- 1. list_instances() → rebuild fleet awareness
5229
- 2. list_teams() → restore team structure
5230
- 3. list_decisions() → reload policies and conventions
5231
-
5232
- Only then handle incoming requests.
5233
-
5234
- -----
5235
-
5236
- ## Development Workflow Policy
5237
-
5238
- All code changes across the fleet should follow this workflow.
5239
- The coordinator enforces compliance but does not perform these steps directly.
5240
- Remind instances of this policy when delegating code tasks.
5241
-
5242
- ### Workflow Stages
5243
- Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
5244
-
5245
- ### Policy Rules
6272
+ BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
5246
6273
 
5247
- 1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
5248
- 2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
5249
- 3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
5250
- 4. Independent review — every merge requires code review from someone other than the author.
5251
- 5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
5252
- 6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
6274
+ ## Playbooks (on-demand skills)
5253
6275
 
5254
- ### Specialist Instance Rules
5255
-
5256
- - Execute within defined scope only
5257
- - Return structured output: result, assumptions, uncertainties, verification status
5258
- - Do NOT create new instances without coordinator approval
5259
-
5260
- -----
5261
-
5262
- ## Team Management
5263
-
5264
- - Always check existing teams before creating new ones
5265
- - Default to ephemeral teams (created for a specific task, dissolved after completion)
5266
- - Clean up ephemeral teams and instances after task completion
5267
-
5268
- -----
5269
-
5270
- ## Instance Configuration Tips
5271
-
5272
- When users create specialized instances, suggest these configurations:
5273
-
5274
- - **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
5275
- - **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
5276
- - **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
6276
+ Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
6277
+ - **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
6278
+ - **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
6279
+ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
5277
6280
  `;
5278
6281
  /** Ensure the general instance has its project instructions file + knowledge */
5279
- ensureGeneralInstructions(workDir, backendName) {
6282
+ ensureGeneralInstructions(workDir, backendName, instanceName) {
5280
6283
  const backend = backendName ?? "claude-code";
6284
+ workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
5281
6285
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
5282
6286
  const filePath = join(workDir, filename);
5283
6287
  mkdirSync(dirname(filePath), { recursive: true });
@@ -5285,60 +6289,238 @@ When users create specialized instances, suggest these configurations:
5285
6289
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
5286
6290
  this.logger.info({ filePath }, "Created general instance instructions file");
5287
6291
  }
5288
- // Sync bundled knowledge files to general's steering directory
6292
+ // Sync bundled knowledge files to general's steering and skills directories.
5289
6293
  this.syncGeneralKnowledge(workDir, backend);
5290
6294
  }
5291
- /** Copy general-knowledge steering + skills to the general instance's workspace */
6295
+ /** Resolve the workspace path a backend actually uses before publishing knowledge. */
6296
+ resolveKnowledgeWorkDir(workDir, backend, instanceName) {
6297
+ // Some backends relocate their real cwd (agy moves hidden paths to
6298
+ // ~/agend-workspaces/<name>). Instructions and skills must land where the
6299
+ // CLI actually runs, not where fleet.yaml points.
6300
+ try {
6301
+ const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
6302
+ .resolveWorkingDirectory?.(workDir, instanceName);
6303
+ if (resolved)
6304
+ workDir = resolved;
6305
+ }
6306
+ catch { /* unknown backend name — keep the raw path */ }
6307
+ return workDir;
6308
+ }
6309
+ /**
6310
+ * Where each backend natively loads on-demand skills from, relative to the
6311
+ * workspace. Backends without a native skill mechanism (opencode, grok,
6312
+ * antigravity, gemini-cli) are deliberately absent: dropping files a CLI
6313
+ * never reads is clutter, not capability. Unknown directories are ignored
6314
+ * by older CLI versions, so publishing is fail-open across upgrades.
6315
+ */
6316
+ static SKILLS_DIR_SEGMENTS = {
6317
+ "kiro-cli": [".kiro", "skills"],
6318
+ "claude-code": [".claude", "skills"],
6319
+ "codex": [".agents", "skills"],
6320
+ // Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
6321
+ // vendor-canonical location is .grok/skills.
6322
+ "opencode": [".agents", "skills"],
6323
+ "grok": [".grok", "skills"],
6324
+ "antigravity": [".agents", "skills"],
6325
+ };
6326
+ /** Copy general-knowledge steering + all role-eligible skills to General. */
5292
6327
  syncGeneralKnowledge(workDir, backend) {
5293
6328
  const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
5294
6329
  if (!existsSync(knowledgeDir))
5295
6330
  return;
5296
- // Sync steering files → .kiro/steering/ (or workDir root for non-kiro)
5297
- const steeringDir = backend === "kiro-cli"
5298
- ? join(workDir, ".kiro", "steering")
5299
- : workDir;
5300
- mkdirSync(steeringDir, { recursive: true });
5301
- const srcSteering = join(knowledgeDir, "steering");
5302
- if (existsSync(srcSteering)) {
5303
- for (const file of readdirSync(srcSteering)) {
5304
- if (!file.endsWith(".md"))
5305
- continue;
5306
- const src = join(srcSteering, file);
6331
+ this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
6332
+ this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
6333
+ this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
6334
+ }
6335
+ /** Publish only the bundled skills eligible for an instance role. */
6336
+ syncRoleSkills(workDir, backend, role, knowledgeDir) {
6337
+ const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
6338
+ if (!skillSegments)
6339
+ return;
6340
+ const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
6341
+ if (!existsSync(root))
6342
+ return;
6343
+ // Before managed-skill manifests existed, only Kiro General received
6344
+ // bundled skills. Allow that one legacy layout to be adopted so upgrades
6345
+ // can keep those copies current; other backends never had unmanaged
6346
+ // AgEnD-published skills and must retain the normal user-ownership guard.
6347
+ const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
6348
+ this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
6349
+ this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
6350
+ }
6351
+ /**
6352
+ * Steering (always-on rules like core-rules.md). Kiro loads a native
6353
+ * steering directory; every other backend gets the content embedded into
6354
+ * its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
6355
+ * block — the previous behavior dropped bare .md files in the workspace
6356
+ * root, which no CLI ever read. The block is replaced in place on every
6357
+ * sync, so rule updates reach EXISTING workspaces; everything the user
6358
+ * wrote outside the markers is preserved byte-for-byte.
6359
+ */
6360
+ syncGeneralSteering(workDir, backend, srcSteering) {
6361
+ if (!existsSync(srcSteering))
6362
+ return;
6363
+ const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
6364
+ if (files.length === 0)
6365
+ return;
6366
+ if (backend === "kiro-cli") {
6367
+ const steeringDir = join(workDir, ".kiro", "steering");
6368
+ mkdirSync(steeringDir, { recursive: true });
6369
+ for (const file of files) {
5307
6370
  const dest = join(steeringDir, file);
5308
- const newContent = readFileSync(src, "utf-8");
6371
+ const newContent = readFileSync(join(srcSteering, file), "utf-8");
5309
6372
  try {
5310
6373
  if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5311
6374
  continue;
5312
6375
  }
5313
- catch { }
6376
+ catch { /* rewrite */ }
5314
6377
  writeFileSync(dest, newContent);
5315
6378
  }
6379
+ return;
5316
6380
  }
5317
- // Sync skills → .kiro/skills/ (kiro-cli only)
5318
- if (backend === "kiro-cli") {
5319
- const srcSkills = join(knowledgeDir, "skills");
5320
- if (existsSync(srcSkills)) {
5321
- const destSkills = join(workDir, ".kiro", "skills");
5322
- mkdirSync(destSkills, { recursive: true });
5323
- for (const skillDir of readdirSync(srcSkills)) {
5324
- const skillSrc = join(srcSkills, skillDir);
5325
- if (!existsSync(join(skillSrc, "SKILL.md")))
5326
- continue;
5327
- const skillDest = join(destSkills, skillDir);
5328
- mkdirSync(skillDest, { recursive: true });
5329
- const src = join(skillSrc, "SKILL.md");
5330
- const dest = join(skillDest, "SKILL.md");
5331
- const newContent = readFileSync(src, "utf-8");
5332
- try {
5333
- if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5334
- continue;
5335
- }
5336
- catch { }
5337
- writeFileSync(dest, newContent);
5338
- }
6381
+ const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
6382
+ const instructionsPath = join(workDir, filename);
6383
+ const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
6384
+ const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
6385
+ let existing = "";
6386
+ try {
6387
+ existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
6388
+ }
6389
+ catch { /* treat as empty */ }
6390
+ const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
6391
+ const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
6392
+ let next;
6393
+ if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
6394
+ next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
6395
+ }
6396
+ else {
6397
+ next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
6398
+ }
6399
+ if (next !== existing) {
6400
+ mkdirSync(dirname(instructionsPath), { recursive: true });
6401
+ writeFileSync(instructionsPath, next);
6402
+ }
6403
+ }
6404
+ static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
6405
+ static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
6406
+ /**
6407
+ * Publish AgEnD's bundled skills into a CLI's native skills directory,
6408
+ * owning ONLY what we published. A manifest records which skill names AgEnD
6409
+ * wrote; a bundled rename/removal deletes the stale managed copy, while a
6410
+ * skill the user created by hand is never listed and therefore never
6411
+ * touched — even if a future bundle happens to reuse its name (the sync
6412
+ * then skips it and logs, rather than overwrite the user's work). The sole
6413
+ * migration exception is the pre-manifest Kiro General layout explicitly
6414
+ * selected by adoptLegacyUnmanaged.
6415
+ */
6416
+ syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
6417
+ if (!existsSync(srcSkills))
6418
+ return;
6419
+ mkdirSync(destSkills, { recursive: true });
6420
+ const manifestPath = join(destSkills, ".agend-managed-skills.json");
6421
+ const hadManifest = existsSync(manifestPath);
6422
+ let previouslyManaged = [];
6423
+ try {
6424
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
6425
+ if (Array.isArray(parsed))
6426
+ previouslyManaged = parsed.filter(n => typeof n === "string");
6427
+ }
6428
+ catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
6429
+ const bundled = readdirSync(srcSkills)
6430
+ .filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
6431
+ .sort();
6432
+ // Pre-manifest Kiro General copied bundled skills directly into
6433
+ // .kiro/skills. If every existing skill is still a bundled name, this is
6434
+ // the unambiguous legacy layout: adopt it once, update it below, and write
6435
+ // the ownership manifest. Any extra skill name keeps the directory fully
6436
+ // on the user-owned/collision path.
6437
+ if (!hadManifest && adoptLegacyUnmanaged) {
6438
+ const existing = readdirSync(destSkills, { withFileTypes: true })
6439
+ .filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
6440
+ .map(entry => entry.name)
6441
+ .sort();
6442
+ if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
6443
+ previouslyManaged = existing;
6444
+ this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
6445
+ }
6446
+ }
6447
+ const eligible = bundled.filter(name => {
6448
+ const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
6449
+ // General is the coordinator and receives both coordinator and worker
6450
+ // playbooks. Workers receive only skills explicitly marked for workers.
6451
+ return role === "general" || roles.includes("worker");
6452
+ });
6453
+ const managed = [];
6454
+ for (const name of eligible) {
6455
+ const destDir = join(destSkills, name);
6456
+ const dest = join(destDir, "SKILL.md");
6457
+ const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
6458
+ if (!isOurs) {
6459
+ // Name collision with a user-authored skill: theirs wins, loudly.
6460
+ this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
6461
+ continue;
6462
+ }
6463
+ managed.push(name);
6464
+ const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
6465
+ try {
6466
+ if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
6467
+ continue;
6468
+ }
6469
+ catch { /* rewrite */ }
6470
+ mkdirSync(destDir, { recursive: true });
6471
+ writeFileSync(dest, newContent);
6472
+ }
6473
+ // Remove managed skills that are no longer bundled OR no longer eligible
6474
+ // for this role. This makes a shared → general-only metadata change take
6475
+ // effect on the next worker startup instead of leaving stale capability.
6476
+ for (const stale of previouslyManaged) {
6477
+ if (eligible.includes(stale))
6478
+ continue;
6479
+ try {
6480
+ rmSync(join(destSkills, stale), { recursive: true, force: true });
6481
+ this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
6482
+ }
6483
+ catch (err) {
6484
+ this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
5339
6485
  }
5340
6486
  }
5341
- this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
6487
+ try {
6488
+ writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
6489
+ }
6490
+ catch (err) {
6491
+ this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
6492
+ }
6493
+ }
6494
+ /** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
6495
+ readManagedSkillRoles(skillPath) {
6496
+ const fallback = ["general"];
6497
+ try {
6498
+ const content = readFileSync(skillPath, "utf-8");
6499
+ const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
6500
+ if (!match)
6501
+ return fallback;
6502
+ const document = parseDocument(match[1]);
6503
+ if (document.errors.length > 0)
6504
+ throw document.errors[0];
6505
+ const frontmatter = document.toJS();
6506
+ if (!frontmatter || frontmatter.roles === undefined)
6507
+ return fallback;
6508
+ if (!Array.isArray(frontmatter.roles))
6509
+ throw new Error("roles must be an array");
6510
+ const roles = [...new Set(frontmatter.roles)]
6511
+ .filter((value) => value === "general" || value === "worker");
6512
+ if (roles.length !== frontmatter.roles.length || roles.length === 0) {
6513
+ throw new Error("roles must contain only general and/or worker");
6514
+ }
6515
+ return roles;
6516
+ }
6517
+ catch (err) {
6518
+ // Fail closed for workers: malformed or unknown metadata keeps the
6519
+ // backwards-compatible General-only behavior instead of leaking an
6520
+ // administrative skill into worker workspaces.
6521
+ this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
6522
+ return fallback;
6523
+ }
5342
6524
  }
5343
6525
  /** Fetch forum topic icon stickers and pick emoji IDs for each state */
5344
6526
  async resolveTopicIcons() {
@@ -5770,6 +6952,17 @@ When users create specialized instances, suggest these configurations:
5770
6952
  return null;
5771
6953
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
5772
6954
  const env = { backend, probedAt: Date.now(), ...probed };
6955
+ // An empty result must never overwrite a catalog we already have. Some
6956
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
6957
+ // returns [] — and writing that would blank the list for the whole 24h
6958
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
6959
+ // antigravity cache replaced by an empty one. Keep the known models and
6960
+ // let the fresher currentModel/version through.
6961
+ if (!env.models?.length) {
6962
+ const previous = this.readCliEnv(backend);
6963
+ if (previous?.models?.length)
6964
+ env.models = previous.models;
6965
+ }
5773
6966
  const path = this.cliEnvPath(backend);
5774
6967
  mkdirSync(dirname(path), { recursive: true });
5775
6968
  writeFileSync(path, JSON.stringify(env, null, 2));
@@ -5808,6 +7001,108 @@ When users create specialized instances, suggest these configurations:
5808
7001
  const env = await this.probeBackend(backendName);
5809
7002
  return env?.models ?? [];
5810
7003
  }
7004
+ /**
7005
+ * Model catalog behind the `list_models` tool.
7006
+ *
7007
+ * The two scopes are not cosmetic. "global" is the account/CLI catalog served
7008
+ * from the startup probe cache; "instance" is resolved through that instance's
7009
+ * OWN backend config, and for a Codex instance on a custom provider that is a
7010
+ * different catalog entirely — `listModels()` reads models_cache.json out of
7011
+ * the instance's private CODEX_HOME. Answering such an instance with the
7012
+ * account list would name models its CLI rejects, which is exactly the
7013
+ * mistake this tool exists to prevent.
7014
+ *
7015
+ * `scope` always describes where the returned LIST came from, not what was
7016
+ * asked for: an instance query that falls back to the account catalog reports
7017
+ * scope "global" and says so in `note`, rather than implying instance-level
7018
+ * accuracy it does not have.
7019
+ *
7020
+ * Never throws — a model listing is an aid, and failing it must not fail a turn.
7021
+ */
7022
+ async listModelCatalog(opts = {}) {
7023
+ const { instanceName } = opts;
7024
+ if (instanceName) {
7025
+ const backend = this.backendNameForInstance(instanceName);
7026
+ const resolved = this.resolveInstanceModel(instanceName);
7027
+ const currentModel = resolved.source === "unresolved" ? null : resolved.model;
7028
+ const provider = this.customProviderFor(instanceName, backend);
7029
+ const scoped = await this.instanceScopedModels(instanceName, backend);
7030
+ if (scoped.length) {
7031
+ return {
7032
+ backend, scope: "instance", instance: instanceName,
7033
+ current_model: currentModel, models: scoped, source: "live",
7034
+ ...(provider ? { note: `Catalog read through this instance's ${backend} provider "${provider}" — it may differ from the account catalog.` } : {}),
7035
+ };
7036
+ }
7037
+ // No instance-local catalog (never launched, or the backend has no
7038
+ // per-instance list). The account catalog is the best available answer,
7039
+ // but it is labelled honestly rather than dressed up as instance scope.
7040
+ const global = await this.globalModelCatalog(backend);
7041
+ return {
7042
+ ...global, instance: instanceName, current_model: currentModel,
7043
+ note: provider
7044
+ ? `No instance-local catalog yet; showing the account catalog, which may NOT match this instance's ${backend} provider "${provider}".`
7045
+ : "No instance-local catalog yet; showing the account catalog.",
7046
+ };
7047
+ }
7048
+ return this.globalModelCatalog(opts.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
7049
+ }
7050
+ /** The custom provider an instance overrides its backend with, if any. */
7051
+ customProviderFor(instanceName, backend) {
7052
+ const opts = this.fleetConfig?.instances?.[instanceName]?.backend_options?.[backend]
7053
+ ?? this.fleetConfig?.defaults?.backend_options?.[backend];
7054
+ const provider = opts?.provider;
7055
+ return typeof provider === "string" && provider.trim() ? provider.trim() : null;
7056
+ }
7057
+ /** Ask a backend for its catalog using ONE instance's real config. Never throws. */
7058
+ async instanceScopedModels(instanceName, backend) {
7059
+ try {
7060
+ const inst = this.fleetConfig?.instances?.[instanceName];
7061
+ const instanceDir = this.getInstanceDir(instanceName);
7062
+ const be = createBackend(backend, instanceDir);
7063
+ if (!be.listModels)
7064
+ return [];
7065
+ return await be.listModels({
7066
+ workingDirectory: inst?.working_directory ?? "",
7067
+ instanceDir,
7068
+ instanceName,
7069
+ mcpServers: {},
7070
+ model: inst?.model,
7071
+ backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
7072
+ }) ?? [];
7073
+ }
7074
+ catch {
7075
+ // listModels is documented never to throw, but a backend constructor can
7076
+ // (missing binary). A catalog is an aid; degrade to the account list.
7077
+ return [];
7078
+ }
7079
+ }
7080
+ /** Account-wide catalog: probe cache first, live probe on miss. */
7081
+ async globalModelCatalog(backend) {
7082
+ const cached = this.readCliEnv(backend);
7083
+ if (cached?.models?.length) {
7084
+ return {
7085
+ backend, scope: "global", current_model: cached.currentModel ?? null,
7086
+ models: cached.models, source: "cache",
7087
+ probed_at: new Date(cached.probedAt).toISOString(),
7088
+ };
7089
+ }
7090
+ const env = await this.probeBackend(backend);
7091
+ if (env?.models?.length) {
7092
+ return {
7093
+ backend, scope: "global", current_model: env.currentModel ?? null,
7094
+ models: env.models, source: "live",
7095
+ probed_at: new Date(env.probedAt).toISOString(),
7096
+ };
7097
+ }
7098
+ // Reported rather than thrown: "we could not enumerate" is a useful answer,
7099
+ // and the caller can still set a model by name (AgEnD passes it through).
7100
+ return {
7101
+ backend, scope: "global", current_model: env?.currentModel ?? null,
7102
+ models: [], source: "fallback",
7103
+ note: `Could not enumerate models for ${backend} (CLI missing, not logged in, or it offers no list). Model names are passed through to the CLI, so a known-good name still works.`,
7104
+ };
7105
+ }
5811
7106
  /** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
5812
7107
  /** Label an effort choice, marking the one currently configured. */
5813
7108
  effortChoiceLabel(level, current) {
@@ -5816,10 +7111,10 @@ When users create specialized instances, suggest these configurations:
5816
7111
  effortMenuHeader(instanceName) {
5817
7112
  const { effort, source } = this.resolveInstanceEffort(instanceName);
5818
7113
  if (!effort)
5819
- return "Current effort: (CLI default)";
7114
+ return t("effort.current_default");
5820
7115
  return source === "fleet-default"
5821
- ? `Current effort: ${effort} (fleet default)`
5822
- : `Current effort: ${effort}`;
7116
+ ? t("effort.current_fleet", effort)
7117
+ : t("effort.current", effort);
5823
7118
  }
5824
7119
  /** `/effort` — DC Select Menu, or apply directly when a level is given. */
5825
7120
  async handleEffortSlash(data, adapterId) {
@@ -5840,11 +7135,11 @@ When users create specialized instances, suggest these configurations:
5840
7135
  }
5841
7136
  const levels = this.effortLevelsFor(name);
5842
7137
  if (levels.length === 0) {
5843
- await data.respond(`❌ ${this.backendNameForInstance(name)} has no reasoning-effort setting.`);
7138
+ await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
5844
7139
  return;
5845
7140
  }
5846
7141
  if (!data.respondChoices) {
5847
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7142
+ await data.respond(t("effort.usage", levels.join("|")));
5848
7143
  return;
5849
7144
  }
5850
7145
  const current = this.resolveInstanceEffort(name).effort;
@@ -5857,20 +7152,20 @@ When users create specialized instances, suggest these configurations:
5857
7152
  timer.unref?.();
5858
7153
  this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
5859
7154
  try {
5860
- await data.respondChoices(`${this.effortMenuHeader(name)}\nSelect a new effort level:`, choices);
7155
+ await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
5861
7156
  }
5862
7157
  catch (err) {
5863
7158
  this.pendingEffortSelects.delete(nonce);
5864
7159
  clearTimeout(timer);
5865
7160
  this.logger.warn({ err, instanceName: name }, "effort menu failed");
5866
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7161
+ await data.respond(t("effort.usage", levels.join("|")));
5867
7162
  }
5868
7163
  }
5869
7164
  /** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
5870
7165
  async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
5871
7166
  const levels = this.effortLevelsFor(instanceName);
5872
7167
  if (levels.length === 0) {
5873
- return `❌ ${this.backendNameForInstance(instanceName)} has no reasoning-effort setting.`;
7168
+ return t("effort.unsupported", this.backendNameForInstance(instanceName));
5874
7169
  }
5875
7170
  const current = this.resolveInstanceEffort(instanceName).effort;
5876
7171
  const nonce = randomBytes(6).toString("hex");
@@ -5886,13 +7181,13 @@ When users create specialized instances, suggest these configurations:
5886
7181
  const p = this.pendingEffortSelects.get(nonce);
5887
7182
  if (p) {
5888
7183
  this.pendingEffortSelects.delete(nonce);
5889
- p.respond("⏰ Effort selection expired.").catch(() => { });
7184
+ p.respond(t("effort.selection_expired")).catch(() => { });
5890
7185
  }
5891
7186
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
5892
7187
  timer.unref?.();
5893
7188
  this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
5894
7189
  try {
5895
- const menuMessageId = await adapter.promptUser(chatId, `${this.effortMenuHeader(instanceName)}\nSelect a new effort level:`, choices, { threadId });
7190
+ const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
5896
7191
  const pending = this.pendingEffortSelects.get(nonce);
5897
7192
  if (pending)
5898
7193
  pending.menuMessageId = menuMessageId;
@@ -5902,7 +7197,7 @@ When users create specialized instances, suggest these configurations:
5902
7197
  this.pendingEffortSelects.delete(nonce);
5903
7198
  clearTimeout(timer);
5904
7199
  this.logger.warn({ err, instanceName }, "TG effort menu failed");
5905
- return `Usage: /effort <${levels.join("|")}>`;
7200
+ return t("effort.usage", levels.join("|"));
5906
7201
  }
5907
7202
  }
5908
7203
  /** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
@@ -5923,7 +7218,7 @@ When users create specialized instances, suggest these configurations:
5923
7218
  this.pendingEffortSelects.delete(match[1]);
5924
7219
  clearTimeout(pending.timer);
5925
7220
  const level = match[2];
5926
- const progressText = `⏳ Setting ${pending.instanceName} effort to \`${level}\`…`;
7221
+ const progressText = t("effort.setting", pending.instanceName, level);
5927
7222
  let progressMsgId;
5928
7223
  if (pending.adapter && pending.adapterChatId) {
5929
7224
  const menuMessageId = pending.menuMessageId ?? data.messageId;
@@ -5955,7 +7250,7 @@ When users create specialized instances, suggest these configurations:
5955
7250
  }
5956
7251
  catch (err) {
5957
7252
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
5958
- result = `Effort switch to \`${level}\` failed: ${err instanceof Error ? err.message : String(err)}`;
7253
+ result = t("effort.switch_failed", level, err instanceof Error ? err.message : String(err));
5959
7254
  }
5960
7255
  if (pending.adapter && pending.adapterChatId) {
5961
7256
  if (progressMsgId) {
@@ -5992,12 +7287,12 @@ When users create specialized instances, suggest these configurations:
5992
7287
  }
5993
7288
  // No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
5994
7289
  if (!data.respondChoices) {
5995
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7290
+ await data.respond(t("model.usage"));
5996
7291
  return;
5997
7292
  }
5998
7293
  const options = await this.getModelOptions(name, isRefresh);
5999
7294
  if (options.length === 0) {
6000
- await data.respond(`No model list available for ${name}. Type \`/model <name>\` to set one directly.`);
7295
+ await data.respond(t("model.list_unavailable", name));
6001
7296
  return;
6002
7297
  }
6003
7298
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
@@ -6011,13 +7306,13 @@ When users create specialized instances, suggest these configurations:
6011
7306
  timer.unref?.();
6012
7307
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
6013
7308
  try {
6014
- await data.respondChoices(`Current model: **${currentDisplay}**\nSelect a new model:`, choices);
7309
+ await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
6015
7310
  }
6016
7311
  catch (err) {
6017
7312
  this.pendingModelSelects.delete(nonce);
6018
7313
  clearTimeout(timer);
6019
7314
  this.logger.warn({ err, instanceName: name }, "model menu failed");
6020
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7315
+ await data.respond(t("model.usage"));
6021
7316
  }
6022
7317
  }
6023
7318
  /**
@@ -6028,7 +7323,7 @@ When users create specialized instances, suggest these configurations:
6028
7323
  async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
6029
7324
  const options = await this.getModelOptions(instanceName);
6030
7325
  if (options.length === 0) {
6031
- return `No model list available for ${instanceName}. Use \`/model <name>\` to set one directly.`;
7326
+ return t("model.list_unavailable", instanceName);
6032
7327
  }
6033
7328
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
6034
7329
  const nonce = randomBytes(6).toString("hex");
@@ -6044,13 +7339,13 @@ When users create specialized instances, suggest these configurations:
6044
7339
  const p = this.pendingModelSelects.get(nonce);
6045
7340
  if (p) {
6046
7341
  this.pendingModelSelects.delete(nonce);
6047
- p.respond("⏰ Model selection expired.").catch(() => { });
7342
+ p.respond(t("model.selection_expired")).catch(() => { });
6048
7343
  }
6049
7344
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
6050
7345
  timer.unref?.();
6051
7346
  this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
6052
7347
  try {
6053
- const menuMessageId = await adapter.promptUser(chatId, `Current model: ${currentDisplay}\nSelect a new model:`, choices, { threadId });
7348
+ const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
6054
7349
  const pending = this.pendingModelSelects.get(nonce);
6055
7350
  if (pending)
6056
7351
  pending.menuMessageId = menuMessageId;
@@ -6060,7 +7355,7 @@ When users create specialized instances, suggest these configurations:
6060
7355
  this.pendingModelSelects.delete(nonce);
6061
7356
  clearTimeout(timer);
6062
7357
  this.logger.warn({ err, instanceName }, "TG model menu failed");
6063
- return `Usage: /model <name> — e.g. /model sonnet`;
7358
+ return t("model.usage");
6064
7359
  }
6065
7360
  }
6066
7361
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
@@ -6083,7 +7378,7 @@ When users create specialized instances, suggest these configurations:
6083
7378
  clearTimeout(pending.timer);
6084
7379
  const model = match[2];
6085
7380
  // Send immediate "⏳ Switching..." feedback, then apply in background.
6086
- const progressText = `⏳ Switching ${pending.instanceName} to \`${model}\`…`;
7381
+ const progressText = t("model.switching", pending.instanceName, model);
6087
7382
  let progressMsgId;
6088
7383
  if (pending.adapter && pending.adapterChatId) {
6089
7384
  // TG path: turn the original menu into the progress message. This both
@@ -6120,7 +7415,7 @@ When users create specialized instances, suggest these configurations:
6120
7415
  }
6121
7416
  catch (err) {
6122
7417
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
6123
- result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
7418
+ result = t("model.switch_failed", model, err instanceof Error ? err.message : String(err));
6124
7419
  }
6125
7420
  if (pending.adapter && pending.adapterChatId) {
6126
7421
  if (progressMsgId) {
@@ -6217,17 +7512,17 @@ When users create specialized instances, suggest these configurations:
6217
7512
  }
6218
7513
  catch { /* treated as unsupported below */ }
6219
7514
  if (strategy === "unsupported" || supported.length === 0) {
6220
- return `❌ ${backendName} has no reasoning-effort setting.`;
7515
+ return t("effort.unsupported", backendName);
6221
7516
  }
6222
7517
  if (!FleetManager.EFFORT_LEVELS.includes(level)) {
6223
- return `❌ Unknown effort level \`${level}\`. Use: ${FleetManager.EFFORT_LEVELS.join(", ")}.`;
7518
+ return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
6224
7519
  }
6225
7520
  const applied = FleetManager.clampEffort(level, supported);
6226
7521
  if (!applied)
6227
- return `❌ ${backendName} accepts none of the canonical effort levels.`;
7522
+ return t("effort.no_canonical", backendName);
6228
7523
  const warn = applied === level
6229
7524
  ? ""
6230
- : `⚠️ Clamped to \`${applied}\` (\`${level}\` not supported by ${backendName}).\n`;
7525
+ : t("effort.clamped", applied, level, backendName);
6231
7526
  // Persist either way: a runtime switch must survive the next respawn too,
6232
7527
  // or the instance silently reverts on restart.
6233
7528
  if (this.fleetConfig?.instances[instanceName]) {
@@ -6236,12 +7531,12 @@ When users create specialized instances, suggest these configurations:
6236
7531
  }
6237
7532
  if (strategy === "runtime") {
6238
7533
  if (!this.instanceIpcClients.get(instanceName))
6239
- return `${warn}❌ ${instanceName} is not running.`;
7534
+ return `${warn}${t("effort.not_running", instanceName)}`;
6240
7535
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
6241
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` (runtime).`;
7536
+ return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
6242
7537
  }
6243
7538
  await this.restartSingleInstance(instanceName);
6244
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` and restarted.`;
7539
+ return `${warn}${t("effort.restart_success", instanceName, applied)}`;
6245
7540
  }
6246
7541
  async applyModel(instanceName, model) {
6247
7542
  const backendName = this.backendNameForInstance(instanceName);
@@ -6250,14 +7545,12 @@ When users create specialized instances, suggest these configurations:
6250
7545
  strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
6251
7546
  }
6252
7547
  catch { /* default restart */ }
6253
- const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
6254
- if (strategy === "runtime") {
6255
- if (!this.instanceIpcClients.get(instanceName))
6256
- return `${warn}❌ ${instanceName} is not running.`;
6257
- this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
6258
- return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
7548
+ const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
7549
+ if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
7550
+ return `${warn}${t("effort.not_running", instanceName)}`;
6259
7551
  }
6260
- // restart: persist the model so the respawned CLI launches with it.
7552
+ // Persist either way: a runtime switch must survive the next respawn too,
7553
+ // or the instance silently reverts to the CLI default after a fleet restart.
6261
7554
  let persisted = false;
6262
7555
  if (this.fleetConfig?.instances[instanceName]) {
6263
7556
  this.fleetConfig.instances[instanceName].model = model;
@@ -6268,9 +7561,13 @@ When users create specialized instances, suggest these configurations:
6268
7561
  persisted = true;
6269
7562
  }
6270
7563
  if (!persisted)
6271
- return `${warn}❌ Could not set model for ${instanceName}.`;
7564
+ return `${warn}${t("model.persist_failed", instanceName)}`;
7565
+ if (strategy === "runtime") {
7566
+ this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
7567
+ return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
7568
+ }
6272
7569
  await this.restartSingleInstance(instanceName);
6273
- return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
7570
+ return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
6274
7571
  }
6275
7572
  /**
6276
7573
  * The trailing "Current effort: …" line for a /model reply.
@@ -6284,10 +7581,10 @@ When users create specialized instances, suggest these configurations:
6284
7581
  return "";
6285
7582
  const { effort, source } = this.resolveInstanceEffort(instanceName);
6286
7583
  if (!effort)
6287
- return "\nCurrent effort: (CLI default)";
7584
+ return `\n${t("effort.current_default")}`;
6288
7585
  return source === "fleet-default"
6289
- ? `\nCurrent effort: ${effort} (fleet default)`
6290
- : `\nCurrent effort: ${effort}`;
7586
+ ? `\n${t("effort.current_fleet", effort)}`
7587
+ : `\n${t("effort.current", effort)}`;
6291
7588
  }
6292
7589
  /** Read recent chat log for agent context */
6293
7590
  getRecentChatLog(instanceName, maxLines = 10) {
@@ -6322,7 +7619,7 @@ When users create specialized instances, suggest these configurations:
6322
7619
  /** Return a user-facing blocker without mutating ClassicBot state. */
6323
7620
  validateClassicStart(channelId, userId, guildId, adapterId) {
6324
7621
  if (!this.classicChannels)
6325
- return "Classic channel manager not initialized.";
7622
+ return t("classic.manager_unavailable");
6326
7623
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
6327
7624
  const generalId = this.findGeneralInstance(adapterId);
6328
7625
  if (generalId)
@@ -6469,6 +7766,7 @@ When users create specialized instances, suggest these configurations:
6469
7766
  return;
6470
7767
  const workDir = join(getAgendHome(), "workspaces", instanceName);
6471
7768
  ensureWorkspaceGit(workDir);
7769
+ const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
6472
7770
  const config = {
6473
7771
  ...DEFAULT_INSTANCE_CONFIG,
6474
7772
  ...this.fleetConfig?.defaults,
@@ -6476,6 +7774,8 @@ When users create specialized instances, suggest these configurations:
6476
7774
  lightweight: true,
6477
7775
  ...(backend ? { backend } : {}),
6478
7776
  ...(model ? { model } : {}),
7777
+ ...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
7778
+ ...(classicIdentity?.description ? { description: classicIdentity.description } : {}),
6479
7779
  ...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
6480
7780
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
6481
7781
  };
@@ -6489,7 +7789,7 @@ When users create specialized instances, suggest these configurations:
6489
7789
  return blocker;
6490
7790
  const classicChannels = this.classicChannels;
6491
7791
  if (!classicChannels)
6492
- return "Classic channel manager not initialized.";
7792
+ return t("classic.manager_unavailable");
6493
7793
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
6494
7794
  clearPausedMarker(this.getInstanceDir(instanceName));
6495
7795
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
@@ -6511,7 +7811,7 @@ When users create specialized instances, suggest these configurations:
6511
7811
  /** Handle /stop slash command — unregister classic channel */
6512
7812
  async handleClassicStop(channelId, adapterId) {
6513
7813
  if (!this.classicChannels)
6514
- return "Classic channel manager not initialized.";
7814
+ return t("classic.manager_unavailable");
6515
7815
  const ch = this.classicChannels.unregister(channelId, adapterId);
6516
7816
  if (!ch)
6517
7817
  return t("classic.no_agent");
@@ -6558,6 +7858,8 @@ When users create specialized instances, suggest these configurations:
6558
7858
  this.clearStatuslineWatchers();
6559
7859
  this.costGuard?.stop();
6560
7860
  this.dailySummary?.stop();
7861
+ this.dailyTipScheduler?.stop();
7862
+ this.dailyTipScheduler = null;
6561
7863
  if (this.updateCheckTimer) {
6562
7864
  clearTimeout(this.updateCheckTimer);
6563
7865
  clearInterval(this.updateCheckTimer);
@@ -6606,11 +7908,11 @@ When users create specialized instances, suggest these configurations:
6606
7908
  for (const pending of this.pendingClassicStarts.values())
6607
7909
  clearTimeout(pending.timer);
6608
7910
  this.pendingClassicStarts.clear();
6609
- for (const pending of this.pendingInteractivePromptAssists.values()) {
7911
+ for (const pending of this.pendingNonceButtons.values()) {
6610
7912
  if (pending.timer)
6611
7913
  clearTimeout(pending.timer);
6612
7914
  }
6613
- this.pendingInteractivePromptAssists.clear();
7915
+ this.pendingNonceButtons.clear();
6614
7916
  this.topicArchiver.stop();
6615
7917
  this.scheduler?.shutdown();
6616
7918
  // Stop instances in parallel batches to avoid long sequential waits.
@@ -6775,7 +8077,8 @@ When users create specialized instances, suggest these configurations:
6775
8077
  /**
6776
8078
  * Hot-reload: re-read fleet.yaml and reconcile running instances.
6777
8079
  * Starts new, stops removed, restarts modified instances.
6778
- * Fleet-level config (access, cost_guard, etc.) requires /restart to take effect.
8080
+ * Whitelisted runtime fields are pushed into live daemons; all other instance
8081
+ * fields, plus cold fleet-level settings, retain restart semantics.
6779
8082
  */
6780
8083
  async reconcileInstances() {
6781
8084
  if (!this.configPath)
@@ -6817,11 +8120,11 @@ When users create specialized instances, suggest these configurations:
6817
8120
  // possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
6818
8121
  // with no explanation anywhere they are looking.
6819
8122
  const why = !validation.valid
6820
- ? `validation failed:\n${validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n")}`
8123
+ ? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
6821
8124
  : unsafeEmpty
6822
- ? `it removed every instance (${oldCount} → 0)`
6823
- : `it removed more than half the instances (${oldCount} → ${newCount})`;
6824
- this.notifyFleetError(`⚠️ fleet.yaml reload REJECTED — ${why}\nThe previous configuration is still running.`);
8125
+ ? t("fleet.reload_removed_all", oldCount)
8126
+ : t("fleet.reload_removed_half", oldCount, newCount);
8127
+ this.notifyFleetError(t("fleet.reload_rejected", why));
6825
8128
  return;
6826
8129
  }
6827
8130
  this.routing.rebuild(this.fleetConfig);
@@ -6829,9 +8132,20 @@ When users create specialized instances, suggest these configurations:
6829
8132
  this.scheduler?.reload();
6830
8133
  const newInstances = this.fleetConfig.instances;
6831
8134
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
6832
- // Detect fleet-level config changes and warn
6833
- const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldConfig?.defaults });
6834
- const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: this.fleetConfig?.defaults });
8135
+ // Detect fleet-level changes which still need a restart. Hot defaults are
8136
+ // reconciled below and must not produce a misleading restart warning.
8137
+ const oldDefaultColdWithFleetFields = oldConfig?.defaults
8138
+ ? splitHotColdConfig(oldConfig.defaults).cold
8139
+ : {};
8140
+ const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
8141
+ ? splitHotColdConfig(this.fleetConfig.defaults).cold
8142
+ : {};
8143
+ // `tips` is a fleet-owned hot switch; it is read at post time and never
8144
+ // belongs in a daemon config or a "restart required" warning.
8145
+ const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
8146
+ const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
8147
+ const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
8148
+ const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
6835
8149
  if (oldFleetLevel !== newFleetLevel) {
6836
8150
  this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
6837
8151
  }
@@ -6843,7 +8157,9 @@ When users create specialized instances, suggest these configurations:
6843
8157
  await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
6844
8158
  }
6845
8159
  }
6846
- // Start new + restart modified instances
8160
+ // Start new + reconcile modified instances. Hot values are always sent as a
8161
+ // complete snapshot: Settings mutates FleetManager's config before SIGHUP,
8162
+ // so an old/new diff alone can miss the live daemon's stale value.
6847
8163
  for (const [name, config] of Object.entries(newInstances)) {
6848
8164
  if (!this.daemons.has(name)) {
6849
8165
  // New instance — startInstance already calls connectIpcToInstance
@@ -6851,14 +8167,33 @@ When users create specialized instances, suggest these configurations:
6851
8167
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
6852
8168
  }
6853
8169
  else if (oldConfig?.instances[name]) {
6854
- // Restart if any config field changed
6855
- if (JSON.stringify(oldConfig.instances[name]) !== JSON.stringify(config)) {
8170
+ const daemon = this.daemons.get(name);
8171
+ const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
8172
+ const oldParts = splitHotColdConfig(runtimeConfig);
8173
+ const newParts = splitHotColdConfig(config);
8174
+ // Every field not explicitly classified hot is cold by default.
8175
+ if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
6856
8176
  this.logger.info({ name }, "Instance config changed — restarting");
6857
8177
  await this.stopInstance(name).catch(() => { });
6858
8178
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
6859
8179
  }
8180
+ else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
8181
+ const update = hotConfigUpdate(config);
8182
+ const ipc = this.instanceIpcClients.get(name);
8183
+ const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
8184
+ if (!sent) {
8185
+ // Daemon is in-process, so a reconnect gap must not leave runtime
8186
+ // state stale. Normal operation still uses the explicit IPC contract.
8187
+ daemon.applyConfigUpdate(update);
8188
+ this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
8189
+ }
8190
+ this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
8191
+ }
6860
8192
  }
6861
8193
  }
8194
+ // warm_cap is fleet-owned; enforce the reloaded value immediately against
8195
+ // currently idle instances instead of waiting for a future state edge.
8196
+ this.enforceWarmCap();
6862
8197
  this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
6863
8198
  }
6864
8199
  async restartInstances() {
@@ -7098,7 +8433,7 @@ When users create specialized instances, suggest these configurations:
7098
8433
  if (generalId) {
7099
8434
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
7100
8435
  // link preview looked bad. Version + /update instruction is enough.
7101
- this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
8436
+ this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
7102
8437
  }
7103
8438
  }
7104
8439
  }
@@ -7418,7 +8753,7 @@ When users create specialized instances, suggest these configurations:
7418
8753
  if (err.code === "EADDRINUSE") {
7419
8754
  if (this.healthPortRetried) {
7420
8755
  this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
7421
- this.notifyFleetError(`⚠️ Dashboard unavailable — health port ${port} is already in use. Stop the conflicting process or configure a different health_port.`);
8756
+ this.notifyFleetError(t("dashboard.port_in_use", port));
7422
8757
  return;
7423
8758
  }
7424
8759
  this.healthPortRetried = true;
@@ -7444,7 +8779,7 @@ When users create specialized instances, suggest these configurations:
7444
8779
  return;
7445
8780
  }
7446
8781
  this.logger.error({ err, port }, "Health server error");
7447
- this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
8782
+ this.notifyFleetError(t("dashboard.server_failed", err.message));
7448
8783
  });
7449
8784
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
7450
8785
  }