@songsid/agend 2.1.3 → 2.1.4-beta.10

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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/README.zh-TW.md +2 -2
  4. package/dist/backend/antigravity.d.ts +1 -0
  5. package/dist/backend/antigravity.js +1 -0
  6. package/dist/backend/antigravity.js.map +1 -1
  7. package/dist/backend/claude-code.d.ts +1 -0
  8. package/dist/backend/claude-code.js +24 -2
  9. package/dist/backend/claude-code.js.map +1 -1
  10. package/dist/backend/codex.d.ts +1 -0
  11. package/dist/backend/codex.js +43 -4
  12. package/dist/backend/codex.js.map +1 -1
  13. package/dist/backend/gemini-cli.d.ts +1 -0
  14. package/dist/backend/gemini-cli.js +1 -0
  15. package/dist/backend/gemini-cli.js.map +1 -1
  16. package/dist/backend/grok.d.ts +1 -0
  17. package/dist/backend/grok.js +1 -0
  18. package/dist/backend/grok.js.map +1 -1
  19. package/dist/backend/kiro.d.ts +1 -0
  20. package/dist/backend/kiro.js +1 -0
  21. package/dist/backend/kiro.js.map +1 -1
  22. package/dist/backend/mock.d.ts +1 -0
  23. package/dist/backend/mock.js +1 -0
  24. package/dist/backend/mock.js.map +1 -1
  25. package/dist/backend/opencode.d.ts +40 -0
  26. package/dist/backend/opencode.js +83 -5
  27. package/dist/backend/opencode.js.map +1 -1
  28. package/dist/backend/types.d.ts +10 -0
  29. package/dist/backend/types.js +7 -0
  30. package/dist/backend/types.js.map +1 -1
  31. package/dist/channel/adapters/discord.js +24 -7
  32. package/dist/channel/adapters/discord.js.map +1 -1
  33. package/dist/channel/mcp-server.js +6 -20
  34. package/dist/channel/mcp-server.js.map +1 -1
  35. package/dist/channel/mcp-tools.js +1 -1
  36. package/dist/channel/mcp-tools.js.map +1 -1
  37. package/dist/channel/types.d.ts +1 -1
  38. package/dist/classic-channel-manager.d.ts +34 -3
  39. package/dist/classic-channel-manager.js +190 -31
  40. package/dist/classic-channel-manager.js.map +1 -1
  41. package/dist/cli.js +74 -23
  42. package/dist/cli.js.map +1 -1
  43. package/dist/completion-install.d.ts +70 -0
  44. package/dist/completion-install.js +152 -0
  45. package/dist/completion-install.js.map +1 -0
  46. package/dist/completion.d.ts +7 -1
  47. package/dist/completion.js +14 -4
  48. package/dist/completion.js.map +1 -1
  49. package/dist/config-validator.js +35 -7
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.js +1 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/daemon.d.ts +53 -9
  54. package/dist/daemon.js +222 -86
  55. package/dist/daemon.js.map +1 -1
  56. package/dist/fleet-context.d.ts +8 -0
  57. package/dist/fleet-manager.d.ts +136 -5
  58. package/dist/fleet-manager.js +1088 -316
  59. package/dist/fleet-manager.js.map +1 -1
  60. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  61. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  62. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  63. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  64. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  65. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  66. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  67. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  68. package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -0
  69. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  70. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  71. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
  72. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  73. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  74. package/dist/instance-lifecycle.d.ts +10 -0
  75. package/dist/instance-lifecycle.js +50 -33
  76. package/dist/instance-lifecycle.js.map +1 -1
  77. package/dist/instructions.d.ts +12 -0
  78. package/dist/instructions.js +33 -0
  79. package/dist/instructions.js.map +1 -1
  80. package/dist/locale.js +130 -0
  81. package/dist/locale.js.map +1 -1
  82. package/dist/outbound-schemas.d.ts +2 -1
  83. package/dist/outbound-schemas.js +1 -0
  84. package/dist/outbound-schemas.js.map +1 -1
  85. package/dist/quickstart.js +29 -2
  86. package/dist/quickstart.js.map +1 -1
  87. package/dist/restart-progress.d.ts +7 -1
  88. package/dist/restart-progress.js +27 -6
  89. package/dist/restart-progress.js.map +1 -1
  90. package/dist/setup-wizard.d.ts +4 -2
  91. package/dist/setup-wizard.js +2 -2
  92. package/dist/setup-wizard.js.map +1 -1
  93. package/dist/tool-progress.d.ts +40 -0
  94. package/dist/tool-progress.js +289 -0
  95. package/dist/tool-progress.js.map +1 -0
  96. package/dist/topic-commands.d.ts +26 -0
  97. package/dist/topic-commands.js +117 -1
  98. package/dist/topic-commands.js.map +1 -1
  99. package/dist/transcript-monitor.d.ts +15 -2
  100. package/dist/transcript-monitor.js +63 -17
  101. package/dist/transcript-monitor.js.map +1 -1
  102. package/dist/transcript-sources.d.ts +106 -0
  103. package/dist/transcript-sources.js +428 -0
  104. package/dist/transcript-sources.js.map +1 -0
  105. package/dist/types.d.ts +8 -0
  106. package/dist/ui/dashboard.html +5 -4
  107. package/dist/update-marker.d.ts +30 -0
  108. package/dist/update-marker.js +71 -20
  109. package/dist/update-marker.js.map +1 -1
  110. package/dist/update-progress.d.ts +6 -0
  111. package/dist/update-progress.js +29 -0
  112. package/dist/update-progress.js.map +1 -0
  113. package/dist/web-api.js +2 -2
  114. package/dist/web-api.js.map +1 -1
  115. package/dist/workflow-templates/default.md +2 -1
  116. package/package.json +2 -2
@@ -4,7 +4,8 @@ import { createServer } from "node:http";
4
4
  import { join, dirname, basename } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
7
- import { clearUpdateMarker, isUpdateInProgress } from "./update-marker.js";
7
+ import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
8
+ import { formatUpdateProgress } from "./update-progress.js";
8
9
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
9
10
  import { readFleetMemory } from "./process-memory.js";
10
11
  import { ReplyDeduper } from "./reply-dedup.js";
@@ -29,7 +30,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
29
30
  import { routeToolCall } from "./channel/tool-router.js";
30
31
  import { Scheduler } from "./scheduler/index.js";
31
32
  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";
33
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, CLEAR_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
33
34
  import { DailySummary } from "./daily-summary.js";
34
35
  import { WebhookEmitter } from "./webhook-emitter.js";
35
36
  import { TmuxControlClient } from "./tmux-control.js";
@@ -134,6 +135,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
134
135
  * exactly one progress message per turn, and it is the cancel button itself.
135
136
  */
136
137
  const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
138
+ /** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
139
+ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
137
140
  /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
138
141
  /**
139
142
  * Default delay before the button starts showing elapsed time. Configurable via
@@ -153,6 +156,12 @@ const PROGRESS_ACTIVITY_MAX_CHARS = 48;
153
156
  * 🫡 passes too — it reads as a deliberate acknowledgement, not plumbing.
154
157
  */
155
158
  const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
159
+ /**
160
+ * Reactions that are neither delivery plumbing nor meaningful conversational
161
+ * feedback. Keep this separate from DELIVERY_STATUS_EMOJIS so adding a UI-only
162
+ * emoji never changes the documented delivery-state protocol.
163
+ */
164
+ const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
156
165
  /**
157
166
  * How long a delivery waits out a disconnected instance IPC before giving up.
158
167
  *
@@ -165,6 +174,13 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
165
174
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
166
175
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
167
176
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
177
+ const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
178
+ const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
179
+ const HANG_CALLBACK_PREFIX = "hang:";
180
+ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
181
+ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
182
+ /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
183
+ const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
168
184
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
169
185
  export class FleetManager {
170
186
  dataDir;
@@ -239,6 +255,8 @@ export class FleetManager {
239
255
  replyDeduper = new ReplyDeduper();
240
256
  /** instanceName → what it is doing right now, when the backend can tell us. */
241
257
  instanceActivity = new Map();
258
+ /** instanceName → this turn's tool list (multi-line), for the bubble. */
259
+ instanceProgress = new Map();
242
260
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
243
261
  ipcWaitTails = new Map();
244
262
  /** instanceName → restart currently executing; concurrent callers join it. */
@@ -253,6 +271,8 @@ export class FleetManager {
253
271
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
254
272
  pendingEffortSelects = new Map();
255
273
  pendingModelSelects = new Map();
274
+ /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
275
+ pendingNonceButtons = new Map();
256
276
  // Model failover state
257
277
  failoverActive = new Map(); // instance → current failover model
258
278
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
@@ -274,6 +294,9 @@ export class FleetManager {
274
294
  healthServer = null;
275
295
  healthPortRetried = false;
276
296
  updateCheckTimer = null;
297
+ updateProgressTimer = null;
298
+ updateProgressEditRunning = false;
299
+ lastUpdateProgressText = null;
277
300
  eventLogPruneTimer = null;
278
301
  logRotateTimer = null;
279
302
  /** Days of event/activity history to keep. */
@@ -553,6 +576,45 @@ export class FleetManager {
553
576
  }
554
577
  await data.respond(await this.topicCommands.runPauseWake(target, action));
555
578
  }
579
+ async handleUpdateSlash(data, adapterId) {
580
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
581
+ if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
582
+ await data.respond(t("not_authorized"));
583
+ return;
584
+ }
585
+ const messageId = await data.respond(t("update.progress.preparing", 0));
586
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
587
+ if (messageId && adapter) {
588
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
589
+ this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
590
+ }
591
+ const { spawn } = await import("node:child_process");
592
+ const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
593
+ const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
594
+ const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
595
+ child.once("error", err => this.failUpdateProgress(err.message));
596
+ child.unref();
597
+ }
598
+ /** Admin-only full conversation reset for fleet-topic and Classic instances. */
599
+ async handleClearSlash(data, adapterId) {
600
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
601
+ await data.respond(t("permission.denied"));
602
+ return;
603
+ }
604
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
605
+ if (!name) {
606
+ await data.respond(t("classic.no_agent"));
607
+ return;
608
+ }
609
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
610
+ if (!adapter) {
611
+ await data.respond(t("clear.prompt_unavailable"));
612
+ return;
613
+ }
614
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
615
+ const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
616
+ await data.respond(fallback ?? t("clear.confirm_posted"));
617
+ }
556
618
  /** Get the adapter bound to an instance, falling back to primary adapter */
