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

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 +72 -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 +14 -0
  52. package/dist/fleet-manager.d.ts +231 -10
  53. package/dist/fleet-manager.js +1798 -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 +217 -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,55 @@ 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 === "on" || mode === "off") {
644
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
645
+ await data.respond(t("permission.denied"));
646
+ return;
647
+ }
648
+ this.fleetConfig.defaults.tips = mode === "on";
649
+ this.saveFleetConfig();
650
+ await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
651
+ return;
652
+ }
653
+ const route = this.routing.resolve(data.channelId);
654
+ if (!route || route.kind !== "general") {
655
+ await data.respond(t("tips.general_only"));
656
+ return;
657
+ }
658
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
659
+ if (!adapter) {
660
+ await data.respond(t("tips.unavailable"));
661
+ return;
662
+ }
663
+ const chatId = this.getGroupIdForInstance(route.name) || data.channelId;
664
+ const result = await this.promptTip(route.name, adapter, chatId, data.channelId);
665
+ await data.respond(t(result === "posted"
666
+ ? "tips.posted"
667
+ : result === "empty" ? "tips.empty" : "tips.unavailable"));
668
+ }
560
669
  /** Admin-only full conversation reset for fleet-topic and Classic instances. */
561
670
  async handleClearSlash(data, adapterId) {
562
671
  if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
@@ -568,7 +677,14 @@ export class FleetManager {
568
677
  await data.respond(t("classic.no_agent"));
569
678
  return;
570
679
  }
571
- await data.respond(await this.topicCommands.sendClear(name));
680
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
681
+ if (!adapter) {
682
+ await data.respond(t("clear.prompt_unavailable"));
683
+ return;
684
+ }
685
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
686
+ const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
687
+ await data.respond(fallback ?? t("clear.confirm_posted"));
572
688
  }
573
689
  /** Get the adapter bound to an instance, falling back to primary adapter */
574
690
  getAdapterForInstance(name) {
@@ -702,6 +818,30 @@ export class FleetManager {
702
818
  return null;
703
819
  return this.instanceStateCache.get(name)?.state ?? null;
704
820
  }
821
+ /**
822
+ * Subscription providers currently relevant to this fleet. Stopped/crashed
823
+ * rows do not keep a usage card alive, while persisted paused instances do.
824
+ * Classic channels use their effective backend merge chain, not the raw row.
825
+ */
826
+ getActiveUsageProviderIds() {
827
+ const providers = new Set();
828
+ const add = (name, backend) => {
829
+ const status = this.getInstanceStatus(name);
830
+ if (status !== "running" && status !== "paused")
831
+ return;
832
+ const provider = usageProviderIdForBackend(backend);
833
+ if (provider)
834
+ providers.add(provider);
835
+ };
836
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
837
+ // loadFleetConfig() has already merged the fleet default into each row.
838
+ add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
839
+ }
840
+ for (const channel of this.classicChannels?.getAll() ?? []) {
841
+ add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
842
+ }
843
+ return providers;
844
+ }
705
845
  isClassicInstance(name) {
706
846
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
707
847
  }
@@ -814,7 +954,7 @@ export class FleetManager {
814
954
  .finally(() => this.warmCapEvicting.delete(victim));
815
955
  }
816
956
  }
817
- waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
957
+ waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
818
958
  const isReady = () => {
819
959
  const snapshot = this.instanceStateCache.get(instanceName);
820
960
  return snapshot?.state === "idle"
@@ -836,8 +976,12 @@ export class FleetManager {
836
976
  this.instanceIdleWaiters.delete(instanceName);
837
977
  resolve(idle);
838
978
  };
839
- const check = () => { if (isReady())
840
- finish(true); };
979
+ const check = () => {
980
+ if (cancelled?.())
981
+ finish(false);
982
+ else if (isReady())
983
+ finish(true);
984
+ };
841
985
  const query = () => {
842
986
  const ipc = this.instanceIpcClients.get(instanceName);
843
987
  if (ipc?.connected) {
@@ -853,22 +997,74 @@ export class FleetManager {
853
997
  query();
854
998
  });
855
999
  }
856
- async deliverWithIdleGate(instanceName, payload, timeoutMs) {
1000
+ /**
1001
+ * Ask the daemon to capture the pane before answering, then wait for any state
1002
+ * report produced after this request. The ordinary query is intentionally
1003
+ * cache-only (idle gates call it frequently); this opt-in refresh is reserved
1004
+ * for lifecycle decisions where a stale state would strand UI.
1005
+ */
1006
+ refreshInstanceExecutionState(instanceName, timeoutMs) {
1007
+ const ipc = this.instanceIpcClients.get(instanceName);
1008
+ if (!ipc?.connected)
1009
+ return Promise.resolve(false);
1010
+ const previous = this.instanceStateCache.get(instanceName);
1011
+ return new Promise(resolve => {
1012
+ let settled = false;
1013
+ const finish = (refreshed) => {
1014
+ if (settled)
1015
+ return;
1016
+ settled = true;
1017
+ clearTimeout(timeout);
1018
+ const waiters = this.instanceIdleWaiters.get(instanceName);
1019
+ waiters?.delete(check);
1020
+ if (waiters?.size === 0)
1021
+ this.instanceIdleWaiters.delete(instanceName);
1022
+ resolve(refreshed);
1023
+ };
1024
+ const check = () => {
1025
+ if (this.instanceStateCache.get(instanceName) !== previous)
1026
+ finish(true);
1027
+ };
1028
+ const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
1029
+ waiters.add(check);
1030
+ this.instanceIdleWaiters.set(instanceName, waiters);
1031
+ const timeout = setTimeout(() => finish(false), timeoutMs);
1032
+ timeout.unref?.();
1033
+ const sent = ipc.send({
1034
+ type: "query_instance_state",
1035
+ requestId: `reply-grace-${Date.now()}`,
1036
+ refresh: true,
1037
+ });
1038
+ if (!sent)
1039
+ finish(false);
1040
+ });
1041
+ }
1042
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1043
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1044
+ return;
857
1045
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
858
1046
  if (this.lifecycle.isPaused(instanceName)) {
859
1047
  const wakeStartedAt = Date.now();
860
1048
  await this.lifecycle.wake(instanceName, 30_000);
1049
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1050
+ return;
861
1051
  // Waking added one to the warm count — make room by evicting a different
862
1052
  // LRU idle instance (never this one; it's about to work).
863
1053
  this.enforceWarmCap(instanceName);
864
1054
  // Never satisfy a post-wake gate from a stale pre-pause cache entry.
865
1055
  idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
866
1056
  }
867
- const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
1057
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
1058
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1059
+ this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
1060
+ return;
1061
+ }
868
1062
  if (!idle) {
869
1063
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
870
1064
  }
871
- await this.sendWhenConnected(instanceName, payload);
1065
+ await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
1066
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1067
+ return;
872
1068
  this.lastDeliveryAt.set(instanceName, Date.now());
873
1069
  }
874
1070
  /**
@@ -887,7 +1083,9 @@ export class FleetManager {
887
1083
  * instance, *including* when the socket happens to be up: otherwise a message
888
1084
  * arriving after the reconnect could overtake one that has been waiting for it.
889
1085
  */