557
619
  getAdapterForInstance(name) {
558
620
  const worldId = this.getInstanceAdapterId(name);
@@ -1016,19 +1078,34 @@ export class FleetManager {
1016
1078
  this.logger.info({ name }, "Instance already running, skipping");
1017
1079
  return;
1018
1080
  }
1081
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1019
1082
  if (config.general_topic) {
1020
1083
  // antigravity (agy) does not read MCP instructions — fleet context and
1021
1084
  // routing instructions are not injected, so it cannot act as a dispatcher.
1022
- const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1023
1085
  if (backend === "antigravity") {
1024
1086
  this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
1025
1087
  this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
1026
1088
  }
1027
- this.ensureGeneralInstructions(config.working_directory, config.backend);
1089
+ this.ensureGeneralInstructions(config.working_directory, backend, name);
1090
+ }
1091
+ else if (kind === "fleet-topic") {
1092
+ // Workers get only role-eligible on-demand skills. Classic instances are
1093
+ // deliberately excluded: their workspace and conversation lifecycle are
1094
+ // managed independently from fleet-topic workers.
1095
+ try {
1096
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
1097
+ this.syncRoleSkills(skillsWorkDir, backend, "worker");
1098
+ }
1099
+ catch (err) {
1100
+ // Skill publishing is additive. A read-only or temporarily unavailable
1101
+ // workspace must not turn an otherwise valid worker startup into a
1102
+ // fleet outage.
1103
+ this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
1104
+ }
1028
1105
  }
1029
1106
  await this.lifecycle.start(name, config, topicMode, {
1030
1107
  kind,
1031
- backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
1108
+ backend,
1032
1109
  model: this.resolveInstanceModel(name).display,
1033
1110
  });
1034
1111
  // Only clear a stale process status after a real start succeeded. Clearing
@@ -1193,7 +1270,18 @@ export class FleetManager {
1193
1270
  this.instanceStateCache.delete(name);
1194
1271
  this.instanceProcessStatus.delete(name);
1195
1272
  this.lastDeliveryAt.delete(name);
1196
- return this.lifecycle.stop(name);
1273
+ // A pending hang/exit/assist offer refers to the instance being torn down;
1274
+ // left alone it would stay clickable for the rest of its 15 minutes. Clear
1275
+ // again AFTER the stop completes: the teardown itself can emit hang /
1276
+ // interactive-prompt / clean-exit events whose handlers post fresh prompts
1277
+ // while the stop is still awaiting (the TOCTOU sol's review called out).
1278
+ this.clearNoncePromptsForInstance(name);
1279
+ try {
1280
+ return await this.lifecycle.stop(name);
1281
+ }
1282
+ finally {
1283
+ this.clearNoncePromptsForInstance(name);
1284
+ }
1197
1285
  }
1198
1286
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
1199
1287
  async restartSingleInstance(name, opts) {
@@ -1293,6 +1381,65 @@ export class FleetManager {
1293
1381
  getDashboardAccess() {
1294
1382
  return { ready: this.healthServerListening, token: this.webToken };
1295
1383
  }
1384
+ beginUpdateProgress(adapter, chatId, threadId, messageId) {
1385
+ persistUpdateProgress(this.dataDir, {
1386
+ adapterId: adapter.id,
1387
+ chatId,
1388
+ ...(threadId ? { threadId } : {}),
1389
+ messageId,
1390
+ });
1391
+ this.lastUpdateProgressText = null;
1392
+ this.startUpdateProgressMonitor(adapter);
1393
+ }
1394
+ failUpdateProgress(message) {
1395
+ setUpdateProgressStage(this.dataDir, "failed", { error: message });
1396
+ }
1397
+ /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
1398
+ startUpdateProgressMonitor(initialAdapter) {
1399
+ if (this.updateProgressTimer)
1400
+ clearInterval(this.updateProgressTimer);
1401
+ const tick = async () => {
1402
+ if (this.updateProgressEditRunning)
1403
+ return;
1404
+ const marker = readUpdateProgress(this.dataDir);
1405
+ if (!marker) {
1406
+ if (this.updateProgressTimer)
1407
+ clearInterval(this.updateProgressTimer);
1408
+ this.updateProgressTimer = null;
1409
+ return;
1410
+ }
1411
+ const target = marker.progress.target;
1412
+ const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
1413
+ if (!adapter)
1414
+ return;
1415
+ const text = formatUpdateProgress(marker);
1416
+ if (text === this.lastUpdateProgressText)
1417
+ return;
1418
+ this.updateProgressEditRunning = true;
1419
+ try {
1420
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
1421
+ this.lastUpdateProgressText = text;
1422
+ if (marker.progress.stage === "failed" || marker.progress.stage === "complete") {
1423
+ clearUpdateMarker(this.dataDir);
1424
+ if (this.updateProgressTimer)
1425
+ clearInterval(this.updateProgressTimer);
1426
+ this.updateProgressTimer = null;
1427
+ }
1428
+ }
1429
+ catch (err) {
1430
+ this.logger.warn({ err }, "Failed to edit update progress");
1431
+ }
1432
+ finally {
1433
+ this.updateProgressEditRunning = false;
1434
+ }
1435
+ };
1436
+ void tick();
1437
+ // Poll stages faster than the elapsed-time display. npm verification and
1438
+ // service-file refresh can be short; 200ms prevents those stages from being
1439
+ // skipped while the text cache still limits normal edits to once per second.
1440
+ this.updateProgressTimer = setInterval(() => void tick(), 200);
1441
+ this.updateProgressTimer.unref?.();
1442
+ }
1296
1443
  /** Start all instances from fleet config */
1297
1444
  async startAll(configPath) {
1298
1445
  const startupStartedAt = Date.now();
@@ -1307,6 +1454,15 @@ export class FleetManager {
1307
1454
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1308
1455
  const fleet = this.loadConfig(configPath);
1309
1456
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
1457
+ const savedUpdateProgress = readUpdateProgress(this.dataDir);
1458
+ const pendingUpdateProgress = savedUpdateProgress
1459
+ && savedUpdateProgress.progress.stage !== "failed"
1460
+ && savedUpdateProgress.progress.stage !== "complete"
1461
+ ? savedUpdateProgress
1462
+ : null;
1463
+ if (pendingUpdateProgress) {
1464
+ setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
1465
+ }
1310
1466
  this.initializeWebAuthTokens();
1311
1467
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
1312
1468
  // Set tmux socket isolation for custom AGEND_HOME
@@ -1354,9 +1510,8 @@ export class FleetManager {
1354
1510
  // routing does NOT go through the routing engine (single-key, can't hold two
1355
1511
  // bots in one channel) — it resolves per-bot via getInstanceByChannel.
1356
1512
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
1357
- const primaryCh = fleet.channels?.[0] ?? fleet.channel;
1358
- if (primaryCh)
1359
- this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
1513
+ const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
1514
+ this.classicChannels.configureAdapters(classicAdapters);
1360
1515
  // Restore the persisted bot binding so replies/cancel go through the right
1361
1516
  // bot after a restart (before this, inbound would re-bind lazily).
1362
1517
  for (const ch of this.classicChannels.getAll()) {
@@ -1525,7 +1680,7 @@ export class FleetManager {
1525
1680
  const generalDir = join(getAgendHome(), name);
1526
1681
  mkdirSync(generalDir, { recursive: true });
1527
1682
  const backendName = fleet.defaults.backend ?? "claude-code";
1528
- this.ensureGeneralInstructions(generalDir, backendName);
1683
+ this.ensureGeneralInstructions(generalDir, backendName, name);
1529
1684
  fleet.instances[name] = {
1530
1685
  ...DEFAULT_INSTANCE_CONFIG,
1531
1686
  working_directory: generalDir,
@@ -1563,7 +1718,7 @@ export class FleetManager {
1563
1718
  const allEntries = Object.entries(fleet.instances);
1564
1719
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
1565
1720
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
1566
- const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
1721
+ const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
1567
1722
  if (generals.length > 0) {
1568
1723
  for (const [name, cfg] of generals) {
1569
1724
  try {
@@ -1602,7 +1757,19 @@ export class FleetManager {
1602
1757
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1603
1758
  }
1604
1759
  })();
1605
- progressStart = adapterStartup.then(() => startupProgress.start(this.restartProgressTarget()));
1760
+ progressStart = adapterStartup.then(() => {
1761
+ if (pendingUpdateProgress) {
1762
+ const saved = pendingUpdateProgress.progress.target;
1763
+ const adapter = this.adapters.get(saved.adapterId);
1764
+ const target = adapter ? {
1765
+ adapter,
1766
+ chatId: saved.chatId,
1767
+ threadId: saved.threadId,
1768
+ } : null;
1769
+ return startupProgress.resume(target, saved.messageId);
1770
+ }
1771
+ return startupProgress.start(this.restartProgressTarget());
1772
+ });
1606
1773
  }
1607
1774
  // The systemd watchdog answers exactly one question: is this process still
1608
1775
  // turning its event loop? Pinging from a timer proves that, and after the
@@ -1980,31 +2147,20 @@ export class FleetManager {
1980
2147
  await this.handleInboundReaction(r);
1981
2148
  }, this.logger, "adapter.reaction"));
1982
2149
  this.adapter.on("callback_query", safeHandler(async (data) => {
2150
+ if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2151
+ return;
2152
+ if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
2153
+ return;
2154
+ if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
2155
+ return;
1983
2156
  if (await this.handleClassicBackendSelection(data))
1984
2157
  return;
1985
2158
  if (await this.handleModelSelection(data))
1986
2159
  return;
1987
2160
  if (await this.handleEffortSelection(data))
1988
2161
  return;
1989
- if (data.callbackData.startsWith("hang:")) {
1990
- const parts = data.callbackData.split(":");
1991
- const action = parts[1];
1992
- const instanceName = parts[2];
1993
- if (action === "restart") {
1994
- await this.stopInstance(instanceName);
1995
- const config = this.fleetConfig?.instances[instanceName];
1996
- if (config) {
1997
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
1998
- await this.startInstance(instanceName, config, topicMode);
1999
- // startInstance already calls connectIpcToInstance
2000
- }
2001
- this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
2002
- }
2003
- else {
2004
- this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
2005
- }
2162
+ if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
2006
2163
  return;
2007
- }
2008
2164
  if (data.callbackData.startsWith("cancel:")) {
2009
2165
  this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
2010
2166
  return;
@@ -2083,6 +2239,29 @@ export class FleetManager {
2083
2239
  const result = await this.topicCommands.sendCompact(name);
2084
2240
  await data.respond(result);
2085
2241
  }
2242
+ else if (data.command === "steer") {
2243
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2244
+ if (!name) {
2245
+ await data.respond(t("classic.no_agent"));
2246
+ return;
2247
+ }
2248
+ const steerText = String(data.options?.message ?? "").trim();
2249
+ if (!steerText) {
2250
+ await data.respond(t("steer.usage"));
2251
+ return;
2252
+ }
2253
+ // chat_id/message_id stay empty: a slash interaction has no channel
2254
+ // message to react to, and an empty chat_id keeps updateLastChat from
2255
+ // rerouting the instance's replies to the slash context.
2256
+ const result = this.topicCommands.sendSteer(name, steerText, {
2257
+ chatId: "", messageId: "", username: data.username ?? "user",
2258
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2259
+ });
2260
+ await data.respond(result);
2261
+ }
2262
+ else if (data.command === "clear") {
2263
+ await this.handleClearSlash(data, adapterId);
2264
+ }
2086
2265
  else if (data.command === "model") {
2087
2266
  await this.handleModelSlash(data, adapterId);
2088
2267
  }
@@ -2135,17 +2314,7 @@ export class FleetManager {
2135
2314
  : t("collab.off.classic"));
2136
2315
  }
2137
2316
  else if (data.command === "update") {
2138
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
2139
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
2140
- await data.respond(t("not_authorized"));
2141
- return;
2142
- }
2143
- await data.respond(t("update.running"));
2144
- const { spawn } = await import("node:child_process");
2145
- const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
2146
- const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
2147
- const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
2148
- child.unref();
2317
+ await this.handleUpdateSlash(data, adapterId);
2149
2318
  }
2150
2319
  else if (data.command === "doctor") {
2151
2320
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
@@ -2214,6 +2383,26 @@ export class FleetManager {
2214
2383
  const result = await this.topicCommands.sendCompact(name);
2215
2384
  await data.respond(result);
2216
2385
  }
2386
+ else if (data.command === "steer") {
2387
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2388
+ if (!name) {
2389
+ await data.respond(t("classic.no_agent"));
2390
+ return;
2391
+ }
2392
+ const steerText = String(data.options?.message ?? "").trim();
2393
+ if (!steerText) {
2394
+ await data.respond(t("steer.usage"));
2395
+ return;
2396
+ }
2397
+ // chat_id/message_id stay empty: a slash interaction has no channel
2398
+ // message to react to, and an empty chat_id keeps updateLastChat from
2399
+ // rerouting the instance's replies to the slash context.
2400
+ const result = this.topicCommands.sendSteer(name, steerText, {
2401
+ chatId: "", messageId: "", username: data.username ?? "user",
2402
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2403
+ });
2404
+ await data.respond(result);
2405
+ }
2217
2406
  }, this.logger, "adapter.slash_command"));
2218
2407
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2219
2408
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2290,30 +2479,20 @@ export class FleetManager {
2290
2479
  await this.handleInboundReaction(r);
2291
2480
  }, this.logger, `adapter[${adapterId}].reaction`));
2292
2481
  adapter.on("callback_query", safeHandler(async (data) => {
2482
+ if (await this.handleClearConfirmation(data, adapterId, adapter))
2483
+ return;
2484
+ if (await this.handleExitRestartPrompt(data, adapterId, adapter))
2485
+ return;
2486
+ if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
2487
+ return;
2293
2488
  if (await this.handleClassicBackendSelection(data))
2294
2489
  return;
2295
2490
  if (await this.handleModelSelection(data))
2296
2491
  return;
2297
2492
  if (await this.handleEffortSelection(data))
2298
2493
  return;
2299
- if (data.callbackData.startsWith("hang:")) {
2300
- const parts = data.callbackData.split(":");
2301
- const action = parts[1];
2302
- const instanceName = parts[2];
2303
- if (action === "restart") {
2304
- await this.stopInstance(instanceName);
2305
- const config = this.fleetConfig?.instances[instanceName];
2306
- if (config) {
2307
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
2308
- await this.startInstance(instanceName, config, topicMode);
2309
- }
2310
- adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
2311
- }
2312
- else {
2313
- adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
2314
- }
2494
+ if (await this.handleHangPrompt(data, adapterId, adapter))
2315
2495
  return;
2316
- }
2317
2496
  if (data.callbackData.startsWith("cancel:")) {
2318
2497
  this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
2319
2498
  return;
@@ -2385,6 +2564,9 @@ export class FleetManager {
2385
2564
  else if (data.command === "model") {
2386
2565
  await this.handleModelSlash(data, adapterId);
2387
2566
  }
2567
+ else if (data.command === "clear") {
2568
+ await this.handleClearSlash(data, adapterId);
2569
+ }
2388
2570
  else if (data.command === "effort") {
2389
2571
  await this.handleEffortSlash(data, adapterId);
2390
2572
  }
@@ -2434,17 +2616,7 @@ export class FleetManager {
2434
2616
  : t("collab.off.classic"));
2435
2617
  }
2436
2618
  else if (data.command === "update") {
2437
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
2438
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
2439
- await data.respond(t("not_authorized"));
2440
- return;
2441
- }
2442
- await data.respond(t("update.running"));
2443
- const { spawn } = await import("node:child_process");
2444
- const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
2445
- const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
2446
- const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
2447
- child.unref();
2619
+ await this.handleUpdateSlash(data, adapterId);
2448
2620
  }
2449
2621
  else if (data.command === "doctor") {
2450
2622
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
@@ -2513,6 +2685,26 @@ export class FleetManager {
2513
2685
  const result = await this.topicCommands.sendCompact(name);
2514
2686
  await data.respond(result);
2515
2687
  }
2688
+ else if (data.command === "steer") {
2689
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2690
+ if (!name) {
2691
+ await data.respond(t("classic.no_agent"));
2692
+ return;
2693
+ }
2694
+ const steerText = String(data.options?.message ?? "").trim();
2695
+ if (!steerText) {
2696
+ await data.respond(t("steer.usage"));
2697
+ return;
2698
+ }
2699
+ // chat_id/message_id stay empty: a slash interaction has no channel
2700
+ // message to react to, and an empty chat_id keeps updateLastChat from
2701
+ // rerouting the instance's replies to the slash context.
2702
+ const result = this.topicCommands.sendSteer(name, steerText, {
2703
+ chatId: "", messageId: "", username: data.username ?? "user",
2704
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2705
+ });
2706
+ await data.respond(result);
2707
+ }
2516
2708
  }, this.logger, `adapter[${adapterId}].slash_command`));
2517
2709
  await adapter.start();
2518
2710
  if (channelConfig.group_id) {
@@ -2607,9 +2799,6 @@ export class FleetManager {
2607
2799
  }
2608
2800
  await this.handleOutboundFromInstance(name, msg);
2609
2801
  }
2610
- else if (msg.type === "fleet_tool_status") {
2611
- this.handleToolStatusFromInstance(name, msg);
2612
- }
2613
2802
  else if (msg.type === "fleet_schedule_create" || msg.type === "fleet_schedule_list" ||
2614
2803
  msg.type === "fleet_schedule_update" || msg.type === "fleet_schedule_delete") {
2615
2804
  this.handleScheduleCrud(name, msg);
@@ -2633,6 +2822,9 @@ export class FleetManager {
2633
2822
  else if (msg.type === "instance_activity") {
2634
2823
  this.cacheInstanceActivity(name, msg.activity);
2635
2824
  }
2825
+ else if (msg.type === "instance_progress") {
2826
+ this.cacheInstanceProgress(name, msg.progress || null);
2827
+ }
2636
2828
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
2637
2829
  this.cacheInstanceExecutionState(name, msg);
2638
2830
  if (msg.type === "instance_state_response") {
@@ -2821,6 +3013,10 @@ export class FleetManager {
2821
3013
  this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
2822
3014
  return;
2823
3015
  }
3016
+ if (IGNORED_REACTION_EMOJIS.has(r.emoji)) {
3017
+ this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring non-contextual emoji reaction");
3018
+ return;
3019
+ }
2824
3020
  this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
2825
3021
  if (r.action === "add") {
2826
3022
  this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
@@ -3076,6 +3272,43 @@ export class FleetManager {
3076
3272
  await msgAdapter?.sendText(chatId, result);
3077
3273
  return;
3078
3274
  }
3275
+ // /steer — interject into the running turn. Not admin-gated: anyone who
3276
+ // can talk to this agent can send it a message; steer only changes when
3277
+ // it lands, and it keeps the full [user:] formatting (unlike /raw).
3278
+ if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
3279
+ const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3280
+ if (!steerName) {
3281
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3282
+ return;
3283
+ }
3284
+ const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
3285
+ if (!steerContent) {
3286
+ await msgAdapter?.sendText(chatId, t("steer.usage"));
3287
+ return;
3288
+ }
3289
+ const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
3290
+ await msgAdapter?.sendText(chatId, result);
3291
+ return;
3292
+ }
3293
+ // Handle /clear command (admin only) — unlike /compact this starts a
3294
+ // fresh conversation and intentionally discards the current history.
3295
+ if (text === "/clear" || text.startsWith("/clear@")) {
3296
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
3297
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/clear"));
3298
+ return;
3299
+ }
3300
+ const clearName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3301
+ if (!clearName) {
3302
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3303
+ return;
3304
+ }
3305
+ if (!msgAdapter)
3306
+ return;
3307
+ const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
3308
+ if (fallback)
3309
+ await msgAdapter.sendText(chatId, fallback);
3310
+ return;
3311
+ }
3079
3312
  // Handle /cancel command
3080
3313
  if (text === "/cancel" || text.startsWith("/cancel@")) {
3081
3314
  const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
@@ -3466,7 +3699,7 @@ export class FleetManager {
3466
3699
  this.clearCancelButton(instanceName);
3467
3700
  }
3468
3701
  else {
3469
- void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
3702
+ void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
3470
3703
  }
3471
3704
  this.reactDone(instanceName);
3472
3705
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
@@ -3482,34 +3715,6 @@ export class FleetManager {
3482
3715
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
3483
3716
  }
3484
3717
  }
3485
- /** Handle tool status update from a daemon instance */
3486
- handleToolStatusFromInstance(instanceName, msg) {
3487
- const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3488
- if (!statusAdapter)
3489
- return;
3490
- const text = msg.text;
3491
- const editMessageId = msg.editMessageId;
3492
- const senderSessionName = msg.senderSessionName;
3493
- const senderInstanceName = senderSessionName && this.fleetConfig?.instances[senderSessionName]
3494
- ? senderSessionName
3495
- : null;
3496
- const routingConfig = senderInstanceName
3497
- ? this.fleetConfig?.instances[senderInstanceName]
3498
- : (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]);
3499
- const threadId = routingConfig?.topic_id ? String(routingConfig.topic_id) : undefined;
3500
- const chatId = statusAdapter.getChatId();
3501
- if (!chatId)
3502
- return;
3503
- if (editMessageId) {
3504
- statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
3505
- }
3506
- else {
3507
- statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
3508
- const ipc = this.instanceIpcClients.get(instanceName);
3509
- ipc?.send({ type: "fleet_tool_status_ack", messageId: sent.messageId });
3510
- }).catch(e => this.logger.warn({ err: e }, "Failed to send tool status message"));
3511
- }
3512
- }
3513
3718
  // ===================== Scheduler =====================
3514
3719
  async handleScheduleTrigger(schedule) {
3515
3720
  const { target, reply_chat_id, reply_thread_id, message, label, id, source, silent } = schedule;
@@ -4243,7 +4448,13 @@ export class FleetManager {
4243
4448
  }
4244
4449
  catch (err) {
4245
4450
  const e = err;
4246
- return stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
4451
+ const output = stripAnsi(e.stdout ?? "").trim();
4452
+ if (output)
4453
+ return output;
4454
+ if (e.code === "ENOENT") {
4455
+ return "Backend doctor unavailable — agend CLI not found in PATH";
4456
+ }
4457
+ return stripAnsi(e.message ?? "").trim() || "Doctor failed";
4247
4458
  }
4248
4459
  }
4249
4460
  /** Drop event/activity rows older than the retention window. Best-effort. */
@@ -4395,6 +4606,371 @@ export class FleetManager {
4395
4606
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
4396
4607
  }
4397
4608
  }
4609
+ // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
4610
+ //
4611
+ // One shared lifecycle for every "notification with decision buttons":
4612
+ // post with a 128-bit nonce, arm a bounded expiry, bind the click to the
4613
+ // exact adapter+chat+thread+message that created it, require fleet admin,
4614
+ // consume exactly once. The features differ only in what they post
4615
+ // and what a consumed click does.
4616
+ /**
4617
+ * Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
4618
+ * The entry is registered before the send and rolled back if the send
4619
+ * fails, so a nonce in the map always refers to a message that exists (or
4620
+ * is about to). Returns the nonce, or null when the alert could not be sent.
4621
+ */
4622
+ async postNonceButtonPrompt(opts) {
4623
+ // 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
4624
+ // callback_data cap still holds: longest id is "interactive-assist:" (19)
4625
+ // + 32 hex + ":confirm" (8) = 59 bytes.
4626
+ const nonce = randomBytes(16).toString("hex");
4627
+ const entry = {
4628
+ prefix: opts.prefix,
4629
+ instanceName: opts.instanceName,
4630
+ adapterId: opts.adapterId,
4631
+ adapter: opts.adapter,
4632
+ chatId: opts.chatId,
4633
+ threadId: opts.threadId,
4634
+ expiredText: opts.expiredText,
4635
+ ...opts.extra,
4636
+ };
4637
+ entry.timer = setTimeout(() => {
4638
+ const pending = this.pendingNonceButtons.get(nonce);
4639
+ if (pending !== entry)
4640
+ return;
4641
+ this.pendingNonceButtons.delete(nonce);
4642
+ if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
4643
+ 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"));
4644
+ }
4645
+ }, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
4646
+ entry.timer.unref?.();
4647
+ this.pendingNonceButtons.set(nonce, entry);
4648
+ try {
4649
+ const sent = await opts.adapter.notifyAlert(opts.chatId, {
4650
+ type: opts.alertType,
4651
+ instanceName: opts.instanceName,
4652
+ message: opts.message,
4653
+ choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
4654
+ }, opts.threadId ? { threadId: opts.threadId } : undefined);
4655
+ entry.messageId = sent.messageId;
4656
+ return nonce;
4657
+ }
4658
+ catch (err) {
4659
+ this.pendingNonceButtons.delete(nonce);
4660
+ if (entry.timer)
4661
+ clearTimeout(entry.timer);
4662
+ this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
4663
+ return null;
4664
+ }
4665
+ }
4666
+ /**
4667
+ * Claim a nonce-armed callback exactly once.
4668
+ *
4669
+ * Returns:
4670
+ * - null — the callback is not for this prefix; try the next handler
4671
+ * - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
4672
+ * - the entry+action — the click is authorized and now claimed (removed
4673
+ * from the map before any await, so double clicks cannot act twice)
4674
+ *
4675
+ * A stale click (expired nonce, or a pre-upgrade button whose payload no
4676
+ * longer parses) collapses the clicked message so the dead button stops
4677
+ * inviting clicks — the same courtesy the cancel button extends.
4678
+ */
4679
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
4680
+ if (!data.callbackData.startsWith(prefix))
4681
+ return null;
4682
+ const match = data.callbackData.match(actionRe);
4683
+ let pending = match ? this.pendingNonceButtons.get(match[1]) : undefined;
4684
+ // The map is shared across prompt kinds. A nonce that resolves to an entry
4685
+ // of a DIFFERENT kind is not a usable capability for this handler — treat
4686
+ // it as stale rather than acting across kinds (fail closed).
4687
+ if (pending && pending.prefix !== prefix)
4688
+ pending = undefined;
4689
+ if (!match || !pending) {
4690
+ const adapter = receivingAdapter ?? this.adapter;
4691
+ adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
4692
+ return "consumed";
4693
+ }
4694
+ // Bind the capability to the exact message/world that created it. Telegram
4695
+ // keyboards are visible to everyone, so the click also requires fleet admin.
4696
+ const isAuthorized = data.userId
4697
+ ? pending.authChannelId
4698
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
4699
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
4700
+ : false;
4701
+ if (pending.adapterId !== callbackAdapterId
4702
+ || data.chatId !== pending.chatId
4703
+ || (pending.threadId != null && data.threadId !== pending.threadId)
4704
+ || (pending.messageId != null && data.messageId !== pending.messageId)
4705
+ || !isAuthorized) {
4706
+ // Deliberately does NOT consume the nonce: the real admin can still click.
4707
+ this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
4708
+ return "consumed";
4709
+ }
4710
+ // Claim before any await: double clicks and duplicate callback delivery can
4711
+ // never act twice.
4712
+ this.pendingNonceButtons.delete(match[1]);
4713
+ if (pending.timer)
4714
+ clearTimeout(pending.timer);
4715
+ return { entry: pending, action: match[2] };
4716
+ }
4717
+ /** Collapse a consumed prompt's buttons into a final status line. */
4718
+ async retireNonceButtons(pending, messageId, text) {
4719
+ if (!pending.adapter.editMessageRemoveButtons) {
4720
+ this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
4721
+ return;
4722
+ }
4723
+ try {
4724
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
4725
+ }
4726
+ catch (err) {
4727
+ // The action was already atomically consumed. An edit failure must not
4728
+ // undo that or make the button actionable again.
4729
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
4730
+ }
4731
+ }
4732
+ /** Post the destructive `/clear` confirmation in the invoking channel. */
4733
+ async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
4734
+ if (!this.topicCommands.supportsClear(instanceName))
4735
+ return CLEAR_UNSUPPORTED_MSG;
4736
+ const adapterId = adapter.id;
4737
+ if (!adapterId)
4738
+ return t("clear.prompt_unavailable");
4739
+ const nonce = await this.postNonceButtonPrompt({
4740
+ prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
4741
+ alertType: "clear_confirm",
4742
+ instanceName,
4743
+ adapter,
4744
+ adapterId,
4745
+ chatId,
4746
+ threadId,
4747
+ message: t("clear.confirm_message", instanceName),
4748
+ choices: [
4749
+ { action: "confirm", label: t("clear.confirm") },
4750
+ { action: "cancel", label: t("clear.cancel") },
4751
+ ],
4752
+ expiredText: t("clear.expired", instanceName),
4753
+ extra: { authChannelId: channelId },
4754
+ timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
4755
+ });
4756
+ return nonce ? null : t("clear.prompt_unavailable");
4757
+ }
4758
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
4759
+ async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
4760
+ const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
4761
+ if (claimed === null)
4762
+ return false;
4763
+ if (claimed === "consumed")
4764
+ return true;
4765
+ const { entry: pending, action } = claimed;
4766
+ this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
4767
+ if (action === "cancel") {
4768
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
4769
+ return true;
4770
+ }
4771
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
4772
+ try {
4773
+ const result = await this.topicCommands.sendClear(pending.instanceName);
4774
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
4775
+ }
4776
+ catch (err) {
4777
+ const message = err instanceof Error ? err.message : String(err);
4778
+ this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
4779
+ 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"));
4780
+ }
4781
+ return true;
4782
+ }
4783
+ /**
4784
+ * Drop every pending prompt that refers to an instance being stopped or
4785
+ * restarted — a restart offer for an instance the operator just restarted
4786
+ * (or an assist for one they stopped) must not stay clickable for the rest
4787
+ * of its 15 minutes.
4788
+ */
4789
+ clearNoncePromptsForInstance(instanceName) {
4790
+ for (const [nonce, entry] of this.pendingNonceButtons) {
4791
+ if (entry.instanceName !== instanceName)
4792
+ continue;
4793
+ this.pendingNonceButtons.delete(nonce);
4794
+ if (entry.timer)
4795
+ clearTimeout(entry.timer);
4796
+ if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
4797
+ entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
4798
+ .catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
4799
+ }
4800
+ }
4801
+ }
4802
+ /** A clean exit is intentional from the CLI's perspective, but often not from
4803
+ * the operator's. Keep the instance notice passive and put the action in the
4804
+ * same-world General topic where an administrator can make the choice. */
4805
+ async notifyNormalExit(instanceName) {
4806
+ this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
4807
+ const worldId = this.getInstanceAdapterId(instanceName);
4808
+ const generalName = this.findGeneralInstance(worldId);
4809
+ if (!generalName) {
4810
+ this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
4811
+ return;
4812
+ }
4813
+ const adapterId = this.getInstanceAdapterId(generalName);
4814
+ const adapter = this.getAdapterForInstance(generalName);
4815
+ const chatId = this.getGroupIdForInstance(generalName);
4816
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
4817
+ const threadId = topicId != null ? String(topicId) : undefined;
4818
+ if (!adapter || !adapterId || !chatId) {
4819
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
4820
+ return;
4821
+ }
4822
+ await this.postNonceButtonPrompt({
4823
+ prefix: EXIT_RESTART_CALLBACK_PREFIX,
4824
+ alertType: "exit_restart",
4825
+ instanceName,
4826
+ adapter,
4827
+ adapterId,
4828
+ chatId,
4829
+ threadId,
4830
+ message: t("exit.general_notice", instanceName),
4831
+ choices: [
4832
+ { action: "restart", label: t("exit.restart") },
4833
+ { action: "ignore", label: t("exit.ignore") },
4834
+ ],
4835
+ expiredText: t("exit.expired", instanceName),
4836
+ });
4837
+ }
4838
+ /** Consume a clean-exit Restart/Ignore button exactly once. */
4839
+ async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
4840
+ const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
4841
+ if (claimed === null)
4842
+ return false;
4843
+ if (claimed === "consumed")
4844
+ return true;
4845
+ const { entry: pending, action } = claimed;
4846
+ this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
4847
+ if (action === "ignore") {
4848
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
4849
+ return true;
4850
+ }
4851
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
4852
+ try {
4853
+ await this.restartSingleInstance(pending.instanceName);
4854
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
4855
+ }
4856
+ catch (err) {
4857
+ const message = err instanceof Error ? err.message : String(err);
4858
+ this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
4859
+ 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"));
4860
+ }
4861
+ return true;
4862
+ }
4863
+ interactivePromptLabel(kind) {
4864
+ const key = `interactive.kind.${kind}`;
4865
+ const translated = t(key);
4866
+ return translated === key ? kind.replaceAll("_", " ") : translated;
4867
+ }
4868
+ /**
4869
+ * Surface a stable terminal prompt in both places that matter:
4870
+ * - the blocked instance gets a plain pointer to General;
4871
+ * - General gets one-shot Confirm/Cancel controls on its own bound adapter.
4872
+ *
4873
+ * Only the prompt category crosses channels. The captured terminal line can
4874
+ * contain usernames or secret-adjacent text and stays in daemon.log.
4875
+ */
4876
+ async notifyInteractivePrompt(instanceName, kind) {
4877
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
4878
+ // Multi-world fleets can have one General per bot/platform. Route the assist
4879
+ // request to the General bound to the same adapter as the blocked instance.
4880
+ const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
4881
+ if (!generalName) {
4882
+ this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
4883
+ return;
4884
+ }
4885
+ const adapterId = this.getInstanceAdapterId(generalName);
4886
+ const adapter = this.getAdapterForInstance(generalName);
4887
+ const chatId = this.getGroupIdForInstance(generalName);
4888
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
4889
+ const threadId = topicId != null ? String(topicId) : undefined;
4890
+ if (!adapter || !adapterId || !chatId) {
4891
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
4892
+ return;
4893
+ }
4894
+ const label = this.interactivePromptLabel(kind);
4895
+ await this.postNonceButtonPrompt({
4896
+ prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
4897
+ alertType: "interactive_prompt",
4898
+ instanceName,
4899
+ adapter,
4900
+ adapterId,
4901
+ chatId,
4902
+ threadId,
4903
+ message: t("interactive.general_notice", instanceName, label),
4904
+ choices: [
4905
+ { action: "confirm", label: t("interactive.confirm") },
4906
+ { action: "cancel", label: t("interactive.cancel") },
4907
+ ],
4908
+ expiredText: t("interactive.expired", instanceName),
4909
+ extra: { generalName, promptKind: kind },
4910
+ });
4911
+ }
4912
+ /** Consume a General assist button exactly once. */
4913
+ async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
4914
+ const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
4915
+ if (claimed === null)
4916
+ return false;
4917
+ if (claimed === "consumed")
4918
+ return true;
4919
+ const { entry: pending, action } = claimed;
4920
+ this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
4921
+ action,
4922
+ userId: data.userId,
4923
+ generalName: pending.generalName,
4924
+ });
4925
+ if (action === "cancel") {
4926
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
4927
+ return true;
4928
+ }
4929
+ // Fail closed on a malformed entry. generalName is set at posting time for
4930
+ // every interactive-assist entry; its absence means entry confusion, and
4931
+ // falling back to the blocked instance would type the assist text into the
4932
+ // very terminal prompt this feature exists to keep humans in front of.
4933
+ const generalName = pending.generalName;
4934
+ if (!generalName) {
4935
+ this.logger.error({ instanceName: pending.instanceName }, "Interactive-assist entry has no General target — refusing to deliver");
4936
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName));
4937
+ return true;
4938
+ }
4939
+ // Injecting into General while General itself is blocked would type into the
4940
+ // terminal prompt instead of the agent input. Fail safe and require attach.
4941
+ if (pending.instanceName === generalName) {
4942
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
4943
+ return true;
4944
+ }
4945
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
4946
+ try {
4947
+ await this.deliverToInstance(generalName, {
4948
+ type: "fleet_inbound",
4949
+ content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
4950
+ targetSession: generalName,
4951
+ meta: {
4952
+ chat_id: pending.chatId,
4953
+ message_id: pending.messageId ?? data.messageId,
4954
+ user: "AgEnD",
4955
+ user_id: data.userId,
4956
+ ts: new Date().toISOString(),
4957
+ thread_id: pending.threadId ?? "",
4958
+ adapter_id: pending.adapterId,
4959
+ source: pending.adapter.type,
4960
+ },
4961
+ }, {
4962
+ // This is system-generated work, not a live user message. Queue behind
4963
+ // General's current turn so Kiro/Claude do not lose it while busy.
4964
+ isCrossInstance: true,
4965
+ });
4966
+ this.logger.info({ instanceName: pending.instanceName, generalName: pending.generalName }, "Interactive prompt assistance delivered to General");
4967
+ }
4968
+ catch (err) {
4969
+ this.logger.warn({ err, instanceName: pending.instanceName, generalName: pending.generalName }, "Failed to deliver interactive prompt assistance to General");
4970
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName), pending.threadId);
4971
+ }
4972
+ return true;
4973
+ }
4398
4974
  // ── Cancel button ────────────────────────────────────────────────────
4399
4975
  // Sent after delivering a user message to an instance; clicking it (or
4400
4976
  // /cancel) sends Escape to the instance's pane to interrupt generation.
@@ -4483,7 +5059,7 @@ export class FleetManager {
4483
5059
  }
4484
5060
  return false;
4485
5061
  }
4486
- async sendCancelButton(instanceName, correlationId) {
5062
+ async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
4487
5063
  // Post first, retire after (see the tail of this method). Retiring up front
4488
5064
  // meant that from the delete until the new message came back — a chat API
4489
5065
  // round trip, and every reply goes through here — the instance had NO live
@@ -4544,6 +5120,10 @@ export class FleetManager {
4544
5120
  // not re-edit identical text — which put a "(edited)" mark on Discord
4545
5121
  // with nothing visibly changed.
4546
5122
  lastProgressText: "👀 處理中…",
5123
+ // A reply can re-post the same in-flight bubble below the reply. Carry
5124
+ // its current list into that replacement; fresh inbound work must start
5125
+ // empty even if the daemon's reset broadcast is still in flight.
5126
+ toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
4547
5127
  };
4548
5128
  this.startProgressTicker(entry);
4549
5129
  // Idle-check backstop: every 5min, if the instance is idle, retire the
@@ -4639,6 +5219,88 @@ export class FleetManager {
4639
5219
  else
4640
5220
  this.instanceActivity.delete(name);
4641
5221
  }
5222
+ /**
5223
+ * Cache the turn's tool-progress list and push it into the instance's live
5224
+ * bubble, coalesced so a burst of tool events cannot flood the Bot API
5225
+ * (Telegram's flood limit is per-chat across ALL forum topics, so edits are
5226
+ * rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
5227
+ */
5228
+ cacheInstanceProgress(name, progress) {
5229
+ if (progress)
5230
+ this.instanceProgress.set(name, progress);
5231
+ else
5232
+ this.instanceProgress.delete(name);
5233
+ for (const entry of this.cancelButtons.values()) {
5234
+ if (entry.instanceName !== name)
5235
+ continue;
5236
+ // Empty means the daemon completed/reset the turn. Clear the live cache
5237
+ // for the next turn, but retain this bubble's last non-empty list so its
5238
+ // retirement can preserve the history instead of deleting the message.
5239
+ if (progress)
5240
+ entry.toolProgress = progress;
5241
+ this.scheduleProgressEdit(entry);
5242
+ }
5243
+ }
5244
+ /** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
5245
+ scheduleProgressEdit(entry) {
5246
+ if (entry.retiring)
5247
+ return;
5248
+ const since = Date.now() - (entry.lastProgressEditAt ?? 0);
5249
+ if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
5250
+ this.refreshBubble(entry);
5251
+ return;
5252
+ }
5253
+ if (entry.progressEditTimer)
5254
+ return; // trailing edit already scheduled
5255
+ entry.progressEditTimer = setTimeout(() => {
5256
+ entry.progressEditTimer = undefined;
5257
+ this.refreshBubble(entry);
5258
+ }, TOOL_PROGRESS_EDIT_MIN_MS - since);
5259
+ entry.progressEditTimer.unref?.();
5260
+ }
5261
+ /**
5262
+ * The ONE composer for the bubble text. Both writers — the elapsed-time
5263
+ * ticker and the tool-progress push — go through here; two independent
5264
+ * renderers editing the same message is how the progress list used to get
5265
+ * wiped by the next elapsed tick (#528 trap 2).
5266
+ */
5267
+ composeBubbleText(entry) {
5268
+ return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
5269
+ }
5270
+ /** Pure composition of header + tool list, exposed for tests. */
5271
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
5272
+ const header = FleetManager.progressText(elapsedMs,
5273
+ // The single-line activity detail is redundant once a tool list exists.
5274
+ progress ? undefined : activity, minElapsedMs);
5275
+ return progress ? `${header}\n${progress}` : header;
5276
+ }
5277
+ /** Recompose and edit the bubble in place; skips when nothing changed. */
5278
+ refreshBubble(entry) {
5279
+ if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
5280
+ clearInterval(entry.progressTimer);
5281
+ return;
5282
+ }
5283
+ const text = this.composeBubbleText(entry);
5284
+ if (text === entry.lastProgressText)
5285
+ return; // nothing changed — skip the API call
5286
+ const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5287
+ if (!adapter?.editAlert)
5288
+ return;
5289
+ entry.lastProgressText = text;
5290
+ entry.lastProgressEditAt = Date.now();
5291
+ adapter.editAlert(entry.chatId, entry.messageId, {
5292
+ type: "cancel",
5293
+ instanceName: entry.instanceName,
5294
+ message: text,
5295
+ choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5296
+ }, entry.threadId ? { threadId: entry.threadId } : undefined)
5297
+ .catch(err => {
5298
+ // A failed progress edit must never escalate: the button still works and
5299
+ // the next tick retries. Common causes are a deleted message or a
5300
+ // rate limit.
5301
+ this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5302
+ });
5303
+ }
4642
5304
  /**
4643
5305
  * Refresh the button's text in place while the instance keeps working.
4644
5306
  *
@@ -4656,31 +5318,7 @@ export class FleetManager {
4656
5318
  return PROGRESS_MIN_ELAPSED_MS;
4657
5319
  }
4658
5320
  startProgressTicker(entry) {
4659
- const tick = () => {
4660
- if (!this.cancelButtons.has(entry.messageId)) {
4661
- clearInterval(entry.progressTimer);
4662
- return;
4663
- }
4664
- const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
4665
- if (text === entry.lastProgressText)
4666
- return; // nothing changed — skip the API call
4667
- const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
4668
- if (!adapter?.editAlert)
4669
- return;
4670
- entry.lastProgressText = text;
4671
- adapter.editAlert(entry.chatId, entry.messageId, {
4672
- type: "cancel",
4673
- instanceName: entry.instanceName,
4674
- message: text,
4675
- choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
4676
- }, entry.threadId ? { threadId: entry.threadId } : undefined)
4677
- .catch(err => {
4678
- // A failed progress edit must never escalate: the button still works and
4679
- // the next tick retries. Common causes are a deleted message or a
4680
- // rate limit.
4681
- this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
4682
- });
4683
- };
5321
+ const tick = () => this.refreshBubble(entry);
4684
5322
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
4685
5323
  entry.progressTimer.unref?.();
4686
5324
  // One extra tick right when the threshold passes, so a 30s threshold shows
@@ -4736,7 +5374,7 @@ export class FleetManager {
4736
5374
  this.deleteButtonMessage(entry)
4737
5375
  .then(() => {
4738
5376
  this.discardButton(entry);
4739
- this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
5377
+ this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
4740
5378
  })
4741
5379
  .catch((err) => this.scheduleButtonRetry(entry, err));
4742
5380
  }
@@ -4748,6 +5386,8 @@ export class FleetManager {
4748
5386
  clearInterval(entry.idleCheckTimer);
4749
5387
  if (entry.progressTimer)
4750
5388
  clearInterval(entry.progressTimer);
5389
+ if (entry.progressEditTimer)
5390
+ clearTimeout(entry.progressEditTimer);
4751
5391
  if (entry.replyGraceTimer)
4752
5392
  clearTimeout(entry.replyGraceTimer);
4753
5393
  this.cancelButtons.delete(entry.messageId);
@@ -4825,12 +5465,17 @@ export class FleetManager {
4825
5465
  // Continue the same retire cycle (bypass the retiring-guard in retireButton).
4826
5466
  entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
4827
5467
  }
4828
- /** Delete one button's message via its own adapter. Resolves on success,
5468
+ /** Retire one cancel bubble via its own adapter. A bubble that accumulated
5469
+ * tool progress is kept as a read-only history message; a plain bubble keeps
5470
+ * the legacy delete/edit-to-checkmark behavior. Resolves on success and
4829
5471
  * rejects on failure so the caller can retry. */