890
- async sendWhenConnected(instanceName, payload) {
1086
+ async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1087
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1088
+ return;
891
1089
  const queued = this.ipcWaitTails.get(instanceName);
892
1090
  if (!queued) {
893
1091
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -896,7 +1094,11 @@ export class FleetManager {
896
1094
  }
897
1095
  const attempt = (queued ?? Promise.resolve())
898
1096
  .catch(() => { })
899
- .then(() => this.sendAfterIpcReturns(instanceName, payload));
1097
+ .then(() => {
1098
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1099
+ return;
1100
+ return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
1101
+ });
900
1102
  // The chain stores a settled-either-way promise so one failed delivery cannot
901
1103
  // wedge every later one, and so `queued` above is safe to await unguarded.
902
1104
  const tail = attempt.catch(() => { });
@@ -913,10 +1115,12 @@ export class FleetManager {
913
1115
  }
914
1116
  }
915
1117
  /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
916
- async sendAfterIpcReturns(instanceName, payload) {
1118
+ async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
917
1119
  const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
918
1120
  let warned = false;
919
1121
  for (;;) {
1122
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1123
+ return;
920
1124
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
921
1125
  // so a cached reference would stay dead forever.
922
1126
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -934,6 +1138,8 @@ export class FleetManager {
934
1138
  }
935
1139
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
936
1140
  async deliverToInstance(instanceName, payload, options = {}) {
1141
+ const deliveryEpoch = this.getDeliveryEpoch(instanceName);
1142
+ const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
937
1143
  const meta = payload.meta && typeof payload.meta === "object"
938
1144
  ? payload.meta
939
1145
  : undefined;
@@ -945,16 +1151,20 @@ export class FleetManager {
945
1151
  if (!waitForIdle) {
946
1152
  if (this.lifecycle.isPaused(instanceName)) {
947
1153
  await this.lifecycle.wake(instanceName, 30_000);
1154
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1155
+ return;
948
1156
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
949
1157
  }
950
- await this.sendWhenConnected(instanceName, payload);
1158
+ await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
1159
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1160
+ return;
951
1161
  // A cross-instance item arriving before the daemon observes this turn as
952
1162
  // working must not trust the stale idle snapshot from before the send.
953
1163
  this.lastDeliveryAt.set(instanceName, Date.now());
954
1164
  return;
955
1165
  }
956
1166
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
957
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
1167
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
958
1168
  this.idleGatedDeliveryTails.set(instanceName, delivery);
959
1169
  try {
960
1170
  await delivery;
@@ -1033,19 +1243,34 @@ export class FleetManager {
1033
1243
  this.logger.info({ name }, "Instance already running, skipping");
1034
1244
  return;
1035
1245
  }
1246
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1036
1247
  if (config.general_topic) {
1037
1248
  // antigravity (agy) does not read MCP instructions — fleet context and
1038
1249
  // routing instructions are not injected, so it cannot act as a dispatcher.
1039
- const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1040
1250
  if (backend === "antigravity") {
1041
1251
  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.");
1252
+ this.notifyInstanceTopic(name, t("general.antigravity_unsupported"));
1253
+ }
1254
+ this.ensureGeneralInstructions(config.working_directory, backend, name);
1255
+ }
1256
+ else if (kind === "fleet-topic") {
1257
+ // Workers get only role-eligible on-demand skills. Classic instances are
1258
+ // deliberately excluded: their workspace and conversation lifecycle are
1259
+ // managed independently from fleet-topic workers.
1260
+ try {
1261
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
1262
+ this.syncRoleSkills(skillsWorkDir, backend, "worker");
1263
+ }
1264
+ catch (err) {
1265
+ // Skill publishing is additive. A read-only or temporarily unavailable
1266
+ // workspace must not turn an otherwise valid worker startup into a
1267
+ // fleet outage.
1268
+ this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
1043
1269
  }
1044
- this.ensureGeneralInstructions(config.working_directory, config.backend);
1045
1270
  }
1046
1271
  await this.lifecycle.start(name, config, topicMode, {
1047
1272
  kind,
1048
- backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
1273
+ backend,
1049
1274
  model: this.resolveInstanceModel(name).display,
1050
1275
  });
1051
1276
  // Only clear a stale process status after a real start succeeded. Clearing
@@ -1210,7 +1435,18 @@ export class FleetManager {
1210
1435
  this.instanceStateCache.delete(name);
1211
1436
  this.instanceProcessStatus.delete(name);
1212
1437
  this.lastDeliveryAt.delete(name);
1213
- return this.lifecycle.stop(name);
1438
+ // A pending hang/exit/assist offer refers to the instance being torn down;
1439
+ // left alone it would stay clickable for the rest of its 15 minutes. Clear
1440
+ // again AFTER the stop completes: the teardown itself can emit hang /
1441
+ // interactive-prompt / clean-exit events whose handlers post fresh prompts
1442
+ // while the stop is still awaiting (the TOCTOU sol's review called out).
1443
+ this.clearNoncePromptsForInstance(name);
1444
+ try {
1445
+ return await this.lifecycle.stop(name);
1446
+ }
1447
+ finally {
1448
+ this.clearNoncePromptsForInstance(name);
1449
+ }
1214
1450
  }
1215
1451
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
1216
1452
  async restartSingleInstance(name, opts) {
@@ -1310,6 +1546,76 @@ export class FleetManager {
1310
1546
  getDashboardAccess() {
1311
1547
  return { ready: this.healthServerListening, token: this.webToken };
1312
1548
  }
1549
+ beginUpdateProgress(adapter, chatId, threadId, messageId) {
1550
+ persistUpdateProgress(this.dataDir, {
1551
+ adapterId: adapter.id,
1552
+ chatId,
1553
+ ...(threadId ? { threadId } : {}),
1554
+ messageId,
1555
+ });
1556
+ this.lastUpdateProgressText = null;
1557
+ this.updateCompletionTipText = null;
1558
+ this.startUpdateProgressMonitor(adapter);
1559
+ }
1560
+ failUpdateProgress(message) {
1561
+ setUpdateProgressStage(this.dataDir, "failed", { error: message });
1562
+ }
1563
+ /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
1564
+ startUpdateProgressMonitor(initialAdapter) {
1565
+ if (this.updateProgressTimer)
1566
+ clearInterval(this.updateProgressTimer);
1567
+ const tick = async () => {
1568
+ if (this.updateProgressEditRunning)
1569
+ return;
1570
+ const marker = readUpdateProgress(this.dataDir);
1571
+ if (!marker) {
1572
+ if (this.updateProgressTimer)
1573
+ clearInterval(this.updateProgressTimer);
1574
+ this.updateProgressTimer = null;
1575
+ return;
1576
+ }
1577
+ const target = marker.progress.target;
1578
+ const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
1579
+ if (!adapter)
1580
+ return;
1581
+ let text = formatUpdateProgress(marker);
1582
+ // No-restart updates complete in the old fleet process, so they never
1583
+ // reach RestartProgress.finish(). Append the same optional tip here.
1584
+ if (marker.progress.stage === "complete" && this.tipsEnabled()) {
1585
+ if (this.updateCompletionTipText === null) {
1586
+ const tip = this.pickAvailableTip();
1587
+ this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
1588
+ }
1589
+ if (this.updateCompletionTipText)
1590
+ text += `\n\n${this.updateCompletionTipText}`;
1591
+ }
1592
+ if (text === this.lastUpdateProgressText)
1593
+ return;
1594
+ this.updateProgressEditRunning = true;
1595
+ try {
1596
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
1597
+ this.lastUpdateProgressText = text;
1598
+ if (marker.progress.stage === "failed" || marker.progress.stage === "complete") {
1599
+ clearUpdateMarker(this.dataDir);
1600
+ if (this.updateProgressTimer)
1601
+ clearInterval(this.updateProgressTimer);
1602
+ this.updateProgressTimer = null;
1603
+ }
1604
+ }
1605
+ catch (err) {
1606
+ this.logger.warn({ err }, "Failed to edit update progress");
1607
+ }
1608
+ finally {
1609
+ this.updateProgressEditRunning = false;
1610
+ }
1611
+ };
1612
+ void tick();
1613
+ // Poll stages faster than the elapsed-time display. npm verification and
1614
+ // service-file refresh can be short; 200ms prevents those stages from being
1615
+ // skipped while the text cache still limits normal edits to once per second.
1616
+ this.updateProgressTimer = setInterval(() => void tick(), 200);
1617
+ this.updateProgressTimer.unref?.();
1618
+ }
1313
1619
  /** Start all instances from fleet config */
1314
1620
  async startAll(configPath) {
1315
1621
  const startupStartedAt = Date.now();
@@ -1323,7 +1629,17 @@ export class FleetManager {
1323
1629
  // Rotate fleet.log if oversized (before any logging)
1324
1630
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1325
1631
  const fleet = this.loadConfig(configPath);
1632
+ this.slimFleetConfigAtStartup();
1326
1633
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
1634
+ const savedUpdateProgress = readUpdateProgress(this.dataDir);
1635
+ const pendingUpdateProgress = savedUpdateProgress
1636
+ && savedUpdateProgress.progress.stage !== "failed"
1637
+ && savedUpdateProgress.progress.stage !== "complete"
1638
+ ? savedUpdateProgress
1639
+ : null;
1640
+ if (pendingUpdateProgress) {
1641
+ setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
1642
+ }
1327
1643
  this.initializeWebAuthTokens();
1328
1644
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
1329
1645
  // Set tmux socket isolation for custom AGEND_HOME
@@ -1371,9 +1687,8 @@ export class FleetManager {
1371
1687
  // routing does NOT go through the routing engine (single-key, can't hold two
1372
1688
  // bots in one channel) — it resolves per-bot via getInstanceByChannel.
1373
1689
  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);
1690
+ const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
1691
+ this.classicChannels.configureAdapters(classicAdapters);
1377
1692
  // Restore the persisted bot binding so replies/cancel go through the right
1378
1693
  // bot after a restart (before this, inbound would re-bind lazily).
1379
1694
  for (const ch of this.classicChannels.getAll()) {
@@ -1542,7 +1857,7 @@ export class FleetManager {
1542
1857
  const generalDir = join(getAgendHome(), name);
1543
1858
  mkdirSync(generalDir, { recursive: true });
1544
1859
  const backendName = fleet.defaults.backend ?? "claude-code";
1545
- this.ensureGeneralInstructions(generalDir, backendName);
1860
+ this.ensureGeneralInstructions(generalDir, backendName, name);
1546
1861
  fleet.instances[name] = {
1547
1862
  ...DEFAULT_INSTANCE_CONFIG,
1548
1863
  working_directory: generalDir,
@@ -1561,6 +1876,13 @@ export class FleetManager {
1561
1876
  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
1877
  this.scheduler.init();
1563
1878
  this.logger.info("Scheduler initialized");
1879
+ // Tips share the daily-report clock but remain an independent internal
1880
+ // job: disabling daily_summary must not disable tips (and vice versa).
1881
+ // The callback checks defaults.tips at fire time, so /tips on|off is hot.
1882
+ this.dailyTipScheduler = new DailyTipScheduler(summaryConfig, costGuardConfig.timezone, () => this.sendTipToGeneral().catch(err => {
1883
+ this.logger.warn({ err }, "Failed to send daily tip");
1884
+ }));
1885
+ this.dailyTipScheduler.start();
1564
1886
  // Inject active decisions as env var for MCP instructions.
1565
1887
  // Snapshotted at startup — new decisions via post_decision are available
1566
1888
  // through list_decisions tool but not auto-injected until restart.
@@ -1580,7 +1902,7 @@ export class FleetManager {
1580
1902
  const allEntries = Object.entries(fleet.instances);
1581
1903
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
1582
1904
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
1583
- const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
1905
+ const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
1584
1906
  if (generals.length > 0) {
1585
1907
  for (const [name, cfg] of generals) {
1586
1908
  try {
@@ -1619,7 +1941,19 @@ export class FleetManager {
1619
1941
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1620
1942
  }
1621
1943
  })();
1622
- progressStart = adapterStartup.then(() => startupProgress.start(this.restartProgressTarget()));
1944
+ progressStart = adapterStartup.then(() => {
1945
+ if (pendingUpdateProgress) {
1946
+ const saved = pendingUpdateProgress.progress.target;
1947
+ const adapter = this.adapters.get(saved.adapterId);
1948
+ const target = adapter ? {
1949
+ adapter,
1950
+ chatId: saved.chatId,
1951
+ threadId: saved.threadId,
1952
+ } : null;
1953
+ return startupProgress.resume(target, saved.messageId);
1954
+ }
1955
+ return startupProgress.start(this.restartProgressTarget());
1956
+ });
1623
1957
  }
1624
1958
  // The systemd watchdog answers exactly one question: is this process still
1625
1959
  // turning its event loop? Pinging from a timer proves that, and after the
@@ -1731,6 +2065,12 @@ export class FleetManager {
1731
2065
  version: agendVersion,
1732
2066
  pausedNames,
1733
2067
  failedNames,
2068
+ tipText: pendingUpdateProgress && this.tipsEnabled()
2069
+ ? (() => {
2070
+ const tip = this.pickAvailableTip();
2071
+ return tip ? this.formatTip(tip) : undefined;
2072
+ })()
2073
+ : undefined,
1734
2074
  });
1735
2075
  if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
1736
2076
  let text;
@@ -1892,14 +2232,14 @@ export class FleetManager {
1892
2232
  notifyAdapterFailure(adapterId, error) {
1893
2233
  const generalId = this.findGeneralInstance();
1894
2234
  if (generalId) {
1895
- this.notifyInstanceTopic(generalId, `⚠️ Adapter "${adapterId}" failed to start: ${error}\nRetrying in background. Other adapters unaffected.`);
2235
+ this.notifyInstanceTopic(generalId, t("adapter.start_failed", adapterId, error));
1896
2236
  }
1897
2237
  }
1898
2238
  /** Notify admin that a retried adapter reconnected. */
1899
2239
  notifyAdapterRecovery(adapterId, attempts) {
1900
2240
  const generalId = this.findGeneralInstance();
1901
2241
  if (generalId) {
1902
- this.notifyInstanceTopic(generalId, `✅ Adapter "${adapterId}" reconnected (after ${attempts} ${attempts === 1 ? "retry" : "retries"}).`);
2242
+ this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
1903
2243
  }
1904
2244
  }
1905
2245
  /** Get adapter states for /status visibility. */
@@ -1997,7 +2337,15 @@ export class FleetManager {
1997
2337
  await this.handleInboundReaction(r);
1998
2338
  }, this.logger, "adapter.reaction"));
1999
2339
  this.adapter.on("callback_query", safeHandler(async (data) => {
2000
- if (await this.handleInteractivePromptAssist(data, adapterId))
2340
+ if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
2341
+ return;
2342
+ if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
2343
+ return;
2344
+ if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2345
+ return;
2346
+ if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
2347
+ return;
2348
+ if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
2001
2349
  return;
2002
2350
  if (await this.handleClassicBackendSelection(data))
2003
2351
  return;
@@ -2005,25 +2353,8 @@ export class FleetManager {
2005
2353
  return;
2006
2354
  if (await this.handleEffortSelection(data))
2007
2355
  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
- }
2356
+ if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
2025
2357
  return;
2026
- }
2027
2358
  if (data.callbackData.startsWith("cancel:")) {
2028
2359
  this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
2029
2360
  return;
@@ -2102,6 +2433,43 @@ export class FleetManager {
2102
2433
  const result = await this.topicCommands.sendCompact(name);
2103
2434
  await data.respond(result);
2104
2435
  }
2436
+ else if (data.command === "steer") {
2437
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2438
+ if (!name) {
2439
+ await data.respond(t("classic.no_agent"));
2440
+ return;
2441
+ }
2442
+ const steerText = String(data.options?.message ?? "").trim();
2443
+ if (!steerText) {
2444
+ await data.respond(t("steer.usage"));
2445
+ return;
2446
+ }
2447
+ // chat_id/message_id stay empty: a slash interaction has no channel
2448
+ // message to react to, and an empty chat_id keeps updateLastChat from
2449
+ // rerouting the instance's replies to the slash context.
2450
+ const result = this.topicCommands.sendSteer(name, steerText, {
2451
+ chatId: "", messageId: "", username: data.username ?? "user",
2452
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2453
+ });
2454
+ await data.respond(result);
2455
+ }
2456
+ else if (data.command === "btw") {
2457
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2458
+ if (!name) {
2459
+ await data.respond(t("classic.no_agent"));
2460
+ return;
2461
+ }
2462
+ const btwText = String(data.options?.message ?? "").trim();
2463
+ if (!btwText) {
2464
+ await data.respond(t("btw.usage"));
2465
+ return;
2466
+ }
2467
+ const result = this.topicCommands.sendBtw(name, btwText, {
2468
+ chatId: "", messageId: "", username: data.username ?? "user",
2469
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2470
+ });
2471
+ await data.respond(result);
2472
+ }
2105
2473
  else if (data.command === "clear") {
2106
2474
  await this.handleClearSlash(data, adapterId);
2107
2475
  }
@@ -2157,17 +2525,7 @@ export class FleetManager {
2157
2525
  : t("collab.off.classic"));
2158
2526
  }
2159
2527
  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();
2528
+ await this.handleUpdateSlash(data, adapterId);
2171
2529
  }
2172
2530
  else if (data.command === "doctor") {
2173
2531
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
@@ -2185,12 +2543,15 @@ export class FleetManager {
2185
2543
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2186
2544
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2187
2545
  // slash_command is Discord-only; editReply renders Markdown natively.
2188
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2546
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2189
2547
  }
2190
2548
  catch (err) {
2191
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2549
+ await data.respond(t("usage.failed", err.message));
2192
2550
  }
2193
2551
  }
2552
+ else if (data.command === "tips") {
2553
+ await this.handleTipsSlash(data, adapterId);
2554
+ }
2194
2555
  else if (data.command === "status") {
2195
2556
  // Admin-gated (like the topic path): the merged table shows every
2196
2557
  // instance's cost and IPC health.
@@ -2236,6 +2597,43 @@ export class FleetManager {
2236
2597
  const result = await this.topicCommands.sendCompact(name);
2237
2598
  await data.respond(result);
2238
2599
  }
2600
+ else if (data.command === "steer") {
2601
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2602
+ if (!name) {
2603
+ await data.respond(t("classic.no_agent"));
2604
+ return;
2605
+ }
2606
+ const steerText = String(data.options?.message ?? "").trim();
2607
+ if (!steerText) {
2608
+ await data.respond(t("steer.usage"));
2609
+ return;
2610
+ }
2611
+ // chat_id/message_id stay empty: a slash interaction has no channel
2612
+ // message to react to, and an empty chat_id keeps updateLastChat from
2613
+ // rerouting the instance's replies to the slash context.
2614
+ const result = this.topicCommands.sendSteer(name, steerText, {
2615
+ chatId: "", messageId: "", username: data.username ?? "user",
2616
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2617
+ });
2618
+ await data.respond(result);
2619
+ }
2620
+ else if (data.command === "btw") {
2621
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2622
+ if (!name) {
2623
+ await data.respond(t("classic.no_agent"));
2624
+ return;
2625
+ }
2626
+ const btwText = String(data.options?.message ?? "").trim();
2627
+ if (!btwText) {
2628
+ await data.respond(t("btw.usage"));
2629
+ return;
2630
+ }
2631
+ const result = this.topicCommands.sendBtw(name, btwText, {
2632
+ chatId: "", messageId: "", username: data.username ?? "user",
2633
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2634
+ });
2635
+ await data.respond(result);
2636
+ }
2239
2637
  }, this.logger, "adapter.slash_command"));
2240
2638
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2241
2639
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2312,7 +2710,15 @@ export class FleetManager {
2312
2710
  await this.handleInboundReaction(r);
2313
2711
  }, this.logger, `adapter[${adapterId}].reaction`));
2314
2712
  adapter.on("callback_query", safeHandler(async (data) => {
2315
- if (await this.handleInteractivePromptAssist(data, adapterId))
2713
+ if (await this.handleTipDismiss(data, adapterId, adapter))
2714
+ return;
2715
+ if (await this.handleTipUnlock(data, adapterId, adapter))
2716
+ return;
2717
+ if (await this.handleClearConfirmation(data, adapterId, adapter))
2718
+ return;
2719
+ if (await this.handleExitRestartPrompt(data, adapterId, adapter))
2720
+ return;
2721
+ if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
2316
2722
  return;
2317
2723
  if (await this.handleClassicBackendSelection(data))
2318
2724
  return;
@@ -2320,24 +2726,8 @@ export class FleetManager {
2320
2726
  return;
2321
2727
  if (await this.handleEffortSelection(data))
2322
2728
  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
- }
2729
+ if (await this.handleHangPrompt(data, adapterId, adapter))
2339
2730
  return;
2340
- }
2341
2731
  if (data.callbackData.startsWith("cancel:")) {
2342
2732
  this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
2343
2733
  return;
@@ -2461,17 +2851,7 @@ export class FleetManager {
2461
2851
  : t("collab.off.classic"));
2462
2852
  }
2463
2853
  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();
2854
+ await this.handleUpdateSlash(data, adapterId);
2475
2855
  }
2476
2856
  else if (data.command === "doctor") {
2477
2857
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
@@ -2489,12 +2869,15 @@ export class FleetManager {
2489
2869
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2490
2870
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2491
2871
  // slash_command is Discord-only; editReply renders Markdown natively.
2492
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2872
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2493
2873
  }
2494
2874
  catch (err) {
2495
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2875
+ await data.respond(t("usage.failed", err.message));
2496
2876
  }
2497
2877
  }
2878
+ else if (data.command === "tips") {
2879
+ await this.handleTipsSlash(data, adapterId);
2880
+ }
2498
2881
  else if (data.command === "status") {
2499
2882
  // Admin-gated (like the topic path): the merged table shows every
2500
2883
  // instance's cost and IPC health.
@@ -2540,6 +2923,43 @@ export class FleetManager {
2540
2923
  const result = await this.topicCommands.sendCompact(name);
2541
2924
  await data.respond(result);
2542
2925
  }
2926
+ else if (data.command === "steer") {
2927
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2928
+ if (!name) {
2929
+ await data.respond(t("classic.no_agent"));
2930
+ return;
2931
+ }
2932
+ const steerText = String(data.options?.message ?? "").trim();
2933
+ if (!steerText) {
2934
+ await data.respond(t("steer.usage"));
2935
+ return;
2936
+ }
2937
+ // chat_id/message_id stay empty: a slash interaction has no channel
2938
+ // message to react to, and an empty chat_id keeps updateLastChat from
2939
+ // rerouting the instance's replies to the slash context.
2940
+ const result = this.topicCommands.sendSteer(name, steerText, {
2941
+ chatId: "", messageId: "", username: data.username ?? "user",
2942
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2943
+ });
2944
+ await data.respond(result);
2945
+ }
2946
+ else if (data.command === "btw") {
2947
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2948
+ if (!name) {
2949
+ await data.respond(t("classic.no_agent"));
2950
+ return;
2951
+ }
2952
+ const btwText = String(data.options?.message ?? "").trim();
2953
+ if (!btwText) {
2954
+ await data.respond(t("btw.usage"));
2955
+ return;
2956
+ }
2957
+ const result = this.topicCommands.sendBtw(name, btwText, {
2958
+ chatId: "", messageId: "", username: data.username ?? "user",
2959
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2960
+ });
2961
+ await data.respond(result);
2962
+ }
2543
2963
  }, this.logger, `adapter[${adapterId}].slash_command`));
2544
2964
  await adapter.start();
2545
2965
  if (channelConfig.group_id) {
@@ -2657,6 +3077,9 @@ export class FleetManager {
2657
3077
  else if (msg.type === "instance_activity") {
2658
3078
  this.cacheInstanceActivity(name, msg.activity);
2659
3079
  }
3080
+ else if (msg.type === "instance_progress") {
3081
+ this.cacheInstanceProgress(name, msg.progress || null);
3082
+ }
2660
3083
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
2661
3084
  this.cacheInstanceExecutionState(name, msg);
2662
3085
  if (msg.type === "instance_state_response") {
@@ -2845,6 +3268,10 @@ export class FleetManager {
2845
3268
  this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
2846
3269
  return;
2847
3270
  }
3271
+ if (IGNORED_REACTION_EMOJIS.has(r.emoji)) {
3272
+ this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring non-contextual emoji reaction");
3273
+ return;
3274
+ }
2848
3275
  this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
2849
3276
  if (r.action === "add") {
2850
3277
  this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
@@ -3100,6 +3527,42 @@ export class FleetManager {
3100
3527
  await msgAdapter?.sendText(chatId, result);
3101
3528
  return;
3102
3529
  }
3530
+ // /steer — interject into the running turn. Not admin-gated: anyone who
3531
+ // can talk to this agent can send it a message; steer only changes when
3532
+ // it lands, and it keeps the full [user:] formatting (unlike /raw).
3533
+ if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
3534
+ const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3535
+ if (!steerName) {
3536
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3537
+ return;
3538
+ }
3539
+ const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
3540
+ if (!steerContent) {
3541
+ await msgAdapter?.sendText(chatId, t("steer.usage"));
3542
+ return;
3543
+ }
3544
+ const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
3545
+ await msgAdapter?.sendText(chatId, result);
3546
+ return;
3547
+ }
3548
+ // /btw — Claude Code's native side-thread command. Like /steer it is
3549
+ // not admin-gated, but it remains a distinct raw CLI command so Claude
3550
+ // does not fold the question into the active task.
3551
+ if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
3552
+ const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3553
+ if (!btwName) {
3554
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3555
+ return;
3556
+ }
3557
+ const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
3558
+ if (!btwContent) {
3559
+ await msgAdapter?.sendText(chatId, t("btw.usage"));
3560
+ return;
3561
+ }
3562
+ const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
3563
+ await msgAdapter?.sendText(chatId, result);
3564
+ return;
3565
+ }
3103
3566
  // Handle /clear command (admin only) — unlike /compact this starts a
3104
3567
  // fresh conversation and intentionally discards the current history.
3105
3568
  if (text === "/clear" || text.startsWith("/clear@")) {
@@ -3112,7 +3575,11 @@ export class FleetManager {
3112
3575
  await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3113
3576
  return;
3114
3577
  }
3115
- await msgAdapter?.sendText(chatId, await this.topicCommands.sendClear(clearName));
3578
+ if (!msgAdapter)
3579
+ return;
3580
+ const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
3581
+ if (fallback)
3582
+ await msgAdapter.sendText(chatId, fallback);
3116
3583
  return;
3117
3584
  }
3118
3585
  // Handle /cancel command
@@ -3123,7 +3590,7 @@ export class FleetManager {
3123
3590
  return;
3124
3591
  }
3125
3592
  const ok = this.cancelInstance(cancelName);
3126
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
3593
+ await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
3127
3594
  return;
3128
3595
  }
3129
3596
  // Handle /ctx command
@@ -3160,7 +3627,7 @@ export class FleetManager {
3160
3627
  const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
3161
3628
  const cmd = saveCommandForBackend(backend, filename);
3162
3629
  if (!cmd) {
3163
- await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
3630
+ await msgAdapter?.sendText(chatId, t("save.unsupported"));
3164
3631
  return;
3165
3632
  }
3166
3633
  this.pasteRawToClassicInstance(saveName, cmd);
@@ -3378,10 +3845,10 @@ export class FleetManager {
3378
3845
  return;
3379
3846
  let warning = "";
3380
3847
  if (rl.five_hour_pct >= 95) {
3381
- warning = `⚠️ ${instanceName} at ${Math.round(rl.five_hour_pct)}% of 5h rate limit. Responses may be slower.`;
3848
+ warning = t("rate_limit.five_hour", instanceName, Math.round(rl.five_hour_pct));
3382
3849
  }
3383
3850
  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.`;
3851
+ warning = t("rate_limit.weekly", instanceName, Math.round(rl.seven_day_pct));
3385
3852
  }
3386
3853
  if (!warning)
3387
3854
  return;
@@ -3505,7 +3972,7 @@ export class FleetManager {
3505
3972
  this.clearCancelButton(instanceName);
3506
3973
  }
3507
3974
  else {
3508
- void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
3975
+ void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
3509
3976
  }
3510
3977
  this.reactDone(instanceName);
3511
3978
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
@@ -3542,7 +4009,11 @@ export class FleetManager {
3542
4009
  if (silent) {
3543
4010
  const ipc = this.instanceIpcClients.get(target);
3544
4011
  if (ipc) {
3545
- ipc.send({ type: "raw_paste", content: message });
4012
+ ipc.send({
4013
+ type: "raw_paste",
4014
+ content: message,
4015
+ delivery_epoch: this.getDeliveryEpoch(target),
4016
+ });
3546
4017
  this.scheduler.recordRun(id, "delivered");
3547
4018
  this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
3548
4019
  }
@@ -3557,10 +4028,19 @@ export class FleetManager {
3557
4028
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
3558
4029
  const deliver = async () => {
3559
4030
  try {
4031
+ // A schedule has no live inbound adapter context. Seed the daemon with
4032
+ // the target instance's configured world so replies use its persona
4033
+ // after a fresh start instead of falling back to channels[0].
4034
+ const adapterId = this.getInstanceAdapterId(target);
3560
4035
  await this.deliverToInstance(target, {
3561
4036
  type: "fleet_schedule_trigger",
3562
4037
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
3563
- meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
4038
+ meta: {
4039
+ chat_id: reply_chat_id,
4040
+ thread_id: reply_thread_id,
4041
+ user: "scheduler",
4042
+ ...(adapterId ? { adapter_id: adapterId } : {}),
4043
+ },
3564
4044
  }, { waitForIdle: true });
3565
4045
  // A scheduled trigger also puts the instance to work — show a cancel button.
3566
4046
  void this.sendCancelButton(target);
@@ -3708,7 +4188,29 @@ export class FleetManager {
3708
4188
  }
3709
4189
  /** Resolve display name for an instance, fallback to instance name. */
3710
4190
  resolveDisplayName(instanceName) {
3711
- return this.fleetConfig?.instances[instanceName]?.display_name ?? instanceName;
4191
+ return this.fleetConfig?.instances[instanceName]?.display_name
4192
+ ?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
4193
+ ?? instanceName;
4194
+ }
4195
+ /** Persist identity to the instance's actual config store. Classic instances
4196
+ * are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
4197
+ setInstanceDisplayName(instanceName, displayName) {
4198
+ const fleetInstance = this.fleetConfig?.instances[instanceName];
4199
+ if (fleetInstance) {
4200
+ fleetInstance.display_name = displayName;
4201
+ this.saveFleetConfig();
4202
+ return true;
4203
+ }
4204
+ return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
4205
+ }
4206
+ setInstanceDescription(instanceName, description) {
4207
+ const fleetInstance = this.fleetConfig?.instances[instanceName];
4208
+ if (fleetInstance) {
4209
+ fleetInstance.description = description;
4210
+ this.saveFleetConfig();
4211
+ return true;
4212
+ }
4213
+ return this.classicChannels?.setDescriptionByInstance(instanceName, description) ?? false;
3712
4214
  }
3713
4215
  handleSetDisplayName(instanceName, msg) {
3714
4216
  const fleetRequestId = msg.fleetRequestId;
@@ -3721,8 +4223,10 @@ export class FleetManager {
3721
4223
  ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: "Name must be 1-30 characters" });
3722
4224
  return;
3723
4225
  }
3724
- this.fleetConfig.instances[instanceName].display_name = displayName;
3725
- this.saveFleetConfig();
4226
+ if (!this.setInstanceDisplayName(instanceName, displayName)) {
4227
+ ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
4228
+ return;
4229
+ }
3726
4230
  this.logger.info({ instanceName, displayName }, "Display name set");
3727
4231
  ipc.send({ type: "fleet_display_name_response", fleetRequestId, result: { display_name: displayName } });
3728
4232
  }
@@ -3737,8 +4241,10 @@ export class FleetManager {
3737
4241
  ipc.send({ type: "fleet_description_response", fleetRequestId, error: "Description cannot be empty" });
3738
4242
  return;
3739
4243
  }
3740
- this.fleetConfig.instances[instanceName].description = description;
3741
- this.saveFleetConfig();
4244
+ if (!this.setInstanceDescription(instanceName, description)) {
4245
+ ipc.send({ type: "fleet_description_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
4246
+ return;
4247
+ }
3742
4248
  this.logger.info({ instanceName, description: description.slice(0, 80) }, "Description set");
3743
4249
  ipc.send({ type: "fleet_description_response", fleetRequestId, result: { description } });
3744
4250
  }
@@ -3867,8 +4373,8 @@ export class FleetManager {
3867
4373
  return { error: "Fleet config not available" };
3868
4374
  if (!name || name.length > 30)
3869
4375
  return { error: "Name must be 1-30 characters" };
3870
- this.fleetConfig.instances[instance].display_name = name;
3871
- this.saveFleetConfig();
4376
+ if (!this.setInstanceDisplayName(instance, name))
4377
+ return { error: `Instance '${instance}' not found` };
3872
4378
  return { display_name: name };
3873
4379
  }
3874
4380
  async handleSetDescriptionHttp(instance, description) {
@@ -3876,8 +4382,8 @@ export class FleetManager {
3876
4382
  return { error: "Fleet config not available" };
3877
4383
  if (!description)
3878
4384
  return { error: "Description cannot be empty" };
3879
- this.fleetConfig.instances[instance].description = description;
3880
- this.saveFleetConfig();
4385
+ if (!this.setInstanceDescription(instance, description))
4386
+ return { error: `Instance '${instance}' not found` };
3881
4387
  return { description };
3882
4388
  }
3883
4389
  summarizeToolCall(tool, args) {
@@ -4009,7 +4515,8 @@ export class FleetManager {
4009
4515
  }
4010
4516
  /**
4011
4517
  * Patch only values changed in the effective config into the original YAML
4012
- * document. Unknown keys, explicit overrides and comments remain untouched.
4518
+ * document. Unknown keys and comments remain untouched; redundant
4519
+ * non-identity instance leaves are canonicalized to inheritance afterward.
4013
4520
  */
4014
4521
  saveFleetConfig(explicitPatches = []) {
4015
4522
  if (!this.fleetConfig || !this.configPath)
@@ -4025,9 +4532,9 @@ export class FleetManager {
4025
4532
  }
4026
4533
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
4027
4534
  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.
4535
+ // Settings edits are expressed against the raw config, so apply them before
4536
+ // canonicalization. A non-identity value equal to its inherited default is
4537
+ // intentionally stored as inheritance rather than an explicit duplicate.
4031
4538
  for (const patch of explicitPatches) {
4032
4539
  if (patch.remove) {
4033
4540
  // YAML's deleteIn throws when an inherited nested key has no raw parent
@@ -4042,7 +4549,24 @@ export class FleetManager {
4042
4549
  this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
4043
4550
  }
4044
4551
  }
4552
+ const rawAfterPatches = this.rawFleetDocument.toJS();
4553
+ const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
4554
+ for (const path of redundantPaths) {
4555
+ if (this.rawFleetDocument.hasIn(path))
4556
+ this.rawFleetDocument.deleteIn(path);
4557
+ // Avoid leaving empty operational maps such as `terminal: {}` while
4558
+ // preserving the instance mapping itself and all surrounding comments.
4559
+ for (let depth = path.length - 1; depth > 2; depth--) {
4560
+ const parentPath = path.slice(0, depth);
4561
+ const parent = this.rawFleetDocument.getIn(parentPath, true);
4562
+ if (!isMap(parent) || parent.items.length > 0)
4563
+ break;
4564
+ this.rawFleetDocument.deleteIn(parentPath);
4565
+ }
4566
+ }
4045
4567
  const output = String(this.rawFleetDocument);
4568
+ if (redundantPaths.length > 0)
4569
+ this.writeFleetConfigBackup(source);
4046
4570
  const tempPath = `${this.configPath}.tmp-${process.pid}`;
4047
4571
  writeFileSync(tempPath, output, "utf-8");
4048
4572
  if (existsSync(this.configPath))
@@ -4050,7 +4574,36 @@ export class FleetManager {
4050
4574
  renameSync(tempPath, this.configPath);
4051
4575
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
4052
4576
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
4053
- this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
4577
+ this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
4578
+ }
4579
+ /** One-time upgrade migration; invalid YAML is never rewritten. */
4580
+ slimFleetConfigAtStartup() {
4581
+ const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
4582
+ if (redundantPaths.length === 0)
4583
+ return;
4584
+ const validation = validateFleetConfig(this.rawFleetConfig);
4585
+ if (!validation.valid) {
4586
+ this.logger.warn({ errors: validation.errors, redundantDefaults: redundantPaths.length }, "Skipping fleet.yaml default slimming because the raw config is invalid");
4587
+ return;
4588
+ }
4589
+ try {
4590
+ this.saveFleetConfig();
4591
+ this.logger.info({ strippedDefaults: redundantPaths.length, backup: `${this.configPath}.bak` }, "Slimmed redundant instance defaults in fleet.yaml");
4592
+ }
4593
+ catch (err) {
4594
+ // A migration must not turn a previously bootable fleet into an outage.
4595
+ this.logger.warn({ err }, "Could not slim fleet.yaml; continuing with the original config");
4596
+ }
4597
+ }
4598
+ writeFleetConfigBackup(source) {
4599
+ if (!this.configPath)
4600
+ return;
4601
+ const backupPath = `${this.configPath}.bak`;
4602
+ const tempPath = `${backupPath}.tmp-${process.pid}`;
4603
+ writeFileSync(tempPath, source, "utf-8");
4604
+ if (existsSync(this.configPath))
4605
+ chmodSync(tempPath, statSync(this.configPath).mode);
4606
+ renameSync(tempPath, backupPath);
4054
4607
  }
4055
4608
  patchFleetDocument(document, path, before, after) {
4056
4609
  if (Object.is(before, after))
@@ -4102,7 +4655,12 @@ export class FleetManager {
4102
4655
  currentNode.value = after;
4103
4656
  }
4104
4657
  else {
4105
- document.setIn(path, after);
4658
+ // Use a YAML collection node for newly-added objects. Passing the raw
4659
+ // object creates a scalar wrapper: it serializes, but nested hasIn /
4660
+ // deleteIn cannot traverse it (notably when slimming create_instance).
4661
+ document.setIn(path, after !== null && typeof after === "object"
4662
+ ? document.createNode(after)
4663
+ : after);
4106
4664
  }
4107
4665
  }
4108
4666
  }
@@ -4193,7 +4751,7 @@ export class FleetManager {
4193
4751
  from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
4194
4752
  });
4195
4753
  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})`);
4754
+ this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
4197
4755
  }
4198
4756
  else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
4199
4757
  // Recover: switch back to primary
@@ -4204,7 +4762,7 @@ export class FleetManager {
4204
4762
  restored: primaryModel, five_hour_pct: fiveHourPct,
4205
4763
  });
4206
4764
  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}`);
4765
+ this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
4208
4766
  }
4209
4767
  }
4210
4768
  toggleFleetCollab(instanceName) {
@@ -4258,9 +4816,9 @@ export class FleetManager {
4258
4816
  if (output)
4259
4817
  return output;
4260
4818
  if (e.code === "ENOENT") {
4261
- return "Backend doctor unavailable — agend CLI not found in PATH";
4819
+ return t("doctor.cli_missing");
4262
4820
  }
4263
- return stripAnsi(e.message ?? "").trim() || "Doctor failed";
4821
+ return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
4264
4822
  }
4265
4823
  }
4266
4824
  /** Drop event/activity rows older than the retention window. Best-effort. */
@@ -4362,7 +4920,7 @@ export class FleetManager {
4362
4920
  this.fleetErrorNotices.delete(oldest);
4363
4921
  }
4364
4922
  const body = suppressed > 0
4365
- ? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
4923
+ ? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
4366
4924
  : text;
4367
4925
  // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
4368
4926
  // and a fleet-level fault is exactly when the General may be down. The topic
@@ -4412,81 +4970,122 @@ export class FleetManager {
4412
4970
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
4413
4971
  }
4414
4972
  }
4415
- interactivePromptLabel(kind) {
4416
- const key = `interactive.kind.${kind}`;
4417
- const translated = t(key);
4418
- return translated === key ? kind.replaceAll("_", " ") : translated;
4419
- }
4973
+ // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
4974
+ //
4975
+ // One shared lifecycle for every "notification with decision buttons":
4976
+ // post with a 128-bit nonce, arm a bounded expiry, bind the click to the
4977
+ // exact adapter+chat+thread+message that created it, require fleet admin for
4978
+ // actions that mutate runtime state (Tips are the harmless exception),
4979
+ // consume exactly once. The features differ only in what they post
4980
+ // and what a consumed click does.
4420
4981
  /**
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.
4982
+ * Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
4983
+ * The entry is registered before the send and rolled back if the send
4984
+ * fails, so a nonce in the map always refers to a message that exists (or
4985
+ * is about to). Returns the nonce, or null when the alert could not be sent.
4427
4986
  */
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);
4987
+ async postNonceButtonPrompt(opts) {
4988
+ // 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
4989
+ // callback_data cap still holds: longest id is "interactive-assist:" (19)
4990
+ // + 32 hex + ":confirm" (8) = 59 bytes.
4991
+ const nonce = randomBytes(16).toString("hex");
4448
4992
  const entry = {
4449
- instanceName,
4450
- generalName,
4451
- kind,
4452
- adapterId,
4453
- adapter,
4454
- chatId,
4455
- threadId,
4993
+ prefix: opts.prefix,
4994
+ instanceName: opts.instanceName,
4995
+ adapterId: opts.adapterId,
4996
+ adapter: opts.adapter,
4997
+ chatId: opts.chatId,
4998
+ threadId: opts.threadId,
4999
+ expiredText: opts.expiredText,
5000
+ ...opts.extra,
4456
5001
  };
4457
5002
  entry.timer = setTimeout(() => {
4458
- const pending = this.pendingInteractivePromptAssists.get(nonce);
5003
+ const pending = this.pendingNonceButtons.get(nonce);
4459
5004
  if (pending !== entry)
4460
5005
  return;
4461
- this.pendingInteractivePromptAssists.delete(nonce);
5006
+ this.pendingNonceButtons.delete(nonce);
4462
5007
  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"));
5008
+ 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
5009
  }
4465
- }, INTERACTIVE_ASSIST_TIMEOUT_MS);
5010
+ }, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
4466
5011
  entry.timer.unref?.();
4467
- this.pendingInteractivePromptAssists.set(nonce, entry);
5012
+ this.pendingNonceButtons.set(nonce, entry);
4468
5013
  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);
5014
+ const sent = await opts.adapter.notifyAlert(opts.chatId, {
5015
+ type: opts.alertType,
5016
+ instanceName: opts.instanceName,
5017
+ message: opts.message,
5018
+ choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
5019
+ }, opts.threadId ? { threadId: opts.threadId } : undefined);
4478
5020
  entry.messageId = sent.messageId;
5021
+ return nonce;
4479
5022
  }
4480
5023
  catch (err) {
4481
- this.pendingInteractivePromptAssists.delete(nonce);
5024
+ this.pendingNonceButtons.delete(nonce);
4482
5025
  if (entry.timer)
4483
5026
  clearTimeout(entry.timer);
4484
- this.logger.warn({ err, instanceName, generalName }, "Failed to send interactive prompt assistance controls");
5027
+ this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
5028
+ return null;
4485
5029
  }
4486
5030
  }
4487
- async retireInteractivePromptButtons(pending, messageId, text) {
5031
+ /**
5032
+ * Claim a nonce-armed callback exactly once.
5033
+ *
5034
+ * Returns:
5035
+ * - null — the callback is not for this prefix; try the next handler
5036
+ * - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
5037
+ * - the entry+action — the click is authorized and now claimed (removed
5038
+ * from the map before any await, so double clicks cannot act twice)
5039
+ *
5040
+ * A stale click (expired nonce, or a pre-upgrade button whose payload no
5041
+ * longer parses) collapses the clicked message so the dead button stops
5042
+ * inviting clicks — the same courtesy the cancel button extends.
5043
+ */
5044
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
5045
+ if (!data.callbackData.startsWith(prefix))
5046
+ return null;
5047
+ const match = data.callbackData.match(actionRe);
5048
+ let pending = match ? this.pendingNonceButtons.get(match[1]) : undefined;
5049
+ // The map is shared across prompt kinds. A nonce that resolves to an entry
5050
+ // of a DIFFERENT kind is not a usable capability for this handler — treat
5051
+ // it as stale rather than acting across kinds (fail closed).
5052
+ if (pending && pending.prefix !== prefix)
5053
+ pending = undefined;
5054
+ if (!match || !pending) {
5055
+ const adapter = receivingAdapter ?? this.adapter;
5056
+ adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
5057
+ return "consumed";
5058
+ }
5059
+ // Bind the capability to the exact message/world that created it. Telegram
5060
+ // keyboards are visible to everyone, so mutating actions require fleet admin;
5061
+ // a Tip acknowledgement only records that the shared content was read.
5062
+ const isAuthorized = data.userId
5063
+ ? pending.allowAnyUser
5064
+ ? true
5065
+ : pending.authChannelId
5066
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
5067
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
5068
+ : false;
5069
+ if (pending.adapterId !== callbackAdapterId
5070
+ || data.chatId !== pending.chatId
5071
+ || (pending.threadId != null && data.threadId !== pending.threadId)
5072
+ || (pending.messageId != null && data.messageId !== pending.messageId)
5073
+ || !isAuthorized) {
5074
+ // Deliberately does NOT consume the nonce: the real admin can still click.
5075
+ this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
5076
+ return "consumed";
5077
+ }
5078
+ // Claim before any await: double clicks and duplicate callback delivery can
5079
+ // never act twice.
5080
+ this.pendingNonceButtons.delete(match[1]);
5081
+ if (pending.timer)
5082
+ clearTimeout(pending.timer);
5083
+ return { entry: pending, action: match[2] };
5084
+ }
5085
+ /** Collapse a consumed prompt's buttons into a final status line. */
5086
+ async retireNonceButtons(pending, messageId, text) {
4488
5087
  if (!pending.adapter.editMessageRemoveButtons) {
4489
- this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove interactive prompt controls");
5088
+ this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
4490
5089
  return;
4491
5090
  }
4492
5091
  try {
@@ -4494,58 +5093,375 @@ export class FleetManager {
4494
5093
  }
4495
5094
  catch (err) {
4496
5095
  // 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");
5096
+ // undo that or make the button actionable again.
5097
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
4499
5098
  }
4500
5099
  }
4501
- /** Consume a General assist button exactly once. */
4502
- async handleInteractivePromptAssist(data, callbackAdapterId) {
4503
- if (!data.callbackData.startsWith(INTERACTIVE_ASSIST_CALLBACK_PREFIX))
5100
+ tipsEnabled() {
5101
+ return this.fleetConfig?.defaults.tips !== false;
5102
+ }
5103
+ readTipState() {
5104
+ if (!this.scheduler)
5105
+ return null;
5106
+ try {
5107
+ return {
5108
+ dismissed: this.scheduler.db.listDismissedTipIds(),
5109
+ advancedUnlocked: this.scheduler.db.isAdvancedTipsUnlocked(),
5110
+ };
5111
+ }
5112
+ catch (err) {
5113
+ // Tips are additive. A damaged/locked scheduler DB must never block fleet
5114
+ // startup, update completion, or a General command.
5115
+ this.logger.warn({ err }, "Failed to read tip state");
5116
+ return null;
5117
+ }
5118
+ }
5119
+ pickAvailableTip() {
5120
+ const state = this.readTipState();
5121
+ return state ? selectTip(state.dismissed, Math.random, state.advancedUnlocked) : null;
5122
+ }
5123
+ tipText(tip) {
5124
+ return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
5125
+ }
5126
+ formatTip(tip) {
5127
+ return `💡 ${t("tips.label")}: ${this.tipText(tip)}`;
5128
+ }
5129
+ /** Post one fresh nonce-armed tip in a known General channel. */
5130
+ async promptTip(generalName, adapter, chatId, threadId) {
5131
+ const state = this.readTipState();
5132
+ if (!state)
5133
+ return "unavailable";
5134
+ if (!state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5135
+ return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
5136
+ ? "posted"
5137
+ : "unavailable";
5138
+ }
5139
+ const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked);
5140
+ if (!tip)
5141
+ return this.scheduler ? "empty" : "unavailable";
5142
+ const nonce = await this.postNonceButtonPrompt({
5143
+ prefix: TIP_DISMISS_CALLBACK_PREFIX,
5144
+ alertType: "tip",
5145
+ instanceName: generalName,
5146
+ adapter,
5147
+ adapterId: adapter.id,
5148
+ chatId,
5149
+ threadId,
5150
+ message: this.formatTip(tip),
5151
+ choices: [{ action: "dismiss", label: t("tips.dismiss") }],
5152
+ // Expiry removes only the stale button; the useful tip remains readable.
5153
+ expiredText: this.formatTip(tip),
5154
+ extra: { allowAnyUser: true, tipId: tip.id },
5155
+ timeoutMs: TIP_BUTTON_TIMEOUT_MS,
5156
+ });
5157
+ return nonce ? "posted" : "unavailable";
5158
+ }
5159
+ async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
5160
+ const nonce = await this.postNonceButtonPrompt({
5161
+ prefix: TIP_UNLOCK_CALLBACK_PREFIX,
5162
+ alertType: "tip",
5163
+ instanceName: generalName,
5164
+ adapter,
5165
+ adapterId: adapter.id,
5166
+ chatId,
5167
+ threadId,
5168
+ message: t("tips.advanced.unlock_prompt"),
5169
+ choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
5170
+ expiredText: t("tips.advanced.expired"),
5171
+ extra: { allowAnyUser: true },
5172
+ timeoutMs: TIP_BUTTON_TIMEOUT_MS,
5173
+ });
5174
+ return nonce !== null;
5175
+ }
5176
+ async sendTipToGeneral() {
5177
+ if (!this.tipsEnabled())
5178
+ return;
5179
+ const generalName = this.findGeneralInstance();
5180
+ if (!generalName)
5181
+ return;
5182
+ const adapter = this.getAdapterForInstance(generalName);
5183
+ const chatId = this.getGroupIdForInstance(generalName);
5184
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5185
+ if (!adapter || !chatId)
5186
+ return;
5187
+ const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
5188
+ if (result === "unavailable") {
5189
+ this.logger.warn({ generalName }, "Daily tip could not be posted");
5190
+ }
5191
+ }
5192
+ async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
5193
+ const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss)$/, data, callbackAdapterId, receivingAdapter);
5194
+ if (claimed === null)
4504
5195
  return false;
4505
- const match = data.callbackData.match(/^interactive-assist:([0-9a-f]+):(confirm|cancel)$/);
4506
- if (!match)
5196
+ if (claimed === "consumed")
4507
5197
  return true;
4508
- const pending = this.pendingInteractivePromptAssists.get(match[1]);
4509
- if (!pending)
5198
+ const { entry: pending } = claimed;
5199
+ if (!pending.tipId || !data.userId || !this.scheduler) {
5200
+ this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
5201
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
4510
5202
  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");
5203
+ }
5204
+ try {
5205
+ this.scheduler.db.dismissTip(data.userId, pending.tipId);
5206
+ }
5207
+ catch (err) {
5208
+ this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip dismissal");
5209
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
4520
5210
  return true;
4521
5211
  }
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];
5212
+ this.eventLog?.insert(pending.instanceName, "tip_dismissed", {
5213
+ tipId: pending.tipId,
5214
+ userId: data.userId,
5215
+ });
5216
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.dismissed"));
5217
+ const state = this.readTipState();
5218
+ if (state && !state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5219
+ await this.promptAdvancedTipUnlock(pending.instanceName, pending.adapter, pending.chatId, pending.threadId);
5220
+ }
5221
+ return true;
5222
+ }
5223
+ async handleTipUnlock(data, callbackAdapterId, receivingAdapter) {
5224
+ const claimed = this.consumeNonceCallback(TIP_UNLOCK_CALLBACK_PREFIX, /^tip-unlock:([0-9a-f]+):(unlock)$/, data, callbackAdapterId, receivingAdapter);
5225
+ if (claimed === null)
5226
+ return false;
5227
+ if (claimed === "consumed")
5228
+ return true;
5229
+ const { entry: pending } = claimed;
5230
+ if (!data.userId || !this.scheduler) {
5231
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5232
+ return true;
5233
+ }
5234
+ try {
5235
+ this.scheduler.db.unlockAdvancedTips(data.userId);
5236
+ }
5237
+ catch (err) {
5238
+ this.logger.warn({ err, userId: data.userId }, "Failed to unlock advanced tips");
5239
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5240
+ return true;
5241
+ }
5242
+ this.eventLog?.insert(pending.instanceName, "tips_advanced_unlocked", { userId: data.userId });
5243
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.advanced.unlocked"));
5244
+ return true;
5245
+ }
5246
+ /** Post the destructive `/clear` confirmation in the invoking channel. */
5247
+ async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
5248
+ if (!this.topicCommands.supportsClear(instanceName))
5249
+ return t("clear.unsupported");
5250
+ const adapterId = adapter.id;
5251
+ if (!adapterId)
5252
+ return t("clear.prompt_unavailable");
5253
+ const nonce = await this.postNonceButtonPrompt({
5254
+ prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
5255
+ alertType: "clear_confirm",
5256
+ instanceName,
5257
+ adapter,
5258
+ adapterId,
5259
+ chatId,
5260
+ threadId,
5261
+ message: t("clear.confirm_message", instanceName),
5262
+ choices: [
5263
+ { action: "confirm", label: t("clear.confirm") },
5264
+ { action: "cancel", label: t("clear.cancel") },
5265
+ ],
5266
+ expiredText: t("clear.expired", instanceName),
5267
+ extra: { authChannelId: channelId },
5268
+ timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
5269
+ });
5270
+ return nonce ? null : t("clear.prompt_unavailable");
5271
+ }
5272
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
5273
+ async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
5274
+ const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
5275
+ if (claimed === null)
5276
+ return false;
5277
+ if (claimed === "consumed")
5278
+ return true;
5279
+ const { entry: pending, action } = claimed;
5280
+ this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
5281
+ if (action === "cancel") {
5282
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
5283
+ return true;
5284
+ }
5285
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
5286
+ try {
5287
+ const result = await this.topicCommands.sendClear(pending.instanceName);
5288
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
5289
+ }
5290
+ catch (err) {
5291
+ const message = err instanceof Error ? err.message : String(err);
5292
+ this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
5293
+ 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"));
5294
+ }
5295
+ return true;
5296
+ }
5297
+ /**
5298
+ * Drop every pending prompt that refers to an instance being stopped or
5299
+ * restarted — a restart offer for an instance the operator just restarted
5300
+ * (or an assist for one they stopped) must not stay clickable for the rest
5301
+ * of its 15 minutes.
5302
+ */
5303
+ clearNoncePromptsForInstance(instanceName) {
5304
+ for (const [nonce, entry] of this.pendingNonceButtons) {
5305
+ if (entry.instanceName !== instanceName)
5306
+ continue;
5307
+ this.pendingNonceButtons.delete(nonce);
5308
+ if (entry.timer)
5309
+ clearTimeout(entry.timer);
5310
+ if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
5311
+ entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
5312
+ .catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
5313
+ }
5314
+ }
5315
+ }
5316
+ /** A clean exit is intentional from the CLI's perspective, but often not from
5317
+ * the operator's. Keep the instance notice passive and put the action in the
5318
+ * same-world General topic where an administrator can make the choice. */
5319
+ async notifyNormalExit(instanceName) {
5320
+ this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
5321
+ const worldId = this.getInstanceAdapterId(instanceName);
5322
+ const generalName = this.findGeneralInstance(worldId);
5323
+ if (!generalName) {
5324
+ this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
5325
+ return;
5326
+ }
5327
+ const adapterId = this.getInstanceAdapterId(generalName);
5328
+ const adapter = this.getAdapterForInstance(generalName);
5329
+ const chatId = this.getGroupIdForInstance(generalName);
5330
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5331
+ const threadId = topicId != null ? String(topicId) : undefined;
5332
+ if (!adapter || !adapterId || !chatId) {
5333
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
5334
+ return;
5335
+ }
5336
+ await this.postNonceButtonPrompt({
5337
+ prefix: EXIT_RESTART_CALLBACK_PREFIX,
5338
+ alertType: "exit_restart",
5339
+ instanceName,
5340
+ adapter,
5341
+ adapterId,
5342
+ chatId,
5343
+ threadId,
5344
+ message: t("exit.general_notice", instanceName),
5345
+ choices: [
5346
+ { action: "restart", label: t("exit.restart") },
5347
+ { action: "ignore", label: t("exit.ignore") },
5348
+ ],
5349
+ expiredText: t("exit.expired", instanceName),
5350
+ });
5351
+ }
5352
+ /** Consume a clean-exit Restart/Ignore button exactly once. */
5353
+ async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
5354
+ const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
5355
+ if (claimed === null)
5356
+ return false;
5357
+ if (claimed === "consumed")
5358
+ return true;
5359
+ const { entry: pending, action } = claimed;
5360
+ this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
5361
+ if (action === "ignore") {
5362
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
5363
+ return true;
5364
+ }
5365
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
5366
+ try {
5367
+ await this.restartSingleInstance(pending.instanceName);
5368
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
5369
+ }
5370
+ catch (err) {
5371
+ const message = err instanceof Error ? err.message : String(err);
5372
+ this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
5373
+ 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"));
5374
+ }
5375
+ return true;
5376
+ }
5377
+ interactivePromptLabel(kind) {
5378
+ const key = `interactive.kind.${kind}`;
5379
+ const translated = t(key);
5380
+ return translated === key ? kind.replaceAll("_", " ") : translated;
5381
+ }
5382
+ /**
5383
+ * Surface a stable terminal prompt in both places that matter:
5384
+ * - the blocked instance gets a plain pointer to General;
5385
+ * - General gets one-shot Confirm/Cancel controls on its own bound adapter.
5386
+ *
5387
+ * Only the prompt category crosses channels. The captured terminal line can
5388
+ * contain usernames or secret-adjacent text and stays in daemon.log.
5389
+ */
5390
+ async notifyInteractivePrompt(instanceName, kind) {
5391
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
5392
+ // Multi-world fleets can have one General per bot/platform. Route the assist
5393
+ // request to the General bound to the same adapter as the blocked instance.
5394
+ const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
5395
+ if (!generalName) {
5396
+ this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
5397
+ return;
5398
+ }
5399
+ const adapterId = this.getInstanceAdapterId(generalName);
5400
+ const adapter = this.getAdapterForInstance(generalName);
5401
+ const chatId = this.getGroupIdForInstance(generalName);
5402
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5403
+ const threadId = topicId != null ? String(topicId) : undefined;
5404
+ if (!adapter || !adapterId || !chatId) {
5405
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
5406
+ return;
5407
+ }
5408
+ const label = this.interactivePromptLabel(kind);
5409
+ await this.postNonceButtonPrompt({
5410
+ prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
5411
+ alertType: "interactive_prompt",
5412
+ instanceName,
5413
+ adapter,
5414
+ adapterId,
5415
+ chatId,
5416
+ threadId,
5417
+ message: t("interactive.general_notice", instanceName, label),
5418
+ choices: [
5419
+ { action: "confirm", label: t("interactive.confirm") },
5420
+ { action: "cancel", label: t("interactive.cancel") },
5421
+ ],
5422
+ expiredText: t("interactive.expired", instanceName),
5423
+ extra: { generalName, promptKind: kind },
5424
+ });
5425
+ }
5426
+ /** Consume a General assist button exactly once. */
5427
+ async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
5428
+ const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
5429
+ if (claimed === null)
5430
+ return false;
5431
+ if (claimed === "consumed")
5432
+ return true;
5433
+ const { entry: pending, action } = claimed;
4528
5434
  this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
4529
5435
  action,
4530
5436
  userId: data.userId,
4531
5437
  generalName: pending.generalName,
4532
5438
  });
4533
5439
  if (action === "cancel") {
4534
- await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
5440
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
5441
+ return true;
5442
+ }
5443
+ // Fail closed on a malformed entry. generalName is set at posting time for
5444
+ // every interactive-assist entry; its absence means entry confusion, and
5445
+ // falling back to the blocked instance would type the assist text into the
5446
+ // very terminal prompt this feature exists to keep humans in front of.
5447
+ const generalName = pending.generalName;
5448
+ if (!generalName) {
5449
+ this.logger.error({ instanceName: pending.instanceName }, "Interactive-assist entry has no General target — refusing to deliver");
5450
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName));
4535
5451
  return true;
4536
5452
  }
4537
5453
  // Injecting into General while General itself is blocked would type into the
4538
5454
  // 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));
5455
+ if (pending.instanceName === generalName) {
5456
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
4541
5457
  return true;
4542
5458
  }
4543
- await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
5459
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
4544
5460
  try {
4545
- await this.deliverToInstance(pending.generalName, {
5461
+ await this.deliverToInstance(generalName, {
4546
5462
  type: "fleet_inbound",
4547
- content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.kind)),
4548
- targetSession: pending.generalName,
5463
+ content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
5464
+ targetSession: generalName,
4549
5465
  meta: {
4550
5466
  chat_id: pending.chatId,
4551
5467
  message_id: pending.messageId ?? data.messageId,
@@ -4604,7 +5520,7 @@ export class FleetManager {
4604
5520
  const force = target.kind === "classic" && !!data.options?.force;
4605
5521
  const cmd = saveCommandForBackend(backend, filename, force);
4606
5522
  if (!cmd) {
4607
- await data.respond(SAVE_UNSUPPORTED_MSG);
5523
+ await data.respond(t("save.unsupported"));
4608
5524
  return;
4609
5525
  }
4610
5526
  if (target.kind === "classic") {
@@ -4657,7 +5573,7 @@ export class FleetManager {
4657
5573
  }
4658
5574
  return false;
4659
5575
  }
4660
- async sendCancelButton(instanceName, correlationId) {
5576
+ async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
4661
5577
  // Post first, retire after (see the tail of this method). Retiring up front
4662
5578
  // meant that from the delete until the new message came back — a chat API
4663
5579
  // round trip, and every reply goes through here — the instance had NO live
@@ -4718,6 +5634,10 @@ export class FleetManager {
4718
5634
  // not re-edit identical text — which put a "(edited)" mark on Discord
4719
5635
  // with nothing visibly changed.
4720
5636
  lastProgressText: "👀 處理中…",
5637
+ // A reply can re-post the same in-flight bubble below the reply. Carry
5638
+ // its current list into that replacement; fresh inbound work must start
5639
+ // empty even if the daemon's reset broadcast is still in flight.
5640
+ toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
4721
5641
  };
4722
5642
  this.startProgressTicker(entry);
4723
5643
  // Idle-check backstop: every 5min, if the instance is idle, retire the
@@ -4771,16 +5691,28 @@ export class FleetManager {
4771
5691
  static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
4772
5692
  if (elapsedMs < minElapsedMs)
4773
5693
  return "👀 處理中…";
5694
+ const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
5695
+ const detail = FleetManager.sanitizeActivity(activity);
5696
+ return detail
5697
+ ? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
5698
+ : `⏳ 處理中… (已進行 ${elapsed})`;
5699
+ }
5700
+ /** Render elapsed time consistently in live and retained progress bubbles. */
5701
+ static formatProgressElapsed(elapsedMs) {
4774
5702
  const totalSeconds = Math.floor(elapsedMs / 1000);
4775
5703
  const minutes = Math.floor(totalSeconds / 60);
4776
5704
  const seconds = totalSeconds % 60;
4777
- const elapsed = minutes >= 60
5705
+ return minutes >= 60
4778
5706
  ? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
4779
5707
  : `${minutes}m ${String(seconds).padStart(2, "0")}s`;
4780
- const detail = FleetManager.sanitizeActivity(activity);
4781
- return detail
4782
- ? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
4783
- : `⏳ 處理中… (已進行 ${elapsed})`;
5708
+ }
5709
+ /** Final read-only form of a bubble that contains semantic tool history.
5710
+ * Neutral wording is intentional: the same retirement primitive is used for
5711
+ * normal completion, a mid-turn bubble replacement, and user cancellation,
5712
+ * so claiming every retained checkpoint is "completed" would be false. */
5713
+ composeRetiredBubbleText(entry) {
5714
+ const elapsed = FleetManager.formatProgressElapsed(Date.now() - (entry.startedAt ?? Date.now()));
5715
+ return `🧾 工具歷程 (記錄至 ${elapsed})\n${entry.toolProgress}`;
4784
5716
  }
4785
5717
  /**
4786
5718
  * Make a tool summary safe to paste into a channel message.
@@ -4813,6 +5745,88 @@ export class FleetManager {
4813
5745
  else
4814
5746
  this.instanceActivity.delete(name);
4815
5747
  }
5748
+ /**
5749
+ * Cache the turn's tool-progress list and push it into the instance's live
5750
+ * bubble, coalesced so a burst of tool events cannot flood the Bot API
5751
+ * (Telegram's flood limit is per-chat across ALL forum topics, so edits are
5752
+ * rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
5753
+ */
5754
+ cacheInstanceProgress(name, progress) {
5755
+ if (progress)
5756
+ this.instanceProgress.set(name, progress);
5757
+ else
5758
+ this.instanceProgress.delete(name);
5759
+ for (const entry of this.cancelButtons.values()) {
5760
+ if (entry.instanceName !== name)
5761
+ continue;
5762
+ // Empty means the daemon completed/reset the turn. Clear the live cache
5763
+ // for the next turn, but retain this bubble's last non-empty list so its
5764
+ // retirement can preserve the history instead of deleting the message.
5765
+ if (progress)
5766
+ entry.toolProgress = progress;
5767
+ this.scheduleProgressEdit(entry);
5768
+ }
5769
+ }
5770
+ /** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
5771
+ scheduleProgressEdit(entry) {
5772
+ if (entry.retiring)
5773
+ return;
5774
+ const since = Date.now() - (entry.lastProgressEditAt ?? 0);
5775
+ if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
5776
+ this.refreshBubble(entry);
5777
+ return;
5778
+ }
5779
+ if (entry.progressEditTimer)
5780
+ return; // trailing edit already scheduled
5781
+ entry.progressEditTimer = setTimeout(() => {
5782
+ entry.progressEditTimer = undefined;
5783
+ this.refreshBubble(entry);
5784
+ }, TOOL_PROGRESS_EDIT_MIN_MS - since);
5785
+ entry.progressEditTimer.unref?.();
5786
+ }
5787
+ /**
5788
+ * The ONE composer for the bubble text. Both writers — the elapsed-time
5789
+ * ticker and the tool-progress push — go through here; two independent
5790
+ * renderers editing the same message is how the progress list used to get
5791
+ * wiped by the next elapsed tick (#528 trap 2).
5792
+ */
5793
+ composeBubbleText(entry) {
5794
+ return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
5795
+ }
5796
+ /** Pure composition of header + tool list, exposed for tests. */
5797
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
5798
+ const header = FleetManager.progressText(elapsedMs,
5799
+ // The single-line activity detail is redundant once a tool list exists.
5800
+ progress ? undefined : activity, minElapsedMs);
5801
+ return progress ? `${header}\n${progress}` : header;
5802
+ }
5803
+ /** Recompose and edit the bubble in place; skips when nothing changed. */
5804
+ refreshBubble(entry) {
5805
+ if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
5806
+ clearInterval(entry.progressTimer);
5807
+ return;
5808
+ }
5809
+ const text = this.composeBubbleText(entry);
5810
+ if (text === entry.lastProgressText)
5811
+ return; // nothing changed — skip the API call
5812
+ const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5813
+ if (!adapter?.editAlert)
5814
+ return;
5815
+ entry.lastProgressText = text;
5816
+ entry.lastProgressEditAt = Date.now();
5817
+ adapter.editAlert(entry.chatId, entry.messageId, {
5818
+ type: "cancel",
5819
+ instanceName: entry.instanceName,
5820
+ message: text,
5821
+ choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5822
+ }, entry.threadId ? { threadId: entry.threadId } : undefined)
5823
+ .catch(err => {
5824
+ // A failed progress edit must never escalate: the button still works and
5825
+ // the next tick retries. Common causes are a deleted message or a
5826
+ // rate limit.
5827
+ this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5828
+ });
5829
+ }
4816
5830
  /**
4817
5831
  * Refresh the button's text in place while the instance keeps working.
4818
5832
  *
@@ -4830,31 +5844,7 @@ export class FleetManager {
4830
5844
  return PROGRESS_MIN_ELAPSED_MS;
4831
5845
  }
4832
5846
  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
- };
5847
+ const tick = () => this.refreshBubble(entry);
4858
5848
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
4859
5849
  entry.progressTimer.unref?.();
4860
5850
  // One extra tick right when the threshold passes, so a 30s threshold shows
@@ -4868,8 +5858,11 @@ export class FleetManager {
4868
5858
  }
4869
5859
  /**
4870
5860
  * 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.
5861
+ * it has not, retire its button. The daemon is asked for a fresh pane capture
5862
+ * before deciding. Reading only the transition cache stranded the first
5863
+ * post-restart bubble when its startup "working" report never got a matching
5864
+ * idle edge. Re-arming replaces the previous timer, so a burst of replies ends
5865
+ * with exactly one pending check.
4873
5866
  */
4874
5867
  armReplyGrace(instanceName) {
4875
5868
  for (const entry of this.cancelButtons.values()) {
@@ -4881,14 +5874,26 @@ export class FleetManager {
4881
5874
  entry.replyGraceTimer = undefined;
4882
5875
  if (!this.cancelButtons.has(entry.messageId))
4883
5876
  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);
5877
+ void this.finishReplyGrace(instanceName, entry);
4888
5878
  }, REPLY_RETIRE_GRACE_MS);
4889
5879
  entry.replyGraceTimer.unref?.();
4890
5880
  }
4891
5881
  }
5882
+ async finishReplyGrace(instanceName, entry) {
5883
+ const refreshed = await this.refreshInstanceExecutionState(instanceName, REPLY_STATE_REFRESH_TIMEOUT_MS);
5884
+ if (!this.cancelButtons.has(entry.messageId) || entry.retiring)
5885
+ return;
5886
+ if (!refreshed) {
5887
+ // Fail safe: without an authoritative answer, retain a potentially live
5888
+ // Cancel button. The 5-minute/30-minute/24-hour safety nets still apply.
5889
+ this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel reply-grace state refresh timed out");
5890
+ return;
5891
+ }
5892
+ if (!this.getInstanceIdle(instanceName))
5893
+ return; // genuine long run keeps its button
5894
+ this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
5895
+ this.retireButton(entry);
5896
+ }
4892
5897
  /** Retire (delete) every cancel button belonging to an instance. */
4893
5898
  retireInstanceButtons(instanceName) {
4894
5899
  // Snapshot first — retireButton may delete entries from the map on success.
@@ -4910,7 +5915,7 @@ export class FleetManager {
4910
5915
  this.deleteButtonMessage(entry)
4911
5916
  .then(() => {
4912
5917
  this.discardButton(entry);
4913
- this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
5918
+ this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
4914
5919
  })
4915
5920
  .catch((err) => this.scheduleButtonRetry(entry, err));
4916
5921
  }
@@ -4922,6 +5927,8 @@ export class FleetManager {
4922
5927
  clearInterval(entry.idleCheckTimer);
4923
5928
  if (entry.progressTimer)
4924
5929
  clearInterval(entry.progressTimer);
5930
+ if (entry.progressEditTimer)
5931
+ clearTimeout(entry.progressEditTimer);
4925
5932
  if (entry.replyGraceTimer)
4926
5933
  clearTimeout(entry.replyGraceTimer);
4927
5934
  this.cancelButtons.delete(entry.messageId);
@@ -4999,12 +6006,21 @@ export class FleetManager {
4999
6006
  // Continue the same retire cycle (bypass the retiring-guard in retireButton).
5000
6007
  entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
5001
6008
  }
5002
- /** Delete one button's message via its own adapter. Resolves on success,
6009
+ /** Retire one cancel bubble via its own adapter. A bubble that accumulated
6010
+ * tool progress is kept as a read-only history message; a plain bubble keeps
6011
+ * the legacy delete/edit-to-checkmark behavior. Resolves on success and
5003
6012
  * rejects on failure so the caller can retry. */
5004
6013
  deleteButtonMessage(e) {
5005
6014
  const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
5006
6015
  if (!adapter)
5007
6016
  return Promise.reject(new Error("no adapter for cancel button"));
6017
+ if (e.toolProgress && adapter.editMessageRemoveButtons) {
6018
+ return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeRetiredBubbleText(e), e.threadId);
6019
+ }
6020
+ // All production adapters currently implement editMessageRemoveButtons.
6021
+ // A third-party adapter without it degrades to the legacy delete behavior:
6022
+ // removing a live Cancel control is safer than retaining an actionable,
6023
+ // untracked button forever.
5008
6024
  if (adapter.deleteMessage)
5009
6025
  return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
5010
6026
  if (adapter.editMessageRemoveButtons)
@@ -5056,11 +6072,27 @@ export class FleetManager {
5056
6072
  const daemon = this.daemons.get(instanceName);
5057
6073
  if (!daemon)
5058
6074
  return false;
6075
+ const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
6076
+ daemon.clearPendingDeliveries?.(deliveryEpoch);
5059
6077
  daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
5060
6078
  this.lastInboundMsg.delete(instanceName);
5061
6079
  this.clearCancelButton(instanceName);
5062
6080
  return true;
5063
6081
  }
6082
+ getDeliveryEpoch(instanceName) {
6083
+ return this.deliveryEpochs.get(instanceName) ?? 0;
6084
+ }
6085
+ isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
6086
+ return deliveryEpoch === this.getDeliveryEpoch(instanceName);
6087
+ }
6088
+ /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
6089
+ cancelPendingDeliveries(instanceName) {
6090
+ const next = this.getDeliveryEpoch(instanceName) + 1;
6091
+ this.deliveryEpochs.set(instanceName, next);
6092
+ for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
6093
+ check();
6094
+ return next;
6095
+ }
5064
6096
  queueMirrorMessage(text) {
5065
6097
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
5066
6098
  if (mirrorTopicId == null || !this.adapter)
@@ -5095,13 +6127,29 @@ export class FleetManager {
5095
6127
  }
5096
6128
  async sendHangNotification(instanceName, unchangedForMs) {
5097
6129
  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)
6130
+ const adapterId = this.getInstanceAdapterId(instanceName);
6131
+ // Same three-way addressing as sendCancelButton: fleet topic → group+thread,
6132
+ // Classic → its own channel (Classic instances are absent from
6133
+ // fleetConfig.instances, so the topic path can never address them), else the
6134
+ // world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
6135
+ // because on channels[]-configured fleets the legacy channel: block is empty.
6136
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
6137
+ const groupId = this.getGroupIdForInstance(instanceName) || undefined;
6138
+ let chatId;
6139
+ let threadId;
6140
+ if (topicId != null && groupId) {
6141
+ chatId = String(groupId);
6142
+ threadId = String(topicId);
6143
+ }
6144
+ else {
6145
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
6146
+ if (!chatId && groupId)
6147
+ chatId = String(groupId);
6148
+ }
6149
+ if (!adapter || !adapterId || !chatId) {
6150
+ this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
5103
6151
  return;
5104
- const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
6152
+ }
5105
6153
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
5106
6154
  const configuredMinutes = instanceHangConfig?.timeout_minutes
5107
6155
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
@@ -5110,17 +6158,52 @@ export class FleetManager {
5110
6158
  ? configuredMinutes
5111
6159
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
5112
6160
  this.setTopicIcon(instanceName, "red");
5113
- await adapter.notifyAlert(String(groupId), {
5114
- type: "hang",
6161
+ await this.postNonceButtonPrompt({
6162
+ prefix: HANG_CALLBACK_PREFIX,
6163
+ alertType: "hang",
5115
6164
  instanceName,
5116
- message: `⚠️ ${instanceName} may be stuck — pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
6165
+ adapter,
6166
+ adapterId,
6167
+ chatId,
6168
+ threadId,
6169
+ message: t("hang.detected", instanceName, unchangedMinutes),
5117
6170
  choices: [
5118
- { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
5119
- { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
6171
+ { action: "restart", label: t("hang.restart") },
6172
+ { action: "wait", label: t("hang.wait") },
5120
6173
  ],
5121
- }, {
5122
- threadId: threadId != null ? String(threadId) : undefined,
5123
- }).catch(e => this.logger.warn({ err: e }, "Failed to send hang notification"));
6174
+ expiredText: t("hang.expired", instanceName),
6175
+ });
6176
+ }
6177
+ /**
6178
+ * Consume a hang Force-restart / Keep-waiting button exactly once. Restart
6179
+ * goes through restartSingleInstance — serialized against concurrent restart
6180
+ * sources, and with a Classic-instance fallback (the previous hand-rolled
6181
+ * stop+start silently left Classic instances stopped while reporting
6182
+ * "restarted").
6183
+ */
6184
+ async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
6185
+ const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
6186
+ if (claimed === null)
6187
+ return false;
6188
+ if (claimed === "consumed")
6189
+ return true;
6190
+ const { entry: pending, action } = claimed;
6191
+ this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
6192
+ if (action === "wait") {
6193
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
6194
+ return true;
6195
+ }
6196
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
6197
+ try {
6198
+ await this.restartSingleInstance(pending.instanceName);
6199
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
6200
+ }
6201
+ catch (err) {
6202
+ const message = err instanceof Error ? err.message : String(err);
6203
+ this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
6204
+ 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"));
6205
+ }
6206
+ return true;
5124
6207
  }
5125
6208
  // ── Topic icon + auto-archive ─────────────────────────────────────────────
5126
6209
  static INSTRUCTIONS_FILENAME = {
@@ -5129,6 +6212,10 @@ export class FleetManager {
5129
6212
  "gemini-cli": "GEMINI.md",
5130
6213
  "opencode": "AGENTS.md",
5131
6214
  "kiro-cli": ".kiro/steering/project.md",
6215
+ // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
6216
+ // same files their writeConfig() appends fleet instructions to.
6217
+ "grok": "AGENTS.md",
6218
+ "antigravity": ".agents/agents.md",
5132
6219
  "mock": "CLAUDE.md",
5133
6220
  };
5134
6221
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -5138,146 +6225,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
5138
6225
  Do NOT modify project files directly — delegate file changes to the project's instance.
5139
6226
  You CAN write code snippets, explain code, and answer technical questions directly.
5140
6227
 
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
5193
-
5194
- Parallel — use only when tasks are independent with no shared state
5195
- Sequential — use when one task's output feeds into the next
5196
-
5197
- -----
5198
-
5199
- ## Result Handling
5200
-
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.
6228
+ ## Task Routing
5207
6229
 
5208
- ### Output to User
6230
+ - **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
6231
+ - **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
6232
+ - **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
5209
6233
 
5210
- Every final response to the user should contain:
6234
+ Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
5211
6235
 
5212
- - Result — the actual answer or deliverable
5213
- - Gaps — anything incomplete or unresolved (omit if none)
6236
+ ## Reply Contract
5214
6237
 
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
- -----
6238
+ 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
6239
 
5225
6240
  ## After Restart
5226
6241
 
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
5246
-
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.
5253
-
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
- -----
6242
+ BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
5261
6243
 
5262
- ## Team Management
6244
+ ## Playbooks (on-demand skills)
5263
6245
 
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.
6246
+ Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
6247
+ - **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
6248
+ - **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
6249
+ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
5277
6250
  `;
5278
6251
  /** Ensure the general instance has its project instructions file + knowledge */
5279
- ensureGeneralInstructions(workDir, backendName) {
6252
+ ensureGeneralInstructions(workDir, backendName, instanceName) {
5280
6253
  const backend = backendName ?? "claude-code";
6254
+ workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
5281
6255
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
5282
6256
  const filePath = join(workDir, filename);
5283
6257
  mkdirSync(dirname(filePath), { recursive: true });
@@ -5285,60 +6259,238 @@ When users create specialized instances, suggest these configurations:
5285
6259
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
5286
6260
  this.logger.info({ filePath }, "Created general instance instructions file");
5287
6261
  }
5288
- // Sync bundled knowledge files to general's steering directory
6262
+ // Sync bundled knowledge files to general's steering and skills directories.
5289
6263
  this.syncGeneralKnowledge(workDir, backend);
5290
6264
  }
5291
- /** Copy general-knowledge steering + skills to the general instance's workspace */
6265
+ /** Resolve the workspace path a backend actually uses before publishing knowledge. */
6266
+ resolveKnowledgeWorkDir(workDir, backend, instanceName) {
6267
+ // Some backends relocate their real cwd (agy moves hidden paths to
6268
+ // ~/agend-workspaces/<name>). Instructions and skills must land where the
6269
+ // CLI actually runs, not where fleet.yaml points.
6270
+ try {
6271
+ const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
6272
+ .resolveWorkingDirectory?.(workDir, instanceName);
6273
+ if (resolved)
6274
+ workDir = resolved;
6275
+ }
6276
+ catch { /* unknown backend name — keep the raw path */ }
6277
+ return workDir;
6278
+ }
6279
+ /**
6280
+ * Where each backend natively loads on-demand skills from, relative to the
6281
+ * workspace. Backends without a native skill mechanism (opencode, grok,
6282
+ * antigravity, gemini-cli) are deliberately absent: dropping files a CLI
6283
+ * never reads is clutter, not capability. Unknown directories are ignored
6284
+ * by older CLI versions, so publishing is fail-open across upgrades.
6285
+ */
6286
+ static SKILLS_DIR_SEGMENTS = {
6287
+ "kiro-cli": [".kiro", "skills"],
6288
+ "claude-code": [".claude", "skills"],
6289
+ "codex": [".agents", "skills"],
6290
+ // Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
6291
+ // vendor-canonical location is .grok/skills.
6292
+ "opencode": [".agents", "skills"],
6293
+ "grok": [".grok", "skills"],
6294
+ "antigravity": [".agents", "skills"],
6295
+ };
6296
+ /** Copy general-knowledge steering + all role-eligible skills to General. */
5292
6297
  syncGeneralKnowledge(workDir, backend) {
5293
6298
  const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
5294
6299
  if (!existsSync(knowledgeDir))
5295
6300
  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);
6301
+ this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
6302
+ this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
6303
+ this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
6304
+ }
6305
+ /** Publish only the bundled skills eligible for an instance role. */
6306
+ syncRoleSkills(workDir, backend, role, knowledgeDir) {
6307
+ const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
6308
+ if (!skillSegments)
6309
+ return;
6310
+ const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
6311
+ if (!existsSync(root))
6312
+ return;
6313
+ // Before managed-skill manifests existed, only Kiro General received
6314
+ // bundled skills. Allow that one legacy layout to be adopted so upgrades
6315
+ // can keep those copies current; other backends never had unmanaged
6316
+ // AgEnD-published skills and must retain the normal user-ownership guard.
6317
+ const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
6318
+ this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
6319
+ this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
6320
+ }
6321
+ /**
6322
+ * Steering (always-on rules like core-rules.md). Kiro loads a native
6323
+ * steering directory; every other backend gets the content embedded into
6324
+ * its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
6325
+ * block — the previous behavior dropped bare .md files in the workspace
6326
+ * root, which no CLI ever read. The block is replaced in place on every
6327
+ * sync, so rule updates reach EXISTING workspaces; everything the user
6328
+ * wrote outside the markers is preserved byte-for-byte.
6329
+ */
6330
+ syncGeneralSteering(workDir, backend, srcSteering) {
6331
+ if (!existsSync(srcSteering))
6332
+ return;
6333
+ const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
6334
+ if (files.length === 0)
6335
+ return;
6336
+ if (backend === "kiro-cli") {
6337
+ const steeringDir = join(workDir, ".kiro", "steering");
6338
+ mkdirSync(steeringDir, { recursive: true });
6339
+ for (const file of files) {
5307
6340
  const dest = join(steeringDir, file);
5308
- const newContent = readFileSync(src, "utf-8");
6341
+ const newContent = readFileSync(join(srcSteering, file), "utf-8");
5309
6342
  try {
5310
6343
  if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5311
6344
  continue;
5312
6345
  }
5313
- catch { }
6346
+ catch { /* rewrite */ }
5314
6347
  writeFileSync(dest, newContent);
5315
6348
  }
6349
+ return;
5316
6350
  }
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
- }
6351
+ const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
6352
+ const instructionsPath = join(workDir, filename);
6353
+ const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
6354
+ const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
6355
+ let existing = "";
6356
+ try {
6357
+ existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
6358
+ }
6359
+ catch { /* treat as empty */ }
6360
+ const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
6361
+ const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
6362
+ let next;
6363
+ if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
6364
+ next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
6365
+ }
6366
+ else {
6367
+ next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
6368
+ }
6369
+ if (next !== existing) {
6370
+ mkdirSync(dirname(instructionsPath), { recursive: true });
6371
+ writeFileSync(instructionsPath, next);
6372
+ }
6373
+ }
6374
+ static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
6375
+ static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
6376
+ /**
6377
+ * Publish AgEnD's bundled skills into a CLI's native skills directory,
6378
+ * owning ONLY what we published. A manifest records which skill names AgEnD
6379
+ * wrote; a bundled rename/removal deletes the stale managed copy, while a
6380
+ * skill the user created by hand is never listed and therefore never
6381
+ * touched — even if a future bundle happens to reuse its name (the sync
6382
+ * then skips it and logs, rather than overwrite the user's work). The sole
6383
+ * migration exception is the pre-manifest Kiro General layout explicitly
6384
+ * selected by adoptLegacyUnmanaged.
6385
+ */
6386
+ syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
6387
+ if (!existsSync(srcSkills))
6388
+ return;
6389
+ mkdirSync(destSkills, { recursive: true });
6390
+ const manifestPath = join(destSkills, ".agend-managed-skills.json");
6391
+ const hadManifest = existsSync(manifestPath);
6392
+ let previouslyManaged = [];
6393
+ try {
6394
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
6395
+ if (Array.isArray(parsed))
6396
+ previouslyManaged = parsed.filter(n => typeof n === "string");
6397
+ }
6398
+ catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
6399
+ const bundled = readdirSync(srcSkills)
6400
+ .filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
6401
+ .sort();
6402
+ // Pre-manifest Kiro General copied bundled skills directly into
6403
+ // .kiro/skills. If every existing skill is still a bundled name, this is
6404
+ // the unambiguous legacy layout: adopt it once, update it below, and write
6405
+ // the ownership manifest. Any extra skill name keeps the directory fully
6406
+ // on the user-owned/collision path.
6407
+ if (!hadManifest && adoptLegacyUnmanaged) {
6408
+ const existing = readdirSync(destSkills, { withFileTypes: true })
6409
+ .filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
6410
+ .map(entry => entry.name)
6411
+ .sort();
6412
+ if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
6413
+ previouslyManaged = existing;
6414
+ this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
6415
+ }
6416
+ }
6417
+ const eligible = bundled.filter(name => {
6418
+ const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
6419
+ // General is the coordinator and receives both coordinator and worker
6420
+ // playbooks. Workers receive only skills explicitly marked for workers.
6421
+ return role === "general" || roles.includes("worker");
6422
+ });
6423
+ const managed = [];
6424
+ for (const name of eligible) {
6425
+ const destDir = join(destSkills, name);
6426
+ const dest = join(destDir, "SKILL.md");
6427
+ const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
6428
+ if (!isOurs) {
6429
+ // Name collision with a user-authored skill: theirs wins, loudly.
6430
+ this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
6431
+ continue;
6432
+ }
6433
+ managed.push(name);
6434
+ const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
6435
+ try {
6436
+ if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
6437
+ continue;
6438
+ }
6439
+ catch { /* rewrite */ }
6440
+ mkdirSync(destDir, { recursive: true });
6441
+ writeFileSync(dest, newContent);
6442
+ }
6443
+ // Remove managed skills that are no longer bundled OR no longer eligible
6444
+ // for this role. This makes a shared → general-only metadata change take
6445
+ // effect on the next worker startup instead of leaving stale capability.
6446
+ for (const stale of previouslyManaged) {
6447
+ if (eligible.includes(stale))
6448
+ continue;
6449
+ try {
6450
+ rmSync(join(destSkills, stale), { recursive: true, force: true });
6451
+ this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
6452
+ }
6453
+ catch (err) {
6454
+ this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
5339
6455
  }
5340
6456
  }
5341
- this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
6457
+ try {
6458
+ writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
6459
+ }
6460
+ catch (err) {
6461
+ this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
6462
+ }
6463
+ }
6464
+ /** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
6465
+ readManagedSkillRoles(skillPath) {
6466
+ const fallback = ["general"];
6467
+ try {
6468
+ const content = readFileSync(skillPath, "utf-8");
6469
+ const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
6470
+ if (!match)
6471
+ return fallback;
6472
+ const document = parseDocument(match[1]);
6473
+ if (document.errors.length > 0)
6474
+ throw document.errors[0];
6475
+ const frontmatter = document.toJS();
6476
+ if (!frontmatter || frontmatter.roles === undefined)
6477
+ return fallback;
6478
+ if (!Array.isArray(frontmatter.roles))
6479
+ throw new Error("roles must be an array");
6480
+ const roles = [...new Set(frontmatter.roles)]
6481
+ .filter((value) => value === "general" || value === "worker");
6482
+ if (roles.length !== frontmatter.roles.length || roles.length === 0) {
6483
+ throw new Error("roles must contain only general and/or worker");
6484
+ }
6485
+ return roles;
6486
+ }
6487
+ catch (err) {
6488
+ // Fail closed for workers: malformed or unknown metadata keeps the
6489
+ // backwards-compatible General-only behavior instead of leaking an
6490
+ // administrative skill into worker workspaces.
6491
+ this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
6492
+ return fallback;
6493
+ }
5342
6494
  }
5343
6495
  /** Fetch forum topic icon stickers and pick emoji IDs for each state */
5344
6496
  async resolveTopicIcons() {
@@ -5770,6 +6922,17 @@ When users create specialized instances, suggest these configurations:
5770
6922
  return null;
5771
6923
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
5772
6924
  const env = { backend, probedAt: Date.now(), ...probed };
6925
+ // An empty result must never overwrite a catalog we already have. Some
6926
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
6927
+ // returns [] — and writing that would blank the list for the whole 24h
6928
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
6929
+ // antigravity cache replaced by an empty one. Keep the known models and
6930
+ // let the fresher currentModel/version through.
6931
+ if (!env.models?.length) {
6932
+ const previous = this.readCliEnv(backend);
6933
+ if (previous?.models?.length)
6934
+ env.models = previous.models;
6935
+ }
5773
6936
  const path = this.cliEnvPath(backend);
5774
6937
  mkdirSync(dirname(path), { recursive: true });
5775
6938
  writeFileSync(path, JSON.stringify(env, null, 2));
@@ -5808,6 +6971,108 @@ When users create specialized instances, suggest these configurations:
5808
6971
  const env = await this.probeBackend(backendName);
5809
6972
  return env?.models ?? [];
5810
6973
  }
6974
+ /**
6975
+ * Model catalog behind the `list_models` tool.
6976
+ *
6977
+ * The two scopes are not cosmetic. "global" is the account/CLI catalog served
6978
+ * from the startup probe cache; "instance" is resolved through that instance's
6979
+ * OWN backend config, and for a Codex instance on a custom provider that is a
6980
+ * different catalog entirely — `listModels()` reads models_cache.json out of
6981
+ * the instance's private CODEX_HOME. Answering such an instance with the
6982
+ * account list would name models its CLI rejects, which is exactly the
6983
+ * mistake this tool exists to prevent.
6984
+ *
6985
+ * `scope` always describes where the returned LIST came from, not what was
6986
+ * asked for: an instance query that falls back to the account catalog reports
6987
+ * scope "global" and says so in `note`, rather than implying instance-level
6988
+ * accuracy it does not have.
6989
+ *
6990
+ * Never throws — a model listing is an aid, and failing it must not fail a turn.
6991
+ */
6992
+ async listModelCatalog(opts = {}) {
6993
+ const { instanceName } = opts;
6994
+ if (instanceName) {
6995
+ const backend = this.backendNameForInstance(instanceName);
6996
+ const resolved = this.resolveInstanceModel(instanceName);
6997
+ const currentModel = resolved.source === "unresolved" ? null : resolved.model;
6998
+ const provider = this.customProviderFor(instanceName, backend);
6999
+ const scoped = await this.instanceScopedModels(instanceName, backend);
7000
+ if (scoped.length) {
7001
+ return {
7002
+ backend, scope: "instance", instance: instanceName,
7003
+ current_model: currentModel, models: scoped, source: "live",
7004
+ ...(provider ? { note: `Catalog read through this instance's ${backend} provider "${provider}" — it may differ from the account catalog.` } : {}),
7005
+ };
7006
+ }
7007
+ // No instance-local catalog (never launched, or the backend has no
7008
+ // per-instance list). The account catalog is the best available answer,
7009
+ // but it is labelled honestly rather than dressed up as instance scope.
7010
+ const global = await this.globalModelCatalog(backend);
7011
+ return {
7012
+ ...global, instance: instanceName, current_model: currentModel,
7013
+ note: provider
7014
+ ? `No instance-local catalog yet; showing the account catalog, which may NOT match this instance's ${backend} provider "${provider}".`
7015
+ : "No instance-local catalog yet; showing the account catalog.",
7016
+ };
7017
+ }
7018
+ return this.globalModelCatalog(opts.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
7019
+ }
7020
+ /** The custom provider an instance overrides its backend with, if any. */
7021
+ customProviderFor(instanceName, backend) {
7022
+ const opts = this.fleetConfig?.instances?.[instanceName]?.backend_options?.[backend]
7023
+ ?? this.fleetConfig?.defaults?.backend_options?.[backend];
7024
+ const provider = opts?.provider;
7025
+ return typeof provider === "string" && provider.trim() ? provider.trim() : null;
7026
+ }
7027
+ /** Ask a backend for its catalog using ONE instance's real config. Never throws. */
7028
+ async instanceScopedModels(instanceName, backend) {
7029
+ try {
7030
+ const inst = this.fleetConfig?.instances?.[instanceName];
7031
+ const instanceDir = this.getInstanceDir(instanceName);
7032
+ const be = createBackend(backend, instanceDir);
7033
+ if (!be.listModels)
7034
+ return [];
7035
+ return await be.listModels({
7036
+ workingDirectory: inst?.working_directory ?? "",
7037
+ instanceDir,
7038
+ instanceName,
7039
+ mcpServers: {},
7040
+ model: inst?.model,
7041
+ backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
7042
+ }) ?? [];
7043
+ }
7044
+ catch {
7045
+ // listModels is documented never to throw, but a backend constructor can
7046
+ // (missing binary). A catalog is an aid; degrade to the account list.
7047
+ return [];
7048
+ }
7049
+ }
7050
+ /** Account-wide catalog: probe cache first, live probe on miss. */
7051
+ async globalModelCatalog(backend) {
7052
+ const cached = this.readCliEnv(backend);
7053
+ if (cached?.models?.length) {
7054
+ return {
7055
+ backend, scope: "global", current_model: cached.currentModel ?? null,
7056
+ models: cached.models, source: "cache",
7057
+ probed_at: new Date(cached.probedAt).toISOString(),
7058
+ };
7059
+ }
7060
+ const env = await this.probeBackend(backend);
7061
+ if (env?.models?.length) {
7062
+ return {
7063
+ backend, scope: "global", current_model: env.currentModel ?? null,
7064
+ models: env.models, source: "live",
7065
+ probed_at: new Date(env.probedAt).toISOString(),
7066
+ };
7067
+ }
7068
+ // Reported rather than thrown: "we could not enumerate" is a useful answer,
7069
+ // and the caller can still set a model by name (AgEnD passes it through).
7070
+ return {
7071
+ backend, scope: "global", current_model: env?.currentModel ?? null,
7072
+ models: [], source: "fallback",
7073
+ 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.`,
7074
+ };
7075
+ }
5811
7076
  /** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
5812
7077
  /** Label an effort choice, marking the one currently configured. */
5813
7078
  effortChoiceLabel(level, current) {
@@ -5816,10 +7081,10 @@ When users create specialized instances, suggest these configurations:
5816
7081
  effortMenuHeader(instanceName) {
5817
7082
  const { effort, source } = this.resolveInstanceEffort(instanceName);
5818
7083
  if (!effort)
5819
- return "Current effort: (CLI default)";
7084
+ return t("effort.current_default");
5820
7085
  return source === "fleet-default"
5821
- ? `Current effort: ${effort} (fleet default)`
5822
- : `Current effort: ${effort}`;
7086
+ ? t("effort.current_fleet", effort)
7087
+ : t("effort.current", effort);
5823
7088
  }
5824
7089
  /** `/effort` — DC Select Menu, or apply directly when a level is given. */
5825
7090
  async handleEffortSlash(data, adapterId) {
@@ -5840,11 +7105,11 @@ When users create specialized instances, suggest these configurations:
5840
7105
  }
5841
7106
  const levels = this.effortLevelsFor(name);
5842
7107
  if (levels.length === 0) {
5843
- await data.respond(`❌ ${this.backendNameForInstance(name)} has no reasoning-effort setting.`);
7108
+ await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
5844
7109
  return;
5845
7110
  }
5846
7111
  if (!data.respondChoices) {
5847
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7112
+ await data.respond(t("effort.usage", levels.join("|")));
5848
7113
  return;
5849
7114
  }
5850
7115
  const current = this.resolveInstanceEffort(name).effort;
@@ -5857,20 +7122,20 @@ When users create specialized instances, suggest these configurations:
5857
7122
  timer.unref?.();
5858
7123
  this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
5859
7124
  try {
5860
- await data.respondChoices(`${this.effortMenuHeader(name)}\nSelect a new effort level:`, choices);
7125
+ await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
5861
7126
  }
5862
7127
  catch (err) {
5863
7128
  this.pendingEffortSelects.delete(nonce);
5864
7129
  clearTimeout(timer);
5865
7130
  this.logger.warn({ err, instanceName: name }, "effort menu failed");
5866
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7131
+ await data.respond(t("effort.usage", levels.join("|")));
5867
7132
  }
5868
7133
  }
5869
7134
  /** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
5870
7135
  async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
5871
7136
  const levels = this.effortLevelsFor(instanceName);
5872
7137
  if (levels.length === 0) {
5873
- return `❌ ${this.backendNameForInstance(instanceName)} has no reasoning-effort setting.`;
7138
+ return t("effort.unsupported", this.backendNameForInstance(instanceName));
5874
7139
  }
5875
7140
  const current = this.resolveInstanceEffort(instanceName).effort;
5876
7141
  const nonce = randomBytes(6).toString("hex");
@@ -5886,13 +7151,13 @@ When users create specialized instances, suggest these configurations:
5886
7151
  const p = this.pendingEffortSelects.get(nonce);
5887
7152
  if (p) {
5888
7153
  this.pendingEffortSelects.delete(nonce);
5889
- p.respond("⏰ Effort selection expired.").catch(() => { });
7154
+ p.respond(t("effort.selection_expired")).catch(() => { });
5890
7155
  }
5891
7156
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
5892
7157
  timer.unref?.();
5893
7158
  this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
5894
7159
  try {
5895
- const menuMessageId = await adapter.promptUser(chatId, `${this.effortMenuHeader(instanceName)}\nSelect a new effort level:`, choices, { threadId });
7160
+ const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
5896
7161
  const pending = this.pendingEffortSelects.get(nonce);
5897
7162
  if (pending)
5898
7163
  pending.menuMessageId = menuMessageId;
@@ -5902,7 +7167,7 @@ When users create specialized instances, suggest these configurations:
5902
7167
  this.pendingEffortSelects.delete(nonce);
5903
7168
  clearTimeout(timer);
5904
7169
  this.logger.warn({ err, instanceName }, "TG effort menu failed");
5905
- return `Usage: /effort <${levels.join("|")}>`;
7170
+ return t("effort.usage", levels.join("|"));
5906
7171
  }
5907
7172
  }
5908
7173
  /** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
@@ -5923,7 +7188,7 @@ When users create specialized instances, suggest these configurations:
5923
7188
  this.pendingEffortSelects.delete(match[1]);
5924
7189
  clearTimeout(pending.timer);
5925
7190
  const level = match[2];
5926
- const progressText = `⏳ Setting ${pending.instanceName} effort to \`${level}\`…`;
7191
+ const progressText = t("effort.setting", pending.instanceName, level);
5927
7192
  let progressMsgId;
5928
7193
  if (pending.adapter && pending.adapterChatId) {
5929
7194
  const menuMessageId = pending.menuMessageId ?? data.messageId;
@@ -5955,7 +7220,7 @@ When users create specialized instances, suggest these configurations:
5955
7220
  }
5956
7221
  catch (err) {
5957
7222
  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)}`;
7223
+ result = t("effort.switch_failed", level, err instanceof Error ? err.message : String(err));
5959
7224
  }
5960
7225
  if (pending.adapter && pending.adapterChatId) {
5961
7226
  if (progressMsgId) {
@@ -5992,12 +7257,12 @@ When users create specialized instances, suggest these configurations:
5992
7257
  }
5993
7258
  // No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
5994
7259
  if (!data.respondChoices) {
5995
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7260
+ await data.respond(t("model.usage"));
5996
7261
  return;
5997
7262
  }
5998
7263
  const options = await this.getModelOptions(name, isRefresh);
5999
7264
  if (options.length === 0) {
6000
- await data.respond(`No model list available for ${name}. Type \`/model <name>\` to set one directly.`);
7265
+ await data.respond(t("model.list_unavailable", name));
6001
7266
  return;
6002
7267
  }
6003
7268
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
@@ -6011,13 +7276,13 @@ When users create specialized instances, suggest these configurations:
6011
7276
  timer.unref?.();
6012
7277
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
6013
7278
  try {
6014
- await data.respondChoices(`Current model: **${currentDisplay}**\nSelect a new model:`, choices);
7279
+ await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
6015
7280
  }
6016
7281
  catch (err) {
6017
7282
  this.pendingModelSelects.delete(nonce);
6018
7283
  clearTimeout(timer);
6019
7284
  this.logger.warn({ err, instanceName: name }, "model menu failed");
6020
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7285
+ await data.respond(t("model.usage"));
6021
7286
  }
6022
7287
  }
6023
7288
  /**
@@ -6028,7 +7293,7 @@ When users create specialized instances, suggest these configurations:
6028
7293
  async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
6029
7294
  const options = await this.getModelOptions(instanceName);
6030
7295
  if (options.length === 0) {
6031
- return `No model list available for ${instanceName}. Use \`/model <name>\` to set one directly.`;
7296
+ return t("model.list_unavailable", instanceName);
6032
7297
  }
6033
7298
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
6034
7299
  const nonce = randomBytes(6).toString("hex");
@@ -6044,13 +7309,13 @@ When users create specialized instances, suggest these configurations:
6044
7309
  const p = this.pendingModelSelects.get(nonce);
6045
7310
  if (p) {
6046
7311
  this.pendingModelSelects.delete(nonce);
6047
- p.respond("⏰ Model selection expired.").catch(() => { });
7312
+ p.respond(t("model.selection_expired")).catch(() => { });
6048
7313
  }
6049
7314
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
6050
7315
  timer.unref?.();
6051
7316
  this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
6052
7317
  try {
6053
- const menuMessageId = await adapter.promptUser(chatId, `Current model: ${currentDisplay}\nSelect a new model:`, choices, { threadId });
7318
+ const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
6054
7319
  const pending = this.pendingModelSelects.get(nonce);
6055
7320
  if (pending)
6056
7321
  pending.menuMessageId = menuMessageId;
@@ -6060,7 +7325,7 @@ When users create specialized instances, suggest these configurations:
6060
7325
  this.pendingModelSelects.delete(nonce);
6061
7326
  clearTimeout(timer);
6062
7327
  this.logger.warn({ err, instanceName }, "TG model menu failed");
6063
- return `Usage: /model <name> — e.g. /model sonnet`;
7328
+ return t("model.usage");
6064
7329
  }
6065
7330
  }
6066
7331
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
@@ -6083,7 +7348,7 @@ When users create specialized instances, suggest these configurations:
6083
7348
  clearTimeout(pending.timer);
6084
7349
  const model = match[2];
6085
7350
  // Send immediate "⏳ Switching..." feedback, then apply in background.
6086
- const progressText = `⏳ Switching ${pending.instanceName} to \`${model}\`…`;
7351
+ const progressText = t("model.switching", pending.instanceName, model);
6087
7352
  let progressMsgId;
6088
7353
  if (pending.adapter && pending.adapterChatId) {
6089
7354
  // TG path: turn the original menu into the progress message. This both
@@ -6120,7 +7385,7 @@ When users create specialized instances, suggest these configurations:
6120
7385
  }
6121
7386
  catch (err) {
6122
7387
  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)}`;
7388
+ result = t("model.switch_failed", model, err instanceof Error ? err.message : String(err));
6124
7389
  }
6125
7390
  if (pending.adapter && pending.adapterChatId) {
6126
7391
  if (progressMsgId) {
@@ -6217,17 +7482,17 @@ When users create specialized instances, suggest these configurations:
6217
7482
  }
6218
7483
  catch { /* treated as unsupported below */ }