4830
5472
  deleteButtonMessage(e) {
4831
5473
  const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
4832
5474
  if (!adapter)
4833
5475
  return Promise.reject(new Error("no adapter for cancel button"));
5476
+ if (e.toolProgress && adapter.editMessageRemoveButtons) {
5477
+ return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeBubbleText(e), e.threadId);
5478
+ }
4834
5479
  if (adapter.deleteMessage)
4835
5480
  return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
4836
5481
  if (adapter.editMessageRemoveButtons)
@@ -4921,13 +5566,29 @@ export class FleetManager {
4921
5566
  }
4922
5567
  async sendHangNotification(instanceName, unchangedForMs) {
4923
5568
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
4924
- if (!adapter)
4925
- return;
4926
- const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
4927
- const groupId = channelCfg?.group_id;
4928
- if (!groupId)
5569
+ const adapterId = this.getInstanceAdapterId(instanceName);
5570
+ // Same three-way addressing as sendCancelButton: fleet topic → group+thread,
5571
+ // Classic its own channel (Classic instances are absent from
5572
+ // fleetConfig.instances, so the topic path can never address them), else the
5573
+ // world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
5574
+ // because on channels[]-configured fleets the legacy channel: block is empty.
5575
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
5576
+ const groupId = this.getGroupIdForInstance(instanceName) || undefined;
5577
+ let chatId;
5578
+ let threadId;
5579
+ if (topicId != null && groupId) {
5580
+ chatId = String(groupId);
5581
+ threadId = String(topicId);
5582
+ }
5583
+ else {
5584
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
5585
+ if (!chatId && groupId)
5586
+ chatId = String(groupId);
5587
+ }
5588
+ if (!adapter || !adapterId || !chatId) {
5589
+ this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
4929
5590
  return;
4930
- const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
5591
+ }
4931
5592
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
4932
5593
  const configuredMinutes = instanceHangConfig?.timeout_minutes
4933
5594
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
@@ -4936,17 +5597,52 @@ export class FleetManager {
4936
5597
  ? configuredMinutes
4937
5598
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
4938
5599
  this.setTopicIcon(instanceName, "red");
4939
- await adapter.notifyAlert(String(groupId), {
4940
- type: "hang",
5600
+ await this.postNonceButtonPrompt({
5601
+ prefix: HANG_CALLBACK_PREFIX,
5602
+ alertType: "hang",
4941
5603
  instanceName,
4942
- message: `⚠️ ${instanceName} may be stuck — pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
5604
+ adapter,
5605
+ adapterId,
5606
+ chatId,
5607
+ threadId,
5608
+ message: t("hang.detected", instanceName, unchangedMinutes),
4943
5609
  choices: [
4944
- { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
4945
- { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
5610
+ { action: "restart", label: t("hang.restart") },
5611
+ { action: "wait", label: t("hang.wait") },
4946
5612
  ],
4947
- }, {
4948
- threadId: threadId != null ? String(threadId) : undefined,
4949
- }).catch(e => this.logger.warn({ err: e }, "Failed to send hang notification"));
5613
+ expiredText: t("hang.expired", instanceName),
5614
+ });
5615
+ }
5616
+ /**
5617
+ * Consume a hang Force-restart / Keep-waiting button exactly once. Restart
5618
+ * goes through restartSingleInstance — serialized against concurrent restart
5619
+ * sources, and with a Classic-instance fallback (the previous hand-rolled
5620
+ * stop+start silently left Classic instances stopped while reporting
5621
+ * "restarted").
5622
+ */
5623
+ async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
5624
+ const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
5625
+ if (claimed === null)
5626
+ return false;
5627
+ if (claimed === "consumed")
5628
+ return true;
5629
+ const { entry: pending, action } = claimed;
5630
+ this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
5631
+ if (action === "wait") {
5632
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
5633
+ return true;
5634
+ }
5635
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
5636
+ try {
5637
+ await this.restartSingleInstance(pending.instanceName);
5638
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
5639
+ }
5640
+ catch (err) {
5641
+ const message = err instanceof Error ? err.message : String(err);
5642
+ this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
5643
+ 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"));
5644
+ }
5645
+ return true;
4950
5646
  }
4951
5647
  // ── Topic icon + auto-archive ─────────────────────────────────────────────
4952
5648
  static INSTRUCTIONS_FILENAME = {
@@ -4955,6 +5651,10 @@ export class FleetManager {
4955
5651
  "gemini-cli": "GEMINI.md",
4956
5652
  "opencode": "AGENTS.md",
4957
5653
  "kiro-cli": ".kiro/steering/project.md",
5654
+ // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
5655
+ // same files their writeConfig() appends fleet instructions to.
5656
+ "grok": "AGENTS.md",
5657
+ "antigravity": ".agents/agents.md",
4958
5658
  "mock": "CLAUDE.md",
4959
5659
  };
4960
5660
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -4964,146 +5664,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
4964
5664
  Do NOT modify project files directly — delegate file changes to the project's instance.
4965
5665
  You CAN write code snippets, explain code, and answer technical questions directly.
4966
5666
 
4967
- -----
5667
+ ## Task Routing
4968
5668
 
4969
- ## Task Classification
5669
+ - **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
5670
+ - **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
5671
+ - **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
4970
5672
 
4971
- Classify every incoming request before acting.
5673
+ Instance discovery order: list_teams() list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
4972
5674
 
4973
- ### Handle Directly (ALL conditions must be true)
5675
+ ## Reply Contract
4974
5676
 
4975
- - No file system access needed
4976
- - No external execution needed
4977
- - Answerable from static knowledge
4978
- - ≤ 2 reasoning steps
4979
-
4980
- Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
4981
-
4982
- ### Delegate to 1 Instance
4983
-
4984
- - Task scoped to a single project or repo
4985
- - Requires file access, code changes, or execution
4986
-
4987
- ### Coordinate Multiple Instances
4988
-
4989
- - Task spans multiple repos or domains
4990
- - Requires outputs from one instance to feed into another
4991
- - Benefits from parallel execution (max 3 instances per task)
4992
-
4993
- -----
4994
-
4995
- ## Instance Discovery (in this order)
4996
- 1. list_teams() → reuse existing teams first
4997
- 2. list_instances() → find by working_directory, description, or tags
4998
- 3. describe_instance() → confirm capabilities before delegating
4999
- 4. create_instance() → only if no suitable instance exists
5000
-
5001
- Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
5002
-
5003
- -----
5004
-
5005
- ## Delegation Protocol
5006
-
5007
- Every delegation via send_to_instance() MUST include:
5008
-
5009
- 1. Task scope — what exactly to do, bounded clearly
5010
- 2. Expected output — what to return and in what form
5011
- 3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
5012
-
5013
- ### Loop Prevention
5014
-
5015
- - Never re-delegate a task back to the instance that sent it to you
5016
- - If a task has bounced 3 times, stop and solve locally or reduce scope
5017
-
5018
- ### Execution Strategy
5019
-
5020
- Parallel — use only when tasks are independent with no shared state
5021
- Sequential — use when one task's output feeds into the next
5022
-
5023
- -----
5024
-
5025
- ## Result Handling
5026
-
5027
- When an instance reports back, classify the outcome:
5028
-
5029
- - Success → Summarize key results for user. Omit internal coordination noise.
5030
- - Partial → State what succeeded, what remains, proposed next steps.
5031
- - Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
5032
- - No response → Ping again after reasonable wait. If still silent: report to user with options.
5033
-
5034
- ### Output to User
5035
-
5036
- Every final response to the user should contain:
5037
-
5038
- - Result — the actual answer or deliverable
5039
- - Gaps — anything incomplete or unresolved (omit if none)
5040
-
5041
- -----
5042
-
5043
- ## Shared Decisions
5044
-
5045
- 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.
5046
-
5047
- When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
5048
-
5049
- -----
5677
+ 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.
5050
5678
 
5051
5679
  ## After Restart
5052
5680
 
5053
- After a restart, run this sequence BEFORE processing any new messages:
5054
- 1. list_instances() → rebuild fleet awareness
5055
- 2. list_teams() → restore team structure
5056
- 3. list_decisions() → reload policies and conventions
5057
-
5058
- Only then handle incoming requests.
5059
-
5060
- -----
5061
-
5062
- ## Development Workflow Policy
5063
-
5064
- All code changes across the fleet should follow this workflow.
5065
- The coordinator enforces compliance but does not perform these steps directly.
5066
- Remind instances of this policy when delegating code tasks.
5067
-
5068
- ### Workflow Stages
5069
- Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
5070
-
5071
- ### Policy Rules
5072
-
5073
- 1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
5074
- 2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
5075
- 3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
5076
- 4. Independent review — every merge requires code review from someone other than the author.
5077
- 5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
5078
- 6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
5079
-
5080
- ### Specialist Instance Rules
5081
-
5082
- - Execute within defined scope only
5083
- - Return structured output: result, assumptions, uncertainties, verification status
5084
- - Do NOT create new instances without coordinator approval
5085
-
5086
- -----
5087
-
5088
- ## Team Management
5089
-
5090
- - Always check existing teams before creating new ones
5091
- - Default to ephemeral teams (created for a specific task, dissolved after completion)
5092
- - Clean up ephemeral teams and instances after task completion
5681
+ BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
5093
5682
 
5094
- -----
5683
+ ## Playbooks (on-demand skills)
5095
5684
 
5096
- ## Instance Configuration Tips
5097
-
5098
- When users create specialized instances, suggest these configurations:
5099
-
5100
- - **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
5101
- - **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
5102
- - **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
5685
+ Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
5686
+ - **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
5687
+ - **development-workflow** the fleet-wide code-change policy you enforce when delegating code tasks.
5688
+ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
5103
5689
  `;
5104
5690
  /** Ensure the general instance has its project instructions file + knowledge */
5105
- ensureGeneralInstructions(workDir, backendName) {
5691
+ ensureGeneralInstructions(workDir, backendName, instanceName) {
5106
5692
  const backend = backendName ?? "claude-code";
5693
+ workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
5107
5694
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
5108
5695
  const filePath = join(workDir, filename);
5109
5696
  mkdirSync(dirname(filePath), { recursive: true });
@@ -5111,60 +5698,238 @@ When users create specialized instances, suggest these configurations:
5111
5698
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
5112
5699
  this.logger.info({ filePath }, "Created general instance instructions file");
5113
5700
  }
5114
- // Sync bundled knowledge files to general's steering directory
5701
+ // Sync bundled knowledge files to general's steering and skills directories.
5115
5702
  this.syncGeneralKnowledge(workDir, backend);
5116
5703
  }
5117
- /** Copy general-knowledge steering + skills to the general instance's workspace */
5704
+ /** Resolve the workspace path a backend actually uses before publishing knowledge. */
5705
+ resolveKnowledgeWorkDir(workDir, backend, instanceName) {
5706
+ // Some backends relocate their real cwd (agy moves hidden paths to
5707
+ // ~/agend-workspaces/<name>). Instructions and skills must land where the
5708
+ // CLI actually runs, not where fleet.yaml points.
5709
+ try {
5710
+ const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
5711
+ .resolveWorkingDirectory?.(workDir, instanceName);
5712
+ if (resolved)
5713
+ workDir = resolved;
5714
+ }
5715
+ catch { /* unknown backend name — keep the raw path */ }
5716
+ return workDir;
5717
+ }
5718
+ /**
5719
+ * Where each backend natively loads on-demand skills from, relative to the
5720
+ * workspace. Backends without a native skill mechanism (opencode, grok,
5721
+ * antigravity, gemini-cli) are deliberately absent: dropping files a CLI
5722
+ * never reads is clutter, not capability. Unknown directories are ignored
5723
+ * by older CLI versions, so publishing is fail-open across upgrades.
5724
+ */
5725
+ static SKILLS_DIR_SEGMENTS = {
5726
+ "kiro-cli": [".kiro", "skills"],
5727
+ "claude-code": [".claude", "skills"],
5728
+ "codex": [".agents", "skills"],
5729
+ // Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
5730
+ // vendor-canonical location is .grok/skills.
5731
+ "opencode": [".agents", "skills"],
5732
+ "grok": [".grok", "skills"],
5733
+ "antigravity": [".agents", "skills"],
5734
+ };
5735
+ /** Copy general-knowledge steering + all role-eligible skills to General. */
5118
5736
  syncGeneralKnowledge(workDir, backend) {
5119
5737
  const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
5120
5738
  if (!existsSync(knowledgeDir))
5121
5739
  return;
5122
- // Sync steering files → .kiro/steering/ (or workDir root for non-kiro)
5123
- const steeringDir = backend === "kiro-cli"
5124
- ? join(workDir, ".kiro", "steering")
5125
- : workDir;
5126
- mkdirSync(steeringDir, { recursive: true });
5127
- const srcSteering = join(knowledgeDir, "steering");
5128
- if (existsSync(srcSteering)) {
5129
- for (const file of readdirSync(srcSteering)) {
5130
- if (!file.endsWith(".md"))
5131
- continue;
5132
- const src = join(srcSteering, file);
5740
+ this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
5741
+ this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
5742
+ this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
5743
+ }
5744
+ /** Publish only the bundled skills eligible for an instance role. */
5745
+ syncRoleSkills(workDir, backend, role, knowledgeDir) {
5746
+ const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
5747
+ if (!skillSegments)
5748
+ return;
5749
+ const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
5750
+ if (!existsSync(root))
5751
+ return;
5752
+ // Before managed-skill manifests existed, only Kiro General received
5753
+ // bundled skills. Allow that one legacy layout to be adopted so upgrades
5754
+ // can keep those copies current; other backends never had unmanaged
5755
+ // AgEnD-published skills and must retain the normal user-ownership guard.
5756
+ const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
5757
+ this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
5758
+ this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
5759
+ }
5760
+ /**
5761
+ * Steering (always-on rules like core-rules.md). Kiro loads a native
5762
+ * steering directory; every other backend gets the content embedded into
5763
+ * its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
5764
+ * block — the previous behavior dropped bare .md files in the workspace
5765
+ * root, which no CLI ever read. The block is replaced in place on every
5766
+ * sync, so rule updates reach EXISTING workspaces; everything the user
5767
+ * wrote outside the markers is preserved byte-for-byte.
5768
+ */
5769
+ syncGeneralSteering(workDir, backend, srcSteering) {
5770
+ if (!existsSync(srcSteering))
5771
+ return;
5772
+ const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
5773
+ if (files.length === 0)
5774
+ return;
5775
+ if (backend === "kiro-cli") {
5776
+ const steeringDir = join(workDir, ".kiro", "steering");
5777
+ mkdirSync(steeringDir, { recursive: true });
5778
+ for (const file of files) {
5133
5779
  const dest = join(steeringDir, file);
5134
- const newContent = readFileSync(src, "utf-8");
5780
+ const newContent = readFileSync(join(srcSteering, file), "utf-8");
5135
5781
  try {
5136
5782
  if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5137
5783
  continue;
5138
5784
  }
5139
- catch { }
5785
+ catch { /* rewrite */ }
5140
5786
  writeFileSync(dest, newContent);
5141
5787
  }
5788
+ return;
5142
5789
  }
5143
- // Sync skills .kiro/skills/ (kiro-cli only)
5144
- if (backend === "kiro-cli") {
5145
- const srcSkills = join(knowledgeDir, "skills");
5146
- if (existsSync(srcSkills)) {
5147
- const destSkills = join(workDir, ".kiro", "skills");
5148
- mkdirSync(destSkills, { recursive: true });
5149
- for (const skillDir of readdirSync(srcSkills)) {
5150
- const skillSrc = join(srcSkills, skillDir);
5151
- if (!existsSync(join(skillSrc, "SKILL.md")))
5152
- continue;
5153
- const skillDest = join(destSkills, skillDir);
5154
- mkdirSync(skillDest, { recursive: true });
5155
- const src = join(skillSrc, "SKILL.md");
5156
- const dest = join(skillDest, "SKILL.md");
5157
- const newContent = readFileSync(src, "utf-8");
5158
- try {
5159
- if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5160
- continue;
5161
- }
5162
- catch { }
5163
- writeFileSync(dest, newContent);
5164
- }
5790
+ const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
5791
+ const instructionsPath = join(workDir, filename);
5792
+ const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
5793
+ const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
5794
+ let existing = "";
5795
+ try {
5796
+ existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
5797
+ }
5798
+ catch { /* treat as empty */ }
5799
+ const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
5800
+ const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
5801
+ let next;
5802
+ if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
5803
+ next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
5804
+ }
5805
+ else {
5806
+ next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
5807
+ }
5808
+ if (next !== existing) {
5809
+ mkdirSync(dirname(instructionsPath), { recursive: true });
5810
+ writeFileSync(instructionsPath, next);
5811
+ }
5812
+ }
5813
+ static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
5814
+ static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
5815
+ /**
5816
+ * Publish AgEnD's bundled skills into a CLI's native skills directory,
5817
+ * owning ONLY what we published. A manifest records which skill names AgEnD
5818
+ * wrote; a bundled rename/removal deletes the stale managed copy, while a
5819
+ * skill the user created by hand is never listed and therefore never
5820
+ * touched — even if a future bundle happens to reuse its name (the sync
5821
+ * then skips it and logs, rather than overwrite the user's work). The sole
5822
+ * migration exception is the pre-manifest Kiro General layout explicitly
5823
+ * selected by adoptLegacyUnmanaged.
5824
+ */
5825
+ syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
5826
+ if (!existsSync(srcSkills))
5827
+ return;
5828
+ mkdirSync(destSkills, { recursive: true });
5829
+ const manifestPath = join(destSkills, ".agend-managed-skills.json");
5830
+ const hadManifest = existsSync(manifestPath);
5831
+ let previouslyManaged = [];
5832
+ try {
5833
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
5834
+ if (Array.isArray(parsed))
5835
+ previouslyManaged = parsed.filter(n => typeof n === "string");
5836
+ }
5837
+ catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
5838
+ const bundled = readdirSync(srcSkills)
5839
+ .filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
5840
+ .sort();
5841
+ // Pre-manifest Kiro General copied bundled skills directly into
5842
+ // .kiro/skills. If every existing skill is still a bundled name, this is
5843
+ // the unambiguous legacy layout: adopt it once, update it below, and write
5844
+ // the ownership manifest. Any extra skill name keeps the directory fully
5845
+ // on the user-owned/collision path.
5846
+ if (!hadManifest && adoptLegacyUnmanaged) {
5847
+ const existing = readdirSync(destSkills, { withFileTypes: true })
5848
+ .filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
5849
+ .map(entry => entry.name)
5850
+ .sort();
5851
+ if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
5852
+ previouslyManaged = existing;
5853
+ this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
5854
+ }
5855
+ }
5856
+ const eligible = bundled.filter(name => {
5857
+ const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
5858
+ // General is the coordinator and receives both coordinator and worker
5859
+ // playbooks. Workers receive only skills explicitly marked for workers.
5860
+ return role === "general" || roles.includes("worker");
5861
+ });
5862
+ const managed = [];
5863
+ for (const name of eligible) {
5864
+ const destDir = join(destSkills, name);
5865
+ const dest = join(destDir, "SKILL.md");
5866
+ const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
5867
+ if (!isOurs) {
5868
+ // Name collision with a user-authored skill: theirs wins, loudly.
5869
+ this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
5870
+ continue;
5871
+ }
5872
+ managed.push(name);
5873
+ const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
5874
+ try {
5875
+ if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5876
+ continue;
5165
5877
  }
5878
+ catch { /* rewrite */ }
5879
+ mkdirSync(destDir, { recursive: true });
5880
+ writeFileSync(dest, newContent);
5881
+ }
5882
+ // Remove managed skills that are no longer bundled OR no longer eligible
5883
+ // for this role. This makes a shared → general-only metadata change take
5884
+ // effect on the next worker startup instead of leaving stale capability.
5885
+ for (const stale of previouslyManaged) {
5886
+ if (eligible.includes(stale))
5887
+ continue;
5888
+ try {
5889
+ rmSync(join(destSkills, stale), { recursive: true, force: true });
5890
+ this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
5891
+ }
5892
+ catch (err) {
5893
+ this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
5894
+ }
5895
+ }
5896
+ try {
5897
+ writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
5898
+ }
5899
+ catch (err) {
5900
+ this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
5901
+ }
5902
+ }
5903
+ /** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
5904
+ readManagedSkillRoles(skillPath) {
5905
+ const fallback = ["general"];
5906
+ try {
5907
+ const content = readFileSync(skillPath, "utf-8");
5908
+ const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
5909
+ if (!match)
5910
+ return fallback;
5911
+ const document = parseDocument(match[1]);
5912
+ if (document.errors.length > 0)
5913
+ throw document.errors[0];
5914
+ const frontmatter = document.toJS();
5915
+ if (!frontmatter || frontmatter.roles === undefined)
5916
+ return fallback;
5917
+ if (!Array.isArray(frontmatter.roles))
5918
+ throw new Error("roles must be an array");
5919
+ const roles = [...new Set(frontmatter.roles)]
5920
+ .filter((value) => value === "general" || value === "worker");
5921
+ if (roles.length !== frontmatter.roles.length || roles.length === 0) {
5922
+ throw new Error("roles must contain only general and/or worker");
5923
+ }
5924
+ return roles;
5925
+ }
5926
+ catch (err) {
5927
+ // Fail closed for workers: malformed or unknown metadata keeps the
5928
+ // backwards-compatible General-only behavior instead of leaking an
5929
+ // administrative skill into worker workspaces.
5930
+ this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
5931
+ return fallback;
5166
5932
  }
5167
- this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
5168
5933
  }
5169
5934
  /** Fetch forum topic icon stickers and pick emoji IDs for each state */
5170
5935
  async resolveTopicIcons() {
@@ -6077,13 +6842,11 @@ When users create specialized instances, suggest these configurations:
6077
6842
  }
6078
6843
  catch { /* default restart */ }
6079
6844
  const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
6080
- if (strategy === "runtime") {
6081
- if (!this.instanceIpcClients.get(instanceName))
6082
- return `${warn}❌ ${instanceName} is not running.`;
6083
- this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
6084
- return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
6845
+ if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
6846
+ return `${warn}❌ ${instanceName} is not running.`;
6085
6847
  }
6086
- // restart: persist the model so the respawned CLI launches with it.
6848
+ // Persist either way: a runtime switch must survive the next respawn too,
6849
+ // or the instance silently reverts to the CLI default after a fleet restart.
6087
6850
  let persisted = false;
6088
6851
  if (this.fleetConfig?.instances[instanceName]) {
6089
6852
  this.fleetConfig.instances[instanceName].model = model;
@@ -6095,6 +6858,10 @@ When users create specialized instances, suggest these configurations:
6095
6858
  }
6096
6859
  if (!persisted)
6097
6860
  return `${warn}❌ Could not set model for ${instanceName}.`;
6861
+ if (strategy === "runtime") {
6862
+ this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
6863
+ return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
6864
+ }
6098
6865
  await this.restartSingleInstance(instanceName);
6099
6866
  return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
6100
6867
  }
@@ -6432,6 +7199,11 @@ When users create specialized instances, suggest these configurations:
6432
7199
  for (const pending of this.pendingClassicStarts.values())
6433
7200
  clearTimeout(pending.timer);
6434
7201
  this.pendingClassicStarts.clear();
7202
+ for (const pending of this.pendingNonceButtons.values()) {
7203
+ if (pending.timer)
7204
+ clearTimeout(pending.timer);
7205
+ }
7206
+ this.pendingNonceButtons.clear();
6435
7207
  this.topicArchiver.stop();
6436
7208
  this.scheduler?.shutdown();
6437
7209
  // Stop instances in parallel batches to avoid long sequential waits.