6219
7484
  if (strategy === "unsupported" || supported.length === 0) {
6220
- return `❌ ${backendName} has no reasoning-effort setting.`;
7485
+ return t("effort.unsupported", backendName);
6221
7486
  }
6222
7487
  if (!FleetManager.EFFORT_LEVELS.includes(level)) {
6223
- return `❌ Unknown effort level \`${level}\`. Use: ${FleetManager.EFFORT_LEVELS.join(", ")}.`;
7488
+ return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
6224
7489
  }
6225
7490
  const applied = FleetManager.clampEffort(level, supported);
6226
7491
  if (!applied)
6227
- return `❌ ${backendName} accepts none of the canonical effort levels.`;
7492
+ return t("effort.no_canonical", backendName);
6228
7493
  const warn = applied === level
6229
7494
  ? ""
6230
- : `⚠️ Clamped to \`${applied}\` (\`${level}\` not supported by ${backendName}).\n`;
7495
+ : t("effort.clamped", applied, level, backendName);
6231
7496
  // Persist either way: a runtime switch must survive the next respawn too,
6232
7497
  // or the instance silently reverts on restart.
6233
7498
  if (this.fleetConfig?.instances[instanceName]) {
@@ -6236,12 +7501,12 @@ When users create specialized instances, suggest these configurations:
6236
7501
  }
6237
7502
  if (strategy === "runtime") {
6238
7503
  if (!this.instanceIpcClients.get(instanceName))
6239
- return `${warn}❌ ${instanceName} is not running.`;
7504
+ return `${warn}${t("effort.not_running", instanceName)}`;
6240
7505
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
6241
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` (runtime).`;
7506
+ return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
6242
7507
  }
6243
7508
  await this.restartSingleInstance(instanceName);
6244
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` and restarted.`;
7509
+ return `${warn}${t("effort.restart_success", instanceName, applied)}`;
6245
7510
  }
6246
7511
  async applyModel(instanceName, model) {
6247
7512
  const backendName = this.backendNameForInstance(instanceName);
@@ -6250,14 +7515,12 @@ When users create specialized instances, suggest these configurations:
6250
7515
  strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
6251
7516
  }
6252
7517
  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)}`;
7518
+ const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
7519
+ if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
7520
+ return `${warn}${t("effort.not_running", instanceName)}`;
6259
7521
  }
6260
- // restart: persist the model so the respawned CLI launches with it.
7522
+ // Persist either way: a runtime switch must survive the next respawn too,
7523
+ // or the instance silently reverts to the CLI default after a fleet restart.
6261
7524
  let persisted = false;
6262
7525
  if (this.fleetConfig?.instances[instanceName]) {
6263
7526
  this.fleetConfig.instances[instanceName].model = model;
@@ -6268,9 +7531,13 @@ When users create specialized instances, suggest these configurations:
6268
7531
  persisted = true;
6269
7532
  }
6270
7533
  if (!persisted)
6271
- return `${warn}❌ Could not set model for ${instanceName}.`;
7534
+ return `${warn}${t("model.persist_failed", instanceName)}`;
7535
+ if (strategy === "runtime") {
7536
+ this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
7537
+ return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
7538
+ }
6272
7539
  await this.restartSingleInstance(instanceName);
6273
- return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
7540
+ return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
6274
7541
  }
6275
7542
  /**
6276
7543
  * The trailing "Current effort: …" line for a /model reply.
@@ -6284,10 +7551,10 @@ When users create specialized instances, suggest these configurations:
6284
7551
  return "";
6285
7552
  const { effort, source } = this.resolveInstanceEffort(instanceName);
6286
7553
  if (!effort)
6287
- return "\nCurrent effort: (CLI default)";
7554
+ return `\n${t("effort.current_default")}`;
6288
7555
  return source === "fleet-default"
6289
- ? `\nCurrent effort: ${effort} (fleet default)`
6290
- : `\nCurrent effort: ${effort}`;
7556
+ ? `\n${t("effort.current_fleet", effort)}`
7557
+ : `\n${t("effort.current", effort)}`;
6291
7558
  }
6292
7559
  /** Read recent chat log for agent context */
6293
7560
  getRecentChatLog(instanceName, maxLines = 10) {
@@ -6322,7 +7589,7 @@ When users create specialized instances, suggest these configurations:
6322
7589
  /** Return a user-facing blocker without mutating ClassicBot state. */
6323
7590
  validateClassicStart(channelId, userId, guildId, adapterId) {
6324
7591
  if (!this.classicChannels)
6325
- return "Classic channel manager not initialized.";
7592
+ return t("classic.manager_unavailable");
6326
7593
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
6327
7594
  const generalId = this.findGeneralInstance(adapterId);
6328
7595
  if (generalId)
@@ -6469,6 +7736,7 @@ When users create specialized instances, suggest these configurations:
6469
7736
  return;
6470
7737
  const workDir = join(getAgendHome(), "workspaces", instanceName);
6471
7738
  ensureWorkspaceGit(workDir);
7739
+ const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
6472
7740
  const config = {
6473
7741
  ...DEFAULT_INSTANCE_CONFIG,
6474
7742
  ...this.fleetConfig?.defaults,
@@ -6476,6 +7744,8 @@ When users create specialized instances, suggest these configurations:
6476
7744
  lightweight: true,
6477
7745
  ...(backend ? { backend } : {}),
6478
7746
  ...(model ? { model } : {}),
7747
+ ...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
7748
+ ...(classicIdentity?.description ? { description: classicIdentity.description } : {}),
6479
7749
  ...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
6480
7750
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
6481
7751
  };
@@ -6489,7 +7759,7 @@ When users create specialized instances, suggest these configurations:
6489
7759
  return blocker;
6490
7760
  const classicChannels = this.classicChannels;
6491
7761
  if (!classicChannels)
6492
- return "Classic channel manager not initialized.";
7762
+ return t("classic.manager_unavailable");
6493
7763
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
6494
7764
  clearPausedMarker(this.getInstanceDir(instanceName));
6495
7765
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
@@ -6511,7 +7781,7 @@ When users create specialized instances, suggest these configurations:
6511
7781
  /** Handle /stop slash command — unregister classic channel */
6512
7782
  async handleClassicStop(channelId, adapterId) {
6513
7783
  if (!this.classicChannels)
6514
- return "Classic channel manager not initialized.";
7784
+ return t("classic.manager_unavailable");
6515
7785
  const ch = this.classicChannels.unregister(channelId, adapterId);
6516
7786
  if (!ch)
6517
7787
  return t("classic.no_agent");
@@ -6558,6 +7828,8 @@ When users create specialized instances, suggest these configurations:
6558
7828
  this.clearStatuslineWatchers();
6559
7829
  this.costGuard?.stop();
6560
7830
  this.dailySummary?.stop();
7831
+ this.dailyTipScheduler?.stop();
7832
+ this.dailyTipScheduler = null;
6561
7833
  if (this.updateCheckTimer) {
6562
7834
  clearTimeout(this.updateCheckTimer);
6563
7835
  clearInterval(this.updateCheckTimer);
@@ -6606,11 +7878,11 @@ When users create specialized instances, suggest these configurations:
6606
7878
  for (const pending of this.pendingClassicStarts.values())
6607
7879
  clearTimeout(pending.timer);
6608
7880
  this.pendingClassicStarts.clear();
6609
- for (const pending of this.pendingInteractivePromptAssists.values()) {
7881
+ for (const pending of this.pendingNonceButtons.values()) {
6610
7882
  if (pending.timer)
6611
7883
  clearTimeout(pending.timer);
6612
7884
  }
6613
- this.pendingInteractivePromptAssists.clear();
7885
+ this.pendingNonceButtons.clear();
6614
7886
  this.topicArchiver.stop();
6615
7887
  this.scheduler?.shutdown();
6616
7888
  // Stop instances in parallel batches to avoid long sequential waits.
@@ -6775,7 +8047,8 @@ When users create specialized instances, suggest these configurations:
6775
8047
  /**
6776
8048
  * Hot-reload: re-read fleet.yaml and reconcile running instances.
6777
8049
  * Starts new, stops removed, restarts modified instances.
6778
- * Fleet-level config (access, cost_guard, etc.) requires /restart to take effect.
8050
+ * Whitelisted runtime fields are pushed into live daemons; all other instance
8051
+ * fields, plus cold fleet-level settings, retain restart semantics.
6779
8052
  */
6780
8053
  async reconcileInstances() {
6781
8054
  if (!this.configPath)
@@ -6817,11 +8090,11 @@ When users create specialized instances, suggest these configurations:
6817
8090
  // possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
6818
8091
  // with no explanation anywhere they are looking.
6819
8092
  const why = !validation.valid
6820
- ? `validation failed:\n${validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n")}`
8093
+ ? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
6821
8094
  : 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.`);
8095
+ ? t("fleet.reload_removed_all", oldCount)
8096
+ : t("fleet.reload_removed_half", oldCount, newCount);
8097
+ this.notifyFleetError(t("fleet.reload_rejected", why));
6825
8098
  return;
6826
8099
  }
6827
8100
  this.routing.rebuild(this.fleetConfig);
@@ -6829,9 +8102,20 @@ When users create specialized instances, suggest these configurations:
6829
8102
  this.scheduler?.reload();
6830
8103
  const newInstances = this.fleetConfig.instances;
6831
8104
  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 });
8105
+ // Detect fleet-level changes which still need a restart. Hot defaults are
8106
+ // reconciled below and must not produce a misleading restart warning.
8107
+ const oldDefaultColdWithFleetFields = oldConfig?.defaults
8108
+ ? splitHotColdConfig(oldConfig.defaults).cold
8109
+ : {};
8110
+ const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
8111
+ ? splitHotColdConfig(this.fleetConfig.defaults).cold
8112
+ : {};
8113
+ // `tips` is a fleet-owned hot switch; it is read at post time and never
8114
+ // belongs in a daemon config or a "restart required" warning.
8115
+ const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
8116
+ const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
8117
+ const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
8118
+ const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
6835
8119
  if (oldFleetLevel !== newFleetLevel) {
6836
8120
  this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
6837
8121
  }
@@ -6843,7 +8127,9 @@ When users create specialized instances, suggest these configurations:
6843
8127
  await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
6844
8128
  }
6845
8129
  }
6846
- // Start new + restart modified instances
8130
+ // Start new + reconcile modified instances. Hot values are always sent as a
8131
+ // complete snapshot: Settings mutates FleetManager's config before SIGHUP,
8132
+ // so an old/new diff alone can miss the live daemon's stale value.
6847
8133
  for (const [name, config] of Object.entries(newInstances)) {
6848
8134
  if (!this.daemons.has(name)) {
6849
8135
  // New instance — startInstance already calls connectIpcToInstance
@@ -6851,14 +8137,33 @@ When users create specialized instances, suggest these configurations:
6851
8137
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
6852
8138
  }
6853
8139
  else if (oldConfig?.instances[name]) {
6854
- // Restart if any config field changed
6855
- if (JSON.stringify(oldConfig.instances[name]) !== JSON.stringify(config)) {
8140
+ const daemon = this.daemons.get(name);
8141
+ const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
8142
+ const oldParts = splitHotColdConfig(runtimeConfig);
8143
+ const newParts = splitHotColdConfig(config);
8144
+ // Every field not explicitly classified hot is cold by default.
8145
+ if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
6856
8146
  this.logger.info({ name }, "Instance config changed — restarting");
6857
8147
  await this.stopInstance(name).catch(() => { });
6858
8148
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
6859
8149
  }
8150
+ else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
8151
+ const update = hotConfigUpdate(config);
8152
+ const ipc = this.instanceIpcClients.get(name);
8153
+ const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
8154
+ if (!sent) {
8155
+ // Daemon is in-process, so a reconnect gap must not leave runtime
8156
+ // state stale. Normal operation still uses the explicit IPC contract.
8157
+ daemon.applyConfigUpdate(update);
8158
+ this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
8159
+ }
8160
+ this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
8161
+ }
6860
8162
  }
6861
8163
  }
8164
+ // warm_cap is fleet-owned; enforce the reloaded value immediately against
8165
+ // currently idle instances instead of waiting for a future state edge.
8166
+ this.enforceWarmCap();
6862
8167
  this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
6863
8168
  }
6864
8169
  async restartInstances() {
@@ -7098,7 +8403,7 @@ When users create specialized instances, suggest these configurations:
7098
8403
  if (generalId) {
7099
8404
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
7100
8405
  // link preview looked bad. Version + /update instruction is enough.
7101
- this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
8406
+ this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
7102
8407
  }
7103
8408
  }
7104
8409
  }
@@ -7418,7 +8723,7 @@ When users create specialized instances, suggest these configurations:
7418
8723
  if (err.code === "EADDRINUSE") {
7419
8724
  if (this.healthPortRetried) {
7420
8725
  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.`);
8726
+ this.notifyFleetError(t("dashboard.port_in_use", port));
7422
8727
  return;
7423
8728
  }
7424
8729
  this.healthPortRetried = true;
@@ -7444,7 +8749,7 @@ When users create specialized instances, suggest these configurations:
7444
8749
  return;
7445
8750
  }
7446
8751
  this.logger.error({ err, port }, "Health server error");
7447
- this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
8752
+ this.notifyFleetError(t("dashboard.server_failed", err.message));
7448
8753
  });
7449
8754
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
7450
8755
  }